mirror of
https://github.com/aykhans/slash-e.git
synced 2025-04-20 22:07:15 +00:00
feat: add collection views
This commit is contained in:
parent
626b0df21c
commit
83970d5d55
101
frontend/web/src/components/CollectionView.tsx
Normal file
101
frontend/web/src/components/CollectionView.tsx
Normal 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;
|
302
frontend/web/src/components/CreateCollectionDialog.tsx
Normal file
302
frontend/web/src/components/CreateCollectionDialog.tsx
Normal 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;
|
@ -201,7 +201,7 @@ const CreateShortcutDialog: React.FC<Props> = (props: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal open={true}>
|
||||||
<ModalDialog>
|
<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>
|
<span className="text-lg font-medium">{isCreating ? "Create Shortcut" : "Edit Shortcut"}</span>
|
||||||
<Button variant="plain" onClick={onClose}>
|
<Button variant="plain" onClick={onClose}>
|
||||||
<Icon.X className="w-5 h-auto text-gray-600" />
|
<Icon.X className="w-5 h-auto text-gray-600" />
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Avatar } from "@mui/joy";
|
import { Avatar } from "@mui/joy";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 useWorkspaceStore from "@/stores/v1/workspace";
|
||||||
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
import { PlanType } from "@/types/proto/api/v2/subscription_service";
|
||||||
import * as api from "../helpers/api";
|
import * as api from "../helpers/api";
|
||||||
@ -12,11 +12,13 @@ import Dropdown from "./common/Dropdown";
|
|||||||
|
|
||||||
const Header: React.FC = () => {
|
const Header: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const location = useLocation();
|
||||||
const workspaceStore = useWorkspaceStore();
|
const workspaceStore = useWorkspaceStore();
|
||||||
const currentUser = useUserStore().getCurrentUser();
|
const currentUser = useUserStore().getCurrentUser();
|
||||||
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
const [showAboutDialog, setShowAboutDialog] = useState<boolean>(false);
|
||||||
const profile = workspaceStore.profile;
|
const profile = workspaceStore.profile;
|
||||||
const isAdmin = currentUser.role === "ADMIN";
|
const isAdmin = currentUser.role === "ADMIN";
|
||||||
|
const shouldShowRouterSwitch = location.pathname === "/" || location.pathname === "/collections";
|
||||||
|
|
||||||
const handleSignOutButtonClick = async () => {
|
const handleSignOutButtonClick = async () => {
|
||||||
await api.signout();
|
await api.signout();
|
||||||
@ -26,10 +28,10 @@ const Header: React.FC = () => {
|
|||||||
return (
|
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 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">
|
<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">
|
<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-8 h-auto mr-2 -mt-0.5 dark:opacity-80 rounded-full shadow" alt="" />
|
<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
|
Slash
|
||||||
</Link>
|
</Link>
|
||||||
{profile.plan === PlanType.PRO && (
|
{profile.plan === PlanType.PRO && (
|
||||||
@ -37,6 +39,36 @@ const Header: React.FC = () => {
|
|||||||
PRO
|
PRO
|
||||||
</span>
|
</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>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
@ -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 Navigator = () => {
|
||||||
const { t } = useTranslation();
|
return <></>;
|
||||||
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 Navigator;
|
export default Navigator;
|
||||||
|
@ -14,7 +14,7 @@ interface Props {
|
|||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
const ShortcutCard = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const viewStore = useViewStore();
|
const viewStore = useViewStore();
|
||||||
@ -127,4 +127,4 @@ const ShortcutView = (props: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShortcutView;
|
export default ShortcutCard;
|
||||||
|
@ -6,10 +6,11 @@ import ShortcutActionsDropdown from "./ShortcutActionsDropdown";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
shortcut: Shortcut;
|
shortcut: Shortcut;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutView = (props: Props) => {
|
const ShortcutView = (props: Props) => {
|
||||||
const { shortcut } = props;
|
const { shortcut, className } = props;
|
||||||
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
const shortcutLink = absolutifyLink(`/s/${shortcut.name}`);
|
||||||
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
const favicon = getFaviconWithGoogleS2(shortcut.link);
|
||||||
|
|
||||||
@ -17,7 +18,8 @@ const ShortcutView = (props: Props) => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
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">
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
70
frontend/web/src/components/ShortcutsNavigator.tsx
Normal file
70
frontend/web/src/components/ShortcutsNavigator.tsx
Normal 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;
|
20
frontend/web/src/hooks/useResponsiveWidth.ts
Normal file
20
frontend/web/src/hooks/useResponsiveWidth.ts
Normal file
@ -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;
|
@ -3,6 +3,7 @@ import { isEqual } from "lodash-es";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import Navigator from "@/components/Navigator";
|
||||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||||
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
import { UserSetting_ColorTheme, UserSetting_Locale } from "@/types/proto/api/v2/user_setting_service";
|
||||||
import Header from "../components/Header";
|
import Header from "../components/Header";
|
||||||
@ -54,6 +55,7 @@ const Root: React.FC = () => {
|
|||||||
{isInitialized && (
|
{isInitialized && (
|
||||||
<div className="w-full h-auto flex flex-col justify-start items-start dark:bg-zinc-900">
|
<div className="w-full h-auto flex flex-col justify-start items-start dark:bg-zinc-900">
|
||||||
<Header />
|
<Header />
|
||||||
|
<Navigator />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
79
frontend/web/src/pages/CollectionDashboard.tsx
Normal file
79
frontend/web/src/pages/CollectionDashboard.tsx
Normal file
@ -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<State>({
|
||||||
|
showCreateCollectionDialog: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([shortcutService.getMyAllShortcuts(), collectionStore.fetchCollectionList()]).finally(() => {
|
||||||
|
loadingState.setFinish();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setShowCreateCollectionDialog = (show: boolean) => {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
showCreateCollectionDialog: show,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
|
<div className="flex flex-row justify-start items-center">
|
||||||
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateCollectionDialog(true)}>
|
||||||
|
<Icon.Plus className="w-5 h-auto" />
|
||||||
|
<span className="ml-0.5">{t("common.create")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FilterView />
|
||||||
|
{loadingState.isLoading ? (
|
||||||
|
<div className="py-12 w-full flex flex-row justify-center items-center opacity-80 dark:text-gray-500">
|
||||||
|
<Icon.Loader className="mr-2 w-5 h-auto animate-spin" />
|
||||||
|
{t("common.loading")}
|
||||||
|
</div>
|
||||||
|
) : collections.length === 0 ? (
|
||||||
|
<div className="py-16 w-full flex flex-col justify-center items-center text-gray-400">
|
||||||
|
<Icon.PackageOpen className="w-16 h-auto" strokeWidth="1" />
|
||||||
|
<p className="mt-4">No collections found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full flex flex-col justify-start items-start gap-3">
|
||||||
|
{collections.map((collection) => {
|
||||||
|
return <CollectionView key={collection.id} collection={collection} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.showCreateCollectionDialog && (
|
||||||
|
<CreateCollectionDialog
|
||||||
|
onClose={() => setShowCreateCollectionDialog(false)}
|
||||||
|
onConfirm={() => setShowCreateCollectionDialog(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionDashboard;
|
119
frontend/web/src/pages/CollectionSpace.tsx
Normal file
119
frontend/web/src/pages/CollectionSpace.tsx
Normal file
@ -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<Collection>();
|
||||||
|
const [shortcuts, setShortcuts] = useState<Shortcut[]>([]);
|
||||||
|
const [selectedShortcut, setSelectedShortcut] = useState<Shortcut>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="w-full h-full sm:px-12 sm:py-10 sm:h-screen sm:bg-gray-100 dark:sm:bg-zinc-800">
|
||||||
|
<div className="w-full h-full flex flex-row sm:border dark:sm:border-zinc-800 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900">
|
||||||
|
<div className="w-full sm:w-56 sm:pr-4 flex flex-col justify-start items-start overflow-auto shrink-0">
|
||||||
|
<div className="w-full sticky top-0 bg-gray-50 dark:bg-zinc-900">
|
||||||
|
<div className="w-full flex flex-row justify-start items-center text-gray-800 dark:text-gray-300">
|
||||||
|
<Icon.LibrarySquare className="w-5 h-auto mr-2 opacity-70" />
|
||||||
|
<span className="text-lg">{collection.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm">{collection.description}</p>
|
||||||
|
<Divider className="!my-2" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex flex-col justify-start items-start gap-1">
|
||||||
|
{shortcuts.map((shortcut) => {
|
||||||
|
return (
|
||||||
|
<ShortcutItem
|
||||||
|
className={classNames(
|
||||||
|
"w-full py-2",
|
||||||
|
selectedShortcut?.id === shortcut.id ? "bg-gray-100 dark:bg-zinc-800" : "border-transparent dark:border-transparent"
|
||||||
|
)}
|
||||||
|
key={shortcut.name}
|
||||||
|
shortcut={convertShortcutFromPb(shortcut)}
|
||||||
|
onClick={() => handleShortcutClick(shortcut)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{sm && (
|
||||||
|
<div className="w-full h-full overflow-clip rounded-lg border dark:border-zinc-800 bg-white dark:bg-zinc-800">
|
||||||
|
{selectedShortcut ? (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center p-8">
|
||||||
|
<Link
|
||||||
|
className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 p-6 rounded-2xl shadow-xl dark:text-gray-400 hover:opacity-80"
|
||||||
|
to={`/s/${selectedShortcut.name}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<Icon.Globe2Icon className="w-12 h-auto mb-1" strokeWidth={1} />
|
||||||
|
<p className="text-lg font-medium leading-8">{selectedShortcut.title || selectedShortcut.name}</p>
|
||||||
|
<p className="text-gray-500">{selectedShortcut.description}</p>
|
||||||
|
<Divider className="!my-2" />
|
||||||
|
<p className="text-gray-400 dark:text-gray-600 text-sm mt-2">
|
||||||
|
<span className="leading-4">Open this site in a new tab</span>
|
||||||
|
<Icon.ArrowUpRight className="inline-block ml-1 -mt-0.5 w-4 h-auto" />
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center items-center p-8">
|
||||||
|
<div className="w-72 max-w-full border dark:border-zinc-900 dark:bg-zinc-900 dark:text-gray-400 p-6 rounded-2xl shadow-xl">
|
||||||
|
<Icon.AppWindow className="w-12 h-auto mb-2" strokeWidth={1} />
|
||||||
|
<p className="text-lg font-medium">Click on a tab in the Sidebar to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CollectionSpace;
|
@ -4,8 +4,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
import CreateShortcutDialog from "../components/CreateShortcutDialog";
|
||||||
import FilterView from "../components/FilterView";
|
import FilterView from "../components/FilterView";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
import Navigator from "../components/Navigator";
|
|
||||||
import ShortcutsContainer from "../components/ShortcutsContainer";
|
import ShortcutsContainer from "../components/ShortcutsContainer";
|
||||||
|
import ShortcutsNavigator from "../components/ShortcutsNavigator";
|
||||||
import ViewSetting from "../components/ViewSetting";
|
import ViewSetting from "../components/ViewSetting";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import { shortcutService } from "../services";
|
import { shortcutService } from "../services";
|
||||||
@ -46,7 +46,7 @@ const Home: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
<div className="mx-auto max-w-8xl w-full px-3 md:px-12 pt-4 pb-6 flex flex-col justify-start items-start">
|
||||||
<Navigator />
|
<ShortcutsNavigator />
|
||||||
<div className="w-full flex flex-row justify-between items-center mb-4">
|
<div className="w-full flex flex-row justify-between items-center mb-4">
|
||||||
<div className="flex flex-row justify-start items-center">
|
<div className="flex flex-row justify-start items-center">
|
||||||
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
<Button className="hover:shadow" variant="soft" size="sm" onClick={() => setShowCreateShortcutDialog(true)}>
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom";
|
import { createBrowserRouter } from "react-router-dom";
|
||||||
|
import CollectionDashboard from "@/pages/CollectionDashboard";
|
||||||
|
import CollectionSpace from "@/pages/CollectionSpace";
|
||||||
import NotFound from "@/pages/NotFound";
|
import NotFound from "@/pages/NotFound";
|
||||||
import SignIn from "@/pages/SignIn";
|
import SignIn from "@/pages/SignIn";
|
||||||
import SignUp from "@/pages/SignUp";
|
import SignUp from "@/pages/SignUp";
|
||||||
@ -17,11 +19,11 @@ const router = createBrowserRouter([
|
|||||||
element: <App />,
|
element: <App />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "auth",
|
path: "/auth",
|
||||||
element: <SignIn />,
|
element: <SignIn />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "auth/signup",
|
path: "/auth/signup",
|
||||||
element: <SignUp />,
|
element: <SignUp />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -29,9 +31,13 @@ const router = createBrowserRouter([
|
|||||||
element: <Root />,
|
element: <Root />,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "/",
|
||||||
element: <Home />,
|
element: <Home />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/collections",
|
||||||
|
element: <CollectionDashboard />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/shortcut/:shortcutId",
|
path: "/shortcut/:shortcutId",
|
||||||
element: <ShortcutDetail />,
|
element: <ShortcutDetail />,
|
||||||
@ -54,6 +60,10 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "c/:collectionName",
|
||||||
|
element: <CollectionSpace />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "*",
|
path: "*",
|
||||||
element: <NotFound />,
|
element: <NotFound />,
|
||||||
|
104
frontend/web/src/stores/v1/collection.ts
Normal file
104
frontend/web/src/stores/v1/collection.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { collectionServiceClient } from "@/grpcweb";
|
||||||
|
import { Collection } from "@/types/proto/api/v2/collection_service";
|
||||||
|
|
||||||
|
interface CollectionState {
|
||||||
|
collectionMapById: Record<number, Collection>;
|
||||||
|
fetchCollectionList: () => Promise<Collection[]>;
|
||||||
|
getOrFetchCollectionById: (id: number) => Promise<Collection>;
|
||||||
|
getCollectionById: (id: number) => Collection;
|
||||||
|
getCollectionList: () => Collection[];
|
||||||
|
fetchCollectionByName: (collectionName: string) => Promise<Collection>;
|
||||||
|
createCollection: (collection: Collection) => Promise<Collection>;
|
||||||
|
updateCollection: (collection: Partial<Collection>, updateMask: string[]) => Promise<Collection>;
|
||||||
|
deleteCollection: (id: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCollectionStore = create<CollectionState>()((set, get) => ({
|
||||||
|
collectionMapById: {},
|
||||||
|
fetchCollectionList: async () => {
|
||||||
|
const { collections } = await collectionServiceClient.listCollections({});
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
collections.forEach((collection) => {
|
||||||
|
collectionMap[collection.id] = collection;
|
||||||
|
});
|
||||||
|
set(collectionMap);
|
||||||
|
return collections;
|
||||||
|
},
|
||||||
|
getOrFetchCollectionById: async (id: number) => {
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
if (collectionMap[id]) {
|
||||||
|
return collectionMap[id] as Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collection } = await collectionServiceClient.getCollection({
|
||||||
|
id: id,
|
||||||
|
});
|
||||||
|
if (!collection) {
|
||||||
|
throw new Error(`Collection with id ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionMap[id] = collection;
|
||||||
|
set(collectionMap);
|
||||||
|
return collection;
|
||||||
|
},
|
||||||
|
getCollectionById: (id: number) => {
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
return collectionMap[id] as Collection;
|
||||||
|
},
|
||||||
|
getCollectionList: () => {
|
||||||
|
return Object.values(get().collectionMapById);
|
||||||
|
},
|
||||||
|
fetchCollectionByName: async (collectionName: string) => {
|
||||||
|
const { collection } = await collectionServiceClient.getCollectionByName({
|
||||||
|
name: collectionName,
|
||||||
|
});
|
||||||
|
if (!collection) {
|
||||||
|
throw new Error(`Collection with name ${collectionName} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
collectionMap[collection.id] = collection;
|
||||||
|
set(collectionMap);
|
||||||
|
return collection;
|
||||||
|
},
|
||||||
|
createCollection: async (collection: Collection) => {
|
||||||
|
const { collection: createdCollection } = await collectionServiceClient.createCollection({
|
||||||
|
collection: collection,
|
||||||
|
});
|
||||||
|
if (!createdCollection) {
|
||||||
|
throw new Error(`Failed to create collection`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
collectionMap[createdCollection.id] = createdCollection;
|
||||||
|
set(collectionMap);
|
||||||
|
return createdCollection;
|
||||||
|
},
|
||||||
|
updateCollection: async (collection: Partial<Collection>, updateMask: string[]) => {
|
||||||
|
const { collection: updatedCollection } = await collectionServiceClient.updateCollection({
|
||||||
|
collection: collection,
|
||||||
|
updateMask: updateMask,
|
||||||
|
});
|
||||||
|
if (!updatedCollection) {
|
||||||
|
throw new Error("Collection not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("updatedCollection", updatedCollection);
|
||||||
|
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
collectionMap[updatedCollection.id] = updatedCollection;
|
||||||
|
set(collectionMap);
|
||||||
|
return updatedCollection;
|
||||||
|
},
|
||||||
|
deleteCollection: async (id: number) => {
|
||||||
|
await collectionServiceClient.deleteCollection({
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
const collectionMap = get().collectionMapById;
|
||||||
|
delete collectionMap[id];
|
||||||
|
set(collectionMap);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useCollectionStore;
|
15
frontend/web/src/utils/shortcut.ts
Normal file
15
frontend/web/src/utils/shortcut.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { Shortcut as ShortcutPb } from "@/types/proto/api/v2/shortcut_service";
|
||||||
|
|
||||||
|
export const convertShortcutFromPb = (shortcutMessage: ShortcutPb): Shortcut => {
|
||||||
|
return {
|
||||||
|
id: shortcutMessage.id,
|
||||||
|
creatorId: shortcutMessage.creatorId,
|
||||||
|
createdTs: shortcutMessage.createdTime?.getTime() || 0,
|
||||||
|
updatedTs: shortcutMessage.updatedTime?.getTime() || 0,
|
||||||
|
name: shortcutMessage.name,
|
||||||
|
link: shortcutMessage.link,
|
||||||
|
title: shortcutMessage.title,
|
||||||
|
description: shortcutMessage.description,
|
||||||
|
tags: shortcutMessage.tags,
|
||||||
|
} as Shortcut;
|
||||||
|
};
|
13
frontend/web/src/utils/visibility.ts
Normal file
13
frontend/web/src/utils/visibility.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Visibility } from "@/types/proto/api/v2/common";
|
||||||
|
|
||||||
|
export const convertVisibilityFromPb = (visibility: Visibility): string => {
|
||||||
|
if (visibility === Visibility.PRIVATE) {
|
||||||
|
return "PRIVATE";
|
||||||
|
} else if (visibility === Visibility.WORKSPACE) {
|
||||||
|
return "WORKSPACE";
|
||||||
|
} else if (visibility === Visibility.PUBLIC) {
|
||||||
|
return "PUBLIC";
|
||||||
|
} else {
|
||||||
|
return "PRIVATE";
|
||||||
|
}
|
||||||
|
};
|
@ -3,9 +3,6 @@ module.exports = {
|
|||||||
content: ["./index.html", "./src/**/*.{js,ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,tsx}"],
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
theme: {
|
theme: {
|
||||||
maxWidth: {
|
|
||||||
"8xl": "88rem",
|
|
||||||
},
|
|
||||||
fontSize: {
|
fontSize: {
|
||||||
xs: ".75rem",
|
xs: ".75rem",
|
||||||
sm: ".875rem",
|
sm: ".875rem",
|
||||||
@ -17,6 +14,9 @@ module.exports = {
|
|||||||
"4xl": "2.25rem",
|
"4xl": "2.25rem",
|
||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
|
maxWidth: {
|
||||||
|
"8xl": "88rem",
|
||||||
|
},
|
||||||
spacing: {
|
spacing: {
|
||||||
112: "28rem",
|
112: "28rem",
|
||||||
128: "32rem",
|
128: "32rem",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user