Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(FE) Add Document Sharing #89

Merged
merged 4 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsDate } from "class-validator";
import { ShareRoleEnum } from "src/utils/constants/share-role";

export class CreateWorkspaceDocumentShareTokenDto {
Expand All @@ -9,6 +8,5 @@ export class CreateWorkspaceDocumentShareTokenDto {

@ApiProperty({ type: Date, description: "Share link expiration date" })
@Type(() => Date)
@IsDate()
expiredAt: Date;
}
23 changes: 23 additions & 0 deletions frontend/src/components/common/ShareButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IconButton } from "@mui/material";
import ShareIcon from "@mui/icons-material/Share";
import ShareModal from "../modals/ShareModal";
import { useState } from "react";

function ShareButton() {
const [shareModalOpen, setShareModalOpen] = useState(false);

const handleShareModalOpen = () => {
setShareModalOpen((prev) => !prev);
};

return (
<>
<IconButton onClick={handleShareModalOpen} color="inherit">
<ShareIcon />
</IconButton>
<ShareModal open={shareModalOpen} onClose={handleShareModalOpen} />
</>
);
}

export default ShareButton;
9 changes: 7 additions & 2 deletions frontend/src/components/editor/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import MarkdownPreview from "@uiw/react-markdown-preview";
import { useCurrentTheme } from "../../hooks/useCurrentTheme";
import { useSelector } from "react-redux";
import { selectEditor } from "../../store/editorSlice";
import { CircularProgress } from "@mui/material";
import { CircularProgress, Stack } from "@mui/material";
import { useEffect, useState } from "react";
import "./editor.css";

Expand All @@ -26,7 +26,12 @@ function Preview() {
};
}, [editorStore.doc]);

if (!editorStore?.doc) return <CircularProgress sx={{ marginX: "auto", mt: 4 }} />;
if (!editorStore?.doc)
return (
<Stack direction="row" justifyContent="center">
<CircularProgress sx={{ mt: 2 }} />
</Stack>
);

