Skip to content

Commit 759fb04

Browse files
committed
feat: 워크스페이스 공유 기능 추가
1 parent d14f0b9 commit 759fb04

File tree

11 files changed

+295
-0
lines changed

11 files changed

+295
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Post } from "@/shared/api";
2+
import { SetWorkspaceStatusResponse } from "../model/workspaceInviteTypes";
3+
4+
export const setWorkspaceStatusToPrivate = async (id: string) => {
5+
// TODO: URL 맞게 고치기.
6+
const url = `/api/workspace/${id}/private`;
7+
await Post<SetWorkspaceStatusResponse, null>(url);
8+
};
9+
10+
export const setWorkspaceStatusToPublic = async (id: string) => {
11+
// TODO: URL 맞게 고치기.
12+
const url = `/api/workspace/${id}/public`;
13+
await Post<SetWorkspaceStatusResponse, null>(url);
14+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Get, Post } from "@/shared/api";
2+
import {
3+
WorkspaceInviteLinkRequest,
4+
WorkspaceInviteLinkResponse,
5+
ValidateWorkspaceLinkResponse,
6+
} from "@/features/workspace/model/workspaceInviteTypes";
7+
8+
export const createWorkspaceInviteLink = async (id: string) => {
9+
const url = `/api/workspace/${id}/invite`;
10+
11+
const res = await Post<
12+
WorkspaceInviteLinkResponse,
13+
WorkspaceInviteLinkRequest
14+
>(url, { id });
15+
16+
return res.data.inviteUrl;
17+
};
18+
19+
export const validateWorkspaceInviteLink = async (token: string) => {
20+
const url = `/api/workspace/join?token=${token}`;
21+
const res = await Get<ValidateWorkspaceLinkResponse>(url);
22+
return res.data;
23+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { create } from "zustand";
2+
3+
type InviteLinkStore = {
4+
inviteLink: string | null;
5+
setInviteLink: (link: string) => void;
6+
};
7+
8+
export const useInviteLinkStore = create<InviteLinkStore>((set) => ({
9+
inviteLink: null,
10+
setInviteLink: (link) => set({ inviteLink: link }),
11+
}));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useMutation } from "@tanstack/react-query";
2+
import {
3+
createWorkspaceInviteLink,
4+
validateWorkspaceInviteLink,
5+
} from "../api/worskspaceInviteApi";
6+
7+
export const useCreateWorkspaceInviteLink = () => {
8+
return useMutation({
9+
mutationFn: (id: string) => createWorkspaceInviteLink(id),
10+
});
11+
};
12+
13+
export const useValidateWorkspaceInviteLink = () => {
14+
return useMutation({
15+
mutationFn: (token: string) => validateWorkspaceInviteLink(token),
16+
});
17+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useMutation, useQueryClient } from "@tanstack/react-query";
2+
import { useUserWorkspace } from "@/features/workspace/model/useWorkspace";
3+
import { useWorkspace } from "@/shared/lib/useWorkspace";
4+
import {
5+
setWorkspaceStatusToPrivate,
6+
setWorkspaceStatusToPublic,
7+
} from "../api/workspaceStatusApi";
8+
9+
export const useWorkspaceStatus = () => {
10+
const { data: workspaces } = useUserWorkspace();
11+
const currentWorkspaceId = useWorkspace();
12+
13+
return workspaces?.find((workspace) => workspace.id === currentWorkspaceId)
14+
?.status;
15+
};
16+
17+
export const useToggleWorkspaceStatus = (
18+
currentStatus: "public" | "private" | undefined,
19+
) => {
20+
const queryClient = useQueryClient();
21+
const currentWorkspaceId = useWorkspace();
22+
23+
return useMutation({
24+
mutationFn: () => {
25+
if (currentStatus === undefined) {
26+
throw new Error("Workspace status is undefined");
27+
}
28+
29+
const toggleFn =
30+
currentStatus === "public"
31+
? setWorkspaceStatusToPrivate
32+
: setWorkspaceStatusToPublic;
33+
34+
return toggleFn(currentWorkspaceId);
35+
},
36+
onSuccess: () => {
37+
queryClient.invalidateQueries({ queryKey: ["userWorkspace"] });
38+
},
39+
});
40+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface WorkspaceInviteLinkRequest {
2+
id: string;
3+
}
4+
5+
export interface WorkspaceInviteLinkResponse {
6+
message: string;
7+
inviteUrl: string;
8+
}
9+
10+
export interface ValidateWorkspaceLinkResponse {
11+
message: string;
12+
}
13+
14+
export interface SetWorkspaceStatusResponse {
15+
message: string;
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// TODO: admin이 아니라면 tooltip 추가 "권한이 없습니다."
2+
3+
import { useWorkspace } from "@/shared/lib/useWorkspace";
4+
import { useUserWorkspace } from "../../model/useWorkspace";
5+
6+
export function Sharebutton() {
7+
const currentWorkspaceId = useWorkspace();
8+
const workspaces = useUserWorkspace();
9+
const workspace = workspaces?.find(
10+
(workspace) => workspace.id === currentWorkspaceId,
11+
);
12+
const isGuest = workspace?.role === "guest";
13+
14+
return (
15+
<div className="flex h-9 items-center justify-center">
16+
<button
17+
disabled={isGuest}
18+
className="rounded-md bg-blue-400 px-2 py-1 text-[#171717] hover:bg-blue-500"
19+
>
20+
공유
21+
</button>
22+
</div>
23+
);
24+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Switch } from "@/shared/ui/Switch";
2+
import { Globe2, Lock, Copy, CheckCheck } from "lucide-react";
3+
import { useState, useEffect } from "react";
4+
import {
5+
useWorkspaceStatus,
6+
useToggleWorkspaceStatus,
7+
} from "@/features/workspace/model/useWorkspaceStatus";
8+
import { useCreateWorkspaceInviteLink } from "@/features/workspace/model/useWorkspaceInvite";
9+
import { useWorkspace } from "@/shared/lib/useWorkspace";
10+
import { useInviteLinkStore } from "@/features/workspace/model/useInviteLinkStore";
11+
12+
export function SharePanel() {
13+
const [copied, setCopied] = useState(false);
14+
const currentWorkspaceId = useWorkspace();
15+
const workspaceStatus = useWorkspaceStatus();
16+
17+
const { inviteLink, setInviteLink } = useInviteLinkStore();
18+
19+
const { mutate: toggleStatus, isPending: isTogglingStatus } =
20+
useToggleWorkspaceStatus(workspaceStatus);
21+
const { mutate: createLink, isPending: isCreatingLink } =
22+
useCreateWorkspaceInviteLink();
23+
24+
const isPublic = workspaceStatus === "public";
25+
const isLoading = isTogglingStatus || isCreatingLink;
26+
27+
useEffect(() => {
28+
if (isPublic) {
29+
setInviteLink(window.location.href);
30+
} else if (!inviteLink && currentWorkspaceId) {
31+
createLink(currentWorkspaceId, {
32+
onSuccess: (inviteUrl) => {
33+
setInviteLink(inviteUrl);
34+
},
35+
});
36+
}
37+
}, [isPublic, currentWorkspaceId]);
38+
39+
const handleCopy = async () => {
40+
const linkToCopy = isPublic ? window.location.href : inviteLink;
41+
if (!linkToCopy) return;
42+
43+
await navigator.clipboard.writeText(linkToCopy);
44+
setCopied(true);
45+
setTimeout(() => setCopied(false), 2000);
46+
};
47+
48+
const handleSwitchChange = () => {
49+
toggleStatus();
50+
};
51+
52+
const displayedLink = isPublic ? window.location.href : inviteLink;
53+
54+
return (
55+
<div className="w-full">
56+
<div className="flex w-full items-center gap-2 p-1">
57+
<div className="flex-row text-sm text-slate-400">공개 범위</div>
58+
<div className="flex items-center space-x-2">
59+
<Switch
60+
checked={isPublic}
61+
onChange={handleSwitchChange}
62+
CheckedIcon={Globe2}
63+
UncheckedIcon={Lock}
64+
/>
65+
</div>
66+
</div>
67+
<div
68+
className={`flex w-full items-center justify-between gap-2 py-2 ${
69+
!isPublic ? "opacity-50" : ""
70+
}`}
71+
>
72+
<div className="w-48 flex-1 truncate rounded-md bg-gray-100 px-3 py-2 text-sm text-gray-600">
73+
{isLoading ? "링크 생성 중..." : displayedLink}
74+
</div>
75+
<button
76+
onClick={handleCopy}
77+
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 disabled:cursor-not-allowed disabled:hover:bg-transparent"
78+
aria-label="Copy URL"
79+
disabled={isLoading || !displayedLink}
80+
>
81+
{copied ? (
82+
<CheckCheck className="h-4 w-4 text-green-500" />
83+
) : (
84+
<Copy className="h-4 w-4 text-gray-500" />
85+
)}
86+
</button>
87+
</div>
88+
</div>
89+
);
90+
}
91+
92+
export default SharePanel;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Popover } from "@/shared/ui";
2+
import { Sharebutton } from "./ShareButton";
3+
import { SharePanel } from "./SharePanel";
4+
export function ShareTool() {
5+
return (
6+
<div className="mr-1">
7+
<Popover placement="bottom" align="start" offset={{ x: -6, y: 16 }}>
8+
<Popover.Trigger>
9+
<Sharebutton />
10+
</Popover.Trigger>
11+
<Popover.Content className="rounded-lg border border-neutral-200 bg-white p-2 shadow-md">
12+
<SharePanel />
13+
</Popover.Content>
14+
</Popover>
15+
</div>
16+
);
17+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
interface SwitchProps {
2+
checked: boolean;
3+
onChange: (checked: boolean) => void;
4+
CheckedIcon: React.ComponentType<{ className?: string }>;
5+
UncheckedIcon: React.ComponentType<{ className?: string }>;
6+
disabled?: boolean;
7+
}
8+
9+
export function Switch({
10+
checked,
11+
onChange,
12+
CheckedIcon,
13+
UncheckedIcon,
14+
disabled,
15+
}: SwitchProps) {
16+
return (
17+
<button
18+
role="switch"
19+
aria-checked={checked}
20+
onClick={() => onChange(!checked)}
21+
className={`relative inline-flex h-6 w-10 shrink-0 cursor-pointer items-center rounded-lg border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${
22+
checked ? "bg-[#b2ffba]" : "bg-gray-200"
23+
}`}
24+
disabled={disabled}
25+
>
26+
<div
27+
className={`pointer-events-none inline-flex h-5 w-5 transform items-center justify-center rounded-md bg-white text-gray-500 shadow-lg ring-0 transition duration-200 ease-in-out ${
28+
checked ? "translate-x-4" : "translate-x-0"
29+
}`}
30+
>
31+
{checked ? (
32+
<CheckedIcon className="h-3 w-3" />
33+
) : (
34+
<UncheckedIcon className="h-3 w-3" />
35+
)}
36+
</div>
37+
</button>
38+
);
39+
}

0 commit comments

Comments
 (0)