@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.
- package/frontend/index.tsx +3 -3
- package/frontend/public/favicon.svg +1 -0
- package/frontend/public/slugbase_icon_blue.svg +1 -0
- package/frontend/public/slugbase_icon_white.png +0 -0
- package/frontend/public/slugbase_icon_white.svg +1 -0
- package/frontend/src/App.tsx +179 -0
- package/frontend/src/api/client.ts +134 -0
- package/frontend/src/components/AppSidebar.tsx +214 -0
- package/frontend/src/components/EmptyState.tsx +33 -0
- package/frontend/src/components/Favicon.tsx +76 -0
- package/frontend/src/components/FilterChips.tsx +60 -0
- package/frontend/src/components/FolderIcon.tsx +207 -0
- package/frontend/src/components/GlobalSearch.tsx +275 -0
- package/frontend/src/components/Layout.tsx +60 -0
- package/frontend/src/components/PageHeader.tsx +31 -0
- package/frontend/src/components/ScopeSegmentedControl.tsx +42 -0
- package/frontend/src/components/SentryDebug.tsx +32 -0
- package/frontend/src/components/StatCard.tsx +66 -0
- package/frontend/src/components/TopBar.tsx +63 -0
- package/frontend/src/components/UserDropdown.tsx +86 -0
- package/frontend/src/components/admin/AdminAI.tsx +207 -0
- package/frontend/src/components/admin/AdminOIDCProviders.tsx +183 -0
- package/frontend/src/components/admin/AdminSettings.tsx +413 -0
- package/frontend/src/components/admin/AdminTeams.tsx +177 -0
- package/frontend/src/components/admin/AdminUsers.tsx +225 -0
- package/frontend/src/components/bookmarks/BookmarkCard.tsx +312 -0
- package/frontend/src/components/bookmarks/BookmarkListItem.tsx +159 -0
- package/frontend/src/components/bookmarks/BookmarkTableView.tsx +419 -0
- package/frontend/src/components/bookmarks/BulkActionModals.tsx +162 -0
- package/frontend/src/components/bookmarks/FilterChips.tsx +5 -0
- package/frontend/src/components/modals/BookmarkModal.tsx +493 -0
- package/frontend/src/components/modals/FolderModal.tsx +306 -0
- package/frontend/src/components/modals/ImportModal.tsx +232 -0
- package/frontend/src/components/modals/OIDCProviderModal.tsx +284 -0
- package/frontend/src/components/modals/SharingModal.tsx +96 -0
- package/frontend/src/components/modals/TagModal.tsx +101 -0
- package/frontend/src/components/modals/TeamAssignmentModal.tsx +354 -0
- package/frontend/src/components/modals/TeamModal.tsx +117 -0
- package/frontend/src/components/modals/UserModal.tsx +225 -0
- package/frontend/src/components/profile/CreateTokenModal.tsx +172 -0
- package/frontend/src/components/sharing/ShareResourceDialog.tsx +422 -0
- package/frontend/src/components/ui/Autocomplete.tsx +155 -0
- package/frontend/src/components/ui/Button.tsx +68 -0
- package/frontend/src/components/ui/ConfirmDialog.tsx +79 -0
- package/frontend/src/components/ui/FormFieldWrapper.tsx +36 -0
- package/frontend/src/components/ui/ModalFooterActions.tsx +49 -0
- package/frontend/src/components/ui/ModalSection.tsx +34 -0
- package/frontend/src/components/ui/PageLoadingSkeleton.tsx +24 -0
- package/frontend/src/components/ui/Select.tsx +61 -0
- package/frontend/src/components/ui/SharingField.tsx +298 -0
- package/frontend/src/components/ui/Toast.tsx +47 -0
- package/frontend/src/components/ui/Tooltip.tsx +21 -0
- package/frontend/src/components/ui/alert-dialog.tsx +139 -0
- package/frontend/src/components/ui/badge.tsx +36 -0
- package/frontend/src/components/ui/button-base.tsx +57 -0
- package/frontend/src/components/ui/card.tsx +76 -0
- package/frontend/src/components/ui/command.tsx +161 -0
- package/frontend/src/components/ui/dialog.tsx +120 -0
- package/frontend/src/components/ui/dropdown-menu.tsx +199 -0
- package/frontend/src/components/ui/input.tsx +22 -0
- package/frontend/src/components/ui/label.tsx +24 -0
- package/frontend/src/components/ui/popover.tsx +33 -0
- package/frontend/src/components/ui/progress.tsx +26 -0
- package/frontend/src/components/ui/scroll-area.tsx +48 -0
- package/frontend/src/components/ui/select-base.tsx +159 -0
- package/frontend/src/components/ui/separator.tsx +29 -0
- package/frontend/src/components/ui/sheet.tsx +140 -0
- package/frontend/src/components/ui/sidebar.tsx +783 -0
- package/frontend/src/components/ui/skeleton.tsx +15 -0
- package/frontend/src/components/ui/sonner.tsx +46 -0
- package/frontend/src/components/ui/switch.tsx +28 -0
- package/frontend/src/components/ui/table.tsx +120 -0
- package/frontend/src/components/ui/tooltip-base.tsx +30 -0
- package/frontend/src/config/api.ts +16 -0
- package/frontend/src/config/mode.ts +6 -0
- package/frontend/src/contexts/AppConfigContext.tsx +39 -0
- package/frontend/src/contexts/AuthContext.tsx +137 -0
- package/frontend/src/contexts/SearchCommandContext.tsx +28 -0
- package/frontend/src/hooks/use-mobile.tsx +19 -0
- package/frontend/src/hooks/useConfirmDialog.ts +63 -0
- package/frontend/src/hooks/useMarketingTheme.ts +47 -0
- package/frontend/src/i18n.ts +39 -0
- package/frontend/src/index.css +117 -0
- package/frontend/src/instrument.ts +20 -0
- package/frontend/src/lib/utils.ts +6 -0
- package/frontend/src/locales/de.json +899 -0
- package/frontend/src/locales/en.json +937 -0
- package/frontend/src/locales/es.json +884 -0
- package/frontend/src/locales/fr.json +550 -0
- package/frontend/src/locales/it.json +535 -0
- package/frontend/src/locales/ja.json +535 -0
- package/frontend/src/locales/nl.json +550 -0
- package/frontend/src/locales/pl.json +535 -0
- package/frontend/src/locales/pt.json +535 -0
- package/frontend/src/locales/ru.json +535 -0
- package/frontend/src/locales/zh.json +535 -0
- package/frontend/src/main.tsx +44 -0
- package/frontend/src/pages/Bookmarks.tsx +1004 -0
- package/frontend/src/pages/Dashboard.tsx +427 -0
- package/frontend/src/pages/Folders.tsx +578 -0
- package/frontend/src/pages/GoPreferences.tsx +134 -0
- package/frontend/src/pages/Login.tsx +196 -0
- package/frontend/src/pages/PasswordReset.tsx +242 -0
- package/frontend/src/pages/Profile.tsx +593 -0
- package/frontend/src/pages/SearchEngineGuide.tsx +135 -0
- package/frontend/src/pages/Setup.tsx +210 -0
- package/frontend/src/pages/Signup.tsx +199 -0
- package/frontend/src/pages/Tags.tsx +421 -0
- package/frontend/src/pages/VerifyEmail.tsx +254 -0
- package/frontend/src/pages/admin/AdminAIPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminLayout.tsx +40 -0
- package/frontend/src/pages/admin/AdminMembersPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminOIDCPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminSettingsPage.tsx +5 -0
- package/frontend/src/pages/admin/AdminTeamsPage.tsx +5 -0
- package/frontend/src/utils/favicon.ts +36 -0
- package/frontend/src/utils/formatRelativeTime.ts +37 -0
- package/frontend/src/utils/safeHref.ts +31 -0
- package/frontend/src/vite-env.d.ts +10 -0
- package/package.json +9 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Bookmark,
|
|
6
|
+
Folder,
|
|
7
|
+
Tag,
|
|
8
|
+
Settings,
|
|
9
|
+
LayoutDashboard,
|
|
10
|
+
ChevronLeft,
|
|
11
|
+
ChevronRight,
|
|
12
|
+
ChevronDown,
|
|
13
|
+
Github,
|
|
14
|
+
Users,
|
|
15
|
+
UserCog,
|
|
16
|
+
Key,
|
|
17
|
+
Sparkles,
|
|
18
|
+
} from 'lucide-react';
|
|
19
|
+
import {
|
|
20
|
+
Sidebar,
|
|
21
|
+
SidebarContent,
|
|
22
|
+
SidebarFooter,
|
|
23
|
+
SidebarGroup,
|
|
24
|
+
SidebarGroupContent,
|
|
25
|
+
SidebarMenu,
|
|
26
|
+
SidebarMenuButton,
|
|
27
|
+
SidebarMenuItem,
|
|
28
|
+
SidebarSeparator,
|
|
29
|
+
useSidebar,
|
|
30
|
+
} from './ui/sidebar';
|
|
31
|
+
import { useAppConfig } from '../contexts/AppConfigContext';
|
|
32
|
+
import type { User } from '../contexts/AuthContext';
|
|
33
|
+
import { cn } from '@/lib/utils';
|
|
34
|
+
|
|
35
|
+
const SIDEBAR_ADMIN_OPEN_KEY = 'slugbase_sidebar_admin_open';
|
|
36
|
+
|
|
37
|
+
interface AppSidebarProps {
|
|
38
|
+
user: User | null;
|
|
39
|
+
version?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function AppSidebar({ user, version = null }: AppSidebarProps) {
|
|
43
|
+
const { t } = useTranslation();
|
|
44
|
+
const location = useLocation();
|
|
45
|
+
const pathname = location.pathname;
|
|
46
|
+
const { appBasePath } = useAppConfig();
|
|
47
|
+
const { setOpenMobile, toggleSidebar, isMobile, state } = useSidebar();
|
|
48
|
+
const adminBase = `${appBasePath || ''}/admin`;
|
|
49
|
+
|
|
50
|
+
const adminNavItems = [
|
|
51
|
+
{ path: `${adminBase}/members`, label: t('admin.users'), icon: Users },
|
|
52
|
+
{ path: `${adminBase}/teams`, label: t('admin.teams'), icon: UserCog },
|
|
53
|
+
{ path: `${adminBase}/oidc`, label: t('admin.oidcProviders'), icon: Key },
|
|
54
|
+
{ path: `${adminBase}/settings`, label: t('admin.settings'), icon: Settings },
|
|
55
|
+
{ path: `${adminBase}/ai`, label: t('admin.ai.nav'), icon: Sparkles },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const isOverviewActive =
|
|
59
|
+
pathname === appBasePath ||
|
|
60
|
+
pathname === appBasePath + '/' ||
|
|
61
|
+
pathname === (appBasePath || '/');
|
|
62
|
+
|
|
63
|
+
const showAdmin = user?.is_admin;
|
|
64
|
+
const [adminOpen, setAdminOpen] = useState(() => {
|
|
65
|
+
if (typeof window === 'undefined') return true;
|
|
66
|
+
const stored = localStorage.getItem(SIDEBAR_ADMIN_OPEN_KEY);
|
|
67
|
+
return stored !== 'false';
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
localStorage.setItem(SIDEBAR_ADMIN_OPEN_KEY, String(adminOpen));
|
|
72
|
+
}, [adminOpen]);
|
|
73
|
+
|
|
74
|
+
const primaryNavItems = [
|
|
75
|
+
{ path: appBasePath || '/', label: t('dashboard.overview'), icon: LayoutDashboard },
|
|
76
|
+
{ path: `${appBasePath}/bookmarks`, label: t('bookmarks.title'), icon: Bookmark },
|
|
77
|
+
{ path: `${appBasePath}/folders`, label: t('folders.title'), icon: Folder },
|
|
78
|
+
{ path: `${appBasePath}/tags`, label: t('tags.title'), icon: Tag },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const handleNavClick = () => {
|
|
82
|
+
if (isMobile) {
|
|
83
|
+
setOpenMobile(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Sidebar collapsible="icon" side="left">
|
|
89
|
+
<SidebarContent>
|
|
90
|
+
<SidebarGroup>
|
|
91
|
+
<SidebarGroupContent>
|
|
92
|
+
<SidebarMenu>
|
|
93
|
+
{primaryNavItems.map((item) => (
|
|
94
|
+
<SidebarMenuItem key={item.path}>
|
|
95
|
+
<SidebarMenuButton
|
|
96
|
+
asChild
|
|
97
|
+
isActive={
|
|
98
|
+
item.path === (appBasePath || '/') ? isOverviewActive : pathname === item.path
|
|
99
|
+
}
|
|
100
|
+
tooltip={item.label}
|
|
101
|
+
>
|
|
102
|
+
<Link to={item.path} onClick={handleNavClick} aria-current={pathname === item.path ? 'page' : undefined}>
|
|
103
|
+
<item.icon className="h-5 w-5" />
|
|
104
|
+
<span>{item.label}</span>
|
|
105
|
+
</Link>
|
|
106
|
+
</SidebarMenuButton>
|
|
107
|
+
</SidebarMenuItem>
|
|
108
|
+
))}
|
|
109
|
+
</SidebarMenu>
|
|
110
|
+
</SidebarGroupContent>
|
|
111
|
+
</SidebarGroup>
|
|
112
|
+
|
|
113
|
+
{showAdmin && (
|
|
114
|
+
<>
|
|
115
|
+
<SidebarSeparator />
|
|
116
|
+
<SidebarGroup>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => setAdminOpen((prev) => !prev)}
|
|
120
|
+
data-sidebar="group-label"
|
|
121
|
+
className={cn(
|
|
122
|
+
'flex h-8 w-full shrink-0 items-center gap-2 overflow-hidden rounded-md px-2 text-left text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
|
123
|
+
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
|
124
|
+
'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 group-data-[collapsible=icon]:!rounded-lg'
|
|
125
|
+
)}
|
|
126
|
+
aria-expanded={adminOpen}
|
|
127
|
+
>
|
|
128
|
+
{adminOpen ? (
|
|
129
|
+
<ChevronDown className="h-5 w-5 shrink-0" />
|
|
130
|
+
) : (
|
|
131
|
+
<ChevronRight className="h-5 w-5 shrink-0" />
|
|
132
|
+
)}
|
|
133
|
+
<span className="truncate group-data-[collapsible=icon]:hidden">{t('admin.title')}</span>
|
|
134
|
+
</button>
|
|
135
|
+
{adminOpen && (
|
|
136
|
+
<SidebarGroupContent>
|
|
137
|
+
<SidebarMenu>
|
|
138
|
+
{adminNavItems.map((item) => {
|
|
139
|
+
const Icon = item.icon;
|
|
140
|
+
return (
|
|
141
|
+
<SidebarMenuItem key={item.path}>
|
|
142
|
+
<SidebarMenuButton
|
|
143
|
+
asChild
|
|
144
|
+
isActive={pathname === item.path}
|
|
145
|
+
tooltip={item.label}
|
|
146
|
+
>
|
|
147
|
+
<Link
|
|
148
|
+
to={item.path}
|
|
149
|
+
onClick={handleNavClick}
|
|
150
|
+
aria-current={pathname === item.path ? 'page' : undefined}
|
|
151
|
+
>
|
|
152
|
+
<Icon className="h-5 w-5" />
|
|
153
|
+
<span>{item.label}</span>
|
|
154
|
+
</Link>
|
|
155
|
+
</SidebarMenuButton>
|
|
156
|
+
</SidebarMenuItem>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</SidebarMenu>
|
|
160
|
+
</SidebarGroupContent>
|
|
161
|
+
)}
|
|
162
|
+
</SidebarGroup>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</SidebarContent>
|
|
166
|
+
|
|
167
|
+
<SidebarFooter>
|
|
168
|
+
<SidebarGroup>
|
|
169
|
+
<SidebarGroupContent>
|
|
170
|
+
<SidebarMenu>
|
|
171
|
+
<SidebarMenuItem>
|
|
172
|
+
<SidebarMenuButton
|
|
173
|
+
asChild
|
|
174
|
+
tooltip="GitHub Repository"
|
|
175
|
+
>
|
|
176
|
+
<a
|
|
177
|
+
href="https://github.com/mdg-labs/slugbase"
|
|
178
|
+
target="_blank"
|
|
179
|
+
rel="noopener noreferrer"
|
|
180
|
+
aria-label="GitHub Repository"
|
|
181
|
+
>
|
|
182
|
+
<Github className="h-5 w-5" />
|
|
183
|
+
<span>GitHub</span>
|
|
184
|
+
{version && state === 'expanded' && (
|
|
185
|
+
<span className="ml-1 truncate text-xs text-muted-foreground font-mono">
|
|
186
|
+
{version}
|
|
187
|
+
</span>
|
|
188
|
+
)}
|
|
189
|
+
</a>
|
|
190
|
+
</SidebarMenuButton>
|
|
191
|
+
</SidebarMenuItem>
|
|
192
|
+
{!isMobile && (
|
|
193
|
+
<SidebarMenuItem>
|
|
194
|
+
<SidebarMenuButton
|
|
195
|
+
onClick={toggleSidebar}
|
|
196
|
+
tooltip={state === 'collapsed' ? t('common.expandSidebar') : t('common.collapseSidebar')}
|
|
197
|
+
aria-label={state === 'collapsed' ? t('common.expandSidebar') : t('common.collapseSidebar')}
|
|
198
|
+
>
|
|
199
|
+
{state === 'collapsed' ? (
|
|
200
|
+
<ChevronRight className="h-5 w-5" />
|
|
201
|
+
) : (
|
|
202
|
+
<ChevronLeft className="h-5 w-5" />
|
|
203
|
+
)}
|
|
204
|
+
<span>{t('common.collapseSidebar')}</span>
|
|
205
|
+
</SidebarMenuButton>
|
|
206
|
+
</SidebarMenuItem>
|
|
207
|
+
)}
|
|
208
|
+
</SidebarMenu>
|
|
209
|
+
</SidebarGroupContent>
|
|
210
|
+
</SidebarGroup>
|
|
211
|
+
</SidebarFooter>
|
|
212
|
+
</Sidebar>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { LucideIcon } from 'lucide-react';
|
|
3
|
+
import { Card, CardContent } from './ui/card';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface EmptyStateProps {
|
|
7
|
+
icon: LucideIcon;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
action?: React.ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
|
|
15
|
+
return (
|
|
16
|
+
<Card className={cn('flex flex-col items-center justify-center py-16 px-6', className)}>
|
|
17
|
+
<CardContent className="flex flex-col items-center text-center">
|
|
18
|
+
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
|
19
|
+
<Icon className="h-8 w-8 text-muted-foreground" />
|
|
20
|
+
</div>
|
|
21
|
+
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
|
22
|
+
{title}
|
|
23
|
+
</h3>
|
|
24
|
+
{description && (
|
|
25
|
+
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6 max-w-md">
|
|
26
|
+
{description}
|
|
27
|
+
</p>
|
|
28
|
+
)}
|
|
29
|
+
{action && <div>{action}</div>}
|
|
30
|
+
</CardContent>
|
|
31
|
+
</Card>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Bookmark as BookmarkIcon } from 'lucide-react';
|
|
3
|
+
import { fetchFavicon } from '../utils/favicon';
|
|
4
|
+
|
|
5
|
+
interface FaviconProps {
|
|
6
|
+
url: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function Favicon({ url, className = '', size = 20 }: FaviconProps) {
|
|
12
|
+
const [faviconUrl, setFaviconUrl] = useState<string>('');
|
|
13
|
+
const [error, setError] = useState(false);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (url) {
|
|
18
|
+
setLoading(true);
|
|
19
|
+
setError(false);
|
|
20
|
+
setFaviconUrl('');
|
|
21
|
+
fetchFavicon(url)
|
|
22
|
+
.then((favicon) => {
|
|
23
|
+
if (favicon) {
|
|
24
|
+
setFaviconUrl(favicon);
|
|
25
|
+
setError(false);
|
|
26
|
+
} else {
|
|
27
|
+
setError(true);
|
|
28
|
+
setFaviconUrl('');
|
|
29
|
+
}
|
|
30
|
+
setLoading(false);
|
|
31
|
+
})
|
|
32
|
+
.catch(() => {
|
|
33
|
+
setError(true);
|
|
34
|
+
setFaviconUrl('');
|
|
35
|
+
setLoading(false);
|
|
36
|
+
});
|
|
37
|
+
} else {
|
|
38
|
+
setError(true);
|
|
39
|
+
setFaviconUrl('');
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}, [url]);
|
|
43
|
+
|
|
44
|
+
const handleImageError = () => {
|
|
45
|
+
setError(true);
|
|
46
|
+
setFaviconUrl('');
|
|
47
|
+
setLoading(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (loading) {
|
|
51
|
+
return (
|
|
52
|
+
<div className={`flex items-center justify-center ${className}`} style={{ width: `${size}px`, height: `${size}px` }}>
|
|
53
|
+
<div className="w-3 h-3 border-2 border-input border-t-primary rounded-full animate-spin" />
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (error || !faviconUrl) {
|
|
59
|
+
return (
|
|
60
|
+
<div className={`flex items-center justify-center ${className}`} style={{ width: `${size}px`, height: `${size}px` }}>
|
|
61
|
+
<BookmarkIcon className="text-primary" style={{ width: `${size}px`, height: `${size}px` }} />
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<img
|
|
68
|
+
src={faviconUrl}
|
|
69
|
+
alt=""
|
|
70
|
+
className={`object-contain ${className}`}
|
|
71
|
+
style={{ width: `${size}px`, height: `${size}px`, minWidth: `${size}px`, minHeight: `${size}px` }}
|
|
72
|
+
onError={handleImageError}
|
|
73
|
+
loading="lazy"
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { X } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export interface FilterChipItem {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
ariaLabel: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface FilterChipsProps {
|
|
10
|
+
chips: FilterChipItem[];
|
|
11
|
+
onRemove: (key: string) => void;
|
|
12
|
+
onClearAll: () => void;
|
|
13
|
+
clearAllLabel: string;
|
|
14
|
+
clearAllAriaLabel: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function FilterChips({ chips, onRemove, onClearAll, clearAllLabel, clearAllAriaLabel }: FilterChipsProps) {
|
|
18
|
+
if (chips.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
22
|
+
{chips.map(({ key, label, ariaLabel }) => (
|
|
23
|
+
<span
|
|
24
|
+
key={key}
|
|
25
|
+
className="inline-flex items-center gap-1.5 pl-2.5 pr-1.5 py-1 rounded-md bg-muted text-muted-foreground text-sm border border-border"
|
|
26
|
+
>
|
|
27
|
+
<span>{label}</span>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={() => onRemove(key)}
|
|
31
|
+
onKeyDown={(e) => {
|
|
32
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
onRemove(key);
|
|
35
|
+
}
|
|
36
|
+
}}
|
|
37
|
+
className="p-0.5 rounded hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
38
|
+
aria-label={ariaLabel}
|
|
39
|
+
>
|
|
40
|
+
<X className="h-3.5 w-3.5" />
|
|
41
|
+
</button>
|
|
42
|
+
</span>
|
|
43
|
+
))}
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
onClick={onClearAll}
|
|
47
|
+
onKeyDown={(e) => {
|
|
48
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
onClearAll();
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
className="text-sm text-muted-foreground hover:text-foreground underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded px-1"
|
|
54
|
+
aria-label={clearAllAriaLabel}
|
|
55
|
+
>
|
|
56
|
+
{clearAllLabel}
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import * as LucideIcons from 'lucide-react';
|
|
3
|
+
import { Folder as DefaultFolderIcon } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface FolderIconProps {
|
|
6
|
+
iconName?: string | null;
|
|
7
|
+
className?: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Get all available icon names from lucide-react
|
|
12
|
+
// Filter out non-icon exports and get all icon component names
|
|
13
|
+
// Lucide icons are React forward ref components (objects with $$typeof and render method)
|
|
14
|
+
function getAllIconNames(): string[] {
|
|
15
|
+
const iconNames: string[] = [];
|
|
16
|
+
const excludeNames = new Set([
|
|
17
|
+
'createLucideIcon',
|
|
18
|
+
'Icon',
|
|
19
|
+
'IconNode',
|
|
20
|
+
'IconProps',
|
|
21
|
+
'LucideProps',
|
|
22
|
+
'default',
|
|
23
|
+
// Exclude non-icon exports
|
|
24
|
+
'defaultProps',
|
|
25
|
+
'forwardRef',
|
|
26
|
+
'memo',
|
|
27
|
+
'lazy',
|
|
28
|
+
'Suspense',
|
|
29
|
+
'Fragment',
|
|
30
|
+
'StrictMode',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
for (const name in LucideIcons) {
|
|
34
|
+
// Skip excluded names
|
|
35
|
+
if (excludeNames.has(name)) continue;
|
|
36
|
+
|
|
37
|
+
// Skip names starting with lowercase (likely not icon components)
|
|
38
|
+
if (name[0] && name[0] === name[0].toLowerCase()) continue;
|
|
39
|
+
|
|
40
|
+
// Skip names ending with 'Icon' (these are typically type exports)
|
|
41
|
+
if (name.endsWith('Icon')) continue;
|
|
42
|
+
|
|
43
|
+
const component = (LucideIcons as any)[name];
|
|
44
|
+
|
|
45
|
+
// Check if it's a valid React component
|
|
46
|
+
// Lucide icons can be either:
|
|
47
|
+
// 1. Function components (typeof === 'function')
|
|
48
|
+
// 2. Forward ref components (object with $$typeof and render method)
|
|
49
|
+
const isFunctionComponent = typeof component === 'function';
|
|
50
|
+
const isForwardRefComponent = component &&
|
|
51
|
+
typeof component === 'object' &&
|
|
52
|
+
(component.$$typeof || component.render);
|
|
53
|
+
|
|
54
|
+
if (isFunctionComponent || isForwardRefComponent) {
|
|
55
|
+
iconNames.push(name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return iconNames.sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Cache the icon names
|
|
63
|
+
let cachedIconNames: string[] | null = null;
|
|
64
|
+
|
|
65
|
+
export function getAllIcons(): string[] {
|
|
66
|
+
if (!cachedIconNames) {
|
|
67
|
+
cachedIconNames = getAllIconNames();
|
|
68
|
+
}
|
|
69
|
+
return cachedIconNames;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Popular/recommended icons for quick access
|
|
73
|
+
const popularIcons = [
|
|
74
|
+
'Folder',
|
|
75
|
+
'FolderOpen',
|
|
76
|
+
'Archive',
|
|
77
|
+
'Briefcase',
|
|
78
|
+
'FileText',
|
|
79
|
+
'Image',
|
|
80
|
+
'Video',
|
|
81
|
+
'Music',
|
|
82
|
+
'Code',
|
|
83
|
+
'Database',
|
|
84
|
+
'Book',
|
|
85
|
+
'GraduationCap',
|
|
86
|
+
'Heart',
|
|
87
|
+
'Star',
|
|
88
|
+
'Home',
|
|
89
|
+
'Calendar',
|
|
90
|
+
'Mail',
|
|
91
|
+
'Settings',
|
|
92
|
+
'Users',
|
|
93
|
+
'Package',
|
|
94
|
+
'Wrench',
|
|
95
|
+
'Tool',
|
|
96
|
+
'Hammer',
|
|
97
|
+
'Screwdriver',
|
|
98
|
+
'FolderPlus',
|
|
99
|
+
'FolderMinus',
|
|
100
|
+
'FolderCheck',
|
|
101
|
+
'FolderX',
|
|
102
|
+
'File',
|
|
103
|
+
'FileCode',
|
|
104
|
+
'FileImage',
|
|
105
|
+
'FileVideo',
|
|
106
|
+
'FileAudio',
|
|
107
|
+
'FileSpreadsheet',
|
|
108
|
+
'FileType',
|
|
109
|
+
'FolderKanban',
|
|
110
|
+
'FolderTree',
|
|
111
|
+
'FolderGit',
|
|
112
|
+
'FolderGit2',
|
|
113
|
+
'FolderSearch',
|
|
114
|
+
'FolderSymlink',
|
|
115
|
+
'FolderUp',
|
|
116
|
+
'FolderDown',
|
|
117
|
+
'FolderInput',
|
|
118
|
+
'FolderOutput',
|
|
119
|
+
'FolderRoot',
|
|
120
|
+
'FolderLock',
|
|
121
|
+
'FolderUnlock',
|
|
122
|
+
'FolderHeart',
|
|
123
|
+
'FolderKey',
|
|
124
|
+
'FolderPen',
|
|
125
|
+
'FolderArchive',
|
|
126
|
+
'FolderOpenDot',
|
|
127
|
+
'FolderDot',
|
|
128
|
+
'FolderSync',
|
|
129
|
+
'FolderClock',
|
|
130
|
+
'FolderCog',
|
|
131
|
+
'FolderPlus2',
|
|
132
|
+
'FolderMinus2',
|
|
133
|
+
'FolderCheck2',
|
|
134
|
+
'FolderX2',
|
|
135
|
+
'FolderQuestion',
|
|
136
|
+
'FolderWarning',
|
|
137
|
+
'FolderAlert',
|
|
138
|
+
'FolderBan',
|
|
139
|
+
'FolderOff',
|
|
140
|
+
'FolderOn',
|
|
141
|
+
'FolderUp2',
|
|
142
|
+
'FolderDown2',
|
|
143
|
+
'FolderInput2',
|
|
144
|
+
'FolderOutput2',
|
|
145
|
+
'FolderRoot2',
|
|
146
|
+
'FolderLock2',
|
|
147
|
+
'FolderUnlock2',
|
|
148
|
+
'FolderHeart2',
|
|
149
|
+
'FolderKey2',
|
|
150
|
+
'FolderPen2',
|
|
151
|
+
'FolderArchive2',
|
|
152
|
+
'FolderOpenDot2',
|
|
153
|
+
'FolderDot2',
|
|
154
|
+
'FolderSync2',
|
|
155
|
+
'FolderClock2',
|
|
156
|
+
'FolderCog2',
|
|
157
|
+
'FolderPlus2',
|
|
158
|
+
'FolderMinus2',
|
|
159
|
+
'FolderCheck2',
|
|
160
|
+
'FolderX2',
|
|
161
|
+
'FolderQuestion2',
|
|
162
|
+
'FolderWarning2',
|
|
163
|
+
'FolderAlert2',
|
|
164
|
+
'FolderBan2',
|
|
165
|
+
'FolderOff2',
|
|
166
|
+
'FolderOn2',
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
export default function FolderIcon({ iconName, className = '', size = 20 }: FolderIconProps) {
|
|
170
|
+
if (!iconName) {
|
|
171
|
+
return <DefaultFolderIcon className={className} style={{ width: `${size}px`, height: `${size}px` }} />;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Try to get the icon from lucide-react (exact match first)
|
|
175
|
+
let IconComponent = (LucideIcons as any)[iconName] as React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
176
|
+
|
|
177
|
+
// If exact match not found, try case-insensitive lookup
|
|
178
|
+
if (!IconComponent) {
|
|
179
|
+
const iconNameLower = iconName.toLowerCase();
|
|
180
|
+
for (const key in LucideIcons) {
|
|
181
|
+
if (key.toLowerCase() === iconNameLower) {
|
|
182
|
+
const candidate = (LucideIcons as any)[key];
|
|
183
|
+
// Check if it's a valid component (function or forward ref object)
|
|
184
|
+
const isValid = typeof candidate === 'function' ||
|
|
185
|
+
(candidate && typeof candidate === 'object' && (candidate.$$typeof || candidate.render));
|
|
186
|
+
if (isValid) {
|
|
187
|
+
IconComponent = candidate as React.ComponentType<{ className?: string; style?: React.CSSProperties }>;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Verify IconComponent is valid before rendering
|
|
195
|
+
const isValidComponent = IconComponent &&
|
|
196
|
+
(typeof IconComponent === 'function' ||
|
|
197
|
+
(typeof IconComponent === 'object' && IconComponent !== null && ((IconComponent as any).$$typeof || (IconComponent as any).render)));
|
|
198
|
+
|
|
199
|
+
if (isValidComponent) {
|
|
200
|
+
return <IconComponent className={className} style={{ width: `${size}px`, height: `${size}px` }} />;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Fallback to default folder icon
|
|
204
|
+
return <DefaultFolderIcon className={className} style={{ width: `${size}px`, height: `${size}px` }} />;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export { popularIcons };
|