feat: add collection views

This commit is contained in:
Steven
2023-11-12 10:47:49 +08:00
parent 626b0df21c
commit 83970d5d55
18 changed files with 887 additions and 83 deletions

View File

@ -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<boolean>(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 (
<>
<div className={classNames("w-full flex flex-col justify-start items-start border rounded-lg hover:shadow dark:border-zinc-800")}>
<div className="bg-gray-100 dark:bg-zinc-800 px-3 py-2 w-full flex flex-row justify-between items-center rounded-t-lg">
<div className="w-auto flex flex-row justify-start items-center mr-1">
<div className="truncate">
<span className="dark:text-gray-400">{collection.title}</span>
</div>
</div>
<div className="flex flex-row justify-end items-center shrink-0">
{collection.visibility !== Visibility.PRIVATE && (
<Link className="w-full text-gray-400 cursor-pointer hover:text-gray-500" to={`/c/${collection.name}`}>
<Icon.Share className="w-4 h-auto mr-2" />
</Link>
)}
<Dropdown
actionsClassName="!w-28 dark:text-gray-500"
actions={
<>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => setShowEditDialog(true)}
>
<Icon.Edit className="w-4 h-auto mr-2" /> {t("common.edit")}
</button>
<button
className="w-full px-2 flex flex-row justify-start items-center text-left leading-8 cursor-pointer rounded text-red-600 hover:bg-gray-100 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60 dark:hover:bg-zinc-800"
onClick={() => {
handleDeleteCollectionButtonClick();
}}
>
<Icon.Trash className="w-4 h-auto mr-2" /> {t("common.delete")}
</button>
</>
}
></Dropdown>
</div>
</div>
<div className="w-full p-3 flex flex-row justify-start items-start flex-wrap gap-3">
{shortcuts.map((shortcut) => {
return <ShortcutItem key={shortcut.id} shortcut={shortcut} onClick={() => handleShortcutClick(shortcut)} />;
})}
</div>
</div>
{showEditDialog && (
<CreateCollectionDialog
collectionId={collection.id}
onClose={() => setShowEditDialog(false)}
onConfirm={() => setShowEditDialog(false)}
/>
)}
</>
);
};
export default CollectionView;

View File

