feat(explorer): save user's view setting to server / optionally share view setting via share link (#2232)

This commit is contained in:
Aaron Liu 2025-06-05 10:00:37 +08:00
parent d674a23b21
commit 9f91f8c98a
25 changed files with 500 additions and 279 deletions

View File

@ -26,7 +26,7 @@
"forgetPassword": "Forgot password?",
"2FA": "2FA Verification",
"input2FACode": "Please enter the six-digit 2FA verification code",
"passwordNotMatch": "Those passwords didnt match.",
"passwordNotMatch": "Those passwords didn't match.",
"findMyPassword": "Find my password",
"passwordReset": "Password has been reset.",
"newPassword": "New password",
@ -363,7 +363,11 @@
"layout": "Layout",
"thumbnails": "Thumbnails",
"on": "On",
"off": "Off"
"off": "Off",
"viewSetting": "View setting",
"saved": "Saved",
"notSet": "Not set",
"deleteViewSetting": "Delete view setting"
},
"modals": {
"showFileName": "Show file name",
@ -465,6 +469,8 @@
"createShareLink": "Create share link",
"privateShare": "Hide from public",
"privateShareDes": "If selected, other people cannot see this share link on your homepage.",
"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.",
"expireAfterDownload": "Expire after being downloaded",
"sharePassword": "Share password",
"randomlyGenerate": "Random",
@ -666,6 +672,10 @@
"createdAt": "Created at: "
},
"setting": {
"syncView": "View settings",
"syncViewDes": "Remember the view settings of each directory and synchronize to the server.",
"syncViewOn": "Sync to server",
"syncViewOff": "Do not sync",
"noAuthenticator": "Add a passkey to sign in using fingerprint, face or USB key.",
"neverUsed": "Never used",
"usedAt": "Last used at <0></0>",

View File

@ -621,7 +621,8 @@
"disable_2fa": "Disable 2FA",
"add_passkey": "Add passkey",
"remove_passkey": "Remove passkey",
"redeem_gift_code": "Redeem gift code"
"redeem_gift_code": "Redeem gift code",
"update_view": "Changed view setting"
},
"server": "Server",
"tempPath": "Temporary path",

View File

@ -183,6 +183,7 @@
"save": "保存",
"noMoreImages": "現在のページには閲覧可能な画像がありません",
"imageViewer": "画像ビューア",
"logUpdateView": "ビュー設定を更新",
"logFileDeleteShare": "共有リンクを削除",
"logFileEditShare": "共有リンクを編集",
"deleteShareWarning": "この共有リンクを削除しますか?",
@ -363,7 +364,11 @@
"layout": "レイアウト",
"thumbnails": "サムネイル",
"on": "オン",
"off": "オフ"
"off": "オフ",
"viewSetting": "ビュー設定",
"saved": "保存",
"notSet": "未設定",
"deleteViewSetting": "ビュー設定を削除"
},
"modals": {
"showFileName": "ファイル名を表示",
@ -498,7 +503,9 @@
"skipSoftDelete": "ファイルを完全に削除",
"skipSoftDeleteDes": "ゴミ箱をスキップしてファイルを完全に削除します",
"unlinkOnly": "物理ファイルを保持",
"unlinkOnlyDes": "ファイル記録のみ削除、物理ファイルは削除されません"
"unlinkOnlyDes": "ファイル記録のみ削除、物理ファイルは削除されません",
"shareView": "共有ビュー設定",
"shareViewDes": "チェックを入れると、他のユーザーがこの共有フォルダにアクセスした際にあなたのビュー設定(レイアウト、ソートなど)を見ることができます。"
},
"uploader": {
"fileCopyName": "コピー_",
@ -666,6 +673,10 @@
"createdAt": "作成日:"
},
"setting": {
"syncView": "ビュー設定",
"syncViewDes": "各ディレクトリのビュー設定を記憶し、サーバーに同期します。",
"syncViewOn": "サーバーに同期",
"syncViewOff": "同期しない",
"noAuthenticator": "顔認証、指紋認証、またはUSBキーによるログインのために通行キーを追加",
"neverUsed": "未使用",
"usedAt": "最終使用日 <0></0>",

View File

@ -617,7 +617,8 @@
"disable_2fa": "2FA無効化",
"add_passkey": "パスキー追加",
"remove_passkey": "パスキー削除",
"redeem_gift_code": "ギフトコード交換"
"redeem_gift_code": "ギフトコード交換",
"update_view": "ビュー設定変更"
},
"server": "サーバー設定",
"tempPath": "一時パス",

View File

@ -363,7 +363,11 @@
"layout": "布局",
"thumbnails": "缩略图",
"on": "开启",
"off": "关闭"
"off": "关闭",
"viewSetting": "视图设置",
"saved": "已保存",
"notSet": "未设置",
"deleteViewSetting": "删除视图设置"
},
"modals": {
"showFileName": "显示文件名",
@ -498,7 +502,13 @@
"skipSoftDelete": "彻底删除文件",
"skipSoftDeleteDes": "跳过回收站,直接删除文件",
"unlinkOnly": "保留物理文件",
"unlinkOnlyDes": "仅删除文件记录,物理文件不会被删除"
"unlinkOnlyDes": "仅删除文件记录,物理文件不会被删除",
"shareView": "分享视图设置",
"shareViewDes": "勾选后,其他用户访问此共享文件夹时可以看到你保存在服务器的视图设置(布局、排序等)。",
"viewSetting": "视图设置",
"saved": "已保存",
"notSet": "未设置",
"deleteViewSetting": "删除视图设置"
},
"uploader": {
"fileCopyName": "副本_",
@ -666,6 +676,10 @@
"createdAt": "创建日期:"
},
"setting": {
"syncView": "视图设置",
"syncViewDes": "是否记住各个目录的视图设置,并同步到服务器。",
"syncViewOn": "同步到服务器",
"syncViewOff": "不同步",
"noAuthenticator": "添加通行密钥以使用人脸、指纹或 USB 密钥登录账号",
"neverUsed": "从未使用过",
"usedAt": "上次使用于 <0></0>",

View File

@ -617,7 +617,8 @@
"disable_2fa": "禁用 2FA",
"add_passkey": "添加通行密钥",
"remove_passkey": "移除通行密钥",
"redeem_gift_code": "兑换礼品码"
"redeem_gift_code": "兑换礼品码",
"update_view": "更改视图设置"
},
"server": "服务器设置",
"tempPath": "临时路径",

View File

