@mdguggenbichler/slugbase-core 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/frontend/index.tsx +3 -3
  2. package/frontend/public/favicon.svg +1 -0
  3. package/frontend/public/slugbase_icon_blue.svg +1 -0
  4. package/frontend/public/slugbase_icon_white.png +0 -0
  5. package/frontend/public/slugbase_icon_white.svg +1 -0
  6. package/frontend/src/App.tsx +179 -0
  7. package/frontend/src/api/client.ts +134 -0
  8. package/frontend/src/components/AppSidebar.tsx +214 -0
  9. package/frontend/src/components/EmptyState.tsx +33 -0
  10. package/frontend/src/components/Favicon.tsx +76 -0
  11. package/frontend/src/components/FilterChips.tsx +60 -0
  12. package/frontend/src/components/FolderIcon.tsx +207 -0
  13. package/frontend/src/components/GlobalSearch.tsx +275 -0
  14. package/frontend/src/components/Layout.tsx +60 -0
  15. package/frontend/src/components/PageHeader.tsx +31 -0
  16. package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
  17. package/frontend/src/components/SentryDebug.tsx +32 -0
  18. package/frontend/src/components/StatCard.tsx +66 -0
  19. package/frontend/src/components/TopBar.tsx +63 -0
  20. package/frontend/src/components/UserDropdown.tsx +86 -0
  21. package/frontend/src/components/admin/AdminAI.tsx +207 -0
  22. package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
  23. package/frontend/src/components/admin/AdminSettings.tsx +413 -0
  24. package/frontend/src/components/admin/AdminTeams.tsx +177 -0
  25. package/frontend/src/components/admin/AdminUsers.tsx +225 -0
  26. package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
  27. package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
  28. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
  29. package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
  30. package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
  31. package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
  32. package/frontend/src/components/modals/FolderModal.tsx +306 -0
  33. package/frontend/src/components/modals/ImportModal.tsx +232 -0
  34. package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
  35. package/frontend/src/components/modals/SharingModal.tsx +96 -0
  36. package/frontend/src/components/modals/TagModal.tsx +101 -0
  37. package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
  38. package/frontend/src/components/modals/TeamModal.tsx +117 -0
  39. package/frontend/src/components/modals/UserModal.tsx +225 -0
  40. package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
  41. package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
  42. package/frontend/src/components/ui/Autocomplete.tsx +155 -0
  43. package/frontend/src/components/ui/Button.tsx +68 -0
  44. package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
  45. package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
  46. package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
  47. package/frontend/src/components/ui/ModalSection.tsx +34 -0
  48. package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
  49. package/frontend/src/components/ui/Select.tsx +61 -0
  50. package/frontend/src/components/ui/SharingField.tsx +298 -0
  51. package/frontend/src/components/ui/Toast.tsx +47 -0
  52. package/frontend/src/components/ui/Tooltip.tsx +21 -0
  53. package/frontend/src/components/ui/alert-dialog.tsx +139 -0
  54. package/frontend/src/components/ui/badge.tsx +36 -0
  55. package/frontend/src/components/ui/button-base.tsx +57 -0
  56. package/frontend/src/components/ui/card.tsx +76 -0
  57. package/frontend/src/components/ui/command.tsx +161 -0
  58. package/frontend/src/components/ui/dialog.tsx +120 -0
  59. package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
  60. package/frontend/src/components/ui/input.tsx +22 -0
  61. package/frontend/src/components/ui/label.tsx +24 -0
  62. package/frontend/src/components/ui/popover.tsx +33 -0
  63. package/frontend/src/components/ui/progress.tsx +26 -0
  64. package/frontend/src/components/ui/scroll-area.tsx +48 -0
  65. package/frontend/src/components/ui/select-base.tsx +159 -0
  66. package/frontend/src/components/ui/separator.tsx +29 -0
  67. package/frontend/src/components/ui/sheet.tsx +140 -0
  68. package/frontend/src/components/ui/sidebar.tsx +783 -0
  69. package/frontend/src/components/ui/skeleton.tsx +15 -0
  70. package/frontend/src/components/ui/sonner.tsx +46 -0
  71. package/frontend/src/components/ui/switch.tsx +28 -0
  72. package/frontend/src/components/ui/table.tsx +120 -0
  73. package/frontend/src/components/ui/tooltip-base.tsx +30 -0
  74. package/frontend/src/config/api.ts +16 -0
  75. package/frontend/src/config/mode.ts +6 -0
  76. package/frontend/src/contexts/AppConfigContext.tsx +39 -0
  77. package/frontend/src/contexts/AuthContext.tsx +137 -0
  78. package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
  79. package/frontend/src/hooks/use-mobile.tsx +19 -0
  80. package/frontend/src/hooks/useConfirmDialog.ts +63 -0
  81. package/frontend/src/hooks/useMarketingTheme.ts +47 -0
  82. package/frontend/src/i18n.ts +39 -0
  83. package/frontend/src/index.css +117 -0
  84. package/frontend/src/instrument.ts +20 -0
  85. package/frontend/src/lib/utils.ts +6 -0
  86. package/frontend/src/locales/de.json +899 -0
  87. package/frontend/src/locales/en.json +937 -0
  88. package/frontend/src/locales/es.json +884 -0
  89. package/frontend/src/locales/fr.json +550 -0
  90. package/frontend/src/locales/it.json +535 -0
  91. package/frontend/src/locales/ja.json +535 -0
  92. package/frontend/src/locales/nl.json +550 -0
  93. package/frontend/src/locales/pl.json +535 -0
  94. package/frontend/src/locales/pt.json +535 -0
  95. package/frontend/src/locales/ru.json +535 -0
  96. package/frontend/src/locales/zh.json +535 -0
  97. package/frontend/src/main.tsx +44 -0
  98. package/frontend/src/pages/Bookmarks.tsx +1004 -0
  99. package/frontend/src/pages/Dashboard.tsx +427 -0
  100. package/frontend/src/pages/Folders.tsx +578 -0
  101. package/frontend/src/pages/GoPreferences.tsx +134 -0
  102. package/frontend/src/pages/Login.tsx +196 -0
  103. package/frontend/src/pages/PasswordReset.tsx +242 -0
  104. package/frontend/src/pages/Profile.tsx +593 -0
  105. package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
  106. package/frontend/src/pages/Setup.tsx +210 -0
  107. package/frontend/src/pages/Signup.tsx +199 -0
  108. package/frontend/src/pages/Tags.tsx +421 -0
  109. package/frontend/src/pages/VerifyEmail.tsx +254 -0
  110. package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
  111. package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
  112. package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
  113. package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
  114. package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
  115. package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
  116. package/frontend/src/utils/favicon.ts +36 -0
  117. package/frontend/src/utils/formatRelativeTime.ts +37 -0
  118. package/frontend/src/utils/safeHref.ts +31 -0
  119. package/frontend/src/vite-env.d.ts +10 -0
  120. package/package.json +9 -1
