feat(ui): custom HTML content in predefined locations

This commit is contained in:
Aaron Liu 2025-07-15 10:42:07 +08:00
parent 686edceda6
commit aa80ca5bb2
14 changed files with 443 additions and 15 deletions

View File

@ -112,6 +112,14 @@
"retryDelayDes": "Initial delay time (seconds) for task retries."
},
"settings": {
"headlessFooter": "Landing page footer",
"headlessFooterDes": "Custom HTML content displayed at the bottom of the login, sign up and callback result pages.",
"headlessBottom": "Landing page bottom",
"headlessBottomDes": "Custom HTML content displayed at the bottom of the login, sign up and callback result pages.",
"customHTML": "Custom HTML",
"customHTMLDes": "Insert custom HTML content at the preset position of the site.",
"sidebarBottom": "Sidebar bottom",
"sidebarBottomDes": "Custom HTML content displayed at the bottom of the sidebar.",
"addNavItem": "Add navigation item",
"customNavItems": "Custom sidebar items",
"customNavItemsDes": "You can add custom items to the sidebar, and users will be redirected to the corresponding link when clicked.",

View File

@ -112,6 +112,14 @@
"retryDelayDes": "タスク再試行の初期遅延時間(秒)"
},
"settings": {
"headlessFooter": "ログインセッションページの下部",
"headlessFooterDes": "ユーザーがログイン、登録、コールバック結果などのページの下部に表示するカスタム HTML コンテンツ。",
"headlessBottom": "ログインセッションページの主体下部",
"headlessBottomDes": "ユーザーがログイン、登録、コールバック結果などのページの主体ボックスの下部に表示するカスタム HTML コンテンツ。",
"customHTML": "カスタム HTML",
"customHTMLDes": "サイトの既定の位置にカスタム HTML コンテンツを挿入します。",
"sidebarBottom": "サイドバーの下部",
"sidebarBottomDes": "サイドバーの下部に表示するカスタム HTML コンテンツ。",
"addNavItem": "ナビゲーション項目を追加",
"customNavItems": "サイドナビゲーションバーのカスタマイズ",
"customNavItemsDes": "左側のナビゲーションバーにカスタム項目を追加できます。ユーザーがクリックすると、対応するリンクに移動します。",

View File

@ -112,6 +112,14 @@
"retryDelayDes": "任务重试的初始延迟时间(秒)。"
},
"settings": {
"headlessFooter": "登录会话页面底部",
"headlessFooterDes": "用户登录、注册、回调结果等页面底部展示的自定义 HTML 内容。",
"headlessBottom": "登录会话页面主体底部",
"headlessBottomDes": "用户登录、注册、回调结果等页面主体框底部展示的自定义 HTML 内容。",
"customHTML": "自定义 HTML",
"customHTMLDes": "在站点的预设位置插入展示自定义的 HTML 内容。",
"sidebarBottom": "侧边栏底部",
"sidebarBottomDes": "在侧边栏底部展示的自定义 HTML 内容。",
"addNavItem": "添加导航条目",
"customNavItems": "自定义侧边导航栏",
"customNavItemsDes": "你可以在左侧导航栏中添加自定义的条目,用户点击后会跳转到对应的链接。",

View File

@ -112,6 +112,14 @@
"retryDelayDes": "任務重試的初始延遲時間(秒)。"
},
"settings": {
"headlessFooter": "登入會話頁面底部",
"headlessFooterDes": "使用者登入、註冊、回調結果等頁面底部展示的自訂 HTML 內容。",
"headlessBottom": "登入會話頁面主體底部",
"headlessBottomDes": "使用者登入、註冊、回調結果等頁面主體框底部展示的自訂 HTML 內容。",
"customHTML": "自訂 HTML",
"customHTMLDes": "在站點的預設位置插入展示自訂的 HTML 內容。",
"sidebarBottom": "側邊欄底部",
"sidebarBottomDes": "在側邊欄底部展示的自訂 HTML 內容。",
"addNavItem": "新增導航條目",
"customNavItems": "自定義側邊導航欄",
"customNavItemsDes": "你可以在側邊導航欄中新增自定義的條目,用戶點擊後會跳轉到對應的鏈接。",

View File

@ -43,6 +43,7 @@ export interface SiteConfig {
thumbnail_height?: number;
custom_props?: CustomProps[];
custom_nav_items?: CustomNavItem[];
custom_html?: CustomHTML;
}
export interface CaptchaResponse {
@ -55,3 +56,9 @@ export interface CustomNavItem {
url: string;
icon: string;
}
export interface CustomHTML {
headless_footer?: string;
headless_bottom?: string;
sidebar_bottom?: string;
}

View File

@ -3,6 +3,7 @@ import { useContext } from "react";
import { useTranslation } from "react-i18next";
import { SettingSection } from "../Settings";
import { SettingContext } from "../SettingWrapper";
import CustomHTML from "./CustomHTML";
import CustomNavItems from "./CustomNavItems";
import ThemeOptions from "./ThemeOptions";
@ -27,6 +28,9 @@ const Appearance = () => {
onChange={(value: string) => setSettings({ custom_nav_items: value })}
/>
</SettingSection>
<SettingSection>
<CustomHTML />
</SettingSection>
</Stack>
</Box>
);

View File

@ -0,0 +1,249 @@
import { LoadingButton } from "@mui/lab";
import {
Box,
CircularProgress,
Container,
FormControl,
Grid,
Grid2,
Paper,
Stack,
Typography,
useTheme,
} from "@mui/material";
import { Suspense, useContext } from "react";
import { useTranslation } from "react-i18next";
import { OutlineIconTextField } from "../../../Common/Form/OutlineIconTextField";
import Logo from "../../../Common/Logo";
import DrawerHeader from "../../../Frame/NavBar/DrawerHeader";
import { SideNavItemComponent } from "../../../Frame/NavBar/PageNavigation";
import StorageSummary from "../../../Frame/NavBar/StorageSummary";
import PoweredBy from "../../../Frame/PoweredBy";
import CloudDownload from "../../../Icons/CloudDownload";
import CloudDownloadOutlined from "../../../Icons/CloudDownloadOutlined";
import CubeSync from "../../../Icons/CubeSync";
import CubeSyncFilled from "../../../Icons/CubeSyncFilled";
import MailOutlined from "../../../Icons/MailOutlined";
import PhoneLaptop from "../../../Icons/PhoneLaptop";
import PhoneLaptopOutlined from "../../../Icons/PhoneLaptopOutlined";
import SettingForm from "../../../Pages/Setting/SettingForm";
import MonacoEditor from "../../../Viewers/CodeViewer/MonacoEditor";
import { SettingContext } from "../SettingWrapper";
import { NoMarginHelperText } from "../Settings";
export interface CustomHTMLProps {}
const HeadlessFooterPreview = ({ footer, bottom }: { footer?: string; bottom?: string }) => {
const { t } = useTranslation("application");
return (
<Box
sx={{
borderRadius: 1,
backgroundColor: (theme) =>
theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900],
}}
>
<Container maxWidth={"xs"}>
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
sx={{ minHeight: "100px" }}
>
<Box sx={{ width: "100%", py: 2 }}>
<Paper
sx={{
padding: (theme) => `${theme.spacing(2)} ${theme.spacing(3)} ${theme.spacing(3)}`,
}}
>
<Logo
sx={{
maxWidth: "40%",
maxHeight: "40px",
mb: 2,
}}
/>
<div>
<Box
sx={{
display: "block",
}}
>
<Typography variant={"h6"}>{t("login.siginToYourAccount")}</Typography>
<FormControl variant="standard" margin="normal" fullWidth>
<OutlineIconTextField label={t("login.email")} variant={"outlined"} icon={<MailOutlined />} />
</FormControl>
<LoadingButton sx={{ mt: 2 }} fullWidth variant="contained" color="primary">
<span>{t("login.continue")}</span>
</LoadingButton>
{bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: bottom }} />
</Box>
)}
</Box>
</div>
</Paper>
</Box>
<PoweredBy />
{footer && (
<Box sx={{ mb: 2, width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: footer }} />
</Box>
)}
</Grid>
</Container>
</Box>
);
};
const SidebarBottomPreview = ({ bottom }: { bottom?: string }) => {
return (
<Box
sx={{
maxWidth: "300px",
borderRadius: 1,
backgroundColor: (theme) =>
theme.palette.mode === "light" ? theme.palette.grey[100] : theme.palette.grey[900],
}}
>
<DrawerHeader disabled />
<Stack
direction={"column"}
spacing={2}
sx={{
px: 1,
pb: 1,
flexGrow: 1,
mx: 1,
overflow: "auto",
}}
>
<Box sx={{ width: "100%" }}>
<SideNavItemComponent
item={{
label: "navbar.remoteDownload",
icon: [CloudDownload, CloudDownloadOutlined],
path: "#1",
}}
/>
<SideNavItemComponent
item={{
label: "navbar.connect",
icon: [PhoneLaptop, PhoneLaptopOutlined],
path: "#1",
}}
/>
<SideNavItemComponent
item={{
label: "navbar.taskQueue",
icon: [CubeSyncFilled, CubeSync],
path: "#1",
}}
/>
</Box>
<StorageSummary />
{bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: bottom ?? "" }} />
</Box>
)}
</Stack>
</Box>
);
};
const CustomHTML = ({}: CustomHTMLProps) => {
const { t } = useTranslation("dashboard");
const { formRef, setSettings, values } = useContext(SettingContext);
const theme = useTheme();
return (
<Box>
<Typography variant="h6" gutterBottom>
{t("settings.customHTML")}
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t("settings.customHTMLDes")}
</Typography>
<Stack spacing={3}>
<SettingForm
title={t("settings.headlessFooter")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<HeadlessFooterPreview footer={values.headless_footer_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.headless_footer_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ headless_footer_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.headlessFooterDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.headlessBottom")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<HeadlessFooterPreview bottom={values.headless_bottom_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.headless_bottom_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ headless_bottom_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.headlessBottomDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
<SettingForm
title={t("settings.sidebarBottom")}
lgWidth={5}
spacing={3}
secondary={
<Grid2 size={{ md: 7, xs: 12 }}>
<SidebarBottomPreview bottom={values.sidebar_bottom_html ?? ""} />
</Grid2>
}
>
<FormControl fullWidth>
<Suspense fallback={<CircularProgress />}>
<MonacoEditor
theme={theme.palette.mode === "dark" ? "vs-dark" : "vs"}
value={values.sidebar_bottom_html}
height={"300px"}
minHeight={"300px"}
language={"html"}
onChange={(e) => setSettings({ sidebar_bottom_html: e as string })}
/>
</Suspense>
<NoMarginHelperText>{t("settings.sidebarBottomDes")}</NoMarginHelperText>
</FormControl>
</SettingForm>
</Stack>
</Box>
);
};
export default CustomHTML;

View File

@ -300,7 +300,16 @@ const Settings = () => {
</SettingsWrapper>
)}
{tab === SettingsPageTab.Appearance && (
<SettingsWrapper settings={["theme_options", "defaultTheme", "custom_nav_items"]}>
<SettingsWrapper
settings={[
"theme_options",
"defaultTheme",
"custom_nav_items",
"headless_footer_html",
"headless_bottom_html",
"sidebar_bottom_html",
]}
>
<Appearance />
</SettingsWrapper>
)}