@ -498,7 +498,13 @@
"skipSoftDelete": "徹底刪除檔案",
"skipSoftDeleteDes": "跳過回收站,直接刪除檔案",
"unlinkOnly": "保留物理檔案",
"unlinkOnlyDes": "僅刪除檔案記錄,物理檔案不會被刪除"
"unlinkOnlyDes": "僅刪除檔案記錄,物理檔案不會被刪除",
"shareView": "分享視圖設定",
"shareViewDes": "勾選後,其他使用者存取此共享資料夾時可以看到你保存在服務器的視圖設定(佈局、排序等)。",
"viewSetting": "視圖設定",
"saved": "已保存",
"notSet": "未設定",
"deleteViewSetting": "刪除視圖設定"
},
"uploader": {
"fileCopyName": "副本_",
@ -666,6 +672,10 @@
"createdAt": "建立日期:"
},
"setting": {
"syncView": "視圖設置",
"syncViewDes": "是否記住各個目錄的視圖設置,並同步到服務器。",
"syncViewOn": "同步到服務器",
"syncViewOff": "不同步",
"noAuthenticator": "新增通行金鑰以使用人臉、指紋或 USB 金鑰登入賬號",
"neverUsed": "從未使用過",
"usedAt": "上次使用於 <0></0>",

View File

@ -612,9 +612,10 @@
"change_password": "更改密碼",
"enable_2fa": "啟用 2FA",
"disable_2fa": "禁用 2FA",
"add_passkey": "新增通行金鑰",
"remove_passkey": "移除通行金鑰",
"redeem_gift_code": "兌換禮品碼"
"add_passkey": "新增通行密鑰",
"remove_passkey": "移除通行密鑰",
"redeem_gift_code": "兌換禮品碼",
"update_view": "更改檢視設定"
},
"server": "伺服器設定",
"tempPath": "臨時路徑",

View File

@ -55,6 +55,7 @@ import {
MoveFileService,
MultipleUriService,
PatchMetadataService,
PatchViewSyncService,
PinFileService,
RenameFileService,
Share,
@ -1987,3 +1988,18 @@ export function sendImport(req: ImportWorkflowService): ThunkResponse<TaskRespon
);
};
}
export function sendPatchViewSync(args: PatchViewSyncService): ThunkResponse<void> {
return async (dispatch, _getState) => {
return await dispatch(
send(
`/file/view`,
{ method: "PATCH", data: args },
{
...defaultOpts,
},
),
);
};
}

View File