@@ -0,0 +1,172 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Key, Copy, AlertTriangle } from 'lucide-react';
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogFooter,
10
+ } from '../ui/dialog';
11
+ import { Separator } from '../ui/separator';
12
+ import { FormFieldWrapper } from '../ui/FormFieldWrapper';
13
+ import { ModalSection } from '../ui/ModalSection';
14
+ import { ModalFooterActions } from '../ui/ModalFooterActions';
15
+ import { Input } from '../ui/input';
16
+ import Button from '../ui/Button';
17
+ import { useToast } from '../ui/Toast';
18
+ import api from '../../api/client';
19
+
20
+ interface CreateTokenModalProps {
21
+ isOpen: boolean;
22
+ onClose: () => void;
23
+ onCreated: () => void;
24
+ }
25
+
26
+ type Step = 'form' | 'reveal';
27
+
28
+ export default function CreateTokenModal({ isOpen, onClose, onCreated }: CreateTokenModalProps) {
29
+ const { t } = useTranslation();
30
+ const { showToast } = useToast();
31
+ const [step, setStep] = useState<Step>('form');
32
+ const [name, setName] = useState('');
33
+ const [creating, setCreating] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [token, setToken] = useState<string | null>(null);
36
+ const [copied, setCopied] = useState(false);
37
+
38
+ function reset() {
39
+ setStep('form');
40
+ setName('');
41
+ setError(null);
42
+ setToken(null);
43
+ setCopied(false);
44
+ }
45
+
46
+ function handleClose() {
47
+ reset();
48
+ onClose();
49
+ if (step === 'reveal') {
50
+ onCreated();
51
+ }
52
+ }
53
+
54
+ async function handleSubmit(e: React.FormEvent) {
55
+ e.preventDefault();
56
+ setCreating(true);
57
+ setError(null);
58
+ try {
59
+ const res = await api.post('/tokens', { name: name.trim() });
60
+ setToken(res.data.token);
61
+ setStep('reveal');
62
+ } catch (err: any) {
63
+ setError(err.response?.data?.error || t('common.error'));
64
+ } finally {
65
+ setCreating(false);
66
+ }
67
+ }
68
+
69
+ async function handleCopy() {
70
+ if (!token) return;
71
+ try {
72
+ await navigator.clipboard.writeText(token);
73
+ setCopied(true);
74
+ setTimeout(() => setCopied(false), 2000);
75
+ showToast(t('common.copied'), 'success');
76
+ } catch {
77
+ const textarea = document.createElement('textarea');
78
+ textarea.value = token;
79
+ document.body.appendChild(textarea);
80
+ textarea.select();
81
+ document.execCommand('copy');
82
+ document.body.removeChild(textarea);
83
+ setCopied(true);
84
+ setTimeout(() => setCopied(false), 2000);
85
+ showToast(t('common.copied'), 'success');
86
+ }
87
+ }
88
+
89
+ const isValid = name.trim().length > 0;
90
+
91
+ return (
92
+ <Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
93
+ <DialogContent className="sm:max-w-md">
94
+ <DialogHeader>
95
+ <DialogTitle className="flex items-center gap-2">
96
+ <Key className="h-5 w-5 text-primary" />
97
+ {step === 'form' ? t('profile.createToken') : t('profile.tokenCreated')}
98
+ </DialogTitle>
99
+ </DialogHeader>
100
+ <Separator />
101
+
102
+ {step === 'form' ? (
103
+ <>
104
+ <form id="create-token-form" onSubmit={handleSubmit} className="space-y-6">
105
+ <ModalSection description={t('profile.apiAccessDescription')}>
106
+ <FormFieldWrapper
107
+ label={t('profile.tokenName')}
108
+ required
109
+ error={error || undefined}
110
+ htmlFor="token-name"
111
+ >
112
+ <Input
113
+ id="token-name"
114
+ type="text"
115
+ required
116
+ value={name}
117
+ onChange={(e) => setName(e.target.value)}
118
+ placeholder={t('profile.tokenNamePlaceholder')}
119
+ maxLength={100}
120
+ />
121
+ </FormFieldWrapper>
122
+ </ModalSection>
123
+ </form>
124
+ <Separator />
125
+ <DialogFooter className="flex-row justify-between sm:justify-end gap-2">
126
+ <ModalFooterActions
127
+ onCancel={handleClose}
128
+ submitLabel={t('profile.createToken')}
129
+ loading={creating}
130
+ submitDisabled={!isValid}
131
+ formId="create-token-form"
132
+ />
133
+ </DialogFooter>
134
+ </>
135
+ ) : (
136
+ <>
137
+ <div className="space-y-4">
138
+ <div className="flex items-start gap-2 rounded-lg border border-amber-500/50 bg-amber-500/10 px-3 py-2">
139
+ <AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
140
+ <p className="text-sm text-muted-foreground">
141
+ {t('profile.tokenRevealWarning')}
142
+ </p>
143
+ </div>
144
+ <div className="flex items-center gap-2">
145
+ <Input
146
+ readOnly
147
+ value={token || ''}
148
+ className="font-mono text-sm truncate bg-muted"
149
+ />
150
+ <Button
151
+ type="button"
152
+ variant="outline"
153
+ size="sm"
154
+ onClick={handleCopy}
155
+ icon={Copy}
156
+ >
157
+ {copied ? t('common.success') : t('profile.copyToken')}
158
+ </Button>
159
+ </div>
160
+ </div>
161
+ <Separator />
162
+ <DialogFooter className="flex-row justify-end">
163
+ <Button type="button" variant="primary" onClick={handleClose}>
164
+ {t('common.close')}
165
+ </Button>
166
+ </DialogFooter>
167
+ </>
168
+ )}
169
+ </DialogContent>
170
+ </Dialog>
171
+ );
172
+ }
@@ -0,0 +1,422 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useAuth } from '../../contexts/AuthContext';
4
+ import api from '../../api/client';
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ DialogDescription,
11
+ } from '../ui/dialog';
12
+ import { Separator } from '../ui/separator';
13
+ import { Input } from '../ui/input';
14
+ import { ScrollArea } from '../ui/scroll-area';
15
+ import { Popover, PopoverContent, PopoverAnchor, PopoverTrigger } from '../ui/popover';
16
+ import {
17
+ Command,
18
+ CommandEmpty,
19
+ CommandGroup,
20
+ CommandInput,
21
+ CommandItem,
22
+ CommandList,
23
+ } from '../ui/command';
24
+ import Button from '../ui/Button';
25
+ import Tooltip from '../ui/Tooltip';
26
+ import { useToast } from '../ui/Toast';
27
+ import { UserPlus, User, Users, X } from 'lucide-react';
28
+ import { cn } from '@/lib/utils';
29
+
30
+ interface SharedUser {
31
+ id: string;
32
+ name: string;
33
+ email: string;
34
+ }
35
+
36
+ interface SharedTeam {
37
+ id: string;
38
+ name: string;
39
+ }
40
+
41
+ interface ShareResourceDialogProps {
42
+ resourceType: 'bookmark' | 'folder';
43
+ resourceId: string;
44
+ resourceName: string;
45
+ isOpen: boolean;
46
+ onClose: () => void;
47
+ onSuccess: () => void;
48
+ }
49
+
50
+ export default function ShareResourceDialog({
51
+ resourceType,
52
+ resourceId,
53
+ resourceName: _resourceName,
54
+ isOpen,
55
+ onClose,
56
+ onSuccess,
57
+ }: ShareResourceDialogProps) {
58
+ const { t } = useTranslation();
59
+ const { user } = useAuth();
60
+ const { showToast } = useToast();
61
+ const [loading, setLoading] = useState(true);
62
+ const [saving, setSaving] = useState(false);
63
+ const [error, setError] = useState<string | null>(null);
64
+ const [sharedUsers, setSharedUsers] = useState<SharedUser[]>([]);
65
+ const [sharedTeams, setSharedTeams] = useState<SharedTeam[]>([]);
66
+ const [resourceData, setResourceData] = useState<{ name?: string; icon?: string | null } | null>(null);
67
+ const [allUsers, setAllUsers] = useState<SharedUser[]>([]);
68
+ const [teams, setTeams] = useState<SharedTeam[]>([]);
69
+ const [emailInput, setEmailInput] = useState('');
70
+ const [peoplePopoverOpen, setPeoplePopoverOpen] = useState(false);
71
+ const [teamsPopoverOpen, setTeamsPopoverOpen] = useState(false);
72
+ const [activeTab, setActiveTab] = useState<'people' | 'teams'>('people');
73
+
74
+ const allowShareToTeams = teams.length > 0;
75
+ const allowShareToUsers = true;
76
+
77
+ const fetchResource = useCallback(async () => {
78
+ if (!resourceId || !isOpen) return;
79
+ setLoading(true);
80
+ setError(null);
81
+ try {
82
+ const endpoint = resourceType === 'bookmark' ? `/bookmarks/${resourceId}` : `/folders/${resourceId}`;
83
+ const res = await api.get(endpoint);
84
+ const data = res.data;
85
+ setSharedUsers(data.shared_users ?? []);
86
+ setSharedTeams(data.shared_teams ?? []);
87
+ setResourceData(resourceType === 'folder' ? { name: data.name, icon: data.icon } : null);
88
+ } catch (err: any) {
89
+ console.error('Failed to fetch resource:', err);
90
+ setError(err.response?.data?.error || t('common.error'));
91
+ showToast(t('common.error'), 'error');
92
+ } finally {
93
+ setLoading(false);
94
+ }
95
+ }, [resourceId, resourceType, isOpen, t, showToast]);
96
+
97
+ const fetchUsersAndTeams = useCallback(async () => {
98
+ try {
99
+ const [usersRes, teamsRes] = await Promise.all([
100
+ api.get('/admin/users'),
101
+ api.get('/teams'),
102
+ ]);
103
+ const users = Array.isArray(usersRes.data) ? usersRes.data : [];
104
+ setAllUsers(users.filter((u: SharedUser) => u.id !== user?.id));
105
+ setTeams(teamsRes.data ?? []);
106
+ } catch (err) {
107
+ console.error('Failed to fetch users/teams:', err);
108
+ }
109
+ }, [user?.id]);
110
+
111
+ useEffect(() => {
112
+ if (isOpen) {
113
+ fetchResource();
114
+ fetchUsersAndTeams();
115
+ }
116
+ }, [isOpen, fetchResource, fetchUsersAndTeams]);
117
+
118
+ async function updateShares(userIds: string[], teamIds: string[], shareAllTeams: boolean) {
119
+ setSaving(true);
120
+ setError(null);
121
+ try {
122
+ const endpoint = resourceType === 'bookmark' ? `/bookmarks/${resourceId}` : `/folders/${resourceId}`;
123
+ const payload: Record<string, unknown> = { user_ids: userIds };
124
+ if (resourceType === 'folder' && resourceData) {
125
+ payload.name = resourceData.name;
126
+ payload.icon = resourceData.icon ?? null;
127
+ }
128
+ if (allowShareToTeams) {
129
+ payload.team_ids = shareAllTeams ? [] : teamIds;
130
+ payload.share_all_teams = shareAllTeams;
131
+ }
132
+ await api.put(endpoint, payload);
133
+ await fetchResource();
134
+ onSuccess();
135
+ showToast(t('common.success'), 'success');
136
+ } catch (err: any) {
137
+ const msg = err.response?.data?.error || t('common.error');
138
+ setError(msg);
139
+ showToast(msg, 'error');
140
+ } finally {
141
+ setSaving(false);
142
+ }
143
+ }
144
+
145
+ function handleRemoveUser(userId: string) {
146
+ const newUserIds = sharedUsers.filter((u) => u.id !== userId).map((u) => u.id);
147
+ updateShares(newUserIds, sharedTeams.map((t) => t.id), false);
148
+ }
149
+
150
+ function handleRemoveTeam(teamId: string) {
151
+ const newTeamIds = sharedTeams.filter((t) => t.id !== teamId).map((t) => t.id);
152
+ updateShares(sharedUsers.map((u) => u.id), newTeamIds, false);
153
+ }
154
+
155
+ function handleAddUser(userId: string) {
156
+ const newUserIds = [...sharedUsers.map((u) => u.id), userId];
157
+ if (newUserIds.includes(userId)) return;
158
+ updateShares(newUserIds, sharedTeams.map((t) => t.id), false);
159
+ setPeoplePopoverOpen(false);
160
+ setEmailInput('');
161
+ }
162
+
163
+ function handleAddUserByEmail() {
164
+ const email = emailInput.trim().toLowerCase();
165
+ if (!email) return;
166
+ const matched = allUsers.find((u) => u.email.toLowerCase() === email);
167
+ if (matched) {
168
+ handleAddUser(matched.id);
169
+ } else {
170
+ showToast(t('sharing.emailNotAssociated'), 'error');
171
+ }
172
+ }
173
+
174
+ function handleAddTeam(teamId: string) {
175
+ const newTeamIds = [...sharedTeams.map((t) => t.id), teamId];
176
+ if (newTeamIds.includes(teamId)) return;
177
+ updateShares(sharedUsers.map((u) => u.id), newTeamIds, false);
178
+ setTeamsPopoverOpen(false);
179
+ }
180
+
181
+ const filteredUsers = allUsers.filter((u) => {
182
+ if (!emailInput.trim()) return true;
183
+ const q = emailInput.toLowerCase();
184
+ return u.email.toLowerCase().includes(q) || (u.name && u.name.toLowerCase().includes(q));
185
+ });
186
+
187
+ const filteredTeams = teams.filter((t) => !sharedTeams.some((st) => st.id === t.id));
188
+
189
+ const hasShares = sharedUsers.length > 0 || sharedTeams.length > 0;
190
+
191
+ const title = resourceType === 'bookmark' ? t('sharing.shareBookmark') : t('sharing.shareFolder');
192
+
193
+ return (
194
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
195
+ <DialogContent className="max-w-lg">
196
+ <DialogHeader>
197
+ <DialogTitle>{title}</DialogTitle>
198
+ <DialogDescription>{t('sharing.inviteDescription')}</DialogDescription>
199
+ </DialogHeader>
200
+ <Separator />
201
+
202
+ {loading ? (
203
+ <div className="py-8 space-y-4">
204
+ <div className="h-20 rounded-md bg-muted animate-pulse" />
205
+ <div className="h-20 rounded-md bg-muted animate-pulse" />
206
+ </div>
207
+ ) : (
208
+ <div className="space-y-6">
209
+ {error && (
210
+ <div className="rounded-md bg-destructive/10 text-destructive text-sm px-3 py-2">
211
+ {error}
212
+ </div>
213
+ )}
214
+
215
+ <div>
216
+ <h4 className="text-sm font-medium mb-2">{t('sharing.peopleWithAccess')}</h4>
217
+ {!hasShares ? (
218
+ <p className="text-sm text-muted-foreground">{t('sharing.notSharedYet')}</p>
219
+ ) : (
220
+ <ScrollArea className="max-h-32 rounded-md border p-2">
221
+ <div className="space-y-1.5">
222
+ {allowShareToTeams && sharedTeams.map((team) => (
223
+ <div
224
+ key={team.id}
225
+ className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 bg-muted/50"
226
+ >
227
+ <div className="flex items-center gap-2 min-w-0">
228
+ <Users className="h-4 w-4 shrink-0 text-muted-foreground" />
229
+ <span className="text-sm truncate">{team.name}</span>
230
+ </div>
231
+ <Tooltip content={t('sharing.removeAccess')}>
232
+ <button
233
+ type="button"
234
+ onClick={() => handleRemoveTeam(team.id)}
235
+ disabled={saving}
236
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
237
+ aria-label={t('sharing.removeAccess')}
238
+ >
239
+ <X className="h-3.5 w-3.5" />
240
+ </button>
241
+ </Tooltip>
242
+ </div>
243
+ ))}
244
+ {sharedUsers.map((u) => (
245
+ <div
246
+ key={u.id}
247
+ className="flex items-center justify-between gap-2 rounded-md px-2 py-1.5 bg-muted/50"
248
+ >
249
+ <div className="flex items-center gap-2 min-w-0">
250
+ <User className="h-4 w-4 shrink-0 text-muted-foreground" />
251
+ <span className="text-sm truncate">{u.name || u.email}</span>
252
+ {u.name && (
253
+ <span className="text-xs text-muted-foreground truncate">({u.email})</span>
254
+ )}
255
+ </div>
256
+ <Tooltip content={t('sharing.removeAccess')}>
257
+ <button
258
+ type="button"
259
+ onClick={() => handleRemoveUser(u.id)}
260
+ disabled={saving}
261
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
262
+ aria-label={t('sharing.removeAccess')}
263
+ >
264
+ <X className="h-3.5 w-3.5" />
265
+ </button>
266
+ </Tooltip>
267
+ </div>
268
+ ))}
269
+ </div>
270
+ </ScrollArea>
271
+ )}
272
+ </div>
273
+
274
+ <Separator />
275
+
276
+ <div>
277
+ <h4 className="text-sm font-medium mb-3">{t('sharing.addAccess')}</h4>
278
+ {allowShareToTeams && allowShareToUsers ? (
279
+ <div className="flex gap-2 border-b mb-3">
280
+ <button
281
+ type="button"
282
+ onClick={() => setActiveTab('people')}
283
+ className={cn(
284
+ 'px-3 py-2 text-sm font-medium border-b-2 transition-colors',
285
+ activeTab === 'people'
286
+ ? 'border-primary text-primary'
287
+ : 'border-transparent text-muted-foreground hover:text-foreground'
288
+ )}
289
+ >
290
+ {t('sharing.people')}
291
+ </button>
292
+ <button
293
+ type="button"
294
+ onClick={() => setActiveTab('teams')}
295
+ className={cn(
296
+ 'px-3 py-2 text-sm font-medium border-b-2 transition-colors',
297
+ activeTab === 'teams'
298
+ ? 'border-primary text-primary'
299
+ : 'border-transparent text-muted-foreground hover:text-foreground'
300
+ )}
301
+ >
302
+ {t('sharing.teams')}
303
+ </button>
304
+ </div>
305
+ ) : null}
306
+
307
+ {allowShareToUsers && (activeTab === 'people' || !allowShareToTeams) && (
308
+ <div className="space-y-2">
309
+ <div className="flex gap-2">
310
+ <Input
311
+ placeholder={t('admin.searchUsers')}
312
+ value={emailInput}
313
+ onChange={(e) => setEmailInput(e.target.value)}
314
+ onKeyDown={(e) => {
315
+ if (e.key === 'Enter') {
316
+ e.preventDefault();
317
+ handleAddUserByEmail();
318
+ }
319
+ }}
320
+ className="flex-1"
321
+ />
322
+ <Popover open={peoplePopoverOpen} onOpenChange={setPeoplePopoverOpen}>
323
+ <PopoverAnchor asChild>
324
+ <Button
325
+ type="button"
326
+ variant="outline"
327
+ size="sm"
328
+ disabled={saving}
329
+ onClick={() => {
330
+ if (emailInput.trim()) {
331
+ handleAddUserByEmail();
332
+ } else {
333
+ setPeoplePopoverOpen(true);
334
+ }
335
+ }}
336
+ >
337
+ <UserPlus className="h-4 w-4" />
338
+ {t('sharing.add')}
339
+ </Button>
340
+ </PopoverAnchor>
341
+ <PopoverContent className="w-72 p-0" align="start">
342
+ <Command>
343
+ <CommandInput
344
+ placeholder={t('admin.searchUsers')}
345
+ value={emailInput}
346
+ onValueChange={setEmailInput}
347
+ />
348
+ <CommandList>
349
+ <CommandEmpty>{t('common.noResults')}</CommandEmpty>
350
+ <CommandGroup>
351
+ {filteredUsers
352
+ .filter((u) => !sharedUsers.some((su) => su.id === u.id))
353
+ .map((u) => (
354
+ <CommandItem
355
+ key={u.id}
356
+ onSelect={() => handleAddUser(u.id)}
357
+ className="flex flex-col items-start gap-0.5"
358
+ >
359
+ <span className="font-medium">{u.name || u.email}</span>
360
+ {u.name && u.email && (
361
+ <span className="text-xs text-muted-foreground">{u.email}</span>
362
+ )}
363
+ </CommandItem>
364
+ ))}
365
+ </CommandGroup>
366
+ </CommandList>
367
+ </Command>
368
+ </PopoverContent>
369
+ </Popover>
370
+ </div>
371
+ <p className="text-xs text-muted-foreground">{t('sharing.emailNotAssociated')}</p>
372
+ </div>
373
+ )}
374
+
375
+ {allowShareToTeams && (activeTab === 'teams' || !allowShareToUsers) && (
376
+ <Popover open={teamsPopoverOpen} onOpenChange={setTeamsPopoverOpen}>
377
+ <PopoverTrigger asChild>
378
+ <Button
379
+ type="button"
380
+ variant="outline"
381
+ size="sm"
382
+ disabled={saving}
383
+ className="w-full justify-start"
384
+ >
385
+ <Users className="h-4 w-4 mr-2" />
386
+ {t('sharing.teams')}
387
+ </Button>
388
+ </PopoverTrigger>
389
+ <PopoverContent className="w-72 p-0" align="start">
390
+ <Command>
391
+ <CommandInput placeholder={t('admin.searchTeams')} />
392
+ <CommandList>
393
+ <CommandEmpty>{t('common.noResults')}</CommandEmpty>
394
+ <CommandGroup>
395
+ {filteredTeams.map((team) => (
396
+ <CommandItem
397
+ key={team.id}
398
+ onSelect={() => handleAddTeam(team.id)}
399
+ >
400
+ {team.name}
401
+ </CommandItem>
402
+ ))}
403
+ </CommandGroup>
404
+ </CommandList>
405
+ </Command>
406
+ </PopoverContent>
407
+ </Popover>
408
+ )}
409
+ </div>
410
+ </div>
411
+ )}
412
+
413
+ <Separator />
414
+ <div className="flex justify-end">
415
+ <Button variant="secondary" onClick={onClose}>
416
+ {t('common.close')}
417
+ </Button>
418
+ </div>
419
+ </DialogContent>
420
+ </Dialog>
421
+ );
422
+ }