Rebuild with custom components

This commit is contained in:
2025-09-03 20:48:50 +02:00
parent 63618d15f4
commit 22e3ec7929
54 changed files with 8423 additions and 11407 deletions

View File

@@ -0,0 +1,6 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": false,
"singleQuote": true
}

View File

@@ -13,42 +13,42 @@ If you are developing a production application, we recommend updating the config
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
},
});
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
"react-x": reactX,
"react-dom": reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs["recommended-typescript"].rules,
...reactDom.configs.recommended.rules,
},
});
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -1,28 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
)

View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/blahaj.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tranga</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/blahaj.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tranga</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,39 @@
{
"name": "tranga-website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --fix",
"swagger-api": "swagger-typescript-api generate -p http://127.0.0.1:6531/swagger/v2/swagger.json -o ./src/apiClient --modular && prettier ./src/apiClient --write",
"prettier:check": "prettier . --check",
"prettier": "prettier . --write"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/inter": "^5.2.5",
"@mui/icons-material": "^7.0.1",
"@mui/joy": "^5.0.0-beta.52",
"@uiw/react-markdown-preview": "^5.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "3.6.2",
"swagger-typescript-api": "^13.2.7",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
"name": "tranga-website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --fix",
"swagger-api": "swagger-typescript-api generate -p http://127.0.0.1:6531/swagger/v2/swagger.json -o ./src/api --modular && prettier ./src/api --write",
"prettier:check": "prettier . --check",
"prettier": "prettier . --write"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/inter": "^5.2.5",
"@mui/icons-material": "^7.0.1",
"@mui/joy": "^5.0.0-beta.52",
"@uiw/react-markdown-preview": "^5.1.4",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"prettier": "3.6.2",
"swagger-typescript-api": "^13.2.7",
"typescript": "~5.7.2",
"typescript-eslint": "^8.24.1",
"vite": "^6.2.0"
}
}

View File

@@ -1,13 +1,13 @@
.app {
position: absolute;
height: 100%;
top: 0;
width: 100%;
left: 0;
position: absolute;
height: 100%;
top: 0;
width: 100%;
left: 0;
}
.app-content {
position: absolute;
width: 100%;
left: 0;
position: absolute;
width: 100%;
left: 0;
}

View File

@@ -1,108 +1,44 @@
import Sheet from "@mui/joy/Sheet";
import "./App.css";
import Settings from "./Components/Settings/Settings.tsx";
import Header from "./Header.tsx";
import { createContext, ReactNode, useEffect, useState } from "react";
import { V2 } from "./apiClient/V2.ts";
import { ApiContext } from "./apiClient/ApiContext.tsx";
import MangaList from "./Components/Mangas/MangaList.tsx";
import {
FileLibrary,
Manga,
MangaConnector,
MinimalManga,
} from "./apiClient/data-contracts.ts";
import Search from "./Components/Search.tsx";
import { Typography } from "@mui/joy";
import Workers from "./Components/WorkerModal/Workers.tsx";
const apiUri =
localStorage.getItem("apiUri") ??
window.location.href.substring(0, window.location.href.lastIndexOf("/")) +
"/api";
localStorage.setItem("apiUri", apiUri);
const Api = new V2({ baseUrl: apiUri });
const manga: Manga[] = [];
const promises: Map<string, Promise<Manga | undefined>> = new Map();
const getManga = async (key: string): Promise<Manga | undefined> => {
const result = manga.find((m) => m.key === key);
if (result) return result;
if (promises.has(key)) return promises.get(key);
const newPromise = retrieveManga(key);
promises.set(key, newPromise);
return newPromise;
};
const retrieveManga = async (key: string): Promise<Manga | undefined> => {
return Api.mangaDetail(key).then((response) => {
if (response.ok) {
manga.push(response.data);
return response.data;
}
return undefined;
});
};
export const MangaConnectorContext = createContext<MangaConnector[]>([]);
export const MangaContext = createContext<{
getManga: (key: string) => Promise<Manga | undefined>;
}>({
getManga,
});
export const FileLibraryContext = createContext<FileLibrary[]>([]);
const updateApiUri = (uri: string) => {
localStorage.setItem("apiUri", uri);
window.location.reload();
};
import Sheet from '@mui/joy/Sheet'
import Header from './Header.tsx'
import Settings from './Components/Settings/Settings.tsx'
import ApiProvider from './contexts/ApiContext.tsx'
import { useEffect, useState } from 'react'
import { ApiConfig } from './api/http-client.ts'
import MangaProvider from './contexts/MangaContext.tsx'
import MangaList from './Components/Mangas/MangaList.tsx'
import Search from './Search.tsx'
import MangaConnectorProvider from './contexts/MangaConnectorContext.tsx'
export default function App() {
const [mangaConnectors, setMangaConnectors] = useState<MangaConnector[]>([]);
const [downloadingManga, setDownloadingManga] = useState<MinimalManga[]>([]);
const [fileLibraries, setFileLibraries] = useState<FileLibrary[]>([]);
const [apiUri, setApiUri] = useState<string>(
localStorage.getItem('apiUri') ??
window.location.href.substring(
0,
window.location.href.lastIndexOf('/')
) + '/api'
)
const [apiConfig, setApiConfig] = useState<ApiConfig>({ baseUrl: apiUri })
useEffect(() => {
setApiConfig({ baseUrl: apiUri })
}, [apiUri])
useEffect(() => {
Api.mangaConnectorList().then((response) => {
if (response.ok) setMangaConnectors(response.data);
});
const [searchOpen, setSearchOpen] = useState<boolean>(false)
Api.fileLibraryList().then((response) => {
if (response.ok) setFileLibraries(response.data);
});
Api.mangaDownloadingList().then((response) => {
if (response.ok) setDownloadingManga(response.data);
});
}, []);
return (
<ApiContext.Provider value={Api}>
<FileLibraryContext value={fileLibraries}>
<MangaConnectorContext.Provider value={mangaConnectors}>
<MangaContext.Provider value={{ getManga }}>
{Api ? (
<Sheet className={"app"}>
<Header>
<Settings setApiUri={updateApiUri} />
<Workers />
</Header>
<Sheet className={"app-content"}>
<MangaList manga={downloadingManga}>
<Search />
</MangaList>
</Sheet>
</Sheet>
) : (
<Loading />
)}
</MangaContext.Provider>
</MangaConnectorContext.Provider>
</FileLibraryContext>
</ApiContext.Provider>
);
}
function Loading(): ReactNode {
return <Typography>Loading</Typography>;
return (
<ApiProvider apiConfig={apiConfig}>
<MangaConnectorProvider>
<MangaProvider>
<Sheet className={'app'}>
<Header>
<Settings setApiUri={setApiUri} />
</Header>
<Sheet className={'app-content'}>
<MangaList openSearch={() => setSearchOpen(true)} />
<Search open={searchOpen} setOpen={setSearchOpen} />
</Sheet>
</Sheet>
</MangaProvider>
</MangaConnectorProvider>
</ApiProvider>
)
}

View File

@@ -0,0 +1,33 @@
import { Button, CircularProgress } from '@mui/joy'
import TProps, { TColor, TDisabled, TState } from './TProps.ts'
import { MouseEventHandler, ReactNode, useState } from 'react'
export default function TButton(props: TButtonProps) {
const [state, setState] = useState<TState>(TState.clean)
const clicked: MouseEventHandler<HTMLAnchorElement> = (e) => {
setState(TState.busy)
e.preventDefault()
if (props.completionAction)
props
.completionAction(undefined)
.then(() => setState(TState.success))
.catch(() => setState(TState.failure))
}
return (
<Button
color={TColor(state)}
disabled={props.disabled ?? TDisabled(state)}
aria-disabled={props.disabled ?? TDisabled(state)}
onClick={clicked}
startDecorator={TDisabled(state) ? <CircularProgress /> : null}
>
{props.children}
</Button>
)
}
export interface TButtonProps extends TProps {
children?: ReactNode
}

View File

@@ -0,0 +1,89 @@
import { Button, CircularProgress, Input } from '@mui/joy'
import { MouseEventHandler, useEffect, useState } from 'react'
import * as React from 'react'
import TProps, { TColor, TDisabled, TState } from './TProps.ts'
export default function TInput(props: TInputProps) {
const [state, setState] = useState<TState>(TState.clean)
const [value, setValue] = useState<
string | number | readonly string[] | undefined
>(props.defaultValue)
const [initialValue, setInitialValue] = useState<
string | number | readonly string[] | undefined
>(props.defaultValue)
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)
const inputValueChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
setValue(e.target.value)
clearTimeout(timerRef.current)
if (!props.autoSubmit) return
timerRef.current = setTimeout(() => {
submit()
}, props.actionDelay ?? 1500)
}
const submitClicked: MouseEventHandler<HTMLAnchorElement> = (e) => {
e.preventDefault()
submit()
}
const keyDownHandler = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') submit()
}
const submit = () => {
setState(TState.busy)
clearTimeout(timerRef.current)
if (props.completionAction)
props
.completionAction(value)
.then(() => {
setState(TState.success)
setInitialValue(value)
})
.catch(() => setState(TState.failure))
}
useEffect(() => {
if (value == initialValue) {
setState(TState.clean)
}
}, [value, initialValue])
return (
<Input
color={TColor(state)}
disabled={props.disabled ?? TDisabled(state)}
aria-disabled={props.disabled ?? TDisabled(state)}
placeholder={props.placeholder}
value={value}
onChange={inputValueChanged}
onKeyDown={keyDownHandler}
startDecorator={TDisabled(state) ? <CircularProgress /> : null}
endDecorator={
props.submitButtonHidden ? null : (
<Button
onClick={submitClicked}
disabled={props.disabled ?? TDisabled(state)}
aria-disabled={props.disabled ?? TDisabled(state)}
>
{props.submitButtonText ?? 'Submit'}
</Button>
)
}
/>
)
}
export interface TInputProps extends TProps {
placeholder?: string
defaultValue?: string | number | readonly string[]
actionDelay?: number
autoSubmit?: boolean
submitButtonHidden?: boolean
submitButtonText?: string
}

View File

@@ -0,0 +1,40 @@
import { ColorPaletteProp } from '@mui/joy'
export enum TState {
clean,
dirty,
busy,
success,
failure,
}
export const TDisabled = (state: TState): boolean => {
switch (state) {
case TState.busy:
return true
default:
return false
}
}
export const TColor = (state: TState): ColorPaletteProp => {
switch (state) {
case TState.clean:
return 'primary'
case TState.dirty:
return 'warning'
case TState.busy:
return 'neutral'
case TState.success:
return 'success'
case TState.failure:
return 'warning'
}
}
export default interface TProps {
disabled?: boolean
completionAction?: (
value: string | number | readonly string[] | undefined
) => Promise<void>
}

View File

@@ -1,34 +0,0 @@
import { Close, Done } from "@mui/icons-material";
import { CircularProgress, ColorPaletteProp } from "@mui/joy";
import { ReactNode } from "react";
export enum LoadingState {
none,
loading,
success,
failure,
}
export function StateIndicator(state: LoadingState): ReactNode {
switch (state) {
case LoadingState.loading:
return <CircularProgress />;
case LoadingState.failure:
return <Close />;
case LoadingState.success:
return <Done />;
default:
return null;
}
}
export function StateColor(state: LoadingState): ColorPaletteProp | undefined {
switch (state) {
case LoadingState.failure:
return "danger";
case LoadingState.success:
return "success";
default:
return undefined;
}
}

