feat(profile): options to select why kind of share links to show in user's profile (#2453)

This commit is contained in:
Aaron Liu 2025-08-12 09:52:45 +08:00
parent 8b2c8a7bdb
commit eb2cfac37d
11 changed files with 295 additions and 49 deletions

View File

@ -486,7 +486,7 @@
"shareLinkPasswordInfo": "Password: {{password}}",
"createShareLink": "Create share link",
"privateShare": "Protect with password",
"privateShareDes": "If selected, password is required to access the share link, others cannot see this share link on your homepage.",
"privateShareDes": "If selected, password is required to access the share link.",
"useCustomPassword": "Custom share link password",
"shareView": "Share view setting",
"shareViewDes": "If selected, other users can see your view setting (layout, sorting, etc.) saved on the server server when accessing this shared folder.",
@ -787,6 +787,13 @@
"regTime": "Sign up date",
"security": "Password and security",
"profilePage": "Public profile",
"publicShareOnly": "Public share only",
"publicShareOnlyDes": "Only show shares without password on the profile page.",
"allShare": "All shares",
"allShareDes": "Show all shares on the profile page (including password-protected shares). Users still need to enter a password to access them.",
"hideShare": "Hide all shares",
"hideShareDes": "Hide all shares on the profile page.",
"userHideShare": "User has hidden the share list",
"accountPassword": "Password",
"2fa": "2FA authentication",
"enabled": "Enabled",

View File

@ -486,7 +486,7 @@
"shareLinkPasswordInfo": "パスワード:{{password}}",
"createShareLink": "共有リンク作成",
"privateShare": "パスワードで保護",
"privateShareDes": "チェックを入れると、パスワードが必要です。他の人はあなたのプロフィールページでこの共有リンクを見ることができません。",
"privateShareDes": "チェックを入れると、パスワードが必要です。",
"useCustomPassword": "カスタムパスワードを使用",
"expireAfterDownload": "ダウンロード後に自動的に期限切れ",
"sharePassword": "共有パスワード",
@ -791,6 +791,13 @@
"regTime": "登録日時",
"security": "パスワードとセキュリティ",
"profilePage": "マイページ",
"publicShareOnly": "パスワードなし共有のみ表示",
"publicShareOnlyDes": "マイページにパスワードなし共有のみ表示します。",
"allShare": "すべての共有",
"allShareDes": "マイページにすべての共有を表示します。パスワードが設定された共有には、パスワードを入力してアクセスする必要があります。",
"hideShare": "すべての共有を非表示",
"hideShareDes": "マイページにすべての共有を非表示にします。",
"userHideShare": "ユーザーが共有リストを非表示にしました",
"accountPassword": "ログインパスワード",
"2fa": "2段階認証",
"enabled": "有効",

View File

@ -486,7 +486,7 @@
"shareLinkPasswordInfo": " 密码: {{password}}",
"createShareLink": "创建分享链接",
"privateShare": "使用密码保护链接",
"privateShareDes": "勾选后,需要使用密码访问分享链接,其他人无法在你的个人主页看到此分享链接。",
"privateShareDes": "勾选后,需要使用密码访问分享链接。",
"useCustomPassword": "自定义分享密码",
"expireAfterDownload": "下载后自动过期",
"sharePassword": "分享密码",
@ -791,6 +791,13 @@
"regTime": "注册时间",
"security": "密码和安全",
"profilePage": "个人主页",
"publicShareOnly": "仅展示无密码分享链接",
"publicShareOnlyDes": "仅在个人主页展示没有设置密码的分享链接。",
"allShare": "所有分享",
"allShareDes": "在个人主页展示所有分享链接(包括有密码的分享)。对于有密码的分享,用户还需要输入密码才能访问。",
"hideShare": "隐藏所有分享链接",
"hideShareDes": "在个人主页隐藏所有分享链接。",
"userHideShare": "用户隐藏了分享链接列表",
"accountPassword": "登录密码",
"2fa": "二步验证",
"enabled": "已开启",

View File

