diff --git a/frontend/web/src/components/CollectionView.tsx b/frontend/web/src/components/CollectionView.tsx new file mode 100644 index 0000000..e4b42d0 --- /dev/null +++ b/frontend/web/src/components/CollectionView.tsx @@ -0,0 +1,101 @@ +import classNames from "classnames"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import useNavigateTo from "@/hooks/useNavigateTo"; +import { useAppSelector } from "@/stores"; +import useCollectionStore from "@/stores/v1/collection"; +import { Collection } from "@/types/proto/api/v2/collection_service"; +import { Visibility } from "@/types/proto/api/v2/common"; +import { showCommonDialog } from "./Alert"; +import CreateCollectionDialog, { ShortcutItem } from "./CreateCollectionDialog"; +import Icon from "./Icon"; +import Dropdown from "./common/Dropdown"; + +interface Props { + collection: Collection; +} + +const CollectionView = (props: Props) => { + const { collection } = props; + const { t } = useTranslation(); + const navigateTo = useNavigateTo(); + const collectionStore = useCollectionStore(); + const { shortcutList } = useAppSelector((state) => state.shortcut); + const [showEditDialog, setShowEditDialog] = useState(false); + const shortcuts = collection.shortcutIds + .map((shortcutId) => shortcutList.find((shortcut) => shortcut?.id === shortcutId)) + .filter(Boolean) as any as Shortcut[]; + + const handleDeleteCollectionButtonClick = () => { + showCommonDialog({ + title: "Delete Collection", + content: `Are you sure to delete collection \`${collection.name}\`? You cannot undo this action.`, + style: "danger", + onConfirm: async () => { + await collectionStore.deleteCollection(collection.id); + }, + }); + }; + + const handleShortcutClick = (shortcut: Shortcut) => { + navigateTo(`/shortcut/${shortcut.id}`); + }; + + return ( + <> +
+
+
+
+ {collection.title} +
+
+
+ {collection.visibility !== Visibility.PRIVATE && ( + + + + )} + + + + + } + > +
+
+
+ {shortcuts.map((shortcut) => { + return handleShortcutClick(shortcut)} />; + })} +
+
+ + {showEditDialog && ( + setShowEditDialog(false)} + onConfirm={() => setShowEditDialog(false)} + /> + )} + + ); +}; + +export default CollectionView; diff --git a/frontend/web/src/components/CreateCollectionDialog.tsx b/frontend/web/src/components/CreateCollectionDialog.tsx new file mode 100644 index 0000000..ad3de28 --- /dev/null +++ b/frontend/web/src/components/CreateCollectionDialog.tsx @@ -0,0 +1,302 @@ +import { Button, Input, Modal, ModalDialog, Radio, RadioGroup } from "@mui/joy"; +import classNames from "classnames"; +import { isUndefined } from "lodash-es"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import { getFaviconWithGoogleS2 } from "@/helpers/utils"; +import useResponsiveWidth from "@/hooks/useResponsiveWidth"; +import { useAppSelector } from "@/stores"; +import useCollectionStore from "@/stores/v1/collection"; +import { Collection } from "@/types/proto/api/v2/collection_service"; +import { Visibility } from "@/types/proto/api/v2/common"; +import { convertVisibilityFromPb } from "@/utils/visibility"; +import useLoading from "../hooks/useLoading"; +import Icon from "./Icon"; + +interface Props { + collectionId?: number; + onClose: () => void; + onConfirm?: () => void; +} + +interface State { + collectionCreate: Collection; +} + +const CreateCollectionDialog: React.FC = (props: Props) => { + const { onClose, onConfirm, collectionId } = props; + const { t } = useTranslation(); + const collectionStore = useCollectionStore(); + const { shortcutList } = useAppSelector((state) => state.shortcut); + const [state, setState] = useState({ + collectionCreate: Collection.fromPartial({ + visibility: Visibility.PRIVATE, + }), + }); + const [selectedShortcuts, setSelectedShortcuts] = useState([]); + const requestState = useLoading(false); + const isCreating = isUndefined(collectionId); + const unselectedShortcuts = shortcutList.filter( + (shortcut) => !selectedShortcuts.find((selectedShortcut) => selectedShortcut.id === shortcut.id) + ); + + useEffect(() => { + (async () => { + if (collectionId) { + const collection = await collectionStore.getOrFetchCollectionById(collectionId); + if (collection) { + setState({ + ...state, + collectionCreate: Object.assign(state.collectionCreate, { + ...collection, + }), + }); + setSelectedShortcuts( + collection.shortcutIds + .map((shortcutId) => shortcutList.find((shortcut) => shortcut.id === shortcutId)) + .filter(Boolean) as Shortcut[] + ); + } + } + })(); + }, [collectionId]); + + const setPartialState = (partialState: Partial) => { + setState({ + ...state, + ...partialState, + }); + }; + + const handleNameInputChange = (e: React.ChangeEvent) => { + setPartialState({ + collectionCreate: Object.assign(state.collectionCreate, { + name: e.target.value.replace(/\s+/g, "-"), + }), + }); + }; + + const handleTitleInputChange = (e: React.ChangeEvent) => { + setPartialState({ + collectionCreate: Object.assign(state.collectionCreate, { + title: e.target.value, + }), + }); + }; + + const handleVisibilityInputChange = (e: React.ChangeEvent) => { + setPartialState({ + collectionCreate: Object.assign(state.collectionCreate, { + visibility: Number(e.target.value), + }), + }); + }; + + const handleDescriptionInputChange = (e: React.ChangeEvent) => { + setPartialState({ + collectionCreate: Object.assign(state.collectionCreate, { + description: e.target.value, + }), + }); + }; + + const handleSaveBtnClick = async () => { + if (!state.collectionCreate.name) { + toast.error("Name is required"); + return; + } + + try { + if (!isCreating) { + await collectionStore.updateCollection( + { + id: collectionId, + name: state.collectionCreate.name, + title: state.collectionCreate.title, + description: state.collectionCreate.description, + visibility: state.collectionCreate.visibility, + shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id), + }, + ["name", "title", "description", "visibility", "shortcut_ids"] + ); + } else { + await collectionStore.createCollection({ + ...state.collectionCreate, + shortcutIds: selectedShortcuts.map((shortcut) => shortcut.id), + }); + } + + if (onConfirm) { + onConfirm(); + } else { + onClose(); + } + } catch (error: any) { + console.error(error); + toast.error(error.response.data.message); + } + }; + + return ( + + +
+ {isCreating ? "Create Collection" : "Edit Collection"} + +
+
+
+ Name +
+ +
+
+
+ Title +
+ +
+
+
+ Description +
+ +
+
+
+ Visibility +
+ + + + +
+

+ {t(`shortcut.visibility.${convertVisibilityFromPb(state.collectionCreate.visibility).toLowerCase()}.description`)} +

+
+
+

+ Shortcuts + ({selectedShortcuts.length}) + {selectedShortcuts.length === 0 && Select a shortcut first} +

+
+ {selectedShortcuts.map((shortcut) => { + return ( + { + setSelectedShortcuts([...selectedShortcuts.filter((selectedShortcut) => selectedShortcut.id !== shortcut.id)]); + }} + /> + ); + })} + {unselectedShortcuts.map((shortcut) => { + return ( + { + setSelectedShortcuts([...selectedShortcuts, shortcut]); + }} + /> + ); + })} +
+
+ +
+ + +
+
+
+
+ ); +}; + +interface ShortcutItemProps { + shortcut: Shortcut; + className?: string; + onClick?: () => void; +} + +export const ShortcutItem = (props: ShortcutItemProps) => { + const { shortcut, className, onClick } = props; + const { sm } = useResponsiveWidth(); + const favicon = getFaviconWithGoogleS2(shortcut.link); + + return ( +
+ + {favicon ? ( + + ) : ( + + )} + +
+
+ +
+ {shortcut.title} + {shortcut.title ? ( + (s/{shortcut.name}) + ) : ( + <> + s/ + {shortcut.name} + + )} +
+
+
+
+ + + +
+ ); +}; + +export default CreateCollectionDialog; diff --git a/frontend/web/src/components/CreateShortcutDialog.tsx b/frontend/web/src/components/CreateShortcutDialog.tsx index 70ec944..424897e 100644 --- a/frontend/web/src/components/CreateShortcutDialog.tsx +++ b/frontend/web/src/components/CreateShortcutDialog.tsx @@ -201,7 +201,7 @@ const CreateShortcutDialog: React.FC = (props: Props) => { return ( -
+
{isCreating ? "Create Shortcut" : "Edit Shortcut"} + } + actionsClassName="!w-36 -left-4" + actions={ + <> + + Shortcuts + + + Collections + + + } + > + + )}
{ - const { t } = useTranslation(); - const viewStore = useViewStore(); - const { shortcutList } = useAppSelector((state) => state.shortcut); - const tags = shortcutList.map((shortcut) => shortcut.tags).flat(); - const currentTab = viewStore.filter.tab || `tab:all`; - const sortedTagMap = sortTags(tags); - - return ( -
- - - {Array.from(sortedTagMap.keys()).map((tag) => ( - - ))} -
- ); -}; - -const sortTags = (tags: string[]): Map => { - const map = new Map(); - for (const tag of tags) { - const count = map.get(tag) || 0; - map.set(tag, count + 1); - } - const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1])); - return sortedMap; + return <>; }; export default Navigator; diff --git a/frontend/web/src/components/ShortcutCard.tsx b/frontend/web/src/components/ShortcutCard.tsx index 7b9b1aa..4b287fe 100644 --- a/frontend/web/src/components/ShortcutCard.tsx +++ b/frontend/web/src/components/ShortcutCard.tsx @@ -14,7 +14,7 @@ interface Props { shortcut: Shortcut; } -const ShortcutView = (props: Props) => { +const ShortcutCard = (props: Props) => { const { shortcut } = props; const { t } = useTranslation(); const viewStore = useViewStore(); @@ -127,4 +127,4 @@ const ShortcutView = (props: Props) => { ); }; -export default ShortcutView; +export default ShortcutCard; diff --git a/frontend/web/src/components/ShortcutView.tsx b/frontend/web/src/components/ShortcutView.tsx index ddc0de7..51be104 100644 --- a/frontend/web/src/components/ShortcutView.tsx +++ b/frontend/web/src/components/ShortcutView.tsx @@ -6,10 +6,11 @@ import ShortcutActionsDropdown from "./ShortcutActionsDropdown"; interface Props { shortcut: Shortcut; + className?: string; } const ShortcutView = (props: Props) => { - const { shortcut } = props; + const { shortcut, className } = props; const shortcutLink = absolutifyLink(`/s/${shortcut.name}`); const favicon = getFaviconWithGoogleS2(shortcut.link); @@ -17,7 +18,8 @@ const ShortcutView = (props: Props) => { <>
diff --git a/frontend/web/src/components/ShortcutsNavigator.tsx b/frontend/web/src/components/ShortcutsNavigator.tsx new file mode 100644 index 0000000..b61c701 --- /dev/null +++ b/frontend/web/src/components/ShortcutsNavigator.tsx @@ -0,0 +1,70 @@ +import classNames from "classnames"; +import { useTranslation } from "react-i18next"; +import { useAppSelector } from "../stores"; +import useViewStore from "../stores/v1/view"; +import Icon from "./Icon"; + +const ShortcutsNavigator = () => { + const { t } = useTranslation(); + const viewStore = useViewStore(); + const { shortcutList } = useAppSelector((state) => state.shortcut); + const tags = shortcutList.map((shortcut) => shortcut.tags).flat(); + const currentTab = viewStore.filter.tab || `tab:all`; + const sortedTagMap = sortTags(tags); + + return ( +
+ + + {Array.from(sortedTagMap.keys()).map((tag) => ( + + ))} +
+ ); +}; + +const sortTags = (tags: string[]): Map => { + const map = new Map(); + for (const tag of tags) { + const count = map.get(tag) || 0; + map.set(tag, count + 1); + } + const sortedMap = new Map([...map.entries()].sort((a, b) => b[1] - a[1])); + return sortedMap; +}; + +export default ShortcutsNavigator; diff --git a/frontend/web/src/hooks/useResponsiveWidth.ts b/frontend/web/src/hooks/useResponsiveWidth.ts new file mode 100644 index 0000000..a09b650 --- /dev/null +++ b/frontend/web/src/hooks/useResponsiveWidth.ts @@ -0,0 +1,20 @@ +/* eslint-disable no-unused-vars */ +import useWindowSize from "react-use/lib/useWindowSize"; + +enum TailwindResponsiveWidth { + sm = 640, + md = 768, + lg = 1024, +} + +const useResponsiveWidth = () => { + const { width } = useWindowSize(); + + return { + sm: width >= TailwindResponsiveWidth.sm, + md: width >= TailwindResponsiveWidth.md, + lg: width >= TailwindResponsiveWidth.lg, + }; +}; + +export default useResponsiveWidth; diff --git a/frontend/web/src/layouts/Root.tsx b/frontend/web/src/layouts/Root.tsx index e479627..02b069e 100644 --- a/frontend/web/src/layouts/Root.tsx +++ b/frontend/web/src/layouts/Root.tsx @@ -3,6 +3,7 @@ import { isEqual } from "lodash-es"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Outlet } from "react-router-dom"; +import Navigator from "@/components/Navigator"; import useNavigateTo from "@/hooks/useNavigateTo"; import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service"; import Header from "../components/Header"; @@ -54,6 +55,7 @@ const Root: React.FC = () => { {isInitialized && (
+
)} diff --git a/frontend/web/src/pages/CollectionDashboard.tsx b/frontend/web/src/pages/CollectionDashboard.tsx new file mode 100644 index 0000000..fb67b07 --- /dev/null +++ b/frontend/web/src/pages/CollectionDashboard.tsx @@ -0,0 +1,79 @@ +import { Button } from "@mui/joy"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import CollectionView from "@/components/CollectionView"; +import CreateCollectionDialog from "@/components/CreateCollectionDialog"; +import { shortcutService } from "@/services"; +import useCollectionStore from "@/stores/v1/collection"; +import FilterView from "../components/FilterView"; +import Icon from "../components/Icon"; +import useLoading from "../hooks/useLoading"; + +interface State { + showCreateCollectionDialog: boolean; +} + +const CollectionDashboard: React.FC = () => { + const { t } = useTranslation(); + const loadingState = useLoading(); + const collectionStore = useCollectionStore(); + const collections = collectionStore.getCollectionList(); + const [state, setState] = useState({ + showCreateCollectionDialog: false, + }); + + useEffect(() => { + Promise.all([shortcutService.getMyAllShortcuts(), collectionStore.fetchCollectionList()]).finally(() => { + loadingState.setFinish(); + }); + }, []); + + const setShowCreateCollectionDialog = (show: boolean) => { + setState({ + ...state, + showCreateCollectionDialog: show, + }); + }; + + return ( + <> +
+
+
+ +
+
+ + {loadingState.isLoading ? ( +
+ + {t("common.loading")} +
+ ) : collections.length === 0 ? ( +
+ +

No collections found.

+
+ ) : ( +
+ {collections.map((collection) => { + return ; + })} +
+ )} +
+ + {state.showCreateCollectionDialog && ( + setShowCreateCollectionDialog(false)} + onConfirm={() => setShowCreateCollectionDialog(false)} + /> + )} + + ); +}; + +export default CollectionDashboard; diff --git a/frontend/web/src/pages/CollectionSpace.tsx b/frontend/web/src/pages/CollectionSpace.tsx new file mode 100644 index 0000000..7f49379 --- /dev/null +++ b/frontend/web/src/pages/CollectionSpace.tsx @@ -0,0 +1,119 @@ +import { Divider } from "@mui/joy"; +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import { Link, useParams } from "react-router-dom"; +import { ShortcutItem } from "@/components/CreateCollectionDialog"; +import Icon from "@/components/Icon"; +import useResponsiveWidth from "@/hooks/useResponsiveWidth"; +import useCollectionStore from "@/stores/v1/collection"; +import useShortcutStore from "@/stores/v1/shortcut"; +import { Collection } from "@/types/proto/api/v2/collection_service"; +import { Shortcut } from "@/types/proto/api/v2/shortcut_service"; +import { convertShortcutFromPb } from "@/utils/shortcut"; + +const CollectionSpace = () => { + const { collectionName } = useParams(); + const { sm } = useResponsiveWidth(); + const collectionStore = useCollectionStore(); + const shortcutStore = useShortcutStore(); + const [collection, setCollection] = useState(); + const [shortcuts, setShortcuts] = useState([]); + const [selectedShortcut, setSelectedShortcut] = useState(); + + if (!collectionName) { + return null; + } + + useEffect(() => { + (async () => { + const collection = await collectionStore.fetchCollectionByName(collectionName); + setCollection(collection); + setShortcuts([]); + for (const shortcutId of collection.shortcutIds) { + try { + const shortcut = await shortcutStore.getOrFetchShortcutById(shortcutId); + setShortcuts((shortcuts) => { + return [...shortcuts, shortcut]; + }); + } catch (error) { + // do nth + } + } + })(); + }, [collectionName]); + + if (!collection) { + return null; + } + + const handleShortcutClick = (shortcut: Shortcut) => { + if (sm) { + setSelectedShortcut(shortcut); + } else { + window.open(`/s/${shortcut.name}`); + } + }; + + return ( +
+
+
+
+
+ + {collection.title} +
+

{collection.description}

+ +
+
+ {shortcuts.map((shortcut) => { + return ( + handleShortcutClick(shortcut)} + /> + ); + })} +
+
+ {sm && ( +
+ {selectedShortcut ? ( +
+ + +

{selectedShortcut.title || selectedShortcut.name}

+

{selectedShortcut.description}

+ +

+ Open this site in a new tab + +

+ +
+ ) : ( +
+
+ +

Click on a tab in the Sidebar to get started.

+
+
+ )} +
+ )} +
+
+ ); +}; + +export default CollectionSpace; diff --git a/frontend/web/src/pages/Home.tsx b/frontend/web/src/pages/Home.tsx index 80b4483..85397d3 100644 --- a/frontend/web/src/pages/Home.tsx +++ b/frontend/web/src/pages/Home.tsx @@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next"; import CreateShortcutDialog from "../components/CreateShortcutDialog"; import FilterView from "../components/FilterView"; import Icon from "../components/Icon"; -import Navigator from "../components/Navigator"; import ShortcutsContainer from "../components/ShortcutsContainer"; +import ShortcutsNavigator from "../components/ShortcutsNavigator"; import ViewSetting from "../components/ViewSetting"; import useLoading from "../hooks/useLoading"; import { shortcutService } from "../services"; @@ -46,7 +46,7 @@ const Home: React.FC = () => { return ( <>
- +