mirror of
https://github.com/usememos/memos.git
synced 2025-03-18 19:50:09 +01:00
feat: new resource dashboard (#1346)
* feat: refator the file dashboard * feat: support select resouce file * feat: suppor delete select files * feat: support share menu, implement rename and delete * chore: change the color of hover * chore: refator file dashboard to page * feat: add i18n for button * feat: beautify the button * fix: the error position of button * feat: only select when click circle instead of whole card * feat: beautify file dashboard * chore: factor the filecard code * feat: using dropdown component intead of component * feat: add i18n for delete selected resource button * feat: delete the unused style of title * chore: refactor file cover * feat: support more type file cover * feat: use memo to reduce unused computing in filecover * feat: when no file be selected, click the delete will error * feat: store the select resource id instead of source to save memory * chore: delete unused code * feat: refactor the file card * chore: delete unused style file * chore: change file to resource * chore: delete unused import * chore: fix the typo * fix: the error of handle check click * fix: the error of handle of uncheck * chore: change the name of selectList to selectedList * chore: change the name of selectList to selectedList * chore: change the name of selectList to selectedList * chore: delete unused import * feat: support Responsive Design * feat: min display two card in a line * feat: adjust the num of a line in responsive design * feat: adjust the num of a line to 6 when using md * feat: add the color of hover source card when dark * chore: refactor resource cover css to reduce code * chore: delete unnessnary change * chore: change the type of callback function * chore: delete unused css code * feat: add zh-hant i18n * feat: change the position of buttons * feat: add title for the icon button * feat: add opacity for icon * feat: refactor searchbar * feat:move Debounce to search * feat: new resource search bar * feat: reduce the size of cover * support file search * Update web/src/pages/ResourcesDashboard.tsx Co-authored-by: boojack <stevenlgtm@gmail.com> * Update web/src/components/ResourceCard.tsx Co-authored-by: boojack <stevenlgtm@gmail.com> * chore: reduce css code * feat: support lowcase and uppercase search * chore: reserve the searchbar * feat: refator resource Search bar * chore: change the param name * feat: resource bar support dark mode * feat: beautify the UI of dashboard * chore: extract positionClassName from actionsClassName * feat: reduce the length of search bar --------- Co-authored-by: boojack <stevenlgtm@gmail.com>
This commit is contained in:
parent
e129b122a4
commit
0a66c5c269
3
web/.vscode/settings.json
vendored
3
web/.vscode/settings.json
vendored
@ -8,5 +8,6 @@
|
||||
],
|
||||
"files.associations": {
|
||||
"*.less": "postcss"
|
||||
}
|
||||
},
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLayoutStore, useUserStore } from "../store/module";
|
||||
import { resolution } from "../utils/layout";
|
||||
import Icon from "./Icon";
|
||||
import showResourcesDialog from "./ResourcesDialog";
|
||||
import showSettingDialog from "./SettingDialog";
|
||||
import showAskAIDialog from "./AskAIDialog";
|
||||
import showArchivedMemoDialog from "./ArchivedMemoDialog";
|
||||
@ -76,6 +75,18 @@ const Header = () => {
|
||||
<Icon.Calendar className="mr-4 w-6 h-auto opacity-80" /> {t("common.daily-review")}
|
||||
</>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/resources"
|
||||
className={({ isActive }) =>
|
||||
`${
|
||||
isActive && "bg-white dark:bg-zinc-700 shadow"
|
||||
} px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700`
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
|
||||
</>
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
<NavLink
|
||||
@ -98,12 +109,6 @@ const Header = () => {
|
||||
>
|
||||
<Icon.Bot className="mr-4 w-6 h-auto opacity-80" /> Ask AI
|
||||
</button>
|
||||
<button
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showResourcesDialog()}
|
||||
>
|
||||
<Icon.Paperclip className="mr-4 w-6 h-auto opacity-80" /> {t("common.resources")}
|
||||
</button>
|
||||
<button
|
||||
className="px-4 pr-5 py-2 rounded-lg flex flex-row items-center text-lg dark:text-gray-200 hover:bg-white hover:shadow dark:hover:bg-zinc-700"
|
||||
onClick={() => showArchivedMemoDialog()}
|
||||
|
131
web/src/components/ResourceCard.tsx
Normal file
131
web/src/components/ResourceCard.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import Icon from "./Icon";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import Dropdown from "./base/Dropdown";
|
||||
import ResourceCover from "./ResourceCover";
|
||||
import { showCommonDialog } from "./Dialog/CommonDialog";
|
||||
import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog";
|
||||
import "../less/resource-card.less";
|
||||
|
||||
interface ResourceProps {
|
||||
resource: Resource;
|
||||
handlecheckClick: () => void;
|
||||
handleUncheckClick: () => void;
|
||||
}
|
||||
|
||||
const ResourceCard = ({ resource, handlecheckClick, handleUncheckClick }: ResourceProps) => {
|
||||
const [isSelected, setIsSelected] = useState<boolean>(false);
|
||||
const resourceStore = useResourceStore();
|
||||
const resources = resourceStore.state.resources;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleRenameBtnClick = (resource: Resource) => {
|
||||
showChangeResourceFilenameDialog(resource.id, resource.filename);
|
||||
};
|
||||
|
||||
const handleDeleteResourceBtnClick = (resource: Resource) => {
|
||||
let warningText = t("resources.warning-text");
|
||||
if (resource.linkedMemoAmount > 0) {
|
||||
warningText = warningText + `\n${t("resources.linked-amount")}: ${resource.linkedMemoAmount}`;
|
||||
}
|
||||
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-resource-dialog",
|
||||
onConfirm: async () => {
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
showPreviewImageDialog(
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
|
||||
resources.findIndex((r) => r.id === resource.id)
|
||||
);
|
||||
} else {
|
||||
window.open(resourceUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
|
||||
const url = getResourceUrl(resource);
|
||||
copy(url);
|
||||
toast.success(t("message.succeed-copy-resource-link"));
|
||||
};
|
||||
|
||||
const handleSelectBtnClick = () => {
|
||||
if (isSelected) {
|
||||
handleUncheckClick();
|
||||
} else {
|
||||
handlecheckClick();
|
||||
}
|
||||
setIsSelected(!isSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="resource-card">
|
||||
<div className="btns-container">
|
||||
<div onClick={() => handleSelectBtnClick()}>
|
||||
{isSelected ? (
|
||||
<Icon.CheckCircle2 className="m-2 text-gray-500 hover:text-black absolute top-1" />
|
||||
) : (
|
||||
<Icon.Circle className="resource-checkbox" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
className="more-action-btn"
|
||||
actionsClassName="!w-28"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handlePreviewBtnClick(resource)}
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
>
|
||||
{t("resources.copy-link")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleRenameBtnClick(resource)}
|
||||
>
|
||||
{t("resources.rename")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleDeleteResourceBtnClick(resource)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<ResourceCover resource={resource} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base overflow-x-auto whitespace-nowrap text-center">{resource.filename}</div>
|
||||
<div className="text-sm text-gray-400 text-center">{dayjs(resource.createdTs).locale("en").format("YYYY/MM/DD HH:mm:ss")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceCard;
|
32
web/src/components/ResourceCover.tsx
Normal file
32
web/src/components/ResourceCover.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import Icon from "./Icon";
|
||||
import "../less/resource-cover.less";
|
||||
|
||||
interface ResourceCoverProps {
|
||||
resource: Resource;
|
||||
}
|
||||
|
||||
const ResourceCover = ({ resource }: ResourceCoverProps) => {
|
||||
switch (resource.type) {
|
||||
case "image/*":
|
||||
return <Icon.FileImage className="resource-cover" />;
|
||||
case "video/*":
|
||||
return <Icon.FileVideo2 className="resource-cover" />;
|
||||
case "audio/*":
|
||||
return <Icon.FileAudio className="resource-cover" />;
|
||||
case "text/*":
|
||||
return <Icon.FileText className="resource-cover" />;
|
||||
case "application/epub+zip":
|
||||
return <Icon.Book className="resource-cover" />;
|
||||
case "application/pdf":
|
||||
return <Icon.Book className="resource-cover" />;
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
return <Icon.FileEdit className="resource-cover" />;
|
||||
case "application/msword":
|
||||
return <Icon.FileEdit className="resource-cover" />;
|
||||
default:
|
||||
return <Icon.File className="resource-cover" />;
|
||||
}
|
||||
};
|
||||
|
||||
export default React.memo(ResourceCover);
|
42
web/src/components/ResourceSearchBar.tsx
Normal file
42
web/src/components/ResourceSearchBar.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState, useRef } from "react";
|
||||
import Icon from "./Icon";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
|
||||
interface ResourceSearchBarProps {
|
||||
setQuery: (queryText: string) => void;
|
||||
}
|
||||
const ResourceSearchBar = ({ setQuery }: ResourceSearchBarProps) => {
|
||||
const [queryText, setQueryText] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleTextQueryInput = (event: React.FormEvent<HTMLInputElement>) => {
|
||||
const text = event.currentTarget.value;
|
||||
setQueryText(text);
|
||||
};
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
setQuery(queryText);
|
||||
},
|
||||
200,
|
||||
[queryText]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-4/12">
|
||||
<div className="w-full h-9 flex flex-row justify-start items-center py-2 px-3 rounded-md bg-gray-200 dark:bg-zinc-800">
|
||||
<Icon.Search className="w-4 h-auto opacity-30 dark:text-gray-200" />
|
||||
<input
|
||||
className="flex ml-2 w-24 grow text-sm outline-none bg-transparent dark:text-gray-200"
|
||||
type="text"
|
||||
placeholder="Search resource "
|
||||
ref={inputRef}
|
||||
value={queryText}
|
||||
onChange={handleTextQueryInput}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceSearchBar;
|
@ -1,191 +0,0 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import { getResourceUrl } from "../utils/resource";
|
||||
import Icon from "./Icon";
|
||||
import Dropdown from "./base/Dropdown";
|
||||
import { generateDialog } from "./Dialog";
|
||||
import { showCommonDialog } from "./Dialog/CommonDialog";
|
||||
import showPreviewImageDialog from "./PreviewImageDialog";
|
||||
import showCreateResourceDialog from "./CreateResourceDialog";
|
||||
import showChangeResourceFilenameDialog from "./ChangeResourceFilenameDialog";
|
||||
import "../less/resources-dialog.less";
|
||||
|
||||
type Props = DialogProps;
|
||||
|
||||
const ResourcesDialog: React.FC<Props> = (props: Props) => {
|
||||
const { destroy } = props;
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const resourceStore = useResourceStore();
|
||||
const resources = resourceStore.state.resources;
|
||||
|
||||
useEffect(() => {
|
||||
resourceStore
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePreviewBtnClick = (resource: Resource) => {
|
||||
const resourceUrl = getResourceUrl(resource);
|
||||
if (resource.type.startsWith("image")) {
|
||||
showPreviewImageDialog(
|
||||
resources.filter((r) => r.type.startsWith("image")).map((r) => getResourceUrl(r)),
|
||||
resources.findIndex((r) => r.id === resource.id)
|
||||
);
|
||||
} else {
|
||||
window.open(resourceUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameBtnClick = (resource: Resource) => {
|
||||
showChangeResourceFilenameDialog(resource.id, resource.filename);
|
||||
};
|
||||
|
||||
const handleCopyResourceLinkBtnClick = (resource: Resource) => {
|
||||
const url = getResourceUrl(resource);
|
||||
copy(url);
|
||||
toast.success(t("message.succeed-copy-resource-link"));
|
||||
};
|
||||
|
||||
const handleDeleteUnusedResourcesBtnClick = () => {
|
||||
let warningText = t("resources.warning-text-unused");
|
||||
const unusedResources = resources.filter((resource) => {
|
||||
if (resource.linkedMemoAmount === 0) {
|
||||
warningText = warningText + `\n- ${resource.filename}`;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (unusedResources.length === 0) {
|
||||
toast.success(t("resources.no-unused-resources"));
|
||||
return;
|
||||
}
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-unused-resources",
|
||||
onConfirm: async () => {
|
||||
for (const resource of unusedResources) {
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteResourceBtnClick = (resource: Resource) => {
|
||||
let warningText = t("resources.warning-text");
|
||||
if (resource.linkedMemoAmount > 0) {
|
||||
warningText = warningText + `\n${t("resources.linked-amount")}: ${resource.linkedMemoAmount}`;
|
||||
}
|
||||
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-resource-dialog",
|
||||
onConfirm: async () => {
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="dialog-header-container">
|
||||
<p className="title-text">{t("common.resources")}</p>
|
||||
<button className="btn close-btn" onClick={destroy}>
|
||||
<Icon.X className="icon-img" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dialog-content-container">
|
||||
<div className="w-full flex flex-row justify-between items-center">
|
||||
<div className="flex flex-row justify-start items-center space-x-2">
|
||||
<Button onClick={() => showCreateResourceDialog({})} startDecorator={<Icon.Plus className="w-5 h-auto" />}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-end items-center">
|
||||
<Button color="danger" onClick={handleDeleteUnusedResourcesBtnClick} startDecorator={<Icon.Trash2 className="w-4 h-auto" />}>
|
||||
<span>{t("resources.clear")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{loadingState.isLoading ? (
|
||||
<div className="loading-text-container">
|
||||
<p className="tip-text">{t("resources.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="resource-table-container">
|
||||
<div className="fields-container">
|
||||
<span className="field-text id-text">ID</span>
|
||||
<span className="field-text name-text">{t("resources.name")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{resources.length === 0 ? (
|
||||
<p className="tip-text">{t("resources.no-resources")}</p>
|
||||
) : (
|
||||
resources.map((resource) => (
|
||||
<div key={resource.id} className="resource-container">
|
||||
<span className="field-text id-text">{resource.id}</span>
|
||||
<span className="field-text name-text" onClick={() => handleRenameBtnClick(resource)}>
|
||||
{resource.filename}
|
||||
</span>
|
||||
<div className="buttons-container">
|
||||
<Dropdown
|
||||
actionsClassName="!w-28"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handlePreviewBtnClick(resource)}
|
||||
>
|
||||
{t("resources.preview")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleCopyResourceLinkBtnClick(resource)}
|
||||
>
|
||||
{t("resources.copy-link")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left text-sm leading-6 py-1 px-3 cursor-pointer rounded text-red-600 hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={() => handleDeleteResourceBtnClick(resource)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default function showResourcesDialog() {
|
||||
generateDialog(
|
||||
{
|
||||
className: "resources-dialog",
|
||||
dialogName: "resources-dialog",
|
||||
},
|
||||
ResourcesDialog,
|
||||
{}
|
||||
);
|
||||
}
|
25
web/src/less/resource-card.less
Normal file
25
web/src/less/resource-card.less
Normal file
@ -0,0 +1,25 @@
|
||||
.resource-card{
|
||||
@apply flex flex-col relative justify-start hover:bg-slate-200 dark:hover:bg-slate-600 w-full m-4 rounded-md;
|
||||
|
||||
&:hover {
|
||||
.resource-checkbox {
|
||||
@apply flex m-2 text-gray-500 absolute top-1 hover:text-black;
|
||||
}
|
||||
.more-action-btn{
|
||||
@apply flex absolute right-0 top-1;
|
||||
}
|
||||
}
|
||||
|
||||
.resource-checkbox{
|
||||
@apply hidden hover:flex ;
|
||||
}
|
||||
|
||||
.more-action-btn{
|
||||
@apply hidden m-2;
|
||||
&:hover {
|
||||
& + .more-action-btns-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
3
web/src/less/resource-cover.less
Normal file
3
web/src/less/resource-cover.less
Normal file
@ -0,0 +1,3 @@
|
||||
.resource-cover{
|
||||
@apply w-full h-full ml-auto mr-auto mt-5 opacity-60;
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
.resources-dialog {
|
||||
@apply px-4;
|
||||
|
||||
> .dialog-container {
|
||||
@apply w-112 max-w-full mb-8;
|
||||
|
||||
> .dialog-content-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .action-buttons-container {
|
||||
@apply w-full flex flex-row justify-between items-center mb-2;
|
||||
|
||||
> .buttons-wrapper {
|
||||
@apply flex flex-row justify-start items-center;
|
||||
|
||||
> .upload-resource-btn {
|
||||
@apply text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-blue-600 text-blue-600 bg-blue-50 hover:opacity-80;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto mr-1;
|
||||
}
|
||||
}
|
||||
|
||||
> .delete-unused-resource-btn {
|
||||
@apply text-sm cursor-pointer px-3 py-1 rounded flex flex-row justify-center items-center border border-red-600 text-red-600 bg-red-100 hover:opacity-80;
|
||||
|
||||
> .icon-img {
|
||||
@apply w-4 h-auto mr-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .loading-text-container {
|
||||
@apply flex flex-col justify-center items-center w-full h-32;
|
||||
}
|
||||
|
||||
> .resource-table-container {
|
||||
@apply flex flex-col justify-start items-start w-full;
|
||||
|
||||
> .fields-container {
|
||||
@apply px-2 py-2 w-full grid grid-cols-7 border-b dark:border-b-zinc-600;
|
||||
|
||||
> .field-text {
|
||||
@apply font-mono text-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
> .tip-text {
|
||||
@apply w-full text-center text-base my-6 mt-8;
|
||||
}
|
||||
|
||||
> .resource-container {
|
||||
@apply px-2 py-2 w-full grid grid-cols-7;
|
||||
|
||||
> .buttons-container {
|
||||
@apply w-full flex flex-row justify-end items-center;
|
||||
}
|
||||
}
|
||||
|
||||
.field-text {
|
||||
@apply w-full truncate text-base pr-2 last:pr-0;
|
||||
|
||||
&.id-text {
|
||||
@apply col-span-1;
|
||||
}
|
||||
|
||||
&.name-text {
|
||||
@apply col-span-5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -77,7 +77,9 @@
|
||||
"clear": "Clear",
|
||||
"warning-text-unused": "Are you sure to delete these unused resources? THIS ACTION IS IRREVERSIBLE❗",
|
||||
"no-unused-resources": "No unused resources",
|
||||
"name": "Name"
|
||||
"name": "Name",
|
||||
"delete-selected-resources": "Delete Selected Resources",
|
||||
"no-files-selected": "No files selected❗"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "Archived Memos",
|
||||
|
@ -77,7 +77,9 @@
|
||||
"clear": "清除",
|
||||
"warning-text-unused": "確定刪除這些無用資源嗎?此操作不可逆❗",
|
||||
"no-unused-resources": "無可刪除的資源",
|
||||
"name": "資源名稱"
|
||||
"name": "資源名稱",
|
||||
"delete-selected-resources": "刪除選中資源",
|
||||
"no-files-selected": "沒有文件被選中❗"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "已封存的 Memo",
|
||||
|
@ -77,7 +77,9 @@
|
||||
"clear": "清除",
|
||||
"warning-text-unused": "确定删除这些无用资源么?此操作不可逆❗",
|
||||
"no-unused-resources": "无可删除的资源",
|
||||
"name": "资源名称"
|
||||
"name": "资源名称",
|
||||
"delete-selected-resources": "删除选中资源",
|
||||
"no-files-selected": "没有文件被选中❗"
|
||||
},
|
||||
"archived": {
|
||||
"archived-memos": "已归档的 Memo",
|
||||
|
168
web/src/pages/ResourcesDashboard.tsx
Normal file
168
web/src/pages/ResourcesDashboard.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { Button } from "@mui/joy";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useLoading from "../hooks/useLoading";
|
||||
import { useResourceStore } from "../store/module";
|
||||
import Icon from "../components/Icon";
|
||||
import ResourceCard from "../components/ResourceCard";
|
||||
import ResourceSearchBar from "../components/ResourceSearchBar";
|
||||
import { showCommonDialog } from "../components/Dialog/CommonDialog";
|
||||
import showCreateResourceDialog from "../components/CreateResourceDialog";
|
||||
import MobileHeader from "../components/MobileHeader";
|
||||
import Dropdown from "../components/base/Dropdown";
|
||||
|
||||
const ResourcesDashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
const loadingState = useLoading();
|
||||
const resourceStore = useResourceStore();
|
||||
const resources = resourceStore.state.resources;
|
||||
const [selectedList, setSelectedList] = useState<Array<ResourceId>>([]);
|
||||
const [isVisiable, setIsVisiable] = useState<boolean>(false);
|
||||
const [queryText, setQueryText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
resourceStore
|
||||
.fetchResourceList()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error.response.data.message);
|
||||
})
|
||||
.finally(() => {
|
||||
loadingState.setFinish();
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedList.length === 0) {
|
||||
setIsVisiable(false);
|
||||
} else {
|
||||
setIsVisiable(true);
|
||||
}
|
||||
}, [selectedList]);
|
||||
|
||||
const handleCheckBtnClick = (resourceId: ResourceId) => {
|
||||
setSelectedList([...selectedList, resourceId]);
|
||||
};
|
||||
|
||||
const handleUncheckBtnClick = (resourceId: ResourceId) => {
|
||||
setSelectedList(selectedList.filter((resId) => resId !== resourceId));
|
||||
};
|
||||
|
||||
const handleDeleteUnusedResourcesBtnClick = () => {
|
||||
let warningText = t("resources.warning-text-unused");
|
||||
const unusedResources = resources.filter((resource) => {
|
||||
if (resource.linkedMemoAmount === 0) {
|
||||
warningText = warningText + `\n- ${resource.filename}`;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (unusedResources.length === 0) {
|
||||
toast.success(t("resources.no-unused-resources"));
|
||||
return;
|
||||
}
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-unused-resources",
|
||||
onConfirm: async () => {
|
||||
for (const resource of unusedResources) {
|
||||
await resourceStore.deleteResourceById(resource.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteSelectedBtnClick = () => {
|
||||
if (selectedList.length == 0) {
|
||||
toast.error(t("resources.no-files-selected"));
|
||||
} else {
|
||||
const warningText = t("resources.warning-text");
|
||||
showCommonDialog({
|
||||
title: t("resources.delete-resource"),
|
||||
content: warningText,
|
||||
style: "warning",
|
||||
dialogName: "delete-resource-dialog",
|
||||
onConfirm: async () => {
|
||||
selectedList.map(async (resourceId: ResourceId) => {
|
||||
await resourceStore.deleteResourceById(resourceId);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="w-full min-h-full flex flex-col justify-start items-center px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
||||
<MobileHeader showSearch={false} />
|
||||
<div className="w-full flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-white dark:bg-zinc-700 text-black dark:text-gray-300">
|
||||
<div className="relative w-full flex flex-row justify-between items-center">
|
||||
<p className="px-2 py-1 flex flex-row justify-start items-center select-none rounded">
|
||||
<Icon.Paperclip className="w-5 h-auto mr-1" /> {t("common.resources")}
|
||||
</p>
|
||||
<ResourceSearchBar setQuery={setQueryText} />
|
||||
</div>
|
||||
<div className=" flex flex-col justify-start items-start w-full">
|
||||
<div className="w-full flex flex-row justify-end items-center">
|
||||
<div className="flex flex-row justify-start items-center space-x-2"></div>
|
||||
<div className="m-2">
|
||||
{isVisiable && (
|
||||
<Button onClick={() => handleDeleteSelectedBtnClick()} color="danger" title={t("resources.delete-selected-resources")}>
|
||||
<Icon.Trash2 className="w-4 h-auto" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="m-2">
|
||||
<Button onClick={() => showCreateResourceDialog({})} title={t("common.create")}>
|
||||
<Icon.Plus className="w-4 h-auto" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dropdown
|
||||
className="drop-shadow-none m-2 "
|
||||
actionsClassName="!w-28 rounded-lg drop-shadow-md dark:bg-zinc-800"
|
||||
positionClassName="mt-2 top-full right-0"
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="w-full content-center text-sm whitespace-nowrap leading-6 py-1 px-3 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-600"
|
||||
onClick={handleDeleteUnusedResourcesBtnClick}
|
||||
>
|
||||
{t("resources.clear")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{loadingState.isLoading ? (
|
||||
<div className="flex flex-col justify-center items-center w-full h-32">
|
||||
<p className="w-full text-center text-base my-6 mt-8">{t("resources.fetching-data")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
|
||||
{resources.length === 0 ? (
|
||||
<p className="w-full text-center text-base my-6 mt-8">{t("resources.no-resources")}</p>
|
||||
) : (
|
||||
resources
|
||||
.filter((res: Resource) => (queryText === "" ? true : res.filename.toLowerCase().includes(queryText.toLowerCase())))
|
||||
.map((resource) => (
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
handlecheckClick={() => handleCheckBtnClick(resource.id)}
|
||||
handleUncheckClick={() => handleUncheckBtnClick(resource.id)}
|
||||
></ResourceCard>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourcesDashboard;
|
@ -4,6 +4,7 @@ import { isNullorUndefined } from "../helpers/utils";
|
||||
import store from "../store";
|
||||
import { initialGlobalState, initialUserState } from "../store/module";
|
||||
import DailyReview from "../pages/DailyReview";
|
||||
import ResourcesDashboard from "../pages/ResourcesDashboard";
|
||||
|
||||
const Root = lazy(() => import("../layouts/Root"));
|
||||
const Auth = lazy(() => import("../pages/Auth"));
|
||||
@ -118,6 +119,25 @@ const router = createBrowserRouter([
|
||||
// do nth
|
||||
}
|
||||
|
||||
const { host } = store.getState().user;
|
||||
if (isNullorUndefined(host)) {
|
||||
return redirect("/auth");
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "resources",
|
||||
element: <ResourcesDashboard />,
|
||||
loader: async () => {
|
||||
await initialGlobalStateLoader();
|
||||
|
||||
try {
|
||||
await initialUserState();
|
||||
} catch (error) {
|
||||
// do nth
|
||||
}
|
||||
|
||||
const { host } = store.getState().user;
|
||||
if (isNullorUndefined(host)) {
|
||||
return redirect("/auth");
|
||||
|
Loading…
x
Reference in New Issue
Block a user