@ -482,7 +482,7 @@
"shareLinkPasswordInfo": " 密碼: {{password}}",
"createShareLink": "建立分享連結",
"privateShare": "使用密碼保護連結",
"privateShareDes": "勾選後,需要使用密碼訪問分享連結,其他人無法在你的個人主頁看到此分享連結。",
"privateShareDes": "勾選後,需要使用密碼訪問分享連結。",
"useCustomPassword": "使用自定義密碼",
"expireAfterDownload": "下載後自動過期",
"sharePassword": "分享密碼",
@ -787,6 +787,13 @@
"regTime": "注冊時間",
"security": "密碼和安全",
"profilePage": "個人主頁",
"publicShareOnly": "僅展示無密碼分享連結",
"publicShareOnlyDes": "僅在個人主頁展示沒有設置密碼的分享連結。",
"allShare": "所有分享",
"allShareDes": "在個人主頁展示所有分享連結(包括有密碼的分享)。對於有密碼的分享,用戶還需要輸入密碼才能訪問。",
"hideShare": "隱藏所有分享連結",
"hideShareDes": "在個人主頁隱藏所有分享連結。",
"userHideShare": "用戶隱藏了分享連結列表",
"accountPassword": "登入密碼",
"2fa": "二步驗證",
"enabled": "已開啟",

View File

