@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,275 @@
1
+ import { useState, useEffect, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { useNavigate } from 'react-router-dom';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import { useSearchCommand } from '../contexts/SearchCommandContext';
6
+ import {
7
+ Search,
8
+ Bookmark,
9
+ Folder,
10
+ Tag,
11
+ ExternalLink,
12
+ Plus,
13
+ ArrowRight,
14
+ } from 'lucide-react';
15
+ import api from '../api/client';
16
+ import { useAppConfig } from '../contexts/AppConfigContext';
17
+ import {
18
+ CommandDialog,
19
+ CommandEmpty,
20
+ CommandGroup,
21
+ CommandInput,
22
+ CommandItem,
23
+ CommandList,
24
+ CommandSeparator,
25
+ } from './ui/command';
26
+
27
+ interface SearchResult {
28
+ id: string;
29
+ type: 'bookmark' | 'folder' | 'tag' | 'navigation' | 'action';
30
+ title: string;
31
+ url?: string;
32
+ icon?: string | null;
33
+ action?: () => void;
34
+ path?: string;
35
+ }
36
+
37
+ export default function GlobalSearch() {
38
+ const { t } = useTranslation();
39
+ const navigate = useNavigate();
40
+ const { appBasePath } = useAppConfig();
41
+ const { user } = useAuth();
42
+ const { open, setOpen, openSearch } = useSearchCommand();
43
+ const [query, setQuery] = useState('');
44
+ const [results, setResults] = useState<SearchResult[]>([]);
45
+ const [loading, setLoading] = useState(false);
46
+
47
+ const showAdmin = user?.is_admin;
48
+
49
+ const navigationItems: SearchResult[] = useMemo(() => [
50
+ { type: 'navigation', title: t('bookmarks.title'), path: `${appBasePath}/bookmarks`, id: 'nav-bookmarks' },
51
+ { type: 'navigation', title: t('folders.title'), path: `${appBasePath}/folders`, id: 'nav-folders' },
52
+ { type: 'navigation', title: t('tags.title'), path: `${appBasePath}/tags`, id: 'nav-tags' },
53
+ { type: 'navigation', title: t('shared.title'), path: `${appBasePath}/shared`, id: 'nav-shared' },
54
+ ...(showAdmin ? [{ type: 'navigation' as const, title: t('admin.title'), path: `${appBasePath}/admin/members`, id: 'nav-admin' }] : []),
55
+ ], [showAdmin, t, appBasePath]);
56
+
57
+ const actionItems: SearchResult[] = useMemo(() => [
58
+ { type: 'action', title: t('bookmarks.create'), path: `${appBasePath}/bookmarks`, id: 'action-create-bookmark', action: () => navigate(`${appBasePath}/bookmarks?create=true`) },
59
+ { type: 'action', title: t('folders.create'), path: `${appBasePath}/folders`, id: 'action-create-folder', action: () => navigate(`${appBasePath}/folders?create=true`) },
60
+ { type: 'action', title: t('bookmarks.import'), path: `${appBasePath}/bookmarks`, id: 'action-import', action: () => navigate(`${appBasePath}/bookmarks?import=true`) },
61
+ { type: 'action', title: t('bookmarks.export'), path: `${appBasePath}/bookmarks`, id: 'action-export', action: () => navigate(`${appBasePath}/bookmarks?export=true`) },
62
+ ], [t, navigate, appBasePath]);
63
+
64
+ useEffect(() => {
65
+ function handleKeyDown(e: KeyboardEvent) {
66
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
67
+ e.preventDefault();
68
+ openSearch();
69
+ }
70
+ }
71
+ document.addEventListener('keydown', handleKeyDown);
72
+ return () => document.removeEventListener('keydown', handleKeyDown);
73
+ }, [openSearch]);
74
+
75
+ useEffect(() => {
76
+ if (!query.trim()) {
77
+ setResults([...navigationItems, ...actionItems]);
78
+ return;
79
+ }
80
+
81
+ if (!open) {
82
+ setResults([]);
83
+ return;
84
+ }
85
+
86
+ const searchTimeout = setTimeout(async () => {
87
+ setLoading(true);
88
+ try {
89
+ const searchLower = query.toLowerCase();
90
+ try {
91
+ const searchRes = await api.get('/bookmarks/search', { params: { q: query } });
92
+ const searchResults: SearchResult[] = searchRes.data.map((item: { id: string; type: string; title: string; url?: string; icon?: string | null }) => ({
93
+ id: item.id,
94
+ type: item.type,
95
+ title: item.title,
96
+ url: item.url,
97
+ icon: item.icon,
98
+ }));
99
+ setResults([
100
+ ...navigationItems.filter((n) => n.title.toLowerCase().includes(searchLower)),
101
+ ...actionItems.filter((a) => a.title.toLowerCase().includes(searchLower)),
102
+ ...searchResults,
103
+ ]);
104
+ } catch {
105
+ const [bookmarksRes, foldersRes, tagsRes] = await Promise.all([
106
+ api.get('/bookmarks', { params: { limit: 100 } }),
107
+ api.get('/folders'),
108
+ api.get('/tags'),
109
+ ]);
110
+ const bookmarksPayload = bookmarksRes.data;
111
+ const bookmarksItems = bookmarksPayload?.items ?? bookmarksPayload ?? [];
112
+
113
+ const bookmarkResults: SearchResult[] = (Array.isArray(bookmarksItems) ? bookmarksItems : [])
114
+ .filter((b: { title?: string; url?: string; slug?: string }) =>
115
+ (b.title?.toLowerCase().includes(searchLower)) ||
116
+ (b.url?.toLowerCase().includes(searchLower)) ||
117
+ (b.slug?.toLowerCase().includes(searchLower))
118
+ )
119
+ .slice(0, 5)
120
+ .map((b: { id: string; title: string; url?: string }) => ({
121
+ id: b.id,
122
+ type: 'bookmark' as const,
123
+ title: b.title,
124
+ url: b.url,
125
+ }));
126
+
127
+ const folderResults: SearchResult[] = foldersRes.data
128
+ .filter((f: { name: string }) => f.name.toLowerCase().includes(searchLower))
129
+ .slice(0, 3)
130
+ .map((f: { id: string; name: string; icon?: string | null }) => ({
131
+ id: f.id,
132
+ type: 'folder' as const,
133
+ title: f.name,
134
+ icon: f.icon,
135
+ }));
136
+
137
+ const tagResults: SearchResult[] = tagsRes.data
138
+ .filter((tag: { name: string }) => tag.name.toLowerCase().includes(searchLower))
139
+ .slice(0, 3)
140
+ .map((tag: { id: string; name: string }) => ({
141
+ id: tag.id,
142
+ type: 'tag' as const,
143
+ title: tag.name,
144
+ }));
145
+
146
+ setResults([
147
+ ...navigationItems.filter((n) => n.title.toLowerCase().includes(searchLower)),
148
+ ...actionItems.filter((a) => a.title.toLowerCase().includes(searchLower)),
149
+ ...bookmarkResults,
150
+ ...folderResults,
151
+ ...tagResults,
152
+ ]);
153
+ }
154
+ } catch (error) {
155
+ console.error('Search failed:', error);
156
+ } finally {
157
+ setLoading(false);
158
+ }
159
+ }, 300);
160
+
161
+ return () => clearTimeout(searchTimeout);
162
+ }, [query, open, navigationItems, actionItems]);
163
+
164
+ function handleResultClick(result: SearchResult) {
165
+ setOpen(false);
166
+ setQuery('');
167
+
168
+ if (result.type === 'navigation' && result.path) {
169
+ navigate(result.path);
170
+ } else if (result.type === 'action' && result.action) {
171
+ result.action();
172
+ } else if (result.type === 'bookmark' && result.url) {
173
+ api.post(`/bookmarks/${result.id}/track-access`).catch(() => {});
174
+ window.open(result.url, '_blank', 'noopener,noreferrer');
175
+ } else if (result.type === 'folder') {
176
+ navigate(`${appBasePath}/bookmarks?folder_id=${result.id}`);
177
+ } else if (result.type === 'tag') {
178
+ navigate(`${appBasePath}/bookmarks?tag_id=${result.id}`);
179
+ }
180
+ }
181
+
182
+ function getResultIcon(result: SearchResult) {
183
+ switch (result.type) {
184
+ case 'bookmark':
185
+ return <Bookmark className="h-4 w-4" />;
186
+ case 'folder':
187
+ return <Folder className="h-4 w-4" />;
188
+ case 'tag':
189
+ return <Tag className="h-4 w-4" />;
190
+ case 'navigation':
191
+ return <ArrowRight className="h-4 w-4" />;
192
+ case 'action':
193
+ return <Plus className="h-4 w-4" />;
194
+ }
195
+ }
196
+
197
+ return (
198
+ <>
199
+ <button
200
+ type="button"
201
+ onClick={openSearch}
202
+ className="hidden md:flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm text-muted-foreground bg-muted/80 hover:bg-accent/80 rounded-xl border border-border hover:border-primary/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
203
+ >
204
+ <Search className="h-5 w-5 shrink-0" />
205
+ <span className="flex-1 truncate">{t('dashboard.searchPlaceholder')}</span>
206
+ <kbd className="shrink-0 inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-muted-foreground bg-background border border-border rounded">
207
+ {navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}K
208
+ </kbd>
209
+ </button>
210
+
211
+ <CommandDialog open={open} onOpenChange={setOpen} shouldFilter={false}>
212
+ <CommandInput
213
+ placeholder={t('dashboard.searchPlaceholder')}
214
+ value={query}
215
+ onValueChange={setQuery}
216
+ />
217
+ <CommandList className="max-h-[60vh]">
218
+ <CommandEmpty>
219
+ {loading ? t('common.loading') : t('common.noResults')}
220
+ </CommandEmpty>
221
+ {!query.trim() ? (
222
+ <>
223
+ <CommandGroup heading={t('common.navigation')}>
224
+ {navigationItems.map((item) => (
225
+ <CommandItem
226
+ key={item.id}
227
+ value={item.id}
228
+ onSelect={() => handleResultClick(item)}
229
+ >
230
+ {getResultIcon(item)}
231
+ <span>{item.title}</span>
232
+ </CommandItem>
233
+ ))}
234
+ </CommandGroup>
235
+ <CommandSeparator />
236
+ <CommandGroup heading={t('common.quickActions')}>
237
+ {actionItems.map((item) => (
238
+ <CommandItem
239
+ key={item.id}
240
+ value={item.id}
241
+ onSelect={() => handleResultClick(item)}
242
+ >
243
+ {getResultIcon(item)}
244
+ <span>{item.title}</span>
245
+ </CommandItem>
246
+ ))}
247
+ </CommandGroup>
248
+ </>
249
+ ) : (
250
+ results.map((result) => (
251
+ <CommandItem
252
+ key={`${result.type}-${result.id}`}
253
+ value={`${result.type}-${result.id}-${result.title}`}
254
+ onSelect={() => handleResultClick(result)}
255
+ >
256
+ {getResultIcon(result)}
257
+ <div className="flex-1 min-w-0">
258
+ <div className="flex items-center gap-2">
259
+ <span className="truncate font-medium">{result.title}</span>
260
+ {result.type === 'bookmark' && (
261
+ <ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
262
+ )}
263
+ </div>
264
+ {result.url && (
265
+ <div className="text-xs text-muted-foreground truncate mt-0.5">{result.url}</div>
266
+ )}
267
+ </div>
268
+ </CommandItem>
269
+ ))
270
+ )}
271
+ </CommandList>
272
+ </CommandDialog>
273
+ </>
274
+ );
275
+ }
@@ -0,0 +1,60 @@
1
+ import { Outlet } from 'react-router-dom';
2
+ import { Suspense, useEffect, useState } from 'react';
3
+ import { useAuth } from '../contexts/AuthContext';
4
+ import { useTranslation } from 'react-i18next';
5
+ import { SearchCommandProvider } from '../contexts/SearchCommandContext';
6
+ import { SidebarProvider, SidebarInset } from './ui/sidebar';
7
+ import TopBar from './TopBar';
8
+ import AppSidebar from './AppSidebar';
9
+ import api from '../api/client';
10
+
11
+ const SIDEBAR_COLLAPSED_KEY = 'slugbase_sidebar_collapsed';
12
+
13
+ export default function Layout() {
14
+ const { user } = useAuth();
15
+ const { t } = useTranslation();
16
+ const [version, setVersion] = useState<string | null>(null);
17
+ const [sidebarOpen, setSidebarOpen] = useState(
18
+ () => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) !== 'true'
19
+ );
20
+
21
+ useEffect(() => {
22
+ localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(!sidebarOpen));
23
+ }, [sidebarOpen]);
24
+
25
+ useEffect(() => {
26
+ api.get('/version')
27
+ .then((res) => {
28
+ if (res.data.commit) {
29
+ setVersion(res.data.commit.substring(0, 7));
30
+ }
31
+ })
32
+ .catch(() => {});
33
+ }, []);
34
+
35
+ return (
36
+ <SearchCommandProvider>
37
+ <SidebarProvider open={sidebarOpen} onOpenChange={setSidebarOpen} className="h-svh overflow-hidden bg-background flex flex-col">
38
+ <TopBar user={user} />
39
+ <div className="flex flex-1 min-h-0 overflow-hidden">
40
+ <AppSidebar user={user} version={version} />
41
+ <SidebarInset className="flex flex-col min-h-0 overflow-hidden">
42
+ <div className="flex-1 min-h-0 overflow-y-auto">
43
+ <div className="px-4 sm:px-6 lg:px-8 py-8 w-full min-h-full">
44
+ <Suspense
45
+ fallback={
46
+ <div className="min-h-[400px] flex items-center justify-center">
47
+ <div className="text-muted-foreground">{t('common.loading')}</div>
48
+ </div>
49
+ }
50
+ >
51
+ <Outlet />
52
+ </Suspense>
53
+ </div>
54
+ </div>
55
+ </SidebarInset>
56
+ </div>
57
+ </SidebarProvider>
58
+ </SearchCommandProvider>
59
+ );
60
+ }
@@ -0,0 +1,31 @@
1
+ import * as React from 'react';
2
+ import { cn } from '@/lib/utils';
3
+
4
+ interface PageHeaderProps {
5
+ title: string;
6
+ subtitle?: string;
7
+ actions?: React.ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ export function PageHeader({ title, subtitle, actions, className }: PageHeaderProps) {
12
+ return (
13
+ <div className={cn('flex items-start justify-between gap-4 flex-wrap', className)}>
14
+ <div className="space-y-1">
15
+ <h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
16
+ {title}
17
+ </h1>
18
+ {subtitle && (
19
+ <p className="text-sm text-gray-600 dark:text-gray-400">
20
+ {subtitle}
21
+ </p>
22
+ )}
23
+ </div>
24
+ {actions && (
25
+ <div className="flex items-center gap-2">
26
+ {actions}
27
+ </div>
28
+ )}
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,42 @@
1
+ export interface ScopeOption {
2
+ value: string;
3
+ label: string;
4
+ }
5
+
6
+ interface ScopeSegmentedControlProps {
7
+ value: string;
8
+ onChange: (value: string) => void;
9
+ options: ScopeOption[];
10
+ ariaLabel?: string;
11
+ }
12
+
13
+ export function ScopeSegmentedControl({ value, onChange, options, ariaLabel }: ScopeSegmentedControlProps) {
14
+ return (
15
+ <div
16
+ className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5"
17
+ role="group"
18
+ aria-label={ariaLabel}
19
+ >
20
+ {options.map((opt) => (
21
+ <button
22
+ key={opt.value}
23
+ type="button"
24
+ onClick={() => onChange(opt.value)}
25
+ onKeyDown={(e) => {
26
+ if (e.key === 'Enter' || e.key === ' ') {
27
+ e.preventDefault();
28
+ onChange(opt.value);
29
+ }
30
+ }}
31
+ className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
32
+ value === opt.value ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
33
+ }`}
34
+ aria-pressed={value === opt.value}
35
+ aria-label={opt.label}
36
+ >
37
+ {opt.label}
38
+ </button>
39
+ ))}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,32 @@
1
+ import * as Sentry from '@sentry/react';
2
+
3
+ /**
4
+ * Floating debug buttons for Sentry testing.
5
+ * Only visible when VITE_SENTRY_DEBUG=true at build time.
6
+ */
7
+ export function SentryDebug() {
8
+ if (import.meta.env.VITE_SENTRY_DEBUG !== 'true') return null;
9
+
10
+ return (
11
+ <div className="fixed bottom-4 right-4 z-50 flex gap-2">
12
+ <button
13
+ type="button"
14
+ onClick={() => {
15
+ throw new Error('Sentry frontend test error (throw)');
16
+ }}
17
+ className="px-3 py-1.5 text-sm rounded-md bg-red-100 dark:bg-red-900/80 text-red-800 dark:text-red-200 hover:opacity-90"
18
+ >
19
+ Test Sentry (throw)
20
+ </button>
21
+ <button
22
+ type="button"
23
+ onClick={() => {
24
+ Sentry.captureException(new Error('Sentry frontend test (capture)'));
25
+ }}
26
+ className="px-3 py-1.5 text-sm rounded-md bg-amber-100 dark:bg-amber-900/80 text-amber-800 dark:text-amber-200 hover:opacity-90"
27
+ >
28
+ Test Sentry (capture)
29
+ </button>
30
+ </div>
31
+ );
32
+ }
@@ -0,0 +1,66 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { LucideIcon } from 'lucide-react';
3
+ import { Card, CardContent } from './ui/card';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ interface StatCardProps {
7
+ label: string;
8
+ value: string | number;
9
+ icon: LucideIcon;
10
+ href?: string;
11
+ /** Use compact padding and smaller icon for dense layouts */
12
+ dense?: boolean;
13
+ /** Tailwind classes for the icon container (e.g. bg-blue-100 dark:bg-blue-900/20) */
14
+ iconContainerClassName?: string;
15
+ /** Tailwind classes for the icon color (e.g. text-blue-600 dark:text-blue-400) */
16
+ iconColorClassName?: string;
17
+ className?: string;
18
+ }
19
+
20
+ export function StatCard({ label, value, icon: Icon, href, dense, iconContainerClassName, iconColorClassName, className }: StatCardProps) {
21
+ const content = (
22
+ <>
23
+ <div className="flex items-center justify-between">
24
+ <div>
25
+ <p className={cn('font-medium text-muted-foreground', dense ? 'text-xs' : 'text-sm')}>
26
+ {label}
27
+ </p>
28
+ <p className={cn('font-semibold mt-2', dense ? 'text-xl' : 'text-2xl')}>
29
+ {value}
30
+ </p>
31
+ </div>
32
+ <div className={cn('rounded-lg', iconContainerClassName ?? 'bg-muted', dense ? 'p-2' : 'p-3')}>
33
+ <Icon className={cn(iconColorClassName ?? 'text-muted-foreground', dense ? 'h-5 w-5' : 'h-6 w-6')} />
34
+ </div>
35
+ </div>
36
+ </>
37
+ );
38
+
39
+ const cardClassName = cn(
40
+ 'transition-colors',
41
+ href && 'cursor-pointer hover:border-primary/70 hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 rounded-xl',
42
+ className
43
+ );
44
+
45
+ const contentPadding = dense ? 'p-3' : 'p-4';
46
+
47
+ if (href) {
48
+ return (
49
+ <Link to={href} className="block focus:outline-none">
50
+ <Card className={cardClassName}>
51
+ <CardContent className={contentPadding}>
52
+ {content}
53
+ </CardContent>
54
+ </Card>
55
+ </Link>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <Card className={cardClassName}>
61
+ <CardContent className={contentPadding}>
62
+ {content}
63
+ </CardContent>
64
+ </Card>
65
+ );
66
+ }
@@ -0,0 +1,63 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { Plus } from 'lucide-react';
4
+ import Button from './ui/Button';
5
+ import GlobalSearch from './GlobalSearch';
6
+ import UserDropdown from './UserDropdown';
7
+ import { SidebarTrigger, useSidebar } from './ui/sidebar';
8
+ import { useAppConfig } from '../contexts/AppConfigContext';
9
+ import type { User } from '../contexts/AuthContext';
10
+
11
+ interface TopBarProps {
12
+ user: User | null;
13
+ }
14
+
15
+ export default function TopBar({ user }: TopBarProps) {
16
+ const { t } = useTranslation();
17
+ const { appBasePath } = useAppConfig();
18
+ const { isMobile } = useSidebar();
19
+
20
+ return (
21
+ <header className="sticky top-0 z-50 flex h-14 shrink-0 items-center border-b bg-background px-4 lg:h-16 lg:px-6 relative">
22
+ {/* Left: SidebarTrigger (mobile) + Logo — left-aligned */}
23
+ <div className="flex items-center gap-3 shrink-0">
24
+ {isMobile && (
25
+ <SidebarTrigger className="-ml-2" aria-label={t('common.expandSidebar')} />
26
+ )}
27
+ <Link
28
+ to={appBasePath || '/'}
29
+ className="flex items-center gap-2 text-xl font-bold text-foreground hover:text-primary transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg"
30
+ >
31
+ <img
32
+ src="/slugbase_icon_blue.svg"
33
+ alt=""
34
+ className="h-10 w-10 lg:h-12 lg:w-12 dark:hidden"
35
+ />
36
+ <img
37
+ src="/slugbase_icon_white.svg"
38
+ alt=""
39
+ className="h-10 w-10 lg:h-12 lg:w-12 hidden dark:block"
40
+ />
41
+ <span className="hidden sm:inline">{t('app.name')}</span>
42
+ </Link>
43
+ </div>
44
+
45
+ {/* Center: search bar — truly centered in header */}
46
+ <div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl px-4 pointer-events-none">
47
+ <div className="pointer-events-auto w-full">
48
+ <GlobalSearch />
49
+ </div>
50
+ </div>
51
+
52
+ {/* Right: Create bookmark + Profile — right-aligned */}
53
+ <div className="flex items-center gap-2 sm:gap-4 shrink-0 ml-auto">
54
+ <Link to={`${appBasePath}/bookmarks?create=true`}>
55
+ <Button variant="primary" size="sm" icon={Plus}>
56
+ <span className="hidden sm:inline">{t('bookmarks.create')}</span>
57
+ </Button>
58
+ </Link>
59
+ <UserDropdown user={user} />
60
+ </div>
61
+ </header>
62
+ );
63
+ }
@@ -0,0 +1,86 @@
1
+ import { Link } from 'react-router-dom';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { User as UserIcon, LogOut, Settings } from 'lucide-react';
4
+ import { useAuth } from '../contexts/AuthContext';
5
+ import { useAppConfig } from '../contexts/AppConfigContext';
6
+ import type { User } from '../contexts/AuthContext';
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from './ui/dropdown-menu';
15
+
16
+ interface UserDropdownProps {
17
+ user: User | null;
18
+ }
19
+
20
+ function getInitials(name: string): string {
21
+ const parts = name.trim().split(/\s+/);
22
+ if (parts.length >= 2) {
23
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
24
+ }
25
+ return name.slice(0, 2).toUpperCase();
26
+ }
27
+
28
+ export default function UserDropdown({ user }: UserDropdownProps) {
29
+ const { t } = useTranslation();
30
+ const { appBasePath } = useAppConfig();
31
+ const { logout } = useAuth();
32
+
33
+ const showAdmin = user?.is_admin;
34
+
35
+ if (!user) return null;
36
+
37
+ return (
38
+ <DropdownMenu>
39
+ <DropdownMenuTrigger asChild>
40
+ <button
41
+ type="button"
42
+ className="flex items-center justify-center w-9 h-9 rounded-full bg-secondary text-secondary-foreground font-medium text-sm hover:bg-accent hover:text-accent-foreground transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
43
+ aria-haspopup="menu"
44
+ aria-label={t('profile.title')}
45
+ >
46
+ {user.name ? (
47
+ getInitials(user.name)
48
+ ) : (
49
+ <UserIcon className="h-5 w-5" />
50
+ )}
51
+ </button>
52
+ </DropdownMenuTrigger>
53
+ <DropdownMenuContent align="end" className="w-56">
54
+ <DropdownMenuLabel>
55
+ <div className="flex flex-col space-y-1">
56
+ <p className="text-sm font-medium leading-none">{user.name}</p>
57
+ <p className="text-xs leading-none text-muted-foreground">{user.email}</p>
58
+ </div>
59
+ </DropdownMenuLabel>
60
+ <DropdownMenuSeparator />
61
+ <DropdownMenuItem asChild>
62
+ <Link to={`${appBasePath}/profile`} className="flex items-center gap-2 cursor-pointer">
63
+ <UserIcon className="h-4 w-4" />
64
+ {t('profile.title')}
65
+ </Link>
66
+ </DropdownMenuItem>
67
+ {showAdmin && (
68
+ <DropdownMenuItem asChild>
69
+ <Link to={`${appBasePath}/admin/members`} className="flex items-center gap-2 cursor-pointer">
70
+ <Settings className="h-4 w-4" />
71
+ {t('admin.title')}
72
+ </Link>
73
+ </DropdownMenuItem>
74
+ )}
75
+ <DropdownMenuSeparator />
76
+ <DropdownMenuItem
77
+ onClick={() => logout()}
78
+ className="flex items-center gap-2 cursor-pointer text-destructive focus:text-destructive"
79
+ >
80
+ <LogOut className="h-4 w-4" />
81
+ {t('auth.logout')}
82
+ </DropdownMenuItem>
83
+ </DropdownMenuContent>
84
+ </DropdownMenu>
85
+ );
86
+ }