View File

@ -23,6 +23,9 @@ const Loading = () => {
const HeadlessFrame = () => {
const loading = useAppSelector((state) => state.globalState.loading.headlessFrame);
const { headless_footer, headless_bottom, sidebar_bottom } = useAppSelector(
(state) => state.siteConfig.basic?.config?.custom_html ?? {},
);
const dispatch = useAppDispatch();
let navigation = useNavigation();
@ -66,6 +69,11 @@ const HeadlessFrame = () => {
}}
>
<Outlet />
{headless_bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: headless_bottom }} />
</Box>
)}
</Box>
{(loading || navigation.state !== "idle") && <Loading />}
</div>
@ -73,6 +81,11 @@ const HeadlessFrame = () => {
</Paper>
</Box>
<PoweredBy />
{headless_footer && (
<Box sx={{ width: "100%", mb: 2 }}>
<div dangerouslySetInnerHTML={{ __html: headless_footer }} />
</Box>
)}
</Grid>
</Container>
</Box>

View File

@ -1,14 +1,15 @@
import { Box, Drawer, Popover, PopoverProps, Stack, useMediaQuery, useTheme } from "@mui/material";
import { useContext, useRef } from "react";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import DrawerHeader from "./DrawerHeader.tsx";
import SessionManager from "../../../session";
import TreeNavigation from "../../FileManager/TreeView/TreeNavigation.tsx";
import { PageVariant, PageVariantContext } from "../NavBarFrame.tsx";
import DrawerHeader from "./DrawerHeader.tsx";
import PageNavigation, { AdminPageNavigation } from "./PageNavigation.tsx";
import StorageSummary from "./StorageSummary.tsx";
import { useContext, useRef } from "react";
import SessionManager from "../../../session";
import { PageVariant, PageVariantContext } from "../NavBarFrame.tsx";
const DrawerContent = () => {
const { sidebar_bottom } = useAppSelector((state) => state.siteConfig.basic?.config?.custom_html ?? {});
const scrollRef = useRef<any>();
const user = SessionManager.currentLoginOrNull();
const theme = useTheme();
@ -38,6 +39,11 @@ const DrawerContent = () => {
</>
)}
{isDashboard && <AdminPageNavigation />}
{sidebar_bottom && (
<Box sx={{ width: "100%" }}>
<div dangerouslySetInnerHTML={{ __html: sidebar_bottom }} />
</Box>
)}
</Stack>
</>
);