return (
<MarkdownPreview
Expand Down
73 changes: 36 additions & 37 deletions frontend/src/components/headers/EditorHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
AppBar,
IconButton,
Paper,
Stack,
ToggleButton,
Expand All @@ -11,62 +10,62 @@ import {
import EditIcon from "@mui/icons-material/Edit";
import VerticalSplitIcon from "@mui/icons-material/VerticalSplit";
import VisibilityIcon from "@mui/icons-material/Visibility";
import AddIcon from "@mui/icons-material/Add";
import { useDispatch, useSelector } from "react-redux";
import { EditorModeType, selectEditor, setMode } from "../../store/editorSlice";
import ThemeButton from "../common/ThemeButton";
import { createDocumentKey } from "../../utils/document";
import { useNavigate } from "react-router-dom";
import ShareButton from "../common/ShareButton";
import { useEffect } from "react";

function EditorHeader() {
const dispatch = useDispatch();
const editorState = useSelector(selectEditor);
const navigate = useNavigate();

useEffect(() => {
if (editorState.shareRole === "READ") {
dispatch(setMode("read"));
}
}, [dispatch, editorState.shareRole]);

const handleChangeMode = (newMode: EditorModeType) => {
dispatch(setMode(newMode));
};

const handleCreateNewDocument = () => {
navigate(`/${createDocumentKey()}`);
};

return (
<AppBar position="static" sx={{ zIndex: 100 }}>
<Toolbar>
<Stack width="100%" direction="row" justifyContent="space-between">
<Stack direction="row" spacing={1}>
<Paper>
<ToggleButtonGroup
value={editorState.mode}
exclusive
onChange={(_, newMode) => handleChangeMode(newMode)}
size="small"
>
<ToggleButton value="edit" aria-label="edit">
<Tooltip title="Edit Mode">
<EditIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="both" aria-label="both">
<Tooltip title="Both Mode">
<VerticalSplitIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="read" aria-label="read">
<Tooltip title="Read Mode">
<VisibilityIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{editorState.shareRole !== "READ" && (
<ToggleButtonGroup
value={editorState.mode}
exclusive
onChange={(_, newMode) => handleChangeMode(newMode)}
size="small"
>
<ToggleButton value="edit" aria-label="edit">
<Tooltip title="Edit Mode">
<EditIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="both" aria-label="both">
<Tooltip title="Both Mode">
<VerticalSplitIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="read" aria-label="read">
<Tooltip title="Read Mode">
<VisibilityIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
)}
</Paper>
<Tooltip title="Create New Note">
<IconButton color="inherit" onClick={handleCreateNewDocument}>
<AddIcon />
</IconButton>
</Tooltip>
</Stack>
<ThemeButton />
<Stack direction="row" alignItems="center" gap={1}>
{!editorState.shareRole && <ShareButton />}
<ThemeButton />
</Stack>
</Stack>
</Toolbar>
</AppBar>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/modals/MemberModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { useMemo, useState } from "react";
import { User } from "../../hooks/api/types/user";
import InfiniteScroll from "react-infinite-scroller";
import { FormContainer, SelectElement } from "react-hook-form-mui";
import { invitationExpiredStringList } from "../../utils/invitation";
import { invitationExpiredStringList } from "../../utils/expire";
import moment, { unitOfTime } from "moment";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import clipboard from "clipboardy";
Expand Down
152 changes: 152 additions & 0 deletions frontend/src/components/modals/ShareModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import {
Button,
FormControl,
IconButton,
Modal,
ModalProps,
Paper,
Stack,
Tooltip,
Typography,
} from "@mui/material";
import { FormContainer, SelectElement } from "react-hook-form-mui";
import { invitationExpiredStringList } from "../../utils/expire";
import { useState } from "react";
import moment, { unitOfTime } from "moment";
import { useParams } from "react-router";
import { useGetDocumentQuery } from "../../hooks/api/document";
import { useCreateWorkspaceSharingTokenMutation } from "../../hooks/api/workspaceDocument";
import { ShareRole } from "../../utils/share";
import clipboard from "clipboardy";
import { useSnackbar } from "notistack";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CloseIcon from "@mui/icons-material/Close";
import { useSearchParams } from "react-router-dom";

interface ShareModalProps extends Omit<ModalProps, "children"> {}

function ShareModal(props: ShareModalProps) {
const { ...modalProps } = props;
const params = useParams();
const [searchParams] = useSearchParams();
const [shareUrl, setShareUrl] = useState<string | null>(null);
const { data: document } = useGetDocumentQuery(
searchParams.get("token") ? null : params.documentSlug
);
const { mutateAsync: createWorkspaceSharingToken } = useCreateWorkspaceSharingTokenMutation(
document?.workspaceId || "",
document?.id || ""
);
const { enqueueSnackbar } = useSnackbar();

const handleCreateShareUrl = async (data: { expiredString: string; role: ShareRole }) => {
let addedTime: Date | null;

if (data.expiredString === invitationExpiredStringList[0]) {
addedTime = null;
} else {
const [num, unit] = data.expiredString.split(" ");
addedTime = moment()
.add(Number(num), unit as unitOfTime.DurationConstructor)
.toDate();
}

const { sharingToken } = await createWorkspaceSharingToken({
role: data.role,
expiredAt: addedTime,
});

setShareUrl(
`${window.location.origin}/document/${params.documentSlug}?token=${sharingToken}`
);
};

const handleCopyShareUrl = async () => {
if (!shareUrl) return;

await clipboard.write(shareUrl);
enqueueSnackbar("URL Copied!", { variant: "success" });
};

return (
<Modal disableAutoFocus {...modalProps}>
<Paper
sx={{
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
p: 4,
width: 400,
}}
>
<IconButton
sx={{
position: "absolute",
top: 28,
right: 28,
}}
onClick={(e) => props.onClose?.(e, "backdropClick")}
>
<CloseIcon />
</IconButton>
<Stack gap={1}>
<Typography variant="subtitle1">Share Link</Typography>
<FormControl>
<FormContainer
defaultValues={{
expiredString: invitationExpiredStringList[0],
role: Object.values(ShareRole)[0],
}}
onSuccess={handleCreateShareUrl}
>
<Stack gap={2}>
<SelectElement
label="Role"
name="role"
options={Object.values(ShareRole).map((role) => ({
id: role,
label: role,
}))}
size="small"
sx={{
width: 1,
}}
variant="filled"
/>
<SelectElement
label="Expired Date"
name="expiredString"
options={invitationExpiredStringList.map((expiredString) => ({
id: expiredString,
label: expiredString,
}))}
size="small"
sx={{
width: 1,
}}
variant="filled"
/>
<Button type="submit" variant="contained">
Generate
</Button>
</Stack>
</FormContainer>
</FormControl>
{Boolean(shareUrl) && (
<Stack direction="row" alignItems="center" gap={2}>
<Typography variant="body1">{shareUrl}</Typography>
<Tooltip title="Copy URL">
<IconButton onClick={handleCopyShareUrl}>
<ContentCopyIcon />
</IconButton>
</Tooltip>
</Stack>
)}
</Stack>
</Paper>
</Modal>
);
}

export default ShareModal;
30 changes: 27 additions & 3 deletions frontend/src/hooks/api/document.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { GetDocumentResponse } from "./types/document";
import { GetDocumentBySharingTokenResponse, GetDocumentResponse } from "./types/document";

export const generateGetDocumentQueryKey = (documentSlug: string) => {
return ["documents", documentSlug];
};

export const useGetDocumentQuery = (documentSlug: string) => {
export const generateGetDocumentBySharingTokenQueryKey = (sharingToken: string) => {
return ["documents", "share", sharingToken];
};

export const useGetDocumentQuery = (documentSlug?: string | null) => {
const query = useQuery({
queryKey: generateGetDocumentQueryKey(documentSlug || ""),
enabled: Boolean(documentSlug),
Expand All @@ -15,7 +19,27 @@ export const useGetDocumentQuery = (documentSlug: string) => {
return res.data;
},
meta: {
errorMessage: "This is a non-existent or unauthorized Workspace.",
errorMessage: "This is a non-existent or unauthorized document.",
},
});

return query;
};

export const useGetDocumentBySharingTokenQuery = (sharingToken?: string | null) => {
const query = useQuery({
queryKey: generateGetDocumentQueryKey(sharingToken || ""),
enabled: Boolean(sharingToken),
queryFn: async () => {
const res = await axios.get<GetDocumentBySharingTokenResponse>("/documents/share", {
params: {
token: sharingToken,
},
});
return res.data;
},
meta: {
errorMessage: "This is a non-existent or expired document.",
},
});

Expand Down
7 changes: 7 additions & 0 deletions frontend/src/hooks/api/types/document.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { ShareRole } from "../../../utils/share";

export class Document {
id: string;
workspaceId: string;
yorkieDocumentId: string;
title: string;
slug: string;
Expand All @@ -9,3 +12,7 @@ export class Document {
}

export class GetDocumentResponse extends Document {}

export class GetDocumentBySharingTokenResponse extends Document {
role: ShareRole;
}
Loading
Loading