Skip to content
File

Blob: src/client/components/shared-with-me-view.tsx

typescript230 lines
1import { useState, useCallback } from "react";
2import { useLocation, useNavigate } from "@tanstack/react-router";
3import { Share2, FileText, AlertCircle, ArrowLeft } from "lucide-react";
4import { Skeleton } from "@/client/components/ui/skeleton";
5import { Button } from "@/client/components/ui/button";
6import { EmojiIcon } from "@/client/components/ui/emoji-icon";
7import { useDocumentTitle } from "@/client/hooks/use-document-title";
8import { useSharedInbox } from "@/client/hooks/use-shared-inbox";
9import { useSharedInboxNavigation } from "@/client/hooks/use-shared-inbox-navigation";
10import { getSharedInboxReturnTo } from "@/client/lib/shared-inbox-navigation";
11import { formatRelativeDate } from "@/client/lib/format-date";
12import { DEFAULT_PAGE_TITLE } from "@/shared/constants";
13import type { SharedWithMeItem } from "@/shared/types";
14 
15function groupByWorkspace(items: SharedWithMeItem[]) {
16 const groups: Map<string, { workspace: SharedWithMeItem["workspace"]; pages: SharedWithMeItem[] }> = new Map();
17 for (const item of items) {
18 let group = groups.get(item.workspace.id);
19 if (!group) {
20 group = { workspace: item.workspace, pages: [] };
21 groups.set(item.workspace.id, group);
22 }
23 group.pages.push(item);
24 }
25 return [...groups.values()];
26}
27 
28export function SharedWithMeView() {
29 useDocumentTitle("Shared with me");
30 const navigate = useNavigate();
31 const location = useLocation();
32 const { items: sharedInbox, workspaceSummaries, status } = useSharedInbox();
33 const [entryReturnTo] = useState(() => getSharedInboxReturnTo(location.state));
34 const { backLabel, canLeaveSharedInbox, leaveSharedInbox } = useSharedInboxNavigation({ returnTo: entryReturnTo });
35 
36 const hasAny = sharedInbox.length > 0 || workspaceSummaries.length > 0;
37 const showLoading = status === "loading" && !hasAny;
38 const showError = !hasAny && status === "error";
39 
40 const handlePageClick = useCallback(
41 (item: SharedWithMeItem) => {
42 // Just navigate -- the page route resolver will bootstrap workspace context
43 navigate({
44 to: "/$workspaceSlug/$pageId",
45 params: { workspaceSlug: item.workspace.slug, pageId: item.page_id },
46 });
47 },
48 [navigate],
49 );
50 
51 if (showLoading) {
52 return (
53 <div className="mx-auto max-w-2xl px-4 py-8 sm:px-6" aria-busy="true">
54 <div className="mb-6 flex items-center gap-3">
55 <Skeleton className="h-5 w-5 rounded-full" />
56 <Skeleton className="h-5 w-40" />
57 </div>
58 <div className="space-y-6">
59 {[0, 1].map((g) => (
60 <div key={g}>
61 <div className="mb-2 flex items-center gap-2 px-1">
62 <Skeleton className="h-4 w-4 shrink-0 rounded" />
63 <Skeleton className="h-3 w-24" />
64 </div>
65 <div className="divide-y divide-zinc-800/60 rounded-lg border border-zinc-800/60 bg-zinc-900/50">
66 {[0, 1].map((r) => (
67 <div key={r} className="flex items-center gap-3 px-3 py-2.5">
68 <Skeleton className="h-4 w-4 shrink-0 rounded" />
69 <div className="min-w-0 flex-1 space-y-1.5">
70 <Skeleton className="h-4 w-40" />
71 <Skeleton className="h-3 w-24" />
72 </div>
73 <Skeleton className="h-4 w-10 shrink-0 rounded" />
74 </div>
75 ))}
76 </div>
77 </div>
78 ))}
79 </div>
80 </div>
81 );
82 }
83 
84 if (showError) {
85 return (
86 <div className="flex h-full items-center justify-center px-4">
87 <div className="animate-slide-up text-center">
88 <AlertCircle className="mx-auto mb-4 h-10 w-10 text-red-400" />
89 <h2 className="text-lg font-semibold text-zinc-200">Couldn't load shared pages</h2>
90 <p className="mt-1 text-sm text-zinc-400">Check your connection and try again.</p>
91 <Button variant="secondary" size="sm" className="mt-4" onClick={() => window.location.reload()}>
92 Retry
93 </Button>
94 </div>
95 </div>
96 );
97 }
98 
99 if (!hasAny) {
100 return (
101 <div className="flex h-full items-center justify-center">
102 <div className="animate-slide-up text-center">
103 <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-zinc-800/50">
104 <Share2 className="h-8 w-8 text-zinc-500" />
105 </div>
106 <h2 className="text-lg font-semibold text-zinc-200">Nothing shared with you yet</h2>
107 <p className="mt-1 text-sm text-zinc-400">Shared pages show up here automatically.</p>
108 {canLeaveSharedInbox && backLabel && (
109 <button
110 onClick={leaveSharedInbox}
111 className="mt-4 inline-flex items-center gap-1.5 text-sm text-zinc-400 transition-colors hover:text-zinc-300"
112 type="button"
113 >
114 <ArrowLeft className="h-3.5 w-3.5" />
115 {backLabel}
116 </button>
117 )}
118 </div>
119 </div>
120 );
121 }
122 
123 const groups = groupByWorkspace(sharedInbox);
124 
125 const handleWorkspaceClick = (slug: string) => {
126 navigate({ to: "/$workspaceSlug", params: { workspaceSlug: slug } });
127 };
128 
129 return (
130 <div className="mx-auto max-w-2xl px-4 py-8 sm:px-6">
131 <div className="animate-slide-up">
132 <div className="mb-6 flex items-center gap-3">
133 {canLeaveSharedInbox && backLabel && (
134 <button
135 onClick={leaveSharedInbox}
136 className="flex items-center justify-center rounded-md p-1 text-zinc-500 transition-colors hover:bg-zinc-800 hover:text-zinc-300"
137 aria-label={backLabel}
138 title={backLabel}
139 type="button"
140 >
141 <ArrowLeft className="h-4 w-4" />
142 </button>
143 )}
144 <Share2 className="h-5 w-5 text-zinc-400" />
145 <h1 className="text-lg font-semibold text-zinc-200">Shared with me</h1>
146 </div>
147
148 <div className="space-y-6">
149 {groups.map(({ workspace, pages }) => (
150 <div key={workspace.id}>
151 <div className="mb-2 flex items-center gap-2 px-1">
152 {workspace.icon ? (
153 <EmojiIcon emoji={workspace.icon} size={14} />
154 ) : (
155 <span className="flex h-4 w-4 items-center justify-center rounded bg-zinc-700 text-[10px] font-medium text-zinc-400">
156 {workspace.name.charAt(0).toUpperCase()}
157 </span>
158 )}
159 <span className="text-xs font-medium text-zinc-400">{workspace.name}</span>
160 </div>
161
162 <div className="divide-y divide-zinc-800/60 rounded-lg border border-zinc-800/60 bg-zinc-900/50">
163 {pages.map((item) => (
164 <button
165 key={item.page_id}
166 onClick={() => handlePageClick(item)}
167 className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-zinc-800/50"
168 >
169 <span className="shrink-0 text-base">
170 {item.icon ?? <FileText className="h-4 w-4 text-zinc-500" />}
171 </span>
172 <div className="min-w-0 flex-1">
173 <div className="truncate text-sm font-medium text-zinc-200">
174 {item.title || DEFAULT_PAGE_TITLE}
175 </div>
176 <div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-500">
177 <span>{item.shared_by_name}</span>
178 <span>·</span>
179 <span>{formatRelativeDate(item.shared_at)}</span>
180 </div>
181 </div>
182 <span
183 className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${
184 item.permission === "edit" ? "bg-accent-500/10 text-accent-400" : "bg-zinc-800 text-zinc-400"
185 }`}
186 >
187 {item.permission === "edit" ? "Edit" : "View"}
188 </span>
189 </button>
190 ))}
191 </div>
192 </div>
193 ))}
194 </div>
195
196 {workspaceSummaries.length > 0 && (
197 <div className="mt-8 border-t border-zinc-800/60 pt-5">
198 <h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">
199 From workspaces you belong to
200 </h2>
201 <div className="divide-y divide-zinc-800/60 rounded-lg border border-zinc-800/60 bg-zinc-900/50">
202 {workspaceSummaries.map(({ workspace, count }) => (
203 <button
204 key={workspace.id}
205 onClick={() => handleWorkspaceClick(workspace.slug)}
206 className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-zinc-800/50"
207 >
208 {workspace.icon ? (
209 <EmojiIcon emoji={workspace.icon} size={16} />
210 ) : (
211 <span className="flex h-4 w-4 items-center justify-center rounded bg-zinc-700 text-[10px] font-medium text-zinc-400">
212 {workspace.name.charAt(0).toUpperCase()}
213 </span>
214 )}
215 <div className="min-w-0 flex-1">
216 <div className="truncate text-sm font-medium text-zinc-200">{workspace.name}</div>
217 <div className="mt-0.5 text-xs text-zinc-500">
218 {count} {count === 1 ? "page" : "pages"} directly shared with you
219 </div>
220 </div>
221 </button>
222 ))}
223 </div>
224 </div>
225 )}
226 </div>
227 </div>
228 );
229}