View File

@@ -1,102 +0,0 @@
import {
CSSProperties,
ReactNode,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
ChapterMangaConnectorId,
MangaConnector,
MangaMangaConnectorId,
} from "../apiClient/data-contracts.ts";
import { Link, Tooltip, Typography } from "@mui/joy";
import { MangaConnectorContext } from "../App.tsx";
import { ApiContext } from "../apiClient/ApiContext.tsx";
export default function MangaConnectorLink({
MangaConnectorId,
imageStyle,
printName,
}: {
MangaConnectorId: MangaMangaConnectorId | ChapterMangaConnectorId;
imageStyle?: CSSProperties;
printName?: boolean;
}): ReactNode {
const mangaConnectorContext = useContext(MangaConnectorContext);
const [mangaConnector, setMangaConnector] = useState<
MangaConnector | undefined
>(
mangaConnectorContext?.find(
(c) => c.name == MangaConnectorId.mangaConnectorName,
),
);
const imageRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const connector = mangaConnectorContext?.find(
(c) => c.name == MangaConnectorId.mangaConnectorName,
);
setMangaConnector(connector);
if (imageRef?.current != null)
imageRef.current.setHTMLUnsafe(
`<img ref=${imageRef} src=${mangaConnector?.iconUrl} style=${imageStyle}/>`,
);
}, [MangaConnectorId, imageStyle, mangaConnector, mangaConnectorContext]);
return (
<Tooltip
title={
<Typography>
{MangaConnectorId.mangaConnectorName}:{" "}
<Link href={MangaConnectorId.websiteUrl as string}>
{MangaConnectorId.websiteUrl}
</Link>
</Typography>
}
>
<Link href={MangaConnectorId.websiteUrl as string}>
<img
ref={imageRef}
src={mangaConnector?.iconUrl}
style={imageStyle}
className={"manga-card-badge-icon"}
/>
{printName ? <Typography>{mangaConnector?.name}</Typography> : null}
</Link>
</Tooltip>
);
}
export function MangaConnectorLinkFromId({
MangaConnectorIdId,
imageStyle,
printName,
}: {
MangaConnectorIdId: string;
imageStyle?: CSSProperties;
printName?: boolean;
}): ReactNode {
const Api = useContext(ApiContext);
const [node, setNode] = useState<ReactNode>(null);
useEffect(() => {
Api.queryMangaMangaConnectorIdDetail(MangaConnectorIdId).then(
(response) => {
if (response.ok)
setNode(
<MangaConnectorLink
key={response.data.key}
MangaConnectorId={response.data}
imageStyle={{ ...imageStyle, width: "25px" }}
printName={printName}
/>,
);
},
);
}, [Api, MangaConnectorIdId, imageStyle, printName]);
return node;
}

View File