View File

@ -1,8 +1,8 @@
import { ChevronLeft } from "@mui/icons-material";
import { Box, Fade, IconButton, styled, useMediaQuery, useTheme } from "@mui/material";
import { useState } from "react";
import { setDrawerOpen } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { ChevronLeft } from "@mui/icons-material";
import { useState } from "react";
import Logo from "../../Common/Logo.tsx";
export const DrawerHeaderContainer = styled("div")(({ theme }) => ({
@ -14,7 +14,7 @@ export const DrawerHeaderContainer = styled("div")(({ theme }) => ({
justifyContent: "flex-end",
}));
const DrawerHeader = () => {
const DrawerHeader = ({ disabled }: { disabled?: boolean }) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dispatch = useAppDispatch();
@ -22,7 +22,10 @@ const DrawerHeader = () => {
const [showCollapse, setShowCollapse] = useState(false);
return (
<DrawerHeaderContainer onMouseEnter={() => setShowCollapse(true)} onMouseLeave={() => setShowCollapse(false)}>
<DrawerHeaderContainer
onMouseEnter={() => setShowCollapse(disabled ? false : true)}
onMouseLeave={() => setShowCollapse(false)}
>
<Box sx={{ width: "100%", pl: 2 }}>
<Logo
sx={{

View File

@ -1,4 +1,6 @@
import { Box, BoxProps, Typography, useTheme } from "@mui/material";
import LogoIcon from "./assets/logo.svg";
import LogoIconDark from "./assets/logo_light.svg";
export interface PoweredByProps extends BoxProps {}
@ -44,12 +46,7 @@ const PoweredBy = ({ ...rest }: PoweredByProps) => {
sx={{
height: 20,
}}
src={
theme.palette.mode === "dark"
? "https://docs.cloudreve.org/logo_light.svg"
: "https://docs.cloudreve.org/logo.svg"
}
alt="Cloudreve"
src={theme.palette.mode === "dark" ? LogoIconDark : LogoIcon}
/>
</Box>
</Box>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 122 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 122 KiB