Dark mode 2 (#100)

* 🗂️ Added tab icons and styles

* 🪑 Styled download panel table

* 🪑 Cleaned up the DC++ search results table

* 🪑 Many changes to DC++ downloads table

* 🏗️ Wired up search with RQ

* 🏗️ Changes to ComicDetail section

* 🔧 Fixing table styes

* 🏗 Fixed the archive ops panel

* 🔧 Tweaked Archive ops further

* 🃏 Styling the action menu

* 🧩 CV Match panel refactor WIP

* 🏗️ Refactored the action menu

* 🤼 Cleaning up CV match panel

* 🏗️ Refactored the scored matches

* 🤼 Revamped CV match panel UX

* 🖌️ Styling tweaks to the side panel

* 🖌️ Cleaned up the form

* 🏗️ Refactored the search form

* 🤐 Added a uncompress indicator

* 🏗️ Fix for encoding # in URIs

* 🔧 Fixed # symbol handling in URLs

* 🔧 Started work on Edit Metadata panel

* 🔎 Added a check for existing uncompressed archives

* 🏗️ Settings styling tweaks

* 🏗️ Fixed invalidation of archiveOps

* 🏗️ Fixed an invalidation query on DC++ download panel

* 🏗️ Fixed CV-sourced Volume info panel

* 🏗️ Fixed volume group card stacks on Dashboard

* 🔍 Fixing CV search page

* 🏗️ Refactoring Volume groups and wanted panel

* 🏗️ Cleaning up useless files

* 🛝 Added keen-slider for pull list

* 🏗️ Abstracted heading/subheading into Header

* 🏗️ Continued refactoring of PullList, Volumes etc.

* 📆 Wired up the datepicker to LoCG pull list
This commit was merged in pull request #100.
This commit is contained in:
2024-02-06 05:58:56 -05:00
committed by GitHub
parent 4f49e538a8
commit c03f706e9d
48 changed files with 13032 additions and 1928 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ comics/
docs/
userdata/
dist/
storybook-static/*
src/client/assets/scss/App.css
/server/
node_modules/

View File

@@ -1,7 +1,7 @@
{
"name": "threetwo",
"version": "0.0.2",
"description": "ThreeTwo! A comic book curator.",
"version": "0.1.0",
"description": "ThreeTwo! A good comic book curator.",
"main": "server/index.js",
"typings": "server/index.js",
"scripts": {
@@ -19,7 +19,7 @@
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"@fortawesome/fontawesome-free": "^6.3.0",
"@redux-devtools/extension": "^3.2.5",
"@popperjs/core": "^2.11.8",
"@rollup/plugin-node-resolve": "^15.0.1",
"@tanstack/react-query": "^5.0.5",
"@tanstack/react-table": "^8.9.3",
@@ -38,10 +38,12 @@
"filename-parser": "^1.0.2",
"final-form": "^4.20.2",
"final-form-arrays": "^3.0.2",
"focus-trap-react": "^10.2.3",
"history": "^5.3.0",
"html-to-text": "^8.1.0",
"immer": "^10.0.3",
"jsdoc": "^3.6.10",
"keen-slider": "^6.8.6",
"lodash": "^4.17.21",
"pretty-bytes": "^5.6.0",
"prop-types": "^15.8.1",
@@ -49,23 +51,21 @@
"react": "^18.2.0",
"react-collapsible": "^2.9.0",
"react-comic-viewer": "^0.4.0",
"react-day-picker": "^8.6.0",
"react-day-picker": "^8.10.0",
"react-dom": "^18.2.0",
"react-fast-compare": "^3.2.0",
"react-final-form": "^6.5.9",
"react-final-form-arrays": "^3.1.4",
"react-loader-spinner": "^4.0.0",
"react-masonry-css": "^1.0.16",
"react-modal": "^3.15.1",
"react-popper": "^2.3.0",
"react-router": "^6.9.0",
"react-router-dom": "^6.9.0",
"react-select": "^5.8.0",
"react-select-async-paginate": "^0.7.2",
"react-sliding-pane": "^7.1.0",
"react-stickynode": "^4.1.0",
"react-textarea-autosize": "^8.3.4",
"reapop": "^4.2.1",
"slick-carousel": "^1.8.1",
"socket.io-client": "^4.3.2",
"styled-components": "^6.1.0",
"threetwo-ui-typings": "^1.0.14",
@@ -119,8 +119,11 @@
"rimraf": "^4.1.3",
"sass": "^1.69.5",
"storybook": "^7.3.2",
"tailwindcss": "^3.3.5",
"tailwindcss": "^3.4.1",
"tui-jsdoc-template": "^1.2.2",
"typescript": "^5.1.6"
},
"resolutions": {
"jackspeak": "2.1.1"
}
}

View File

@@ -8,7 +8,7 @@ import { difference } from "../../shared/utils/object.utils";
import { isEmpty, isNil, map } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
interface IAcquisitionPanelProps {
@@ -57,6 +57,7 @@ export const AcquisitionPanel = (
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
const queryClient = useQueryClient();
// Construct a AirDC++ query based on metadata inferred, upon component mount
// Pre-populate the search input with the search string, so that
@@ -133,7 +134,6 @@ export const AcquisitionPanel = (
);
setAirDCPPSearchInstance(currentInstance);
setAirDCPPSearchInfo(searchInfo);
console.log("Asdas", airDCPPSearchInfo);
if (currentInstance.result_count === 0) {
// ...nothing was received, show an informative message to the user
console.log("No more search results.");
@@ -198,6 +198,8 @@ export const AcquisitionPanel = (
type,
},
});
console.log(bundleDBImportResult?.data);
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
// dispatch({
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
@@ -228,41 +230,9 @@ export const AcquisitionPanel = (
search(manualQuery, airDCPPSocketInstance);
};
// download via AirDC++
const downloadDCPPResult = useCallback(
(searchInstanceId, resultId, name, size, type) => {
// dispatch(
// downloadAirDCPPItem(
// searchInstanceId,
// resultId,
// props.comicObjectId,
// name,
// size,
// type,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
// this is to update the download count badge on the downloads tab
// dispatch(
// getBundlesForComic(
// props.comicObjectId,
// airDCPPConfiguration.airDCPPState.socket,
// {
// username: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.username}`,
// password: `${airDCPPConfiguration.airDCPPState.settings.directConnect.client.host.password}`,
// },
// ),
// );
},
[],
);
return (
<>
<div className="comic-detail columns">
<div className="mt-5">
{!isEmpty(airDCPPSocketInstance) ? (
<Form
onSubmit={getDCPPSearchResults}
@@ -270,44 +240,40 @@ export const AcquisitionPanel = (
issueName,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form
onSubmit={handleSubmit}
className="column is-three-quarters"
>
<div className="box search">
<div className="columns">
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="column is-two-thirds">
<input
{...input}
className="input main-search-bar is-medium"
placeholder="Type an issue/volume name"
/>
<span className="help is-clearfix is-light is-info"></span>
<form onSubmit={handleSubmit}>
<Field name="issueName">
{({ input, meta }) => {
return (
<div className="max-w-fit">
<div className="flex flex-row bg-slate-300 dark:bg-slate-400 rounded-l-lg">
<div className="w-10 pl-2 pt-1 text-gray-400 dark:text-gray-200">
<i className="icon-[solar--magnifer-bold-duotone] h-7 w-7" />
</div>
);
}}
</Field>
<input
{...input}
className="dark:bg-slate-400 bg-slate-300 py-2 px-2 rounded-l-md border-gray-300 h-10 min-w-full dark:text-slate-800 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type an issue/volume name"
/>
<div className="column">
<button
type="submit"
className={
airDCPPSearchStatus
? "button is-loading is-warning"
: "button is-success is-light"
}
>
<span className="icon is-small">
<img src="/src/client/assets/img/airdcpp_logo.svg" />
</span>
<span className="airdcpp-text">Search on AirDC++</span>
</button>
</div>
</div>
</div>
<button
className="sm:mt-0 min-w-fit 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"
>
<div className="flex flex-row">
Search DC++
<div className="h-5 w-5 ml-2">
<img
src="/src/client/assets/img/airdcpp_logo.svg"
className="h-5 w-5"
/>
</div>
</div>
</button>
</div>
</div>
);
}}
</Field>
</form>
)}
/>
@@ -327,52 +293,45 @@ export const AcquisitionPanel = (
{!isNil(airDCPPSearchInstance) &&
!isEmpty(airDCPPSearchInfo) &&
!isNil(hubs) && (
<div className="columns">
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>
<div className="tags mb-1">
{hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
<div className="flex flex-row gap-3 my-5 font-hasklig">
<div className="block max-w-sm h-fit p-6 text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>
<div className="mb-1">
{hubs.map((value, idx) => (
<span className="tag is-warning" key={idx}>
{value.identity.name}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
</div>
))}
</div>
</dt>
<dt>
Query:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.pattern}
</span>
</dt>
<dd>
Extensions:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.extensions.join(", ")}
</span>
</dd>
<dd>
File type:
<span className="has-text-weight-semibold">
{airDCPPSearchInfo.query.file_type}
</span>
</dd>
</dl>
</div>
<div className="column is-one-quarter is-size-7">
<div className="card">
<div className="card-content">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
<div className="block max-w-sm p-6 h-fit text-sm bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
<dl>
<dt>Search Instance: {airDCPPSearchInstance.id}</dt>
<dt>Owned by {airDCPPSearchInstance.owner}</dt>
<dd>Expires in: {airDCPPSearchInstance.expires_in}</dd>
</dl>
</div>
</div>
)}
@@ -380,26 +339,36 @@ export const AcquisitionPanel = (
{/* AirDC++ results */}
<div className="columns">
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
<div className="column">
<table className="table">
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200 dark:border-gray-500">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-500 text-md">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Slots</th>
<th>Actions</th>
<th className="whitespace-nowrap px-2 py-2 font-medium text-gray-900 dark:text-slate-200">
Name
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Type
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Slots
</th>
<th className="whitespace-nowrap py-2 font-medium text-gray-900 dark:text-slate-200">
Actions
</th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
{map(airDCPPSearchResults, ({ result }, idx) => {
return (
<tr
key={idx}
className={
!isNil(result.dupe) ? "dupe-search-result" : ""
!isNil(result.dupe)
? "bg-gray-100 dark:bg-gray-700"
: "w-fit text-sm"
}
>
<td>
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
<p className="mb-2">
{result.type.id === "directory" ? (
<i className="fas fa-folder"></i>
@@ -409,16 +378,39 @@ export const AcquisitionPanel = (
<dl>
<dd>
<div className="tags">
<div className="inline-flex flex-row gap-2">
{!isNil(result.dupe) ? (
<span className="tag is-warning">Dupe</span>
<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--copy-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
Dupe
</span>
</span>
) : null}
<span className="tag is-light is-info">
{result.users.user.nicks}
{/* Nicks */}
<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--user-rounded-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.users.user.nicks}
</span>
</span>
{/* Flags */}
{result.users.user.flags.map((flag, idx) => (
<span className="tag is-light" key={idx}>
{flag}
<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--tag-horizontal-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{flag}
</span>
</span>
))}
</div>
@@ -426,25 +418,32 @@ export const AcquisitionPanel = (
</dl>
</td>
<td>
<span className="tag is-light is-info">
{result.type.id === "directory"
? "directory"
: result.type.str}
{/* Extension */}
<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--zip-file-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{result.type.str}
</span>
</span>
</td>
<td>
<div className="tags has-addons">
<span className="tag is-success">
{result.slots.free} free
<td className="px-2">
{/* Slots */}
<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--settings-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
<span className="tag is-light">
{result.slots.total}
<span className="text-md text-slate-500 dark:text-slate-900">
{result.slots.total} slots; {result.slots.free} free
</span>
</div>
</span>
</td>
<td>
<td className="px-2">
<button
className="button is-small is-light is-success"
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-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"
onClick={() =>
download(
airDCPPSearchInstance.id,
@@ -457,10 +456,10 @@ export const AcquisitionPanel = (
)
}
>
<span className="icon">
<i className="fas fa-file-download"></i>
<span className="text-xs">Download</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--download-bold-duotone]"></i>
</span>
<span>Download </span>
</button>
</td>
</tr>
@@ -470,25 +469,28 @@ export const AcquisitionPanel = (
</table>
</div>
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
<p>
The default search term is an auto-detected title; you may
need to change it to get better matches if the auto-detected
one doesn't work.
</p>
<div className="">
<article
role="alert"
className="mt-4 rounded-lg text-sm 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>
The default search term is an auto-detected title; you may need
to change it to get better matches if the auto-detected one
doesn't work.
</div>
</article>
<article className="message is-warning">
<div className="message-body is-size-6 is-family-secondary">
<p className="content">
Searching via <strong>AirDC++</strong> is still in
<strong>alpha</strong>. Some searches may take arbitrarily
long, or may not work at all. Searches from <code>ADCS</code>
hubs are more reliable than <code>NMDCS</code> ones.
</p>
<article
role="alert"
className="mt-4 rounded-lg text-sm 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>
Searching via <strong>AirDC++</strong> is still in{" "}
<strong>alpha</strong>. Some searches may take arbitrarily long,
or may not work at all. Searches from{" "}
<code className="font-hasklig">ADCS</code> hubs are more
reliable than <code className="font-hasklig">NMDCS</code> ones.
</div>
</article>
</div>

View File

@@ -1,94 +1,23 @@
import { filter, isEmpty, isNil, isUndefined } from "lodash";
import React, { ReactElement, useCallback } from "react";
import Select, { components } from "react-select";
import { fetchComicVineMatches } from "../../../actions/fileops.actions";
import { refineQuery } from "filename-parser";
import React, { ReactElement } from "react";
import Select from "react-select";
export const Menu = (props): ReactElement => {
const { data } = props;
const { setSlidingPanelContentId, setVisible } = props.handlers;
const openDrawerWithCVMatches = useCallback(() => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
if (!isUndefined(data.rawFileDetails)) {
issueSearchQuery = refineQuery(data.rawFileDetails.name);
} else if (!isEmpty(data.sourcedMetadata)) {
issueSearchQuery = refineQuery(data.sourcedMetadata.comicvine.name);
}
// dispatch(fetchComicVineMatches(data, issueSearchQuery, seriesSearchQuery));
setSlidingPanelContentId("CVMatches");
setVisible(true);
}, [data]);
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span>
<i className="fa-solid fa-wand-magic"></i> Match on ComicVine
</span>
);
const editLabel = (
<span>
<i className="fa-regular fa-pen-to-square"></i> Edit Metadata
</span>
);
const deleteLabel = (
<span>
<i className="fa-regular fa-trash-alt"></i> Delete Comic
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(data.rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
option: (base, { data, isDisabled, isFocused, isSelected }) => {
return {
...base,
backgroundColor: isFocused ? "gray" : "black",
};
},
control: (base) => ({
...base,
backgroundColor: "black",
border: "1px solid #CCC",
}),
};
const {
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
} = props.configuration;
return (
<Select
components={{ Placeholder }}
placeholder={
<span>
<i className="fa-solid fa-list"></i> Actions
<span className="inline-flex flex-row items-center gap-2 pt-1">
<div className="w-6 h-6">
<i className="icon-[solar--cursor-bold-duotone] w-6 h-6"></i>
</div>
<div>Select An Action</div>
</span>
}
styles={customStyles}

View File

@@ -15,7 +15,8 @@ import AcquisitionPanel from "./AcquisitionPanel";
import DownloadsPanel from "./DownloadsPanel";
import { VolumeInformation } from "./Tabs/VolumeInformation";
import { isEmpty, isUndefined, isNil } from "lodash";
import { isEmpty, isUndefined, isNil, filter } from "lodash";
import { components } from "react-select";
import { RootState } from "threetwo-ui-typings";
import "react-sliding-pane/dist/react-sliding-pane.css";
@@ -27,6 +28,10 @@ import ComicViewer from "react-comic-viewer";
import { extractComicArchive } from "../../actions/fileops.actions";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import axios from "axios";
import { styled } from "styled-components";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { refineQuery } from "filename-parser";
type ComicDetailProps = {};
/**
@@ -56,20 +61,8 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
const [modalIsOpen, setIsOpen] = useState(false);
const [comicVineMatches, setComicVineMatches] = useState([]);
// const comicVineSearchResults = useSelector(
// (state: RootState) => state.comicInfo.searchResults,
// );
// const comicVineSearchQueryObject = useSelector(
// (state: RootState) => state.comicInfo.searchQuery,
// );
// const comicVineAPICallProgress = useSelector(
// (state: RootState) => state.comicInfo.inProgress,
// );
//
// const extractedComicBook = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.reading,
// );
const { comicObjectId } = useParams<{ comicObjectId: string }>();
// const dispatch = useDispatch();
@@ -87,6 +80,10 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
// );
}, []);
// overridden <SlidingPanel> with some styles
const StyledSlidingPanel = styled(SlidingPane)`
background: #ccc;
`;
const afterOpenModal = useCallback((things) => {
// references are now sync'd and can be accessed.
// subtitle.style.color = "#f00";
@@ -102,57 +99,183 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
CVMatches: {
content: (props) => (
<>
{/* <div className="card search-criteria-card">
<div className="card-content">
<ComicVineSearchForm data={rawFileDetails} />
</div>
<div>
<ComicVineSearchForm data={rawFileDetails} />
</div>
<p className="is-size-5 mt-3 mb-2 ml-3">Searching for:</p>
{inferredMetadata.issue ? (
<div className="ml-3">
<span className="tag mr-3">{inferredMetadata.issue.name} </span>
<span className="tag"> # {inferredMetadata.issue.number} </span>
</div>
) : null}
{!comicVineAPICallProgress ? (
<ComicVineMatchPanel
props={{
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
comicObjectId,
}}
/>
) : (
<div className="progress-indicator-container">
<div className="indicator">
<Loader
type="MutatingDots"
color="#CCC"
secondaryColor="#999"
height={100}
width={100}
visible={comicVineAPICallProgress}
/>
</div>
</div>
)} */}
<div className="border-slate-500 border rounded-lg p-2 mt-3">
<p className="">Searching for:</p>
{inferredMetadata.issue ? (
<>
<span className="">{inferredMetadata.issue.name} </span>
<span className=""> # {inferredMetadata.issue.number} </span>
</>
) : null}
</div>
<ComicVineMatchPanel
props={{
comicVineMatches,
comicObjectId,
}}
/>
</>
),
},
editComicBookMetadata: {
content: () => <EditMetadataPanel />,
content: () => <EditMetadataPanel data={rawFileDetails} />,
},
};
// Actions
const fetchComicVineMatches = async (
searchPayload,
issueSearchQuery,
seriesSearchQuery,
) => {
try {
const response = await axios({
url: `${COMICVINE_SERVICE_URI}/volumeBasedSearch`,
method: "POST",
data: {
format: "json",
// hack
query: issueSearchQuery.inferredIssueDetails.name
.replace(/[^a-zA-Z0-9 ]/g, "")
.trim(),
limit: "100",
page: 1,
resources: "volume",
scorerConfiguration: {
searchParams: issueSearchQuery.inferredIssueDetails,
},
rawFileDetails: searchPayload,
},
transformResponse: (r) => {
const matches = JSON.parse(r);
return matches;
// return sortBy(matches, (match) => -match.score);
},
});
let matches: any = [];
if (!isNil(response.data.results) && response.data.results.length === 1) {
matches = response.data.results;
} else {
matches = response.data.map((match) => match);
}
const scoredMatches = matches.sort((a, b) => b.score - a.score);
setComicVineMatches(scoredMatches);
} catch (err) {
console.log(err);
}
};
// Action event handlers
const openDrawerWithCVMatches = () => {
let seriesSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
let issueSearchQuery: IComicVineSearchQuery = {} as IComicVineSearchQuery;
if (!isUndefined(rawFileDetails)) {
issueSearchQuery = refineQuery(rawFileDetails.name);
} else if (!isEmpty(comicvine)) {
issueSearchQuery = refineQuery(comicvine.name);
}
fetchComicVineMatches(rawFileDetails, issueSearchQuery, seriesSearchQuery);
setSlidingPanelContentId("CVMatches");
setVisible(true);
};
const openEditMetadataPanel = useCallback(() => {
setSlidingPanelContentId("editComicBookMetadata");
setVisible(true);
}, []);
// Actions menu options and handler
const CVMatchLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--magic-stick-3-bold-duotone] w-6 h-6"></i>
</div>
<div>Match on ComicVine</div>
</span>
);
const editLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--pen-2-bold-duotone] w-6 h-6"></i>
</div>
<div>Edit Metadata</div>
</span>
);
const deleteLabel = (
<span className="inline-flex flex-row items-center gap-2">
<div className="w-6 h-6">
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-6 h-6"></i>
</div>
<div>Delete Comic</div>
</span>
);
const Placeholder = (props) => {
return <components.Placeholder {...props} />;
};
const actionOptions = [
{ value: "match-on-comic-vine", label: CVMatchLabel },
{ value: "edit-metdata", label: editLabel },
{ value: "delete-comic", label: deleteLabel },
];
const filteredActionOptions = filter(actionOptions, (item) => {
if (isUndefined(rawFileDetails)) {
return item.value !== "match-on-comic-vine";
}
return item;
});
const handleActionSelection = (action) => {
switch (action.value) {
case "match-on-comic-vine":
openDrawerWithCVMatches();
break;
case "edit-metdata":
openEditMetadataPanel();
break;
default:
console.log("No valid action selected.");
break;
}
};
const customStyles = {
menu: (base) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
}),
placeholder: (base) => ({
...base,
color: "black",
}),
option: (base, { data, isDisabled, isFocused, isSelected }) => ({
...base,
backgroundColor: isFocused ? "gray" : "rgb(156, 163, 175)",
}),
singleValue: (base) => ({
...base,
paddingTop: "0.4rem",
}),
control: (base) => ({
...base,
backgroundColor: "rgb(156, 163, 175)",
color: "black",
border: "1px solid rgb(156, 163, 175)",
}),
};
// check for the availability of CV metadata
const isComicBookMetadataAvailable =
!isUndefined(comicvine) && !isUndefined(comicvine.volumeInformation);
// check for the availability of rawFileDetails
const areRawFileDetailsAvailable =
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails.cover);
!isUndefined(rawFileDetails) && !isEmpty(rawFileDetails);
const { issueName, url } = determineCoverFile({
rawFileDetails,
@@ -172,7 +295,9 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{
id: 1,
name: "Volume Information",
icon: <i className="fa-solid fa-layer-group"></i>,
icon: (
<i className="h-5 w-5 icon-[solar--book-2-bold] text-slate-500 dark:text-slate-300"></i>
),
content: isComicBookMetadataAvailable ? (
<VolumeInformation data={data.data} key={1} />
) : null,
@@ -181,28 +306,30 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
{
id: 2,
name: "ComicInfo.xml",
icon: <i className="fa-solid fa-code"></i>,
icon: (
<i className="h-5 w-5 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300" />
),
content: (
<div className="columns" key={2}>
<div className="column is-three-quarters">
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
<div key={2}>
{!isNil(comicInfo) && <ComicInfoXML json={comicInfo} />}
</div>
),
shouldShow: !isEmpty(comicInfo),
},
{
id: 3,
icon: <i className="fa-regular fa-file-archive"></i>,
icon: (
<i className="h-5 w-5 icon-[solar--winrar-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "Archive Operations",
content: <></>,
/*
<ArchiveOperations data={data.data} key={3} /> */
content: <ArchiveOperations data={data.data} key={3} />,
shouldShow: areRawFileDetailsAvailable,
},
{
id: 4,
icon: <i className="fa-solid fa-circle-nodes"></i>,
icon: (
<i className="h-5 w-5 icon-[solar--folder-path-connect-bold-duotone] text-slate-500 dark:text-slate-300" />
),
name: "DC++ Search",
content: (
<AcquisitionPanel
@@ -217,26 +344,32 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
},
{
id: 5,
icon: <i className="fa-solid fa-droplet"></i>,
icon: (
<span className="inline-flex flex-row">
<i className="h-5 w-5 icon-[solar--magnet-bold-duotone] text-slate-500 dark:text-slate-300" />
</span>
),
name: "Torrent Search",
content: <>Torrents</>,
shouldShow: true,
},
{
id: 6,
icon: null,
name: !isEmpty(data.data) ? (
<span className="download-tab-name">Downloads</span>
) : (
"Downloads"
),
content: !isNil(data.data) && !isEmpty(data.data) && (
<DownloadsPanel
data={data.data.acquisition.directconnect}
comicObjectId={comicObjectId}
key={5}
/>
),
name: "Downloads",
icon: <>{acquisition?.directconnect?.downloads?.length}</>,
content:
!isNil(data.data) && !isEmpty(data.data) ? (
<DownloadsPanel key={5} />
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
),
shouldShow: true,
},
];
@@ -271,7 +404,21 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
created_at: createdAt,
updated_at: updatedAt,
}}
/>
>
{/* action dropdown */}
<div className="mt-1 flex flex-row gap-2 w-full">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
configuration={{
filteredActionOptions,
customStyles,
handleActionSelection,
Placeholder,
}}
/>
</div>
</RawFileDetails>
{/* <Modal
style={{ content: { marginTop: "2rem" } }}
@@ -293,22 +440,15 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
</Modal> */}
</div>
)}
{/* action dropdown */}
{/* <div className="mt-4 is-size-7">
<Menu
data={data.data}
handlers={{ setSlidingPanelContentId, setVisible }}
/>
</div> */}
</div>
</div>
<TabControls
filteredTabs={filteredTabs}
acquisition={acquisition}
downloadCount={acquisition?.directconnect?.downloads?.length}
/>
<SlidingPane
<StyledSlidingPanel
isOpen={visible}
onRequestClose={() => setVisible(false)}
title={"Comic Vine Search Matches"}
@@ -316,7 +456,7 @@ export const ComicDetail = (data: ComicDetailProps): ReactElement => {
>
{slidingPanelContentId !== "" &&
contentForSlidingPanel[slidingPanelContentId].content()}
</SlidingPane>
</StyledSlidingPanel>
</>
)}
</div>

