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,41 @@
import { Box, styled } from "@mui/material";
export const BorderedCard = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
}));
export const BorderedCardClickable = styled(BorderedCard)(({ theme }) => ({
cursor: "pointer",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
transition: "background-color 0.3s ease",
}));
export const BorderedCardClickableBaImg = styled(BorderedCardClickable)<{ img?: string }>(({ theme, img }) => ({
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
top: "-20px",
right: "-20px",
width: "150px",
height: "150px",
backgroundImage: `url(${img})`,
backgroundSize: "cover",
backgroundPosition: "center",
transform: "rotate(-10deg)",
opacity: 0.1,
maskImage: "radial-gradient(circle at center, black 30%, transparent 80%)",
pointerEvents: "none",
zIndex: 0,
},
"& > *": {
position: "relative",
zIndex: 1,
},
}));

View File

@@ -0,0 +1,44 @@
import { TextFieldProps } from "@mui/material";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { DenseFilledTextField } from "../../Common/StyledComponents";
export interface EndpointInputProps extends TextFieldProps<"outlined"> {
enforceProtocol?: boolean;
enforcePrefix?: boolean;
}
export const EndpointInput = (props: EndpointInputProps) => {
const { t } = useTranslation("dashboard");
const { enforceProtocol, enforcePrefix = true, onChange, ...rest } = props;
const [value, setValue] = useState<string>((props.value as string) ?? "");
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
onChange?.(e);
},
[onChange],
);
const showError = useMemo(() => {
if (!enforceProtocol) return false;
return value.startsWith("http://") && window.location.protocol == "https:";
}, [enforceProtocol, value]);
return (
<DenseFilledTextField
onChange={handleChange}
value={props.value}
slotProps={{
htmlInput: {
// start with http:// or https://
pattern: enforcePrefix ? `^https?://.*$` : undefined,
title: enforcePrefix ? t("settings.startWithProtocol") : undefined,
},
}}
error={showError}
helperText={showError ? t("settings.tlsWarning") : undefined}
{...rest}
/>
);
};

View File

@@ -0,0 +1,94 @@
import { ListItemText } from "@mui/material";
import FormControl from "@mui/material/FormControl";
import { useEffect, useState } from "react";
import { getGroupList } from "../../../api/api";
import { GroupEnt } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface GroupSelectionInputProps {
value: string;
onChange: (value: string) => void;
onChangeGroup?: (group?: GroupEnt) => void;
emptyValue?: string;
emptyText?: string;
fullWidth?: boolean;
required?: boolean;
}
const AnonymousGroupId = 3;
const GroupSelectionInput = ({
value,
onChange,
onChangeGroup,
emptyValue,
emptyText,
fullWidth,
required,
}: GroupSelectionInputProps) => {
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [groups, setGroups] = useState<GroupEnt[]>([]);
useEffect(() => {
setLoading(true);
dispatch(
getGroupList({
page_size: 1000,
page: 1,
order_by: "id",
order_direction: "asc",
}),
)
.then((res) => {
setGroups(res.groups);
})
.finally(() => {
setLoading(false);
});
}, []);
const handleChange = (value: string) => {
onChange(value);
onChangeGroup?.(groups.find((g) => g.id === parseInt(value)));
};
return (
<FormControl fullWidth={fullWidth}>
<DenseSelect
disabled={loading}
value={value}
onChange={(e) => handleChange(e.target.value as string)}
required={required}
>
{groups
.filter((g) => g.id != AnonymousGroupId)
.map((g) => (
<SquareMenuItem value={g.id.toString()}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{g.name}
</ListItemText>
</SquareMenuItem>
))}
{emptyValue !== undefined && emptyText && (
<SquareMenuItem value={emptyValue}>
<ListItemText
primary={<em>{emptyText}</em>}
slotProps={{
primary: { variant: "body2" },
}}
/>
</SquareMenuItem>
)}
</DenseSelect>
</FormControl>
);
};
export default GroupSelectionInput;

View File