@ -1,3 +1,4 @@
import { ListViewColumnSetting } from "../component/FileManager/Explorer/ListView/Column.tsx";
import { User } from "./user.ts";
export interface PaginationArgs {
@ -49,6 +50,7 @@ export interface ExtendedInfo {
storage_used: number;
shares?: Share[];
entities?: Entity[];
view?: ExplorerView;
}
export interface Entity {
@ -65,6 +67,7 @@ export interface Share {
name?: string;
expires?: string;
is_private?: boolean;
share_view?: boolean;
remain_downloads?: number;
created_at?: string;
url: string;
@ -114,6 +117,16 @@ export interface NavigatorProps {
order_direction_options: string[];
}
export interface ExplorerView {
page_size: number;
order?: string;
order_direction?: string;
view?: string;
thumbnail?: boolean;
columns?: ListViewColumnSetting[];
gallery_width?: number;
}
export interface ListResponse {
files: FileResponse[];
pagination: PaginationResults;
@ -124,6 +137,7 @@ export interface ListResponse {
single_file_view?: boolean;
parent?: FileResponse;
storage_policy?: StoragePolicy;
view?: ExplorerView;
}
export const Metadata = {
@ -262,6 +276,7 @@ export interface ShareCreateService {
downloads?: number;
is_private?: boolean;
expire?: number;
share_view?: boolean;
}
export interface CreateFileService {
@ -368,6 +383,7 @@ export const AuditLogType = {
remove_passkey: 53,
redeem_gift_code: 54,
file_imported: 55,
update_view: 56,
};
export interface MultipleUriService {
@ -477,3 +493,8 @@ export interface DirectLink {
file_url: string;
link: string;
}
export interface PatchViewSyncService {
uri: string;
view?: ExplorerView;
}

View File

@ -22,6 +22,7 @@ export interface User {
group?: Group;
pined?: PinedFile[];
language?: string;
disable_view_sync?: boolean;
}
export interface Group {
id: string;
@ -99,6 +100,7 @@ export interface UserSettings {
passwordless: boolean;
two_fa_enabled: boolean;
passkeys?: Passkey[];
disable_view_sync: boolean;
}
export interface PatchUserSetting {
@ -112,6 +114,7 @@ export interface PatchUserSetting {
new_password?: string;
two_fa_enabled?: boolean;
two_fa_code?: string;
disable_view_sync?: boolean;
}
export interface PasskeyCredentialOption {

View File

@ -63,6 +63,7 @@ export const eventCategories = {
AuditLogType.move_to_trash,
AuditLogType.update_metadata,
AuditLogType.get_direct_link,
AuditLogType.update_view,
],
},
share: {

View File

@ -1,20 +1,21 @@
import { useTranslation } from "react-i18next";
import { Box, DialogContent, IconButton, Tooltip, useTheme } from "@mui/material";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import dayjs from "dayjs";
import { TFunction } from "i18next";
import React, { useCallback, useEffect, useState } from "react";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx";
import { useTranslation } from "react-i18next";
import { CSSTransition, SwitchTransition } from "react-transition-group";
import { Share as ShareModel } from "../../../../api/explorer.ts";
import { closeShareLinkDialog } from "../../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { createOrUpdateShareLink } from "../../../../redux/thunks/share.ts";
import { FilledTextField } from "../../../Common/StyledComponents.tsx";
import { copyToClipboard, sendLink } from "../../../../util";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import { FilledTextField } from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import Share from "../../../Icons/Share.tsx";
import { FileManagerIndex } from "../../FileManager.tsx";
import { TFunction } from "i18next";
import { Share as ShareModel } from "../../../../api/explorer.ts";
import dayjs from "dayjs";
import ShareSettingContent, { downloadOptions, expireOptions, ShareSetting } from "./ShareSetting.tsx";
const initialSetting: ShareSetting = {
expires_val: expireOptions[2],
@ -24,6 +25,7 @@ const initialSetting: ShareSetting = {
const shareToSetting = (share: ShareModel, t: TFunction): ShareSetting => {
const res: ShareSetting = {
is_private: share.is_private,
share_view: share.share_view,
downloads: share.remain_downloads != undefined && share.remain_downloads > 0,
expires_val: expireOptions[2],

View File

@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import {
Autocomplete,
Checkbox,
@ -13,14 +12,16 @@ import {
TextField,
Typography,
} from "@mui/material";
import { FileResponse, FileType } from "../../../../api/explorer.ts";
import MuiAccordion from "@mui/material/Accordion";
import MuiAccordionSummary from "@mui/material/AccordionSummary";
import MuiAccordionDetails from "@mui/material/AccordionDetails";
import MuiAccordionSummary from "@mui/material/AccordionSummary";
import { useState } from "react";
import Eye from "../../../Icons/Eye.tsx";
import Timer from "../../../Icons/Timer.tsx";
import { useTranslation } from "react-i18next";
import { FileResponse, FileType } from "../../../../api/explorer.ts";
import ClockArrowDownload from "../../../Icons/ClockArrowDownload.tsx";
import Eye from "../../../Icons/Eye.tsx";
import TableSettingsOutlined from "../../../Icons/TableSettings.tsx";
import Timer from "../../../Icons/Timer.tsx";
const Accordion = styled(MuiAccordion)(() => ({
border: "0px solid rgba(0, 0, 0, .125)",
@ -69,6 +70,7 @@ const StyledListItemButton = styled(ListItemButton)(() => ({}));
export interface ShareSetting {
is_private?: boolean;
share_view?: boolean;
downloads?: boolean;
expires?: boolean;
@ -122,7 +124,7 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
setExpanded(isExpanded ? panel : undefined);
};
const handleCheck = (prop: "is_private" | "expires" | "downloads") => () => {
const handleCheck = (prop: "is_private" | "share_view" | "expires" | "downloads") => () => {
if (!setting[prop]) {
handleExpand(prop)(null, true);
}
@ -150,6 +152,22 @@ const ShareSettingContent = ({ setting, file, editing, onSettingChange }: ShareS
</AccordionSummary>
<AccordionDetails>{t("application:modals.privateShareDes")}</AccordionDetails>
</Accordion>
{file?.type == FileType.folder && (
<Accordion expanded={expanded === "share_view"} onChange={handleExpand("share_view")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>
<ListItemIcon>
<TableSettingsOutlined />
</ListItemIcon>
<ListItemText primary={t("application:modals.shareView")} />
<ListItemSecondaryAction>
<Checkbox checked={setting.share_view} onChange={handleCheck("share_view")} />
</ListItemSecondaryAction>
</StyledListItemButton>
</AccordionSummary>
<AccordionDetails>{t("application:modals.shareViewDes")}</AccordionDetails>
</Accordion>
)}
<Accordion expanded={expanded === "expires"} onChange={handleExpand("expires")}>
<AccordionSummary aria-controls="panel1a-content" id="panel1a-header">
<StyledListItemButton>

View File

@ -1,4 +1,3 @@
import { useTranslation } from "react-i18next";
import {
Box,
DialogContent,
@ -10,21 +9,21 @@ import {
TableHead,
TableRow,
} from "@mui/material";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { useSnackbar } from "notistack";
import { useCallback, useEffect, useState } from "react";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import { useTranslation } from "react-i18next";
import { setListViewColumnSettingDialog } from "../../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { applyListColumns } from "../../../../redux/thunks/filemanager.ts";
import AutoHeight from "../../../Common/AutoHeight.tsx";
import { FileManagerIndex } from "../../FileManager.tsx";
import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx";
import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx";
import ArrowDown from "../../../Icons/ArrowDown.tsx";
import Dismiss from "../../../Icons/Dismiss.tsx";
import { setListViewColumns } from "../../../../redux/fileManagerSlice.ts";
import SessionManager, { UserSettings } from "../../../../session";
import { FileManagerIndex } from "../../FileManager.tsx";
import AddColumn from "./AddColumn.tsx";
import { useSnackbar } from "notistack";
import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx";
import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx";
const ColumnSetting = () => {
const { t } = useTranslation();
@ -32,12 +31,8 @@ const ColumnSetting = () => {
const { enqueueSnackbar } = useSnackbar();
const [columns, setColumns] = useState<ListViewColumnSetting[]>([]);
const open = useAppSelector(
(state) => state.globalState.listViewColumnSettingDialogOpen,
);
const listViewColumns = useAppSelector(
(state) => state.fileManager[FileManagerIndex.main].listViewColumns,
);
const open = useAppSelector((state) => state.globalState.listViewColumnSettingDialogOpen);
const listViewColumns = useAppSelector((state) => state.fileManager[FileManagerIndex.main].listViewColumns);
useEffect(() => {
if (open) {
@ -51,8 +46,7 @@ const ColumnSetting = () => {
const onSubmitted = useCallback(() => {
if (columns.length > 0) {
dispatch(setListViewColumns(columns));
SessionManager.set(UserSettings.ListViewColumns, columns);
dispatch(applyListColumns(FileManagerIndex.main, columns));
}
dispatch(setListViewColumnSettingDialog(false));
}, [dispatch, columns]);
@ -60,10 +54,7 @@ const ColumnSetting = () => {
const onColumnAdded = useCallback(
(column: ListViewColumnSetting) => {
const existed = columns.find((c) => c.type === column.type);
if (
!existed ||
existed.props?.metadata_key != column.props?.metadata_key
) {
if (!existed || existed.props?.metadata_key != column.props?.metadata_key) {
setColumns((prev) => [...prev, column]);
} else {
enqueueSnackbar(t("application:fileManager.columnExisted"), {
@ -101,11 +92,7 @@ const ColumnSetting = () => {
</TableHead>
<TableBody>
{columns.map((column, index) => (
<TableRow
hover
key={index}
sx={{ "&:last-child td, &:last-child th": { border: 0 } }}
>
<TableRow hover key={index} sx={{ "&:last-child td, &:last-child th": { border: 0 } }}>
<TableCell component="th" scope="row">
{t(getColumnTypeDefaults(column).title)}
</TableCell>

View File

@ -20,11 +20,7 @@ export interface ResizeProps {
startX: number;
}
const ListHeader = ({
setColumns,
commitColumnSetting,
columns,
}: ListHeaderProps) => {
const ListHeader = ({ setColumns, commitColumnSetting, columns }: ListHeaderProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [showDivider, setShowDivider] = useState(false);
@ -44,14 +40,9 @@ const ListHeader = ({
const column = columns[resizeProps.current.index];
const currentWidth = column.width ?? column.defaults.width;
const minWidth = column.defaults.minWidth ?? 100;
const newWidth = Math.max(
minWidth,
currentWidth + (e.clientX - resizeProps.current.startX),
);
const newWidth = Math.max(minWidth, currentWidth + (e.clientX - resizeProps.current.startX));
setColumns((prev) =>
prev.map((c, index) =>
index === resizeProps.current?.index ? { ...c, width: newWidth } : c,
),
prev.map((c, index) => (index === resizeProps.current?.index ? { ...c, width: newWidth } : c)),
);
},
[columns, setColumns],
@ -65,16 +56,12 @@ const ListHeader = ({
}, [onMouseMove, commitColumnSetting]);
const fmIndex = useContext(FmIndexContext);
const orderMethodOptions = useAppSelector(
(state) => state.fileManager[fmIndex].list?.props.order_by_options,
);
const orderMethodOptions = useAppSelector((state) => state.fileManager[fmIndex].list?.props.order_by_options);
const orderDirectionOption = useAppSelector(
(state) => state.fileManager[fmIndex].list?.props.order_direction_options,
);
const sortBy = useAppSelector((state) => state.fileManager[fmIndex].sortBy);
const sortDirection = useAppSelector(
(state) => state.fileManager[fmIndex].sortDirection,
);
const sortDirection = useAppSelector((state) => state.fileManager[fmIndex].sortDirection);
const allAvailableSortOptions = useMemo((): {
[key: string]: boolean;
@ -83,10 +70,7 @@ const ListHeader = ({
const res: { [key: string]: boolean } = {};
orderMethodOptions.forEach((method) => {
// make sure orderDirectionOption contains both asc and desc
if (
orderDirectionOption.includes("asc") &&
orderDirectionOption.includes("desc")
) {
if (orderDirectionOption.includes("asc") && orderDirectionOption.includes("desc")) {
res[method] = true;
}
});
@ -96,8 +80,6 @@ const ListHeader = ({
const setSortBy = useCallback(
(order_by: string, order_direction: string) => {
dispatch(changeSortOption(fmIndex, order_by, order_direction));
SessionManager.set(UserSettings.SortBy, order_by);
SessionManager.set(UserSettings.SortDirection, order_direction);
},
[dispatch, fmIndex],
);
@ -119,15 +101,8 @@ const ListHeader = ({
key={index}
column={column}
setSortBy={setSortBy}
sortable={
!!column.defaults.order_by &&
allAvailableSortOptions[column.defaults.order_by]
}
sortDirection={
sortBy && sortBy === column.defaults.order_by
? sortDirection
: undefined
}
sortable={!!column.defaults.order_by && allAvailableSortOptions[column.defaults.order_by]}
sortDirection={sortBy && sortBy === column.defaults.order_by ? sortDirection : undefined}
/>
))}
<Fade in={showDivider}>
@ -139,11 +114,7 @@ const ListHeader = ({
}}
>
<Tooltip title={t("fileManager.addColumn")}>
<IconButton
onClick={() => dispatch(setListViewColumnSettingDialog(true))}
sx={{ ml: 1 }}
size={"small"}
>
<IconButton onClick={() => dispatch(setListViewColumnSettingDialog(true))} sx={{ ml: 1 }} size={"small"}>
<Add
sx={{
width: "18px",

View File

@ -1,24 +1,13 @@
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Box, useMediaQuery, useTheme } from "@mui/material";
import {
getColumnTypeDefaults,
ListViewColumn,
ListViewColumnSetting,
} from "./Column.tsx";
import ListHeader from "./ListHeader.tsx";
import ListBody from "./ListBody.tsx";
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useAppDispatch, useAppSelector } from "../../../../redux/hooks.ts";
import { applyListColumns } from "../../../../redux/thunks/filemanager.ts";
import { FmIndexContext } from "../../FmIndexContext.tsx";
import { setListViewColumns } from "../../../../redux/fileManagerSlice.ts";
import SessionManager, { UserSettings } from "../../../../session";
import { SearchLimitReached } from "../EmptyFileList.tsx";
import { getColumnTypeDefaults, ListViewColumn, ListViewColumnSetting } from "./Column.tsx";
import ListBody from "./ListBody.tsx";
import ListHeader from "./ListHeader.tsx";
const ListView = React.forwardRef(
(
@ -34,12 +23,8 @@ const ListView = React.forwardRef(
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dispatch = useAppDispatch();
const fmIndex = useContext(FmIndexContext);
const recursion_limit_reached = useAppSelector(
(state) => state.fileManager[fmIndex].list?.recursion_limit_reached,
);
const columnSetting = useAppSelector(
(state) => state.fileManager[fmIndex].listViewColumns,
);
const recursion_limit_reached = useAppSelector((state) => state.fileManager[fmIndex].list?.recursion_limit_reached);
const columnSetting = useAppSelector((state) => state.fileManager[fmIndex].listViewColumns);
const [columns, setColumns] = useState<ListViewColumn[]>(
columnSetting.map(
@ -64,10 +49,7 @@ const ListView = React.forwardRef(
}, [columnSetting]);
const totalWidth = useMemo(() => {
return columns.reduce(
(acc, column) => acc + (column.width ?? column.defaults.width),
0,
);
return columns.reduce((acc, column) => acc + (column.width ?? column.defaults.width), 0);
}, [columns]);
const commitColumnSetting = useCallback(() => {
@ -82,8 +64,7 @@ const ListView = React.forwardRef(
return prev;
});
if (settings.length > 0) {
dispatch(setListViewColumns(settings));
SessionManager.set(UserSettings.ListViewColumns, settings);
dispatch(applyListColumns(fmIndex, settings));
}
}, [dispatch, setColumns]);
@ -98,11 +79,7 @@ const ListView = React.forwardRef(
flexDirection: "column",
}}
>
<ListHeader
commitColumnSetting={commitColumnSetting}
setColumns={setColumns}
columns={columns}
/>
<ListHeader commitColumnSetting={commitColumnSetting} setColumns={setColumns} columns={columns} />
<ListBody columns={columns} />
{recursion_limit_reached && (
<Box sx={{ px: 1, py: 1 }}>

View File

@ -1,15 +1,16 @@
import { FileResponse, FileType, FolderSummary, Metadata } from "../../../api/explorer.ts";
import { useTranslation } from "react-i18next";
import { Link, Skeleton, Typography } from "@mui/material";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import InfoRow from "./InfoRow.tsx";
import dayjs from "dayjs";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { getFileInfo, sendPatchViewSync } from "../../../api/api.ts";
import { ExplorerView, FileResponse, FileType, FolderSummary, Metadata } from "../../../api/explorer.ts";
import { useAppDispatch } from "../../../redux/hooks.ts";
import SessionManager from "../../../session/index.ts";
import { sizeToString } from "../../../util";
import FileBadge from "../FileBadge.tsx";
import CrUri from "../../../util/uri.ts";
import TimeBadge from "../../Common/TimeBadge.tsx";
import { useAppDispatch } from "../../../redux/hooks.ts";
import { getFileInfo } from "../../../api/api.ts";
import dayjs from "dayjs";
import FileBadge from "../FileBadge.tsx";
import InfoRow from "./InfoRow.tsx";
export interface BasicInfoProps {
target: FileResponse;
@ -25,6 +26,12 @@ const BasicInfo = ({ target }: BasicInfoProps) => {
setFolderSummary(null);
}, [target]);
const [viewSetting, setViewSetting] = useState<ExplorerView | undefined>(undefined);
useEffect(() => {
setViewSetting(target?.extended_info?.view);
}, [target]);
const isSymbolicLink = useMemo(() => {
return !!(target.metadata && target.metadata[Metadata.share_redirect]);
}, [target.metadata]);
@ -68,6 +75,10 @@ const BasicInfo = ({ target }: BasicInfoProps) => {
return new CrUri(target.path);
}, [target]);
const viewSyncEnabled = useMemo(() => {
return !SessionManager.currentLoginOrNull()?.user?.disable_view_sync;
}, [target]);
const restoreParent = useMemo(() => {
if (!target.metadata || !target.metadata[Metadata.restore_uri]) {
return null;
@ -117,6 +128,16 @@ const BasicInfo = ({ target }: BasicInfoProps) => {
});
}, [folderSummary, t]);
const handleDeleteViewSetting = useCallback(() => {
dispatch(sendPatchViewSync({ uri: target.path }))
.then(() => {
setViewSetting(undefined);
})
.catch((error) => {
console.error("Failed to delete view setting:", error);
});
}, [target.path, dispatch]);
return (
<>
<Typography sx={{ pt: 1 }} color="textPrimary" fontWeight={500} variant={"subtitle1"}>
@ -215,6 +236,30 @@ const BasicInfo = ({ target }: BasicInfoProps) => {
title={t("application:fileManager.modifiedAt")}
content={<TimeBadge variant={"body2"} datetime={target.updated_at} />}
/>
{target.type == FileType.folder && viewSyncEnabled && target.owned && !restoreParent && !isSymbolicLink && (
<InfoRow
title={t("application:fileManager.viewSetting")}
content={
!!viewSetting ? (
<>
{t("application:fileManager.saved")}{" "}
<Link
href={"#"}
onClick={(e) => {
e.preventDefault();
handleDeleteViewSetting();
}}
underline={"hover"}
>
{t("application:fileManager.deleteViewSetting")}
</Link>
</>
) : (
t("application:fileManager.notSet")
)
}
/>
)}
</>
);
};

View File

@ -1,16 +1,9 @@
import {
ListItemIcon,
ListItemText,
Menu,
MenuItem,
MenuProps,
} from "@mui/material";
import { ListItemIcon, ListItemText, Menu, MenuItem, MenuProps } from "@mui/material";
import { useContext, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import React, { useContext, useMemo } from "react";
import Checkmark from "../../Icons/Checkmark.tsx";
import { changeSortOption } from "../../../redux/thunks/filemanager.ts";
import SessionManager, { UserSettings } from "../../../session";
import Checkmark from "../../Icons/Checkmark.tsx";
import { FmIndexContext } from "../FmIndexContext.tsx";
@ -80,24 +73,17 @@ const SortMethodMenu = ({ onClose, ...rest }: MenuProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const fmIndex = useContext(FmIndexContext);
const orderMethodOptions = useAppSelector(
(state) => state.fileManager[fmIndex].list?.props.order_by_options,
);
const orderMethodOptions = useAppSelector((state) => state.fileManager[fmIndex].list?.props.order_by_options);
const orderDirectionOption = useAppSelector(
(state) => state.fileManager[fmIndex].list?.props.order_direction_options,
);
const sortBy = useAppSelector((state) => state.fileManager[fmIndex].sortBy);
const sortDirection = useAppSelector(
(state) => state.fileManager[fmIndex].sortDirection,
);
const sortDirection = useAppSelector((state) => state.fileManager[fmIndex].sortDirection);
const options = useMemo(() => {
if (!orderMethodOptions || !orderDirectionOption) return [];
const res: sortOption[] = [];
const selectedVal =
!sortBy || !sortDirection
? "created_at_asc"
: `${sortBy}_${sortDirection}`;
const selectedVal = !sortBy || !sortDirection ? "created_at_asc" : `${sortBy}_${sortDirection}`;
orderMethodOptions.forEach((method) => {
orderDirectionOption.forEach((direction) => {
const key = `${method}_${direction}`;
@ -110,25 +96,15 @@ const SortMethodMenu = ({ onClose, ...rest }: MenuProps) => {
}, [orderMethodOptions, orderDirectionOption, sortBy, sortDirection]);
const selectOption = (option: sortOption) => {
dispatch(
changeSortOption(fmIndex, option.order_by, option.order_direction),
);
SessionManager.set(UserSettings.SortBy, option.order_by);
SessionManager.set(UserSettings.SortDirection, option.order_direction);
dispatch(changeSortOption(fmIndex, option.order_by, option.order_direction));
onClose && onClose({}, "escapeKeyDown");
};
return (
<Menu onClose={onClose} {...rest}>
{options.map((option) => (
<MenuItem
dense
key={option.order_by + option.order_direction}
onClick={() => selectOption(option)}
>
{!option.selected && (
<ListItemText inset>{t(option.label)}</ListItemText>
)}
<MenuItem dense key={option.order_by + option.order_direction} onClick={() => selectOption(option)}>
{!option.selected && <ListItemText inset>{t(option.label)}</ListItemText>}
{option.selected && (
<>
<ListItemIcon>

View File

@ -9,30 +9,29 @@ import {
ToggleButtonGroup,
Typography,
} from "@mui/material";
import React, { useContext, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Layouts } from "../../../redux/fileManagerSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import AppsListOutlined from "../../Icons/AppsListOutlined.tsx";
import GridOutlined from "../../Icons/GridOutlined.tsx";
import Grid from "../../Icons/Grid.tsx";
import AppsList from "../../Icons/AppsList.tsx";
import NavIconTransition from "../../Frame/NavBar/NavIconTransition.tsx";
import React, { useContext } from "react";
import SessionManager, { UserSettings } from "../../../session";
import {
Layouts,
setGalleryWidth,
setLayout,
setShowThumb,
} from "../../../redux/fileManagerSlice.ts";
import ImageOutlined from "../../Icons/ImageOutlined.tsx";
import ImageOffOutlined from "../../Icons/ImageOffOutlined.tsx";
import { changePageSize } from "../../../redux/thunks/filemanager.ts";
applyGalleryWidth,
changePageSize,
setLayoutSetting,
setThumbToggle,
} from "../../../redux/thunks/filemanager.ts";
import NavIconTransition from "../../Frame/NavBar/NavIconTransition.tsx";
import AppsList from "../../Icons/AppsList.tsx";
import AppsListOutlined from "../../Icons/AppsListOutlined.tsx";
import Grid from "../../Icons/Grid.tsx";
import GridOutlined from "../../Icons/GridOutlined.tsx";
import ImageCopy from "../../Icons/ImageCopy.tsx";
import ImageCopyOutlined from "../../Icons/ImageCopyOutlined.tsx";
import ImageOffOutlined from "../../Icons/ImageOffOutlined.tsx";
import ImageOutlined from "../../Icons/ImageOutlined.tsx";
import { FmIndexContext } from "../FmIndexContext.tsx";
import Setting from "../../Icons/Setting.tsx";
import { setListViewColumnSettingDialog } from "../../../redux/globalStateSlice.ts";
import Setting from "../../Icons/Setting.tsx";
import { FmIndexContext } from "../FmIndexContext.tsx";
const layoutOptions: {
label: string;
@ -80,71 +79,44 @@ const ViewOptionPopover = ({ ...rest }: PopoverProps) => {
const dispatch = useAppDispatch();
const fmIndex = useContext(FmIndexContext);
const layout = useAppSelector((state) => state.fileManager[fmIndex].layout);
const showThumb = useAppSelector(
(state) => state.fileManager[fmIndex].showThumb,
);
const pageSize = useAppSelector(
(state) => state.fileManager[fmIndex].pageSize,
);
const pageSizeMax = useAppSelector(
(state) => state.fileManager[fmIndex].list?.props.max_page_size,
);
const galleryWidth = useAppSelector(
(state) => state.fileManager[fmIndex].galleryWidth,
);
const showThumb = useAppSelector((state) => state.fileManager[fmIndex].showThumb);
const pageSize = useAppSelector((state) => state.fileManager[fmIndex].pageSize);
const pageSizeMax = useAppSelector((state) => state.fileManager[fmIndex].list?.props.max_page_size);
const galleryWidth = useAppSelector((state) => state.fileManager[fmIndex].galleryWidth);
const [desiredPageSize, setDesiredPageSize] = React.useState(pageSize);
const pageSizeMaxSafe = pageSizeMax ?? desiredPageSize;
const step = pageSizeMaxSafe - MinPageSize <= 100 ? 1 : 10;
const [desiredImageWidth, setDesiredImageWidth] =
React.useState(galleryWidth);
const [desiredImageWidth, setDesiredImageWidth] = React.useState(galleryWidth);
const handleLayoutChange = (
_event: React.MouseEvent<HTMLElement>,
newMode: string,
) => {
useEffect(() => {
setDesiredPageSize(pageSize);
}, [pageSize]);
const handleLayoutChange = (_event: React.MouseEvent<HTMLElement>, newMode: string) => {
if (newMode) {
dispatch(setLayout({ index: fmIndex, value: newMode }));
SessionManager.set(UserSettings.Layout, newMode);
dispatch(setLayoutSetting(fmIndex, newMode));
}
};
const handleThumbChange = (
_event: React.MouseEvent<HTMLElement>,
newMode: boolean,
) => {
dispatch(setShowThumb({ index: fmIndex, value: newMode }));
SessionManager.set(UserSettings.ShowThumb, newMode);
const handleThumbChange = (_event: React.MouseEvent<HTMLElement>, newMode: boolean) => {
dispatch(setThumbToggle(fmIndex, newMode));
};
const handlePageSlideChange = (
_event: Event,
newValue: number | number[],
) => {
const handlePageSlideChange = (_event: Event, newValue: number | number[]) => {
setDesiredPageSize(newValue as number);
};
const commitPageSize = (
_event: React.SyntheticEvent | Event,
newValue: number | number[],
) => {
const commitPageSize = (_event: React.SyntheticEvent | Event, newValue: number | number[]) => {
const pageSize = Math.max(MinPageSize, newValue as number);
SessionManager.set(UserSettings.PageSize, pageSize);
dispatch(changePageSize(fmIndex, pageSize));
};
const handleImageSizeChange = (
_event: Event,
newValue: number | number[],
) => {
const handleImageSizeChange = (_event: Event, newValue: number | number[]) => {
setDesiredImageWidth(newValue as number);
};
const commitImageSize = (
_event: React.SyntheticEvent | Event,
newValue: number | number[],
) => {
SessionManager.set(UserSettings.GalleryWidth, newValue as number);
dispatch(setGalleryWidth({ index: fmIndex, value: newValue as number }));
const commitImageSize = (_event: React.SyntheticEvent | Event, newValue: number | number[]) => {
dispatch(applyGalleryWidth(fmIndex, newValue as number));
};
return (
@ -161,11 +133,7 @@ const ViewOptionPopover = ({ ...rest }: PopoverProps) => {
>
<Box sx={{ p: 2, minWidth: "300px" }}>
<Box>
<Typography
variant="subtitle2"
sx={{ mb: 0.5, ml: 0.5 }}
color={"text.secondary"}
>
<Typography variant="subtitle2" sx={{ mb: 0.5, ml: 0.5 }} color={"text.secondary"}>
{t("application:fileManager.layout")}
</Typography>
<ToggleButtonGroup
@ -190,11 +158,7 @@ const ViewOptionPopover = ({ ...rest }: PopoverProps) => {
</ToggleButtonGroup>
</Box>
<Collapse in={layout == Layouts.grid}>
<Typography
variant="subtitle2"
sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }}
color={"text.secondary"}
>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }} color={"text.secondary"}>
{t("application:fileManager.thumbnails")}
</Typography>
<ToggleButtonGroup
@ -207,45 +171,25 @@ const ViewOptionPopover = ({ ...rest }: PopoverProps) => {
>
{thumbOptions.map((option) => (
<ToggleButton key={option.label} value={option.value}>
<option.icon
sx={{ mr: 1, height: "20px" }}
fontSize={"small"}
/>
<option.icon sx={{ mr: 1, height: "20px" }} fontSize={"small"} />
{t(option.label)}
</ToggleButton>
))}
</ToggleButtonGroup>
</Collapse>
<Collapse in={layout == Layouts.list}>
<Typography
variant="subtitle2"
sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }}
color={"text.secondary"}
>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }} color={"text.secondary"}>
{t("application:fileManager.listColumnSetting")}
</Typography>
<ToggleButtonGroup
value={0}
fullWidth
size="small"
color="primary"
exclusive
>
<ToggleButton
value={1}
onClick={() => dispatch(setListViewColumnSettingDialog(true))}
>
<ToggleButtonGroup value={0} fullWidth size="small" color="primary" exclusive>
<ToggleButton value={1} onClick={() => dispatch(setListViewColumnSettingDialog(true))}>
<Setting sx={{ mr: 1, height: "20px" }} fontSize={"small"} />
{t("application:fileManager.listColumnSetting")}
</ToggleButton>
</ToggleButtonGroup>
</Collapse>
<Collapse in={layout == Layouts.gallery}>
<Typography
variant="subtitle2"
sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }}
color={"text.secondary"}
>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }} color={"text.secondary"}>
{t("application:fileManager.imageSize")}
</Typography>
<Box sx={{ px: 1 }}>
@ -269,11 +213,7 @@ const ViewOptionPopover = ({ ...rest }: PopoverProps) => {
</Box>
</Box>
</Collapse>
<Typography
variant="subtitle2"
sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }}
color={"text.secondary"}
>
<Typography variant="subtitle2" sx={{ mt: 1.5, mb: 0.5, ml: 0.5 }} color={"text.secondary"}>
{t("application:fileManager.paginationSize")}
</Typography>
<Box sx={{ px: 1 }}>

View File

@ -0,0 +1,9 @@
import { SvgIcon, SvgIconProps } from "@mui/material";
export default function RectangleLandscapeSync(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M2 7.25A3.25 3.25 0 0 1 5.25 4h13.5A3.25 3.25 0 0 1 22 7.25v4.56a6.518 6.518 0 0 0-1.5-1.078V7.25a1.75 1.75 0 0 0-1.75-1.75H5.25A1.75 1.75 0 0 0 3.5 7.25v9.5c0 .966.784 1.75 1.75 1.75h6.063c.173.534.412 1.037.709 1.5H5.25A3.25 3.25 0 0 1 2 16.75zm10 9.25a5.5 5.5 0 1 0 11 0a5.5 5.5 0 0 0-11 0m8.5-3.5a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h1a2.496 2.496 0 0 0-2-1c-.833 0-1.572.407-2.027 1.036a.5.5 0 0 1-.81-.586A3.496 3.496 0 0 1 17.5 13c.98 0 1.865.403 2.5 1.05v-.55a.5.5 0 0 1 .5-.5M15 18.95v.55a.5.5 0 0 1-1 0v-2a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 0 1h-1c.456.608 1.183 1 2 1c.766 0 1.452-.344 1.911-.888a.5.5 0 0 1 .764.645A3.493 3.493 0 0 1 17.5 20a3.49 3.49 0 0 1-2.5-1.05" />
</SvgIcon>
);
}

View File

@ -0,0 +1,9 @@
import { SvgIcon, SvgIconProps } from "@mui/material";
export default function RectangleLandscapeSync(props: SvgIconProps) {
return (
<SvgIcon {...props}>
<path d="M3.28 2.22a.75.75 0 1 0-1.06 1.06l1.25 1.25A3.247 3.247 0 0 0 2 7.25v9.5A3.25 3.25 0 0 0 5.25 20h6.772a6.471 6.471 0 0 1-.709-1.5H5.25a1.75 1.75 0 0 1-1.75-1.75v-9.5c0-.727.443-1.35 1.074-1.615l8.145 8.145a5.5 5.5 0 0 0 7.502 7.502l.498.498a.75.75 0 0 0 1.061-1.06zm15.44 17.56c-.38.142-.79.219-1.22.219a3.49 3.49 0 0 1-2.5-1.05v.55a.5.5 0 0 1-1 0v-2a.5.5 0 0 1 .5-.5h1.44l.882.883A.498.498 0 0 1 16.5 18h-1a2.496 2.496 0 0 0 2.406.967zm-3.863-8.106l1.512 1.512A3.494 3.494 0 0 1 17.5 13c.98 0 1.865.403 2.5 1.05v-.55a.5.5 0 0 1 1 0v2a.5.5 0 0 1-.5.5h-1.318l3.143 3.143a5.5 5.5 0 0 0-7.467-7.468m3.392 3.392l-1.05-1.05a2.496 2.496 0 0 1 2.3.982h-.999a.498.498 0 0 0-.25.068M7.182 4l1.5 1.5H18.75c.966 0 1.75.784 1.75 1.75v3.482A6.518 6.518 0 0 1 22 11.81V7.25A3.25 3.25 0 0 0 18.75 4z" />
</SvgIcon>
);
}

View File

@ -9,6 +9,8 @@ import {
ListItemText,
Stack,
styled,
ToggleButton,
ToggleButtonGroup,
Typography,
useMediaQuery,
useTheme,
@ -21,6 +23,7 @@ import { UserSettings as UserSettingsType } from "../../../api/user.ts";
import { languages } from "../../../i18n.ts";
import { setPreferredTheme } from "../../../redux/globalStateSlice.ts";
import { useAppDispatch, useAppSelector } from "../../../redux/hooks.ts";
import { clearLocalCustomView } from "../../../redux/thunks/filemanager.ts";
import { selectLanguage } from "../../../redux/thunks/settings.ts";
import SessionManager, { UserSettings } from "../../../session";
import { refreshTimeZone, timeZone } from "../../../util/datetime.ts";
@ -33,6 +36,8 @@ import {
import { SquareMenuItem } from "../../FileManager/ContextMenu/ContextMenu.tsx";
import { ColorCircle, SelectorBox } from "../../FileManager/FileInfo/ColorCircle/CircleColorSelector.tsx";
import { SwitchPopover } from "../../Frame/NavBar/DarkThemeSwitcher.tsx";
import RectangleLandscapeSync from "../../Icons/RectangleLandscapeSync.tsx";
import RectangleLandscapeSyncOff from "../../Icons/RectangleLandscapeSyncOff.tsx";
import SettingForm from "./SettingForm.tsx";
export interface PreferenceSettingProps {
@ -141,6 +146,30 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
});
};
const onDisableViewSyncChange = (e: React.MouseEvent<HTMLElement>, enabled: boolean) => {
setSetting({ ...setting, disable_view_sync: !enabled });
setLoading(true);
dispatch(
sendUpdateUserSetting({
disable_view_sync: !enabled,
}),
)
.then(() => {
const user = SessionManager.currentLoginOrNull();
if (user?.user) {
SessionManager.updateUserIfExist({
...user?.user,
disable_view_sync: !enabled,
});
}
clearLocalCustomView();
setLoading(false);
})
.finally(() => {
setLoading(false);
});
};
return (
<Stack spacing={3}>
<SettingForm title={t("setting.language")} lgWidth={3}>
@ -276,6 +305,27 @@ const PreferenceSetting = ({ setting, setSetting }: PreferenceSettingProps) => {
</Stack>
</OutlinedSettingBox>
</SettingForm>
<SettingForm title={t("setting.syncView")} lgWidth={12}>
<ToggleButtonGroup
color="primary"
value={!setting.disable_view_sync}
exclusive
disabled={loading}
onChange={onDisableViewSyncChange}
size={"small"}
aria-label="Platform"
>
<ToggleButton value={true}>
<RectangleLandscapeSync fontSize="small" sx={{ mr: 1 }} />
{t("setting.syncViewOn")}
</ToggleButton>
<ToggleButton value={false}>
<RectangleLandscapeSyncOff fontSize="small" sx={{ mr: 1 }} />
{t("setting.syncViewOff")}
</ToggleButton>
</ToggleButtonGroup>
<FormHelperText>{t("setting.syncViewDes")}</FormHelperText>
</SettingForm>
</Stack>
);
};

View File

@ -1,14 +1,15 @@
import dayjs from "dayjs";
import { getFileInfo, getFileList, getUserCapacity } from "../../api/api.ts";
import { FileResponse, ListResponse, Metadata } from "../../api/explorer.ts";
import { getFileInfo, getFileList, getUserCapacity, sendPatchViewSync } from "../../api/api.ts";
import { ExplorerView, FileResponse, ListResponse, Metadata } from "../../api/explorer.ts";
import { getActionOpt } from "../../component/FileManager/ContextMenu/useActionDisplayOpt.ts";
import { ListViewColumnSetting } from "../../component/FileManager/Explorer/ListView/Column.tsx";
import { FileManagerIndex } from "../../component/FileManager/FileManager.tsx";
import { Condition, ConditionType } from "../../component/FileManager/Search/AdvanceSearch/ConditionBox.tsx";
import { MinPageSize } from "../../component/FileManager/TopBar/ViewOptionPopover.tsx";
import { SelectType } from "../../component/Uploader/core";
import { defaultPath } from "../../hooks/useNavigation.tsx";
import { router } from "../../router";
import SessionManager from "../../session";
import SessionManager, { UserSettings } from "../../session";
import { getFileLinkedUri, sleep } from "../../util";
import CrUri, { Filesystem, SearchParam, UriQuery } from "../../util/uri.ts";
import {
@ -19,16 +20,21 @@ import {
clearSelected,
closeContextMenu,
ContextMenuTypes,
Layouts,
resetFileManager,
setCapacity,
setContextMenu,
setFmError,
setFmLoading,
setGalleryWidth,
setLayout,
setListViewColumns,
setMultiSelectHovered,
setPage,
setPageSize,
setPathProps,
setSelected,
setShowThumb,
setSortOption,
SingleManager,
} from "../fileManagerSlice.ts";
@ -82,6 +88,7 @@ export function setTargetPath(index: number, path: string): AppThunk {
let generation = 0;
export interface NavigateReconcileOptions {
next_page?: boolean;
sync_view?: boolean;
}
const pageSize = (fm: SingleManager) => {
@ -110,7 +117,7 @@ export function navigateReconcile(index: number, opt?: NavigateReconcileOptions)
fileManager,
globalState: { sidebarOpen, imageViewer },
} = getState();
const { path, list } = fileManager[index];
const { path, list, pure_path } = fileManager[index];
if (!path) {
return;
}
@ -121,16 +128,30 @@ export function navigateReconcile(index: number, opt?: NavigateReconcileOptions)
}
dispatch(setFmError({ index, value: undefined }));
if (opt?.sync_view) {
try {
await dispatch(syncViewSettings(index));
} catch (e) {}
}
const currentLogin = SessionManager.currentLoginOrNull();
const currentView = localCustomView[pure_path ?? ""];
let useCustomView = currentLogin?.user.disable_view_sync || currentView;
let listRes: ListResponse | null = null;
try {
listRes = await dispatch(
getFileList({
next_page_token: opt && opt.next_page ? list?.pagination.next_token : undefined,
page_size: pageSize(fileManager[index]),
uri: path,
order_by: fileManager[index].sortBy,
order_direction: fileManager[index].sortDirection,
page: list?.pagination.page ?? undefined,
...(useCustomView
? {
page_size: currentView?.page_size ?? pageSize(fileManager[index]),
order_by: currentView?.order ?? fileManager[index].sortBy,
order_direction: currentView?.order_direction ?? fileManager[index].sortDirection,
}
: {}),
}),
);
} catch (e) {
@ -163,6 +184,42 @@ export function navigateReconcile(index: number, opt?: NavigateReconcileOptions)
}),
);
if (listRes.view) {
// Apply view setting from cloud
dispatch(setPageSize({ index, value: listRes.view.page_size }));
dispatch(
setSortOption({ index, value: [listRes.view.order ?? "created_at", listRes.view.order_direction ?? "asc"] }),
);
if (!currentView) {
dispatch(setShowThumb({ index, value: !!listRes.view.thumbnail }));
dispatch(setLayout({ index, value: listRes.view.view ?? Layouts.grid }));
dispatch(
setListViewColumns(listRes.view.columns ?? SessionManager.getWithFallback(UserSettings.ListViewColumns)),
);
dispatch(
setGalleryWidth({
index,
value: listRes.view.gallery_width ?? SessionManager.getWithFallback(UserSettings.GalleryWidth),
}),
);
}
}
if (currentView) {
// Apply view setting from local cache
dispatch(setShowThumb({ index, value: !!currentView.thumbnail }));
dispatch(setLayout({ index, value: currentView.view ?? Layouts.grid }));
dispatch(
setListViewColumns(currentView.columns ?? SessionManager.getWithFallback(UserSettings.ListViewColumns)),
);
dispatch(
setGalleryWidth({
index,
value: currentView.gallery_width ?? SessionManager.getWithFallback(UserSettings.GalleryWidth),
}),
);
}
if (opt && opt.next_page) {
dispatch(appendListResponse({ index, value: listRes }));
} else {
@ -226,8 +283,9 @@ export function loadChild(index: number, path: string, beforeLoad?: () => void):
export function changePageSize(index: number, pageSize: number): AppThunk {
return async (dispatch, _getState) => {
SessionManager.set(UserSettings.PageSize, pageSize);
dispatch(setPageSize({ index, value: pageSize }));
dispatch(navigateReconcile(index));
dispatch(navigateReconcile(index, { sync_view: true }));
};
}
@ -241,7 +299,9 @@ export function changePage(index: number, page: number): AppThunk {
export function changeSortOption(index: number, sortBy: string, sortDirection: string): AppThunk {
return async (dispatch, _getState) => {
dispatch(setSortOption({ index, value: [sortBy, sortDirection] }));
dispatch(navigateReconcile(index));
SessionManager.set(UserSettings.SortBy, sortBy);
SessionManager.set(UserSettings.SortDirection, sortDirection);
dispatch(navigateReconcile(index, { sync_view: true }));
};
}
@ -562,3 +622,89 @@ export function openContextUrlFromUri(index: number, uri: string, e: React.Mouse
dispatch(openFileContextMenu(index, file, true, e, ContextMenuTypes.file, false));
};
}
export function setThumbToggle(index: number, value: boolean): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
await dispatch(syncViewSettings(index, undefined, undefined, value));
dispatch(setShowThumb({ index: index, value: value }));
SessionManager.set(UserSettings.ShowThumb, value);
dispatch(setFmLoading({ index, value: false }));
};
}
export function setLayoutSetting(index: number, value: string): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
dispatch(setLayout({ index: index, value: value }));
SessionManager.set(UserSettings.Layout, value);
await dispatch(syncViewSettings(index));
dispatch(setFmLoading({ index, value: false }));
};
}
let localCustomView: Record<string, ExplorerView> = {};
export const clearLocalCustomView = () => {
localCustomView = {};
};
export function syncViewSettings(
index: number,
columns?: ListViewColumnSetting[],
galleryWidth?: number,
thumbOff?: boolean,
): AppThunk {
return async (dispatch, getState) => {
const fm = getState().fileManager[index];
const currentLogin = SessionManager.currentLoginOrNull();
if (!fm.list || !fm.pure_path) {
return;
}
const parent = fm.list.parent;
const crUri = new CrUri(fm.pure_path);
const shouldUpdatedView =
currentLogin &&
!currentLogin.user.disable_view_sync &&
(parent?.owned || crUri.fs() == Filesystem.trash || crUri.fs() == Filesystem.shared_with_me);
const currentView: ExplorerView = {
page_size: pageSize(fm),
order: fm.sortBy ?? "created_at",
order_direction: fm.sortDirection ?? "asc",
view: fm.layout ?? Layouts.grid,
thumbnail: thumbOff ?? fm.showThumb,
columns: columns ?? fm.listViewColumns,
gallery_width: galleryWidth ?? fm.galleryWidth ?? 110,
};
if (shouldUpdatedView) {
await dispatch(
sendPatchViewSync({
uri: fm.pure_path,
view: currentView,
}),
);
} else {
localCustomView[fm.pure_path] = currentView;
}
};
}
export function applyListColumns(index: number, columns: ListViewColumnSetting[]): AppThunk {
return async (dispatch, _getState) => {
dispatch(setListViewColumns(columns));
SessionManager.set(UserSettings.ListViewColumns, columns);
dispatch(syncViewSettings(index, columns));
};
}
export function applyGalleryWidth(index: number, width: number): AppThunk {
return async (dispatch, _getState) => {
dispatch(setFmLoading({ index, value: true }));
await dispatch(syncViewSettings(index, undefined, width));
dispatch(setGalleryWidth({ index, value: width }));
SessionManager.set(UserSettings.GalleryWidth, width);
dispatch(setFmLoading({ index, value: false }));
};
}

View File

@ -20,6 +20,7 @@ export function createOrUpdateShareLink(
const req: ShareCreateService = {
uri: file.path,
is_private: setting.is_private,
share_view: setting.share_view,
downloads: setting.downloads && setting.downloads_val.value > 0 ? setting.downloads_val.value : undefined,
expire: setting.expires && setting.expires_val.value > 0 ? setting.expires_val.value : undefined,
};