first commit
This commit is contained in:
38
src/component/Common/AutoHeight.tsx
Executable file
38
src/component/Common/AutoHeight.tsx
Executable file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import AnimateHeight, { Height } from "react-animate-height";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
// @ts-ignore
|
||||
const AutoHeight = ({ children, ...props }) => {
|
||||
const [height, setHeight] = useState<Height>("auto");
|
||||
const contentDiv = useRef<HTMLDivElement | null>(null);
|
||||
const theme = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const element = contentDiv.current as HTMLDivElement;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
setHeight(element.clientHeight);
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [contentDiv]);
|
||||
|
||||
return (
|
||||
<AnimateHeight
|
||||
{...props}
|
||||
height={height}
|
||||
duration={theme.transitions.duration.standard}
|
||||
easing={theme.transitions.easing.easeInOut}
|
||||
contentClassName="auto-content"
|
||||
contentRef={contentDiv}
|
||||
disableDisplayNone
|
||||
>
|
||||
{children}
|
||||
</AnimateHeight>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutoHeight;
|
||||
16
src/component/Common/BorderLinearProgress.tsx
Executable file
16
src/component/Common/BorderLinearProgress.tsx
Executable file
@@ -0,0 +1,16 @@
|
||||
import { LinearProgress, linearProgressClasses } from "@mui/material";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
const BorderLinearProgress = styled(LinearProgress)(({ theme }) => ({
|
||||
height: 8,
|
||||
borderRadius: 5,
|
||||
[`&.${linearProgressClasses.colorPrimary}`]: {
|
||||
backgroundColor: theme.palette.grey[theme.palette.mode === "light" ? 200 : 800],
|
||||
},
|
||||
[`& .${linearProgressClasses.bar}`]: {
|
||||
borderRadius: 5,
|
||||
backgroundColor: theme.palette.mode === "light" ? "#1a90ff" : "#308fe8",
|
||||
},
|
||||
}));
|
||||
|
||||
export default BorderLinearProgress;
|
||||
243
src/component/Common/Captcha/CapCaptcha.tsx
Executable file
243
src/component/Common/Captcha/CapCaptcha.tsx
Executable file
@@ -0,0 +1,243 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { CaptchaParams } from "./Captcha.tsx";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
|
||||
// Cap Widget URLs
|
||||
const CAP_WASM_UNPKG_URL = "https://unpkg.com/@cap.js/wasm@0.0.4/browser/cap_wasm.js";
|
||||
const CAP_WASM_JSDELIVR_URL = "https://cdn.jsdelivr.net/npm/@cap.js/wasm@0.0.4/browser/cap_wasm.min.js";
|
||||
const CAP_WIDGET_UNPKG_URL = "https://unpkg.com/@cap.js/widget";
|
||||
const CAP_WIDGET_JSDELIVR_URL = "https://cdn.jsdelivr.net/npm/@cap.js/widget";
|
||||
|
||||
export interface CapProps {
|
||||
onStateChange: (state: CaptchaParams) => void;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// Standard input height
|
||||
const STANDARD_INPUT_HEIGHT = "56px";
|
||||
|
||||
const CapCaptcha = ({ onStateChange, generation, fullWidth, ...rest }: CapProps & { fullWidth?: boolean }) => {
|
||||
const captchaRef = useRef<HTMLDivElement>(null);
|
||||
const widgetRef = useRef<any>(null);
|
||||
const onStateChangeRef = useRef(onStateChange);
|
||||
const scriptLoadedRef = useRef(false);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const capInstanceURL = useAppSelector((state) => state.siteConfig.basic.config.captcha_cap_instance_url);
|
||||
const capSiteKey = useAppSelector((state) => state.siteConfig.basic.config.captcha_cap_site_key);
|
||||
const capAssetServer = useAppSelector((state) => state.siteConfig.basic.config.captcha_cap_asset_server);
|
||||
|
||||
// Keep callback reference up to date
|
||||
useEffect(() => {
|
||||
onStateChangeRef.current = onStateChange;
|
||||
}, [onStateChange]);
|
||||
|
||||
// Apply responsive styles for fullWidth mode
|
||||
const applyFullWidthStyles = (widget: HTMLElement) => {
|
||||
const applyStyles = () => {
|
||||
// Style widget container
|
||||
widget.style.width = "100%";
|
||||
widget.style.display = "block";
|
||||
widget.style.boxSizing = "border-box";
|
||||
|
||||
// Style internal captcha element
|
||||
const captchaElement = widget.shadowRoot?.querySelector(".captcha") || widget.querySelector(".captcha");
|
||||
if (captchaElement) {
|
||||
const captchaEl = captchaElement as HTMLElement;
|
||||
captchaEl.style.width = "100%";
|
||||
captchaEl.style.maxWidth = "none";
|
||||
captchaEl.style.minWidth = "0";
|
||||
captchaEl.style.boxSizing = "border-box";
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Apply immediately or wait for DOM changes
|
||||
if (!applyStyles()) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (applyStyles()) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(widget, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Fallback timeout
|
||||
setTimeout(() => {
|
||||
applyStyles();
|
||||
observer.disconnect();
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
const createWidget = () => {
|
||||
if (!captchaRef.current || !capInstanceURL || !capSiteKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing widget
|
||||
if (widgetRef.current) {
|
||||
widgetRef.current.remove?.();
|
||||
widgetRef.current = null;
|
||||
}
|
||||
|
||||
// Clear container
|
||||
captchaRef.current.innerHTML = "";
|
||||
|
||||
if (typeof window !== "undefined" && (window as any).Cap) {
|
||||
const widget = document.createElement("cap-widget");
|
||||
|
||||
// Cap 2.0 API format: {instanceURL}/{siteKey}/
|
||||
const apiEndpoint = `${capInstanceURL.replace(/\/$/, "")}/${capSiteKey}/`;
|
||||
widget.setAttribute("data-cap-api-endpoint", apiEndpoint);
|
||||
widget.id = "cap-widget";
|
||||
|
||||
// Set internationalization attributes (Cap official i18n format)
|
||||
widget.setAttribute("data-cap-i18n-initial-state", t("captcha.cap.human"));
|
||||
widget.setAttribute("data-cap-i18n-verifying-label", t("captcha.cap.verifying"));
|
||||
widget.setAttribute("data-cap-i18n-solved-label", t("captcha.cap.verified"));
|
||||
|
||||
captchaRef.current.appendChild(widget);
|
||||
|
||||
widget.addEventListener("solve", (e: any) => {
|
||||
const token = e.detail.token;
|
||||
if (token) {
|
||||
onStateChangeRef.current({ ticket: token });
|
||||
}
|
||||
});
|
||||
|
||||
// Apply fullWidth styles if needed
|
||||
if (fullWidth) {
|
||||
applyFullWidthStyles(widget);
|
||||
}
|
||||
|
||||
widgetRef.current = widget;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (generation > 0) {
|
||||
createWidget();
|
||||
}
|
||||
}, [generation, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capInstanceURL || !capSiteKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在加载 widget 脚本之前设置 WASM URL
|
||||
if (capAssetServer === "instance") {
|
||||
(window as any).CAP_CUSTOM_WASM_URL = `${capInstanceURL.replace(/\/$/, "")}/assets/cap_wasm.js`;
|
||||
} else if (capAssetServer === "unpkg") {
|
||||
(window as any).CAP_CUSTOM_WASM_URL = CAP_WASM_UNPKG_URL;
|
||||
} else {
|
||||
// jsdelivr - 默认CDN
|
||||
(window as any).CAP_CUSTOM_WASM_URL = CAP_WASM_JSDELIVR_URL;
|
||||
}
|
||||
|
||||
const scriptId = "cap-widget-script";
|
||||
let script = document.getElementById(scriptId) as HTMLScriptElement;
|
||||
|
||||
const initWidget = () => {
|
||||
scriptLoadedRef.current = true;
|
||||
|
||||
// Add a small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
createWidget();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
if (!script) {
|
||||
script = document.createElement("script");
|
||||
script.id = scriptId;
|
||||
|
||||
// 根据配置选择静态资源源
|
||||
let assetSource;
|
||||
if (capAssetServer === "instance") {
|
||||
assetSource = `${capInstanceURL.replace(/\/$/, "")}/assets/widget.js`;
|
||||
} else if (capAssetServer === "unpkg") {
|
||||
assetSource = CAP_WIDGET_UNPKG_URL;
|
||||
} else {
|
||||
// jsdelivr - 默认CDN
|
||||
assetSource = CAP_WIDGET_JSDELIVR_URL;
|
||||
}
|
||||
|
||||
script.src = assetSource;
|
||||
script.async = true;
|
||||
script.onload = initWidget;
|
||||
script.onerror = () => {
|
||||
if (capAssetServer === "instance") {
|
||||
console.error("Failed to load Cap widget script from instance server");
|
||||
} else if (capAssetServer === "unpkg") {
|
||||
console.error("Failed to load Cap widget script from unpkg CDN");
|
||||
} else {
|
||||
console.error("Failed to load Cap widget script from jsDelivr CDN");
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else if (scriptLoadedRef.current || (window as any).Cap) {
|
||||
// Script already loaded
|
||||
initWidget();
|
||||
} else {
|
||||
// Script exists but not loaded yet
|
||||
script.onload = initWidget;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup widget (keep script for reuse)
|
||||
if (widgetRef.current) {
|
||||
widgetRef.current.remove?.();
|
||||
widgetRef.current = null;
|
||||
}
|
||||
if (captchaRef.current) {
|
||||
captchaRef.current.innerHTML = "";
|
||||
}
|
||||
};
|
||||
}, [capInstanceURL, capSiteKey, capAssetServer, t]);
|
||||
|
||||
if (!capInstanceURL || !capSiteKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
// Container full width when needed
|
||||
...(fullWidth && { width: "100%" }),
|
||||
|
||||
// CSS variables for Cloudreve theme adaptation
|
||||
"& cap-widget": {
|
||||
"--cap-border-radius": `${theme.shape.borderRadius}px`,
|
||||
"--cap-background": theme.palette.background.paper,
|
||||
"--cap-border-color": theme.palette.divider,
|
||||
"--cap-color": theme.palette.text.primary,
|
||||
"--cap-widget-height": fullWidth ? STANDARD_INPUT_HEIGHT : "auto",
|
||||
"--cap-widget-padding": "16px",
|
||||
"--cap-gap": "12px",
|
||||
"--cap-checkbox-size": "20px",
|
||||
"--cap-checkbox-border-radius": "4px",
|
||||
"--cap-checkbox-background": theme.palette.action.hover,
|
||||
"--cap-checkbox-border": `1px solid ${theme.palette.divider}`,
|
||||
"--cap-font": String(theme.typography.fontFamily || "Roboto, sans-serif"),
|
||||
"--cap-spinner-color": theme.palette.primary.main,
|
||||
"--cap-spinner-background-color": theme.palette.action.hover,
|
||||
"--cap-credits-font-size": String(theme.typography.caption.fontSize || "12px"),
|
||||
"--cap-opacity-hover": "0.7",
|
||||
} as React.CSSProperties,
|
||||
}}
|
||||
>
|
||||
<div ref={captchaRef} {...rest} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CapCaptcha;
|
||||
37
src/component/Common/Captcha/Captcha.tsx
Executable file
37
src/component/Common/Captcha/Captcha.tsx
Executable file
@@ -0,0 +1,37 @@
|
||||
import { CaptchaType } from "../../../api/site.ts";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import CapCaptcha from "./CapCaptcha.tsx";
|
||||
import DefaultCaptcha from "./DefaultCaptcha.tsx";
|
||||
import ReCaptchaV2 from "./ReCaptchaV2.tsx";
|
||||
import TurnstileCaptcha from "./TurnstileCaptcha.tsx";
|
||||
|
||||
export interface CaptchaProps {
|
||||
onStateChange: (state: CaptchaParams) => void;
|
||||
generation: number;
|
||||
noLabel?: boolean;
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export interface CaptchaParams {
|
||||
[x: string]: any;
|
||||
}
|
||||
|
||||
export const Captcha = (props: CaptchaProps) => {
|
||||
const captchaType = useAppSelector((state) => state.siteConfig.basic.config.captcha_type);
|
||||
|
||||
// const recaptcha = useRecaptcha(setCaptchaLoading);
|
||||
// const tcaptcha = useTCaptcha(setCaptchaLoading);
|
||||
|
||||
switch (captchaType) {
|
||||
case CaptchaType.RECAPTCHA:
|
||||
return <ReCaptchaV2 {...props} />;
|
||||
case CaptchaType.TURNSTILE:
|
||||
return <TurnstileCaptcha {...props} />;
|
||||
case CaptchaType.CAP:
|
||||
return <CapCaptcha {...props} />;
|
||||
// case "tcaptcha":
|
||||
// return { ...tcaptcha, captchaRefreshRef, captchaLoading };
|
||||
default:
|
||||
return <DefaultCaptcha {...props} />;
|
||||
}
|
||||
};
|
||||
98
src/component/Common/Captcha/DefaultCaptcha.tsx
Executable file
98
src/component/Common/Captcha/DefaultCaptcha.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import { Box, InputAdornment, Skeleton, TextField } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCaptcha } from "../../../api/api.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { CaptchaParams } from "./Captcha.tsx";
|
||||
|
||||
export interface DefaultCaptchaProps {
|
||||
onStateChange: (state: CaptchaParams) => void;
|
||||
generation: number;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
const DefaultCaptcha = ({ onStateChange, generation, noLabel, ...rest }: DefaultCaptchaProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [sessionId, setSessionID] = useState("");
|
||||
const [captchaData, setCaptchaData] = useState<string>();
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
setCaptchaData(undefined);
|
||||
const captchaResponse = await dispatch(getCaptcha());
|
||||
setCaptchaData(captchaResponse.image);
|
||||
setSessionID(captchaResponse.ticket);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshCaptcha();
|
||||
}, [generation]);
|
||||
|
||||
useEffect(() => {
|
||||
onStateChange({ captcha, ticket: sessionId });
|
||||
}, [captcha, sessionId]);
|
||||
|
||||
return (
|
||||
<TextField
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
pr: 0.5,
|
||||
},
|
||||
}}
|
||||
variant={"outlined"}
|
||||
label={noLabel ? undefined : t("login.captcha")}
|
||||
onChange={(e) => setCaptcha(e.target.value)}
|
||||
value={captcha}
|
||||
autoComplete={"true"}
|
||||
{...rest}
|
||||
slotProps={{
|
||||
input: {
|
||||
endAdornment: (
|
||||
<InputAdornment position={"end"}>
|
||||
<Box
|
||||
sx={{
|
||||
cursor: "pointer",
|
||||
height: 48,
|
||||
}}
|
||||
title={t("login.clickToRefresh")}
|
||||
>
|
||||
{!captchaData && (
|
||||
<Skeleton
|
||||
animation={"wave"}
|
||||
sx={{
|
||||
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
|
||||
}}
|
||||
variant="rounded"
|
||||
width={192}
|
||||
height={48}
|
||||
/>
|
||||
)}
|
||||
{captchaData && (
|
||||
<Box
|
||||
component={"img"}
|
||||
sx={{
|
||||
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
|
||||
height: 48,
|
||||
}}
|
||||
src={captchaData}
|
||||
alt="captcha"
|
||||
onClick={refreshCaptcha}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
|
||||
htmlInput: {
|
||||
name: "captcha",
|
||||
id: "captcha",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultCaptcha;
|
||||
60
src/component/Common/Captcha/ReCaptchaV2.tsx
Executable file
60
src/component/Common/Captcha/ReCaptchaV2.tsx
Executable file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { CaptchaParams } from "./Captcha.tsx";
|
||||
import ReCAPTCHA from "react-google-recaptcha";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
|
||||
export interface ReCaptchaV2Props {
|
||||
onStateChange: (state: CaptchaParams) => void;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
subTitle: string;
|
||||
recaptchaOptions: {
|
||||
useRecaptchaNet: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.recaptchaOptions = {
|
||||
useRecaptchaNet: true,
|
||||
};
|
||||
|
||||
const ReCaptchaV2 = ({ onStateChange, generation, ...rest }: ReCaptchaV2Props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const captchaRef = useRef();
|
||||
const reCaptchaKey = useAppSelector((state) => state.siteConfig.basic.config.captcha_ReCaptchaKey);
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
captchaRef.current?.reset();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshCaptcha();
|
||||
}, [generation]);
|
||||
|
||||
const onCompleted = () => {
|
||||
const recaptchaValue = captchaRef.current?.getValue();
|
||||
if (recaptchaValue) {
|
||||
onStateChange({ captcha: recaptchaValue });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
<ReCAPTCHA
|
||||
style={{ display: "inline-block" }}
|
||||
ref={captchaRef}
|
||||
sitekey={reCaptchaKey}
|
||||
onChange={onCompleted}
|
||||
theme={theme.palette.mode}
|
||||
{...rest}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReCaptchaV2;
|
||||
50
src/component/Common/Captcha/TurnstileCaptcha.tsx
Executable file
50
src/component/Common/Captcha/TurnstileCaptcha.tsx
Executable file
@@ -0,0 +1,50 @@
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { Box, useTheme } from "@mui/material";
|
||||
import i18next from "i18next";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useAppSelector } from "../../../redux/hooks.ts";
|
||||
import { CaptchaParams } from "./Captcha.tsx";
|
||||
|
||||
export interface TurnstileProps {
|
||||
onStateChange: (state: CaptchaParams) => void;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
const TurnstileCaptcha = ({ onStateChange, generation, ...rest }: TurnstileProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const captchaRef = useRef();
|
||||
const turnstileKey = useAppSelector((state) => state.siteConfig.basic.config.turnstile_site_id);
|
||||
|
||||
const refreshCaptcha = async () => {
|
||||
captchaRef.current?.reset();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshCaptcha();
|
||||
}, [generation]);
|
||||
|
||||
const onCompleted = (t: string) => {
|
||||
onStateChange({ ticket: t });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
{turnstileKey && (
|
||||
<Turnstile
|
||||
ref={captchaRef}
|
||||
siteKey={turnstileKey}
|
||||
options={{
|
||||
size: "flexible",
|
||||
theme: theme.palette.mode,
|
||||
language: i18next.language,
|
||||
}}
|
||||
onSuccess={onCompleted}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TurnstileCaptcha;
|
||||
39
src/component/Common/CircularProgress.tsx
Executable file
39
src/component/Common/CircularProgress.tsx
Executable file
@@ -0,0 +1,39 @@
|
||||
import { Box, CircularProgress, circularProgressClasses, CircularProgressProps } from "@mui/material";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
export interface FacebookCircularProgressProps extends CircularProgressProps {
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
}
|
||||
|
||||
const FacebookCircularProgress = forwardRef(({ sx, bgColor, fgColor, ...rest }: FacebookCircularProgressProps, ref) => {
|
||||
return (
|
||||
<Box sx={{ position: "relative", ...sx }} ref={ref}>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
sx={{
|
||||
color: (theme) => bgColor ?? theme.palette.grey[theme.palette.mode === "light" ? 200 : 800],
|
||||
}}
|
||||
size={40}
|
||||
thickness={4}
|
||||
{...rest}
|
||||
value={100}
|
||||
/>
|
||||
<CircularProgress
|
||||
sx={{
|
||||
color: (theme) => fgColor ?? theme.palette.primary.main,
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
[`& .${circularProgressClasses.circle}`]: {
|
||||
strokeLinecap: "round",
|
||||
},
|
||||
}}
|
||||
size={40}
|
||||
thickness={4}
|
||||
{...rest}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
export default FacebookCircularProgress;
|
||||
18
src/component/Common/Code.tsx
Executable file
18
src/component/Common/Code.tsx
Executable file
@@ -0,0 +1,18 @@
|
||||
import { Box, styled } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
|
||||
const StyledCode = styled(Box)(({ theme }) => ({
|
||||
backgroundColor: grey[100],
|
||||
...theme.applyStyles("dark", {
|
||||
backgroundColor: grey[900],
|
||||
}),
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: "4px",
|
||||
padding: "1px",
|
||||
paddingLeft: "4px",
|
||||
paddingRight: "4px",
|
||||
}));
|
||||
|
||||
export const Code = ({ children }: { children?: React.ReactNode }) => {
|
||||
return <StyledCode as="code">{children}</StyledCode>;
|
||||
};
|
||||
30
src/component/Common/ErrorBoundary.tsx
Executable file
30
src/component/Common/ErrorBoundary.tsx
Executable file
@@ -0,0 +1,30 @@
|
||||
import { useRouteError } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function ErrorBoundary() {
|
||||
let error = useRouteError();
|
||||
const { t } = useTranslation();
|
||||
console.log(error);
|
||||
// Uncaught ReferenceError: path is not defined
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<h1 style={{ color: "#a4a4a4", margin: "5px 0px" }}>:(</h1>
|
||||
<h2 style={{ margin: "15px 0px" }}>{t("common:renderError")}</h2>
|
||||
{!!error && (
|
||||
<details>
|
||||
<summary>{t("common:errorDetails")}</summary>
|
||||
<pre>
|
||||
<code>{error.toString()}</code>
|
||||
</pre>
|
||||
{error.stack && (
|
||||
<pre>
|
||||
<code>{error.stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
17
src/component/Common/FadeTransition.css
Executable file
17
src/component/Common/FadeTransition.css
Executable file
@@ -0,0 +1,17 @@
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade-enter-active {
|
||||
opacity: 1;
|
||||
}
|
||||
.fade-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
.fade-exit-active {
|
||||
opacity: 0;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-exit-active {
|
||||
transition: opacity 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
127
src/component/Common/Form/EncodingSelector.tsx
Executable file
127
src/component/Common/Form/EncodingSelector.tsx
Executable file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
FormControl,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
SelectProps,
|
||||
styled,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NoLabelFilledSelect } from "../../FileManager/Sidebar/CustomProps/MultiSelectPropsContent.tsx";
|
||||
import Translate from "../../Icons/Translate.tsx";
|
||||
|
||||
const encodings = [
|
||||
"ibm866",
|
||||
"iso8859_2",
|
||||
"iso8859_3",
|
||||
"iso8859_4",
|
||||
"iso8859_5",
|
||||
"iso8859_6",
|
||||
"iso8859_7",
|
||||
"iso8859_8",
|
||||
"iso8859_8I",
|
||||
"iso8859_10",
|
||||
"iso8859_13",
|
||||
"iso8859_14",
|
||||
"iso8859_15",
|
||||
"iso8859_16",
|
||||
"koi8r",
|
||||
"koi8u",
|
||||
"macintosh",
|
||||
"windows874",
|
||||
"windows1250",
|
||||
"windows1251",
|
||||
"windows1252",
|
||||
"windows1253",
|
||||
"windows1254",
|
||||
"windows1255",
|
||||
"windows1256",
|
||||
"windows1257",
|
||||
"windows1258",
|
||||
"macintoshcyrillic",
|
||||
"gbk",
|
||||
"gb18030",
|
||||
"big5",
|
||||
"eucjp",
|
||||
"iso2022jp",
|
||||
"shiftjis",
|
||||
"euckr",
|
||||
"utf16be",
|
||||
"utf16le",
|
||||
];
|
||||
|
||||
const defaultEncodingValue = " ";
|
||||
|
||||
export interface EncodingSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label?: string;
|
||||
size?: "small" | "medium";
|
||||
variant?: "outlined" | "standard" | "filled";
|
||||
fullWidth?: boolean;
|
||||
showIcon?: boolean;
|
||||
SelectProps?: Partial<SelectProps>;
|
||||
}
|
||||
|
||||
export const StyledInputAdornment = styled(InputAdornment)(({ theme }) => ({
|
||||
"&.MuiInputAdornment-positionStart": {
|
||||
marginTop: "0!important",
|
||||
},
|
||||
}));
|
||||
|
||||
const EncodingSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
size = "medium",
|
||||
variant = "outlined",
|
||||
fullWidth = false,
|
||||
showIcon = true,
|
||||
SelectProps,
|
||||
}: EncodingSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
|
||||
const displayLabel = label || t("modals.selectEncoding");
|
||||
|
||||
const SelectComponent = size == "small" ? NoLabelFilledSelect : Select;
|
||||
const InputAdornmentComponent = size == "small" ? StyledInputAdornment : InputAdornment;
|
||||
|
||||
return (
|
||||
<FormControl variant={variant} fullWidth={fullWidth} size={size}>
|
||||
{size != "small" && <InputLabel>{displayLabel}</InputLabel>}
|
||||
<SelectComponent
|
||||
variant={variant}
|
||||
size={size}
|
||||
startAdornment={
|
||||
showIcon &&
|
||||
!isMobile && (
|
||||
<InputAdornmentComponent position="start" sx={{ mt: 0 }}>
|
||||
<Translate />
|
||||
</InputAdornmentComponent>
|
||||
)
|
||||
}
|
||||
label={displayLabel}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as string)}
|
||||
{...SelectProps}
|
||||
>
|
||||
<MenuItem value={defaultEncodingValue}>
|
||||
<em>{t("modals.defaultEncoding")}</em>
|
||||
</MenuItem>
|
||||
{encodings.map((enc) => (
|
||||
<MenuItem key={enc} value={enc}>
|
||||
{enc}
|
||||
</MenuItem>
|
||||
))}
|
||||
</SelectComponent>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export { defaultEncodingValue };
|
||||
export default EncodingSelector;
|
||||
22
src/component/Common/Form/FileDisplayForm.tsx
Executable file
22
src/component/Common/Form/FileDisplayForm.tsx
Executable file
@@ -0,0 +1,22 @@
|
||||
import FileBadge from "../../FileManager/FileBadge.tsx";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { StyledTextField } from "./PathSelectorForm.tsx";
|
||||
|
||||
export interface FileDisplayFormProps {
|
||||
file: FileResponse;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const FileDisplayForm = ({ file, label }: FileDisplayFormProps) => {
|
||||
return (
|
||||
<StyledTextField
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: <FileBadge file={file} clickable={false} />,
|
||||
}}
|
||||
label={label}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
};
|
||||
21
src/component/Common/Form/OutlineIconTextField.tsx
Executable file
21
src/component/Common/Form/OutlineIconTextField.tsx
Executable file
@@ -0,0 +1,21 @@
|
||||
import { InputAdornment, TextField, TextFieldProps, useMediaQuery, useTheme } from "@mui/material";
|
||||
|
||||
export interface OutlineIconTextFieldProps extends TextFieldProps<"outlined"> {
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
export const OutlineIconTextField = ({ icon, ...rest }: OutlineIconTextFieldProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
return (
|
||||
<TextField
|
||||
{...rest}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: !isMobile && <InputAdornment position="start">{icon}</InputAdornment>,
|
||||
...rest.InputProps,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
48
src/component/Common/Form/PathSelectorForm.tsx
Executable file
48
src/component/Common/Form/PathSelectorForm.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { styled, TextField, TextFieldProps } from "@mui/material";
|
||||
import { useCallback } from "react";
|
||||
import { FileType } from "../../../api/explorer.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { selectPath } from "../../../redux/thunks/dialog.ts";
|
||||
import FileBadge from "../../FileManager/FileBadge.tsx";
|
||||
|
||||
export interface PathSelectorFormProps {
|
||||
path: string;
|
||||
label?: string;
|
||||
onChange: (path: string) => void;
|
||||
variant?: string;
|
||||
textFieldProps?: TextFieldProps;
|
||||
allowedFs?: string[];
|
||||
}
|
||||
|
||||
export const StyledTextField = styled(TextField)({
|
||||
"& .MuiInputBase-root": {
|
||||
paddingLeft: "8px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
paddingLeft: "8px",
|
||||
cursor: "pointer",
|
||||
},
|
||||
});
|
||||
|
||||
export const PathSelectorForm = ({ path, onChange, label, variant, textFieldProps }: PathSelectorFormProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(selectPath(variant ?? "saveTo", path)).then((path) => {
|
||||
onChange(path);
|
||||
});
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<StyledTextField
|
||||
onClick={onClick}
|
||||
variant="outlined"
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
startAdornment: <FileBadge simplifiedFile={{ path, type: FileType.folder }} clickable={false} />,
|
||||
}}
|
||||
label={label}
|
||||
fullWidth
|
||||
{...textFieldProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
47
src/component/Common/LanguageSwitcher.tsx
Executable file
47
src/component/Common/LanguageSwitcher.tsx
Executable file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import i18next from "i18next";
|
||||
import { languages } from "../../i18n";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton, Menu, MenuItem, Tooltip } from "@mui/material";
|
||||
import Translate from "../Icons/Translate.tsx";
|
||||
|
||||
const LanguageSwitcher: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={t("login.switchLanguage")}>
|
||||
<IconButton onClick={handleClick} sx={{ ml: 1 }}>
|
||||
<Translate />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
{languages.map((lang) => (
|
||||
<MenuItem
|
||||
key={lang.code}
|
||||
selected={i18next.language === lang.code}
|
||||
onClick={() => {
|
||||
i18next.changeLanguage(lang.code);
|
||||
handleClose();
|
||||
}}
|
||||
sx={{ fontSize: 14 }}
|
||||
>
|
||||
{lang.displayName}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
48
src/component/Common/Logo.tsx
Executable file
48
src/component/Common/Logo.tsx
Executable file
@@ -0,0 +1,48 @@
|
||||
import { Box, Skeleton, useTheme } from "@mui/material";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useAppSelector } from "../../redux/hooks.ts";
|
||||
|
||||
const Logo = (props: any) => {
|
||||
const theme = useTheme();
|
||||
const imageRef = useRef<HTMLImageElement>();
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const { mode } = theme.palette;
|
||||
const logo = useAppSelector((state) => state.siteConfig.basic.config.logo);
|
||||
const logo_light = useAppSelector((state) => state.siteConfig.basic.config.logo_light);
|
||||
useEffect(() => {
|
||||
setLoaded(logo == logo_light);
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (imageRef.current?.complete) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!logo || !loaded) && <Skeleton animation="wave" {...props} />}
|
||||
{logo && (
|
||||
<Box
|
||||
ref={imageRef}
|
||||
component={"img"}
|
||||
onLoad={() => setLoaded(true)}
|
||||
src={mode === "light" ? logo : logo_light}
|
||||
alt="Logo"
|
||||
{...props}
|
||||
sx={{
|
||||
display: loaded ? "block" : "none",
|
||||
// disable drag
|
||||
userSelect: "none",
|
||||
WebkitUserDrag: "none",
|
||||
MozUserDrag: "none",
|
||||
msUserDrag: "none",
|
||||
...props.sx,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
45
src/component/Common/Nothing.tsx
Executable file
45
src/component/Common/Nothing.tsx
Executable file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import PackageOpen from "../Icons/PackageOpen.tsx";
|
||||
|
||||
export interface NothingProps {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
top?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export default function Nothing({ primary, secondary, top = 20, size = 1 }: NothingProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
margin: `${50 * size}px auto`,
|
||||
paddingTop: `${top}px`,
|
||||
bottom: "0",
|
||||
|
||||
color: (theme) => theme.palette.action.disabled,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<PackageOpen
|
||||
sx={{
|
||||
fontSize: 100 * size,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant={"h5"}
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: (theme) => theme.palette.action.disabled,
|
||||
}}
|
||||
>
|
||||
{primary}
|
||||
</Typography>
|
||||
{secondary && (
|
||||
<Typography variant={"body2"} sx={{ color: (theme) => theme.palette.action.disabled }}>
|
||||
{secondary}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
104
src/component/Common/ResponsiveTabs.tsx
Executable file
104
src/component/Common/ResponsiveTabs.tsx
Executable file
@@ -0,0 +1,104 @@
|
||||
import * as React from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { Box, ListItemIcon, ListItemText, Menu, MenuItem, Typography, useMediaQuery, useTheme } from "@mui/material";
|
||||
import { StyledTab, StyledTabs } from "./StyledComponents.tsx";
|
||||
import CaretDown from "../Icons/CaretDown.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { bindMenu, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
|
||||
|
||||
export interface Tab<T> {
|
||||
label: React.ReactNode;
|
||||
value: T;
|
||||
icon?: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface ResponsiveTabsProps<T> {
|
||||
tabs: Tab<T>[];
|
||||
value: T;
|
||||
onChange: (event: React.SyntheticEvent, value: T) => void;
|
||||
}
|
||||
|
||||
const ResponsiveTabs = <T,>({ tabs, value, onChange }: ResponsiveTabsProps<T>) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [hideTabs, setHideTabs] = useState(false);
|
||||
const tabsRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation();
|
||||
const moreOptionState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "tabMore",
|
||||
});
|
||||
const { onClose, ...menuProps } = bindMenu(moreOptionState);
|
||||
useLayoutEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (tabsRef.current?.children[0]?.children[0]) {
|
||||
setHideTabs((e) =>
|
||||
e
|
||||
? true
|
||||
: (tabsRef.current?.children[0]?.children[0]?.scrollWidth ?? 0) >
|
||||
(tabsRef.current?.children[0]?.children[0]?.clientWidth ?? 0),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
window.addEventListener("resize", checkOverflow);
|
||||
return () => window.removeEventListener("resize", checkOverflow);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
pb: "2px",
|
||||
}}
|
||||
>
|
||||
<StyledTabs ref={tabsRef} value={value} onChange={onChange}>
|
||||
{tabs
|
||||
.filter((tab) => (isMobile || hideTabs ? tab.value == value : true))
|
||||
.map((tab) => (
|
||||
<StyledTab label={tab.label} value={tab.value} icon={tab.icon} />
|
||||
))}
|
||||
{(isMobile || hideTabs) && tabs.length > 1 && (
|
||||
<>
|
||||
<StyledTab
|
||||
label={
|
||||
<Typography
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: "4px",
|
||||
}}
|
||||
variant={"inherit"}
|
||||
>
|
||||
{t("application:navbar.showMore")}
|
||||
<CaretDown sx={{ fontSize: 15 }} />
|
||||
</Typography>
|
||||
}
|
||||
{...bindTrigger(moreOptionState)}
|
||||
/>
|
||||
<Menu {...menuProps} onClose={onClose}>
|
||||
{tabs
|
||||
.filter((tab) => tab.value != value)
|
||||
.map((option, index) => (
|
||||
<MenuItem
|
||||
dense
|
||||
key={index}
|
||||
onClick={(e) => {
|
||||
onClose();
|
||||
onChange(e, option.value);
|
||||
}}
|
||||
>
|
||||
{option.icon && <ListItemIcon>{option.icon}</ListItemIcon>}
|
||||
<ListItemText>{option.label}</ListItemText>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</StyledTabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResponsiveTabs;
|
||||
186
src/component/Common/SizeInput.tsx
Executable file
186
src/component/Common/SizeInput.tsx
Executable file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
FilledInput,
|
||||
FilledInputProps,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
styled,
|
||||
} from "@mui/material";
|
||||
import { parseInt } from "lodash";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch } from "../../redux/hooks.ts";
|
||||
import { DenseFilledTextField } from "./StyledComponents.tsx";
|
||||
|
||||
const unitTransform = (v?: number): number[] => {
|
||||
if (!v || v.toString() === "0") {
|
||||
return [0, 1024 * 1024];
|
||||
}
|
||||
for (let i = 4; i >= 0; i--) {
|
||||
const base = Math.pow(1024, i);
|
||||
if (v % base === 0) {
|
||||
return [v / base, base];
|
||||
}
|
||||
}
|
||||
|
||||
return [0, 1024 * 1024];
|
||||
};
|
||||
|
||||
export interface SizeInputProps {
|
||||
onChange: (size: number) => void;
|
||||
min?: number;
|
||||
value: number;
|
||||
required?: boolean;
|
||||
label?: string;
|
||||
max?: number;
|
||||
suffix?: string;
|
||||
inputProps?: FilledInputProps;
|
||||
variant?: "filled" | "outlined";
|
||||
allowZero?: boolean;
|
||||
}
|
||||
|
||||
export const StyledSelect = styled(Select)(() => ({
|
||||
"& .MuiFilledInput-input": {
|
||||
paddingTop: "5px",
|
||||
"&:focus": {
|
||||
backgroundColor: "initial",
|
||||
},
|
||||
},
|
||||
minWidth: "70px",
|
||||
marginTop: "14px",
|
||||
backgroundColor: "initial",
|
||||
}));
|
||||
|
||||
export const StyleOutlinedSelect = styled(Select)(({ theme }) => ({
|
||||
"& .MuiFilledInput-input": {
|
||||
paddingTop: "5px",
|
||||
"&:focus": {
|
||||
backgroundColor: "initial",
|
||||
},
|
||||
},
|
||||
minWidth: "70px",
|
||||
backgroundColor: "initial",
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
}));
|
||||
|
||||
export default function SizeInput({
|
||||
onChange,
|
||||
min,
|
||||
value,
|
||||
required,
|
||||
label,
|
||||
max,
|
||||
inputProps,
|
||||
allowZero = true,
|
||||
suffix,
|
||||
variant = "filled",
|
||||
}: SizeInputProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [unit, setUnit] = useState(1);
|
||||
const [val, setVal] = useState(value);
|
||||
const [err, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
onChange(val * unit);
|
||||
if ((max && val * unit > max) || (min && val * unit < min)) {
|
||||
setError(t("common:incorrectSizeInput"));
|
||||
} else {
|
||||
setError("");
|
||||
}
|
||||
}, [val, unit, max, min]);
|
||||
|
||||
useEffect(() => {
|
||||
const res = unitTransform(value);
|
||||
setUnit(res[1]);
|
||||
setVal(res[0]);
|
||||
}, [value]);
|
||||
|
||||
if (variant === "outlined") {
|
||||
return (
|
||||
<DenseFilledTextField
|
||||
value={val}
|
||||
type={"number"}
|
||||
inputProps={{ step: 1, min: allowZero ? 0 : 1 }}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVal(parseInt(e.target.value) ?? 0)}
|
||||
error={err !== ""}
|
||||
helperText={err}
|
||||
required={required}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<StyleOutlinedSelect
|
||||
variant={"filled"}
|
||||
size={"small"}
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as number)}
|
||||
>
|
||||
<MenuItem dense value={1}>
|
||||
B{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024}>
|
||||
KB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024}>
|
||||
MB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024 * 1024}>
|
||||
GB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024 * 1024 * 1024}>
|
||||
TB{suffix && suffix}
|
||||
</MenuItem>
|
||||
</StyleOutlinedSelect>
|
||||
</InputAdornment>
|
||||
),
|
||||
...inputProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl variant={"filled"} error={err !== ""}>
|
||||
<InputLabel htmlFor="component-helper">{label}</InputLabel>
|
||||
<FilledInput
|
||||
value={val}
|
||||
type={"number"}
|
||||
inputProps={{ step: 1, min: allowZero ? 0 : 1 }}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVal(parseInt(e.target.value) ?? 0)}
|
||||
required={required}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<StyledSelect
|
||||
variant={"filled"}
|
||||
size={"small"}
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as number)}
|
||||
>
|
||||
<MenuItem dense value={1}>
|
||||
B{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024}>
|
||||
KB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024}>
|
||||
MB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024 * 1024}>
|
||||
GB{suffix && suffix}
|
||||
</MenuItem>
|
||||
<MenuItem dense value={1024 * 1024 * 1024 * 1024}>
|
||||
TB{suffix && suffix}
|
||||
</MenuItem>
|
||||
</StyledSelect>
|
||||
</InputAdornment>
|
||||
}
|
||||
{...inputProps}
|
||||
/>
|
||||
{err !== "" && <FormHelperText>{err}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
82
src/component/Common/Snackbar/FileIconSnackbar.tsx
Executable file
82
src/component/Common/Snackbar/FileIconSnackbar.tsx
Executable file
@@ -0,0 +1,82 @@
|
||||
import { Box } from "@mui/material";
|
||||
import MuiSnackbarContent from "@mui/material/SnackbarContent";
|
||||
import { CustomContentProps } from "notistack";
|
||||
import * as React from "react";
|
||||
import { forwardRef, useState } from "react";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx";
|
||||
|
||||
declare module "notistack" {
|
||||
interface VariantOverrides {
|
||||
file: {
|
||||
file: FileResponse;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface FileIconSnackbarProps extends CustomContentProps {
|
||||
file: FileResponse;
|
||||
}
|
||||
|
||||
const FileIconSnackbar = forwardRef<HTMLDivElement, FileIconSnackbarProps>((props, ref) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const {
|
||||
// You have access to notistack props and options 👇🏼
|
||||
message,
|
||||
action,
|
||||
id,
|
||||
file,
|
||||
// as well as your own custom props 👇🏼
|
||||
...other
|
||||
} = props;
|
||||
|
||||
let componentOrFunctionAction: React.ReactNode = undefined;
|
||||
if (typeof action === "function") {
|
||||
componentOrFunctionAction = action(id);
|
||||
} else {
|
||||
componentOrFunctionAction = action;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiSnackbarContent
|
||||
ref={ref}
|
||||
sx={{
|
||||
borderRadius: "12px",
|
||||
width: "100%",
|
||||
"& .MuiSnackbarContent-message": {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
message={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexGrow: 1, gap: 1 }}>
|
||||
<FileTypeIcon sx={{ width: 20, height: 20 }} reverseDarkMode name={file.name} fileType={file.type} />
|
||||
<Box>{message}</Box>
|
||||
</Box>
|
||||
|
||||
{componentOrFunctionAction && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginLeft: "auto",
|
||||
paddingLeft: "16px",
|
||||
marginRight: "-8px",
|
||||
}}
|
||||
>
|
||||
{componentOrFunctionAction}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileIconSnackbar;
|
||||
91
src/component/Common/Snackbar/LoadingSnackbar.tsx
Executable file
91
src/component/Common/Snackbar/LoadingSnackbar.tsx
Executable file
@@ -0,0 +1,91 @@
|
||||
import { Box } from "@mui/material";
|
||||
import MuiSnackbarContent from "@mui/material/SnackbarContent";
|
||||
import { CustomContentProps } from "notistack";
|
||||
import * as React from "react";
|
||||
import { forwardRef, useEffect, useState } from "react";
|
||||
import CircularProgress from "../CircularProgress.tsx";
|
||||
|
||||
declare module "notistack" {
|
||||
interface VariantOverrides {
|
||||
loading: {
|
||||
getProgress?: () => number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface LoadingSnackbarProps extends CustomContentProps {
|
||||
getProgress?: () => number;
|
||||
}
|
||||
|
||||
const LoadingSnackbar = forwardRef<HTMLDivElement, LoadingSnackbarProps>((props, ref) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const {
|
||||
// You have access to notistack props and options 👇🏼
|
||||
message,
|
||||
action,
|
||||
id,
|
||||
getProgress,
|
||||
// as well as your own custom props 👇🏼
|
||||
...other
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
var intervalId: NodeJS.Timeout;
|
||||
if (getProgress) {
|
||||
intervalId = setInterval(() => {
|
||||
setProgress(getProgress());
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [getProgress]);
|
||||
|
||||
let componentOrFunctionAction: React.ReactNode = undefined;
|
||||
if (typeof action === "function") {
|
||||
componentOrFunctionAction = action(id);
|
||||
} else {
|
||||
componentOrFunctionAction = action;
|
||||
}
|
||||
|
||||
return (
|
||||
<MuiSnackbarContent
|
||||
ref={ref}
|
||||
sx={{ borderRadius: "12px" }}
|
||||
message={
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<CircularProgress
|
||||
size={20}
|
||||
sx={{ height: 20, mr: 2 }}
|
||||
variant={progress ? "determinate" : "indeterminate"}
|
||||
value={progress}
|
||||
/>
|
||||
</Box>
|
||||
<Box>{message}</Box>
|
||||
{componentOrFunctionAction && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginLeft: "auto",
|
||||
paddingLeft: "16px",
|
||||
marginRight: "-8px",
|
||||
}}
|
||||
>
|
||||
{componentOrFunctionAction}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default LoadingSnackbar;
|
||||
148
src/component/Common/Snackbar/snackbar.tsx
Executable file
148
src/component/Common/Snackbar/snackbar.tsx
Executable file
@@ -0,0 +1,148 @@
|
||||
import { Button } from "@mui/material";
|
||||
import { closeSnackbar, SnackbarKey } from "notistack";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { FileResponse } from "../../../api/explorer.ts";
|
||||
import { Response } from "../../../api/request.ts";
|
||||
import { setBatchDownloadLogDialog, setShareReadmeOpen } from "../../../redux/globalStateSlice.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { showAggregatedErrorDialog } from "../../../redux/thunks/dialog.ts";
|
||||
import { navigateToPath } from "../../../redux/thunks/filemanager.ts";
|
||||
import { FileManagerIndex } from "../../FileManager/FileManager.tsx";
|
||||
|
||||
export const DefaultCloseAction = (snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => closeSnackbar(snackbarId)} color="inherit" size="small">
|
||||
{t("dismiss", { ns: "common" })}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ErrorListDetailAction = (error: Response<any>) => (snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const Close = DefaultCloseAction(snackbarId);
|
||||
|
||||
const showDetails = useCallback(() => {
|
||||
dispatch(showAggregatedErrorDialog(error));
|
||||
closeSnackbar(snackbarId);
|
||||
}, [dispatch, error, snackbarId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={showDetails} color="inherit" size="small">
|
||||
{t("common:errorDetails")}
|
||||
</Button>
|
||||
{Close}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const OpenReadMeAction = (file: FileResponse) => (snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const Close = DefaultCloseAction(snackbarId);
|
||||
|
||||
const openReadMe = useCallback(() => {
|
||||
dispatch(setShareReadmeOpen({ open: true, target: file }));
|
||||
closeSnackbar(snackbarId);
|
||||
}, [dispatch, file, snackbarId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={openReadMe} color="inherit" size="small">
|
||||
{t("application:modals.view")}
|
||||
</Button>
|
||||
{Close}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ViewDstAction = (dst: string) => (snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const Close = DefaultCloseAction(snackbarId);
|
||||
|
||||
const viewDst = useCallback(() => {
|
||||
dispatch(navigateToPath(FileManagerIndex.main, dst));
|
||||
closeSnackbar(snackbarId);
|
||||
}, [dispatch, snackbarId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={viewDst} color="inherit" size="small">
|
||||
{t("application:modals.view")}
|
||||
</Button>
|
||||
{Close}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ViewDownloadLogAction = (downloadId: string) => (_snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const viewLogs = useCallback(() => {
|
||||
dispatch(setBatchDownloadLogDialog({ open: true, id: downloadId }));
|
||||
}, [dispatch, downloadId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={viewLogs} color="inherit" size="small">
|
||||
{t("application:fileManager.details")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ViewTaskAction =
|
||||
(path: string = "/tasks") =>
|
||||
(snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const Close = DefaultCloseAction(snackbarId);
|
||||
|
||||
const viewDst = useCallback(() => {
|
||||
navigate(path);
|
||||
closeSnackbar(snackbarId);
|
||||
}, [navigate, snackbarId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={viewDst} color="inherit" size="small">
|
||||
{t("application:modals.view")}
|
||||
</Button>
|
||||
{Close}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ServiceWorkerUpdateAction = (updateServiceWorker: () => void) => (snackbarId: SnackbarKey | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const Close = DefaultCloseAction(snackbarId);
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
// Update service worker and reload
|
||||
updateServiceWorker();
|
||||
closeSnackbar(snackbarId);
|
||||
}, [updateServiceWorker, snackbarId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleUpdate} color="inherit" size="small">
|
||||
{t("common:update")}
|
||||
</Button>
|
||||
{Close}
|
||||
</>
|
||||
);
|
||||
};
|
||||
264
src/component/Common/StyledComponents.tsx
Executable file
264
src/component/Common/StyledComponents.tsx
Executable file
@@ -0,0 +1,264 @@
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import {
|
||||
alpha,
|
||||
Autocomplete,
|
||||
Box,
|
||||
Button,
|
||||
ButtonProps,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
FormControlLabelProps,
|
||||
ListItemText,
|
||||
ListItemTextProps,
|
||||
Paper,
|
||||
Select,
|
||||
styled,
|
||||
Tab,
|
||||
TableCell,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
export const DefaultButton = styled(({ variant, ...rest }: ButtonProps) => <Button variant={variant} {...rest} />)(
|
||||
({ variant, theme }) => ({
|
||||
color: theme.palette.text.primary,
|
||||
minHeight: theme.spacing(4),
|
||||
"& .MuiButton-startIcon": {
|
||||
marginLeft: 0,
|
||||
},
|
||||
border: variant == "outlined" ? `1px solid ${theme.palette.divider}` : "none",
|
||||
}),
|
||||
);
|
||||
|
||||
export const StyledTableContainerPaper = styled(Paper)(({ theme }) => ({
|
||||
boxShadow: "none",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
}));
|
||||
|
||||
export const NoWrapTypography = styled(Typography)({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const BadgeText = styled(NoWrapTypography)({
|
||||
marginLeft: "8px",
|
||||
});
|
||||
|
||||
export const FilledTextField = styled(TextField)(() => ({
|
||||
"& .Mui-disabled:before": {
|
||||
border: "none",
|
||||
},
|
||||
}));
|
||||
|
||||
export const DenseFilledTextField = styled(FilledTextField)(({ theme }) => ({
|
||||
"& .MuiOutlinedInput-input": {
|
||||
paddingTop: theme.spacing(1.2),
|
||||
paddingBottom: theme.spacing(1.2),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
},
|
||||
"& .MuiInputBase-root.MuiOutlinedInput-root": {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
// no class .Mui-focused
|
||||
"&:not(.Mui-focused):not(.MuiInputLabel-shrink)": {
|
||||
transform: "translate(14px, 10px) scale(1)",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoLabelFilledTextField = styled(FilledTextField)<{ backgroundColor?: string }>(
|
||||
({ theme, backgroundColor }) => ({
|
||||
"& .MuiInputBase-root": {
|
||||
...(backgroundColor && {
|
||||
backgroundColor: backgroundColor,
|
||||
}),
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
"& .MuiFilledInput-input": {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
"&.Mui-disabled": {
|
||||
borderBottomStyle: "none",
|
||||
"&::before": {
|
||||
borderBottomStyle: "none !important",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const DenseAutocomplete = styled(Autocomplete)(({ theme }) => ({
|
||||
"& .MuiOutlinedInput-root": {
|
||||
paddingTop: "6px",
|
||||
paddingBottom: "6px",
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
// no class .Mui-focused
|
||||
"&:not(.Mui-focused):not(.MuiInputLabel-shrink)": {
|
||||
transform: "translate(14px, 12px) scale(1)",
|
||||
},
|
||||
},
|
||||
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
|
||||
padding: "5.5px 4px 5.5px 5px",
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoWrapTableCell = styled(TableCell)({
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
export const StyledCheckbox = styled(Checkbox)(({ theme }) => ({
|
||||
width: 16,
|
||||
height: 16,
|
||||
}));
|
||||
|
||||
export const SecondaryButton = styled(Button)(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.focus,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SecondaryLoadingButton = styled(LoadingButton)(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.action.focus,
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoWrapBox = styled(Box)({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const DenseSelect = styled(Select)(({ theme }) => ({
|
||||
minHeight: "39px",
|
||||
"& .MuiOutlinedInput-input": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
paddingTop: "6px",
|
||||
paddingBottom: "6px",
|
||||
},
|
||||
"& .MuiFilledInput-input": {
|
||||
paddingTop: "4px",
|
||||
paddingBottom: "4px",
|
||||
},
|
||||
"& .MuiListItemIcon-root": {
|
||||
minWidth: 36,
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
padding: "8px 0px",
|
||||
overflow: "initial",
|
||||
minHeight: 36,
|
||||
minWidth: 0,
|
||||
transition: theme.transitions.create(["background-color", "color"]),
|
||||
"&::after": {
|
||||
content: "''",
|
||||
borderRadius: 8,
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: -8,
|
||||
right: -8,
|
||||
transition: theme.transitions.create(["background-color"]),
|
||||
},
|
||||
"&.MuiTab-root>.MuiTab-iconWrapper": {
|
||||
height: 20,
|
||||
width: 20,
|
||||
marginRight: 4,
|
||||
marginBottom: 0,
|
||||
},
|
||||
"&.MuiTab-root": {
|
||||
flexDirection: "row",
|
||||
paddingRight: 4,
|
||||
},
|
||||
"&.MuiButtonBase-root .MuiTouchRipple-root": {
|
||||
borderRadius: 8,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
left: -8,
|
||||
right: -8,
|
||||
},
|
||||
"&:hover": {
|
||||
"&:not(.Mui-selected)": { color: theme.palette.text.primary },
|
||||
"&::after": {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
"&.Mui-selected::after": {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.06),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||
minHeight: 36,
|
||||
overflow: "initial",
|
||||
"& .MuiTabs-flexContainer": {
|
||||
gap: 24,
|
||||
},
|
||||
"& .MuiTabs-scroller": {
|
||||
overflow: "initial!important",
|
||||
},
|
||||
"& .MuiTabs-indicator": {
|
||||
bottom: "initial",
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoWrapCell = styled(TableCell)({
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
export const SquareChip = styled(Chip)(() => ({
|
||||
borderRadius: 8,
|
||||
}));
|
||||
|
||||
export const SmallFormControlLabel = styled((props: FormControlLabelProps) => (
|
||||
<FormControlLabel
|
||||
{...props}
|
||||
slotProps={{
|
||||
typography: {
|
||||
variant: "body2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))(() => ({}));
|
||||
|
||||
export const StyledListItemText = styled((props: ListItemTextProps) => (
|
||||
<ListItemText
|
||||
{...props}
|
||||
slotProps={{
|
||||
primary: {
|
||||
variant: "subtitle2",
|
||||
color: "textPrimary",
|
||||
},
|
||||
|
||||
secondary: {
|
||||
variant: "body2",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
))(() => ({}));
|
||||
21
src/component/Common/StyledFormControl.tsx
Executable file
21
src/component/Common/StyledFormControl.tsx
Executable file
@@ -0,0 +1,21 @@
|
||||
import { Box, Typography } from "@mui/material";
|
||||
|
||||
export interface StyledFormControlProps {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const StyledFormControl = ({ title, children }: StyledFormControlProps) => {
|
||||
return (
|
||||
<Box>
|
||||
{title && (
|
||||
<Typography fontWeight={600} sx={{ mb: 0.5 }} variant={"body2"}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StyledFormControl;
|
||||
64
src/component/Common/TimeBadge.tsx
Executable file
64
src/component/Common/TimeBadge.tsx
Executable file
@@ -0,0 +1,64 @@
|
||||
import { Tooltip, Typography, TypographyProps } from "@mui/material";
|
||||
import { useMemo } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatLocalTime } from "../../util/datetime.ts";
|
||||
import TimeAgo from "timeago-react";
|
||||
|
||||
const defaultTimeAgoThreshold = 7 * 60 * 24; // 7 days
|
||||
|
||||
export interface TimeBadgeProps extends TypographyProps {
|
||||
datetime: string | dayjs.Dayjs;
|
||||
// If the time difference is less than this value in minutes, use time ago format
|
||||
timeAgoThreshold?: number;
|
||||
}
|
||||
|
||||
const TimeBadge = ({ timeAgoThreshold = defaultTimeAgoThreshold, datetime, sx, ...rest }: TimeBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const timeStr = useMemo(() => {
|
||||
if (typeof datetime === "string") {
|
||||
return datetime;
|
||||
}
|
||||
return datetime.toISOString();
|
||||
}, [datetime]);
|
||||
|
||||
const useTimeAgo = useMemo(() => {
|
||||
let t: dayjs.Dayjs;
|
||||
if (typeof datetime === "string") {
|
||||
t = dayjs(datetime);
|
||||
} else {
|
||||
t = datetime;
|
||||
}
|
||||
const now = dayjs();
|
||||
const diff = now.diff(t, "minute");
|
||||
return diff < timeAgoThreshold;
|
||||
}, [timeAgoThreshold, datetime]);
|
||||
|
||||
const fullTime = useMemo(() => {
|
||||
return formatLocalTime(datetime);
|
||||
}, [datetime]);
|
||||
return (
|
||||
<Tooltip title={useTimeAgo ? fullTime : ""}>
|
||||
<Typography
|
||||
component={"span"}
|
||||
sx={{
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{useTimeAgo ? (
|
||||
<TimeAgo
|
||||
datetime={timeStr}
|
||||
locale={t("timeAgoLocaleCode", {
|
||||
ns: "common",
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
fullTime
|
||||
)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBadge;
|
||||
160
src/component/Common/User/UserAvatar.tsx
Executable file
160
src/component/Common/User/UserAvatar.tsx
Executable file
@@ -0,0 +1,160 @@
|
||||
import { Avatar, Skeleton } from "@mui/material";
|
||||
import { grey } from "@mui/material/colors";
|
||||
import { bindHover, bindPopover } from "material-ui-popup-state";
|
||||
import { usePopupState } from "material-ui-popup-state/hooks";
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInView } from "react-intersection-observer";
|
||||
import { ApiPrefix } from "../../../api/request.ts";
|
||||
import { User } from "../../../api/user.ts";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { loadUserInfo } from "../../../redux/thunks/session.ts";
|
||||
import InPrivate from "../../Icons/InPrivate.tsx";
|
||||
import UserPopover from "./UserPopover.tsx";
|
||||
|
||||
export interface UserAvatarProps {
|
||||
user?: User;
|
||||
uid?: string;
|
||||
overwriteTextSize?: boolean;
|
||||
onUserLoaded?: (user: User) => void;
|
||||
enablePopover?: boolean;
|
||||
square?: boolean;
|
||||
cacheKey?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
function stringToColor(string: string) {
|
||||
let hash = 0;
|
||||
let i;
|
||||
|
||||
/* eslint-disable no-bitwise */
|
||||
for (i = 0; i < string.length; i += 1) {
|
||||
hash = string.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = "#";
|
||||
|
||||
for (i = 0; i < 3; i += 1) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += `00${value.toString(16)}`.slice(-2);
|
||||
}
|
||||
/* eslint-enable no-bitwise */
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
export const AnonymousUser: User = {
|
||||
id: "",
|
||||
nickname: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
const UserAvatar = memo(
|
||||
({
|
||||
user,
|
||||
key,
|
||||
overwriteTextSize,
|
||||
onUserLoaded,
|
||||
uid,
|
||||
sx,
|
||||
square,
|
||||
cacheKey,
|
||||
enablePopover,
|
||||
...rest
|
||||
}: UserAvatarProps) => {
|
||||
const [loadedUser, setLoadedUser] = useState<User | undefined>(undefined);
|
||||
const dispatch = useAppDispatch();
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "user",
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
rootMargin: "200px 0px",
|
||||
skip: !!user,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (inView && !loadedUser) {
|
||||
if (uid) {
|
||||
loadUser(uid);
|
||||
}
|
||||
}
|
||||
}, [inView]);
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setLoadedUser(user);
|
||||
}
|
||||
}, [user]);
|
||||
const loadUser = useCallback(
|
||||
async (uid: string) => {
|
||||
try {
|
||||
const u = await dispatch(loadUserInfo(uid));
|
||||
setLoadedUser(u);
|
||||
if (onUserLoaded) {
|
||||
onUserLoaded(u);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to load user info", e);
|
||||
}
|
||||
},
|
||||
[dispatch, setLoadedUser, onUserLoaded],
|
||||
);
|
||||
|
||||
const avatarUrl = useMemo(() => {
|
||||
if (loadedUser) {
|
||||
return ApiPrefix + `/user/avatar/${loadedUser.id}${cacheKey ? `?nocache=1&key=${cacheKey ?? 0}` : ""}`;
|
||||
}
|
||||
return undefined;
|
||||
}, [loadedUser, cacheKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{loadedUser && (
|
||||
<>
|
||||
<Avatar
|
||||
alt={loadedUser.nickname ?? t("modals.anonymous")}
|
||||
src={avatarUrl}
|
||||
slotProps={{
|
||||
img: {
|
||||
loading: "lazy",
|
||||
alt: "",
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
{...bindHover(popupState)}
|
||||
sx={[
|
||||
{
|
||||
bgcolor: loadedUser.nickname ? stringToColor(loadedUser.nickname) : grey[500],
|
||||
...sx,
|
||||
},
|
||||
overwriteTextSize && {
|
||||
fontSize: `${sx.width * 0.6}px!important`,
|
||||
},
|
||||
square && {
|
||||
borderRadius: (theme) => `${theme.shape.borderRadius}px`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{loadedUser.id == "" ? (
|
||||
<InPrivate
|
||||
sx={{
|
||||
...(overwriteTextSize
|
||||
? {
|
||||
fontSize: `${sx.width * 0.6}px`,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
</Avatar>
|
||||
{enablePopover && user && <UserPopover user={user} {...bindPopover(popupState)} />}
|
||||
</>
|
||||
)}
|
||||
{!loadedUser && <Skeleton ref={ref} variant={"circular"} sx={{ ...sx }} {...rest} />}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default UserAvatar;
|
||||
52
src/component/Common/User/UserBadge.tsx
Executable file
52
src/component/Common/User/UserBadge.tsx
Executable file
@@ -0,0 +1,52 @@
|
||||
import UserAvatar, { UserAvatarProps } from "./UserAvatar.tsx";
|
||||
import { Skeleton, TypographyProps } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { BadgeText, DefaultButton } from "../StyledComponents.tsx";
|
||||
import { usePopupState } from "material-ui-popup-state/hooks";
|
||||
import UserPopover from "./UserPopover.tsx";
|
||||
import { bindHover, bindPopover } from "material-ui-popup-state";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface UserBadgeProps extends UserAvatarProps {
|
||||
textProps?: TypographyProps;
|
||||
}
|
||||
|
||||
const UserBadge = ({ textProps, user, uid, ...rest }: UserBadgeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [userLoaded, setUserLoaded] = useState(user);
|
||||
const navigate = useNavigate();
|
||||
const popupState = usePopupState({
|
||||
variant: "popover",
|
||||
popupId: "user",
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<DefaultButton
|
||||
{...bindHover(popupState)}
|
||||
onClick={() => navigate(`/profile/${user?.id ?? uid}`)}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
maxWidth: "150px",
|
||||
}}
|
||||
>
|
||||
<UserAvatar overwriteTextSize user={user} onUserLoaded={(u) => setUserLoaded(u)} uid={uid} {...rest} />
|
||||
<BadgeText {...textProps}>
|
||||
{userLoaded ? (
|
||||
userLoaded.id ? (
|
||||
userLoaded.nickname
|
||||
) : (
|
||||
t("application:modals.anonymous")
|
||||
)
|
||||
) : (
|
||||
<Skeleton width={60} />
|
||||
)}
|
||||
</BadgeText>
|
||||
</DefaultButton>
|
||||
{userLoaded && <UserPopover user={userLoaded} {...bindPopover(popupState)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBadge;
|
||||
122
src/component/Common/User/UserPopover.tsx
Executable file
122
src/component/Common/User/UserPopover.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
import { Box, Button, PopoverProps, styled, Tooltip, Typography } from "@mui/material";
|
||||
import HoverPopover from "material-ui-popup-state/HoverPopover";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { User } from "../../../api/user.ts";
|
||||
import UserAvatar from "./UserAvatar.tsx";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { loadUserInfo } from "../../../redux/thunks/session.ts";
|
||||
import MailOutlined from "../../Icons/MailOutlined.tsx";
|
||||
import HomeOutlined from "../../Icons/HomeOutlined.tsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import TimeBadge from "../TimeBadge.tsx";
|
||||
|
||||
interface UserPopoverProps extends PopoverProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const ActionButton = styled(Button)({
|
||||
minWidth: "initial",
|
||||
});
|
||||
|
||||
export const UserProfile = ({ user, open, displayOnly }: { user: User; open: boolean; displayOnly?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const [loadedUser, setLoadedUser] = useState(user);
|
||||
useEffect(() => {
|
||||
if (open && user.id && !user.group) {
|
||||
dispatch(loadUserInfo(user.id)).then((u) => {
|
||||
if (u) {
|
||||
setLoadedUser(u);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<UserAvatar overwriteTextSize user={user} sx={{ width: 80, height: 80 }} />
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Typography variant={"h6"} fontWeight={600}>
|
||||
{user.id ? user.nickname : t("application:modals.anonymous")}
|
||||
</Typography>
|
||||
{displayOnly && (
|
||||
<Typography variant={"body2"} sx={{ ml: 1 }} color={"text.secondary"}>
|
||||
{loadedUser?.group ? loadedUser.group.name : ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
{!displayOnly && (
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
{loadedUser?.group ? loadedUser.group.name : ""}
|
||||
</Typography>
|
||||
)}
|
||||
{displayOnly && loadedUser?.created_at && (
|
||||
<Typography variant={"body2"} color={"text.secondary"}>
|
||||
<Trans
|
||||
i18nKey={"setting.accountCreatedAt"}
|
||||
ns={"application"}
|
||||
components={[<TimeBadge variant={"inherit"} datetime={loadedUser.created_at} />]}
|
||||
/>
|
||||
</Typography>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 0.5,
|
||||
position: "relative",
|
||||
left: -10,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
{!displayOnly && user.id && (
|
||||
<Tooltip title={t("application:setting.profilePage")}>
|
||||
<ActionButton onClick={() => navigate(`/profile/${user.id}`)}>
|
||||
<HomeOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{user.email && (
|
||||
<Tooltip title={user.email}>
|
||||
<ActionButton onClick={() => window.open(`mailto:${user.email}`)}>
|
||||
<MailOutlined />
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const UserPopover = ({ user, open, ...rest }: UserPopoverProps) => {
|
||||
const stopPropagation = useCallback((e: any) => e.stopPropagation(), []);
|
||||
return (
|
||||
<HoverPopover
|
||||
onMouseDown={stopPropagation}
|
||||
onMouseUp={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
open={open}
|
||||
{...rest}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: "300px", p: 2 }}>
|
||||
<UserProfile user={user} open={open} />
|
||||
</Box>
|
||||
</HoverPopover>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPopover;
|
||||
Reference in New Issue
Block a user