View File

@@ -14,7 +14,7 @@ export const ComicDetailContainer = (): ReactElement | null => {
isLoading,
isError,
} = useQuery({
queryKey: [],
queryKey: ["comicBookMetadata"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
@@ -24,11 +24,6 @@ export const ComicDetailContainer = (): ReactElement | null => {
},
}),
});
console.log(comicBookDetailData);
useEffect(() => {
// dispatch(getComicBookDetailById(comicObjectId));
// dispatch(getSettings());
}, []);
{
isError && <>Error</>;

View File

@@ -2,105 +2,105 @@ import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import dayjs from "dayjs";
import { isUndefined } from "lodash";
import { isEmpty, isUndefined } from "lodash";
import Card from "../shared/Carda";
import { convert } from "html-to-text";
export const ComicVineDetails = (props): ReactElement => {
const { data, updatedAt } = props;
return (
<div className="column is-half">
<div className="comic-detail comicvine-metadata">
<dl>
<dt>ComicVine Metadata</dt>
<dd className="is-size-7">
Last scraped on {dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</dd>
<dd>
<div className="columns mt-2">
<div className="column is-2">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column is-10">
<dl>
<dt>
<h6 className="has-text-weight-bold mb-2">{data.name}</h6>
</dt>
<dd>
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</dd>
<dd>
Published by
<span className="has-text-weight-semibold">
{" "}
{data.volumeInformation.publisher.name}
</span>
</dd>
<dd>
Total issues in this volume:
{data.volumeInformation.count_of_issues}
</dd>
<dd>
<div className="field is-grouped mt-2">
{data.issue_number && (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Issue Number</span>
<span className="tag is-warning">
{data.issue_number}
</span>
</div>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Detected Type</span>
<span className="tag is-warning">
{
detectIssueTypes(
data.volumeInformation.description,
).displayName
}
</span>
</div>
</div>
) : data.resource_type ? (
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Type</span>
<span className="tag is-warning">
{data.resource_type}
</span>
</div>
</div>
) : null}
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">
ComicVine Issue ID
</span>
<span className="tag is-success">{data.id}</span>
</div>
</div>
<div className="text-slate-500 dark:text-gray-400">
<div className="">
<div>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<Card
imageUrl={data.volumeInformation.image.thumb_url}
orientation={"vertical-2"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-row">
<div>
{/* Title */}
<div>
<div className="text-lg">{data.name}</div>
<div className="text-sm">
Is a part of{" "}
<span className="has-text-info">
{data.volumeInformation.name}
</span>
</div>
</dd>
</dl>
</div>
{/* Comicvine metadata */}
<div className="mt-2">
<div className="text-md">ComicVine Metadata</div>
<div className="text-sm">
Last scraped on{" "}
{dayjs(updatedAt).format("MMM D YYYY [at] h:mm a")}
</div>
<div className="text-sm">
ComicVine Issue ID
<span>{data.id}</span>
</div>
</div>
</div>
{/* Publisher details */}
<div className="ml-8">
Published by{" "}
<span>{data.volumeInformation.publisher.name}</span>
<div>
Total issues in this volume{" "}
<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="text-md text-slate-900 dark:text-slate-900">
{data.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div>
{data.issue_number && (
<div className="">
<span>Issue Number</span>
<span>{data.issue_number}</span>
</div>
)}
{!isUndefined(
detectIssueTypes(data.volumeInformation.description),
) ? (
<div>
<span>Detected Type</span>
<span>
{
detectIssueTypes(data.volumeInformation.description)
.displayName
}
</span>
</div>
) : data.resource_type ? (
<div>
<span>Type</span>
<span>{data.resource_type}</span>
</div>
) : null}
</div>
</div>
</div>
{/* Description */}
<div className="mt-3 w-3/4">
{!isEmpty(data.description) &&
convert(data.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
);

View File

@@ -2,22 +2,41 @@ import React, { ReactElement } from "react";
import { ComicVineSearchForm } from "../ComicVineSearchForm";
import MatchResult from "./MatchResult";
import { isEmpty } from "lodash";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
export const ComicVineMatchPanel = (comicVineData): ReactElement => {
const {
comicObjectId,
comicVineSearchQueryObject,
comicVineAPICallProgress,
comicVineSearchResults,
} = comicVineData.props;
const { comicObjectId, comicVineMatches } = comicVineData.props;
const { comicvine } = useStore(
useShallow((state) => ({
comicvine: state.comicvine,
})),
);
return (
<>
<div className="search-results-container">
{!isEmpty(comicVineSearchResults) && (
<div>
{!isEmpty(comicVineMatches) ? (
<MatchResult
matchData={comicVineSearchResults}
matchData={comicVineMatches}
comicObjectId={comicObjectId}
/>
) : (
<>
<article
role="alert"
className="mt-4 rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600 text-sm"
>
<div>
<p>ComicVine match results are an approximation.</p>
<p>
Auto-matching is not available yet. If you see no results or
poor quality ones, you can override the search query
parameters to get better ones.
</p>
</div>
</article>
<div className="text-md my-5">{comicvine.scrapingStatus}</div>
</>
)}
</div>
</>

View File

@@ -34,83 +34,55 @@ export const ComicVineSearchForm = (data) => {
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit}>
<span className="field is-normal">
<label className="label mb-2 is-size-5">Search Manually</label>
<span className="flex items-center">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
Override Search Query
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueName">
{(props) => (
<p className="control is-expanded has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue name"
/>
<span className="icon is-small is-left">
<i className="fas fa-journal-whills"></i>
</span>
</p>
)}
</Field>
</div>
<label className="block py-1">Issue Name</label>
<Field name="issueName">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-full rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Type the issue name"
/>
)}
</Field>
<div className="flex flex-row gap-4">
<div>
<label className="block py-1">Number</label>
<Field name="issueNumber">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-14 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="#"
/>
)}
</Field>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<Field name="issueNumber">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue number"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div className="field">
<Field name="issueYear">
{(props) => (
<p className="control has-icons-left">
<input
{...props.input}
className="input is-normal"
placeholder="Type the issue year"
/>
<span className="icon is-small is-left">
<i className="fas fa-hashtag"></i>
</span>
</p>
)}
</Field>
</div>
<div>
<label className="block py-1">Year</label>
<Field name="issueYear">
{(props) => (
<input
{...props.input}
className="appearance-none dark:bg-slate-100 bg-slate-100 h-10 w-20 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="1984"
/>
)}
</Field>
</div>
</div>
<div className="field is-horizontal">
<div className="field-body">
<div className="field">
<div className="control">
<button
type="submit"
className="button is-success is-light is-outlined is-small"
>
<span className="icon">
<i className="fas fa-search"></i>
</span>
<span>Search</span>
</button>
</div>
</div>
<div className="flex justify-end mt-5">
<button
type="submit"
className="flex h-10 sm:mt-3 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-4 py-2 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
>
Search
</button>
</div>
</div>
</form>

View File

@@ -1,73 +1,103 @@
import React, { useEffect, useContext, ReactElement } from "react";
import { getBundlesForComic } from "../../actions/airdcpp.actions";
import React, { useEffect, useContext, ReactElement, useState } from "react";
import { RootState } from "threetwo-ui-typings";
import { isEmpty, isNil, map } from "lodash";
import { isEmpty, map } from "lodash";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
import ellipsize from "ellipsize";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import { useStore } from "../../store";
import { useShallow } from "zustand/react/shallow";
import { useParams } from "react-router-dom";
interface IDownloadsPanelProps {
data: any;
comicObjectId: string;
key: number;
}
export const DownloadsPanel = (
props: IDownloadsPanelProps,
): ReactElement | null => {
// const bundles = useSelector((state: RootState) => {
// return state.airdcpp.bundles;
// });
//
// // AirDCPP Socket initialization
// const userSettings = useSelector((state: RootState) => state.settings.data);
// const airDCPPConfiguration = useContext(AirDCPPSocketContext);
const {
airDCPPState: { socket, settings },
} = airDCPPConfiguration;
const { comicObjectId } = useParams<{ comicObjectId: string }>();
const [bundles, setBundles] = useState([]);
const { airDCPPSocketInstance } = useStore(
useShallow((state) => ({
airDCPPSocketInstance: state.airDCPPSocketInstance,
})),
);
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
useEffect(() => {
try {
if (!isEmpty(userSettings)) {
// dispatch(
// getBundlesForComic(props.comicObjectId, socket, {
// username: `${settings.directConnect.client.host.username}`,
// password: `${settings.directConnect.client.host.password}`,
// }),
// );
}
} catch (error) {
throw new Error(error);
const { data: comicObject, isSuccess } = useQuery({
queryKey: ["bundles"],
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
id: `${comicObjectId}`,
},
}),
});
const getBundles = async (comicObject) => {
if (comicObject?.data.acquisition.directconnect) {
const filteredBundles =
comicObject.data.acquisition.directconnect.downloads.map(
async ({ bundleId }) => {
return await airDCPPSocketInstance.get(`queue/bundles/${bundleId}`);
},
);
return await Promise.all(filteredBundles);
}
}, [airDCPPConfiguration]);
};
useEffect(() => {
getBundles(comicObject).then((result) => {
setBundles(result);
});
}, [comicObject]);
const Bundles = (props) => {
return !isEmpty(props.data) ? (
<div className="column is-full">
<table className="table is-striped">
return (
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Download Time</th>
<th>Bundle ID</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Filename
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Size
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Download Time
</th>
<th className="whitespace-nowrap px-4 py-2 font-medium text-gray-900 dark:text-slate-200">
Bundle ID
</th>
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-gray-200">
{map(props.data, (bundle) => (
<tr key={bundle.id}>
<td>
<tr key={bundle.id} className="text-sm">
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<h5>{ellipsize(bundle.name, 58)}</h5>
<span className="is-size-7">{bundle.target}</span>
<span className="text-xs">
{ellipsize(bundle.target, 88)}
</span>
</td>
<td>{prettyBytes(bundle.size)}</td>
<td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{prettyBytes(bundle.size)}
</td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
{dayjs
.unix(bundle.time_finished)
.format("h:mm on ddd, D MMM, YYYY")}
</td>
<td>
<td className="whitespace-nowrap px-2 py-2 text-gray-700 dark:text-slate-300">
<span className="tag is-warning">{bundle.id}</span>
</td>
</tr>
@@ -75,29 +105,15 @@ export const DownloadsPanel = (
</tbody>
</table>
</div>
) : (
<div className="column is-full"> {"No Downloads Found"} </div>
);
};
return !isNil(props.data) ? (
<>
<div className="columns is-multiline">
{!isEmpty(socket) ? (
<Bundles data={bundles} />
) : (
<div className="column is-three-fifths">
<article className="message is-info">
<div className="message-body is-size-6 is-family-secondary">
AirDC++ is not configured. Please configure it in{" "}
<code>Settings</code>.
</div>
</article>
</div>
)}
</div>
</>
) : null;
return (
<div className="columns is-multiline">
{!isEmpty(airDCPPSocketInstance) && !isEmpty(bundles) && (
<Bundles data={bundles} />
)}
</div>
);
};
export default DownloadsPanel;

View File

@@ -9,6 +9,8 @@ export const EditMetadataPanel = (props): ReactElement => {
const validate = async () => {};
const onSubmit = async () => {};
const { data } = props;
const AsyncSelectPaginateAdapter = ({ input, ...rest }) => {
return (
<AsyncSelectPaginate
@@ -56,90 +58,59 @@ export const EditMetadataPanel = (props): ReactElement => {
<label className="label">Issue Details</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name="issue_name"
component="input"
className="input"
initialValue={rawFileDetails}
placeholder={"Issue Name"}
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-user-ninja"></i>
</span>
</p>
</div>
<Field
name="issue_name"
component="input"
className="appearance-none w-full dark:bg-slate-400 bg-slate-100 h-10 rounded-md border-none text-gray-700 dark:text-slate-200 py-1 pr-7 pl-3 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
initialValue={data.name}
placeholder={"Issue Name"}
/>
</div>
</div>
{/* Issue Number and year */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="issue_number"
component="input"
className="input"
placeholder="Issue Number"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-hashtag"></i>
</span>
</p>
<p className="help">Do not enter the first zero</p>
</div>
<div className="mt-4 flex flex-row gap-2">
<div>
<div className="text-sm">Issue Number</div>
<Field
name="issue_number"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Issue Number"
/>
<p className="text-xs">Do not enter the first zero</p>
</div>
<div>
{/* year */}
<div className="field">
<p className="control">
<Field
name="issue_year"
component="input"
className="input"
/>
</p>
</div>
<div className="text-sm">Issue Year</div>
<Field
name="issue_year"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
/>
</div>
<div>
<div className="text-sm">Page Count</div>
<Field
name="page_count"
component="input"
className="dark:bg-slate-400 w-20 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
placeholder="Page Count"
/>
</div>
</div>
{/* page count */}
<div className="field is-horizontal">
<div className="field-label"></div>
<div className="field-body">
<div className="field">
<p className="control has-icons-left">
<Field
name="page_count"
component="input"
className="input"
placeholder="Page Count"
/>
<span className="icon is-small is-left">
<i className="fa-solid fa-note-sticky"></i>
</span>
</p>
</div>
</div>
</div>
{/* Description */}
<div className="field is-horizontal">
<div className="field-label is-normal">
<label className="label">Description</label>
</div>
<div className="field-body">
<div className="field">
<p className="control is-expanded has-icons-left">
<Field
name={"description"}
className="textarea"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</p>
</div>
</div>
<div className="mt-2">
<label className="text-sm">Description</label>
<Field
name={"description"}
className="dark:bg-slate-400 w-full min-h-24 bg-slate-100 py-2 px-2 rounded-md border-gray-300 h-10 dark:text-slate-200 sm:text-md sm:leading-5 focus:outline-none focus:shadow-outline-blue focus:border-blue-300"
component={TextareaAutosizeAdapter}
placeholder={"Description"}
/>
</div>
<hr size="1" />

View File

@@ -1,8 +1,9 @@
import React, { useCallback } from "react";
import React from "react";
import { isNil, map } from "lodash";
import { applyComicVineMatch } from "../../actions/comicinfo.actions";
import { convert } from "html-to-text";
import ellipsize from "ellipsize";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
import axios from "axios";
interface MatchResultProps {
matchData: any;
@@ -14,14 +15,24 @@ const handleBrokenImage = (e) => {
};
export const MatchResult = (props: MatchResultProps) => {
const applyCVMatch = useCallback(
// (match, comicObjectId) => {
// dispatch(applyComicVineMatch(match, comicObjectId));
// },
[],
);
const applyCVMatch = async (match, comicObjectId) => {
return await axios.request({
url: `${LIBRARY_SERVICE_BASE_URI}/applyComicVineMetadata`,
method: "POST",
data: {
match,
comicObjectId,
},
});
};
return (
<>
<span className="flex items-center mt-6">
<span className="text-md text-slate-500 dark:text-slate-500 pr-5">
ComicVine Matches
</span>
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
{map(props.matchData, (match, idx) => {
let issueDescription = "";
if (!isNil(match.description)) {
@@ -31,88 +42,90 @@ export const MatchResult = (props: MatchResultProps) => {
},
});
}
const bestMatchCSSClass = idx === 0 ? "bg-green-100" : "bg-slate-300";
return (
<div className="search-result mb-4" key={idx}>
<div className="columns">
<div className="column is-one-fifth">
<div className={`${bestMatchCSSClass} my-5 p-4 rounded-lg`} key={idx}>
<div className="flex flex-row gap-4">
<div className="min-w-fit">
<img
className="cover-image"
className="rounded-md"
src={match.image.thumb_url}
onError={handleBrokenImage}
/>
</div>
<div>
<div className="flex flex-row mb-1 justify-end">
{match.name ? (
<p className="text-md w-full">{match.name}</p>
) : null}
<div className="search-result-details column">
<div className="is-size-5">{match.name}</div>
<div className="field is-grouped is-grouped-multiline mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag">Number</span>
<span className="tag is-primary">
{match.issue_number}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Cover Date</span>
<span className="tag is-warning">{match.cover_date}</span>
</div>
</div>
{/* score */}
<span className="inline-flex h-fit w-fit items-center bg-green-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-green-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--course-up-line-duotone] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.score, 10)}
</span>
</span>
</div>
<div className="is-size-7">
<span className="flex flex-row gap-2 mb-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(match.issue_number, 10)}
</span>
</span>
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--calendar-mark-bold-duotone] w-5 h-5"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
Cover Date: {match.cover_date}
</span>
</span>
</span>
<div className="text-sm">
{ellipsize(issueDescription, 300)}
</div>
</div>
</div>
<div className="vertical-line"></div>
<div className="columns ml-6 volume-information">
<div className="column is-one-fifth">
<div className="flex flex-row gap-2 my-4 ml-10">
<div className="">
<img
src={match.volumeInformation.results.image.icon_url}
className="cover-image"
className="rounded-md w-full"
onError={handleBrokenImage}
/>
</div>
<div className="column">
<div className="is-size-6">{match.volume.name}</div>
<div className="field is-grouped is-grouped-multiline mt-2">
<div className="control">
<div className="tags has-addons">
<span className="tag">Total Issues</span>
<span className="tag is-warning">
{match.volumeInformation.results.count_of_issues}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag">Publisher</span>
<span className="tag is-warning">
{match.volumeInformation.results.publisher.name}
</span>
</div>
</div>
<div className="">
<span>{match.volume.name}</span>
<div className="text-sm">
<p>
Total Issues:
{match.volumeInformation.results.count_of_issues}
</p>
<p>
Published by{" "}
{match.volumeInformation.results.publisher.name}
</p>
</div>
</div>
</div>
<div className="columns">
<div className="column">
<button
className="button is-normal is-outlined is-primary is-light is-pulled-right"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="icon is-size-5">
<i className="fas fa-clipboard-check"></i>
</span>
<span>Apply Match</span>
</button>
</div>
<div className="flex justify-end">
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-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"
onClick={() => applyCVMatch(match, props.comicObjectId)}
>
<span className="text-md">Apply Match</span>
<span className="w-5 h-5">
<i className="h-5 w-5 icon-[solar--magic-stick-3-bold-duotone]"></i>
</span>
</button>
</div>
</div>
);

View File

@@ -7,22 +7,21 @@ import { format, parseISO } from "date-fns";
export const RawFileDetails = (props): ReactElement => {
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
props.data;
const PaperClipIcon = () => <></>;
return (
<>
<div className="max-w-2xl ml-5">
<div className="px-4 sm:px-6">
<p className="text-gray-500">
<p className="text-gray-500 dark:text-gray-400">
<span className="text-xl">{rawFileDetails.name}</span>
</p>
</div>
<div className="px-4 py-5 sm:px-6">
<dl className="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2">
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Raw File Details
</dt>
<dd className="mt-1 text-sm text-gray-900">
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{rawFileDetails.containedIn +
"/" +
rawFileDetails.name +
@@ -30,10 +29,10 @@ export const RawFileDetails = (props): ReactElement => {
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Inferred Issue Metadata
</dt>
<dd className="mt-1 text-sm text-gray-900">
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
Series Name: {inferredMetadata.issue.name}
{!isEmpty(inferredMetadata.issue.number) ? (
<span className="tag is-primary is-light">
@@ -43,8 +42,10 @@ export const RawFileDetails = (props): ReactElement => {
</dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">MIMEType</dt>
<dd className="mt-1 text-sm text-gray-900">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
MIMEType
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
{/* File extension */}
<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">
@@ -56,11 +57,12 @@ export const RawFileDetails = (props): ReactElement => {
</span>
</span>
</dd>
<dd className="text-sm text-gray-900"></dd>
</div>
<div className="sm:col-span-1">
<dt className="text-sm font-medium text-gray-500">File Size</dt>
<dd className="mt-1 text-sm text-gray-900">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
File Size
</dt>
<dd className="mt-1 text-sm text-gray-500 dark:text-slate-900">
{/* size */}
<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">
@@ -74,62 +76,19 @@ export const RawFileDetails = (props): ReactElement => {
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500">
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Import Details
</dt>
<dd className="mt-1 text-sm text-gray-900">
<dd className="mt-1 text-sm text-gray-900 dark:text-gray-400">
{format(parseISO(created_at), "dd MMMM, yyyy")},{" "}
{format(parseISO(created_at), "h aaaa")}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-gray-500">Attachments</dt>
<dd className="mt-1 text-sm text-gray-900">
<ul
role="list"
className="divide-y divide-gray-200 rounded-md border border-gray-200"
>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
<PaperClipIcon
className="h-5 w-5 flex-shrink-0 text-gray-400"
aria-hidden="true"
/>
<span className="ml-2 w-0 flex-1 truncate">
resume_back_end_developer.pdf
</span>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
<li className="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div className="flex w-0 flex-1 items-center">
{/* Read comic button */}
<button
className="button is-success is-light"
onClick={() => {}}
>
<i className="fa-solid fa-book-open mr-2"></i>
Read
</button>
</div>
<div className="ml-4 flex-shrink-0">
<a
href="#"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Download
</a>
</div>
</li>
</ul>
</dd>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400">
Actions
</dt>
<dd className="mt-1 text-sm text-gray-900">{props.children}</dd>
</div>
</dl>
</div>
@@ -164,4 +123,5 @@ RawFileDetails.propTypes = {
created_at: PropTypes.string,
updated_at: PropTypes.string,
}),
children: PropTypes.any,
};

View File

@@ -1,43 +1,45 @@
import React, { ReactElement, useEffect, useState } from "react";
import { isEmpty, isNil } from "lodash";
import { isNil } from "lodash";
export const TabControls = (props): ReactElement => {
// const comicBookDetailData = useSelector(
// (state: RootState) => state.comicInfo.comicBookDetail,
// );
const { filteredTabs, acquisition } = props;
const { filteredTabs, downloadCount } = props;
const [active, setActive] = useState(filteredTabs[0].id);
useEffect(() => {
setActive(filteredTabs[0].id);
}, [acquisition]);
// useEffect(() => {
// setActive(filteredTabs[0].id);
// }, [filteredTabs]);
return (
<>
<div className="hidden sm:block mt-7">
<div className="hidden sm:block mt-7 mb-3 w-fit">
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-6" aria-label="Tabs">
<nav className="flex gap-6" aria-label="Tabs">
{filteredTabs.map(({ id, name, icon }) => (
<a
key={id}
className="inline-flex shrink-0 items-center gap-2 border-b-2 border-transparent px-1 pb-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700"
className="inline-flex shrink-0 items-center gap-2 border-b border-transparent px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:text-gray-700"
aria-current="page"
onClick={() => setActive(id)}
>
{/* Downloads tab and count badge */}
{/* <a>
{id === 6 && !isNil(acquisition.directconnect) ? (
<span className="download-icon-labels">
<i className="fa-solid fa-download"></i>
<span className="tag downloads-count is-info is-light">
{acquisition.directconnect.downloads.length}
<>
{id === 6 && !isNil(downloadCount) ? (
<span className="inline-flex flex-row">
{/* download count */}
<span className="inline-flex mx-2 items-center bg-slate-200 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="text-md text-slate-500 dark:text-slate-900">
{icon}
</span>
</span>
<i className="h-5 w-5 icon-[solar--download-bold-duotone] text-slate-500 dark:text-slate-300" />
</span>
</span>
) : (
<span className="icon is-small">{icon}</span>
)}
{name}
</a> */}
{name}
) : (
<span className="w-5 h-5">{icon}</span>
)}
{name}
</>
</a>
))}
</nav>

View File

@@ -1,42 +1,130 @@
import React, { ReactElement, useCallback, useState } from "react";
import React, { ReactElement, useCallback, useEffect, useState } from "react";
import { DnD } from "../../shared/Draggable/DnD";
import { isEmpty } from "lodash";
import Sticky from "react-stickynode";
import SlidingPane from "react-sliding-pane";
import { extractComicArchive } from "../../../actions/fileops.actions";
import { analyzeImage } from "../../../actions/fileops.actions";
import { Canvas } from "../../shared/Canvas";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import {
IMAGETRANSFORMATION_SERVICE_BASE_URI,
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../../constants/endpoints";
import { useStore } from "../../../store";
import { useShallow } from "zustand/react/shallow";
import { escapePoundSymbol } from "../../../shared/utils/formatting.utils";
export const ArchiveOperations = (props): ReactElement => {
const { data } = props;
// const isComicBookExtractionInProgress = useSelector(
// (state: RootState) => state.fileOps.comicBookExtractionInProgress,
// );
// const extractedComicBookArchive = useSelector(
// (state: RootState) => state.fileOps.extractedComicBookArchive.analysis,
// );
//
// const imageAnalysisResult = useSelector((state: RootState) => {
// return state.fileOps.imageAnalysisResults;
// });
const unpackComicArchive = useCallback(() => {
// dispatch(
// extractComicArchive(data.rawFileDetails.filePath, {
// type: "full",
// purpose: "analysis",
// imageResizeOptions: {
// baseWidth: 275,
// },
// }),
// );
}, []);
const { socketIOInstance } = useStore(
useShallow((state) => ({
socketIOInstance: state.socketIOInstance,
})),
);
const queryClient = useQueryClient();
// sliding panel config
const [visible, setVisible] = useState(false);
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
// current image
const [currentImage, setCurrentImage] = useState([]);
const [uncompressedArchive, setUncompressedArchive] = useState([]);
const [imageAnalysisResult, setImageAnalysisResult] = useState({});
const constructImagePaths = (data): Array<string> => {
return data?.map((path: string) =>
escapePoundSymbol(encodeURI(`${LIBRARY_SERVICE_HOST}/${path}`)),
);
};
// Listen to the uncompression complete event and orchestrate the final payload
socketIOInstance.on("LS_UNCOMPRESSION_JOB_COMPLETE", (data) => {
setUncompressedArchive(constructImagePaths(data?.uncompressedArchive));
});
useEffect(() => {
let isMounted = true;
if (data.rawFileDetails?.archive?.uncompressed) {
const fetchUncompressedArchive = async () => {
try {
const response = await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/walkFolders`,
method: "POST",
data: {
basePathToWalk: data?.rawFileDetails?.archive?.expandedPath,
extensions: [".jpg", ".jpeg", ".png", ".bmp", "gif"],
},
transformResponse: async (responseData) => {
const parsedData = JSON.parse(responseData);
const paths = parsedData.map((pathObject) => {
return `${pathObject.containedIn}/${pathObject.name}${pathObject.extension}`;
});
const uncompressedArchive = constructImagePaths(paths);
if (isMounted) {
setUncompressedArchive(uncompressedArchive);
}
},
});
} catch (error) {
console.error("Error fetching uncompressed archive:", error);
// Handle error if necessary
}
};
fetchUncompressedArchive();
}
// Cleanup function
return () => {
isMounted = false;
setUncompressedArchive([]);
};
}, [data]);
const analyzeImage = async (imageFilePath: string) => {
const response = await axios({
url: `${IMAGETRANSFORMATION_SERVICE_BASE_URI}/analyze`,
method: "POST",
data: {
imageFilePath,
},
});
setImageAnalysisResult(response?.data);
queryClient.invalidateQueries({ queryKey: ["uncompressedArchive"] });
};
const {
data: uncompressionResult,
refetch,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
method: "POST",
url: `http://localhost:3000/api/library/uncompressFullArchive`,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
data: {
filePath: data.rawFileDetails.filePath,
comicObjectId: data._id,
options: {
type: "full",
purpose: "analysis",
imageResizeOptions: {
baseWidth: 275,
},
},
},
}),
queryKey: ["uncompressedArchive"],
enabled: false,
});
if (isSuccess) {
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
}
// sliding panel init
const contentForSlidingPanel = {
@@ -44,13 +132,13 @@ export const ArchiveOperations = (props): ReactElement => {
content: () => {
return (
<div>
<pre className="is-size-7">{currentImage}</pre>
<pre className="text-sm">{currentImage}</pre>
{!isEmpty(imageAnalysisResult) ? (
<pre className="is-size-7 p-2 mt-3">
<pre className="p-2 mt-3">
<Canvas data={imageAnalysisResult} />
</pre>
) : null}
<pre className="is-size-7 mt-3">
<pre className="font-hasklig mt-3 text-sm">
{JSON.stringify(imageAnalysisResult.analyzedData, null, 2)}
</pre>
</div>
@@ -62,54 +150,80 @@ export const ArchiveOperations = (props): ReactElement => {
// sliding panel handlers
const openImageAnalysisPanel = useCallback((imageFilePath) => {
setSlidingPanelContentId("imageAnalysis");
// dispatch(analyzeImage(imageFilePath));
analyzeImage(imageFilePath);
setCurrentImage(imageFilePath);
setVisible(true);
}, []);
return (
<div key={2}>
<button
className={
isComicBookExtractionInProgress
? "button is-loading is-warning"
: "button is-warning"
}
onClick={unpackComicArchive}
<article
role="alert"
className="mt-4 text-md 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"
>
<span className="icon is-small">
<i className="fa-solid fa-box-open"></i>
</span>
<span>Unpack comic archive</span>
</button>
<div className="columns">
<div className="mt-5">
{!isEmpty(extractedComicBookArchive) ? (
<div>
<p>You can perform several operations on your comic book archive.</p>
<p>
Uncompressing, re-organizing the individual pages, then
re-compressing to a different format, for example.
</p>
<p>You can also analyze color histograms of pages.</p>
</div>
</article>
<div className="mt-5">
{data.rawFileDetails.archive?.uncompressed ? (
<article
role="alert"
className="mt-4 text-md rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
>
This issue is already uncompressed at:
<p>
<code className="font-hasklig text-sm">
{data.rawFileDetails.archive.expandedPath}
</code>
<div className="">It has {uncompressedArchive?.length} pages</div>
</p>
</article>
) : null}
<div className="flex flex-row gap-2 mt-4">
{!data.rawFileDetails?.archive?.uncompressed ? (
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Unpack Comic Archive</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--box-bold-duotone]"></i>
</span>
</button>
) : null}
{!isEmpty(uncompressedArchive) ? (
<div>
<button
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-3 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => refetch()}
>
<span className="text-md">Convert to .cbz</span>
<span className="w-6 h-6">
<i className="h-6 w-6 icon-[solar--zip-file-bold-duotone]"></i>
</span>
</button>
</div>
) : null}
</div>
</div>
<div>
<div className="mt-10">
{!isEmpty(uncompressedArchive) ? (
<DnD
data={extractedComicBookArchive}
data={uncompressedArchive}
onClickHandler={openImageAnalysisPanel}
/>
) : null}
</div>
{!isEmpty(extractedComicBookArchive) ? (
<div className="column mt-5">
<Sticky enabled={true} top={70} bottomBoundary={3000}>
<div className="card">
<div className="card-content">
<span className="has-text-size-4">
{extractedComicBookArchive.length} pages
</span>
<button className="button is-small is-light is-primary is-outlined">
<span className="icon is-small">
<i className="fa-solid fa-compress"></i>
</span>
<span>Convert to CBZ</span>
</button>
</div>
</div>
</Sticky>
</div>
) : null}
</div>
<SlidingPane
isOpen={visible}

View File

@@ -4,52 +4,59 @@ import React, { ReactElement } from "react";
export const ComicInfoXML = (data): ReactElement => {
const { json } = data;
return (
<div className="comicInfo-metadata">
<dl className="has-text-size-7">
<dd className="has-text-weight-medium">{json.series[0]}</dd>
<dd className="mt-2 mb-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.publisher[0]}
<div className="flex md:w-4/5 lg:w-78">
<dl className="dark:bg-yellow-600 bg-yellow-200 p-3 rounded-lg">
<dt>
<p className="text-lg">{json.series[0]}</p>
</dt>
<dd className="text-sm">
published by{" "}
<span className="underline">
{json.publisher[0]}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</dd>
<span className="inline-flex flex-row gap-2">
{/* Issue number */}
{!isUndefined(json.number) && (
<dd className="my-2">
<span className="inline-flex items-center bg-slate-50 text-sm text-slate-800 font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline] w-4 h-4"></i>
</span>
<span className="text-slate-900 dark:text-slate-900">
{parseInt(json.number[0], 10)}
</span>
</span>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Issue #</span>
<span className="tag is-warning is-light">
{!isUndefined(json.number) && parseInt(json.number[0], 10)}
</span>
</dd>
)}
<dd className="my-2">
{/* Genre */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-sm font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--sticker-smile-circle-bold-duotone] w-5 h-5"></i>
</span>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag">Pages</span>
<span className="tag is-warning is-light">
{json.pagecount[0]}
</span>
<span className="text-slate-500 dark:text-slate-900">
{json.genre[0]}
</span>
</div>
{!isUndefined(json.genre) && (
<div className="control">
<span className="tags has-addons">
<span className="tag">Genre</span>
<span className="tag is-success is-light">
{json.genre[0]}
</span>
</span>
</div>
)}
</div>
</span>
</dd>
</span>
<dd className="my-1">
{/* Summary */}
{!isUndefined(json.summary) && (
<span className="text-md text-slate-500 dark:text-slate-900">
{json.summary[0]}
</span>
)}
</dd>
<dd>
<span className="is-size-7">{json.notes[0]}</span>
</dd>
<dd className="mt-1 mb-1">
{!isUndefined(json.summary) && json.summary[0]}
{/* Notes */}
<span className="text-sm text-slate-500 dark:text-slate-900">
{json.notes[0]}
</span>
</dd>
</dl>
</div>

View File

@@ -1,30 +1,15 @@
import React, { ReactElement } from "react";
import ComicVineDetails from "../ComicVineDetails";
import { convert } from "html-to-text";
import { isEmpty } from "lodash";
export const VolumeInformation = (props): ReactElement => {
const { data } = props;
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return (
<div key={1}>
<div className="columns is-multiline">
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
<div className="column is-8">
{!isEmpty(data.sourcedMetadata.comicvine.description) &&
convert(data.sourcedMetadata.comicvine.description, {
baseElements: {
selectors: ["p"],
},
})}
</div>
</div>
<ComicVineDetails
data={data.sourcedMetadata.comicvine}
updatedAt={data.updatedAt}
/>
</div>
);
};

View File

@@ -1,29 +1,14 @@
import React, { ReactElement, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import React, { ReactElement } from "react";
import ZeroState from "./ZeroState";
import { RecentlyImported } from "./RecentlyImported";
import { WantedComicsList } from "./WantedComicsList";
import { VolumeGroups } from "./VolumeGroups";
import { LibraryStatistics } from "./LibraryStatistics";
import { PullList } from "./PullList";
import {
fetchVolumeGroups,
getComicBooks,
} from "../../actions/fileops.actions";
import { getLibraryStatistics } from "../../actions/comicinfo.actions";
import { isEmpty, isNil, isUndefined } from "lodash";
import Header from "../shared/Header";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { Card } from "../shared/Carda";
import {
LIBRARY_SERVICE_BASE_URI,
LIBRARY_SERVICE_HOST,
} from "../../constants/endpoints";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Dashboard = (): ReactElement => {
const { data: recentComics } = useQuery({
@@ -44,145 +29,43 @@ export const Dashboard = (): ReactElement => {
queryKey: ["recentComics"],
});
console.log("hari om", recentComics);
// useEffect(() => {
// dispatch(fetchVolumeGroups());
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": false },
// comicStatus: "recent",
// }),
// );
// dispatch(
// getComicBooks({
// paginationOptions: {
// page: 0,
// limit: 5,
// sort: { updatedAt: "-1" },
// },
// predicate: { "acquisition.source.wanted": true },
// comicStatus: "wanted",
// }),
// );
// dispatch(getLibraryStatistics());
// }, []);
//
// const recentComics = useSelector(
// (state: RootState) => state.fileOps.recentComics,
// );
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
// const volumeGroups = useSelector(
// (state: RootState) => state.fileOps.comicVolumeGroups,
// );
const { data: wantedComics } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBooks`,
method: "POST",
data: {
paginationOptions: {
page: 0,
limit: 5,
sort: { updatedAt: "-1" },
},
predicate: { "acquisition.source.wanted": true },
},
}),
queryKey: ["wantedComics"],
});
const { data: volumeGroups } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookGroups`,
method: "GET",
}),
queryKey: ["volumeGroups"],
});
//
// const libraryStatistics = useSelector(
// (state: RootState) => state.comicInfo.libraryStatistics,
// );
return (
<div className="container mx-auto max-w-full">
<section>
<h1>Dashboard</h1>
<div className="grid grid-cols-5 gap-6">
{recentComics?.data.docs.map(
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
},
idx,
) => {
const { issueName, url } = determineCoverFile({
rawFileDetails,
comicvine,
comicInfo,
locg,
});
const { issue, coverURL, icon } = determineExternalMetadata(
name,
{
comicvine,
comicInfo,
locg,
},
);
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
return (
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dt className="sr-only">Address</dt>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline]"></i>
</span>
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
</dd>
</div>
<div className="flex flex-row items-center gap-1 my-2">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-slate-500 dark:text-slate-300"></i>
)}
{/* ComicVine metadata presence */}
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
</div>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
);
},
)}
</div>
</section>
<PullList />
{recentComics && <RecentlyImported comics={recentComics?.data.docs} />}
{/* Wanted comics */}
<WantedComicsList comics={wantedComics?.data?.docs} />
{/* Volume groups */}
<VolumeGroups volumeGroups={volumeGroups?.data} />
</div>
);
};

View File

@@ -1,160 +1,152 @@
import { isNil, map } from "lodash";
import React, { createRef, ReactElement, useCallback, useEffect } from "react";
import React, { ReactElement, useState } from "react";
import { map } from "lodash";
import Card from "../shared/Carda";
import Header from "../shared/Header";
import Masonry from "react-masonry-css";
import { useDispatch, useSelector } from "react-redux";
import { getWeeklyPullList } from "../../actions/comicinfo.actions";
import { importToDB } from "../../actions/fileops.actions";
import ellipsize from "ellipsize";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { Link } from "react-router-dom";
import axios from "axios";
import rateLimiter from "axios-rate-limit";
import { setupCache } from "axios-cache-interceptor";
import { useQuery } from "@tanstack/react-query";
import "keen-slider/keen-slider.min.css";
import { useKeenSlider } from "keen-slider/react";
import { COMICVINE_SERVICE_URI } from "../../constants/endpoints";
import { Field, Form } from "react-final-form";
import DatePickerDialog from "../shared/DatePicker";
import { format } from "date-fns";
type PullListProps = {
issues: any;
};
export const PullList = ({ issues }: PullListProps): ReactElement => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(
getWeeklyPullList({
startDate: "2023-9-9",
pageSize: "15",
currentPage: "1",
}),
);
}, []);
const addToLibrary = useCallback(
(sourceName: string, locgMetadata) =>
dispatch(importToDB(sourceName, { locg: locgMetadata })),
[],
const http = rateLimiter(axios.create(), {
maxRequests: 1,
perMilliseconds: 1000,
maxRPS: 1,
});
const cachedAxios = setupCache(axios);
export const PullList = (): ReactElement => {
// datepicker
const date = new Date();
const [inputValue, setInputValue] = useState<string>(
format(date, "M-dd-yyyy"),
);
/*
const foo = {
coverFile: {}, // pointer to which cover file to use
rawFileDetails: {}, // #1
sourcedMetadata: {
comicInfo: {},
comicvine: {}, // #2
locg: {}, // #2
},
};
*/
const pullList = useSelector((state: RootState) => state.comicInfo.pullList);
let sliderRef = createRef();
const settings = {
dots: false,
infinite: false,
speed: 500,
slidesToShow: 5,
slidesToScroll: 5,
initialSlide: 0,
responsive: [
{
breakpoint: 1024,
settings: {
slidesToShow: 3,
slidesToScroll: 3,
infinite: false,
},
// keen slider
const [sliderRef, instanceRef] = useKeenSlider(
{
loop: true,
slides: {
origin: "auto",
number: 15,
perView: 5,
spacing: 15,
},
{
breakpoint: 600,
settings: {
slidesToShow: 2,
slidesToScroll: 2,
initialSlide: 0,
},
},
{
breakpoint: 480,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
},
slideChanged() {
console.log("slide changed");
},
},
[
// add plugins here
],
};
);
const {
data: pullList,
refetch,
isSuccess,
isLoading,
isError,
} = useQuery({
queryFn: async (): any =>
await cachedAxios(`${COMICVINE_SERVICE_URI}/getWeeklyPullList`, {
method: "get",
params: { startDate: inputValue, pageSize: "15", currentPage: "1" },
}),
queryKey: ["pullList", inputValue],
});
const addToLibrary = (sourceName: string, locgMetadata) =>
importToDB(sourceName, { locg: locgMetadata });
const next = () => {
sliderRef.slickNext();
// sliderRef.slickNext();
};
const previous = () => {
sliderRef.slickPrev();
// sliderRef.slickPrev();
};
return (
<>
<div className="content">
<Header headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
iconClassNames="fa-solid fa-binoculars mr-2"/>
<div className="field is-grouped">
<Header
headerContent="Discover"
subHeaderContent="Pull List aggregated for the week from League Of Comic Geeks"
iconClassNames="fa-solid fa-binoculars mr-2"
link="/pull-list/all/"
/>
<div className="flex flex-row gap-5 mb-3">
{/* select week */}
<div className="control">
<div className="select is-small">
<select>
<option>Select Week</option>
<option>With options</option>
</select>
</div>
</div>
{/* See all pull list issues */}
<div className="control">
<Link to={"/pull-list/all/"}>
<button className="button is-small">View all issues</button>
</Link>
</div>
<div className="field has-addons">
<div className="control">
<button className="button is-rounded is-small" onClick={previous}>
<i className="fa-solid fa-caret-left"></i>
</button>
</div>
<div className="control">
<button className="button is-rounded is-small" onClick={next}>
<i className="fa-solid fa-caret-right"></i>
</button>
</div>
<div className="flex flex-row gap-4 my-3">
<Form
onSubmit={() => {}}
render={({ handleSubmit }) => (
<form>
<div className="flex flex-col gap-2">
{/* week selection for pull list */}
<DatePickerDialog
inputValue={inputValue}
setter={setInputValue}
/>
{inputValue && (
<div className="text-sm">
Showing pull list for <span>{inputValue}</span>
</div>
)}
</div>
</form>
)}
/>
</div>
</div>
</div>
<Slider {...settings} ref={(c) => (sliderRef = c)}>
{!isNil(pullList) &&
pullList &&
map(pullList, ({ issue }, idx) => {
{isSuccess && !isLoading && (
<div ref={sliderRef} className="keen-slider flex flex-row">
{map(pullList?.data.result, (issue, idx) => {
return (
<Card
key={idx}
orientation={"vertical"}
imageUrl={issue.cover}
hasDetails
title={ellipsize(issue.name, 18)}
cardContainerStyle={{
marginRight: 22,
boxShadow: "-2px 4px 15px -6px rgba(0,0,0,0.57)",
}}
>
<div className="content">
<div className="control">
<span className="tag">{issue.publisher}</span>
<div key={idx} className="keen-slider__slide">
<Card
orientation={"vertical-2"}
imageUrl={issue.cover}
hasDetails
title={ellipsize(issue.name, 25)}
>
<div className="px-1">
<span className="inline-flex mb-2 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-1 rounded-md dark:text-slate-900 dark:bg-slate-400">
{issue.publisher}
</span>
<div className="flex flex-row justify-end">
<button
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => addToLibrary("locg", issue)}
>
<i className="icon-[solar--add-square-bold-duotone] w-5 h-5 mr-2"></i>{" "}
Want
</button>
</div>
</div>
<div className="mt-2">
<button
className="button is-small is-success is-outlined is-light"
onClick={() => addToLibrary("locg", issue)}
>
<i className="fa-solid fa-plus"></i> Want
</button>
</div>
</div>
</Card>
</Card>
</div>
);
})}
</Slider>
</div>
)}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</>
);
};

View File

@@ -4,47 +4,35 @@ import { Link } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import {
determineCoverFile,
determineExternalMetadata,
} from "../../shared/utils/metadata.utils";
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
import Header from "../shared/Header";
type RecentlyImportedProps = {
comicBookCovers: any;
comics: any;
};
export const RecentlyImported = ({
comicBookCovers,
}: RecentlyImportedProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
600: 2,
};
export const RecentlyImported = (
comics: RecentlyImportedProps,
): ReactElement => {
return (
<>
<div className="content mt-5">
<h4 className="title is-4">
<i className="fa-solid fa-file-arrow-down"></i> Recently Imported
</h4>
<p className="subtitle is-7">
Recent Library activity such as imports, tagging, etc.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
{map(
comicBookCovers,
<div>
<Header
headerContent="Recently Imported"
subHeaderContent="Recent Library activity such as imports, tagging, etc."
iconClassNames="fa-solid fa-binoculars mr-2"
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{comics?.comics.map(
(
{
_id,
rawFileDetails,
sourcedMetadata: { comicvine, comicInfo, locg },
inferredMetadata,
acquisition: {
source: { name },
},
@@ -62,85 +50,80 @@ export const RecentlyImported = ({
comicInfo,
locg,
});
const isComicBookMetadataAvailable =
const isComicVineMetadataAvailable =
!isUndefined(comicvine) &&
!isUndefined(comicvine.volumeInformation);
const titleElement = (
<Link to={"/comic/details/" + _id}>
{ellipsize(issueName, 20)}
</Link>
);
return (
<React.Fragment key={_id}>
<Card
orientation={"vertical"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : null}
>
<div className="content is-flex is-flex-direction-row">
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="icon custom-icon is-small has-text-danger mr-2">
<img src="/src/client/assets/img/missing-file.svg" />
<Card
orientation="vertical-2"
key={idx}
imageUrl={`${LIBRARY_SERVICE_HOST}/${rawFileDetails.cover.filePath}`}
title={inferredMetadata.issue.name}
hasDetails
>
<div>
<dd className="text-sm my-1 flex flex-row gap-1">
{/* Issue number */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--hashtag-outline]"></i>
</span>
)}
<span className="text-md text-slate-900">
{inferredMetadata.issue.number}
</span>
</span>
{/* File extension */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--file-bold-duotone] w-4 h-4"></i>
</span>
<span className="text-md text-slate-500 dark:text-slate-900">
{rawFileDetails.extension}
</span>
</span>
{/* Uncompressed status */}
{rawFileDetails?.archive?.uncompressed ? (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-4 h-4"></i>
</span>
</span>
) : null}
</dd>
</div>
<div className="flex flex-row items-center gap-1 mt-2 pb-1">
<div className="sm:inline-flex sm:shrink-0 sm:items-center sm:gap-2">
{/* ComicInfo.xml presence */}
{!isNil(comicInfo) && !isEmpty(comicInfo) && (
<span className="icon custom-icon is-small has-text-danger">
<img
src="/src/client/assets/img/comicinfoxml.svg"
alt={"ComicInfo.xml file detected."}
/>
</span>
<div mt-1>
<i className="h-7 w-7 icon-[solar--code-file-bold-duotone] text-yellow-500 dark:text-yellow-300"></i>
</div>
)}
{/* ComicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
{isComicVineMetadataAvailable && (
<span className="w-7 h-7">
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
/>
</span>
)}
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
) : null}
</div>
</Card>
{/* metadata card */}
{!isNil(name) && (
<Card orientation="horizontal" hasDetails imageUrl={coverURL}>
<dd className="is-size-9">
<dl>
<span className="icon custom-icon">
<img src={`/src/client/assets/img/${icon}`} />
</span>
</dl>
<dl>
<span className="small-tag">
{ellipsize(issue, 15)}
</span>
</dl>
</dd>
</Card>
)}
</React.Fragment>
{/* Raw file presence */}
{isNil(rawFileDetails) && (
<span className="h-6 w-5 sm:shrink-0 sm:items-center sm:gap-2">
<i className="icon-[solar--file-corrupted-outline] h-5 w-5" />
</span>
)}
</div>
</Card>
);
},
)}
</Masonry>
</>
</div>
</div>
);
};
};

View File

@@ -2,15 +2,10 @@ import { map, unionBy } from "lodash";
import React, { ReactElement } from "react";
import ellipsize from "ellipsize";
import { Link, useNavigate } from "react-router-dom";
import Masonry from "react-masonry-css";
import Card from "../shared/Carda";
import Header from "../shared/Header";
export const VolumeGroups = (props): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
// Till mongo gives us back the deduplicated results with the ObjectId
const deduplicatedGroups = unionBy(props.volumeGroups, "volumes.id");
const navigate = useNavigate();
@@ -19,44 +14,48 @@ export const VolumeGroups = (props): ReactElement => {
};
return (
<section className="volumes-container mt-4">
<div className="content">
<a className="mb-1" onClick={navigateToVolumes}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-layer-group"></i> Volumes
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">Based on ComicVine Volume information</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="volumes-grid"
columnClassName="volumes-grid-column"
>
<section>
<Header
headerContent="Volumes"
subHeaderContent="Based on ComicVine Volume information"
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/volumes"}
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{map(deduplicatedGroups, (data) => {
return (
<div className="stack" key={data._id}>
<img src={data.volumes.image.small_url} />
<div className="content">
<div className="stack-title is-size-8">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 18)}
</Link>
</div>
<div className="control">
<span className="tags has-addons">
<span className="tag is-primary is-light">Issues</span>
<span className="tag">{data.volumes.count_of_issues}</span>
<div className="max-w-sm mx-auto" key={data._id}>
<Card
orientation="vertical-2"
key={data._id}
imageUrl={data.volumes.image.small_url}
hasDetails
>
<div className="py-3">
<div className="text-sm">
<Link to={`/volume/details/${data._id}`}>
{ellipsize(data.volumes.name, 48)}
</Link>
</div>
{/* issue count */}
<span className="inline-flex mt-1 items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 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">
{data.volumes.count_of_issues} issues
</span>
</span>
</div>
</div>
</Card>
<div className="w-11/12 h-2 mx-auto bg-slate-900 rounded-b opacity-75"></div>
<div className="w-10/12 h-2 mx-auto bg-slate-900 rounded-b opacity-50"></div>
<div className="w-9/12 h-2 mx-auto bg-slate-900 rounded-b opacity-25"></div>
</div>
);
})}
</Masonry>
</div>
</section>
);
};

View File

@@ -4,8 +4,8 @@ import { Link, useNavigate } from "react-router-dom";
import ellipsize from "ellipsize";
import { isEmpty, isNil, isUndefined, map } from "lodash";
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
import Masonry from "react-masonry-css";
import { determineCoverFile } from "../../shared/utils/metadata.utils";
import Header from "../shared/Header";
type WantedComicsListProps = {
comics: any;
@@ -14,37 +14,17 @@ type WantedComicsListProps = {
export const WantedComicsList = ({
comics,
}: WantedComicsListProps): ReactElement => {
const breakpointColumnsObj = {
default: 5,
1100: 4,
700: 2,
500: 1,
};
const navigate = useNavigate();
const navigateToWantedComics = (row) => {
navigate(`/wanted/all`);
};
return (
<>
<div className="content mt-6">
<a className="mb-1" onClick={navigateToWantedComics}>
<span className="is-size-4 has-text-weight-semibold">
<i className="fa-solid fa-asterisk"></i> Wanted Comics
</span>
<span className="icon mt-1">
<i className="fa-solid fa-angle-right"></i>
</span>
</a>
<p className="subtitle is-7">
Comics marked as wanted from various sources.
</p>
</div>
<Masonry
breakpointCols={breakpointColumnsObj}
className="recent-comics-container"
columnClassName="recent-comics-column"
>
<Header
headerContent="Wanted Comics"
subHeaderContent="Comics marked as wanted from various sources"
iconClassNames="fa-solid fa-binoculars mr-2"
link={"/wanted"}
/>
<div className="grid grid-cols-5 gap-6 mt-3">
{map(
comics,
({
@@ -73,42 +53,54 @@ export const WantedComicsList = ({
return (
<Card
key={_id}
orientation={"vertical"}
orientation={"vertical-2"}
imageUrl={url}
hasDetails
title={issueName ? titleElement : <span>No Name</span>}
>
<div className="content is-flex is-flex-direction-row">
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/cvlogo.svg" />
</span>
)}
{!isEmpty(locg) && (
<span className="icon custom-icon">
<img src="/src/client/assets/img/locglogo.svg" />
</span>
)}
<div className="pb-1">
{/* Issue type */}
{isComicBookMetadataAvailable &&
!isNil(
detectIssueTypes(comicvine.volumeInformation.description),
) ? (
<span className="tag is-warning">
{
detectIssueTypes(
comicvine.volumeInformation.description,
).displayName
}
</span>
<div className="my-2">
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 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(
comicvine.volumeInformation.description,
).displayName
}
</span>
</span>
</div>
) : null}
{/* comicVine metadata presence */}
{isComicBookMetadataAvailable && (
<img
src="/src/client/assets/img/cvlogo.svg"
alt={"ComicVine metadata detected."}
className="w-7 h-7"
/>
)}
{!isEmpty(locg) && (
<img
src="/src/client/assets/img/locglogo.svg"
className="w-7 h-7"
/>
)}
</div>
</Card>
);
},
)}
</Masonry>
</div>
</>
);
};

View File

@@ -6,8 +6,8 @@ interface ZeroStateProps {
}
const ZeroState: React.FunctionComponent<ZeroStateProps> = (props) => {
return (
<article className="message is-info">
<div className="message-body">
<article className="">
<div className="">
<p>{props.header}</p>
{props.message}
</div>

View File

@@ -226,7 +226,7 @@ export const Import = (props: IProps): ReactElement => {
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
</span>
<div className="overflow-x-auto mt-4 rounded-lg border border-gray-200">
<div className="overflow-x-auto w-fit mt-4 rounded-lg border border-gray-200">
<table className="min-w-full divide-y-2 divide-gray-200 dark:divide-gray-200 text-md">
<thead className="ltr:text-left rtl:text-right">
<tr>

View File

@@ -1,11 +1,16 @@
import React, { useMemo, ReactElement, useState } from "react";
import React, { useMemo, ReactElement, useState, useEffect } from "react";
import PropTypes from "prop-types";
import { useNavigate } from "react-router-dom";
import { isEmpty, isNil, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import T2Table from "../shared/T2Table";
import SearchBar from "../Library/SearchBar";
import ellipsize from "ellipsize";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import {
useQuery,
keepPreviousData,
useQueryClient,
} from "@tanstack/react-query";
import axios from "axios";
import { format, fromUnixTime, parseISO } from "date-fns";
@@ -20,32 +25,56 @@ export const Library = (): ReactElement => {
// Default page state
// offset: 0
const [offset, setOffset] = useState(0);
// Method to fetch paginated issues
const fetchIssues = async (searchQuery, offset, type) => {
let pagination = {
size: 15,
const [searchQuery, setSearchQuery] = useState({
query: {},
pagination: {
size: 25,
from: offset,
};
},
type: "all",
trigger: "libraryPage",
});
const queryClient = useQueryClient();
/**
* Method that queries the Elasticsearch index "comics" for issues specified by the query
* @param searchQuery - A searchQuery object that contains the search term, type, and pagination params.
*/
const fetchIssues = async (searchQuery) => {
const { pagination, query, type } = searchQuery;
return await axios({
method: "POST",
url: "http://localhost:3000/api/search/searchIssue",
data: {
searchQuery,
query,
pagination,
type,
},
});
};
const searchIssues = (e) => {
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {
volumeName: e.search,
},
pagination: {
size: 15,
from: 0,
},
type: "volumeName",
trigger: "libraryPage",
});
};
const { data, isLoading, isError, isPlaceholderData } = useQuery({
queryKey: ["comics", offset],
queryFn: () => fetchIssues({}, offset, "all"),
queryKey: ["comics", offset, searchQuery],
queryFn: () => fetchIssues(searchQuery),
placeholderData: keepPreviousData,
});
const searchResults = data?.data;
// Programmatically navigate to comic detail
const navigate = useNavigate();
const navigateToComicDetail = (row) => {
@@ -56,7 +85,7 @@ export const Library = (): ReactElement => {
return value.data ? (
<dl className="flex flex-col text-md p-3 ml-4 my-3 rounded-lg dark:bg-yellow-500 bg-yellow-300 w-max">
{/* Series Name */}
<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="inline-flex items-center w-fit 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--bookmark-square-minimalistic-bold-duotone] w-5 h-5"></i>
</span>
@@ -123,7 +152,7 @@ export const Library = (): ReactElement => {
accessorKey: "_source.createdAt",
cell: (info) => {
return !isNil(info.getValue()) ? (
<div className="text-xs w-max ml-3 my-3 text-slate-600">
<div className="text-sm w-max ml-3 my-3 text-slate-600 dark:text-slate-900">
<p>{format(parseISO(info.getValue()), "dd MMMM, yyyy")} </p>
{format(parseISO(info.getValue()), "h aaaa")}
</div>
@@ -171,7 +200,17 @@ export const Library = (): ReactElement => {
**/
const nextPage = (pageIndex: number, pageSize: number) => {
if (!isPlaceholderData) {
setOffset(pageSize * pageIndex + 1);
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from: pageSize * pageIndex + 1,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(pageSize * pageIndex + 1);
}
};
@@ -189,7 +228,17 @@ export const Library = (): ReactElement => {
} else {
from = (pageIndex - 1) * pageSize + 2 - (pageSize + 1);
}
setOffset(from);
queryClient.invalidateQueries({ queryKey: ["comics"] });
setSearchQuery({
query: {},
pagination: {
size: 15,
from,
},
type: "all",
trigger: "libraryPage",
});
// setOffset(from);
};
// ImportStatus.propTypes = {
@@ -215,7 +264,7 @@ export const Library = (): ReactElement => {
</header>
{!isUndefined(searchResults?.hits) ? (
<div>
<div className="library">
<div>
<T2Table
totalPages={searchResults.hits.total.value}
columns={columns}
@@ -225,11 +274,13 @@ export const Library = (): ReactElement => {
nextPage,
previousPage,
}}
/>
>
<SearchBar searchHandler={(e) => searchIssues(e)} />
</T2Table>
</div>
</div>
) : (
<>
<div className="mx-auto max-w-screen-xl mt-5">
<article
role="alert"
className="rounded-lg max-w-screen-md border-s-4 border-yellow-500 bg-yellow-50 p-4 dark:border-s-4 dark:border-yellow-600 dark:bg-yellow-300 dark:text-slate-600"
@@ -247,7 +298,7 @@ export const Library = (): ReactElement => {
{!isUndefined(searchResults?.data?.meta?.body) ? (
<p>
{JSON.stringify(
searchResults.data.meta.body.error.root_cause,
searchResults?.data.meta.body.error.root_cause,
null,
4,
)}
@@ -255,7 +306,7 @@ export const Library = (): ReactElement => {
) : null}
</pre>
</div>
</>
</div>
)}
</section>
</div>

View File

@@ -1,32 +1,13 @@
import React, { ReactElement, useCallback } from "react";
import React, { ReactElement } from "react";
import PropTypes from "prop-types";
import { Form, Field } from "react-final-form";
import { Link } from "react-router-dom";
import { searchIssue } from "../../actions/fileops.actions";
export const SearchBar = (): ReactElement => {
const handleSubmit = useCallback((e) => {
// dispatch(
// searchIssue(
// {
// query: {
// volumeName: e.search,
// },
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumeName",
// trigger: "libraryPage",
// },
// ),
// );
}, []);
export const SearchBar = (props): ReactElement => {
const { searchHandler } = props;
return (
<Form
onSubmit={handleSubmit}
onSubmit={searchHandler}
initialValues={{}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit}>

View File

@@ -1,8 +1,7 @@
import React, { useCallback, ReactElement } from "react";
import React, { useCallback, ReactElement, useState } from "react";
import { isNil, isEmpty } from "lodash";
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
import { importToDB } from "../../actions/fileops.actions";
import { useSelector, useDispatch } from "react-redux";
import { comicinfoAPICall } from "../../actions/comicinfo.actions";
import { search } from "../../services/api/SearchApi";
import { Form, Field } from "react-final-form";
@@ -10,6 +9,12 @@ import Card from "../shared/Carda";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import dayjs from "dayjs";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
COMICVINE_SERVICE_URI,
LIBRARY_SERVICE_BASE_URI,
} from "../../constants/endpoints";
import axios from "axios";
interface ISearchProps {}
@@ -17,164 +22,223 @@ export const Search = ({}: ISearchProps): ReactElement => {
const formData = {
search: "",
};
const dispatch = useDispatch();
const getCVSearchResults = useCallback(
(searchQuery) => {
dispatch(
comicinfoAPICall({
callURIAction: "search",
callMethod: "GET",
callParams: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery.search,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
const queryClient = useQueryClient();
const [searchQuery, setSearchQuery] = useState("");
const [comicVineMetadata, setComicVineMetadata] = useState({});
const getCVSearchResults = (searchQuery) => {
setSearchQuery(searchQuery.search);
// queryClient.invalidateQueries({ queryKey: ["comicvineSearchResults"] });
};
const {
data: comicVineSearchResults,
isLoading,
isSuccess,
} = useQuery({
queryFn: async () =>
await axios({
url: `${COMICVINE_SERVICE_URI}/search`,
method: "GET",
params: {
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
query: searchQuery,
format: "json",
limit: "10",
offset: "0",
field_list:
"id,name,deck,api_detail_url,image,description,volume,cover_date",
resources: "issue",
},
}),
queryKey: ["comicvineSearchResults", searchQuery],
enabled: !isNil(searchQuery),
});
// add to library
const { data: additionResult } = useQuery({
queryFn: async () =>
await axios({
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
method: "POST",
data: {
importType: "new",
payload: {
rawFileDetails: {
name: "",
},
importStatus: {
isImported: true,
tagged: false,
matchedResult: {
score: "0",
},
},
sourcedMetadata:
{ comicvine: comicVineMetadata?.comicData } || null,
acquisition: { source: { wanted: true, name: "comicvine" } },
},
}),
);
},
[dispatch],
);
},
}),
queryKey: ["additionResult"],
enabled: !isNil(comicVineMetadata.comicData),
});
const addToLibrary = useCallback(
(sourceName: string, comicData) =>
dispatch(importToDB(sourceName, { comicvine: comicData })),
[],
);
const addToLibrary = (sourceName: string, comicData) =>
setComicVineMetadata({ sourceName, comicData });
const comicVineSearchResults = useSelector(
(state: RootState) => state.comicInfo.searchResults,
);
const createDescriptionMarkup = (html) => {
return { __html: html };
};
return (
<>
<section className="container">
<div className="section search">
<h1 className="title">Search</h1>
<div>
<section>
<header className="bg-slate-200 dark:bg-slate-500">
<div className="px-2 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={getCVSearchResults}
initialValues={{
...formData,
}}
render={({ handleSubmit, form, submitting, pristine, values }) => (
<form onSubmit={handleSubmit} className="form columns search">
<div className="column is-three-quarters search">
<Field name="search">
{({ input, meta }) => {
return (
<input
{...input}
className="input main-search-bar is-large"
placeholder="Type an issue/volume name"
/>
);
}}
</Field>
</div>
<div className="column">
<button type="submit" className="button is-medium">
<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>
</form>
)}
/>
{!isNil(comicVineSearchResults.results) &&
!isEmpty(comicVineSearchResults.results) ? (
<div className="columns is-multiline">
{comicVineSearchResults.results.map((result) => {
return (
<div
key={result.id}
className="comicvine-metadata column is-10 mb-3"
>
<div className="columns">
<div className="column is-one-quarter">
<Card
key={result.id}
orientation={"vertical"}
imageUrl={result.image.small_url}
title={result.name}
hasDetails={false}
></Card>
</div>
{!isNil(comicVineSearchResults?.data.results) &&
!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) => {
return isSuccess ? (
<div key={result.id} className="mb-5">
<div className="flex flex-row">
<div className="mr-5">
<Card
key={result.id}
orientation={"cover-only"}
imageUrl={result.image.small_url}
hasDetails={false}
/>
</div>
<div className="column">
<div className="text-xl">
{!isEmpty(result.volume.name) ? (
result.volume.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
<div className="column">
<div className="is-size-3">
{!isEmpty(result.name) ? (
result.name
) : (
<span className="is-size-3">No Name</span>
)}
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning">
{result.id}
</span>
</div>
<div className="field is-grouped mt-1">
<div className="control">
<div className="tags has-addons">
<span className="tag is-light">Cover date</span>
<span className="tag is-info is-light">
{dayjs(result.cover_date).format("MMM D, YYYY")}
</span>
</div>
</div>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p>
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p"],
},
}),
320,
)}
</p>
<div className="control">
<div className="tags has-addons">
<span className="tag is-warning">{result.id}</span>
</div>
</div>
</div>
<a href={result.api_detail_url}>
{result.api_detail_url}
</a>
<p>
{ellipsize(
convert(result.description, {
baseElements: {
selectors: ["p", "div"],
},
}),
320,
)}
</p>
<div className="mt-2">
<button
className="button is-success is-light is-outlined mt-2"
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-2 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
onClick={() => addToLibrary("comicvine", result)}
>
<i className="fa-solid fa-plus mr-2"></i> Want
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
Mark as Wanted
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
<article className="message is-dark is-half">
<div className="message-body">
<p className="mb-2">
<span className="tag is-medium is-info is-light">
Search the ComicVine database
</span>
</div>
) : (
<div>Loading</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>
</div>
)}
</section>
</>
</div>
);
};

View File

@@ -14,7 +14,6 @@ export const QbittorrentConnectionForm = (): ReactElement => {
method: "GET",
}),
});
console.log(data);
const hostDetails = data?.data?.bittorrent?.client?.host;
// connect to qbittorrent client
const { data: connectionDetails } = useQuery({

View File

@@ -11,6 +11,7 @@ interface ISettingsProps {}
export const Settings = (props: ISettingsProps): ReactElement => {
const [active, setActive] = useState("gen-db");
console.log(active);
const settingsContent = [
{
id: "adc-hubs",
@@ -67,67 +68,69 @@ export const Settings = (props: ISettingsProps): ReactElement => {
</div>
</div>
</header>
<div className="flex flex-cols max-w-screen-xl mx-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => {
return (
<div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul className="pl-4 mt-2">
{map(item.children, (item, idx) => (
<li key={item.id} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
<div className="flex flex-row">
<div className="inset-y-0 w-80 dark:bg-gray-800 bg-slate-300 text-white overflow-y-auto">
<aside className="px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
{map(settingsObject, (settingObject, idx) => {
return (
<div
className="w-64 py-2 text-slate-700 dark:text-slate-400"
key={idx}
>
<h3 className="text-l pb-2">
{settingObject.category.toUpperCase()}
</h3>
{/* First level children */}
{!isUndefined(settingObject.children) ? (
<ul key={settingObject.id}>
{map(settingObject.children, (item, idx) => {
return (
<li key={idx} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() => setActive(item.id.toString())}
>
{item.displayName}
</a>
{/* Second level children */}
{!isUndefined(item.children) ? (
<ul className="pl-4 mt-2">
{map(item.children, (item, idx) => (
<li key={item.id} className="mb-2">
<a
className={
item.id.toString() === active
? "is-active flex items-center"
: "flex items-center"
}
onClick={() =>
setActive(item.id.toString())
}
>
{item.displayName}
</a>
</li>
))}
</ul>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
})}
</aside>
</div>
{/* content for settings */}
<div className="max-w-screen-xl">
<div className="content">
<div className="flex mx-12">
<div className="">
{map(settingsContent, ({ id, content }) =>
active === id ? content : null,
)}

View File

@@ -4,124 +4,82 @@ import Card from "../shared/Carda";
import T2Table from "../shared/T2Table";
import ellipsize from "ellipsize";
import { convert } from "html-to-text";
import { isUndefined } from "lodash";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const Volumes = (props): ReactElement => {
// const volumes = useSelector((state: RootState) => state.fileOps.volumes);
useEffect(() => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "volumes",
// trigger: "volumesPage",
// },
// ),
// );
}, []);
const {
data: volumes,
isSuccess,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
pagination: {
size: 25,
from: 0,
},
type: "volumes",
trigger: "volumesPage",
},
}),
queryKey: ["volumes"],
});
console.log(volumes);
const columnData = useMemo(
() => [
(): any => [
{
header: "Volume Details",
id: "volumeDetails",
minWidth: 450,
accessorKey: "_source",
cell: (row) => {
cell: (row): any => {
const foo = row.getValue();
return (
<div className="columns">
<div className="column">
<div className="comic-detail issue-metadata">
<dl>
<dd>
<div className="columns mt-2">
<div className="">
<Card
imageUrl={
foo.sourcedMetadata.comicvine.volumeInformation
.image.thumb_url
}
orientation={"vertical"}
hasDetails={false}
// cardContainerStyle={{ maxWidth: 200 }}
/>
</div>
<div className="column">
<dl>
<dt>
<h6 className="name has-text-weight-medium mb-1">
{
foo.sourcedMetadata.comicvine
.volumeInformation.name
}
</h6>
</dt>
<dd className="is-size-7">
published by{" "}
<span className="has-text-weight-semibold">
{
foo.sourcedMetadata.comicvine
.volumeInformation.publisher.name
}
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine
.volumeInformation.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span className="tag is-success is-light has-text-weight-semibold">
Total Issues
</span>
<span className="tag is-success is-light">
{
foo.sourcedMetadata.comicvine
.volumeInformation.count_of_issues
}
</span>
</span>
</div>
</div>
</dd>
</dl>
</div>
</div>
</dd>
</dl>
</div>
<div className="flex flex-row gap-3 mt-5">
<Card
imageUrl={
foo.sourcedMetadata.comicvine.volumeInformation.image
.small_url
}
orientation={"cover-only"}
hasDetails={false}
/>
<div className="dark:bg-[#647587] bg-slate-200 p-3 rounded-lg h-fit">
<span className="text-xl mb-1">
{foo.sourcedMetadata.comicvine.volumeInformation.name}
</span>
<p>
{ellipsize(
convert(
foo.sourcedMetadata.comicvine.volumeInformation
.description,
{
baseElements: {
selectors: ["p"],
},
},
),
120,
)}
</p>
</div>
</div>
);
},
},
{
header: "Download Status",
header: "Other Details",
columns: [
{
header: "Files",
header: "Downloads",
accessorKey: "_source.acquisition.directconnect",
align: "right",
cell: (props) => {
@@ -142,12 +100,34 @@ export const Volumes = (props): ReactElement => {
},
},
{
header: "Type",
id: "Air",
header: "Publisher",
accessorKey: "_source.sourcedMetadata.comicvine.volumeInformation",
cell: (props): any => {
const row = props.getValue();
return <div className="mt-5 text-md">{row.publisher.name}</div>;
},
},
{
header: "Type",
id: "dcc",
header: "Issue Count",
accessorKey:
"_source.sourcedMetadata.comicvine.volumeInformation.count_of_issues",
cell: (props): any => {
const row = props.getValue();
return (
<div className="mt-5">
{/* issue count */}
<span className="inline-flex items-center bg-slate-50 text-slate-800 font-medium px-2.5 py-0.5 rounded-md dark:text-slate-600 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-6 h-6"></i>
</span>
<span className="text-lg text-slate-500 dark:text-slate-900">
{row}
</span>
</span>
</div>
);
},
},
],
},
@@ -155,17 +135,29 @@ export const Volumes = (props): ReactElement => {
[],
);
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Volumes</h1>
</div>
{!isUndefined(volumes.hits) && (
<div>
<section className="">
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 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">
Volumes
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse your collection of volumes.
</p>
</div>
</div>
</div>
</header>
{isSuccess ? (
<div>
<div className="library">
<T2Table
sourceData={volumes?.hits}
totalPages={volumes.hits.length}
sourceData={volumes?.data.hits.hits}
totalPages={volumes?.data.hits.hits.length}
paginationHandlers={{
nextPage: () => {},
previousPage: () => {},
@@ -174,9 +166,13 @@ export const Volumes = (props): ReactElement => {
/>
</div>
</div>
)}
</div>
</section>
) : null}
{isError ? (
<div>An error was encountered while retrieving volumes</div>
) : null}
{isLoading ? <>Loading...</> : null}
</section>
</div>
);
};

View File

@@ -1,32 +1,36 @@
import React, { ReactElement, useCallback, useEffect, useMemo } from "react";
import { searchIssue } from "../../actions/fileops.actions";
import SearchBar from "../Library/SearchBar";
import T2Table from "../shared/T2Table";
import { isEmpty, isUndefined } from "lodash";
import MetadataPanel from "../shared/MetadataPanel";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
export const WantedComics = (props): ReactElement => {
// const wantedComics = useSelector(
// (state: RootState) => state.fileOps.wantedComics,
// );
useEffect(() => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: 25,
// from: 0,
// },
// type: "wanted",
// trigger: "wantedComicsPage"
// },
// ),
// );
}, []);
const {
data: wantedComics,
isSuccess,
isError,
isLoading,
} = useQuery({
queryFn: async () =>
await axios({
url: `${SEARCH_SERVICE_BASE_URI}/searchIssue`,
method: "POST",
data: {
query: {},
pagination: {
size: 25,
from: 0,
},
type: "wanted",
trigger: "wantedComicsPage",
},
}),
queryKey: ["wantedComics"],
enabled: true,
});
const columnData = [
{
header: "Comic Information",
@@ -36,7 +40,10 @@ export const WantedComics = (props): ReactElement => {
id: "comicDetails",
minWidth: 350,
accessorFn: (data) => data,
cell: (value) => <MetadataPanel data={value.getValue()} />,
cell: (value) => {
const row = value.getValue()._source;
return row && <MetadataPanel data={row} />;
},
},
],
},
@@ -45,8 +52,8 @@ export const WantedComics = (props): ReactElement => {
columns: [
{
header: "Files",
accessorKey: "acquisition",
align: "right",
accessorKey: "_source.acquisition",
cell: (props) => {
const {
directconnect: { downloads },
@@ -69,7 +76,7 @@ export const WantedComics = (props): ReactElement => {
{
header: "Download Details",
id: "downloadDetails",
accessorKey: "acquisition",
accessorKey: "_source.acquisition",
cell: (data) => (
<ol>
{data.getValue().directconnect.downloads.map((download, idx) => {
@@ -98,23 +105,23 @@ export const WantedComics = (props): ReactElement => {
* @returns void
*
**/
const nextPage = useCallback((pageIndex: number, pageSize: number) => {
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from: pageSize * pageIndex + 1,
},
type: "wanted",
trigger: "wantedComicsPage",
},
),
);
}, []);
// const nextPage = useCallback((pageIndex: number, pageSize: number) => {
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from: pageSize * pageIndex + 1,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
/**
* Pagination control that fetches the previous x (pageSize) items
@@ -123,55 +130,71 @@ export const WantedComics = (props): ReactElement => {
* @param {number} pageSize
* @returns void
**/
const previousPage = useCallback((pageIndex: number, pageSize: number) => {
let from = 0;
if (pageIndex === 2) {
from = (pageIndex - 1) * pageSize + 2 - 17;
} else {
from = (pageIndex - 1) * pageSize + 2 - 16;
}
dispatch(
searchIssue(
{
query: {},
},
{
pagination: {
size: pageSize,
from,
},
type: "wanted",
trigger: "wantedComicsPage",
},
),
);
}, []);
// const previousPage = useCallback((pageIndex: number, pageSize: number) => {
// let from = 0;
// if (pageIndex === 2) {
// from = (pageIndex - 1) * pageSize + 2 - 17;
// } else {
// from = (pageIndex - 1) * pageSize + 2 - 16;
// }
// dispatch(
// searchIssue(
// {
// query: {},
// },
// {
// pagination: {
// size: pageSize,
// from,
// },
// type: "wanted",
// trigger: "wantedComicsPage",
// },
// ),
// );
// }, []);
return (
<section className="container">
<div className="section">
<div className="header-area">
<h1 className="title">Wanted Comics</h1>
</div>
{!isEmpty(wantedComics) && (
<div className="">
<section className="">
<header className="bg-slate-200 dark:bg-slate-500">
<div className="mx-auto max-w-screen-xl px-2 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">
Wanted Comics
</h1>
<p className="mt-1.5 text-sm text-gray-500 dark:text-white">
Browse through comics you marked as "wanted."
</p>
</div>
</div>
</div>
</header>
{isSuccess ? (
<div>
<div className="library">
<T2Table
sourceData={wantedComics}
totalPages={wantedComics.length}
sourceData={wantedComics?.data.hits.hits}
totalPages={wantedComics?.data.hits.hits.length}
columns={columnData}
paginationHandlers={{
nextPage: nextPage,
previousPage: previousPage,
nextPage: () => {},
previousPage: () => {},
}}
// rowClickHandler={navigateToComicDetail}
/>
{/* pagination controls */}
</div>
</div>
)}
</div>
</section>
) : null}
{isLoading ? <div>Loading...</div> : null}
{isError ? (
<div>An error occurred while retrieving the pull list.</div>
) : null}
</section>
</div>
);
};

View File

@@ -1,8 +1,7 @@
import React, { useEffect, useRef } from "react";
export const Canvas = (data) => {
const { colorHistogramData } = data.data;
console.log(data);
export const Canvas = ({ data }) => {
const { colorHistogramData } = data;
const width = 559;
const height = 200;
const pixelRatio = window.devicePixelRatio;
@@ -10,7 +9,11 @@ export const Canvas = (data) => {
const canvas = useRef(null);
useEffect(() => {
const context = canvas.current.getContext("2d");
const context = canvas.current?.getContext("2d");
if (!context) {
return;
}
context.scale(pixelRatio, pixelRatio);
const guideHeight = 8;
const startY = height - guideHeight;
@@ -46,18 +49,24 @@ export const Canvas = (data) => {
context.stroke();
// Guide
context.strokeStyle = "rgb(" + i + ", " + i + ", " + i + ")";
context.strokeStyle = `rgb(${i}, ${i}, ${i})`;
context.beginPath();
context.moveTo(x, startY);
context.lineTo(x, height);
context.closePath();
context.stroke();
}
});
// Cleanup function
return () => {
// Perform cleanup actions here
};
}, [colorHistogramData, pixelRatio]);
const dw = Math.floor(pixelRatio * width);
const dh = Math.floor(pixelRatio * height);
const style = { width, height };
return <canvas ref={canvas} width={dw} height={dh} style={style} />;
};

View File

@@ -83,24 +83,26 @@ const renderCard = (props: ICardProps): ReactElement => {
case "vertical-2":
return (
<div className="block rounded-md w-fit h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
<div className="block rounded-md w-64 h-fit shadow-md shadow-white-400 bg-gray-200 dark:bg-slate-500">
<img
alt="Home"
src={props.imageUrl}
className="rounded-t-md object-cover"
/>
<div className="mt-2 px-2">
<dl>
<div>
<dd className="text-md text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
</dl>
{props.title ? (
<div className="px-3 pt-3 mb-2">
<dd className="text-sm text-slate-500 dark:text-black">
{props.title}
</dd>
</div>
) : null}
{props.hasDetails && <>{props.children}</>}
</div>
{props.hasDetails ? (
<div className="px-2">
<>{props.children}</>
</div>
) : null}
</div>
);

View File

@@ -0,0 +1,128 @@
import React, { ChangeEventHandler, useRef, useState } from "react";
import { format, isValid, parse, parseISO } from "date-fns";
import FocusTrap from "focus-trap-react";
import { DayPicker, SelectSingleEventHandler } from "react-day-picker";
import { usePopper } from "react-popper";
export const DatePickerDialog = (props) => {
console.log(props);
const { setter, apiAction } = props;
const [selected, setSelected] = useState<Date>();
const [isPopperOpen, setIsPopperOpen] = useState(false);
const popperRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const customStyles = {
container: {
// Style for the entire container
border: "1px solid #ccc",
borderRadius: "4px",
padding: "10px",
width: "300px",
},
day: {
// Style for individual days
padding: "5px",
margin: "2px",
},
selected: {
// Style for selected days
backgroundColor: "#007bff",
color: "#fff",
},
disabled: {
// Style for disabled days
color: "#ccc",
},
today: {
// Style for today's date
backgroundColor: "#f0f0f0",
},
dayWrapper: {
// Style for the wrapper around each day
display: "inline-block",
},
};
const popper = usePopper(popperRef.current, popperElement, {
placement: "bottom-start",
});
const closePopper = () => {
setIsPopperOpen(false);
buttonRef?.current?.focus();
};
const handleButtonClick = () => {
setIsPopperOpen(true);
};
const handleDaySelect: SelectSingleEventHandler = (date) => {
setSelected(date);
if (date) {
setter(format(date, "M-dd-yyyy"));
apiAction();
closePopper();
} else {
setter("");
}
};
return (
<div>
<div ref={popperRef}>
<button
ref={buttonRef}
type="button"
aria-label="Pick a date"
onClick={handleButtonClick}
className="flex space-x-1 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
>
<span className="pr-1 pt-0.5 h-8">
<span className="icon-[solar--calendar-date-bold-duotone] w-6 h-6"></span>
</span>
Pick a date
</button>
</div>
{isPopperOpen && (
<FocusTrap
active
focusTrapOptions={{
initialFocus: false,
allowOutsideClick: true,
clickOutsideDeactivates: true,
onDeactivate: closePopper,
fallbackFocus: buttonRef.current || undefined,
}}
>
<div
tabIndex={-1}
style={popper.styles.popper}
className="bg-slate-200 mt-3 p-2 rounded-lg z-50"
{...popper.attributes.popper}
ref={setPopperElement}
role="dialog"
aria-label="DayPicker calendar"
>
<DayPicker
initialFocus={isPopperOpen}
mode="single"
defaultMonth={selected}
selected={selected}
onSelect={handleDaySelect}
styles={customStyles}
/>
</div>
</FocusTrap>
)}
</div>
);
};
export default DatePickerDialog;

View File

@@ -64,7 +64,7 @@ export const DnD = (data) => {
className="mt-2 mb-2"
onClick={(e) => data.onClickHandler(url)}
>
<div className="box p-2 pl-3 control-palette">
<div className="box p-2 control-palette">
<span className="tag is-warning mr-2">{index}</span>
<span className="icon is-small mr-2">
<i className="fa-solid fa-vial"></i>

View File

@@ -8,7 +8,6 @@ export function Grid({ children, columns }) {
gridTemplateColumns: `repeat(${columns}, 200px)`,
columnGap: 1,
gridGap: 10,
padding: 10,
}}
>
{children}

View File

@@ -1,19 +1,32 @@
import React, { ReactElement } from "react";
import { Link } from "react-router-dom";
type IHeaderProps = {
headerContent: string;
subHeaderContent: string;
iconClassNames: string;
link?: string;
};
export const Header = (props: IHeaderProps): ReactElement => {
return (
<>
<h4 className="title is-4">
<i className={props.iconClassNames}></i> {props.headerContent}
</h4>
<p className="subtitle is-7">{props.subHeaderContent}</p>
</>
<div className="mt-7">
<div className="">
{props.link ? (
<Link to={props.link}>
<span className="text-xl">
<span className="underline">
{props.headerContent}{" "}
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
</span>
</span>
</Link>
) : (
<div className="text-xl">{props.headerContent}</div>
)}
<p className="">{props.subHeaderContent}</p>
</div>
</div>
);
};

View File

@@ -16,6 +16,7 @@ interface IMetadatPanelProps {
containerStyle: any;
}
export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
console.log(props);
const {
rawFileDetails,
inferredMetadata,
@@ -78,6 +79,15 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
{prettyBytes(rawFileDetails.fileSize)}
</span>
</span>
{/* Uncompressed version available? */}
{rawFileDetails.archive?.uncompressed && (
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
<span className="pr-1 pt-1">
<i className="icon-[solar--bookmark-bold-duotone] w-3.5 h-3.5"></i>
</span>
</span>
)}
</dd>
</dl>
),
@@ -105,7 +115,6 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
</span>
</span>
</dd>
<dd className="is-size-7">
<span>
{ellipsize(
@@ -118,42 +127,13 @@ export const MetadataPanel = (props: IMetadatPanelProps): ReactElement => {
)}
</span>
</dd>
<dd className="is-size-7 mt-2">
<div className="field is-grouped is-grouped-multiline">
<div className="control">
<span className="tags">
<span
className="tag is-success is-light has-text-weight-semibold"
style={props.tagsStyle}
>
{comicvine.volumeInformation.start_year}
</span>
<span
className="tag is-success is-light"
style={props.tagsStyle}
>
{comicvine.volumeInformation.count_of_issues}
</span>
</span>
</div>
<div className="control">
<div className="tags has-addons">
<span
className="tag is-primary is-light"
style={props.tagsStyle}
>
ComicVine ID
</span>
<span
className="tag is-info is-light"
style={props.tagsStyle}
>
{comicvine.id}
</span>
</div>
</div>
</div>
<span className="my-3 mx-2">
{comicvine.volumeInformation.start_year}
</span>
{comicvine.volumeInformation.count_of_issues}
ComicVine ID
{comicvine.id}
</dd>
</dl>
),

View File

@@ -52,12 +52,12 @@ export const Navbar2 = (): ReactElement => {
</li>
<li>
<a
<Link
to="/volumes"
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
href="/"
>
Volumes
</a>
</Link>
</li>
<li>
@@ -68,6 +68,14 @@ export const Navbar2 = (): ReactElement => {
Downloads
</a>
</li>
<li>
<Link
className="text-gray-500 transition hover:text-gray-500/75 dark:text-white dark:hover:text-white/75"
to="/search"
>
Comicvine Search
</Link>
</li>
</ul>
</nav>

View File

@@ -1,7 +1,5 @@
import React, { ReactElement, useMemo, useState } from "react";
import PropTypes from "prop-types";
import SearchBar from "../Library/SearchBar";
import { Link } from "react-router-dom";
import {
ColumnDef,
flexRender,
@@ -72,10 +70,9 @@ export const T2Table = (tableOptions): ReactElement => {
return (
<div className="container max-w-fit mx-14">
<div>
{/* Search bar */}
<div className="flex flex-row gap-2 justify-between mt-5">
<SearchBar />
<div className="flex flex-row gap-2 justify-between mt-7">
{/* Search bar */}
{tableOptions.children}
{/* pagination controls */}
<div>
Page {pageIndex} of {Math.ceil(totalPages / pageSize)}
@@ -154,5 +151,6 @@ T2Table.propTypes = {
previousPage: PropTypes.func,
}),
rowClickHandler: PropTypes.func,
children: PropTypes.any,
};
export default T2Table;

View File

@@ -1,48 +0,0 @@
export const comicModel = {
name: "",
type: "",
import: {
isImported: false,
},
userAddedMetadata: {
tags: [],
},
comicStructure: {
cover: {
thumb: "http://thumb",
medium: "http://medium",
large: "http://large",
},
collection: {
publishDate: "",
type: "", // issue, series, trade paperback
metadata: {
publisher: "",
issueNumber: "",
description: "",
synopsis: "",
team: {},
},
},
},
sourcedMetadata: {
comicvine: {},
shortboxed: {},
gcd: {},
},
rawFileDetails: {
fileName: "",
path: "",
extension: "",
},
acquisition: {
release: {},
torrent: {
magnet: "",
tracker: "",
status: "",
},
usenet: {},
},
};

View File

@@ -8,7 +8,6 @@ export const hostURIBuilder = (options: Record<string, string>): string => {
options.apiPath
);
};
console.log(import.meta);
export const CORS_PROXY_SERVER_URI = hostURIBuilder({
protocol: "http",

View File

@@ -1 +0,0 @@
export const SUPPORTED_COMIC_ARCHIVES = [".cbz", ".cbr"];

View File

@@ -0,0 +1,16 @@
import React, { useEffect } from "react";
import BlazeSlider from "blaze-slider";
export const useBlazeSlider = (config) => {
const sliderRef = React.useRef();
const elRef = React.useRef();
useEffect(() => {
// if not already initialized
if (!sliderRef.current) {
sliderRef.current = new BlazeSlider(elRef.current, config);
}
}, []);
return elRef;
};

View File

@@ -11,8 +11,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import Import from "./components/Import/Import";
import Dashboard from "./components/Dashboard/Dashboard";
import Search from "./components/Search/Search";
import TabulatedContentContainer from "./components/Library/TabulatedContentContainer";
import { ComicDetailContainer } from "./components/ComicDetail/ComicDetailContainer";
import Volumes from "./components/Volumes/Volumes";
import WantedComics from "./components/WantedComics/WantedComics";
const queryClient = new QueryClient();
@@ -22,6 +25,7 @@ const router = createBrowserRouter([
element: <App />,
errorElement: <ErrorPage />,
children: [
{ path: "/", element: <Dashboard /> },
{ path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> },
{
@@ -33,6 +37,9 @@ const router = createBrowserRouter([
element: <ComicDetailContainer />,
},
{ path: "import", element: <Import path={"./comics"} /> },
{ path: "search", element: <Search /> },
{ path: "volumes", element: <Volumes /> },
{ path: "wanted", element: <WantedComics /> },
],
},
]);

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { isEmpty, isNil, isUndefined } from "lodash";
import { isNil } from "lodash";
import io from "socket.io-client";
import { SOCKET_BASE_URI } from "../constants/endpoints";
import { produce } from "immer";
@@ -28,6 +28,11 @@ export const useStore = create((set, get) => ({
// Socket.io state
socketIOInstance: {},
// ComicVine Scraping status
comicvine: {
scrapingStatus: "",
},
// Import job queue and associated statuses
importJobQueue: {
successfulJobCount: 0,
@@ -153,6 +158,16 @@ socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
});
// ComicVine Scraping status
socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
setState((state) => ({
comicvine: {
...state.comicvine,
scrapingStatus: data.message,
},
}));
});
/**
* Method to init AirDC++ Socket with supplied settings
* @param configuration - credentials, and hostname details to init AirDC++ connection
@@ -254,5 +269,3 @@ if (!isNil(directConnectConfiguration)) {
} else {
console.log("problem");
}
console.log("connected?", getState());

10886
yarn.lock Normal file

File diff suppressed because it is too large Load Diff