@ -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: Props) => {
const { onClose, onConfirm, collectionId } = props;
const { t } = useTranslation();
const collectionStore = useCollectionStore();
const { shortcutList } = useAppSelector((state) => state.shortcut);
const [state, setState] = useState<State>({
collectionCreate: Collection.fromPartial({
visibility: Visibility.PRIVATE,
}),
});
const [selectedShortcuts, setSelectedShortcuts] = useState<Shortcut[]>([]);
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<State>) => {
setState({
...state,
...partialState,
});
};
const handleNameInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
name: e.target.value.replace(/\s+/g, "-"),
}),
});
};
const handleTitleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
title: e.target.value,
}),
});
};
const handleVisibilityInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPartialState({
collectionCreate: Object.assign(state.collectionCreate, {
visibility: Number(e.target.value),
}),
});
};
const handleDescriptionInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Modal open={true}>
<ModalDialog>
<div className="w-full flex flex-row justify-between items-center">
<span className="text-lg font-medium">{isCreating ? "Create Collection" : "Edit Collection"}</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden w-80 sm:w-96 max-w-full">
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Name</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Unique collection name"
value={state.collectionCreate.name}
onChange={handleNameInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Title</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Title"
value={state.collectionCreate.title}
onChange={handleTitleInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Description</span>
<div className="relative w-full">
<Input
className="w-full"
type="text"
placeholder="Description"
value={state.collectionCreate.description}
onChange={handleDescriptionInputChange}
/>
</div>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<span className="mb-2">Visibility</span>
<div className="w-full flex flex-row justify-start items-center text-base">
<RadioGroup orientation="horizontal" value={state.collectionCreate.visibility} onChange={handleVisibilityInputChange}>
<Radio value={Visibility.PRIVATE} label={t(`shortcut.visibility.private.self`)} />
<Radio value={Visibility.PUBLIC} label={t(`shortcut.visibility.public.self`)} />
</RadioGroup>
</div>
<p className="mt-3 text-sm text-gray-500 w-full bg-gray-100 border border-gray-200 dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400 px-2 py-1 rounded-md">
{t(`shortcut.visibility.${convertVisibilityFromPb(state.collectionCreate.visibility).toLowerCase()}.description`)}
</p>
</div>
<div className="w-full flex flex-col justify-start items-start mb-3">
<p className="mb-2">
<span>Shortcuts</span>
<span className="opacity-60">({selectedShortcuts.length})</span>
{selectedShortcuts.length === 0 && <span className="ml-2 italic opacity-80 text-sm">Select a shortcut first</span>}
</p>
<div className="w-full py-1 flex flex-row justify-start items-start flex-wrap overflow-hidden gap-2">
{selectedShortcuts.map((shortcut) => {
return (
<ShortcutItem
key={shortcut.id}
className="bg-gray-100 shadow dark:bg-zinc-800 dark:border-zinc-700 dark:text-gray-400"
shortcut={shortcut}
onClick={() => {
setSelectedShortcuts([...selectedShortcuts.filter((selectedShortcut) => selectedShortcut.id !== shortcut.id)]);
}}
/>
);
})}
{unselectedShortcuts.map((shortcut) => {
return (
<ShortcutItem
key={shortcut.id}
className="border-dashed"
shortcut={shortcut}
onClick={() => {
setSelectedShortcuts([...selectedShortcuts, shortcut]);
}}
/>
);
})}
</div>
</div>
<div className="w-full flex flex-row justify-end items-center mt-4 space-x-2">
<Button color="neutral" variant="plain" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={onClose}>
{t("common.cancel")}
</Button>
<Button color="primary" disabled={requestState.isLoading} loading={requestState.isLoading} onClick={handleSaveBtnClick}>
{t("common.save")}
</Button>
</div>
</div>
</ModalDialog>
</Modal>
);
};
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 (
<div
className={classNames(
"group w-auto select-none px-2 py-1 flex flex-row justify-start items-center border rounded-lg hover:bg-gray-100 dark:border-zinc-800 dark:hover:bg-zinc-800 cursor-pointer",
className
)}
onClick={onClick}
>
<span className={classNames("w-5 h-5 flex justify-center items-center overflow-clip shrink-0")}>
{favicon ? (
<img className="w-full h-auto rounded-full" src={favicon} decoding="async" loading="lazy" />
) : (
<Icon.CircleSlash className="w-full h-auto text-gray-400" />
)}
</span>
<div className="ml-1 w-full flex flex-col justify-start items-start truncate">
<div className="w-full flex flex-row justify-start items-center">
<span className={classNames("max-w-full flex flex-row px-1 justify-start items-center rounded-md")}>
<div className="truncate">
<span className="dark:text-gray-400">{shortcut.title}</span>
{shortcut.title ? (
<span className="text-gray-500">(s/{shortcut.name})</span>
) : (
<>
<span className="text-gray-400 dark:text-gray-500">s/</span>
<span className="truncate dark:text-gray-400">{shortcut.name}</span>
</>
)}
</div>
</span>
</div>
</div>
<Link
className={classNames("w-6 h-6 p-1 rounded-lg bg-gray-200 dark:bg-zinc-900 hover:opacity-80", sm && "hidden group-hover:block")}
to={`/s/${shortcut.name}`}
target="_blank"
>
<Icon.ArrowUpRight className="w-4 h-auto text-gray-400 shrink-0" />
</Link>
</div>
);
};
export default CreateCollectionDialog;

View File

