diff --git a/web/.vscode/settings.json b/web/.vscode/settings.json index f11c3555..cba5eb29 100644 --- a/web/.vscode/settings.json +++ b/web/.vscode/settings.json @@ -8,5 +8,6 @@ ], "files.associations": { "*.less": "postcss" - } + }, + "i18n-ally.keystyle": "nested" } diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 33049c8a..bf8f401b 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -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 = () => { {t("common.daily-review")} + + `${ + 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` + } + > + <> + {t("common.resources")} + + )} { > Ask AI - + + + + + } + /> + +
+ +
+
+
{resource.filename}
+
{dayjs(resource.createdTs).locale("en").format("YYYY/MM/DD HH:mm:ss")}
+
+ + ); +}; + +export default ResourceCard; diff --git a/web/src/components/ResourceCover.tsx b/web/src/components/ResourceCover.tsx new file mode 100644 index 00000000..a70cb9ef --- /dev/null +++ b/web/src/components/ResourceCover.tsx @@ -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 ; + case "video/*": + return ; + case "audio/*": + return ; + case "text/*": + return ; + case "application/epub+zip": + return ; + case "application/pdf": + return ; + case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": + return ; + case "application/msword": + return ; + default: + return ; + } +}; + +export default React.memo(ResourceCover); diff --git a/web/src/components/ResourceSearchBar.tsx b/web/src/components/ResourceSearchBar.tsx new file mode 100644 index 00000000..6a364f4d --- /dev/null +++ b/web/src/components/ResourceSearchBar.tsx @@ -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(null); + + const handleTextQueryInput = (event: React.FormEvent) => { + const text = event.currentTarget.value; + setQueryText(text); + }; + + useDebounce( + () => { + setQuery(queryText); + }, + 200, + [queryText] + ); + + return ( +
+
+ + +
+
+ ); +}; + +export default ResourceSearchBar; diff --git a/web/src/components/ResourcesDialog.tsx b/web/src/components/ResourcesDialog.tsx deleted file mode 100644 index 244b6806..00000000 --- a/web/src/components/ResourcesDialog.tsx +++ /dev/null @@ -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) => { - 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 ( - <> -
-

{t("common.resources")}

- -
-
-
-
- -
-
- -
-
- {loadingState.isLoading ? ( -
-

{t("resources.fetching-data")}

-
- ) : ( -
-
- ID - {t("resources.name")} - -
- {resources.length === 0 ? ( -

{t("resources.no-resources")}

- ) : ( - resources.map((resource) => ( -
- {resource.id} - handleRenameBtnClick(resource)}> - {resource.filename} - -
- - - - - - } - /> -
-
- )) - )} -
- )} -
- - ); -}; - -export default function showResourcesDialog() { - generateDialog( - { - className: "resources-dialog", - dialogName: "resources-dialog", - }, - ResourcesDialog, - {} - ); -} diff --git a/web/src/less/resource-card.less b/web/src/less/resource-card.less new file mode 100644 index 00000000..592b7760 --- /dev/null +++ b/web/src/less/resource-card.less @@ -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; + } + } + } +} diff --git a/web/src/less/resource-cover.less b/web/src/less/resource-cover.less new file mode 100644 index 00000000..d1632644 --- /dev/null +++ b/web/src/less/resource-cover.less @@ -0,0 +1,3 @@ +.resource-cover{ + @apply w-full h-full ml-auto mr-auto mt-5 opacity-60; +} \ No newline at end of file diff --git a/web/src/less/resources-dialog.less b/web/src/less/resources-dialog.less deleted file mode 100644 index a71e6738..00000000 --- a/web/src/less/resources-dialog.less +++ /dev/null @@ -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; - } - } - } - } - } -} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 51cb71c7..2147e949 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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", diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index 73068acc..7ed26d9f 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -77,7 +77,9 @@ "clear": "清除", "warning-text-unused": "確定刪除這些無用資源嗎?此操作不可逆❗", "no-unused-resources": "無可刪除的資源", - "name": "資源名稱" + "name": "資源名稱", + "delete-selected-resources": "刪除選中資源", + "no-files-selected": "沒有文件被選中❗" }, "archived": { "archived-memos": "已封存的 Memo", diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index 166e30c8..41ce9297 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -77,7 +77,9 @@ "clear": "清除", "warning-text-unused": "确定删除这些无用资源么?此操作不可逆❗", "no-unused-resources": "无可删除的资源", - "name": "资源名称" + "name": "资源名称", + "delete-selected-resources": "删除选中资源", + "no-files-selected": "没有文件被选中❗" }, "archived": { "archived-memos": "已归档的 Memo", diff --git a/web/src/pages/ResourcesDashboard.tsx b/web/src/pages/ResourcesDashboard.tsx new file mode 100644 index 00000000..ed112b55 --- /dev/null +++ b/web/src/pages/ResourcesDashboard.tsx @@ -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>([]); + const [isVisiable, setIsVisiable] = useState(false); + const [queryText, setQueryText] = useState(""); + + 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 ( +
+ +
+
+

+ {t("common.resources")} +

+ +
+
+
+
+
+ {isVisiable && ( + + )} +
+ +
+ +
+ + + + + } + /> +
+ {loadingState.isLoading ? ( +
+

{t("resources.fetching-data")}

+
+ ) : ( +
+ {resources.length === 0 ? ( +

{t("resources.no-resources")}

+ ) : ( + resources + .filter((res: Resource) => (queryText === "" ? true : res.filename.toLowerCase().includes(queryText.toLowerCase()))) + .map((resource) => ( + handleCheckBtnClick(resource.id)} + handleUncheckClick={() => handleUncheckBtnClick(resource.id)} + > + )) + )} +
+ )} +
+
+
+ ); +}; + +export default ResourcesDashboard; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 9846a976..a4ba2637 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -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: , + loader: async () => { + await initialGlobalStateLoader(); + + try { + await initialUserState(); + } catch (error) { + // do nth + } + const { host } = store.getState().user; if (isNullorUndefined(host)) { return redirect("/auth");