@@ -1,25 +1,25 @@
.manga-card-badge {
margin: 16px 18px 0 0;
}
.manga-card {
width: 220px;
height: 300px;
width: 220px;
height: 300px;
}
.manga-cover-blur {
background: linear-gradient(
135deg,
rgba(245, 169, 184, 0.85) 40%,
rgba(91, 206, 250, 0.3)
);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
)backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
.manga-card-cover {
}
.manga-card-badge-icon {
width: 25px;
height: 25px;
.manga-card-cover-blur {
background: linear-gradient(
135deg,
rgba(245, 169, 184, 0.85) 40%,
rgba(91, 206, 250, 0.3)
);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.manga-modal {
width: 90%;
margin: auto;
.manga-card-content {
}

View File

@@ -1,180 +1,65 @@
import {
Badge,
Box,
Card,
CardContent,
CardCover,
Chip,
Link,
Modal,
ModalDialog,
Stack,
Tooltip,
Typography,
} from "@mui/joy";
import { Manga, MinimalManga } from "../../apiClient/data-contracts.ts";
import {
Dispatch,
ReactNode,
SetStateAction,
useContext,
useEffect,
useState,
} from "react";
import "./MangaCard.css";
import MangaConnectorBadge from "./MangaConnectorBadge.tsx";
import ModalClose from "@mui/joy/ModalClose";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import MarkdownPreview from "@uiw/react-markdown-preview";
import { MangaContext } from "../../App.tsx";
import { MangaConnectorLinkFromId } from "../MangaConnectorLink.tsx";
Badge,
Card,
CardContent,
CardCover,
Skeleton,
Typography,
} from '@mui/joy'
import { EventHandler, ReactNode, useContext, useEffect, useState } from 'react'
import './MangaCard.css'
import MangaConnectorIcon from './MangaConnectorIcon.tsx'
import { Manga, MinimalManga } from '../../api/data-contracts.ts'
import { ApiContext } from '../../contexts/ApiContext.tsx'
export function MangaCard({
manga,
children,
export default function MangaCard({
mangaDetail,
key,
onClick,
}: {
manga: MinimalManga | undefined;
children?: ReactNode;
}) {
const [open, setOpen] = useState(false);
mangaDetail?: Manga | MinimalManga
key?: string
onClick?: EventHandler<any>
}): ReactNode {
const Api = useContext(ApiContext)
if (manga === undefined) return PlaceHolderCard();
const [manga, setManga] = useState<Manga | MinimalManga | undefined>(
mangaDetail
)
return (
<MangaConnectorBadge manga={manga}>
<Card className={"manga-card"} onClick={() => setOpen(true)}>
<CardCover className={"manga-cover"}>
<MangaCover mangaId={manga?.key} />
</CardCover>
<CardCover className={"manga-cover-blur"} />
<CardContent className={"manga-content"}>
<Typography level={"title-lg"}>{manga?.name}</Typography>
</CardContent>
</Card>
<MangaModal minimalManga={manga} open={open} setOpen={setOpen}>
{children}
</MangaModal>
</MangaConnectorBadge>
);
}
useEffect(() => {
if (!key) return
Api.mangaDetail(key).then((data) => {
if (data.ok) {
setManga(data.data)
}
})
}, [Api, key])
export function MangaModal({
minimalManga,
open,
setOpen,
children,
}: {
minimalManga: MinimalManga;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
children?: ReactNode;
}) {
const { getManga } = useContext(MangaContext);
const [manga, setManga] = useState<Manga>();
useEffect(() => {
getManga(minimalManga.key).then(setManga);
}, [getManga, minimalManga]);
return (
<Modal open={open} onClose={() => setOpen(false)} className={"manga-modal"}>
<ModalDialog style={{ width: "100%" }}>
<ModalClose />
<Tooltip
title={
<Stack spacing={1}>
{manga?.altTitles?.map((title) => (
<Chip>{title.title}</Chip>
))}
</Stack>
}
return (
<Badge
badgeContent={manga?.mangaConnectorIds.map((id) => (
<MangaConnectorIcon key={id.mangaConnectorName} />
))}
className={'manga-card-badge'}
>
<Typography level={"h4"} width={"fit-content"}>
{manga?.name ?? minimalManga.name}
</Typography>
</Tooltip>
<Stack direction={"row"} spacing={2}>
<Box key={"Cover"} className={"manga-card"}>
<MangaCover mangaId={minimalManga.key} />
</Box>
<Stack
key={"Description"}
direction={"column"}
sx={{ width: "calc(100% - 230px)" }}
>
<Stack
key={"Tags"}
direction={"row"}
flexWrap={"wrap"}
useFlexGap
spacing={0.5}
>
{manga?.mangaConnectorIdsIds?.map((idid) => (
<MangaConnectorLinkFromId
key={idid}
MangaConnectorIdId={idid}
/>
))}
{manga?.mangaTags?.map((tag) => (
<Chip key={tag.tag}>{tag.tag}</Chip>
))}
{manga?.links?.map((link) => (
<Chip key={link.key}>
<Link href={link.linkUrl}>{link.linkProvider}</Link>
</Chip>
))}
</Stack>
<Box sx={{ flexGrow: 1 }}>
<MarkdownPreview
source={manga?.description ?? "Loading..."}
style={{
background: "transparent",
maxHeight: "50vh",
overflowY: "auto",
}}
/>
</Box>
<Stack
sx={{ justifySelf: "flex-end", alignSelf: "flex-end" }}
spacing={2}
direction={"row"}
>
{manga ? children : null}
</Stack>
</Stack>
</Stack>
</ModalDialog>
</Modal>
);
<Card className={'manga-card'} onClick={onClick}>
<CardCover className={'manga-card-cover'}>
<img src={'/blahaj.png'} />
</CardCover>
<CardCover className={'manga-card-cover-blur'} />
<CardContent className={'manga-card-content'}>
<Typography level={'h4'}>
{manga?.name ?? (
<Skeleton>{stringWithRandomLength()}</Skeleton>
)}
</Typography>
</CardContent>
</Card>
</Badge>
)
}
function PlaceHolderCard() {
return (
<Badge>
<Card className={"manga-card"}>
<CardCover className={"manga-cover"}>
<img src={"/blahaj.png"} />
</CardCover>
<CardCover className={"manga-cover-blur"} />
</Card>
</Badge>
);
}
function MangaCover({ mangaId }: { mangaId?: string }) {
const api = useContext(ApiContext);
const uri = mangaId
? `${api.baseUrl}/v2/Manga/${mangaId}/Cover`
: "blahaj.png";
return (
<img
src={uri}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
borderRadius: "var(--CardCover-radius)",
}}
/>
);
const stringWithRandomLength = (): string => {
return 'wow'
}

View File

@@ -1,23 +0,0 @@
import { Badge } from "@mui/joy";
import { MinimalManga } from "../../apiClient/data-contracts.ts";
import { ReactNode } from "react";
import "./MangaCard.css";
import MangaConnectorLink from "../MangaConnectorLink.tsx";
export default function MangaConnectorBadge({
manga,
children,
}: {
manga: MinimalManga;
children?: ReactNode;
}) {
return (
<Badge
badgeContent={manga.mangaConnectorIds?.map((id) => (
<MangaConnectorLink key={id.key} MangaConnectorId={id} />
))}
>
{children}
</Badge>
);
}

View File

@@ -0,0 +1,41 @@
import { ReactNode, useContext, useEffect, useState } from 'react'
import { MangaConnector } from '../../api/data-contracts.ts'
import { Tooltip } from '@mui/joy'
import { ApiContext } from '../../contexts/ApiContext.tsx'
export default function MangaConnectorIcon({
mangaConnector,
key,
}: {
mangaConnector?: MangaConnector
key?: string
}): ReactNode {
const Api = useContext(ApiContext)
const [connector, setConnector] = useState<MangaConnector | undefined>(
mangaConnector
)
useEffect(() => {
if (mangaConnector) {
setConnector(mangaConnector)
return
}
if (!key) return
Api.mangaConnectorDetail(key).then((result) => {
if (result.ok) {
setConnector(result.data)
}
})
}, [Api, key, mangaConnector])
return (
<Tooltip title={connector?.name ?? 'loading'}>
<img
src={connector?.iconUrl ?? '/blahaj.png'}
width={'25px'}
height={'25px'}
/>
</Tooltip>
)
}

View File

@@ -1,148 +0,0 @@
import { Manga } from "../../apiClient/data-contracts.ts";
import {
ChangeEvent,
Dispatch,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { Button, Checkbox, Option, Select, Stack, Typography } from "@mui/joy";
import Drawer from "@mui/joy/Drawer";
import ModalClose from "@mui/joy/ModalClose";
import { MangaConnectorLinkFromId } from "../MangaConnectorLink.tsx";
import Sheet from "@mui/joy/Sheet";
import { FileLibraryContext, MangaContext } from "../../App.tsx";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { LoadingState, StateIndicator } from "../Loading.tsx";
import * as React from "react";
export default function ({ mangaId }: { mangaId: string }): ReactNode {
const [open, setOpen] = useState(false);
const { getManga } = useContext(MangaContext);
const [manga, setManga] = useState<Manga>();
useEffect(() => {
getManga(mangaId).then(setManga);
}, [getManga, mangaId]);
return (
<>
<Button onClick={() => setOpen(true)}>Download</Button>
<DownloadDrawer manga={manga} open={open} setOpen={setOpen} />
</>
);
}
function DownloadDrawer({
manga,
open,
setOpen,
}: {
manga: Manga | undefined;
open: boolean;
setOpen: Dispatch<boolean>;
}): ReactNode {
const fileLibraries = useContext(FileLibraryContext);
const Api = useContext(ApiContext);
const onLibraryChange = (
_: React.MouseEvent | React.KeyboardEvent | React.FocusEvent | null,
value: string | null,
) => {
if (!value) return;
if (!manga) return;
Api.mangaChangeLibraryCreate(manga.key, value);
};
return (
<Drawer open={open} onClose={() => setOpen(false)}>
<ModalClose />
<Sheet sx={{ width: "calc(95% - 60px)", margin: "30px" }}>
<Typography>Download to Library:</Typography>
<Select
placeholder={"Library"}
onChange={onLibraryChange}
value={manga?.libraryId}
>
{fileLibraries?.map((library) => (
<Option value={library.key} key={library.key}>
<Typography>{library.libraryName}</Typography>{" "}
<Typography>({library.basePath})</Typography>
</Option>
))}
</Select>
<Typography>Download from:</Typography>
<Stack>
{manga?.mangaConnectorIdsIds?.map((id) => (
<DownloadCheckBox key={id} mangaConnectorIdId={id} />
))}
</Stack>
</Sheet>
</Drawer>
);
}
function DownloadCheckBox({
mangaConnectorIdId,
}: {
mangaConnectorIdId: string;
}): ReactNode {
const Api = useContext(ApiContext);
const [useForDownloading, setUseForDownloading] = useState<boolean>(false);
const [loading, setLoading] = useState<LoadingState>(LoadingState.none);
useEffect(() => {
setLoading(LoadingState.loading);
Api.queryMangaMangaConnectorIdDetail(mangaConnectorIdId)
.then((response) => {
if (response.ok) {
setUseForDownloading(response.data.useForDownload as boolean);
setLoading(LoadingState.none);
} else setLoading(LoadingState.failure);
})
.catch((_) => setLoading(LoadingState.failure));
}, [Api, mangaConnectorIdId]);
const onSelected = (event: ChangeEvent<HTMLInputElement>) => {
setLoading(LoadingState.loading);
const val = event.currentTarget.checked;
Api.queryMangaMangaConnectorIdDetail(mangaConnectorIdId)
.then((response) => {
if (!response.ok) {
setLoading(LoadingState.failure);
return;
}
Api.mangaSetAsDownloadFromCreate(
response.data.objId,
response.data.mangaConnectorName,
val,
)
.then((response) => {
if (response.ok) {
setUseForDownloading(val);
setLoading(LoadingState.success);
} else setLoading(LoadingState.failure);
})
.catch((_) => setLoading(LoadingState.failure));
})
.catch((_) => setLoading(LoadingState.failure));
};
return (
<Checkbox
indeterminateIcon={StateIndicator(LoadingState.loading)}
indeterminate={loading === LoadingState.loading}
disabled={loading === LoadingState.loading}
checked={useForDownloading}
onChange={onSelected}
label={
<Typography>
<MangaConnectorLinkFromId
MangaConnectorIdId={mangaConnectorIdId}
printName={true}
/>
</Typography>
}
/>
);
}

View File

@@ -1,3 +0,0 @@
.manga-list {
padding-top: 12px;
}

View File

@@ -1,38 +1,72 @@
import { ReactNode } from "react";
import { MangaCard } from "./MangaCard.tsx";
import { Stack } from "@mui/joy";
import "./MangaList.css";
import { MinimalManga } from "../../apiClient/data-contracts.ts";
import MangaDownloadDialog from "./MangaDownloadDialog.tsx";
import MangaMerge from "./MangaMerge.tsx";
import { Stack } from '@mui/joy'
import './MangaList.css'
import { ReactNode, useContext, useEffect, useState } from 'react'
import {
Manga,
MangaReleaseStatus,
MinimalManga,
} from '../../api/data-contracts.ts'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import MangaCard from './MangaCard.tsx'
export default function MangaList({
manga,
children,
openSearch,
}: {
manga: MinimalManga[];
children?: ReactNode;
}) {
return (
<Stack
className={"manga-list"}
direction={"row"}
useFlexGap={true}
spacing={2}
flexWrap={"wrap"}
sx={{
mx: "calc(-1 * var(--ModalDialog-padding))",
px: "var(--ModalDialog-padding)",
overflowY: "scroll",
}}
>
{children}
{manga?.map((minimalManga) => (
<MangaCard key={minimalManga.key} manga={minimalManga}>
<MangaDownloadDialog mangaId={minimalManga.key} />
<MangaMerge manga={minimalManga} />
</MangaCard>
))}
</Stack>
);
openSearch: () => void
}): ReactNode {
const Api = useContext(ApiContext)
const [downloadingManga, setDownloadingManga] = useState<MinimalManga[]>([])
useEffect(() => {
Api.mangaDownloadingList().then((data) => {
if (data.ok) {
setDownloadingManga(data.data)
}
})
}, [Api])
return (
<MangaCardList manga={downloadingManga}>
<MangaCard
onClick={openSearch}
mangaDetail={{
name: 'Search',
description: 'Search for a new Manga',
releaseStatus: MangaReleaseStatus.Continuing,
mangaConnectorIds: [],
key: 'Search',
}}
/>
</MangaCardList>
)
}
export function MangaCardList({
manga,
children,
}: {
manga: (Manga | MinimalManga)[]
children?: ReactNode
}): ReactNode {
return (
<Stack
className={'manga-list'}
direction={'row'}
flexWrap={'wrap'}
sx={{
mx: 'calc(-1 * var(--ModalDialog-padding))',
px: 'var(--ModalDialog-padding)',
overflowY: 'scroll',
justifyItems: 'space-between',
}}
>
{children}
{manga.map((m) => (
<>
<MangaCard mangaDetail={m} />
<span style={{ flexGrow: 1 }} />
</>
))}
</Stack>
)
}

View File

@@ -1,143 +0,0 @@
import { ReactNode, useContext, useEffect, useState } from "react";
import { Manga, MinimalManga } from "../../apiClient/data-contracts.ts";
import Drawer from "@mui/joy/Drawer";
import ModalClose from "@mui/joy/ModalClose";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { MangaCard } from "./MangaCard.tsx";
import {
Alert,
Button,
Modal,
ModalDialog,
Stack,
Tooltip,
Typography,
} from "@mui/joy";
import { KeyboardDoubleArrowRight, Warning } from "@mui/icons-material";
import { LoadingState, StateIndicator } from "../Loading.tsx";
export default function ({
manga,
}: {
manga: MinimalManga | undefined;
}): ReactNode {
const Api = useContext(ApiContext);
const [similar, setSimilar] = useState<Manga[]>();
const [open, setOpen] = useState<boolean>(false);
useEffect(() => {
if (manga === undefined || !open) return;
Api.queryMangaSimilarNameList(manga.key as string).then((response) => {
if (response.ok)
Api.mangaWithIDsCreate(response.data).then((response) => {
if (response.ok) setSimilar(response.data);
});
});
}, [Api, manga, open]);
const exit = (manga: Manga) => {
setOpen(false);
setSimilar(similar?.filter((m) => m.key != manga.key));
};
return (
<>
<Button onClick={(_) => setOpen(true)}>Merge</Button>
<Drawer
size={"lg"}
open={open}
onClose={() => setOpen(false)}
anchor={"bottom"}
>
<ModalClose />
<Typography>Merge targets: {similar?.length ?? 0}</Typography>
<Stack direction={"row"} spacing={2} flexWrap={"wrap"} useFlexGap>
{similar
? similar?.map((similarManga) => (
<MangaCard manga={similarManga}>
<ConfirmationModal
manga={manga as Manga}
similarManga={similarManga}
exit={() => exit(similarManga)}
/>
</MangaCard>
))
: "Loading..."}
</Stack>
</Drawer>
</>
);
}
function ConfirmationModal({
manga,
similarManga,
exit,
}: {
manga: Manga;
similarManga: Manga;
exit: () => void;
}): ReactNode {
const [open, setOpen] = useState<boolean>(false);
const Api = useContext(ApiContext);
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.none,
);
const merge = () => {
setLoadingState(LoadingState.loading);
Api.mangaMergeIntoPartialUpdate(
manga.key as string,
similarManga.key as string,
)
.then((response) => {
if (response.ok) {
setLoadingState(LoadingState.success);
setOpen(false);
exit();
} else setLoadingState(LoadingState.failure);
})
.catch((_) => setLoadingState(LoadingState.failure));
};
return (
<>
{loadingState == LoadingState.success ? (
<Alert
color="success"
startDecorator={StateIndicator(LoadingState.success)}
>
Merged Successfully!
</Alert>
) : (
<Button onClick={(_) => setOpen(true)}>Merge into</Button>
)}
<Modal open={open} onClose={(_) => setOpen(false)}>
<ModalDialog>
<ModalClose />
<Typography level={"h2"}>Confirm Merge</Typography>
<Stack direction={"row"} spacing={3} alignItems={"center"}>
<MangaCard manga={manga} />
<Typography color={"danger"} level={"h1"}>
<KeyboardDoubleArrowRight />
</Typography>
<MangaCard manga={similarManga} />
</Stack>
<Tooltip title={"THIS CAN NOT BE UNDONE!"}>
<Button
onClick={merge}
disabled={loadingState === LoadingState.loading}
endDecorator={StateIndicator(loadingState)}
color={"danger"}
startDecorator={<Warning />}
>
Merge
</Button>
</Tooltip>
</ModalDialog>
</Modal>
</>
);
}

View File

@@ -1,175 +0,0 @@
import {
Dispatch,
KeyboardEventHandler,
ReactNode,
useContext,
useState,
} from "react";
import {
Badge,
Button,
Card,
CardContent,
CardCover,
Chip,
Input,
Modal,
ModalDialog,
Option,
Select,
Step,
StepIndicator,
Stepper,
Typography,
} from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose";
import { MangaConnectorContext } from "../App.tsx";
import { MangaConnector, MinimalManga } from "../apiClient/data-contracts.ts";
import MangaList from "./Mangas/MangaList.tsx";
import { ApiContext } from "../apiClient/ApiContext.tsx";
import { LoadingState, StateColor, StateIndicator } from "./Loading.tsx";
export default function (): ReactNode {
const [open, setOpen] = useState(false);
return (
<Badge badgeContent={"+"}>
<Card
onClick={() => {
if (!open) setOpen(true);
}}
className={"manga-card"}
>
<CardCover className={"manga-cover"}>
<img src={"/blahaj.png"} />
</CardCover>
<CardCover className={"manga-cover-blur"} />
<CardContent>Add</CardContent>
<CardContent>
<SearchDialog open={open} setOpen={setOpen} />
</CardContent>
</Card>
</Badge>
);
}
function SearchDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<boolean>;
}): ReactNode {
const mangaConnectors = useContext(MangaConnectorContext);
const Api = useContext(ApiContext);
const [selectedMangaConnector, setSelectedMangaConnector] = useState<
MangaConnector | undefined
>(undefined);
const [searchTerm, setSearchTerm] = useState<string>();
const [searchResults, setSearchResults] = useState<MinimalManga[]>([]);
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.none,
);
const doTheSearch = () => {
if (searchTerm === undefined || searchTerm.length < 1) return;
if (!isUrl(searchTerm) && selectedMangaConnector === undefined) return;
setLoadingState(LoadingState.loading);
if (isUrl(searchTerm))
Api.searchUrlCreate(searchTerm)
.then((response) => {
if (response.ok) {
setSearchResults([response.data]);
setLoadingState(LoadingState.success);
} else setLoadingState(LoadingState.failure);
})
.catch(() => setLoadingState(LoadingState.failure));
else
Api.searchDetail(selectedMangaConnector!.name, searchTerm)
.then((response) => {
if (response.ok) {
setSearchResults(response.data);
setLoadingState(LoadingState.success);
} else setLoadingState(LoadingState.failure);
})
.catch(() => setLoadingState(LoadingState.failure));
};
const isUrl = (url: string) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
const keyDownCheck: KeyboardEventHandler<HTMLInputElement> = (e) => {
if (e.key === "Enter") {
doTheSearch();
}
};
return (
<Modal
sx={{ width: "100%", height: "100%" }}
open={open}
onClose={() => setOpen(false)}
>
<ModalDialog sx={{ width: "80%" }}>
<ModalClose />
<Stepper orientation={"vertical"}>
<Step indicator={<StepIndicator>1</StepIndicator>}>
<Typography>Connector</Typography>
<Select
disabled={loadingState == LoadingState.loading}
onChange={(_, v) =>
setSelectedMangaConnector(v as MangaConnector)
}
>
{mangaConnectors?.map((con) => (
<Option value={con}>
<Typography>
<img
src={con.iconUrl}
style={{ maxHeight: "var(--Icon-fontSize)" }}
/>
{con.name}
</Typography>
</Option>
))}
</Select>
</Step>
<Step indicator={<StepIndicator>2</StepIndicator>}>
<Typography>Search</Typography>
<Input
disabled={loadingState == LoadingState.loading}
onKeyDown={keyDownCheck}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
endDecorator={
<Button
disabled={loadingState == LoadingState.loading}
onClick={doTheSearch}
endDecorator={StateIndicator(loadingState)}
color={StateColor(loadingState)}
>
Search
</Button>
}
/>
</Step>
<Step indicator={<StepIndicator>3</StepIndicator>}>
<Typography>
Result <Chip>{searchResults.length}</Chip>
</Typography>
<MangaList manga={searchResults} />
</Step>
</Stepper>
</ModalDialog>
</Modal>
);
}

