first commit

This commit is contained in:
2025-10-19 13:31:11 +00:00
commit 8bfc183b66
1248 changed files with 195992 additions and 0 deletions

View 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;

View 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;

View 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;

View 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} />;
}
};

View 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;

View 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;

View 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;

View 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
View 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>;
};

View 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;

View 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);
}

View 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;

View 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
/>
);
};

View 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,
},
}}
/>
);
};

View 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}
/>
);
};

View 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
View 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;

View 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>
);
}

View 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;

View 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>
);
}

View 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;

View 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;

View 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}
</>
);
};

View 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",
},
}}
/>
))(() => ({}));

View 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;

View 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;

View 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;

View 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;

View 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;