@@ -0,0 +1,60 @@
import { DialogContent, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
import { useTranslation } from "react-i18next";
import { NoWrapTableCell } from "../../Common/StyledComponents.tsx";
import DraggableDialog from "../../Dialogs/DraggableDialog";
export interface MagicVar {
name: string;
value: string;
example?: string;
}
export interface MagicVarDialogProps {
open: boolean;
onClose: () => void;
vars: MagicVar[];
}
const MagicVarDialog = ({ open, onClose, vars }: MagicVarDialogProps) => {
const { t } = useTranslation("dashboard");
return (
<DraggableDialog
title={t("policy.magicVar.variable")}
onAccept={onClose}
dialogProps={{
fullWidth: true,
maxWidth: "md",
open,
onClose,
}}
>
<DialogContent>
<TableContainer sx={{ mt: 1, maxHeight: 440 }}>
<Table stickyHeader sx={{ width: "100%", tableLayout: "fixed" }} size="small">
<TableHead>
<TableRow>
<NoWrapTableCell width={350}>{t("policy.magicVar.variable")}</NoWrapTableCell>
<NoWrapTableCell width={200}>{t("policy.magicVar.description")}</NoWrapTableCell>
<NoWrapTableCell width={250}>{t("policy.magicVar.example")}</NoWrapTableCell>
</TableRow>
</TableHead>
<TableBody>
{vars.map((v, i) => (
<TableRow key={i} sx={{ "&:last-child td, &:last-child th": { border: 0 } }} hover>
<TableCell>
<code>{v.name}</code>
</TableCell>
<TableCell>{t(v.value)}</TableCell>
<TableCell sx={{ wordBreak: "break-all" }}>{v.example}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
</DraggableDialog>
);
};
export default MagicVarDialog;

View File

@@ -0,0 +1,89 @@
import { Alert, Box, OutlinedSelectProps, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getNodeList } from "../../../api/api";
import { Node, NodeStatus, NodeType } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface NodeSelectionInputProps extends OutlinedSelectProps {
value: number;
onChange: (value: number) => void;
}
export const NodeStatusCondition = "node_status";
const NodeSelectionInput = ({ value, onChange, ...rest }: NodeSelectionInputProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [loading, setLoading] = useState(true);
const [nodes, setNodes] = useState<Node[]>([]);
useEffect(() => {
setLoading(true);
dispatch(
getNodeList({
page_size: 1000,
page: 1,
order_by: "id",
order_direction: "desc",
conditions: {
[NodeStatusCondition]: NodeStatus.active,
},
}),
)
.then((res) => {
const filteredNodes = res.nodes.filter((n) => n.type != NodeType.master);
setNodes(filteredNodes);
if (!value && filteredNodes.length > 0) {
onChange(filteredNodes[0].id);
}
})
.finally(() => {
setLoading(false);
});
}, []);
if (!loading && nodes.length == 0) {
return <Alert severity="warning">{t("settings.noNodes")}</Alert>;
}
return (
<DenseSelect
disabled={loading}
value={value}
onChange={(e) => onChange(e.target.value as number)}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
{...rest}
>
{nodes.map((g) => (
<SquareMenuItem value={g.id}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{g.name}
</Typography>
<Typography variant={"caption"} color={"textSecondary"}>
{g.server}
</Typography>
</Box>
</SquareMenuItem>
))}
</DenseSelect>
);
};
export default NodeSelectionInput;

View File

@@ -0,0 +1,141 @@
import {
Alert,
AlertTitle,
Button,
DialogContent,
List,
ListItem,
ListItemIcon,
ListItemText,
Typography,
styled,
} from "@mui/material";
import dayjs from "dayjs";
import { useCallback, useMemo } from "react";
import { Trans, useTranslation } from "react-i18next";
import DraggableDialog, { StyledDialogActions } from "../../Dialogs/DraggableDialog";
import CheckmarkCircleFilled from "../../Icons/CheckmarkCircleFilled";
import Gift from "../../Icons/Gift";
import { Code } from "../../Common/Code.tsx";
export interface ProDialogProps {
open: boolean;
onClose: () => void;
}
const features = [
"shareLinkCollabration",
"filePermission",
"multipleStoragePolicy",
"auditAndActivity",
"vasService",
"sso",
"more",
];
const StyledButton = styled(Button)(({ theme }) => ({
background: `linear-gradient(45deg, ${theme.palette.primary.main} 30%, ${theme.palette.primary.light} 90%)`,
color: theme.palette.primary.contrastText,
"&:hover": {
background: `linear-gradient(45deg, ${theme.palette.primary.dark} 30%, ${theme.palette.primary.main} 90%)`,
},
transition: "all 300ms cubic-bezier(0.4, 0, 0.2, 1) !important",
}));
// TODO: fetch from cloudreve.org
const currentPromotion = {
code: "FI5Q9668YV",
discount: 15,
start: "2025-08-12T00:00:00Z",
end: "2025-10-12T23:59:59Z",
};
const ProDialog = ({ open, onClose }: ProDialogProps) => {
const { t } = useTranslation("dashboard");
const openMore = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
window.open("https://cloudreve.org/pro", "_blank");
}, []);
const showPromotion = useMemo(() => {
const now = dayjs();
return now >= dayjs(currentPromotion.start) && now <= dayjs(currentPromotion.end);
}, []);
return (
<DraggableDialog
title={t("pro.title")}
dialogProps={{
open,
onClose,
maxWidth: "sm",
fullWidth: true,
}}
>
<DialogContent>
<Typography variant="body1" color="text.secondary">
{t("pro.description")}
</Typography>
<Typography variant="body1" fontWeight={600} sx={{ mt: 2 }}>
{t("pro.proInclude")}
</Typography>
<List dense>
{features.map((feature) => (
<ListItem key={feature}>
<ListItemIcon
sx={{
minWidth: "36px",
}}
>
<CheckmarkCircleFilled color="primary" />
</ListItemIcon>
<ListItemText
slotProps={{
primary: {
sx: {},
variant: "body1",
},
}}
>
{t(`pro.${feature}`)}
</ListItemText>
</ListItem>
))}
</List>
{showPromotion && (
<Alert
iconMapping={{
info: <Gift fontSize="inherit" />,
}}
severity="info"
sx={{ mt: 2 }}
>
<AlertTitle>{t("pro.promotionTitle")}</AlertTitle>
<Trans
i18nKey="dashboard:pro.promotion"
values={{
code: currentPromotion.code,
discount: currentPromotion.discount,
}}
components={[<Code />, <Typography component={"span"} fontWeight={600} />]}
/>
</Alert>
)}
</DialogContent>
<StyledDialogActions
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" color="primary" onClick={onClose}>
{t("pro.later")}
</Button>
<StyledButton onClick={openMore} variant="contained" color="primary">
{t("pro.learnMore")}
</StyledButton>
</StyledDialogActions>
</DraggableDialog>
);
};
export default ProDialog;

View File

@@ -0,0 +1,45 @@
import { Box, debounce, useTheme } from "@mui/material";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getShareList } from "../../../api/api.ts";
import { Share } from "../../../api/dashboard.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { DenseAutocomplete, DenseFilledTextField, NoWrapBox, SquareChip } from "../../Common/StyledComponents.tsx";
import FileTypeIcon from "../../FileManager/Explorer/FileTypeIcon.tsx";
import LinkDismiss from "../../Icons/LinkDismiss.tsx";
export interface SharesInputProps {}
const SharesInput = (props: SharesInputProps) => {
const theme = useTheme();
const { t } = useTranslation();
const [options, setOptions] = useState<number[]>([]);
return (
<DenseAutocomplete
multiple
options={options}
blurOnSelect
renderInput={(params) => (
<DenseFilledTextField
{...params}
sx={{
"& .MuiInputBase-root": {},
"& .MuiInputBase-root.MuiOutlinedInput-root": {
paddingTop: theme.spacing(0.6),
paddingBottom: theme.spacing(0.6),
},
mt: 0,
}}
variant="outlined"
margin="dense"
placeholder={t("dashboard:settings.searchShare")}
type="text"
fullWidth
/>
)}
/>
);
};
export default SharesInput;

View File

@@ -0,0 +1,122 @@
import { Box, FormControl, ListItemText, SelectChangeEvent, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { getStoragePolicyList } from "../../../api/api";
import { StoragePolicy } from "../../../api/dashboard";
import { useAppDispatch } from "../../../redux/hooks";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface SinglePolicySelectionInputProps {
value: number | undefined;
onChange: (value: number) => void;
emptyValue?: number;
emptyText?: string;
simplified?: boolean;
}
const SinglePolicySelectionInput = ({
value,
onChange,
emptyValue,
emptyText,
simplified,
}: SinglePolicySelectionInputProps) => {
const { t } = useTranslation("dashboard");
const dispatch = useAppDispatch();
const [policies, setPolicies] = useState<StoragePolicy[]>([]);
const [loading, setLoading] = useState(false);
const [policyMap, setPolicyMap] = useState<Record<number, StoragePolicy>>({});
const handleChange = (event: SelectChangeEvent<unknown>) => {
const {
target: { value },
} = event;
onChange(value as number);
};
useEffect(() => {
setLoading(true);
dispatch(getStoragePolicyList({ page: 1, page_size: 1000, order_by: "id", order_direction: "asc" }))
.then((res) => {
setPolicies(res.policies);
setPolicyMap(
res.policies.reduce(
(acc, policy) => {
acc[policy.id] = policy;
return acc;
},
{} as Record<number, StoragePolicy>,
),
);
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<FormControl fullWidth>
<DenseSelect
value={value}
required
onChange={handleChange}
sx={{
minHeight: 39,
}}
renderValue={
simplified
? (value) => (
<ListItemText
primary={policyMap[value as number]?.name}
slotProps={{ primary: { variant: "body2" } }}
/>
)
: undefined
}
disabled={loading}
MenuProps={{
PaperProps: { sx: { maxWidth: 230 } },
MenuListProps: {
sx: {
"& .MuiMenuItem-root": {
whiteSpace: "normal",
},
},
},
}}
>
{policies.length > 0 &&
policies.map((policy) => (
<SquareMenuItem key={policy.id} value={policy.id}>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography variant={"body2"} fontWeight={600}>
{policy.name}
</Typography>
<Typography variant={"caption"} color={"textSecondary"}>
{t(`policy.${policy.type}`)}
</Typography>
</Box>
</SquareMenuItem>
))}
{emptyValue !== undefined && emptyText && (
<SquareMenuItem value={emptyValue}>
<ListItemText
primary={<em>{t(emptyText)}</em>}
slotProps={{
primary: { variant: "body2" },
}}
/>
</SquareMenuItem>
)}
</DenseSelect>
</FormControl>
);
};
export default SinglePolicySelectionInput;

View File

@@ -0,0 +1,86 @@
import {
Box,
ListItemText,
Pagination,
PaginationProps,
SelectChangeEvent,
useMediaQuery,
useTheme,
} from "@mui/material";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { DenseSelect } from "../../Common/StyledComponents";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu";
export interface TablePaginationProps extends PaginationProps {
rowsPerPageOptions?: number[];
rowsPerPage?: number;
onRowsPerPageChange?: (pageSize: number) => void;
page: number;
totalItems: number;
onChange: (event: React.ChangeEvent<unknown>, value: number) => void;
}
export const TablePagination = ({
rowsPerPageOptions = [5, 10, 25, 50],
rowsPerPage = 5,
onRowsPerPageChange,
page,
onChange,
totalItems,
...props
}: TablePaginationProps) => {
const { t } = useTranslation("dashboard");
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const onDenseSelectChange = (e: SelectChangeEvent<unknown>) => {
onRowsPerPageChange?.(e.target.value as number);
};
useEffect(() => {
if ((page - 1) * rowsPerPage >= totalItems) {
onChange({} as React.ChangeEvent<unknown>, Math.ceil(totalItems / rowsPerPage));
}
}, [rowsPerPage, totalItems]);
return (
<Box
sx={{
py: 1,
display: "flex",
flexDirection: isMobile ? "column" : "row",
justifyContent: "space-between",
alignItems: isMobile ? "start" : "center",
gap: 1,
}}
>
<Pagination
size={isMobile ? "small" : "medium"}
count={Math.ceil(totalItems / rowsPerPage)}
page={page}
onChange={onChange}
{...props}
/>
<DenseSelect
variant="filled"
value={rowsPerPage}
onChange={onDenseSelectChange}
renderValue={(value) => (
<ListItemText primary={t("settings.perPage", { num: value })} slotProps={{ primary: { variant: "body2" } }} />
)}
>
{rowsPerPageOptions.map((option) => (
<SquareMenuItem key={option} value={option}>
<ListItemText
primary={t("settings.perPage", { num: option })}
slotProps={{ primary: { variant: "body2" } }}
/>
</SquareMenuItem>
))}
</DenseSelect>
</Box>
);
};
export default TablePagination;