View File

@@ -1,43 +1,41 @@
import { ReactNode, useContext, useState } from "react";
import { SettingsContext, SettingsItem } from "./Settings.tsx";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { ColorPaletteProp, Input } from "@mui/joy";
import * as React from "react";
import MarkdownPreview from "@uiw/react-markdown-preview";
import { ReactNode, useContext } from 'react'
import { SettingsContext, SettingsItem } from './Settings.tsx'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import MarkdownPreview from '@uiw/react-markdown-preview'
import TInput from '../Inputs/TInput.tsx'
export default function (): ReactNode {
const settings = useContext(SettingsContext);
const Api = useContext(ApiContext);
export default function ChapterNamingScheme(): ReactNode {
const settings = useContext(SettingsContext)
const Api = useContext(ApiContext)
const [scheme, setScheme] = useState<ColorPaletteProp>("neutral");
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const schemeChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timerRef.current);
setScheme("warning");
timerRef.current = setTimeout(() => {
Api.settingsChapterNamingSchemePartialUpdate(e.target.value)
.then((response) => {
if (response.ok) setScheme("success");
else setScheme("danger");
})
.catch(() => setScheme("danger"));
}, 1000);
};
return (
<SettingsItem title={"Chapter Naming Scheme"}>
<MarkdownPreview
style={{ backgroundColor: "transparent" }}
source={
"Placeholders:\n * %M Obj Name\n * %V Volume\n * %C Chapter\n * %T Title\n * %A Author (first in list)\n * %I Chapter Internal ID\n * %i Obj Internal ID\n * %Y Year (Obj)\n *\n * ?_(...) replace _ with a value from above:\n * Everything inside the braces will only be added if the value of %_ is not null"
const schemeChanged = async (
value: string | number | readonly string[] | undefined
) => {
if (typeof value != 'string') return Promise.reject()
try {
const response =
await Api.settingsChapterNamingSchemePartialUpdate(value)
if (response.ok) return Promise.resolve()
else return Promise.reject()
} catch {
return await Promise.reject()
}
/>
<Input
color={scheme}
defaultValue={settings?.chapterNamingScheme as string}
placeholder={"Scheme"}
onChange={schemeChanged}
/>
</SettingsItem>
);
}
return (
<SettingsItem title={'Chapter Naming Scheme'}>
<MarkdownPreview
style={{ backgroundColor: 'transparent' }}
source={
'Placeholders:\n * %M Obj Name\n * %V Volume\n * %C Chapter\n * %T Title\n * %A Author (first in list)\n * %I Chapter Internal ID\n * %i Obj Internal ID\n * %Y Year (Obj)\n *\n * ?_(...) replace _ with a value from above:\n * Everything inside the braces will only be added if the value of %_ is not null'
}
/>
<TInput
defaultValue={settings?.chapterNamingScheme as string}
placeholder={'Scheme'}
completionAction={schemeChanged}
actionDelay={5000}
/>
</SettingsItem>
)
}

View File

@@ -1,14 +1,14 @@
import { SettingsItem } from "./Settings.tsx";
import ImageCompression from "./ImageCompression.tsx";
import DownloadLanguage from "./DownloadLanguage.tsx";
import ChapterNamingScheme from "./ChapterNamingScheme.tsx";
import { SettingsItem } from './Settings.tsx'
import ImageCompression from './ImageCompression.tsx'
import DownloadLanguage from './DownloadLanguage.tsx'
import ChapterNamingScheme from './ChapterNamingScheme.tsx'
export default function () {
return (
<SettingsItem title={"Download"}>
<ImageCompression />
<DownloadLanguage />
<ChapterNamingScheme />
</SettingsItem>
);
export default function Download() {
return (
<SettingsItem title={'Download'}>
<ImageCompression />
<DownloadLanguage />
<ChapterNamingScheme />
</SettingsItem>
)
}

View File

@@ -1,36 +1,33 @@
import { ReactNode, useContext, useState } from "react";
import { SettingsContext, SettingsItem } from "./Settings.tsx";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { ColorPaletteProp, Input } from "@mui/joy";
import * as React from "react";
import { ReactNode, useContext } from 'react'
import { SettingsContext, SettingsItem } from './Settings.tsx'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import TInput from '../Inputs/TInput.tsx'
export default function (): ReactNode {
const settings = useContext(SettingsContext);
const Api = useContext(ApiContext);
export default function DownloadLanguage(): ReactNode {
const settings = useContext(SettingsContext)
const Api = useContext(ApiContext)
const [color, setColor] = useState<ColorPaletteProp>("neutral");
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const languageChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timerRef.current);
setColor("warning");
timerRef.current = setTimeout(() => {
Api.settingsDownloadLanguagePartialUpdate(e.target.value)
.then((response) => {
if (response.ok) setColor("success");
else setColor("danger");
})
.catch(() => setColor("danger"));
}, 1000);
};
const languageChanged = async (
value: string | number | readonly string[] | undefined
) => {
if (typeof value != 'string') return Promise.reject()
try {
const response =
await Api.settingsDownloadLanguagePartialUpdate(value)
if (response.ok) return Promise.resolve()
else return Promise.reject()
} catch {
return await Promise.reject()
}
}
return (
<SettingsItem title={"Download Language"}>
<Input
color={color}
defaultValue={settings?.downloadLanguage as string}
placeholder={"Language code (f.e. 'en')"}
onChange={languageChanged}
/>
</SettingsItem>
);
return (
<SettingsItem title={'Download Language'}>
<TInput
defaultValue={settings?.downloadLanguage as string}
placeholder={"Language code (f.e. 'en')"}
completionAction={languageChanged}
/>
</SettingsItem>
)
}

View File

@@ -1,37 +1,32 @@
import { ReactNode, useContext, useState } from "react";
import { SettingsContext, SettingsItem } from "./Settings.tsx";
import { ColorPaletteProp, Input } from "@mui/joy";
import * as React from "react";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { ReactNode, useContext } from 'react'
import { SettingsContext, SettingsItem } from './Settings.tsx'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import TInput from '../Inputs/TInput.tsx'
export default function (): ReactNode {
const settings = useContext(SettingsContext);
const Api = useContext(ApiContext);
export default function FlareSolverr(): ReactNode {
const settings = useContext(SettingsContext)
const Api = useContext(ApiContext)
const [uriColor, setUriColor] = useState<ColorPaletteProp>("neutral");
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const uriChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timerRef.current);
setUriColor("warning");
timerRef.current = setTimeout(() => {
Api.settingsFlareSolverrUrlCreate(e.target.value)
.then((response) => {
if (response.ok) setUriColor("success");
else setUriColor("danger");
})
.catch(() => setUriColor("danger"));
}, 1000);
};
const uriChanged = async (
value: string | number | readonly string[] | undefined
) => {
if (typeof value != 'string') return Promise.reject()
try {
const response = await Api.settingsFlareSolverrUrlCreate(value)
if (response.ok) return Promise.resolve()
else return Promise.reject()
} catch (reason) {
return await Promise.reject(reason)
}
}
return (
<SettingsItem title={"FlareSolverr"}>
<Input
color={uriColor}
defaultValue={settings?.flareSolverrUrl as string}
type={"url"}
placeholder={"URL"}
onChange={uriChanged}
/>
</SettingsItem>
);
return (
<SettingsItem title={'FlareSolverr'}>
<TInput
placeholder={'FlareSolverr URL'}
defaultValue={settings?.flareSolverrUrl as string}
completionAction={uriChanged}
/>
</SettingsItem>
)
}