@ -83,6 +83,7 @@ export interface Share {
downloaded: number;
expired?: boolean;
unlocked: boolean;
password_protected: boolean;
source_type?: number;
owner: User;
source_uri?: string;

View File

@ -23,6 +23,7 @@ export interface User {
pined?: PinedFile[];
language?: string;
disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel;
}
export interface Group {
id: string;
@ -102,6 +103,7 @@ export interface UserSettings {
two_fa_enabled: boolean;
passkeys?: Passkey[];
disable_view_sync: boolean;
share_links_in_profile: ShareLinksInProfileLevel;
}
export interface PatchUserSetting {
@ -116,6 +118,7 @@ export interface PatchUserSetting {
two_fa_enabled?: boolean;
two_fa_code?: string;
disable_view_sync?: boolean;
share_links_in_profile?: ShareLinksInProfileLevel;
}
export interface PasskeyCredentialOption {
@ -191,3 +194,9 @@ export interface ResetPasswordService {
password: string;
secret: string;
}
export enum ShareLinksInProfileLevel {
public_share_only = "",
all_share = "all_share",
hide_share = "hide_share",
}

View File

@ -0,0 +1,9 @@
import { SvgIcon, SvgIconProps } from "@mui/material";
export default function EyeOff(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M2.22 2.22a.75.75 0 0 0-.073.976l.073.084l4.034 4.035a9.986 9.986 0 0 0-3.955 5.75a.75.75 0 0 0 1.455.364a8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084l-6.113-6.114l.001-.002l-1.2-1.198l-2.87-2.87h.002l-2.88-2.877l.001-.002l-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0m7.984 9.045l3.535 3.536a2.5 2.5 0 0 1-3.535-3.535M12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272a.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5m.195 3.51l3.801 3.8a4.003 4.003 0 0 0-3.801-3.8" />
</SvgIcon>
);
}

View File

@ -12,7 +12,7 @@ import { DenseSelect } from "../../Common/StyledComponents.tsx";
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import ShareCard from "../Shares/ShareCard.tsx";
import { useParams } from "react-router-dom";
import { User } from "../../../api/user.ts";
import { ShareLinksInProfileLevel, User } from "../../../api/user.ts";
import { loadUserInfo } from "../../../redux/thunks/session.ts";
import { UserProfile } from "../../Common/User/UserPopover.tsx";
import PageContainer from "../PageContainer.tsx";
@ -110,31 +110,36 @@ const Profile = () => {
<PageHeader
secondaryAction={
<FormControl variant="outlined">
<DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}>
<SquareMenuItem value={"desc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtDesc")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"asc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtAsc")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</FormControl>
user &&
user.share_links_in_profile !== ShareLinksInProfileLevel.hide_share && (
<FormControl variant="outlined">
<DenseSelect variant="outlined" value={orderDirection} onChange={onSelectChange}>
<SquareMenuItem value={"desc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtDesc")}
</ListItemText>
</SquareMenuItem>
<SquareMenuItem value={"asc"}>
<ListItemText
slotProps={{
primary: { variant: "body2" },
}}
>
{t("application:share.createdAtAsc")}
</ListItemText>
</SquareMenuItem>
</DenseSelect>
</FormControl>
)
}
skipChangingDocumentTitle
onRefresh={() => refresh()}
onRefresh={
user && user.share_links_in_profile !== ShareLinksInProfileLevel.hide_share ? () => refresh() : undefined
}
loading={loading}
title={t("application:share.somebodyShare", {
name: user?.nickname ?? "-",
@ -142,19 +147,28 @@ const Profile = () => {
/>
<Grid container spacing={1}>
{shares.map((share) => (
<ShareCard share={share} onShareDeleted={onShareDeleted} />
))}
{nextPageToken != undefined && (
{user &&
user.share_links_in_profile !== ShareLinksInProfileLevel.hide_share &&
shares.map((share) => <ShareCard share={share} onShareDeleted={onShareDeleted} />)}
{nextPageToken != undefined &&
user &&
user?.share_links_in_profile !== ShareLinksInProfileLevel.hide_share && (
<>
{[...Array(4)].map((_, i) => (
<ShareCard
onShareDeleted={onShareDeleted}
onLoad={i == 0 ? loadNextPage(shares, nextPageToken) : undefined}
loading={true}
key={i == 0 ? nextPageToken : i}
/>
))}
</>
)}
{user && user.share_links_in_profile === ShareLinksInProfileLevel.hide_share && (
<>
{[...Array(4)].map((_, i) => (
<ShareCard
onShareDeleted={onShareDeleted}
onLoad={i == 0 ? loadNextPage(shares, nextPageToken) : undefined}
loading={true}
key={i == 0 ? nextPageToken : i}
/>
))}
<Box sx={{ p: 1, width: "100%", textAlign: "center" }}>
<Nothing size={0.8} top={63} primary={t("setting.userHideShare")} />
</Box>
</>
)}
</Grid>

View File

@ -1,14 +1,17 @@
import { LoadingButton } from "@mui/lab";
import { Collapse, Grid2, Stack, Typography, useMediaQuery, useTheme } from "@mui/material";
import { Collapse, Grid2, Stack, Typography, useMediaQuery, useTheme, styled } from "@mui/material";
import { bindPopover, bindTrigger, usePopupState } from "material-ui-popup-state/hooks";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { sendUpdateUserSetting } from "../../../api/api.ts";
import { UserSettings } from "../../../api/user.ts";
import { UserSettings, ShareLinksInProfileLevel } from "../../../api/user.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import SessionManager from "../../../session";
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import { DefaultButton, DenseFilledTextField } from "../../Common/StyledComponents.tsx";
import TimeBadge from "../../Common/TimeBadge.tsx";
import CaretDown from "../../Icons/CaretDown.tsx";
import AvatarSetting from "./AvatarSetting.tsx";
import ProfileSettingPopover, { useProfileSettingSummary } from "./ProfileSettingPopover.tsx";
import SettingForm from "./SettingForm.tsx";
export interface ProfileSettingProps {
@ -16,6 +19,18 @@ export interface ProfileSettingProps {
setSetting: (setting: UserSettings) => void;
}
const ProfileDropButton = styled(DefaultButton)(({ theme }) => ({
color: theme.palette.text.secondary,
minWidth: 0,
minHeight: 0,
fontSize: theme.typography.body2.fontSize,
fontWeight: theme.typography.body2.fontWeight,
borderRadius: "4px",
padding: "0px 4px",
position: "relative",
left: "-4px",
}));
const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
const { t } = useTranslation();
const theme = useTheme();
@ -26,6 +41,14 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
const user = SessionManager.currentLoginOrNull();
const [nick, setNick] = useState(user?.user.nickname);
const [nickLoading, setNickLoading] = useState(false);
const [profileSettingLoading, setProfileSettingLoading] = useState(false);
const profileSettingPopup = usePopupState({
variant: "popover",
popupId: "profileSetting",
});
const profileSettingSummary = useProfileSettingSummary(setting.share_links_in_profile);
const onClick = () => {
// Validate input length
@ -49,6 +72,21 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
}
};
const onProfileSettingChange = (value: ShareLinksInProfileLevel) => {
setProfileSettingLoading(true);
dispatch(sendUpdateUserSetting({ share_links_in_profile: value }))
.then(() => {
setSetting({
...setting,
share_links_in_profile: value,
});
profileSettingPopup.close();
})
.finally(() => {
setProfileSettingLoading(false);
});
};
return (
<Stack spacing={3}>
<Grid2
@ -94,11 +132,29 @@ const ProfileSetting = ({ setting, setSetting }: ProfileSettingProps) => {
</SettingForm>
</Grid2>
<SettingForm title={t("setting.group")} noContainer lgWidth={12}>
<Typography variant={"body2"} color={"textSecondary"}>
{user?.user.group?.name}
</Typography>
</SettingForm>
<Grid2 spacing={isMobile ? 3 : 4} container sx={{ width: "100%" }}>
<SettingForm title={t("setting.group")} noContainer lgWidth={6}>
<Typography variant={"body2"} color={"textSecondary"}>
{user?.user.group?.name}
</Typography>
</SettingForm>
<SettingForm title={t("setting.profilePage")} noContainer lgWidth={6}>
<ProfileDropButton
size={"small"}
{...bindTrigger(profileSettingPopup)}
endIcon={<CaretDown sx={{ fontSize: "12px!important" }} />}
disabled={profileSettingLoading}
>
{profileSettingSummary}
</ProfileDropButton>
<ProfileSettingPopover
currentValue={setting.share_links_in_profile}
onValueChange={onProfileSettingChange}
{...bindPopover(profileSettingPopup)}
/>
</SettingForm>
</Grid2>
</Stack>
</Grid2>
</Grid2>

View File

@ -0,0 +1,127 @@
import {
Divider,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Popover,
PopoverProps,
SvgIconProps,
} from "@mui/material";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ShareLinksInProfileLevel } from "../../../api/user.ts";
import Eye from "../../Icons/Eye.tsx";
import EyeOff from "../../Icons/EyeOff.tsx";
import Globe from "../../Icons/Globe.tsx";
export interface ProfileSettingPopoverProps extends PopoverProps {
currentValue: ShareLinksInProfileLevel;
onValueChange?: (value: ShareLinksInProfileLevel) => void;
readOnly?: boolean;
}
const profileSettingOptions: {
value: ShareLinksInProfileLevel;
label: string;
description: string;
icon?: ((props: SvgIconProps) => JSX.Element) | typeof Eye;
}[] = [
{
value: ShareLinksInProfileLevel.public_share_only,
label: "application:setting.publicShareOnly",
description: "application:setting.publicShareOnlyDes",
icon: Eye,
},
{
value: ShareLinksInProfileLevel.all_share,
label: "application:setting.allShare",
description: "application:setting.allShareDes",
icon: Globe,
},
{
value: ShareLinksInProfileLevel.hide_share,
label: "application:setting.hideShare",
description: "application:setting.hideShareDes",
icon: EyeOff,
},
];
export const useProfileSettingSummary = (value: ShareLinksInProfileLevel) => {
const { t } = useTranslation();
const summary = useMemo(() => {
const option = profileSettingOptions.find((opt) => opt.value === (value ?? ""));
return option ? t(option.label) : t("application:setting.publicShareOnly");
}, [value, t]);
return summary;
};
const ProfileSettingPopover = ({ currentValue, onValueChange, readOnly, ...rest }: ProfileSettingPopoverProps) => {
const { t } = useTranslation();
const handleChange = (value: ShareLinksInProfileLevel) => () => {
if (onValueChange && !readOnly) {
onValueChange(value);
}
};
return (
<Popover
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
{...rest}
>
<List dense sx={{ maxWidth: 360 }} disablePadding>
{profileSettingOptions.map((option, index) => (
<>
<ListItem disablePadding key={option.value}>
<ListItemButton
selected={currentValue === option.value}
disabled={readOnly}
onClick={handleChange(option.value)}
sx={{ borderRadius: "0px" }}
role={undefined}
dense
>
{option.icon && (
<ListItemIcon sx={{ minWidth: "32px", margin: "0px 8px" }}>
<option.icon
sx={{
color: (theme) => (currentValue === option.value ? theme.palette.primary.main : "inherit"),
}}
/>
</ListItemIcon>
)}
<ListItemText
primary={t(option.label)}
secondary={t(option.description)}
slotProps={{
secondary: {
variant: "body2",
sx: {
wordWrap: "break-word",
},
},
}}
/>
</ListItemButton>
</ListItem>
{index < profileSettingOptions.length - 1 && <Divider />}
</>
))}
<Divider />
</List>
</Popover>
);
};
export default ProfileSettingPopover;

View File

@ -32,6 +32,7 @@ import Clipboard from "../../Icons/Clipboard.tsx";
import DeleteOutlined from "../../Icons/DeleteOutlined.tsx";
import Eye from "../../Icons/Eye.tsx";
import LinkEdit from "../../Icons/LinkEdit.tsx";
import LockClosedOutlined from "../../Icons/LockClosedOutlined.tsx";
import Open from "../../Icons/Open.tsx";
import { SummaryButton } from "../Tasks/TaskCard.tsx";
@ -209,7 +210,8 @@ const ShareCard = ({ share, onShareDeleted, onLoad, loading }: ShareCardProps) =
alignItems: "center",
}}
>
<NoWrapBox>
<NoWrapBox sx={{ display: "flex", gap: 0.5, alignItems: "center" }}>
{share && share?.password_protected && <LockClosedOutlined sx={{ fontSize: "16px" }} />}
<Tooltip title={share?.name ?? ""}>
<Typography variant={"body2"} fontWeight={500} noWrap>
{loading ? <Skeleton variant={"text"} width={200} /> : share?.name}