@ -201,7 +201,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
return (
<Modal open={true}>
<ModalDialog>
<div className="flex flex-row justify-between items-center w-80 sm:w-96 mb-4">
<div className="flex flex-row justify-between items-center w-80 sm:w-96">
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
<Button variant="plain" onClick={onClose}>
<Icon.X className="w-5 h-auto text-gray-600" />

View File

@ -1,7 +1,7 @@
import { Avatar } from "@mui/joy";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import useWorkspaceStore from "@/stores/v1/workspace";
import { PlanType } from "@/types/proto/api/v2/subscription_service";
import * as api from "../helpers/api";
@ -12,11 +12,13 @@ import Dropdown from "./common/Dropdown";
const Header: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const workspaceStore = useWorkspaceStore();
const currentUser = useUserStore().getCurrentUser();
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
const profile = workspaceStore.profile;
const isAdmin = currentUser.role === "ADMIN";
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections";
const handleSignOutButtonClick = async () => {
await api.signout();
@ -26,10 +28,10 @@ const Header: React.FC = () => {
return (
<>
<div className="w-full bg-gray-50 dark:bg-zinc-800 border-b border-b-gray-200 dark:border-b-zinc-800">
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-5 flex flex-row justify-between items-center">
<div className="w-full max-w-8xl mx-auto px-3 md:px-12 py-3 sm:py-5 flex flex-row justify-between items-center">
<div className="flex flex-row justify-start items-center shrink mr-2">
<Link to="/" className="text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
<img id="logo-img" src="/logo.png" className="w-8 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
<Link to="/" className="sm:text-lg cursor-pointer flex flex-row justify-start items-center dark:text-gray-400">
<img id="logo-img" src="/logo.png" className="w-6 sm:w-8 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
Slash
</Link>
{profile.plan === PlanType.PRO && (
@ -37,6 +39,36 @@ const Header: React.FC = () => {
PRO
</span>
)}
{shouldShowRouterSwitch && (
<>
<span className="font-mono opacity-60 mx-2">/</span>
<Dropdown
trigger={
<button className="sm:text-lg flex flex-row justify-end items-center cursor-pointer">
<span className="dark:text-gray-400">{location.pathname === "/" ? "Shortcuts" : "Collections"}</span>
<Icon.ChevronsUpDown className="ml-1 w-4 h-auto text-gray-600 dark:text-gray-400" />
</button>
}
actionsClassName="!w-36 -left-4"
actions={
<>
<Link
to="/"
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
>
<Icon.SquareSlash className="w-5 h-auto mr-2" /> Shortcuts
</Link>
<Link
to="/collections"
className="w-full px-2 flex flex-row justify-start items-center text-left dark:text-gray-400 leading-8 cursor-pointer rounded hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:opacity-60"
>
<Icon.LibrarySquare className="w-5 h-auto mr-2" /> Collections
</Link>
</>
}
></Dropdown>
</>
)}
</div>
<div className="relative flex-shrink-0">
<Dropdown

View File

@ -1,70 +1,5 @@
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "../stores";
import useViewStore from "../stores/v1/view";
import Icon from "./Icon";
const Navigator = () => {
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 (
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
>
<Icon.CircleSlash className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.all")}</span>
</button>
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
>
<Icon.User className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.mine")}</span>
</button>
{Array.from(sortedTagMap.keys()).map((tag) => (
<button
key={tag}
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}`
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
>
<Icon.Hash className="w-4 h-auto mr-0.5" />
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
</button>
))}
</div>
);
};
const sortTags = (tags: string[]): Map<string, number> => {
const map = new Map<string, number>();
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;

View File

@ -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;

View File

@ -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) => {
<>
<div
className={classNames(
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800"
"group w-full px-3 py-2 flex flex-col justify-start items-start border rounded-lg hover:bg-gray-100 hover:shadow dark:border-zinc-800 dark:hover:bg-zinc-800",
className
)}
>
<div className="w-full flex flex-row justify-between items-center">

View File

@ -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 (
<div className="w-full flex flex-row justify-start items-center mb-4 gap-1 sm:flex-wrap overflow-x-auto no-scrollbar">
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:all"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:all" })}
>
<Icon.CircleSlash className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.all")}</span>
</button>
<button
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === "tab:mine"
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: "tab:mine" })}
>
<Icon.User className="w-4 h-auto mr-1" />
<span className="font-normal">{t("filter.mine")}</span>
</button>
{Array.from(sortedTagMap.keys()).map((tag) => (
<button
key={tag}
className={classNames(
"flex flex-row justify-center items-center px-2 leading-7 text-sm dark:text-gray-400 rounded-md",
currentTab === `tag:${tag}`
? "bg-gray-600 dark:bg-zinc-700 text-white dark:text-gray-400 shadow"
: "hover:bg-gray-200 dark:hover:bg-zinc-700"
)}
onClick={() => viewStore.setFilter({ tab: `tag:${tag}`, tag: undefined })}
>
<Icon.Hash className="w-4 h-auto mr-0.5" />
<span className="max-w-[8rem] truncate font-normal">{tag}</span>
</button>
))}
</div>
);
};
const sortTags = (tags: string[]): Map<string, number> => {
const map = new Map<string, number>();
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;