View File

@@ -1,17 +1,17 @@
import { ReactNode, useContext } from "react";
import { SettingsContext, SettingsItem } from "./Settings.tsx";
import { Slider } from "@mui/joy";
import { ReactNode, useContext } from 'react'
import { SettingsContext, SettingsItem } from './Settings.tsx'
import { Slider } from '@mui/joy'
export default function (): ReactNode {
const settings = useContext(SettingsContext);
export default function ImageCompression(): ReactNode {
const settings = useContext(SettingsContext)
return (
<SettingsItem title={"Image Compression"}>
<Slider
sx={{ marginTop: "20px" }}
valueLabelDisplay={"auto"}
defaultValue={settings?.imageCompression}
></Slider>
</SettingsItem>
);
return (
<SettingsItem title={'Image Compression'}>
<Slider
sx={{ marginTop: '20px' }}
valueLabelDisplay={'auto'}
defaultValue={settings?.imageCompression}
></Slider>
</SettingsItem>
)
}

View File

@@ -1,25 +0,0 @@
import { Modal, ModalDialog, Tab, TabList, Tabs } from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose";
import { Dispatch } from "react";
export default function ({
open,
setOpen,
}: {
open: boolean;
setOpen: Dispatch<boolean>;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<ModalDialog>
<ModalClose />
<Tabs sx={{ width: "95%" }} defaultValue={"komga"}>
<TabList>
<Tab value={"komga"}>Komga</Tab>
<Tab value={"kavita"}>Kavita</Tab>
</TabList>
</Tabs>
</ModalDialog>
</Modal>
);
}

View File

@@ -1,20 +0,0 @@
import { Button, Card, Typography } from "@mui/joy";
import { useState } from "react";
import ListLibraryConnectors from "./ListLibraryConnectors.tsx";
import AddLibraryConnector from "./AddLibraryConnector.tsx";
export default function () {
const [addDialogOpen, setAddDialogOpen] = useState<boolean>(false);
return (
<Card>
<Typography>Library Connectors</Typography>
<Button onClick={() => setAddDialogOpen(true)}>Add</Button>
<ListLibraryConnectors />
<AddLibraryConnector
open={addDialogOpen}
setOpen={() => setAddDialogOpen(false)}
/>
</Card>
);
}

View File

@@ -1,34 +0,0 @@
import { useContext, useEffect, useState } from "react";
import { ApiContext } from "../../../apiClient/ApiContext.tsx";
import { LibraryConnector } from "../../../apiClient/data-contracts.ts";
import { Card, Chip, Input, Stack } from "@mui/joy";
export default function () {
const Api = useContext(ApiContext);
const [libraryConnectors, setLibraryConnectors] = useState<
LibraryConnector[]
>([]);
useEffect(() => {
Api.libraryConnectorList().then((r) => {
if (r.ok) setLibraryConnectors(r.data);
});
}, [Api]);
return (
<Stack direction={"column"} spacing={1}>
{libraryConnectors.map((c) => (
<LibraryConnectorItem key={c.key} connector={c} />
))}
</Stack>
);
}
function LibraryConnectorItem({ connector }: { connector: LibraryConnector }) {
return (
<Card>
<Chip>{connector.libraryType}</Chip>
<Input disabled value={connector.baseUrl} />
</Card>
);
}

View File

@@ -1,33 +1,26 @@
import { Button } from "@mui/joy";
import { SettingsItem } from "./Settings.tsx";
import { useContext, useState } from "react";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { LoadingState, StateColor, StateIndicator } from "../Loading.tsx";
import { SettingsItem } from './Settings.tsx'
import { useContext } from 'react'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import TButton from '../Inputs/TButton.tsx'
export default function () {
const Api = useContext(ApiContext);
export default function Maintenance() {
const Api = useContext(ApiContext)
const [unusedMangaState, setUnusedMangaState] = useState(LoadingState.none);
const cleanUnusedManga = () => {
setUnusedMangaState(LoadingState.loading);
Api.maintenanceCleanupNoDownloadMangaCreate()
.then((r) => {
if (r.ok) setUnusedMangaState(LoadingState.success);
else setUnusedMangaState(LoadingState.failure);
})
.catch((_) => setUnusedMangaState(LoadingState.failure));
};
const cleanUnusedManga = async (): Promise<void> => {
try {
const result = await Api.maintenanceCleanupNoDownloadMangaCreate()
if (result.ok) return Promise.resolve()
else return Promise.reject()
} catch (reason) {
return await Promise.reject(reason)
}
}
return (
<SettingsItem title={"Maintenance"}>
<Button
disabled={unusedMangaState == LoadingState.loading}
color={StateColor(unusedMangaState)}
endDecorator={StateIndicator(unusedMangaState)}
onClick={cleanUnusedManga}
>
Cleanup unused Manga
</Button>
</SettingsItem>
);
return (
<SettingsItem title={'Maintenance'}>
<TButton completionAction={cleanUnusedManga}>
Cleanup unused Manga
</TButton>
</SettingsItem>
)
}

View File

@@ -1,232 +0,0 @@
import { ReactNode, useContext, useState } from "react";
import { ApiContext } from "../../../apiClient/ApiContext";
import {
Button,
Input,
Modal,
ModalDialog,
Stack,
Tab,
TabList,
TabPanel,
Tabs,
} from "@mui/joy";
import ModalClose from "@mui/joy/ModalClose";
import {
GotifyRecord,
NtfyRecord,
PushoverRecord,
} from "../../../apiClient/data-contracts.ts";
import { LoadingState, StateColor, StateIndicator } from "../../Loading.tsx";
import * as React from "react";
export default function ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<ModalDialog>
<ModalClose />
<Tabs sx={{ width: "95%" }} defaultValue={"gotify"}>
<TabList>
<Tab value={"gotify"}>Gotify</Tab>
<Tab value={"ntfy"}>Ntfy</Tab>
<Tab value={"pushover"}>Pushover</Tab>
</TabList>
<Gotify />
<Ntfy />
<Pushover />
</Tabs>
</ModalDialog>
</Modal>
);
}
function NotificationConnectorTab({
value,
children,
add,
state,
}: {
value: string;
children: ReactNode;
add: React.MouseEventHandler<HTMLAnchorElement> | undefined;
state: LoadingState;
}) {
const IsLoading = (state: LoadingState): boolean =>
state === LoadingState.loading;
return (
<TabPanel value={value}>
<Stack spacing={1}>
{children}
<Button
onClick={add}
endDecorator={StateIndicator(state)}
loading={IsLoading(state)}
disabled={IsLoading(state)}
color={StateColor(state)}
>
Add
</Button>
</Stack>
</TabPanel>
);
}
function Gotify() {
const Api = useContext(ApiContext);
const [gotifyData, setGotifyData] = useState<GotifyRecord>({});
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.none,
);
const Add = () => {
setLoadingState(LoadingState.loading);
Api.notificationConnectorGotifyUpdate(gotifyData)
.then((response) => {
if (response.ok) setLoadingState(LoadingState.success);
else setLoadingState(LoadingState.failure);
})
.catch((_) => setLoadingState(LoadingState.failure));
};
return (
<NotificationConnectorTab value={"gotify"} add={Add} state={loadingState}>
<Input
placeholder={"Name"}
value={gotifyData.name as string}
onChange={(e) => setGotifyData({ ...gotifyData, name: e.target.value })}
/>
<Input
placeholder={"https://[...]/message"}
value={gotifyData.endpoint as string}
onChange={(e) =>
setGotifyData({ ...gotifyData, endpoint: e.target.value })
}
/>
<Input
placeholder={"Apptoken"}
type={"password"}
value={gotifyData.appToken as string}
onChange={(e) =>
setGotifyData({ ...gotifyData, appToken: e.target.value })
}
/>
<Input
placeholder={"Priority"}
type={"number"}
value={gotifyData.priority as number}
onChange={(e) =>
setGotifyData({ ...gotifyData, priority: e.target.valueAsNumber })
}
/>
</NotificationConnectorTab>
);
}
function Ntfy() {
const Api = useContext(ApiContext);
const [ntfyData, setNtfyData] = useState<NtfyRecord>({});
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.none,
);
const Add = () => {
setLoadingState(LoadingState.loading);
Api.notificationConnectorNtfyUpdate(ntfyData)
.then((response) => {
if (response.ok) setLoadingState(LoadingState.success);
else setLoadingState(LoadingState.failure);
})
.catch((_) => setLoadingState(LoadingState.failure));
};
return (
<NotificationConnectorTab value={"ntfy"} add={Add} state={loadingState}>
<Input
placeholder={"Name"}
value={ntfyData.name as string}
onChange={(e) => setNtfyData({ ...ntfyData, name: e.target.value })}
/>
<Input
placeholder={"Endpoint"}
value={ntfyData.endpoint as string}
onChange={(e) => setNtfyData({ ...ntfyData, endpoint: e.target.value })}
/>
<Input
placeholder={"Topic"}
value={ntfyData.topic as string}
onChange={(e) => setNtfyData({ ...ntfyData, topic: e.target.value })}
/>
<Input
placeholder={"Username"}
value={ntfyData.username as string}
onChange={(e) => setNtfyData({ ...ntfyData, username: e.target.value })}
/>
<Input
placeholder={"Password"}
type={"password"}
value={ntfyData.password as string}
onChange={(e) => setNtfyData({ ...ntfyData, password: e.target.value })}
/>
<Input
placeholder={"Priority"}
type={"number"}
value={ntfyData.priority as number}
onChange={(e) =>
setNtfyData({ ...ntfyData, priority: e.target.valueAsNumber })
}
/>
</NotificationConnectorTab>
);
}
function Pushover() {
const Api = useContext(ApiContext);
const [pushoverData, setPushoverData] = useState<PushoverRecord>({});
const [loadingState, setLoadingState] = useState<LoadingState>(
LoadingState.none,
);
const Add = () => {
setLoadingState(LoadingState.loading);
Api.notificationConnectorPushoverUpdate(pushoverData)
.then((response) => {
if (response.ok) setLoadingState(LoadingState.success);
else setLoadingState(LoadingState.failure);
})
.catch((_) => setLoadingState(LoadingState.failure));
};
return (
<NotificationConnectorTab value={"pushover"} add={Add} state={loadingState}>
<Input
placeholder={"Name"}
value={pushoverData.name as string}
onChange={(e) =>
setPushoverData({ ...pushoverData, name: e.target.value })
}
/>
<Input
placeholder={"User"}
value={pushoverData.user as string}
onChange={(e) =>
setPushoverData({ ...pushoverData, user: e.target.value })
}
/>
<Input
placeholder={"AppToken"}
type={"password"}
value={pushoverData.appToken as string}
onChange={(e) =>
setPushoverData({ ...pushoverData, appToken: e.target.value })
}
/>
</NotificationConnectorTab>
);
}

View File

