Compare commits
8 Commits
react-19-u
...
qbittorren
| Author | SHA1 | Date | |
|---|---|---|---|
| c6f719e78b | |||
| b4d1b678b1 | |||
| aa3192bc1a | |||
| 56ddfbd16e | |||
| 173735da45 | |||
| f4408cd493 | |||
| 41a9428729 | |||
| e1da6ddcef |
18
Dockerfile
18
Dockerfile
@@ -3,21 +3,17 @@ LABEL maintainer="Rishi Ghan <rishi.ghan@gmail.com>"
|
||||
|
||||
WORKDIR /threetwo
|
||||
|
||||
# Copy package.json and lock file first to leverage Docker cache
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
COPY nodemon.json ./
|
||||
COPY jsdoc.json ./
|
||||
|
||||
# RUN apt-get update && apt-get install -y git python3 build-essential autoconf automake g++ libpng-dev make
|
||||
RUN apk --no-cache add g++ make libpng-dev git python3 libc6-compat autoconf automake libjpeg-turbo-dev libpng-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||
RUN yarn --ignore-engines
|
||||
|
||||
# Install build dependencies necessary for native modules
|
||||
RUN apk --no-cache add g++ make libpng-dev git python3 autoconf automake libjpeg-turbo-dev mesa-dev mesa libxi build-base gcc libtool nasm
|
||||
|
||||
# Install node modules
|
||||
RUN yarn install --ignore-engines
|
||||
# Explicitly install sass
|
||||
RUN yarn add -D sass
|
||||
# Copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
# Use yarn start if you want to stick with yarn, or change to npm start if you prefer npm
|
||||
ENTRYPOINT [ "yarn", "start" ]
|
||||
ENTRYPOINT [ "npm", "start" ]
|
||||
30
package.json
30
package.json
@@ -18,8 +18,6 @@
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
"@floating-ui/react": "^0.26.12",
|
||||
"@floating-ui/react-dom": "^2.0.8",
|
||||
"@fortawesome/fontawesome-free": "^6.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -29,23 +27,20 @@
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"airdcpp-apisocket": "^2.5.0-beta.2",
|
||||
"axios": "^1.7.4",
|
||||
"axios": "^1.3.4",
|
||||
"axios-cache-interceptor": "^1.0.1",
|
||||
"axios-rate-limit": "^1.3.0",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"date-fns": "^2.28.0",
|
||||
"dayjs": "^1.10.6",
|
||||
"ellipsize": "^0.5.1",
|
||||
"express": "^4.20.0",
|
||||
"express": "^4.17.1",
|
||||
"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",
|
||||
"i18next": "^23.11.1",
|
||||
"i18next-browser-languagedetector": "^7.2.1",
|
||||
"i18next-http-backend": "^2.5.0",
|
||||
"immer": "^10.0.3",
|
||||
"jsdoc": "^3.6.10",
|
||||
"keen-slider": "^6.8.6",
|
||||
@@ -53,27 +48,28 @@
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.10.5",
|
||||
"react": "^19.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-collapsible": "^2.9.0",
|
||||
"react-comic-viewer": "^0.4.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
"react-dom": "^19.0.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-i18next": "^14.1.0",
|
||||
"react-loader-spinner": "^4.0.0",
|
||||
"react-modal": "^3.15.1",
|
||||
"react-router": "^7.1.5",
|
||||
"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-textarea-autosize": "^8.3.4",
|
||||
"react-toastify": "^10.0.5",
|
||||
"reapop": "^4.2.1",
|
||||
"socket.io-client": "^4.3.2",
|
||||
"styled-components": "^6.1.0",
|
||||
"threetwo-ui-typings": "^1.0.14",
|
||||
"vite": "^5.4.12",
|
||||
"vite": "^5.0.5",
|
||||
"vite-plugin-html": "^3.2.0",
|
||||
"websocket": "^1.0.34",
|
||||
"zustand": "^4.4.6"
|
||||
@@ -97,8 +93,8 @@
|
||||
"@types/jest": "^26.0.20",
|
||||
"@types/lodash": "^4.14.168",
|
||||
"@types/node": "^14.14.34",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react": "^18.0.28",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -112,7 +108,7 @@
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"eslint-plugin-storybook": "^0.6.13",
|
||||
"express": "^4.20.0",
|
||||
"express": "^4.17.1",
|
||||
"install": "^0.13.0",
|
||||
"jest": "^29.6.3",
|
||||
"nodemon": "^3.0.1",
|
||||
@@ -121,7 +117,7 @@
|
||||
"prettier": "^2.2.1",
|
||||
"react-refresh": "^0.14.0",
|
||||
"rimraf": "^4.1.3",
|
||||
"sass": "^1.77.0",
|
||||
"sass": "^1.69.5",
|
||||
"storybook": "^7.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tui-jsdoc-template": "^1.2.2",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Navbar2 } from "./shared/Navbar2";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../assets/scss/App.scss";
|
||||
|
||||
export const App = (): ReactElement => {
|
||||
@@ -9,7 +8,6 @@ export const App = (): ReactElement => {
|
||||
<>
|
||||
<Navbar2 />
|
||||
<Outlet />
|
||||
<ToastContainer stacked hideProgressBar />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, ReactElement, useEffect, useState } from "react";
|
||||
import { getBundlesForComic, sleep } from "../../actions/airdcpp.actions";
|
||||
import { SearchQuery, PriorityEnum, SearchResponse } from "threetwo-ui-typings";
|
||||
import { RootState, SearchInstance } from "threetwo-ui-typings";
|
||||
import ellipsize from "ellipsize";
|
||||
@@ -9,7 +10,6 @@ import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
|
||||
interface IAcquisitionPanelProps {
|
||||
query: any;
|
||||
@@ -21,9 +21,17 @@ interface IAcquisitionPanelProps {
|
||||
export const AcquisitionPanel = (
|
||||
props: IAcquisitionPanelProps,
|
||||
): ReactElement => {
|
||||
const { socketIOInstance } = useStore(
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPDownloadTick,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -32,56 +40,20 @@ export const AcquisitionPanel = (
|
||||
hub_urls: string[] | undefined | null;
|
||||
priority: PriorityEnum;
|
||||
}
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
// Add other properties as needed
|
||||
slots: any;
|
||||
type: any;
|
||||
users: any;
|
||||
name: string;
|
||||
dupe: Boolean;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const handleSearch = (searchQuery) => {
|
||||
// Use the already connected socket instance to emit events
|
||||
socketIOInstance.emit("initiateSearch", searchQuery);
|
||||
};
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: settings?.data.directConnect?.client?.host,
|
||||
},
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
});
|
||||
const { comicObjectId } = props;
|
||||
const issueName = props.query.issue.name || "";
|
||||
const sanitizedIssueName = issueName.replace(/[^a-zA-Z0-9 ]/g, " ");
|
||||
|
||||
const [dcppQuery, setDcppQuery] = useState({});
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState<
|
||||
SearchResult[]
|
||||
>([]);
|
||||
const [airDCPPSearchResults, setAirDCPPSearchResults] = useState([]);
|
||||
const [airDCPPSearchStatus, setAirDCPPSearchStatus] = useState(false);
|
||||
const [airDCPPSearchInstance, setAirDCPPSearchInstance] = useState({});
|
||||
const [airDCPPSearchInfo, setAirDCPPSearchInfo] = useState({});
|
||||
@@ -89,7 +61,7 @@ export const AcquisitionPanel = (
|
||||
|
||||
// Construct a AirDC++ query based on metadata inferred, upon component mount
|
||||
// Pre-populate the search input with the search string, so that
|
||||
// all the user has to do is hit "Search AirDC++" to perform a search
|
||||
// All the user has to do is hit "Search AirDC++"
|
||||
useEffect(() => {
|
||||
// AirDC++ search query
|
||||
const dcppSearchQuery = {
|
||||
@@ -97,7 +69,7 @@ export const AcquisitionPanel = (
|
||||
pattern: `${sanitizedIssueName.replace(/#/g, "")}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: map(hubs?.data, (item) => item.value),
|
||||
hub_urls: map(hubs, (item) => item.value),
|
||||
priority: 5,
|
||||
};
|
||||
setDcppQuery(dcppSearchQuery);
|
||||
@@ -108,48 +80,79 @@ export const AcquisitionPanel = (
|
||||
* @param {SearchData} data - a SearchData query
|
||||
* @param {any} ADCPPSocket - an intialized AirDC++ socket instance
|
||||
*/
|
||||
const search = async (searchData: any) => {
|
||||
setAirDCPPSearchResults([]);
|
||||
socketIOInstance.emit("call", "socket.search", {
|
||||
query: searchData,
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
// hostname: `192.168.1.119:5600`,
|
||||
hostname: `127.0.0.1:5600`,
|
||||
username: `user`,
|
||||
password: `pass`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
socketIOInstance.on("searchResultAdded", ({ result }: any) => {
|
||||
setAirDCPPSearchResults((previousState) => {
|
||||
const exists = previousState.some((item) => result.id === item.id);
|
||||
if (!exists) {
|
||||
return [...previousState, result];
|
||||
const search = async (data: SearchData, ADCPPSocket: any) => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket();
|
||||
}
|
||||
return previousState;
|
||||
});
|
||||
});
|
||||
const instance: SearchInstance = await ADCPPSocket.post("search");
|
||||
setAirDCPPSearchStatus(true);
|
||||
|
||||
socketIOInstance.on("searchResultUpdated", ({ result }: any) => {
|
||||
// ...update properties of the existing result in the UI
|
||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||
(bundle) => bundle.id === result.id,
|
||||
);
|
||||
const updatedState = [...airDCPPSearchResults];
|
||||
if (!isNil(difference(updatedState[bundleToUpdateIndex], result))) {
|
||||
updatedState[bundleToUpdateIndex] = result;
|
||||
// We want to get notified about every new result in order to make the user experience better
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_added",
|
||||
async (groupedResult) => {
|
||||
// ...add the received result in the UI
|
||||
// (it's probably a good idea to have some kind of throttling for the UI updates as there can be thousands of results)
|
||||
setAirDCPPSearchResults((state) => [...state, groupedResult]);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We also want to update the existing items in our list when new hits arrive for the previously listed files/directories
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_result_updated",
|
||||
async (groupedResult) => {
|
||||
// ...update properties of the existing result in the UI
|
||||
const bundleToUpdateIndex = airDCPPSearchResults?.findIndex(
|
||||
(bundle) => bundle.result.id === groupedResult.result.id,
|
||||
);
|
||||
const updatedState = [...airDCPPSearchResults];
|
||||
if (
|
||||
!isNil(difference(updatedState[bundleToUpdateIndex], groupedResult))
|
||||
) {
|
||||
updatedState[bundleToUpdateIndex] = groupedResult;
|
||||
}
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
|
||||
// We need to show something to the user in case the search won't yield any results so that he won't be waiting forever)
|
||||
// Wait for 5 seconds for any results to arrive after the searches were sent to the hubs
|
||||
await ADCPPSocket.addListener(
|
||||
`search`,
|
||||
"search_hub_searches_sent",
|
||||
async (searchInfo) => {
|
||||
await sleep(5000);
|
||||
|
||||
// Check the number of received results (in real use cases we should know that even without calling the API)
|
||||
const currentInstance = await ADCPPSocket.get(
|
||||
`search/${instance.id}`,
|
||||
);
|
||||
setAirDCPPSearchInstance(currentInstance);
|
||||
setAirDCPPSearchInfo(searchInfo);
|
||||
if (currentInstance.result_count === 0) {
|
||||
// ...nothing was received, show an informative message to the user
|
||||
console.log("No more search results.");
|
||||
}
|
||||
|
||||
// The search can now be considered to be "complete"
|
||||
// If there's an "in progress" indicator in the UI, that could also be disabled here
|
||||
setAirDCPPSearchInstance(instance);
|
||||
setAirDCPPSearchStatus(false);
|
||||
},
|
||||
instance.id,
|
||||
);
|
||||
// Finally, perform the actual search
|
||||
await ADCPPSocket.post(`search/${instance.id}/hub_search`, data);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw error;
|
||||
}
|
||||
setAirDCPPSearchResults((state) => [...state, ...updatedState]);
|
||||
});
|
||||
|
||||
socketIOInstance.on("searchInitiated", (data) => {
|
||||
setAirDCPPSearchInstance(data.instance);
|
||||
});
|
||||
socketIOInstance.on("searchesSent", (data) => {
|
||||
setAirDCPPSearchInfo(data.searchInfo);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Method to download a bundle associated with a search result from AirDC++
|
||||
@@ -159,7 +162,7 @@ export const AcquisitionPanel = (
|
||||
* @param {String} name - description
|
||||
* @param {Number} size - description
|
||||
* @param {any} type - description
|
||||
* @param {any} config - description
|
||||
* @param {any} ADCPPSocket - description
|
||||
* @returns {void} - description
|
||||
*/
|
||||
const download = async (
|
||||
@@ -169,22 +172,50 @@ export const AcquisitionPanel = (
|
||||
name: String,
|
||||
size: Number,
|
||||
type: any,
|
||||
config: any,
|
||||
): Promise<void> => {
|
||||
socketIOInstance.emit(
|
||||
"call",
|
||||
"socket.download",
|
||||
{
|
||||
searchInstanceId,
|
||||
resultId,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
config,
|
||||
},
|
||||
(data: any) => console.log(data),
|
||||
);
|
||||
ADCPPSocket: any,
|
||||
): void => {
|
||||
try {
|
||||
if (!ADCPPSocket.isConnected()) {
|
||||
await ADCPPSocket.connect();
|
||||
}
|
||||
let bundleDBImportResult = {};
|
||||
const downloadResult = await ADCPPSocket.post(
|
||||
`search/${searchInstanceId}/results/${resultId}/download`,
|
||||
);
|
||||
|
||||
if (!isNil(downloadResult)) {
|
||||
bundleDBImportResult = await axios({
|
||||
method: "POST",
|
||||
url: `http://localhost:3000/api/library/applyAirDCPPDownloadMetadata`,
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
bundleId: downloadResult.bundle_info.id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
},
|
||||
});
|
||||
console.log(bundleDBImportResult?.data);
|
||||
queryClient.invalidateQueries({ queryKey: ["comicBookMetadata"] });
|
||||
|
||||
// dispatch({
|
||||
// type: AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
// downloadResult,
|
||||
// bundleDBImportResult,
|
||||
// });
|
||||
//
|
||||
// dispatch({
|
||||
// type: IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
// comicBookDetail: bundleDBImportResult.data,
|
||||
// IMS_inProgress: false,
|
||||
// });
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const getDCPPSearchResults = async (searchQuery) => {
|
||||
const manualQuery = {
|
||||
@@ -192,17 +223,17 @@ export const AcquisitionPanel = (
|
||||
pattern: `${searchQuery.issueName}`,
|
||||
extensions: ["cbz", "cbr", "cb7"],
|
||||
},
|
||||
hub_urls: [hubs?.data[0].hub_url],
|
||||
hub_urls: map(hubs, (hub) => hub.hub_url),
|
||||
priority: 5,
|
||||
};
|
||||
|
||||
search(manualQuery);
|
||||
search(manualQuery, airDCPPSocketInstance);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-5 mb-3">
|
||||
{!isEmpty(hubs?.data) ? (
|
||||
<div className="mt-5">
|
||||
{!isEmpty(airDCPPSocketInstance) ? (
|
||||
<Form
|
||||
onSubmit={getDCPPSearchResults}
|
||||
initialValues={{
|
||||
@@ -247,24 +278,16 @@ export const AcquisitionPanel = (
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<article
|
||||
role="alert"
|
||||
className="mt-4 rounded-lg text-sm 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"
|
||||
>
|
||||
No AirDC++ hub configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Hubs</code>.
|
||||
</article>
|
||||
<div className="">
|
||||
<article className="">
|
||||
<div className="">
|
||||
AirDC++ is not configured. Please configure it in{" "}
|
||||
<code>Settings > AirDC++ > Connection</code>.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* configured hub */}
|
||||
{!isEmpty(hubs?.data) && (
|
||||
<span className="inline-flex items-center bg-green-50 text-slate-800 text-xs font-medium px-2.5 py-0.5 rounded-md dark:text-slate-900 dark:bg-green-300">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--server-2-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
{hubs && hubs?.data[0].hub_url}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* AirDC++ search instance details */}
|
||||
{!isNil(airDCPPSearchInstance) &&
|
||||
@@ -275,7 +298,7 @@ export const AcquisitionPanel = (
|
||||
<dl>
|
||||
<dt>
|
||||
<div className="mb-1">
|
||||
{hubs?.data.map((value, idx: string) => (
|
||||
{hubs.map((value, idx) => (
|
||||
<span className="tag is-warning" key={idx}>
|
||||
{value.identity.name}
|
||||
</span>
|
||||
@@ -314,7 +337,7 @@ export const AcquisitionPanel = (
|
||||
)}
|
||||
|
||||
{/* AirDC++ results */}
|
||||
<div className="">
|
||||
<div className="columns">
|
||||
{!isNil(airDCPPSearchResults) && !isEmpty(airDCPPSearchResults) ? (
|
||||
<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">
|
||||
@@ -335,121 +358,113 @@ export const AcquisitionPanel = (
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-500">
|
||||
{map(
|
||||
airDCPPSearchResults,
|
||||
({ dupe, type, name, id, slots, users, size }, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(dupe)
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "w-fit text-sm"
|
||||
}
|
||||
>
|
||||
<td className="whitespace-nowrap px-3 py-3 text-gray-700 dark:text-slate-300">
|
||||
<p className="mb-2">
|
||||
{type.id === "directory" ? (
|
||||
<i className="fas fa-folder"></i>
|
||||
) : null}
|
||||
{ellipsize(name, 70)}
|
||||
</p>
|
||||
{map(airDCPPSearchResults, ({ result }, idx) => {
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={
|
||||
!isNil(result.dupe)
|
||||
? "bg-gray-100 dark:bg-gray-700"
|
||||
: "w-fit text-sm"
|
||||
}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
{ellipsize(result.name, 70)}
|
||||
</p>
|
||||
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-row gap-2">
|
||||
{!isNil(dupe) ? (
|
||||
<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}
|
||||
|
||||
{/* Nicks */}
|
||||
<dl>
|
||||
<dd>
|
||||
<div className="inline-flex flex-row gap-2">
|
||||
{!isNil(result.dupe) ? (
|
||||
<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>
|
||||
<i className="icon-[solar--copy-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{users.user.nicks}
|
||||
Dupe
|
||||
</span>
|
||||
</span>
|
||||
{/* Flags */}
|
||||
{users.user.flags.map((flag, idx) => (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{flag}
|
||||
</span>
|
||||
{/* 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="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>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
{/* 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">
|
||||
{type.str}
|
||||
</span>
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{flag}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</td>
|
||||
<td>
|
||||
{/* 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>
|
||||
</td>
|
||||
<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="text-md text-slate-500 dark:text-slate-900">
|
||||
{slots.total} slots; {slots.free} free
|
||||
</span>
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{result.type.str}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
<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={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
id,
|
||||
comicObjectId,
|
||||
name,
|
||||
size,
|
||||
type,
|
||||
{
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<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="text-md text-slate-500 dark:text-slate-900">
|
||||
{result.slots.total} slots; {result.slots.free} free
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2">
|
||||
<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={() =>
|
||||
download(
|
||||
airDCPPSearchInstance.id,
|
||||
result.id,
|
||||
comicObjectId,
|
||||
result.name,
|
||||
result.size,
|
||||
result.type,
|
||||
airDCPPSocketInstance,
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { fetchMetronResource } from "../../../actions/metron.actions";
|
||||
import Creatable from "react-select/creatable";
|
||||
import { withAsyncPaginate } from "react-select-async-paginate";
|
||||
const CreatableAsyncPaginate = withAsyncPaginate(Creatable);
|
||||
|
||||
interface AsyncSelectPaginateProps {
|
||||
metronResource: string;
|
||||
placeholder?: string;
|
||||
value?: object;
|
||||
onChange?(...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactElement => {
|
||||
export const AsyncSelectPaginate = (props): ReactElement => {
|
||||
const [value, setValue] = useState(null);
|
||||
const [isAddingInProgress, setIsAddingInProgress] = useState(false);
|
||||
|
||||
@@ -44,4 +38,11 @@ export const AsyncSelectPaginate = (props: AsyncSelectPaginateProps): ReactEleme
|
||||
);
|
||||
};
|
||||
|
||||
AsyncSelectPaginate.propTypes = {
|
||||
metronResource: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
value: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default AsyncSelectPaginate;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, ReactElement, useCallback } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import { ComicVineMatchPanel } from "./ComicVineMatchPanel";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ComicDetail } from "../ComicDetail/ComicDetail";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LIBRARY_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import dayjs from "dayjs";
|
||||
import { isEmpty, isUndefined } from "lodash";
|
||||
import Card from "../shared/Carda";
|
||||
import { convert } from "html-to-text";
|
||||
|
||||
interface ComicVineDetailsProps {
|
||||
updatedAt?: string;
|
||||
data?: {
|
||||
name?: string;
|
||||
number?: string;
|
||||
resource_type?: string;
|
||||
id?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement => {
|
||||
export const ComicVineDetails = (props): ReactElement => {
|
||||
const { data, updatedAt } = props;
|
||||
return (
|
||||
<div className="text-slate-500 dark:text-gray-400">
|
||||
@@ -116,3 +107,13 @@ export const ComicVineDetails = (props: ComicVineDetailsProps): ReactElement =>
|
||||
};
|
||||
|
||||
export default ComicVineDetails;
|
||||
|
||||
ComicVineDetails.propTypes = {
|
||||
updatedAt: PropTypes.string,
|
||||
data: PropTypes.shape({
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.string,
|
||||
resource_type: PropTypes.string,
|
||||
id: PropTypes.number,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useContext, ReactElement, useState } from "react";
|
||||
import { RootState } from "threetwo-ui-typings";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { isEmpty, map } from "lodash";
|
||||
import { AirDCPPBundles } from "./AirDCPPBundles";
|
||||
import { TorrentDownloads } from "./TorrentDownloads";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
QBITTORRENT_SERVICE_BASE_URI,
|
||||
TORRENT_JOB_SERVICE_BASE_URI,
|
||||
SOCKET_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import { useStore } from "../../store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useParams } from "react-router";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
interface IDownloadsPanelProps {
|
||||
key: number;
|
||||
@@ -23,11 +22,13 @@ export const DownloadsPanel = (
|
||||
props: IDownloadsPanelProps,
|
||||
): ReactElement | null => {
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
const [bundles, setBundles] = useState([]);
|
||||
const [infoHashes, setInfoHashes] = useState<string[]>([]);
|
||||
const [torrentDetails, setTorrentDetails] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("directconnect");
|
||||
const { socketIOInstance } = useStore(
|
||||
const [activeTab, setActiveTab] = useState("torrents");
|
||||
const { airDCPPSocketInstance, socketIOInstance } = useStore(
|
||||
useShallow((state: any) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
socketIOInstance: state.socketIOInstance,
|
||||
})),
|
||||
);
|
||||
@@ -43,30 +44,34 @@ export const DownloadsPanel = (
|
||||
.filter((item) => item !== undefined);
|
||||
setTorrentDetails(torrents);
|
||||
});
|
||||
|
||||
/**
|
||||
* Query to fetch AirDC++ download bundles for a given comic resource Id
|
||||
* @param {string} {comicObjectId} - A mongo id that identifies a comic document
|
||||
*/
|
||||
const { data: bundles } = useQuery({
|
||||
// Fetch the downloaded files and currently-downloading file(s) from AirDC++
|
||||
const { data: comicObject, isSuccess } = useQuery({
|
||||
queryKey: ["bundles"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getBundles`,
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
data: {
|
||||
comicObjectId,
|
||||
config: {
|
||||
protocol: `ws`,
|
||||
hostname: `192.168.1.119:5600`,
|
||||
username: `admin`,
|
||||
password: `password`,
|
||||
},
|
||||
id: `${comicObjectId}`,
|
||||
},
|
||||
}),
|
||||
enabled: activeTab !== "" && activeTab === "directconnect",
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Call the scheduled job for fetching torrent data
|
||||
// triggered by the active tab been set to "torrents"
|
||||
const { data: torrentData } = useQuery({
|
||||
@@ -79,11 +84,20 @@ export const DownloadsPanel = (
|
||||
},
|
||||
}),
|
||||
queryKey: [activeTab],
|
||||
enabled: activeTab !== "" && activeTab === "torrents",
|
||||
});
|
||||
console.log(bundles);
|
||||
|
||||
useEffect(() => {
|
||||
getBundles(comicObject).then((result) => {
|
||||
setBundles(result);
|
||||
});
|
||||
}, [comicObject]);
|
||||
|
||||
return (
|
||||
<div className="columns is-multiline">
|
||||
{!isEmpty(airDCPPSocketInstance) &&
|
||||
!isEmpty(bundles) &&
|
||||
activeTab === "directconnect" && <AirDCPPBundles data={bundles} />}
|
||||
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="Download Type" className="sr-only">
|
||||
@@ -126,14 +140,7 @@ export const DownloadsPanel = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "torrents" ? (
|
||||
<TorrentDownloads data={torrentDetails} />
|
||||
) : null}
|
||||
{!isNil(bundles?.data) && bundles?.data.length !== 0 ? (
|
||||
<AirDCPPBundles data={bundles.data} />
|
||||
) : (
|
||||
"nutin"
|
||||
)}
|
||||
{activeTab === "torrents" && <TorrentDownloads data={torrentDetails} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { isEmpty } from "lodash";
|
||||
import { format, parseISO } from "date-fns";
|
||||
|
||||
interface RawFileDetailsProps {
|
||||
data?: {
|
||||
rawFileDetails?: {
|
||||
containedIn?: string;
|
||||
name?: string;
|
||||
fileSize?: number;
|
||||
path?: string;
|
||||
extension?: string;
|
||||
mimeType?: string;
|
||||
cover?: {
|
||||
filePath?: string;
|
||||
};
|
||||
};
|
||||
inferredMetadata?: {
|
||||
issue?: {
|
||||
year?: string;
|
||||
name?: string;
|
||||
number?: number;
|
||||
subtitle?: string;
|
||||
};
|
||||
};
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
export const RawFileDetails = (props): ReactElement => {
|
||||
const { rawFileDetails, inferredMetadata, created_at, updated_at } =
|
||||
props.data;
|
||||
return (
|
||||
@@ -124,3 +98,30 @@ export const RawFileDetails = (props: RawFileDetailsProps): ReactElement => {
|
||||
};
|
||||
|
||||
export default RawFileDetails;
|
||||
|
||||
RawFileDetails.propTypes = {
|
||||
data: PropTypes.shape({
|
||||
rawFileDetails: PropTypes.shape({
|
||||
containedIn: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
fileSize: PropTypes.number,
|
||||
path: PropTypes.string,
|
||||
extension: PropTypes.string,
|
||||
mimeType: PropTypes.string,
|
||||
cover: PropTypes.shape({
|
||||
filePath: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
inferredMetadata: PropTypes.shape({
|
||||
issue: PropTypes.shape({
|
||||
year: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
number: PropTypes.number,
|
||||
subtitle: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
created_at: PropTypes.string,
|
||||
updated_at: PropTypes.string,
|
||||
}),
|
||||
children: PropTypes.any,
|
||||
};
|
||||
|
||||
@@ -23,17 +23,15 @@ export const TorrentSearchPanel = (props) => {
|
||||
url: `${PROWLARR_SERVICE_BASE_URI}/search`,
|
||||
method: "POST",
|
||||
data: {
|
||||
prowlarrQuery: {
|
||||
port: "9696",
|
||||
apiKey: "38c2656e8f5d4790962037b8c4416a8f",
|
||||
offset: 0,
|
||||
categories: [7030],
|
||||
query: searchTerm.issueName,
|
||||
host: "localhost",
|
||||
limit: 100,
|
||||
type: "search",
|
||||
indexerIds: [2],
|
||||
},
|
||||
port: "9696",
|
||||
apiKey: "c4f42e265fb044dc81f7e88bd41c3367",
|
||||
offset: 0,
|
||||
categories: [7030],
|
||||
query: searchTerm.issueName,
|
||||
host: "localhost",
|
||||
limit: 100,
|
||||
type: "search",
|
||||
indexerIds: [2],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -21,15 +21,13 @@ export const Dashboard = (): ReactElement => {
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: false },
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": false },
|
||||
comicStatus: "recent",
|
||||
},
|
||||
}),
|
||||
queryKey: ["recentComics"],
|
||||
});
|
||||
// Wanted Comics
|
||||
|
||||
const { data: wantedComics } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
@@ -41,9 +39,7 @@ export const Dashboard = (): ReactElement => {
|
||||
limit: 5,
|
||||
sort: { updatedAt: "-1" },
|
||||
},
|
||||
predicate: {
|
||||
wanted: { $exists: true, $ne: null },
|
||||
},
|
||||
predicate: { "acquisition.source.wanted": true },
|
||||
},
|
||||
}),
|
||||
queryKey: ["wantedComics"],
|
||||
|
||||
@@ -4,7 +4,7 @@ import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
import { importToDB } from "../../actions/fileops.actions";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import axios from "axios";
|
||||
import rateLimiter from "axios-rate-limit";
|
||||
@@ -86,8 +86,8 @@ export const PullList = (): ReactElement => {
|
||||
<span className="text-md">
|
||||
Pull List aggregated for the week from{" "}
|
||||
<span className="underline">
|
||||
<a href="https://www.tfaw.com/comics/new-releases.html">
|
||||
Things From Another World
|
||||
<a href="https://leagueofcomicgeeks.com/comics/new-comics">
|
||||
League Of Comic Geeks
|
||||
</a>
|
||||
<i className="icon-[solar--arrow-right-up-outline] w-4 h-4" />
|
||||
</span>
|
||||
@@ -132,13 +132,13 @@ export const PullList = (): ReactElement => {
|
||||
<div key={idx} className="keen-slider__slide">
|
||||
<Card
|
||||
orientation={"vertical-2"}
|
||||
imageUrl={issue.coverImageUrl}
|
||||
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.publicationDate}
|
||||
{issue.publisher}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import Card from "../shared/Carda";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import ellipsize from "ellipsize";
|
||||
import { isEmpty, isNil, isUndefined, map } from "lodash";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
@@ -18,7 +18,6 @@ type RecentlyImportedProps = {
|
||||
export const RecentlyImported = (
|
||||
comics: RecentlyImportedProps,
|
||||
): ReactElement => {
|
||||
console.log(comics);
|
||||
return (
|
||||
<div>
|
||||
<Header
|
||||
@@ -34,7 +33,9 @@ export const RecentlyImported = (
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
inferredMetadata,
|
||||
wanted: { source } = {},
|
||||
acquisition: {
|
||||
source: { name },
|
||||
},
|
||||
},
|
||||
idx,
|
||||
) => {
|
||||
@@ -44,14 +45,11 @@ export const RecentlyImported = (
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(
|
||||
source,
|
||||
{
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
},
|
||||
);
|
||||
const { issue, coverURL, icon } = determineExternalMetadata(name, {
|
||||
comicvine,
|
||||
comicInfo,
|
||||
locg,
|
||||
});
|
||||
const isComicVineMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { map, unionBy } from "lodash";
|
||||
import React, { ReactElement } from "react";
|
||||
import ellipsize from "ellipsize";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import Card from "../shared/Carda";
|
||||
import Header from "../shared/Header";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import Card from "../shared/Carda";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
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";
|
||||
@@ -31,9 +31,10 @@ export const WantedComicsList = ({
|
||||
_id,
|
||||
rawFileDetails,
|
||||
sourcedMetadata: { comicvine, comicInfo, locg },
|
||||
wanted,
|
||||
}) => {
|
||||
const isComicBookMetadataAvailable = !isUndefined(comicvine);
|
||||
const isComicBookMetadataAvailable =
|
||||
!isUndefined(comicvine) &&
|
||||
!isUndefined(comicvine.volumeInformation);
|
||||
const consolidatedComicMetadata = {
|
||||
rawFileDetails,
|
||||
comicvine,
|
||||
@@ -41,15 +42,12 @@ export const WantedComicsList = ({
|
||||
locg,
|
||||
};
|
||||
|
||||
const {
|
||||
issueName,
|
||||
url,
|
||||
publisher = null,
|
||||
} = determineCoverFile(consolidatedComicMetadata);
|
||||
const { issueName, url } = determineCoverFile(
|
||||
consolidatedComicMetadata,
|
||||
);
|
||||
const titleElement = (
|
||||
<Link to={"/comic/details/" + _id}>
|
||||
{ellipsize(issueName, 20)}
|
||||
<p>{publisher}</p>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
@@ -61,42 +59,28 @@ export const WantedComicsList = ({
|
||||
title={issueName ? titleElement : <span>No Name</span>}
|
||||
>
|
||||
<div className="pb-1">
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(detectIssueTypes(comicvine.description)) ? (
|
||||
<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.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
{/* Issue type */}
|
||||
{isComicBookMetadataAvailable &&
|
||||
!isNil(
|
||||
detectIssueTypes(comicvine.volumeInformation.description),
|
||||
) ? (
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
{/* issues marked as wanted, part of this volume */}
|
||||
{wanted?.markEntireVolumeWanted ? (
|
||||
<div className="text-sm">sagla volume pahije</div>
|
||||
) : (
|
||||
<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--documents-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{wanted.issues.length}
|
||||
</span>
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(
|
||||
comicvine.volumeInformation.description,
|
||||
).displayName
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* comicVine metadata presence */}
|
||||
{isComicBookMetadataAvailable && (
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo, ReactElement, useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import MetadataPanel from "../shared/MetadataPanel";
|
||||
import T2Table from "../shared/T2Table";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isNil, isEmpty, isUndefined } from "lodash";
|
||||
import Masonry from "react-masonry-css";
|
||||
import Card from "../shared/Carda";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
|
||||
interface ILibraryGridProps {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const SearchBar = (props): ReactElement => {
|
||||
const { searchHandler } = props;
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { isNil, isEmpty, isUndefined } from "lodash";
|
||||
import React, { useCallback, ReactElement, useState } from "react";
|
||||
import { isNil, isEmpty } from "lodash";
|
||||
import { IExtractedComicBookCoverFile, RootState } from "threetwo-ui-typings";
|
||||
import { detectIssueTypes } from "../../shared/utils/tradepaperback.utils";
|
||||
|
||||
import { Form, Field } from "react-final-form";
|
||||
import Card from "../shared/Carda";
|
||||
import ellipsize from "ellipsize";
|
||||
import { convert } from "html-to-text";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../../shared/utils/i18n.util";
|
||||
import PopoverButton from "../shared/PopoverButton";
|
||||
import dayjs from "dayjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
@@ -23,121 +20,64 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
const formData = {
|
||||
search: "",
|
||||
};
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [comicVineMetadata, setComicVineMetadata] = useState({});
|
||||
const [selectedResource, setSelectedResource] = useState("volume");
|
||||
const { t } = useTranslation();
|
||||
const handleResourceChange = (value) => {
|
||||
setSelectedResource(value);
|
||||
const getCVSearchResults = (searchQuery) => {
|
||||
setSearchQuery(searchQuery.search);
|
||||
};
|
||||
|
||||
const {
|
||||
mutate,
|
||||
data: comicVineSearchResults,
|
||||
isPending,
|
||||
isLoading,
|
||||
isSuccess,
|
||||
} = useMutation({
|
||||
mutationFn: async (data: { search: string; resource: string }) => {
|
||||
const { search, resource } = data;
|
||||
return await axios({
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/search`,
|
||||
method: "GET",
|
||||
params: {
|
||||
api_key: "a5fa0663683df8145a85d694b5da4b87e1c92c69",
|
||||
query: search,
|
||||
query: searchQuery,
|
||||
format: "json",
|
||||
limit: "10",
|
||||
offset: "0",
|
||||
field_list:
|
||||
"id,name,deck,api_detail_url,image,description,volume,cover_date,start_year,count_of_issues,publisher,issue_number",
|
||||
resources: resource,
|
||||
"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, mutate: addToWantedList } = useMutation({
|
||||
mutationFn: async ({
|
||||
source,
|
||||
comicObject,
|
||||
markEntireVolumeWanted,
|
||||
resourceType,
|
||||
}) => {
|
||||
let volumeInformation = {};
|
||||
let issues = [];
|
||||
switch (resourceType) {
|
||||
case "issue":
|
||||
const { id, api_detail_url, image, cover_date, issue_number } =
|
||||
comicObject;
|
||||
// Add issue metadata
|
||||
issues.push({
|
||||
id,
|
||||
url: api_detail_url,
|
||||
image,
|
||||
coverDate: cover_date,
|
||||
issueNumber: issue_number,
|
||||
});
|
||||
console.log(issues);
|
||||
// Get volume metadata from CV
|
||||
const response = await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getVolumes`,
|
||||
method: "POST",
|
||||
data: {
|
||||
volumeURI: comicObject.volume.api_detail_url,
|
||||
fieldList:
|
||||
"id,name,deck,api_detail_url,image,description,start_year,year,count_of_issues,publisher,first_issue,last_issue",
|
||||
},
|
||||
});
|
||||
// set volume metadata key
|
||||
volumeInformation = response.data?.results;
|
||||
break;
|
||||
|
||||
case "volume":
|
||||
const {
|
||||
id: volumeId,
|
||||
api_detail_url: apiUrl,
|
||||
image: volumeImage,
|
||||
name,
|
||||
publisher,
|
||||
} = comicObject;
|
||||
volumeInformation = {
|
||||
id: volumeId,
|
||||
url: apiUrl,
|
||||
image: volumeImage,
|
||||
name,
|
||||
publisher,
|
||||
};
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log("Invalid resource type.");
|
||||
break;
|
||||
}
|
||||
// Add to wanted list
|
||||
return await axios({
|
||||
const { data: additionResult } = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/rawImportToDb`,
|
||||
method: "POST",
|
||||
data: {
|
||||
importType: "new",
|
||||
payload: {
|
||||
rawFileDetails: {
|
||||
name: "",
|
||||
},
|
||||
importStatus: {
|
||||
isImported: false, // wanted, but not acquired yet.
|
||||
isImported: true,
|
||||
tagged: false,
|
||||
matchedResult: {
|
||||
score: "0",
|
||||
},
|
||||
},
|
||||
wanted: {
|
||||
source,
|
||||
markEntireVolumeWanted,
|
||||
issues,
|
||||
volume: volumeInformation,
|
||||
},
|
||||
sourcedMetadata: { comicvine: volumeInformation },
|
||||
sourcedMetadata:
|
||||
{ comicvine: comicVineMetadata?.comicData } || null,
|
||||
acquisition: { source: { wanted: true, name: "comicvine" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
queryKey: ["additionResult"],
|
||||
enabled: !isNil(comicVineMetadata.comicData),
|
||||
});
|
||||
|
||||
const addToLibrary = (sourceName: string, comicData) =>
|
||||
@@ -147,15 +87,6 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
return { __html: html };
|
||||
};
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const formData = { ...values, resource: selectedResource };
|
||||
try {
|
||||
mutate(formData);
|
||||
} catch (error) {
|
||||
// Handle error
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<section>
|
||||
@@ -176,7 +107,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
</header>
|
||||
<div className="mx-auto max-w-screen-sm px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
onSubmit={getCVSearchResults}
|
||||
initialValues={{
|
||||
...formData,
|
||||
}}
|
||||
@@ -208,73 +139,19 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
{/* resource type selection: volume, issue etc. */}
|
||||
<div className="flex flex-row gap-3 mt-4">
|
||||
<Field name="resource" type="radio" value="volume">
|
||||
{({ input: volumesInput, meta }) => (
|
||||
<div className="w-fit rounded-xl">
|
||||
<div>
|
||||
<input
|
||||
{...volumesInput}
|
||||
type="radio"
|
||||
id="volume"
|
||||
checked={selectedResource === "volume"}
|
||||
onChange={() => handleResourceChange("volume")}
|
||||
className="peer hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="volume"
|
||||
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||
>
|
||||
Volumes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field name="resource" type="radio" value="issue">
|
||||
{({ input: issuesInput, meta }) => (
|
||||
<div className="w-fit rounded-xl">
|
||||
<div>
|
||||
<input
|
||||
{...issuesInput}
|
||||
type="radio"
|
||||
id="issue"
|
||||
checked={selectedResource === "issue"}
|
||||
onChange={() => handleResourceChange("issue")}
|
||||
className="peer hidden"
|
||||
/>
|
||||
<label
|
||||
htmlFor="issue"
|
||||
className="block cursor-pointer select-none rounded-xl p-2 text-center peer-checked:bg-blue-500 peer-checked:font-bold peer-checked:text-white"
|
||||
>
|
||||
Issues
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isPending && (
|
||||
<div className="max-w-screen-xl px-4 py-4 sm:px-6 sm:py-8 lg:px-8">
|
||||
Loading results...
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(comicVineSearchResults?.data?.results) ? (
|
||||
{isLoading && <>Loading kaka...</>}
|
||||
{!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 result.resource_type === "issue" ? (
|
||||
<div
|
||||
key={result.id}
|
||||
className="mb-5 dark:bg-slate-400 p-4 rounded-lg"
|
||||
>
|
||||
return isSuccess ? (
|
||||
<div key={result.id} className="mb-5">
|
||||
<div className="flex flex-row">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<div className="mr-5">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
@@ -282,7 +159,7 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div className="column">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.volume.name) ? (
|
||||
result.volume.name
|
||||
@@ -290,19 +167,27 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
<span className="is-size-3">No Name</span>
|
||||
)}
|
||||
</div>
|
||||
{result.cover_date && (
|
||||
<p>
|
||||
<span className="tag is-light">Cover date</span>
|
||||
{dayjs(result.cover_date).format("MMM D, YYYY")}
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<p className="tag is-warning">{result.id}</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 className="text-sm">
|
||||
<p>
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
@@ -313,129 +198,19 @@ export const Search = ({}: ISearchProps): ReactElement => {
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<PopoverButton
|
||||
content={`This will add ${result.volume.name} to your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
comicObject: result,
|
||||
markEntireVolumeWanted: false,
|
||||
resourceType: "issue",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<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-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="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||
Mark as Wanted
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
result.resource_type === "volume" && (
|
||||
<div
|
||||
key={result.id}
|
||||
className="mb-5 dark:bg-slate-500 p-4 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<div className="mr-5 min-w-[80px] max-w-[13%]">
|
||||
<Card
|
||||
key={result.id}
|
||||
orientation={"cover-only"}
|
||||
imageUrl={result.image.small_url}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<div className="text-xl">
|
||||
{!isEmpty(result.name) ? (
|
||||
result.name
|
||||
) : (
|
||||
<span className="text-xl">No Name</span>
|
||||
)}
|
||||
{result.start_year && <> ({result.start_year})</>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* issue count */}
|
||||
{result.count_of_issues && (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--documents-minimalistic-bold-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{t("issueWithCount", {
|
||||
count: result.count_of_issues,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* type: TPB, one-shot, graphic novel etc. */}
|
||||
{!isNil(result.description) &&
|
||||
!isUndefined(result.description) && (
|
||||
<>
|
||||
{!isEmpty(
|
||||
detectIssueTypes(result.description),
|
||||
) && (
|
||||
<div className="my-2">
|
||||
<span className="inline-flex items-center bg-slate-50 text-slate-800 text-xs font-medium px-2 rounded-md dark:text-slate-900 dark:bg-slate-400">
|
||||
<span className="pr-1 pt-1">
|
||||
<i className="icon-[solar--book-2-line-duotone] w-5 h-5"></i>
|
||||
</span>
|
||||
|
||||
<span className="text-md text-slate-500 dark:text-slate-900">
|
||||
{
|
||||
detectIssueTypes(result.description)
|
||||
.displayName
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="tag is-warning">{result.id}</span>
|
||||
<p>
|
||||
<a href={result.api_detail_url}>
|
||||
{result.api_detail_url}
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{/* description */}
|
||||
<p className="text-sm">
|
||||
{ellipsize(
|
||||
convert(result.description, {
|
||||
baseElements: {
|
||||
selectors: ["p", "div"],
|
||||
},
|
||||
}),
|
||||
320,
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<PopoverButton
|
||||
content={`Adding this volume will add ${t(
|
||||
"issueWithCount",
|
||||
{
|
||||
count: result.count_of_issues,
|
||||
},
|
||||
)} to your wanted list.`}
|
||||
clickHandler={() =>
|
||||
addToWantedList({
|
||||
source: "comicvine",
|
||||
comicObject: result,
|
||||
markEntireVolumeWanted: true,
|
||||
resourceType: "volume",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div>Loading</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import React, { ReactElement, useEffect, useState, useContext } from "react";
|
||||
import { Form, Field } from "react-final-form";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import Select from "react-select";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useStore } from "../../../store";
|
||||
import axios from "axios";
|
||||
import { produce } from "immer";
|
||||
import { AIRDCPP_SERVICE_BASE_URI } from "../../../constants/endpoints";
|
||||
|
||||
export const AirDCPPHubsForm = (): ReactElement => {
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation,
|
||||
} = useStore((state) => ({
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
}));
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: async () =>
|
||||
@@ -22,31 +29,23 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
}),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the hubs list from an AirDCPP Socket
|
||||
*/
|
||||
const { data: hubs } = useQuery({
|
||||
queryKey: ["hubs"],
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${AIRDCPP_SERVICE_BASE_URI}/getHubs`,
|
||||
method: "POST",
|
||||
data: {
|
||||
host: settings?.data.directConnect?.client?.host,
|
||||
},
|
||||
}),
|
||||
enabled: !isEmpty(settings?.data.directConnect?.client?.host),
|
||||
queryFn: async () => await airDCPPSocketInstance.get(`hubs`),
|
||||
});
|
||||
|
||||
let hubList: any[] = [];
|
||||
let hubList = {};
|
||||
if (!isNil(hubs)) {
|
||||
hubList = hubs?.data.map(({ hub_url, identity }) => ({
|
||||
hubList = hubs.map(({ hub_url, identity }) => ({
|
||||
value: hub_url,
|
||||
label: identity.name,
|
||||
}));
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
@@ -57,112 +56,79 @@ export const AirDCPPHubsForm = (): ReactElement => {
|
||||
settingsKey: "directConnect",
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["settings"], (oldData: any) =>
|
||||
produce(oldData, (draft: any) => {
|
||||
draft.data.directConnect.client = {
|
||||
...draft.data.directConnect.client,
|
||||
...data.data.directConnect.client,
|
||||
};
|
||||
}),
|
||||
);
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
});
|
||||
|
||||
const validate = async (values) => {
|
||||
const errors = {};
|
||||
// Add any validation logic here if needed
|
||||
return errors;
|
||||
};
|
||||
const validate = async () => {};
|
||||
|
||||
const SelectAdapter = ({ input, ...rest }) => {
|
||||
return <Select {...input} {...rest} isClearable isMulti />;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading settings.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isEmpty(hubList) && !isUndefined(hubs) ? (
|
||||
<Form
|
||||
onSubmit={(values) => {
|
||||
mutation.mutate(values);
|
||||
}}
|
||||
onSubmit={mutate}
|
||||
validate={validate}
|
||||
render={({ handleSubmit }) => (
|
||||
<form onSubmit={handleSubmit} className="mt-10">
|
||||
<h2 className="text-xl">Configure DC++ Hubs</h2>
|
||||
<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"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<h3 className="title">Hubs</h3>
|
||||
<h6 className="subtitle has-text-grey-light">
|
||||
Select the hubs you want to perform searches against. Your
|
||||
selection in the dropdown <strong>will replace</strong> the
|
||||
existing selection.
|
||||
Select the hubs you want to perform searches against.
|
||||
</h6>
|
||||
</article>
|
||||
|
||||
<div className="field">
|
||||
<label className="block py-1 mt-3">AirDC++ Host</label>
|
||||
<Field
|
||||
name="hubs"
|
||||
component={SelectAdapter}
|
||||
className="basic-multi-select"
|
||||
placeholder="Select Hubs to Search Against"
|
||||
options={hubList}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex space-x-1 sm:mt-5 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-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
>
|
||||
<div className="field">
|
||||
<label className="label">AirDC++ Host</label>
|
||||
<div className="control">
|
||||
<Field
|
||||
name="hubs"
|
||||
component={SelectAdapter}
|
||||
className="basic-multi-select"
|
||||
placeholder="Select Hubs to Search Against"
|
||||
options={hubList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="button is-primary">
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
>
|
||||
<div className="message-body">
|
||||
No configured hubs detected in AirDC++. <br />
|
||||
Configure to a hub in AirDC++ and then select a default hub here.
|
||||
</div>
|
||||
</article>
|
||||
<>
|
||||
<article className="message">
|
||||
<div className="message-body">
|
||||
No configured hubs detected in AirDC++. <br />
|
||||
Configure to a hub in AirDC++ and then select a default hub here.
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
)}
|
||||
{!isEmpty(settings?.data.directConnect?.client.hubs) ? (
|
||||
<>
|
||||
<div className="mt-4">
|
||||
<article className="message is-warning">
|
||||
<div className="message-body is-size-6 is-family-secondary"></div>
|
||||
<div className="message-body is-size-6 is-family-secondary">
|
||||
Your selection in the dropdown <strong>will replace</strong> the
|
||||
existing selection.
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div>
|
||||
<span className="flex items-center mt-10 mb-4">
|
||||
<span className="text-xl text-slate-500 dark:text-slate-200 pr-5">
|
||||
Default Hub for Searches
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-slate-200 dark:bg-slate-400"></span>
|
||||
</span>
|
||||
<div className="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow dark:bg-slate-400 dark:border-gray-700">
|
||||
{settings?.data.directConnect?.client.hubs.map(
|
||||
({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="box mt-3">
|
||||
<h6>Default Hub For Searches:</h6>
|
||||
{settings?.data.directConnect?.client.hubs.map(
|
||||
({ value, label }) => (
|
||||
<div key={value}>
|
||||
<div>{label}</div>
|
||||
<span className="is-size-7">{value}</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -1,65 +1,76 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { ReactElement, useCallback } from "react";
|
||||
import { AirDCPPSettingsConfirmation } from "./AirDCPPSettingsConfirmation";
|
||||
import { isUndefined, isEmpty } from "lodash";
|
||||
import { ConnectionForm } from "../../shared/ConnectionForm/ConnectionForm";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { initializeAirDCPPSocket, useStore } from "../../../store/index";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import {
|
||||
AIRDCPP_SERVICE_BASE_URI,
|
||||
SETTINGS_SERVICE_BASE_URI,
|
||||
} from "../../../constants/endpoints";
|
||||
|
||||
export const AirDCPPSettingsForm = () => {
|
||||
const [airDCPPSessionInformation, setAirDCPPSessionInformation] =
|
||||
useState(null);
|
||||
// Fetching all settings
|
||||
const { data: settingsData, isSuccess: settingsSuccess } = useQuery({
|
||||
queryKey: ["airDCPPSettings"],
|
||||
queryFn: () => axios.get(`${SETTINGS_SERVICE_BASE_URI}/getAllSettings`),
|
||||
});
|
||||
|
||||
// Fetch session information
|
||||
const fetchSessionInfo = (host) => {
|
||||
return axios.post(`${AIRDCPP_SERVICE_BASE_URI}/initialize`, { host });
|
||||
};
|
||||
|
||||
// Use effect to trigger side effects on settings fetch success
|
||||
useEffect(() => {
|
||||
if (settingsSuccess && settingsData?.data?.directConnect?.client?.host) {
|
||||
const host = settingsData.data.directConnect.client.host;
|
||||
fetchSessionInfo(host).then((response) => {
|
||||
setAirDCPPSessionInformation(response.data);
|
||||
});
|
||||
}
|
||||
}, [settingsSuccess, settingsData]);
|
||||
|
||||
// Handle setting update and subsequent AirDC++ initialization
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (values) => {
|
||||
console.log(values);
|
||||
return axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: values,
|
||||
settingsKey: "directConnect",
|
||||
});
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
const host = response?.data?.directConnect?.client?.host;
|
||||
if (host) {
|
||||
const response = await fetchSessionInfo(host);
|
||||
setAirDCPPSessionInformation(response.data);
|
||||
// setState({ airDCPPClientConfiguration: host });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const deleteSettingsMutation = useMutation(() =>
|
||||
axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: {},
|
||||
settingsKey: "directConnect",
|
||||
}),
|
||||
export const AirDCPPSettingsForm = (): ReactElement => {
|
||||
// cherry-picking selectors for:
|
||||
// 1. initial values for the form
|
||||
// 2. If initial values are present, get the socket information to display
|
||||
const { setState } = useStore;
|
||||
const {
|
||||
airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPClientConfiguration,
|
||||
airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
||||
airDCPPClientConfiguration: state.airDCPPClientConfiguration,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPSocketInstance: state.airDCPPSocketInstance,
|
||||
setAirDCPPSocketInstance: state.setAirDCPPSocketInstance,
|
||||
})),
|
||||
);
|
||||
|
||||
const initFormData = settingsData?.data?.directConnect?.client?.host ?? {};
|
||||
/**
|
||||
* Mutation to update settings and subsequently initialize
|
||||
* AirDC++ socket with those settings
|
||||
*/
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: async (values) =>
|
||||
await axios({
|
||||
url: `http://localhost:3000/api/settings/saveSettings`,
|
||||
method: "POST",
|
||||
data: { settingsPayload: values, settingsKey: "directConnect" },
|
||||
}),
|
||||
onSuccess: async (values) => {
|
||||
const {
|
||||
data: {
|
||||
directConnect: {
|
||||
client: { host },
|
||||
},
|
||||
},
|
||||
} = values;
|
||||
const dcppSocketInstance = await initializeAirDCPPSocket(host);
|
||||
setState({
|
||||
airDCPPClientConfiguration: host,
|
||||
airDCPPSocketInstance: dcppSocketInstance,
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteSettingsMutation = useMutation(
|
||||
async () =>
|
||||
await axios.post("http://localhost:3000/api/settings/saveSettings", {
|
||||
settingsPayload: {},
|
||||
settingsKey: "directConnect",
|
||||
}),
|
||||
);
|
||||
|
||||
// const removeSettings = useCallback(async () => {
|
||||
// // airDCPPSettings.setSettings({});
|
||||
// }, []);
|
||||
//
|
||||
const initFormData = !isUndefined(airDCPPClientConfiguration)
|
||||
? airDCPPClientConfiguration
|
||||
: {};
|
||||
return (
|
||||
<>
|
||||
<ConnectionForm
|
||||
@@ -68,12 +79,13 @@ export const AirDCPPSettingsForm = () => {
|
||||
formHeading={"Configure AirDC++"}
|
||||
/>
|
||||
|
||||
{airDCPPSessionInformation && (
|
||||
{!isEmpty(airDCPPSessionInformation) ? (
|
||||
<AirDCPPSettingsConfirmation settings={airDCPPSessionInformation} />
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{settingsData?.data && (
|
||||
{!isEmpty(airDCPPClientConfiguration) ? (
|
||||
<p className="control mt-4">
|
||||
as
|
||||
<button
|
||||
className="button is-danger"
|
||||
onClick={() => deleteSettingsMutation.mutate()}
|
||||
@@ -81,7 +93,7 @@ export const AirDCPPSettingsForm = () => {
|
||||
Delete
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,11 +48,13 @@ export const SystemSettingsForm = (): ReactElement => {
|
||||
</article>
|
||||
|
||||
<button
|
||||
className="flex space-x-1 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-red-400 dark:border-red-200 bg-red-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-red-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
className={
|
||||
isLoading ? "button is-danger is-loading" : "button is-danger"
|
||||
}
|
||||
onClick={() => flushDb()}
|
||||
>
|
||||
<span className="pt-1 px-1">
|
||||
<i className="icon-[solar--trash-bin-trash-bold-duotone] w-7 h-7"></i>
|
||||
<span className="icon">
|
||||
<i className="fas fa-eraser"></i>
|
||||
</span>
|
||||
<span>Flush DB & Temporary Folders</span>
|
||||
</button>
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { isEmpty, isNil, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { ReactElement, useState, useCallback } from "react";
|
||||
import { isEmpty, isUndefined, map, partialRight, pick } from "lodash";
|
||||
import React, { useEffect, ReactElement, useState, useCallback } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useParams } from "react-router";
|
||||
import { analyzeLibrary } from "../../actions/comicinfo.actions";
|
||||
import { useQuery, useMutation, QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getComicBookDetailById,
|
||||
getIssuesForSeries,
|
||||
analyzeLibrary,
|
||||
} from "../../actions/comicinfo.actions";
|
||||
import PotentialLibraryMatches from "./PotentialLibraryMatches";
|
||||
import Masonry from "react-masonry-css";
|
||||
import { Card } from "../shared/Carda";
|
||||
import SlidingPane from "react-sliding-pane";
|
||||
import { convert } from "html-to-text";
|
||||
import ellipsize from "ellipsize";
|
||||
import {
|
||||
COMICVINE_SERVICE_URI,
|
||||
LIBRARY_SERVICE_BASE_URI,
|
||||
} from "../../constants/endpoints";
|
||||
import axios from "axios";
|
||||
|
||||
const VolumeDetails = (props): ReactElement => {
|
||||
const breakpointColumnsObj = {
|
||||
default: 6,
|
||||
1100: 4,
|
||||
700: 3,
|
||||
500: 2,
|
||||
};
|
||||
// sliding panel config
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [slidingPanelContentId, setSlidingPanelContentId] = useState("");
|
||||
const [matches, setMatches] = useState([]);
|
||||
const [storyArcsData, setStoryArcsData] = useState([]);
|
||||
const [active, setActive] = useState(1);
|
||||
|
||||
// sliding panel init
|
||||
@@ -28,9 +33,7 @@ const VolumeDetails = (props): ReactElement => {
|
||||
content: () => {
|
||||
const ids = map(matches, partialRight(pick, "_id"));
|
||||
const matchIds = ids.map((id: any) => id._id);
|
||||
{
|
||||
/* return <PotentialLibraryMatches matches={matchIds} />; */
|
||||
}
|
||||
return <PotentialLibraryMatches matches={matchIds} />;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -42,145 +45,68 @@ const VolumeDetails = (props): ReactElement => {
|
||||
setVisible(true);
|
||||
}, []);
|
||||
|
||||
// const analyzeIssues = useCallback((issues) => {
|
||||
// dispatch(analyzeLibrary(issues));
|
||||
// }, []);
|
||||
//
|
||||
const analyzeIssues = useCallback((issues) => {
|
||||
dispatch(analyzeLibrary(issues));
|
||||
}, []);
|
||||
|
||||
const comicBookDetails = useSelector(
|
||||
(state: RootState) => state.comicInfo.comicBookDetail,
|
||||
);
|
||||
const issuesForVolume = useSelector(
|
||||
(state: RootState) => state.comicInfo.issuesForVolume,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(getIssuesForSeries(comicObjectId));
|
||||
dispatch(getComicBookDetailById(comicObjectId));
|
||||
}, []);
|
||||
|
||||
const { comicObjectId } = useParams<{ comicObjectId: string }>();
|
||||
|
||||
const { data: comicObject, isSuccess: isComicObjectFetchedSuccessfully } =
|
||||
useQuery({
|
||||
queryFn: async () =>
|
||||
axios({
|
||||
url: `${LIBRARY_SERVICE_BASE_URI}/getComicBookById`,
|
||||
method: "POST",
|
||||
data: {
|
||||
id: comicObjectId,
|
||||
},
|
||||
}),
|
||||
queryKey: ["comicObject"],
|
||||
});
|
||||
|
||||
// get issues for a series
|
||||
const {
|
||||
data: issuesForSeries,
|
||||
isSuccess,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryFn: async () =>
|
||||
await axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getIssuesForVolume`,
|
||||
method: "POST",
|
||||
data: {
|
||||
volumeId:
|
||||
comicObject?.data?.sourcedMetadata.comicvine.volumeInformation.id,
|
||||
},
|
||||
}),
|
||||
queryKey: ["issuesForSeries", comicObject?.data],
|
||||
enabled: !isUndefined(comicObject?.data),
|
||||
});
|
||||
// get story arcs
|
||||
const useGetStoryArcs = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (comicObject) =>
|
||||
axios({
|
||||
url: `${COMICVINE_SERVICE_URI}/getResource`,
|
||||
method: "POST",
|
||||
data: {
|
||||
comicObject,
|
||||
resource: "issue",
|
||||
filter: `id:${comicObject?.sourcedMetadata.comicvine.id}`,
|
||||
},
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setStoryArcsData(data?.data.results);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: getStoryArcs,
|
||||
isIdle,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
status,
|
||||
} = useGetStoryArcs();
|
||||
|
||||
const IssuesInVolume = () => (
|
||||
<>
|
||||
{!isUndefined(issuesForSeries) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForSeries)}>
|
||||
{!isUndefined(issuesForVolume) ? (
|
||||
<div className="button" onClick={() => analyzeIssues(issuesForVolume)}>
|
||||
Analyze Library
|
||||
</div>
|
||||
) : null}
|
||||
<>
|
||||
{isSuccess &&
|
||||
issuesForSeries.data.map((issue) => {
|
||||
return (
|
||||
<>
|
||||
<Masonry
|
||||
breakpointCols={breakpointColumnsObj}
|
||||
className="issues-container"
|
||||
columnClassName="issues-column"
|
||||
>
|
||||
{!isUndefined(issuesForVolume) && !isEmpty(issuesForVolume)
|
||||
? issuesForVolume.map((issue) => {
|
||||
return (
|
||||
<Card
|
||||
key={issue.id}
|
||||
imageUrl={issue.image.small_url}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
|
||||
const Issues = () => (
|
||||
<>
|
||||
<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>
|
||||
You can add a single issue or the whole volume, and it will be added
|
||||
to the list of `Wanted` items.
|
||||
</div>
|
||||
</article>
|
||||
<div className="flex flex-wrap">
|
||||
{isSuccess &&
|
||||
issuesForSeries?.data.map((issue) => {
|
||||
return (
|
||||
<div className="my-3 dark:bg-slate-400 bg-slate-300 p-4 rounded-lg w-3/4">
|
||||
<div className="flex flex-row gap-4 mb-2">
|
||||
<div className="w-fit">
|
||||
<img
|
||||
src={issue.image.thumb_url}
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-3/4">
|
||||
<p className="text-xl">{issue.name}</p>
|
||||
<p className="text-sm">
|
||||
{convert(issue.description, {
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
imageUrl={issue.image.thumb_url}
|
||||
orientation={"vertical"}
|
||||
hasDetails
|
||||
borderColorClass={
|
||||
!isEmpty(issue.matches) ? "green-border" : ""
|
||||
}
|
||||
backgroundColor={!isEmpty(issue.matches) ? "beige" : ""}
|
||||
onClick={() =>
|
||||
openPotentialLibraryMatchesPanel(issue.matches)
|
||||
}
|
||||
>
|
||||
<span className="tag is-warning mr-1">
|
||||
{issue.issue_number}
|
||||
</span>
|
||||
{!isEmpty(issue.matches) ? (
|
||||
<>
|
||||
<span className="icon has-text-success">
|
||||
<i className="fa-regular fa-asterisk"></i>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
: "loading"}
|
||||
</Masonry>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -189,44 +115,20 @@ const VolumeDetails = (props): ReactElement => {
|
||||
{
|
||||
id: 1,
|
||||
name: "Issues in Volume",
|
||||
icon: <i className="icon-[solar--documents-bold-duotone] w-6 h-6"></i>,
|
||||
content: <Issues />,
|
||||
icon: <i className="fa-solid fa-layer-group"></i>,
|
||||
content: <IssuesInVolume key={1} />,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: (
|
||||
<i className="icon-[solar--users-group-rounded-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
icon: <i className="fa-regular fa-mask"></i>,
|
||||
name: "Characters",
|
||||
content: <div key={2}>Characters</div>,
|
||||
content: <div key={2}>asdasd</div>,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: (
|
||||
<i className="icon-[solar--book-bookmark-bold-duotone] w-6 h-6"></i>
|
||||
),
|
||||
name: "Story Arcs",
|
||||
content: (
|
||||
<div key={3}>
|
||||
<button className="" onClick={() => getStoryArcs(comicObject?.data)}>
|
||||
Get story arcs
|
||||
</button>
|
||||
{status === "pending" && <>{status}</>}
|
||||
{!isEmpty(storyArcsData) && status === "success" && (
|
||||
<>
|
||||
<ul>
|
||||
{storyArcsData.map((storyArc) => {
|
||||
return (
|
||||
<li>
|
||||
<span className="text-lg">{storyArc?.name}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: <i className="fa-solid fa-scroll"></i>,
|
||||
name: "Arcs",
|
||||
content: <div key={3}>asdasd</div>,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -234,26 +136,21 @@ const VolumeDetails = (props): ReactElement => {
|
||||
const MetadataTabGroup = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="hidden sm:block mt-7 mb-3 w-fit">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4" aria-label="Tabs">
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
className={`inline-flex shrink-0 items-center gap-2 px-1 py-1 text-md font-medium text-gray-500 dark:text-gray-400 hover:border-gray-300 hover:border-b hover:dark:text-slate-200 ${
|
||||
active === id
|
||||
? "border-b border-cyan-50 dark:text-slate-200"
|
||||
: "border-b border-transparent"
|
||||
}`}
|
||||
aria-current="page"
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<span className="pt-1">{icon}</span>
|
||||
<div className="tabs">
|
||||
<ul>
|
||||
{tabGroup.map(({ id, name, icon }) => (
|
||||
<li
|
||||
key={id}
|
||||
className={id === active ? "is-active" : ""}
|
||||
onClick={() => setActive(id)}
|
||||
>
|
||||
<a>
|
||||
<span className="icon is-small">{icon}</span>
|
||||
{name}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{tabGroup.map(({ id, content }) => {
|
||||
return active === id ? content : null;
|
||||
@@ -261,103 +158,97 @@ const VolumeDetails = (props): ReactElement => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
if (isComicObjectFetchedSuccessfully && !isUndefined(comicObject.data)) {
|
||||
const { sourcedMetadata } = comicObject.data;
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<div className="container mx-auto mt-4">
|
||||
<div>
|
||||
<div className="flex flex-row gap-4">
|
||||
{/* Volume cover */}
|
||||
if (
|
||||
!isUndefined(comicBookDetails.sourcedMetadata) &&
|
||||
!isUndefined(comicBookDetails.sourcedMetadata.comicvine.volumeInformation)
|
||||
) {
|
||||
return (
|
||||
<div className="container volume-details">
|
||||
<div className="section">
|
||||
{/* Title */}
|
||||
<h1 className="title">
|
||||
{comicBookDetails.sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</h1>
|
||||
<div className="columns is-multiline">
|
||||
{/* Volume cover */}
|
||||
<div className="column is-narrow">
|
||||
<Card
|
||||
imageUrl={
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.image.small_url
|
||||
}
|
||||
orientation={"cover-only"}
|
||||
cardContainerStyle={{ maxWidth: 275 }}
|
||||
orientation={"vertical"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="field is-grouped">
|
||||
{/* Title */}
|
||||
<span className="text-2xl">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</span>
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{sourcedMetadata.comicvine.volumeInformation.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Publisher */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-warning is-light">Publisher</span>
|
||||
<span className="tag is-volume-related">
|
||||
{
|
||||
sourcedMetadata.comicvine.volumeInformation.publisher
|
||||
.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="column is-three-fifths">
|
||||
<div className="field is-grouped mt-2">
|
||||
{/* Comicvine Id */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag">ComicVine Id</span>
|
||||
<span className="tag is-info is-light">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.id
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
{/* Publisher */}
|
||||
<div className="control">
|
||||
<div className="tags has-addons">
|
||||
<span className="tag is-warning is-light">Publisher</span>
|
||||
<span className="tag is-volume-related">
|
||||
{
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.publisher.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
{/* Deck */}
|
||||
<div>
|
||||
{!isEmpty(
|
||||
comicBookDetails.sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
)
|
||||
? ellipsize(
|
||||
convert(
|
||||
comicBookDetails.sourcedMetadata.comicvine
|
||||
.volumeInformation.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
),
|
||||
300,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
{/* <pre>{JSON.stringify(issuesForVolume, undefined, 2)}</pre> */}
|
||||
</div>
|
||||
<MetadataTabGroup />
|
||||
</div>
|
||||
</>
|
||||
|
||||
<SlidingPane
|
||||
isOpen={visible}
|
||||
onRequestClose={() => setVisible(false)}
|
||||
title={"Potential Matches in Library"}
|
||||
width={"600px"}
|
||||
>
|
||||
{slidingPanelContentId !== "" &&
|
||||
contentForSlidingPanel[slidingPanelContentId].content()}
|
||||
</SlidingPane>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import Card from "../shared/Carda";
|
||||
import T2Table from "../shared/T2Table";
|
||||
import ellipsize from "ellipsize";
|
||||
import { convert } from "html-to-text";
|
||||
import { Link } from "react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { SEARCH_SERVICE_BASE_URI } from "../../constants/endpoints";
|
||||
@@ -40,39 +39,35 @@ export const Volumes = (props): ReactElement => {
|
||||
header: "Volume Details",
|
||||
id: "volumeDetails",
|
||||
minWidth: 450,
|
||||
accessorFn: (row) => row,
|
||||
accessorKey: "_source",
|
||||
cell: (row): any => {
|
||||
const comicObject = row.getValue();
|
||||
const {
|
||||
_source: { sourcedMetadata },
|
||||
} = comicObject;
|
||||
console.log("jaggu", row.getValue());
|
||||
const foo = row.getValue();
|
||||
return (
|
||||
<div className="flex flex-row gap-3 mt-5">
|
||||
<Link to={`/volume/details/${comicObject._id}`}>
|
||||
<Card
|
||||
imageUrl={
|
||||
sourcedMetadata.comicvine.volumeInformation.image.small_url
|
||||
}
|
||||
orientation={"cover-only"}
|
||||
hasDetails={false}
|
||||
/>
|
||||
</Link>
|
||||
<div className="dark:bg-[#647587] bg-slate-200 rounded-lg w-3/4 h-fit p-3">
|
||||
<div className="text-xl mb-1 w-fit">
|
||||
{sourcedMetadata.comicvine.volumeInformation.name}
|
||||
</div>
|
||||
<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(
|
||||
sourcedMetadata.comicvine.volumeInformation.description,
|
||||
foo.sourcedMetadata.comicvine.volumeInformation
|
||||
.description,
|
||||
{
|
||||
baseElements: {
|
||||
selectors: ["p"],
|
||||
},
|
||||
},
|
||||
),
|
||||
180,
|
||||
120,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -167,7 +162,6 @@ export const Volumes = (props): ReactElement => {
|
||||
nextPage: () => {},
|
||||
previousPage: () => {},
|
||||
}}
|
||||
rowClickHandler={() => {}}
|
||||
columns={columnData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
70
src/client/components/shared/Card.tsx
Normal file
70
src/client/components/shared/Card.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from "react";
|
||||
import { IExtractedComicBookCoverFile } from "threetwo-ui-typings";
|
||||
import {
|
||||
removeLeadingPeriod,
|
||||
escapePoundSymbol,
|
||||
} from "../shared/utils/formatting.utils";
|
||||
import { isUndefined, isEmpty, isNil } from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
import ellipsize from "ellipsize";
|
||||
|
||||
interface IProps {
|
||||
comicBookCoversMetadata?: IExtractedComicBookCoverFile;
|
||||
mongoObjId?: number;
|
||||
hasTitle: boolean;
|
||||
title?: string;
|
||||
isHorizontal: boolean;
|
||||
}
|
||||
interface IState {}
|
||||
|
||||
class Card extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
public drawCoverCard = (
|
||||
metadata: IExtractedComicBookCoverFile,
|
||||
): JSX.Element => {
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}` + removeLeadingPeriod(metadata.path),
|
||||
);
|
||||
const filePath = escapePoundSymbol(encodedFilePath);
|
||||
return (
|
||||
<div>
|
||||
<div className="card generic-card">
|
||||
<div className={this.props.isHorizontal ? "is-horizontal" : ""}>
|
||||
<div className="card-image">
|
||||
<figure className="image">
|
||||
<img src={filePath} alt="Placeholder image" />
|
||||
</figure>
|
||||
</div>
|
||||
{this.props.hasTitle && (
|
||||
<div className="card-content">
|
||||
<ul>
|
||||
<Link to={"/comic/details/" + this.props.mongoObjId}>
|
||||
<li className="has-text-weight-semibold">
|
||||
{ellipsize(metadata.name, 18)}
|
||||
</li>
|
||||
</Link>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
{!isUndefined(this.props.comicBookCoversMetadata) &&
|
||||
!isEmpty(this.props.comicBookCoversMetadata) &&
|
||||
this.drawCoverCard(this.props.comicBookCoversMetadata)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -83,7 +83,7 @@ const renderCard = (props: ICardProps): ReactElement => {
|
||||
|
||||
case "vertical-2":
|
||||
return (
|
||||
<div className="block rounded-md max-w-64 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}
|
||||
|
||||
@@ -1,42 +1,71 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import React, { ChangeEventHandler, useRef, useState } from "react";
|
||||
|
||||
import { format, isValid, parse, parseISO } from "date-fns";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import { ClassNames, DayPicker } from "react-day-picker";
|
||||
import { useFloating, offset, flip, autoUpdate } from "@floating-ui/react-dom";
|
||||
import styles from "react-day-picker/dist/style.module.css";
|
||||
import { DayPicker, SelectSingleEventHandler } from "react-day-picker";
|
||||
import { usePopper } from "react-popper";
|
||||
|
||||
export const DatePickerDialog = (props) => {
|
||||
const { setter, apiAction } = props;
|
||||
const [selected, setSelected] = useState<Date>();
|
||||
const [isPopperOpen, setIsPopperOpen] = useState(false);
|
||||
|
||||
const classNames: ClassNames = {
|
||||
...styles,
|
||||
head: "custom-head",
|
||||
};
|
||||
const popperRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const { x, y, reference, floating, strategy, refs, update } = useFloating({
|
||||
placement: "bottom-end",
|
||||
middleware: [offset(10), flip()],
|
||||
strategy: "absolute",
|
||||
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();
|
||||
buttonRef?.current?.focus();
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
setIsPopperOpen(true);
|
||||
if (refs.reference.current && refs.floating.current) {
|
||||
autoUpdate(refs.reference.current, refs.floating.current, update);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDaySelect = (date) => {
|
||||
const handleDaySelect: SelectSingleEventHandler = (date) => {
|
||||
setSelected(date);
|
||||
if (date) {
|
||||
setter(format(date, "yyyy-MM-dd"));
|
||||
setter(format(date, "M-dd-yyyy"));
|
||||
apiAction();
|
||||
closePopper();
|
||||
} else {
|
||||
@@ -46,14 +75,17 @@ export const DatePickerDialog = (props) => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={reference}>
|
||||
<div ref={popperRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label="Pick a date"
|
||||
onClick={handleButtonClick}
|
||||
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"
|
||||
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>
|
||||
@@ -69,14 +101,11 @@ export const DatePickerDialog = (props) => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={floating}
|
||||
style={{
|
||||
position: strategy,
|
||||
zIndex: "999",
|
||||
borderRadius: "10px",
|
||||
boxShadow: "0 4px 6px rgba(0,0,0,0.1)", // Example of adding a shadow
|
||||
}}
|
||||
className="bg-slate-400 dark:bg-slate-500"
|
||||
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"
|
||||
>
|
||||
@@ -86,7 +115,7 @@ export const DatePickerDialog = (props) => {
|
||||
defaultMonth={selected}
|
||||
selected={selected}
|
||||
onSelect={handleDaySelect}
|
||||
classNames={classNames}
|
||||
styles={customStyles}
|
||||
/>
|
||||
</div>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactElement } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type IHeaderProps = {
|
||||
headerContent: string;
|
||||
|
||||
312
src/client/components/shared/Navbar.tsx
Normal file
312
src/client/components/shared/Navbar.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import React from "react";
|
||||
import { SearchBar } from "../GlobalSearchBar/SearchBar";
|
||||
import { DownloadProgressTick } from "../ComicDetail/DownloadProgressTick";
|
||||
import { Link } from "react-router-dom";
|
||||
import { isEmpty, isNil, isUndefined } from "lodash";
|
||||
import { format, fromUnixTime } from "date-fns";
|
||||
import { useStore } from "../../store/index";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
const Navbar: React.FunctionComponent = (props) => {
|
||||
const {
|
||||
airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation,
|
||||
airDCPPDownloadTick,
|
||||
importJobQueue,
|
||||
} = useStore(
|
||||
useShallow((state) => ({
|
||||
airDCPPSocketConnected: state.airDCPPSocketConnected,
|
||||
airDCPPDisconnectionInfo: state.airDCPPDisconnectionInfo,
|
||||
airDCPPSessionInformation: state.airDCPPSessionInformation,
|
||||
airDCPPDownloadTick: state.airDCPPDownloadTick,
|
||||
importJobQueue: state.importJobQueue,
|
||||
})),
|
||||
);
|
||||
// const downloadProgressTick = useSelector(
|
||||
// (state: RootState) => state.airdcpp.downloadProgressData,
|
||||
// );
|
||||
//
|
||||
// const airDCPPSocketConnectionStatus = useSelector(
|
||||
// (state: RootState) => state.airdcpp.isAirDCPPSocketConnected,
|
||||
// );
|
||||
// const airDCPPSessionInfo = useSelector(
|
||||
// (state: RootState) => state.airdcpp.airDCPPSessionInfo,
|
||||
// );
|
||||
// const socketDisconnectionReason = useSelector(
|
||||
// (state: RootState) => state.airdcpp.socketDisconnectionReason,
|
||||
// );
|
||||
|
||||
return (
|
||||
<nav className="navbar is-fixed-top">
|
||||
<div className="navbar-brand">
|
||||
<Link to="/dashboard" className="navbar-item">
|
||||
<img
|
||||
src="/src/client/assets/img/threetwo.svg"
|
||||
alt="ThreeTwo! A comic book curator"
|
||||
width="112"
|
||||
height="28"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<a className="navbar-item is-hidden-desktop">
|
||||
<span className="icon">
|
||||
<i className="fas fa-github"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<a className="navbar-item is-hidden-desktop">
|
||||
<span className="icon">
|
||||
<i className="fas fa-twitter"></i>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div className="navbar-burger burger" data-target="navMenubd-example">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="navMenubd-example" className="navbar-menu">
|
||||
<div className="navbar-start">
|
||||
<Link to="/" className="navbar-item">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Link to="/import" className="navbar-item">
|
||||
Import
|
||||
</Link>
|
||||
|
||||
<Link to="/library" className="navbar-item">
|
||||
Library
|
||||
</Link>
|
||||
|
||||
<Link to="/downloads" className="navbar-item">
|
||||
Downloads
|
||||
</Link>
|
||||
|
||||
{/* <SearchBar /> */}
|
||||
|
||||
<Link to="/search" className="navbar-item">
|
||||
Search ComicVine
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-end">
|
||||
<a className="navbar-item is-hidden-desktop-only"></a>
|
||||
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
<a className="navbar-link is-arrowless">
|
||||
<i className="fa-solid fa-download"></i>
|
||||
{!isEmpty(airDCPPDownloadTick) && (
|
||||
<div className="pulsating-circle"></div>
|
||||
)}
|
||||
</a>
|
||||
{!isEmpty(airDCPPDownloadTick) ? (
|
||||
<div className="navbar-dropdown is-right is-boxed">
|
||||
<a className="navbar-item">
|
||||
<DownloadProgressTick data={airDCPPDownloadTick} />
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isUndefined(importJobQueue.status) &&
|
||||
location.hash !== "#/import" ? (
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
<a className="navbar-link is-arrowless">
|
||||
<i className="fa-solid fa-file-import has-text-warning-dark"></i>
|
||||
</a>
|
||||
|
||||
<div className="navbar-dropdown is-right is-boxed">
|
||||
<a className="navbar-item">
|
||||
<ul>
|
||||
{importJobQueue.successfulJobCount > 0 ? (
|
||||
<li className="mb-2">
|
||||
<span className="tag is-success mr-2">
|
||||
{importJobQueue.successfulJobCount}
|
||||
</span>
|
||||
imported.
|
||||
</li>
|
||||
) : null}
|
||||
{importJobQueue.failedJobCount > 0 ? (
|
||||
<li>
|
||||
<span className="tag is-danger mr-2">
|
||||
{importJobQueue.failedJobCount}
|
||||
</span>
|
||||
failed to import.
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AirDC++ socket connection status */}
|
||||
<div className="navbar-item has-dropdown is-hoverable">
|
||||
{!isUndefined(airDCPPSessionInformation.user) ? (
|
||||
<>
|
||||
<a className="navbar-link is-arrowless has-text-success">
|
||||
<i className="fa-solid fa-bolt"></i>
|
||||
</a>
|
||||
<div className="navbar-dropdown pr-2 pl-2 is-right airdcpp-status is-boxed">
|
||||
{/* AirDC++ Session Information */}
|
||||
|
||||
<p>
|
||||
Last login was{" "}
|
||||
<span className="tag">
|
||||
{format(
|
||||
fromUnixTime(
|
||||
airDCPPSessionInformation?.user.last_login,
|
||||
),
|
||||
"dd MMMM, yyyy",
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<hr className="navbar-divider" />
|
||||
<p>
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.user.username}
|
||||
</span>
|
||||
connected to{" "}
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.system_info.client_version}
|
||||
</span>{" "}
|
||||
with session ID{" "}
|
||||
<span className="tag has-text-success">
|
||||
{airDCPPSessionInformation.session_id}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a className="navbar-link is-arrowless has-text-danger">
|
||||
<i className="fa-solid fa-bolt"></i>
|
||||
</a>
|
||||
<div className="navbar-dropdown pr-2 pl-2 is-right is-boxed">
|
||||
<pre>{JSON.stringify(airDCPPDisconnectionInfo, null, 2)}</pre>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="navbar-item has-dropdown is-hoverable is-mega">
|
||||
<div className="navbar-link flex">Blog</div>
|
||||
<div id="blogDropdown" className="navbar-dropdown">
|
||||
<div className="container is-fluid">
|
||||
<div className="columns">
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
|
||||
<a
|
||||
className="navbar-item "
|
||||
href="http://bulma.io/documentation/columns/basics/"
|
||||
>
|
||||
Columns
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
|
||||
<a className="navbar-item" href="/2017/08/03/list-of-tags/">
|
||||
<div className="navbar-content">
|
||||
<p>
|
||||
<small className="has-text-info">03 Aug 2017</small>
|
||||
</p>
|
||||
<p>New feature: list of tags</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div className="column">
|
||||
<h1 className="title is-6 is-mega-menu-title">
|
||||
Sub Menu Title
|
||||
</h1>
|
||||
<a
|
||||
className="navbar-item "
|
||||
href="/documentation/overview/start/"
|
||||
>
|
||||
Overview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="navbar-divider" />
|
||||
<div className="navbar-item">
|
||||
<div className="navbar-content">
|
||||
<div className="level is-mobile">
|
||||
<div className="level-left">
|
||||
<div className="level-item">
|
||||
<strong>Stay up to date!</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="level-right">
|
||||
<div className="level-item">
|
||||
<a
|
||||
className="button bd-is-rss is-small"
|
||||
href="http://bulma.io/atom.xml"
|
||||
>
|
||||
<span className="icon is-small">
|
||||
<i className="fa fa-rss"></i>
|
||||
</span>
|
||||
<span>Subscribe</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="navbar-item">
|
||||
<div className="field is-grouped">
|
||||
<p className="control">
|
||||
<Link to="/settings" className="navbar-item">
|
||||
Settings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactElement, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDarkMode } from "../../hooks/useDarkMode";
|
||||
|
||||
export const Navbar2 = (): ReactElement => {
|
||||
@@ -98,7 +98,7 @@ export const Navbar2 = (): ReactElement => {
|
||||
<li>
|
||||
{/* Light/Dark Mode toggle */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||
<span className="text-gray-600 dark:text-white">Light</span>
|
||||
<label
|
||||
htmlFor="toggle"
|
||||
className="relative inline-flex items-center"
|
||||
@@ -117,7 +117,7 @@ export const Navbar2 = (): ReactElement => {
|
||||
}`}
|
||||
></span>
|
||||
</label>
|
||||
<span className="text-gray-600 dark:text-white">Light</span>
|
||||
<span className="text-gray-600 dark:text-white">Dark</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useFloating, offset, flip } from "@floating-ui/react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "../../shared/utils/i18n.util"; // Ensure you import your i18n configuration
|
||||
|
||||
const PopoverButton = ({ content, clickHandler }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
// Use destructuring to obtain the reference and floating setters, among other values.
|
||||
const { x, y, refs, strategy, floatingStyles } = useFloating({
|
||||
placement: "right",
|
||||
middleware: [offset(8), flip()],
|
||||
strategy: "absolute",
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div>
|
||||
{/* Apply the reference setter directly to the ref prop */}
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
onFocus={() => setIsVisible(true)}
|
||||
onBlur={() => setIsVisible(false)}
|
||||
aria-describedby="popover"
|
||||
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={clickHandler}
|
||||
>
|
||||
<i className="icon-[solar--add-square-bold-duotone] w-6 h-6 mr-2"></i>{" "}
|
||||
Mark as Wanted
|
||||
</button>
|
||||
{isVisible && (
|
||||
<div
|
||||
ref={refs.setFloating} // Apply the floating setter directly to the ref prop
|
||||
style={floatingStyles}
|
||||
className="text-sm bg-slate-400 p-2 rounded-md"
|
||||
role="tooltip"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopoverButton;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { ReactElement, useMemo, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
@@ -8,19 +9,7 @@ import {
|
||||
PaginationState,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
interface T2TableProps {
|
||||
sourceData?: unknown[];
|
||||
totalPages?: number;
|
||||
columns?: unknown[];
|
||||
paginationHandlers?: {
|
||||
nextPage?(...args: unknown[]): unknown;
|
||||
previousPage?(...args: unknown[]): unknown;
|
||||
};
|
||||
rowClickHandler?(...args: unknown[]): unknown;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
export const T2Table = (tableOptions): ReactElement => {
|
||||
const {
|
||||
sourceData,
|
||||
columns,
|
||||
@@ -153,4 +142,15 @@ export const T2Table = (tableOptions: T2TableProps): ReactElement => {
|
||||
);
|
||||
};
|
||||
|
||||
T2Table.propTypes = {
|
||||
sourceData: PropTypes.array,
|
||||
totalPages: PropTypes.number,
|
||||
columns: PropTypes.array,
|
||||
paginationHandlers: PropTypes.shape({
|
||||
nextPage: PropTypes.func,
|
||||
previousPage: PropTypes.func,
|
||||
}),
|
||||
rowClickHandler: PropTypes.func,
|
||||
children: PropTypes.any,
|
||||
};
|
||||
export default T2Table;
|
||||
|
||||
@@ -104,10 +104,3 @@ export const TORRENT_JOB_SERVICE_BASE_URI = hostURIBuilder({
|
||||
port: "3000",
|
||||
apiPath: `/api/torrentjobs`,
|
||||
});
|
||||
|
||||
export const AIRDCPP_SERVICE_BASE_URI = hostURIBuilder({
|
||||
protocol: "http",
|
||||
host: import.meta.env.UNDERLYING_HOSTNAME || "localhost",
|
||||
port: "3000",
|
||||
apiPath: `/api/airdcpp`,
|
||||
});
|
||||
|
||||
11
src/client/context/SocketIOContext.tsx
Normal file
11
src/client/context/SocketIOContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React, { createContext } from "react";
|
||||
|
||||
export const SocketIOContext = createContext({});
|
||||
|
||||
export const SocketIOProvider = ({ children, socket }) => {
|
||||
return (
|
||||
<SocketIOContext.Provider value={socket}>
|
||||
{children}
|
||||
</SocketIOContext.Provider>
|
||||
);
|
||||
};
|
||||
16
src/client/hooks/useBlazeSlider.tsx
Normal file
16
src/client/hooks/useBlazeSlider.tsx
Normal 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;
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from "react";
|
||||
import { render } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./components/App";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
import Settings from "./components/Settings/Settings";
|
||||
import { ErrorPage } from "./components/shared/ErrorPage";
|
||||
import i18n from "./shared/utils/i18n.util";
|
||||
const rootEl = document.getElementById("root");
|
||||
const root = createRoot(rootEl);
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Import from "./components/Import/Import";
|
||||
import Dashboard from "./components/Dashboard/Dashboard";
|
||||
@@ -11,53 +14,37 @@ 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 VolumeDetails from "./components/VolumeDetail/VolumeDetail";
|
||||
import WantedComics from "./components/WantedComics/WantedComics";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const router = createBrowserRouter(
|
||||
[
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ path: "/", element: <Dashboard /> },
|
||||
{ path: "dashboard", element: <Dashboard /> },
|
||||
{ path: "settings", element: <Settings /> },
|
||||
{ path: "library", element: <TabulatedContentContainer category="library" /> },
|
||||
{ path: "comic/details/:comicObjectId", element: <ComicDetailContainer /> },
|
||||
{ path: "import", element: <Import path={"./comics"} /> },
|
||||
{ path: "search", element: <Search /> },
|
||||
{ path: "volume/details/:comicObjectId", element: <VolumeDetails /> },
|
||||
{ path: "volumes", element: <Volumes /> },
|
||||
{ path: "wanted", element: <WantedComics /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
future: {
|
||||
v7_relativeSplatPath: true,
|
||||
v7_fetcherPersist: true,
|
||||
v7_normalizeFormMethod: true,
|
||||
v7_partialHydration: true,
|
||||
v7_skipActionErrorRevalidation: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
path: "/",
|
||||
element: <App />,
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ path: "/", element: <Dashboard /> },
|
||||
{ path: "dashboard", element: <Dashboard /> },
|
||||
{ path: "settings", element: <Settings /> },
|
||||
{
|
||||
path: "library",
|
||||
element: <TabulatedContentContainer category="library" />,
|
||||
},
|
||||
{
|
||||
path: "comic/details/:comicObjectId",
|
||||
element: <ComicDetailContainer />,
|
||||
},
|
||||
{ path: "import", element: <Import path={"./comics"} /> },
|
||||
{ path: "search", element: <Search /> },
|
||||
{ path: "volumes", element: <Volumes /> },
|
||||
{ path: "wanted", element: <WantedComics /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (rootElement) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider
|
||||
router={router}
|
||||
future={{ v7_startTransition: true }}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
} else {
|
||||
console.error("Root element not found");
|
||||
}
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"issueWithCount_one": "{{count}} issue",
|
||||
"issueWithCount_other": "{{count}} issues"
|
||||
}
|
||||
133
src/client/reducers/airdcpp.reducer.ts
Normal file
133
src/client/reducers/airdcpp.reducer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
AIRDCPP_SEARCH_IN_PROGRESS,
|
||||
AIRDCPP_SEARCH_RESULTS_ADDED,
|
||||
AIRDCPP_SEARCH_RESULTS_UPDATED,
|
||||
AIRDCPP_HUB_SEARCHES_SENT,
|
||||
AIRDCPP_RESULT_DOWNLOAD_INITIATED,
|
||||
AIRDCPP_DOWNLOAD_PROGRESS_TICK,
|
||||
AIRDCPP_FILE_DOWNLOAD_COMPLETED,
|
||||
AIRDCPP_BUNDLES_FETCHED,
|
||||
AIRDCPP_TRANSFERS_FETCHED,
|
||||
LIBRARY_ISSUE_BUNDLES,
|
||||
AIRDCPP_SOCKET_CONNECTED,
|
||||
AIRDCPP_SOCKET_DISCONNECTED,
|
||||
} from "../constants/action-types";
|
||||
import { LOCATION_CHANGE } from "redux-first-history";
|
||||
import { isNil, isUndefined } from "lodash";
|
||||
import { difference } from "../shared/utils/object.utils";
|
||||
|
||||
const initialState = {
|
||||
searchResults: [],
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: null,
|
||||
searchInstance: null,
|
||||
downloadResult: null,
|
||||
bundleDBImportResult: null,
|
||||
downloadFileStatus: {},
|
||||
bundles: [],
|
||||
transfers: [],
|
||||
isAirDCPPSocketConnected: false,
|
||||
airDCPPSessionInfo: {},
|
||||
socketDisconnectionReason: {},
|
||||
};
|
||||
|
||||
function airdcppReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case AIRDCPP_SEARCH_RESULTS_ADDED:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [...state.searchResults, action.groupedResult],
|
||||
isAirDCPPSearchInProgress: true,
|
||||
};
|
||||
case AIRDCPP_SEARCH_RESULTS_UPDATED:
|
||||
const bundleToUpdateIndex = state.searchResults.findIndex(
|
||||
(bundle) => bundle.result.id === action.groupedResult.result.id,
|
||||
);
|
||||
const updatedState = [...state.searchResults];
|
||||
|
||||
if (
|
||||
!isNil(
|
||||
difference(updatedState[bundleToUpdateIndex], action.groupedResult),
|
||||
)
|
||||
) {
|
||||
updatedState[bundleToUpdateIndex] = action.groupedResult;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
searchResults: updatedState,
|
||||
};
|
||||
case AIRDCPP_SEARCH_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSearchInProgress: true,
|
||||
};
|
||||
case AIRDCPP_HUB_SEARCHES_SENT:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: action.searchInfo,
|
||||
searchInstance: action.instance,
|
||||
};
|
||||
case AIRDCPP_RESULT_DOWNLOAD_INITIATED:
|
||||
return {
|
||||
...state,
|
||||
downloadResult: action.downloadResult,
|
||||
bundleDBImportResult: action.bundleDBImportResult,
|
||||
};
|
||||
case AIRDCPP_DOWNLOAD_PROGRESS_TICK:
|
||||
return {
|
||||
...state,
|
||||
downloadProgressData: action.downloadProgressData,
|
||||
};
|
||||
case AIRDCPP_BUNDLES_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
bundles: action.bundles,
|
||||
};
|
||||
case LIBRARY_ISSUE_BUNDLES:
|
||||
return {
|
||||
...state,
|
||||
issue_bundles: action.issue_bundles,
|
||||
};
|
||||
case AIRDCPP_FILE_DOWNLOAD_COMPLETED:
|
||||
console.log("COMPLETED", action);
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
case AIRDCPP_TRANSFERS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
transfers: action.bundles,
|
||||
};
|
||||
|
||||
case AIRDCPP_SOCKET_CONNECTED:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSocketConnected: true,
|
||||
airDCPPSessionInfo: action.data,
|
||||
};
|
||||
|
||||
case AIRDCPP_SOCKET_DISCONNECTED:
|
||||
return {
|
||||
...state,
|
||||
isAirDCPPSocketConnected: false,
|
||||
socketDisconnectionReason: action.data,
|
||||
};
|
||||
case LOCATION_CHANGE:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
isAirDCPPSearchInProgress: false,
|
||||
searchInfo: null,
|
||||
searchInstance: null,
|
||||
downloadResult: null,
|
||||
bundleDBImportResult: null,
|
||||
// bundles: [],
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default airdcppReducer;
|
||||
138
src/client/reducers/comicinfo.reducer.ts
Normal file
138
src/client/reducers/comicinfo.reducer.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { isEmpty } from "lodash";
|
||||
import {
|
||||
CV_API_CALL_IN_PROGRESS,
|
||||
CV_SEARCH_SUCCESS,
|
||||
CV_CLEANUP,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_FETCHED,
|
||||
IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED,
|
||||
IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS,
|
||||
CV_ISSUES_METADATA_CALL_IN_PROGRESS,
|
||||
CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED,
|
||||
CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS,
|
||||
CV_WEEKLY_PULLLIST_FETCHED,
|
||||
CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS,
|
||||
LIBRARY_STATISTICS_CALL_IN_PROGRESS,
|
||||
LIBRARY_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
|
||||
const initialState = {
|
||||
pullList: [],
|
||||
libraryStatistics: [],
|
||||
searchResults: [],
|
||||
searchQuery: {},
|
||||
inProgress: false,
|
||||
comicBookDetail: {},
|
||||
comicBooksDetails: [],
|
||||
issuesForVolume: [],
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
|
||||
function comicinfoReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case CV_API_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
inProgress: true,
|
||||
};
|
||||
case CV_SEARCH_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
searchResults: action.searchResults,
|
||||
searchQuery: action.searchQueryObject,
|
||||
inProgress: false,
|
||||
};
|
||||
case IMS_COMIC_BOOK_DB_OBJECT_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
IMS_inProgress: true,
|
||||
};
|
||||
|
||||
case IMS_COMIC_BOOK_DB_OBJECT_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBookDetail: action.comicBookDetail,
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
case IMS_COMIC_BOOKS_DB_OBJECTS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBooksDetails: action.comicBooks,
|
||||
IMS_inProgress: false,
|
||||
};
|
||||
case CV_CLEANUP:
|
||||
return {
|
||||
...state,
|
||||
searchResults: [],
|
||||
searchQuery: {},
|
||||
issuesForVolume: [],
|
||||
};
|
||||
case CV_ISSUES_METADATA_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
|
||||
case CV_ISSUES_FOR_VOLUME_IN_LIBRARY_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
issuesForVolume: action.issues,
|
||||
inProgress: false,
|
||||
};
|
||||
|
||||
case CV_ISSUES_MATCHES_IN_LIBRARY_FETCHED:
|
||||
const updatedState = [...state.issuesForVolume];
|
||||
action.matches.map((match) => {
|
||||
updatedState.map((issue, idx) => {
|
||||
const matches = [];
|
||||
if (!isEmpty(match.hits.hits)) {
|
||||
return match.hits.hits.map((hit) => {
|
||||
if (
|
||||
parseInt(issue.issue_number, 10) ===
|
||||
hit._source.inferredMetadata.issue.number
|
||||
) {
|
||||
matches.push(hit);
|
||||
const updatedIssueResult = { ...issue, matches };
|
||||
updatedState[idx] = updatedIssueResult;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
issuesForVolume: updatedState,
|
||||
};
|
||||
|
||||
case CV_WEEKLY_PULLLIST_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
case CV_WEEKLY_PULLLIST_FETCHED: {
|
||||
const foo = [];
|
||||
action.data.map((item) => {
|
||||
foo.push({issue: item})
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
inProgress: false,
|
||||
pullList: foo,
|
||||
};
|
||||
}
|
||||
case LIBRARY_STATISTICS_CALL_IN_PROGRESS:
|
||||
return {
|
||||
inProgress: true,
|
||||
...state,
|
||||
};
|
||||
case LIBRARY_STATISTICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
inProgress: false,
|
||||
libraryStatistics: action.data,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default comicinfoReducer;
|
||||
334
src/client/reducers/fileops.reducer.ts
Normal file
334
src/client/reducers/fileops.reducer.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { isUndefined, map } from "lodash";
|
||||
import { LOCATION_CHANGE } from "redux-first-history";
|
||||
import { determineCoverFile } from "../shared/utils/metadata.utils";
|
||||
import {
|
||||
IMS_COMICBOOK_METADATA_FETCHED,
|
||||
IMS_RAW_IMPORT_SUCCESSFUL,
|
||||
IMS_RAW_IMPORT_FAILED,
|
||||
IMS_RECENT_COMICS_FETCHED,
|
||||
IMS_WANTED_COMICS_FETCHED,
|
||||
WANTED_COMICS_FETCHED,
|
||||
IMS_CV_METADATA_IMPORT_SUCCESSFUL,
|
||||
IMS_CV_METADATA_IMPORT_FAILED,
|
||||
IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS,
|
||||
IMS_COMIC_BOOK_GROUPS_FETCHED,
|
||||
IMS_COMIC_BOOK_GROUPS_CALL_FAILED,
|
||||
IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS,
|
||||
LS_IMPORT,
|
||||
LS_COVER_EXTRACTED,
|
||||
LS_COVER_EXTRACTION_FAILED,
|
||||
LS_COMIC_ADDED,
|
||||
IMG_ANALYSIS_CALL_IN_PROGRESS,
|
||||
IMG_ANALYSIS_DATA_FETCH_SUCCESS,
|
||||
SS_SEARCH_RESULTS_FETCHED,
|
||||
SS_SEARCH_IN_PROGRESS,
|
||||
FILEOPS_STATE_RESET,
|
||||
LS_IMPORT_CALL_IN_PROGRESS,
|
||||
SS_SEARCH_FAILED,
|
||||
SS_SEARCH_RESULTS_FETCHED_SPECIAL,
|
||||
VOLUMES_FETCHED,
|
||||
COMICBOOK_EXTRACTION_SUCCESS,
|
||||
LIBRARY_SERVICE_HEALTH,
|
||||
LS_IMPORT_QUEUE_DRAINED,
|
||||
LS_SET_QUEUE_STATUS,
|
||||
RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION,
|
||||
LS_IMPORT_JOB_STATISTICS_FETCHED,
|
||||
} from "../constants/action-types";
|
||||
import { removeLeadingPeriod } from "../shared/utils/formatting.utils";
|
||||
import { LIBRARY_SERVICE_HOST } from "../constants/endpoints";
|
||||
|
||||
const initialState = {
|
||||
IMSCallInProgress: false,
|
||||
IMGCallInProgress: false,
|
||||
SSCallInProgress: false,
|
||||
imageAnalysisResults: {},
|
||||
comicBookExtractionInProgress: false,
|
||||
LSQueueImportStatus: undefined,
|
||||
comicBookMetadata: [],
|
||||
comicVolumeGroups: [],
|
||||
isSocketConnected: false,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportError: {},
|
||||
rawImportError: {},
|
||||
extractedComicBookArchive: {
|
||||
reading: [],
|
||||
analysis: [],
|
||||
},
|
||||
recentComics: [],
|
||||
wantedComics: [],
|
||||
libraryComics: [],
|
||||
volumes: [],
|
||||
librarySearchResultsFormatted: [],
|
||||
lastQueueJob: "",
|
||||
successfulJobCount: 0,
|
||||
failedJobCount: 0,
|
||||
importJobStatistics: [],
|
||||
libraryQueueResults: [],
|
||||
librarySearchError: {},
|
||||
libraryServiceStatus: {},
|
||||
};
|
||||
function fileOpsReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case IMS_COMICBOOK_METADATA_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
comicBookMetadata: [...state.comicBookMetadata, action.data],
|
||||
IMSCallInProgress: false,
|
||||
};
|
||||
|
||||
case LS_IMPORT_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMS_RAW_IMPORT_SUCCESSFUL:
|
||||
return {
|
||||
...state,
|
||||
rawImportDetails: action.rawImportDetails,
|
||||
};
|
||||
case IMS_RAW_IMPORT_FAILED:
|
||||
return {
|
||||
...state,
|
||||
rawImportErorr: action.rawImportError,
|
||||
};
|
||||
case IMS_RECENT_COMICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
recentComics: action.data.docs,
|
||||
};
|
||||
case IMS_WANTED_COMICS_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
wantedComics: action.data,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_SUCCESSFUL:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportDetails: action.importResult,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_CALL_IN_PROGRESS:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: true,
|
||||
};
|
||||
case IMS_CV_METADATA_IMPORT_FAILED:
|
||||
return {
|
||||
...state,
|
||||
isComicVineMetadataImportInProgress: false,
|
||||
comicVineMetadataImportError: action.importError,
|
||||
};
|
||||
case IMS_COMIC_BOOK_GROUPS_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_GROUPS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
comicVolumeGroups: action.data,
|
||||
IMSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_GROUPS_CALL_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
IMSCallInProgress: false,
|
||||
error: action.error,
|
||||
};
|
||||
}
|
||||
case IMS_COMIC_BOOK_ARCHIVE_EXTRACTION_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
comicBookExtractionInProgress: true,
|
||||
};
|
||||
}
|
||||
|
||||
case LOCATION_CHANGE: {
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: [],
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT: {
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: "running",
|
||||
};
|
||||
}
|
||||
|
||||
case LS_COVER_EXTRACTED: {
|
||||
if (state.recentComics.length === 5) {
|
||||
state.recentComics.pop();
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
successfulJobCount: action.completedJobCount,
|
||||
lastQueueJob: action.importResult.rawFileDetails.name,
|
||||
recentComics: [...state.recentComics, action.importResult],
|
||||
};
|
||||
}
|
||||
|
||||
case LS_COVER_EXTRACTION_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
failedJobCount: action.failedJobCount,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT_QUEUE_DRAINED: {
|
||||
localStorage.removeItem("sessionId");
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: "drained",
|
||||
};
|
||||
}
|
||||
|
||||
case RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION: {
|
||||
console.log("Restoring state for an active import in progress...");
|
||||
return {
|
||||
...state,
|
||||
successfulJobCount: action.completedJobCount,
|
||||
failedJobCount: action.failedJobCount,
|
||||
LSQueueImportStatus: action.queueStatus,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_SET_QUEUE_STATUS: {
|
||||
return {
|
||||
...state,
|
||||
LSQueueImportStatus: action.data.queueStatus,
|
||||
};
|
||||
}
|
||||
|
||||
case LS_IMPORT_JOB_STATISTICS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
importJobStatistics: action.data,
|
||||
};
|
||||
}
|
||||
|
||||
case COMICBOOK_EXTRACTION_SUCCESS: {
|
||||
const comicBookPages: string[] = [];
|
||||
map(action.result.files, (page) => {
|
||||
const pageFilePath = removeLeadingPeriod(page);
|
||||
const imagePath = encodeURI(`${LIBRARY_SERVICE_HOST}${pageFilePath}`);
|
||||
comicBookPages.push(imagePath);
|
||||
});
|
||||
|
||||
switch (action.result.purpose) {
|
||||
case "reading":
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: {
|
||||
reading: comicBookPages,
|
||||
},
|
||||
comicBookExtractionInProgress: false,
|
||||
};
|
||||
|
||||
case "analysis":
|
||||
return {
|
||||
...state,
|
||||
extractedComicBookArchive: {
|
||||
analysis: comicBookPages,
|
||||
},
|
||||
comicBookExtractionInProgress: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case LS_COMIC_ADDED: {
|
||||
return {
|
||||
...state,
|
||||
};
|
||||
}
|
||||
case IMG_ANALYSIS_CALL_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
IMGCallInProgress: true,
|
||||
};
|
||||
}
|
||||
case IMG_ANALYSIS_DATA_FETCH_SUCCESS: {
|
||||
return {
|
||||
...state,
|
||||
imageAnalysisResults: action.result,
|
||||
};
|
||||
}
|
||||
|
||||
case SS_SEARCH_IN_PROGRESS: {
|
||||
return {
|
||||
...state,
|
||||
SSCallInProgress: true,
|
||||
};
|
||||
}
|
||||
|
||||
case SS_SEARCH_RESULTS_FETCHED: {
|
||||
return {
|
||||
...state,
|
||||
libraryComics: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case SS_SEARCH_RESULTS_FETCHED_SPECIAL: {
|
||||
const foo = [];
|
||||
if (!isUndefined(action.data.hits)) {
|
||||
map(action.data.hits, ({ _source }) => {
|
||||
foo.push(_source);
|
||||
});
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
librarySearchResultsFormatted: foo,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case WANTED_COMICS_FETCHED: {
|
||||
const foo = [];
|
||||
if (!isUndefined(action.data.hits)) {
|
||||
map(action.data.hits, ({ _source }) => {
|
||||
foo.push(_source);
|
||||
});
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
wantedComics: foo,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
|
||||
case VOLUMES_FETCHED:
|
||||
return {
|
||||
...state,
|
||||
volumes: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
|
||||
case SS_SEARCH_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
librarySearchError: action.data,
|
||||
SSCallInProgress: false,
|
||||
};
|
||||
}
|
||||
case LIBRARY_SERVICE_HEALTH: {
|
||||
return {
|
||||
...state,
|
||||
libraryServiceStatus: action.status,
|
||||
};
|
||||
}
|
||||
case FILEOPS_STATE_RESET: {
|
||||
return {
|
||||
...state,
|
||||
imageAnalysisResults: {},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default fileOpsReducer;
|
||||
11
src/client/reducers/index.js
Normal file
11
src/client/reducers/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import comicinfoReducer from "../reducers/comicinfo.reducer";
|
||||
import fileOpsReducer from "../reducers/fileops.reducer";
|
||||
import airdcppReducer from "../reducers/airdcpp.reducer";
|
||||
// import settingsReducer from "../reducers/settings.reducer";
|
||||
|
||||
export const reducers = {
|
||||
comicInfo: comicinfoReducer,
|
||||
fileOps: fileOpsReducer,
|
||||
airdcpp: airdcppReducer,
|
||||
// settings: settingsReducer,
|
||||
};
|
||||
67
src/client/reducers/settings.reducer.ts
Normal file
67
src/client/reducers/settings.reducer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
|
||||
import { RootState } from "../store";
|
||||
import { isUndefined } from "lodash";
|
||||
import { SETTINGS_SERVICE_BASE_URI } from "../constants/endpoints";
|
||||
|
||||
export interface InitialState {
|
||||
data: object;
|
||||
inProgress: boolean;
|
||||
dbFlushed: boolean;
|
||||
torrentsList: Array<any>;
|
||||
}
|
||||
const initialState: InitialState = {
|
||||
data: {},
|
||||
inProgress: false,
|
||||
dbFlushed: false,
|
||||
torrentsList: [],
|
||||
};
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
name: "settings",
|
||||
initialState,
|
||||
reducers: {
|
||||
SETTINGS_CALL_IN_PROGRESS: (state) => {
|
||||
state.inProgress = true;
|
||||
},
|
||||
|
||||
SETTINGS_OBJECT_FETCHED: (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_OBJECT_DELETED: (state, action) => {
|
||||
state.data = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_DB_FLUSH_SUCCESS: (state, action) => {
|
||||
state.dbFlushed = action.payload;
|
||||
state.inProgress = false;
|
||||
},
|
||||
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED: (state, action) => {
|
||||
console.log(state);
|
||||
console.log(action);
|
||||
state.torrentsList = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
SETTINGS_CALL_IN_PROGRESS,
|
||||
SETTINGS_OBJECT_FETCHED,
|
||||
SETTINGS_OBJECT_DELETED,
|
||||
SETTINGS_DB_FLUSH_SUCCESS,
|
||||
SETTINGS_QBITTORRENT_TORRENTS_LIST_FETCHED,
|
||||
} = settingsSlice.actions;
|
||||
|
||||
// Other code such as selectors can use the imported `RootState` type
|
||||
export const torrentsList = (state: RootState) => state.settings.torrentsList;
|
||||
export const qBittorrentSettings = (state: RootState) => {
|
||||
console.log(state);
|
||||
if (!isUndefined(state.settings?.data?.bittorrent)) {
|
||||
return state.settings?.data?.bittorrent.client.host;
|
||||
}
|
||||
};
|
||||
export default settingsSlice.reducer;
|
||||
11
src/client/shared/middleware/SocketIOMiddleware.tsx
Normal file
11
src/client/shared/middleware/SocketIOMiddleware.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
const socketIOMiddleware = (socket) => {
|
||||
return (store) => (next) => (action) => {
|
||||
if (action.type === "EMIT_SOCKET_EVENT") {
|
||||
const { event, data } = action.payload;
|
||||
socket.emit(event, data);
|
||||
}
|
||||
return next(action);
|
||||
};
|
||||
};
|
||||
|
||||
export default socketIOMiddleware;
|
||||
10
src/client/shared/socket.io/instance.tsx
Normal file
10
src/client/shared/socket.io/instance.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import io from "socket.io-client";
|
||||
import { SOCKET_BASE_URI } from "../../constants/endpoints";
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
const socketIOConnectionInstance = io(SOCKET_BASE_URI, {
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
query: { sessionId },
|
||||
});
|
||||
|
||||
export default socketIOConnectionInstance;
|
||||
@@ -1,25 +0,0 @@
|
||||
// i18n.js
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import HttpBackend from "i18next-http-backend";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
i18n
|
||||
// Learn more about options: https://www.i18next.com/overview/configuration-options
|
||||
.use(HttpBackend) // Load translations over http
|
||||
.use(LanguageDetector) // Detect language automatically
|
||||
.use(initReactI18next) // Pass i18n instance to react-i18next
|
||||
.init({
|
||||
lng: "en", // Specify the language
|
||||
fallbackLng: "en",
|
||||
debug: true,
|
||||
interpolation: {
|
||||
escapeValue: false, // Not needed for React
|
||||
},
|
||||
backend: {
|
||||
// path where resources get loaded from
|
||||
loadPath: "./src/client/locales/en/translation.json",
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -1,12 +1,11 @@
|
||||
import { filter, isEmpty, isNil, isUndefined, min, minBy } from "lodash";
|
||||
import { filter, isEmpty, isUndefined, min, minBy } from "lodash";
|
||||
import { LIBRARY_SERVICE_HOST } from "../../constants/endpoints";
|
||||
import { escapePoundSymbol } from "./formatting.utils";
|
||||
|
||||
export const determineCoverFile = (data): any => {
|
||||
export const determineCoverFile = (data) => {
|
||||
/* For a payload like this:
|
||||
const foo = {
|
||||
rawFileDetails: {}, // #1
|
||||
wanted: {},
|
||||
comicInfo: {},
|
||||
comicvine: {}, // #2
|
||||
locg: {}, // #3
|
||||
@@ -20,44 +19,36 @@ export const determineCoverFile = (data): any => {
|
||||
issueName: "",
|
||||
publisher: "",
|
||||
},
|
||||
wanted: {
|
||||
objectReference: "wanted",
|
||||
priority: 2,
|
||||
url: "",
|
||||
issueName: "",
|
||||
publisher: "",
|
||||
},
|
||||
comicvine: {
|
||||
objectReference: "comicvine",
|
||||
priority: 3,
|
||||
priority: 2,
|
||||
url: "",
|
||||
issueName: "",
|
||||
publisher: "",
|
||||
},
|
||||
locg: {
|
||||
objectReference: "locg",
|
||||
priority: 4,
|
||||
priority: 3,
|
||||
url: "",
|
||||
issueName: "",
|
||||
publisher: "",
|
||||
},
|
||||
};
|
||||
// comicvine
|
||||
if (!isEmpty(data.comicvine)) {
|
||||
coverFile.comicvine.url = data?.comicvine?.image.small_url;
|
||||
coverFile.comicvine.issueName = data.comicvine?.name;
|
||||
coverFile.comicvine.publisher = data.comicvine?.publisher?.name;
|
||||
if (
|
||||
!isUndefined(data.comicvine) &&
|
||||
!isUndefined(data.comicvine.volumeInformation)
|
||||
) {
|
||||
coverFile.comicvine.url = data.comicvine.image.small_url;
|
||||
coverFile.comicvine.issueName = data.comicvine.name;
|
||||
coverFile.comicvine.publisher = data.comicvine.volumeInformation.publisher;
|
||||
}
|
||||
// rawFileDetails
|
||||
if (!isEmpty(data.rawFileDetails)) {
|
||||
if (!isEmpty(data.rawFileDetails.cover)) {
|
||||
const encodedFilePath = encodeURI(
|
||||
`${LIBRARY_SERVICE_HOST}/${data.rawFileDetails.cover.filePath}`,
|
||||
);
|
||||
coverFile.rawFile.url = escapePoundSymbol(encodedFilePath);
|
||||
coverFile.rawFile.issueName = data.rawFileDetails.name;
|
||||
}
|
||||
// wanted
|
||||
|
||||
if (!isUndefined(data.locg)) {
|
||||
coverFile.locg.url = data.locg.cover;
|
||||
coverFile.locg.issueName = data.locg.name;
|
||||
@@ -78,30 +69,25 @@ export const determineCoverFile = (data): any => {
|
||||
|
||||
export const determineExternalMetadata = (
|
||||
metadataSource: string,
|
||||
source: any,
|
||||
): any => {
|
||||
if (!isNil(source)) {
|
||||
switch (metadataSource) {
|
||||
case "comicvine":
|
||||
return {
|
||||
coverURL:
|
||||
source.comicvine?.image.small_url ||
|
||||
source.comicvine.volumeInformation?.image.small_url,
|
||||
issue: source.comicvine.name,
|
||||
icon: "cvlogo.svg",
|
||||
};
|
||||
case "locg":
|
||||
return {
|
||||
coverURL: source.locg.cover,
|
||||
issue: source.locg.name,
|
||||
icon: "locglogo.svg",
|
||||
};
|
||||
case undefined:
|
||||
return {};
|
||||
source: any
|
||||
) => {
|
||||
switch (metadataSource) {
|
||||
case "comicvine":
|
||||
return {
|
||||
coverURL: source.comicvine.image.small_url,
|
||||
issue: source.comicvine.name,
|
||||
icon: "cvlogo.svg",
|
||||
};
|
||||
case "locg":
|
||||
return {
|
||||
coverURL: source.locg.cover,
|
||||
issue: source.locg.name,
|
||||
icon: "locglogo.svg",
|
||||
};
|
||||
case undefined:
|
||||
return {};
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,6 @@ export const detectIssueTypes = (deck: string): any => {
|
||||
const matches = map(issueTypeMatchers, (matcher) => {
|
||||
return getIssueTypeDisplayName(deck, matcher.regex, matcher.displayName);
|
||||
});
|
||||
|
||||
return compact(matches)[0];
|
||||
};
|
||||
|
||||
|
||||
@@ -3,17 +3,31 @@ import { isNil } from "lodash";
|
||||
import io from "socket.io-client";
|
||||
import { SOCKET_BASE_URI } from "../constants/endpoints";
|
||||
import { produce } from "immer";
|
||||
import AirDCPPSocket from "../services/DcppSearchService";
|
||||
import axios from "axios";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import "react-toastify/dist/ReactToastify.min.css";
|
||||
|
||||
/* Broadly, this file sets up:
|
||||
* 1. The zustand-based global client state
|
||||
* 2. socket.io client
|
||||
* 3. AirDC++ websocket connection
|
||||
*/
|
||||
export const useStore = create((set, get) => ({
|
||||
// AirDC++ state
|
||||
airDCPPSocketInstance: {},
|
||||
airDCPPSocketConnected: false,
|
||||
airDCPPDisconnectionInfo: {},
|
||||
airDCPPClientConfiguration: {},
|
||||
airDCPPSessionInformation: {},
|
||||
setAirDCPPSocketConnectionStatus: () =>
|
||||
set((value) => ({
|
||||
airDCPPSocketConnected: value,
|
||||
})),
|
||||
airDCPPDownloadTick: {},
|
||||
airDCPPTransfers: {},
|
||||
// Socket.io state
|
||||
socketIOInstance: {},
|
||||
|
||||
// ComicVine Scraping status
|
||||
comicvine: {
|
||||
scrapingStatus: "",
|
||||
@@ -84,7 +98,6 @@ if (!isNil(sessionId)) {
|
||||
"call",
|
||||
"socket.resumeSession",
|
||||
{
|
||||
namespace: "/",
|
||||
sessionId,
|
||||
},
|
||||
(data) => console.log(data),
|
||||
@@ -113,12 +126,11 @@ socketIOInstance.on("RESTORE_JOB_COUNTS_AFTER_SESSION_RESTORATION", (data) => {
|
||||
// by the LS_COVER_EXTRACTED/LS_COVER_EXTRACTION_FAILED events
|
||||
socketIOInstance.on("LS_COVER_EXTRACTED", (data) => {
|
||||
const { completedJobCount, importResult } = data;
|
||||
console.log(importResult);
|
||||
setState((state) => ({
|
||||
importJobQueue: {
|
||||
...state.importJobQueue,
|
||||
successfulJobCount: completedJobCount,
|
||||
mostRecentImport: importResult.data.rawFileDetails.name,
|
||||
mostRecentImport: importResult.rawFileDetails.name,
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -132,11 +144,6 @@ socketIOInstance.on("LS_COVER_EXTRACTION_FAILED", (data) => {
|
||||
}));
|
||||
});
|
||||
|
||||
socketIOInstance.on("searchResultsAvailable", (data) => {
|
||||
console.log(data);
|
||||
toast(`Results found for query: ${JSON.stringify(data.query, null, 2)}`);
|
||||
});
|
||||
|
||||
// 1b. Clear the localStorage sessionId upon receiving the
|
||||
// LS_IMPORT_QUEUE_DRAINED event
|
||||
socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
||||
@@ -147,6 +154,7 @@ socketIOInstance.on("LS_IMPORT_QUEUE_DRAINED", (data) => {
|
||||
status: "drained",
|
||||
},
|
||||
}));
|
||||
console.log("a", queryClient);
|
||||
queryClient.invalidateQueries({ queryKey: ["allImportJobResults"] });
|
||||
});
|
||||
|
||||
@@ -159,3 +167,105 @@ socketIOInstance.on("CV_SCRAPING_STATUS", (data) => {
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Method to init AirDC++ Socket with supplied settings
|
||||
* @param configuration - credentials, and hostname details to init AirDC++ connection
|
||||
* @returns Initialized AirDC++ connection socket instance
|
||||
*/
|
||||
export const initializeAirDCPPSocket = async (configuration): Promise<any> => {
|
||||
try {
|
||||
console.log("[AirDCPP]: Initializing socket...");
|
||||
|
||||
const initializedAirDCPPSocket = new AirDCPPSocket({
|
||||
protocol: `${configuration.protocol}`,
|
||||
hostname: `${configuration.hostname}:${configuration.port}`,
|
||||
username: `${configuration.username}`,
|
||||
password: `${configuration.password}`,
|
||||
});
|
||||
|
||||
// Set up connect and disconnect handlers
|
||||
initializedAirDCPPSocket.onConnected = (sessionInfo) => {
|
||||
// update global state with socket connection status
|
||||
setState({
|
||||
airDCPPSocketConnected: true,
|
||||
});
|
||||
};
|
||||
initializedAirDCPPSocket.onDisconnected = async (
|
||||
reason,
|
||||
code,
|
||||
wasClean,
|
||||
) => {
|
||||
// update global state with socket connection status
|
||||
setState({
|
||||
disconnectionInfo: { reason, code, wasClean },
|
||||
airDCPPSocketConnected: false,
|
||||
});
|
||||
};
|
||||
// AirDC++ Socket-related connection and post-connection
|
||||
// Attempt connection
|
||||
const airDCPPSessionInformation = await initializedAirDCPPSocket.connect();
|
||||
setState({
|
||||
airDCPPSessionInformation,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
initializedAirDCPPSocket.addListener(
|
||||
`queue`,
|
||||
"queue_bundle_tick",
|
||||
async (downloadProgressData) => {
|
||||
console.log(downloadProgressData);
|
||||
setState({
|
||||
airDCPPDownloadTick: downloadProgressData,
|
||||
});
|
||||
},
|
||||
);
|
||||
initializedAirDCPPSocket.addListener(
|
||||
"queue",
|
||||
"queue_bundle_added",
|
||||
async (data) => {
|
||||
console.log("JEMEN:", data);
|
||||
},
|
||||
);
|
||||
|
||||
initializedAirDCPPSocket.addListener(
|
||||
`queue`,
|
||||
"queue_bundle_status",
|
||||
async (bundleData) => {
|
||||
let count = 0;
|
||||
if (bundleData.status.completed && bundleData.status.downloaded) {
|
||||
// dispatch the action for raw import, with the metadata
|
||||
if (count < 1) {
|
||||
console.log(`[AirDCPP]: Download complete.`);
|
||||
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return initializedAirDCPPSocket;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. get settings from mongo
|
||||
const { data } = await axios({
|
||||
url: "http://localhost:3000/api/settings/getAllSettings",
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
const directConnectConfiguration = data?.directConnect?.client.host;
|
||||
|
||||
// 2. If available, init AirDC++ Socket with those settings
|
||||
if (!isNil(directConnectConfiguration)) {
|
||||
const airDCPPSocketInstance = await initializeAirDCPPSocket(
|
||||
directConnectConfiguration,
|
||||
);
|
||||
setState({
|
||||
airDCPPSocketInstance,
|
||||
airDCPPClientConfiguration: directConnectConfiguration,
|
||||
});
|
||||
} else {
|
||||
console.log("problem");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user