@@ -1,67 +0,0 @@
import { ApiContext } from "../../../apiClient/ApiContext.tsx";
import { useContext, useEffect, useState } from "react";
import { NotificationConnector } from "../../../apiClient/data-contracts.ts";
import {
Card,
Chip,
Input,
Stack,
Table,
Textarea,
Typography,
} from "@mui/joy";
export default function () {
const Api = useContext(ApiContext);
const [notificationConnectors, setNotificationConnectors] = useState<
NotificationConnector[]
>([]);
useEffect(() => {
Api.notificationConnectorList().then((r) => {
if (r.ok) setNotificationConnectors(r.data);
});
}, [Api]);
return (
<Stack direction={"column"} spacing={1}>
{notificationConnectors.map((c) => (
<NotificationConnectorItem key={c.name} connector={c} />
))}
</Stack>
);
}
function NotificationConnectorItem({
connector,
}: {
connector: NotificationConnector;
}) {
return (
<Card>
<Typography left={"h2"}>{connector.name}</Typography>
<Input
disabled
startDecorator={<Chip>{connector.httpMethod}</Chip>}
value={connector.url}
/>
<Table>
<thead>
<tr>
<th>Header</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{Object.entries(connector.headers).map((x) => (
<tr key={x[0]}>
<td>{x[0]}</td>
<td>{[x[1]]}</td>
</tr>
))}
</tbody>
</Table>
<Textarea disabled value={connector.body} />
</Card>
);
}

View File

@@ -1,12 +1,11 @@
import { SettingsItem } from "./Settings.tsx";
import FlareSolverr from "./FlareSolverr.tsx";
import LibraryConnectors from "./LibraryConnectors/LibraryConnectors.tsx";
import { SettingsItem } from './Settings.tsx'
import FlareSolverr from './FlareSolverr.tsx'
import { ReactNode } from 'react'
export default function () {
return (
<SettingsItem title={"Services"}>
<FlareSolverr />
<LibraryConnectors />
</SettingsItem>
);
export default function Services(): ReactNode {
return (
<SettingsItem title={'Services'}>
<FlareSolverr />
</SettingsItem>
)
}

View File

@@ -1,114 +1,107 @@
import ModalClose from "@mui/joy/ModalClose";
import ModalClose from '@mui/joy/ModalClose'
import {
Accordion,
AccordionDetails,
AccordionGroup,
AccordionSummary,
Button,
ColorPaletteProp,
DialogContent,
DialogTitle,
Input,
Modal,
ModalDialog,
} from "@mui/joy";
import "./Settings.css";
import * as React from "react";
Accordion,
AccordionDetails,
AccordionGroup,
AccordionSummary,
Button,
DialogContent,
DialogTitle,
Modal,
ModalDialog,
} from '@mui/joy'
import './Settings.css'
import * as React from 'react'
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { TrangaSettings } from "../../apiClient/data-contracts.ts";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
import { SxProps } from "@mui/joy/styles/types";
import ImageCompression from "./ImageCompression.tsx";
import FlareSolverr from "./FlareSolverr.tsx";
import DownloadLanguage from "./DownloadLanguage.tsx";
import ChapterNamingScheme from "./ChapterNamingScheme.tsx";
import Maintenance from "./Maintenance.tsx";
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { SxProps } from '@mui/joy/styles/types'
import ImageCompression from './ImageCompression.tsx'
import FlareSolverr from './FlareSolverr.tsx'
import DownloadLanguage from './DownloadLanguage.tsx'
import ChapterNamingScheme from './ChapterNamingScheme.tsx'
import Maintenance from './Maintenance.tsx'
import { ApiContext } from '../../contexts/ApiContext.tsx'
import { TrangaSettings } from '../../api/data-contracts.ts'
import TInput from '../Inputs/TInput.tsx'
export const SettingsContext = createContext<TrangaSettings | undefined>(
undefined,
);
undefined
)
export default function Settings({
setApiUri,
setApiUri,
}: {
setApiUri: (uri: string) => void;
setApiUri: (uri: string) => void
}) {
const Api = useContext(ApiContext);
const [settings, setSettings] = useState<TrangaSettings>();
const Api = useContext(ApiContext)
const [settings, setSettings] = useState<TrangaSettings>()
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(false)
const [apiUriColor, setApiUriColor] = useState<ColorPaletteProp>("neutral");
const timerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
Api.settingsList().then((response) => {
setSettings(response.data)
})
}, [Api])
useEffect(() => {
Api.settingsList().then((response) => {
setSettings(response.data);
});
}, [Api]);
const apiUriChanged = (
value: string | number | readonly string[] | undefined
) => {
if (typeof value != 'string') return Promise.reject()
setApiUri(value)
return Promise.resolve()
}
const apiUriChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
clearTimeout(timerRef.current);
setApiUriColor("warning");
timerRef.current = setTimeout(() => {
setApiUri(e.target.value);
setApiUriColor("success");
}, 1000);
};
const ModalStyle: SxProps = {
width: '80%',
height: '80%',
}
const ModalStyle: SxProps = {
width: "80%",
height: "80%",
};
return (
<SettingsContext.Provider value={settings}>
<Button onClick={() => setOpen(true)}>Settings</Button>
<Modal open={open} onClose={() => setOpen(false)}>
<ModalDialog sx={ModalStyle}>
<ModalClose />
<DialogTitle>Settings</DialogTitle>
<DialogContent>
<AccordionGroup>
<SettingsItem title={"ApiUri"}>
<Input
color={apiUriColor}
placeholder={"http(s)://"}
type={"url"}
defaultValue={Api.baseUrl}
onChange={apiUriChanged}
/>
</SettingsItem>
<ImageCompression />
<FlareSolverr />
<DownloadLanguage />
<ChapterNamingScheme />
<Maintenance />
</AccordionGroup>
</DialogContent>
</ModalDialog>
</Modal>
</SettingsContext.Provider>
);
return (
<SettingsContext.Provider value={settings}>
<Button onClick={() => setOpen(true)}>Settings</Button>
<Modal open={open} onClose={() => setOpen(false)}>
<ModalDialog sx={ModalStyle}>
<ModalClose />
<DialogTitle>Settings</DialogTitle>
<DialogContent>
<AccordionGroup>
<SettingsItem title={'ApiUri'}>
<TInput
placeholder={'http(s)://'}
defaultValue={Api.baseUrl}
completionAction={apiUriChanged}
/>
</SettingsItem>
<ImageCompression />
<FlareSolverr />
<DownloadLanguage />
<ChapterNamingScheme />
<Maintenance />
</AccordionGroup>
</DialogContent>
</ModalDialog>
</Modal>
</SettingsContext.Provider>
)
}
export function SettingsItem({
title,
children,
title,
children,
}: {
title: string;
children: ReactNode;
title: string
children: ReactNode
}) {
return (
<Accordion>
<AccordionSummary>{title}</AccordionSummary>
<AccordionDetails>{children}</AccordionDetails>
</Accordion>
);
return (
<Accordion>
<AccordionSummary>{title}</AccordionSummary>
<AccordionDetails>{children}</AccordionDetails>
</Accordion>
)
}

View File

@@ -1,75 +0,0 @@
import { Dispatch, ReactNode, useContext, useEffect, useState } from "react";
import Drawer from "@mui/joy/Drawer";
import { Button, Option, Select, Table } from "@mui/joy";
import { BaseWorker } from "../../apiClient/data-contracts.ts";
import ModalClose from "@mui/joy/ModalClose";
import { ApiContext } from "../../apiClient/ApiContext.tsx";
export default function (): ReactNode {
const [open, setOpen] = useState(false);
const [workers, setWorkers] = useState<BaseWorker[]>([]);
const Api = useContext(ApiContext);
useEffect(() => {
Api.workerList().then((response) => {
if (response.ok) {
setWorkers(response.data);
}
});
}, [Api]);
return (
<>
<Button onClick={() => setOpen(true)}>Workers</Button>
<WorkerDrawer open={open} setOpen={setOpen} workers={workers} />
</>
);
}
function WorkerDrawer({
open,
setOpen,
workers,
}: {
open: boolean;
setOpen: Dispatch<boolean>;
workers: BaseWorker[];
}): ReactNode {
return (
<Drawer open={open} onClose={() => setOpen(false)} size={"lg"}>
<ModalClose />
<Table
borderAxis="bothBetween"
size="md"
stickyFooter={false}
stickyHeader
>
<thead>
<tr>
<th>Key</th>
<th>Can run</th>
<th>Missing dependencies</th>
</tr>
</thead>
<tbody>
{workers.map((worker) => {
return (
<tr key={worker.key}>
<td>{worker.key}</td>
<td>{worker.allDependenciesFulfilled ? "yes" : "no"}</td>
<td>
<Select placeholder={"Missing dependencies"}>
{worker.missingDependencies?.map((worker) => {
return <Option value={worker.key}>{worker.key}</Option>;
})}
</Select>
</td>
</tr>
);
})}
</tbody>
</Table>
</Drawer>
);
}

View File

@@ -1,11 +1,11 @@
.header {
position: sticky !important;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 60px;
padding: 10px;
display: flex;
flex-flow: row nowrap;
position: sticky !important;
z-index: 1000;
top: 0;
left: 0;
width: 100%;
height: 60px;
padding: 10px;
display: flex;
flex-flow: row nowrap;
}

View File

@@ -1,91 +1,99 @@
import Sheet from "@mui/joy/Sheet";
import { Link, Stack, Typography } from "@mui/joy";
import { ReactElement, ReactNode, useContext } from "react";
import "./Header.css";
import { Article, GitHub } from "@mui/icons-material";
import { ApiContext } from "./apiClient/ApiContext.tsx";
import Sheet from '@mui/joy/Sheet'
import { Link, Stack, Typography } from '@mui/joy'
import { ReactElement, ReactNode, useContext } from 'react'
import './Header.css'
import { Article, GitHub } from '@mui/icons-material'
import { ApiContext } from './contexts/ApiContext.tsx'
export default function Header({
children,
children,
}: {
children?: ReactNode;
children?: ReactNode
}): ReactElement {
const Api = useContext(ApiContext);
const Api = useContext(ApiContext)
return (
<Sheet className={"header"}>
<Stack
direction={"row"}
spacing={2}
sx={{
width: "100%",
alignItems: "center",
justifyContent: "space-between",
}}
useFlexGap
>
<Stack sx={{ flexGrow: 1, flexBasis: 1 }} direction={"row"} spacing={2}>
{children}
</Stack>
<Stack
sx={{
flexGrow: 1,
height: "100%",
flexBasis: 1,
justifyContent: "center",
}}
direction={"row"}
>
<img
src={"/blahaj.png"}
style={{ cursor: "grab", maxHeight: "100%" }}
/>
<Typography
level={"h2"}
sx={{
background:
"linear-gradient(110deg, var(--joy-palette-primary-solidBg), var(--joy-palette-success-400))",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
fontWeight: "bold",
cursor: "default",
}}
>
Tranga
</Typography>
</Stack>
<Stack
sx={{ flexGrow: 1, flexBasis: 1, justifyContent: "flex-end" }}
direction={"row"}
spacing={2}
>
<Link
target={"_blank"}
href={"https://github.com/C9Glax/tranga"}
color={"neutral"}
height={"min-content"}
>
<GitHub /> Server
</Link>
<Link
target={"_blank"}
href={"https://github.com/C9Glax/tranga-website"}
color={"neutral"}
height={"min-content"}
>
<GitHub /> Website
</Link>
<Link
target={"_blank"}
href={Api.baseUrl + "/swagger"}
color={"neutral"}
height={"min-content"}
>
<Article />
Swagger
</Link>
</Stack>
</Stack>
</Sheet>
);
return (
<Sheet className={'header'}>
<Stack
direction={'row'}
spacing={2}
sx={{
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}}
useFlexGap
>
<Stack
sx={{ flexGrow: 1, flexBasis: 1 }}
direction={'row'}
spacing={2}
>
{children}
</Stack>
<Stack
sx={{
flexGrow: 1,
height: '100%',
flexBasis: 1,
justifyContent: 'center',
}}
direction={'row'}
>
<img
src={'/blahaj.png'}
style={{ cursor: 'grab', maxHeight: '100%' }}
/>
<Typography
level={'h2'}
sx={{
background:
'linear-gradient(110deg, var(--joy-palette-primary-solidBg), var(--joy-palette-success-400))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
fontWeight: 'bold',
cursor: 'default',
}}
>
Tranga
</Typography>
</Stack>
<Stack
sx={{
flexGrow: 1,
flexBasis: 1,
justifyContent: 'flex-end',
}}
direction={'row'}
spacing={2}
>
<Link
target={'_blank'}
href={'https://github.com/C9Glax/tranga'}
color={'neutral'}
height={'min-content'}
>
<GitHub /> Server
</Link>
<Link
target={'_blank'}
href={'https://github.com/C9Glax/tranga-website'}
color={'neutral'}
height={'min-content'}
>
<GitHub /> Website
</Link>
<Link
target={'_blank'}
href={Api.baseUrl + '/swagger'}
color={'neutral'}
height={'min-content'}
>
<Article />
Swagger
</Link>
</Stack>
</Stack>
</Sheet>
)
}

View File

@@ -0,0 +1,123 @@
import { Dispatch, ReactNode, useContext, useState } from 'react'
import {
List,
ListItem,
ListItemDecorator,
Modal,
ModalDialog,
Step,
StepIndicator,
Stepper,
Typography,
} from '@mui/joy'
import ModalClose from '@mui/joy/ModalClose'
import { MangaConnectorContext } from './contexts/MangaConnectorContext.tsx'
import MangaConnectorIcon from './Components/Mangas/MangaConnectorIcon.tsx'
import TInput from './Components/Inputs/TInput.tsx'
import { ApiContext } from './contexts/ApiContext.tsx'
import { MangaCardList } from './Components/Mangas/MangaList.tsx'
import { MangaConnector, MinimalManga } from './api/data-contracts.ts'
export default function Search({
open,
setOpen,
}: {
open: boolean
setOpen: Dispatch<boolean>
}): ReactNode {
const Api = useContext(ApiContext)
const MangaConnectors = useContext(MangaConnectorContext)
const [selectedConnector, setSelectedConnector] = useState<MangaConnector>()
const [searchResults, setSearchResults] = useState<MinimalManga[]>([])
const startSearch = (
value: string | number | readonly string[] | undefined
): Promise<void> => {
if (typeof value != 'string') return Promise.reject()
setSearchResults([])
if (isUrl(value)) {
return Api.searchUrlCreate(value)
.then((result) => {
if (result.ok) {
setSearchResults([result.data])
return Promise.resolve()
} else return Promise.reject()
})
.catch(Promise.reject)
} else {
if (!selectedConnector) return Promise.reject()
return Api.searchDetail(selectedConnector?.key, value)
.then((result) => {
if (result.ok) {
setSearchResults(result.data)
return Promise.resolve()
} else return Promise.reject()
})
.catch(Promise.reject)
}
}
return (
<Modal open={open} onClose={() => setOpen(false)}>
<ModalDialog sx={{ width: '90vw' }}>
<ModalClose />
<Stepper>
<Step
orientation={'vertical'}
indicator={<StepIndicator>1</StepIndicator>}
>
<Typography level={'title-lg'}>
Select a connector
</Typography>
<List>
{MangaConnectors.map((c) => (
<ListItem
onClick={() => setSelectedConnector(c)}
>
<ListItemDecorator>
<MangaConnectorIcon
mangaConnector={c}
/>
</ListItemDecorator>
<Typography
sx={
c.key == selectedConnector?.key
? { fontWeight: 'bold' }
: {}
}
>
{c.name}
</Typography>
</ListItem>
))}
</List>
</Step>
<Step
orientation={'vertical'}
indicator={<StepIndicator>2</StepIndicator>}
>
<Typography level={'title-lg'}>
Enter a search term or URL
</Typography>
<TInput
placeholder={'Manga-name or URL'}
completionAction={startSearch}
/>
</Step>
</Stepper>
<MangaCardList manga={searchResults} />
</ModalDialog>
</Modal>
)
}
function isUrl(str: string): boolean {
try {
new URL(str)
return true
} catch {
return false
}
}

View File

@@ -1,4 +0,0 @@
import { createContext } from "react";
import { V2 } from "./V2.ts";
export const ApiContext = createContext<V2>(new V2());

View File

@@ -1,32 +0,0 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import { HttpClient, RequestParams } from "./http-client";
export class CleanupNoDownloadManga<
SecurityDataType = unknown,
> extends HttpClient<SecurityDataType> {
/**
* No description
*
* @tags Maintenance
* @name CleanupNoDownloadMangaCreate
* @summary Removes all API.Schema.MangaContext.Manga not marked for Download on any API.MangaConnectors.MangaConnector
* @request POST:/CleanupNoDownloadManga
*/
cleanupNoDownloadMangaCreate = (params: RequestParams = {}) =>
this.request<void, string>({
path: `/CleanupNoDownloadManga`,
method: "POST",
...params,
});
}

View File

@@ -1,31 +0,0 @@
import { createContext, useContext, useState } from "react";
import { TrangaSettings } from "./data-contracts.ts";
import { ApiContext } from "./ApiContext.tsx";
const [settingsPromise, setSettingsPromise] =
useState<Promise<TrangaSettings | undefined>>();
const [settings, setSettings] = useState<TrangaSettings>();
export const SettingsContext = createContext<{
GetSettings: () => Promise<TrangaSettings | undefined>;
}>({
GetSettings: (): Promise<TrangaSettings | undefined> => {
const API = useContext(ApiContext);
const promise = settingsPromise;
if (promise) return promise;
const p = new Promise<TrangaSettings | undefined>((resolve, reject) => {
if (settings) resolve(settings);
console.log(`Fetching settings`);
API.settingsList()
.then((result) => {
if (!result.ok) throw new Error(`Error fetching settings`);
setSettings(result.data);
resolve(result.data);
})
.catch(reject);
});
setSettingsPromise(p);
return p;
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,438 +0,0 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export enum WorkerExecutionState {
Failed = "Failed",
Cancelled = "Cancelled",
Created = "Created",
Waiting = "Waiting",
Running = "Running",
Completed = "Completed",
}
export enum RequestType {
Default = "Default",
MangaDexFeed = "MangaDexFeed",
MangaImage = "MangaImage",
MangaCover = "MangaCover",
MangaDexImage = "MangaDexImage",
MangaInfo = "MangaInfo",
}
export enum MangaReleaseStatus {
Continuing = "Continuing",
Completed = "Completed",
OnHiatus = "OnHiatus",
Cancelled = "Cancelled",
Unreleased = "Unreleased",
}
export enum LibraryType {
Komga = "Komga",
Kavita = "Kavita",
}
export interface AltTitle {
/**
* @minLength 0
* @maxLength 8
*/
language: string;
/**
* @minLength 0
* @maxLength 256
*/
title: string;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface Author {
/**
* @minLength 0
* @maxLength 128
*/
authorName: string;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface BaseWorker {
/** Workers this Worker depends on being completed before running. */
dependsOn?: BaseWorker[] | null;
/** API.Workers.BaseWorker.DependsOn where API.Workers.WorkerExecutionState is less than Completed. */
missingDependencies?: BaseWorker[] | null;
allDependenciesFulfilled?: boolean;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface Chapter {
/**
* @minLength 0
* @maxLength 64
*/
parentMangaId: string;
idsOnMangaConnectors?: Record<string, string>;
/** @format int32 */
volumeNumber?: number | null;
/**
* @minLength 0
* @maxLength 10
*/
chapterNumber: string;
/**
* @minLength 0
* @maxLength 256
*/
title?: string | null;
/**
* @minLength 0
* @maxLength 256
*/
fileName: string;
downloaded: boolean;
fullArchiveFilePath?: string | null;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface ChapterMangaConnectorId {
/**
* @minLength 0
* @maxLength 64
*/
objId: string;
/**
* @minLength 0
* @maxLength 32
*/
mangaConnectorName: string;
/**
* @minLength 0
* @maxLength 256
*/
idOnConnectorSite: string;
/**
* @format uri
* @minLength 0
* @maxLength 512
*/
websiteUrl?: string | null;
useForDownload?: boolean;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface FileLibrary {
/**
* @minLength 0
* @maxLength 256
*/
basePath: string;
/**
* @minLength 0
* @maxLength 512
*/
libraryName: string;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface GotifyRecord {
name?: string | null;
endpoint?: string | null;
appToken?: string | null;
/** @format int32 */
priority?: number;
}
export interface LibraryConnector {
libraryType: LibraryType;
/**
* @format uri
* @minLength 0
* @maxLength 256
*/
baseUrl: string;
/**
* @minLength 0
* @maxLength 256
*/
auth: string;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface Link {
/**
* @minLength 0
* @maxLength 64
*/
linkProvider: string;
/**
* @format uri
* @minLength 0
* @maxLength 2048
*/
linkUrl: string;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface Manga {
/**
* @minLength 0
* @maxLength 512
*/
name: string;
/** @minLength 1 */
description: string;
releaseStatus: MangaReleaseStatus;
/**
* @minLength 0
* @maxLength 64
*/
libraryId?: string | null;
authors?: Author[] | null;
mangaTags?: MangaTag[] | null;
links?: Link[] | null;
altTitles?: AltTitle[] | null;
/** @format float */
ignoreChaptersBefore: number;
/**
* @minLength 0
* @maxLength 1024
*/
directoryName: string;
/** @format int32 */
year?: number | null;
/**
* @minLength 0
* @maxLength 8
*/
originalLanguage?: string | null;
chapterIds?: string[] | null;
idsOnMangaConnectors?: Record<string, string>;
mangaConnectorIdsIds?: string[] | null;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface MangaConnector {
/**
* @minLength 0
* @maxLength 32
*/
name: string;
/**
* @minLength 0
* @maxLength 8
*/
supportedLanguages: string[];
/**
* @minLength 0
* @maxLength 2048
*/
iconUrl: string;
/**
* @minLength 0
* @maxLength 256
*/
baseUris: string[];
enabled: boolean;
}
export interface MangaMangaConnectorId {
/**
* @minLength 0
* @maxLength 64
*/
objId: string;
/**
* @minLength 0
* @maxLength 32
*/
mangaConnectorName: string;
/**
* @minLength 0
* @maxLength 256
*/
idOnConnectorSite: string;
/**
* @format uri
* @minLength 0
* @maxLength 512
*/
websiteUrl?: string | null;
useForDownload?: boolean;
/**
* @minLength 16
* @maxLength 64
*/
key: string;
}
export interface MangaTag {
/**
* @minLength 0
* @maxLength 64
*/
tag: string;
}
export interface MetadataEntry {
mangaId?: string | null;
metadataFetcherName?: string | null;
identifier?: string | null;
}
export interface MetadataSearchResult {
identifier?: string | null;
name?: string | null;
url?: string | null;
description?: string | null;
coverUrl?: string | null;
}
export interface MinimalManga {
/**
* @minLength 16
* @maxLength 64
*/
key: string;
/** @minLength 1 */
name: string;
/** @minLength 1 */
description: string;
releaseStatus: MangaReleaseStatus;
mangaConnectorIds?: MangaMangaConnectorId[] | null;
}
export interface NotificationConnector {
/**
* @minLength 0
* @maxLength 64
*/
name: string;
/**
* @format uri
* @minLength 0
* @maxLength 2048
*/
url: string;
headers: Record<string, string>;
/**
* @minLength 0
* @maxLength 8
*/
httpMethod: string;
/**
* @minLength 0
* @maxLength 4096
*/
body: string;
}
export interface NtfyRecord {
name?: string | null;
endpoint?: string | null;
username?: string | null;
password?: string | null;
topic?: string | null;
/** @format int32 */
priority?: number;
}
export interface ProblemDetails {
type?: string | null;
title?: string | null;
/** @format int32 */
status?: number | null;
detail?: string | null;
instance?: string | null;
[key: string]: any;
}
export interface PushoverRecord {
name?: string | null;
appToken?: string | null;
user?: string | null;
}
export interface TrangaSettings {
downloadLocation?: string | null;
userAgent?: string | null;
/** @format int32 */
imageCompression?: number;
blackWhiteImages?: boolean;
flareSolverrUrl?: string | null;
/**
* Placeholders:
* %M Obj Name
* %V Volume
* %C Chapter
* %T Title
* %A Author (first in list)
* %I Chapter Internal ID
* %i Obj Internal ID
* %Y Year (Obj)
*
* ?_(...) replace _ with a value from above:
* Everything inside the braces will only be added if the value of %_ is not null
*/
chapterNamingScheme?: string | null;
/** @format int32 */
workCycleTimeoutMs?: number;
requestLimits?: {
/** @format int32 */
Default?: number;
/** @format int32 */
MangaDexFeed?: number;
/** @format int32 */
MangaImage?: number;
/** @format int32 */
MangaCover?: number;
/** @format int32 */
MangaDexImage?: number;
/** @format int32 */
MangaInfo?: number;
} | null;
downloadLanguage?: string | null;
}

View File

@@ -1,260 +0,0 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (
securityData: SecurityDataType | null,
) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) =>
fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) =>
Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData()),
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(
params1: RequestParams,
params2?: RequestParams,
): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData
? { "Content-Type": type }
: {}),
},
signal:
(cancelToken
? this.createAbortSignal(cancelToken)
: requestParams.signal) || null,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body),
},
).then(async (response) => {
const r = response.clone() as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const data = !responseFormat
? r
: await response[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}

View File

@@ -0,0 +1,20 @@
import { V2 } from '../api/V2.ts'
import { createContext, ReactNode, useEffect, useState } from 'react'
import { ApiConfig } from '../api/http-client.ts'
export const ApiContext = createContext<V2>(new V2())
export default function ApiProvider({
apiConfig,
children,
}: {
apiConfig: ApiConfig
children: ReactNode
}) {
const [api, setApi] = useState<V2>(new V2(apiConfig))
useEffect(() => {
setApi(new V2(apiConfig))
}, [apiConfig])
return <ApiContext value={api}>{children}</ApiContext>
}

View File

@@ -0,0 +1,33 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { MangaConnector } from '../api/data-contracts.ts'
import { ApiContext } from './ApiContext.tsx'
export const MangaConnectorContext = createContext<MangaConnector[]>([])
export default function MangaConnectorProvider({
children,
}: {
children: ReactNode
}) {
const Api = useContext(ApiContext)
const [state, setState] = useState<MangaConnector[]>([])
useEffect(() => {
Api.mangaConnectorList().then((result) => {
if (result.ok) {
setState(result.data)
}
})
}, [Api])
return (
<MangaConnectorContext value={state}>{children}</MangaConnectorContext>
)
}

View File

@@ -0,0 +1,43 @@
import { createContext, ReactNode, useContext } from 'react'
import { ApiContext } from './ApiContext.tsx'
import { Manga } from '../api/data-contracts.ts'
import { V2 } from '../api/V2.ts'
export const MangaContext = createContext<M>({
GetManga: () => Promise.reject(),
})
const manga: Map<string, Manga> = new Map()
const promises: Map<string, Promise<Manga | undefined>> = new Map()
export default function MangaProvider({ children }: { children: ReactNode }) {
const Api = useContext(ApiContext)
return (
<MangaContext value={{ GetManga: (k) => getManga(k, Api) }}>
{children}
</MangaContext>
)
}
function getManga(key: string, Api: V2): Promise<Manga | undefined> {
if (manga.has(key)) return Promise.resolve(manga.get(key))
if (promises.has(key)) return promises.get(key)!
const newPromise = Api.mangaDetail(key)
.then((data) => {
if (data.ok) {
manga.set(key, data.data)
return data.data
} else return undefined
})
.catch(() => {
return undefined
})
promises.set(key, newPromise)
return newPromise
}
export interface M {
GetManga(key: string): Promise<Manga | undefined>
}

View File

@@ -1,25 +1,25 @@
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
// @ts-expect-error font
import "@fontsource/inter";
import { CssVarsProvider } from "@mui/joy/styles";
import CssBaseline from "@mui/joy/CssBaseline";
import { StrictMode } from "react";
import { trangaTheme } from "./theme.ts";
import '@fontsource/inter'
import { CssVarsProvider } from '@mui/joy/styles'
import CssBaseline from '@mui/joy/CssBaseline'
import { StrictMode } from 'react'
import { trangaTheme } from './theme.ts'
export default function MyApp() {
return (
<StrictMode>
<CssVarsProvider theme={trangaTheme}>
{/* must be used under CssVarsProvider */}
<CssBaseline />
return (
<StrictMode>
<CssVarsProvider theme={trangaTheme}>
{/* must be used under CssVarsProvider */}
<CssBaseline />
{/* The rest of your application */}
<App />
</CssVarsProvider>
</StrictMode>
);
{/* The rest of your application */}
<App />
</CssVarsProvider>
</StrictMode>
)
}
createRoot(document.getElementById("root")!).render(<MyApp />);
createRoot(document.getElementById('root')!).render(<MyApp />)

View File

@@ -1,87 +1,87 @@
import { extendTheme } from "@mui/joy/styles";
import { extendTheme } from '@mui/joy/styles'
export const trangaTheme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: {
"50": "#FCE5EA",
"100": "#FBDDE3",
"200": "#F9CBD4",
"300": "#F7BAC6",
"400": "#F5A9B8",
"500": "#F5A9B8",
"600": "#C48793",
"700": "#AC7681",
"800": "#93656E",
"900": "#7B555C",
colorSchemes: {
light: {
palette: {
primary: {
'50': '#FCE5EA',
'100': '#FBDDE3',
'200': '#F9CBD4',
'300': '#F7BAC6',
'400': '#F5A9B8',
'500': '#F5A9B8',
'600': '#C48793',
'700': '#AC7681',
'800': '#93656E',
'900': '#7B555C',
},
neutral: {
'50': '#E6E6E6',
'100': '#CCCCCC',
'200': '#B3B3B3',
'300': '#999999',
'400': '#808080',
'500': '#666666',
'600': '#4C4C4C',
'700': '#333333',
'800': '#191919',
'900': '#000',
plainColor: 'var(--joy-palette-neutral-50)',
plainHoverBg: 'var(--joy-palette-neutral-700)',
outlinedColor: 'var(--joy-palette-neutral-50)',
},
success: {
'50': '#cef0fe',
'100': '#bdebfd',
'200': '#9de2fc',
'300': '#7cd8fb',
'400': '#5bcefa',
'500': '#5bcefa',
'600': '#49a5c8',
'700': '#4090af',
'800': '#2e677d',
'900': '#245264',
},
danger: {
'50': '#f2c0b3',
'100': '#ea9680',
'200': '#e68166',
'300': '#dd5733',
'400': '#d52d00',
'500': '#d52d00',
'600': '#aa2400',
'700': '#951f00',
'800': '#6b1700',
'900': '#400d00',
},
warning: {
'50': '#ffebdd',
'100': '#ffd7bb',
'200': '#ffc29a',
'300': '#ffae78',
'400': '#ff9a56',
'500': '#ff9a56',
'600': '#cc7b45',
'700': '#995c34',
'800': '#663e22',
'900': '#331f11',
},
background: {
body: 'var(--joy-palette-neutral-900)',
surface: 'var(--joy-palette-neutral-900)',
popup: 'var(--joy-palette-neutral-800)',
},
text: {
primary: 'var(--joy-palette-neutral-50)',
secondary: 'var(--joy-palette-success-200)',
tertiary: 'var(--joy-palette-primary-200)',
icon: 'var(--joy-palette-primary-50)',
},
},
},
neutral: {
"50": "#E6E6E6",
"100": "#CCCCCC",
"200": "#B3B3B3",
"300": "#999999",
"400": "#808080",
"500": "#666666",
"600": "#4C4C4C",
"700": "#333333",
"800": "#191919",
"900": "#000",
plainColor: "var(--joy-palette-neutral-50)",
plainHoverBg: "var(--joy-palette-neutral-700)",
outlinedColor: "var(--joy-palette-neutral-50)",
dark: {
palette: {},
},
success: {
"50": "#cef0fe",
"100": "#bdebfd",
"200": "#9de2fc",
"300": "#7cd8fb",
"400": "#5bcefa",
"500": "#5bcefa",
"600": "#49a5c8",
"700": "#4090af",
"800": "#2e677d",
"900": "#245264",
},
danger: {
"50": "#f2c0b3",
"100": "#ea9680",
"200": "#e68166",
"300": "#dd5733",
"400": "#d52d00",
"500": "#d52d00",
"600": "#aa2400",
"700": "#951f00",
"800": "#6b1700",
"900": "#400d00",
},
warning: {
"50": "#ffebdd",
"100": "#ffd7bb",
"200": "#ffc29a",
"300": "#ffae78",
"400": "#ff9a56",
"500": "#ff9a56",
"600": "#cc7b45",
"700": "#995c34",
"800": "#663e22",
"900": "#331f11",
},
background: {
body: "var(--joy-palette-neutral-900)",
surface: "var(--joy-palette-neutral-900)",
popup: "var(--joy-palette-neutral-800)",
},
text: {
primary: "var(--joy-palette-neutral-50)",
secondary: "var(--joy-palette-success-200)",
tertiary: "var(--joy-palette-primary-200)",
icon: "var(--joy-palette-primary-50)",
},
},
},
dark: {
palette: {},
},
},
});
})

View File

@@ -1,26 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -1,7 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -1,24 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,10 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
},
});
plugins: [react()],
server: {
host: '127.0.0.1',
},
})