@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,159 @@
|
|
|
1
|
+
import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square } from 'lucide-react';
|
|
2
|
+
import Button from '../ui/Button';
|
|
3
|
+
import Tooltip from '../ui/Tooltip';
|
|
4
|
+
import Favicon from '../Favicon';
|
|
5
|
+
import FolderIcon from '../FolderIcon';
|
|
6
|
+
import { safeHref } from '../../utils/safeHref';
|
|
7
|
+
|
|
8
|
+
interface Bookmark {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
forwarding_enabled: boolean;
|
|
14
|
+
owner_user_key?: string;
|
|
15
|
+
folders?: Array<{ id: string; name: string; icon?: string | null; shared_teams?: Array<{ id: string; name: string }> }>;
|
|
16
|
+
tags?: Array<{ id: string; name: string }>;
|
|
17
|
+
shared_teams?: Array<{ id: string; name: string }>;
|
|
18
|
+
bookmark_type?: 'own' | 'shared';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface BookmarkListItemProps {
|
|
22
|
+
bookmark: Bookmark;
|
|
23
|
+
compact: boolean;
|
|
24
|
+
selected: boolean;
|
|
25
|
+
onSelect: () => void;
|
|
26
|
+
onEdit: () => void;
|
|
27
|
+
onDelete: () => void;
|
|
28
|
+
onCopyUrl: () => void;
|
|
29
|
+
bulkMode: boolean;
|
|
30
|
+
user: any;
|
|
31
|
+
t: any;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function BookmarkListItem({
|
|
35
|
+
bookmark,
|
|
36
|
+
compact,
|
|
37
|
+
selected,
|
|
38
|
+
onSelect,
|
|
39
|
+
onEdit,
|
|
40
|
+
onDelete,
|
|
41
|
+
onCopyUrl,
|
|
42
|
+
bulkMode,
|
|
43
|
+
user: _user,
|
|
44
|
+
t,
|
|
45
|
+
}: BookmarkListItemProps) {
|
|
46
|
+
const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
|
|
47
|
+
(bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
|
|
48
|
+
const isShared = totalSharedTeams > 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={`group bg-white dark:bg-gray-800 rounded-lg border ${
|
|
53
|
+
selected
|
|
54
|
+
? 'border-blue-500 dark:border-blue-400 ring-2 ring-blue-500/20'
|
|
55
|
+
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500'
|
|
56
|
+
} hover:shadow transition-all duration-200 ${compact ? 'p-2.5 min-h-[80px]' : 'p-4'}`}
|
|
57
|
+
>
|
|
58
|
+
<div className={`flex items-center ${compact ? 'gap-3' : 'gap-4'}`}>
|
|
59
|
+
{bulkMode && (
|
|
60
|
+
<button
|
|
61
|
+
onClick={onSelect}
|
|
62
|
+
className="flex-shrink-0 text-blue-600 dark:text-blue-400"
|
|
63
|
+
>
|
|
64
|
+
{selected ? <CheckSquare className="h-5 w-5" /> : <Square className="h-5 w-5" />}
|
|
65
|
+
</button>
|
|
66
|
+
)}
|
|
67
|
+
<div className={`flex-shrink-0 ${compact ? 'w-10 h-10' : 'w-12 h-12'} rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/20 flex items-center justify-center border border-blue-100 dark:border-blue-800/50 overflow-hidden`}>
|
|
68
|
+
<Favicon url={bookmark.url} size={compact ? 20 : 24} />
|
|
69
|
+
</div>
|
|
70
|
+
<div className="flex-1 min-w-0">
|
|
71
|
+
<div className="flex items-start justify-between gap-4">
|
|
72
|
+
<div className="flex-1 min-w-0">
|
|
73
|
+
<h3 className={`${compact ? 'text-sm' : 'text-[15px]'} font-medium text-gray-900 dark:text-white mb-1`}>
|
|
74
|
+
{bookmark.title}
|
|
75
|
+
</h3>
|
|
76
|
+
<p className={`${compact ? 'text-xs' : 'text-sm'} text-gray-700 dark:text-gray-200 truncate ${compact ? 'mb-1' : 'mb-2'}`}>
|
|
77
|
+
{bookmark.url}
|
|
78
|
+
</p>
|
|
79
|
+
<div className={`flex flex-wrap items-center gap-2 ${compact ? 'min-h-[24px]' : ''}`}>
|
|
80
|
+
{bookmark.folders && bookmark.folders.length > 0 && (
|
|
81
|
+
bookmark.folders.slice(0, 1).map((folder) => (
|
|
82
|
+
<span
|
|
83
|
+
key={folder.id}
|
|
84
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 ${compact ? 'text-xs' : 'text-xs'} font-medium bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded-md`}
|
|
85
|
+
>
|
|
86
|
+
<FolderIcon iconName={folder.icon} size={12} />
|
|
87
|
+
{folder.name}
|
|
88
|
+
</span>
|
|
89
|
+
))
|
|
90
|
+
)}
|
|
91
|
+
{bookmark.tags && bookmark.tags.length > 0 && (
|
|
92
|
+
bookmark.tags.slice(0, compact ? 2 : 3).map((tag) => (
|
|
93
|
+
<span
|
|
94
|
+
key={tag.id}
|
|
95
|
+
className={`inline-flex items-center gap-1 px-2 py-0.5 ${compact ? 'text-xs' : 'text-xs'} font-medium bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-md`}
|
|
96
|
+
>
|
|
97
|
+
<TagIcon className="h-3 w-3" />
|
|
98
|
+
{tag.name}
|
|
99
|
+
</span>
|
|
100
|
+
))
|
|
101
|
+
)}
|
|
102
|
+
{isShared && (
|
|
103
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md">
|
|
104
|
+
<Share2 className="h-3 w-3" />
|
|
105
|
+
{totalSharedTeams > 0
|
|
106
|
+
? t('bookmarks.sharedWithTeams', { count: totalSharedTeams, teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams') })
|
|
107
|
+
: t('bookmarks.shared')}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex items-center gap-2 flex-shrink-0">
|
|
113
|
+
{bookmark.forwarding_enabled && (
|
|
114
|
+
<Tooltip content={`${window.location.origin}/go/${bookmark.slug}`}>
|
|
115
|
+
<button
|
|
116
|
+
onClick={onCopyUrl}
|
|
117
|
+
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
|
118
|
+
title={t('bookmarks.copyUrl')}
|
|
119
|
+
>
|
|
120
|
+
<Copy className="h-4 w-4" />
|
|
121
|
+
</button>
|
|
122
|
+
</Tooltip>
|
|
123
|
+
)}
|
|
124
|
+
<a
|
|
125
|
+
href={safeHref(bookmark.url)}
|
|
126
|
+
target="_blank"
|
|
127
|
+
rel="noopener noreferrer"
|
|
128
|
+
>
|
|
129
|
+
<Button variant="primary" size="sm" icon={ExternalLink} className={compact ? 'text-xs px-2' : ''}>
|
|
130
|
+
{t('bookmarks.open')}
|
|
131
|
+
</Button>
|
|
132
|
+
</a>
|
|
133
|
+
{bookmark.bookmark_type === 'own' && (
|
|
134
|
+
<>
|
|
135
|
+
<Button
|
|
136
|
+
variant="ghost"
|
|
137
|
+
size="sm"
|
|
138
|
+
icon={Edit}
|
|
139
|
+
onClick={onEdit}
|
|
140
|
+
title={t('common.edit')}
|
|
141
|
+
className="px-2"
|
|
142
|
+
/>
|
|
143
|
+
<Button
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="sm"
|
|
146
|
+
icon={Trash2}
|
|
147
|
+
onClick={onDelete}
|
|
148
|
+
title={t('common.delete')}
|
|
149
|
+
className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 px-2"
|
|
150
|
+
/>
|
|
151
|
+
</>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { Share2, Tag as TagIcon, ExternalLink, Copy, Edit, Trash2, CheckSquare, Square, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
|
3
|
+
import Button from '../ui/Button';
|
|
4
|
+
import Tooltip from '../ui/Tooltip';
|
|
5
|
+
import Favicon from '../Favicon';
|
|
6
|
+
import FolderIcon from '../FolderIcon';
|
|
7
|
+
import { Badge } from '../ui/badge';
|
|
8
|
+
import {
|
|
9
|
+
Table,
|
|
10
|
+
TableBody,
|
|
11
|
+
TableCell,
|
|
12
|
+
TableHead,
|
|
13
|
+
TableHeader,
|
|
14
|
+
TableRow,
|
|
15
|
+
} from '../ui/table';
|
|
16
|
+
import { Card } from '../ui/card';
|
|
17
|
+
import { safeHref } from '../../utils/safeHref';
|
|
18
|
+
import { formatRelativeTime, formatFullDateTime } from '../../utils/formatRelativeTime';
|
|
19
|
+
|
|
20
|
+
interface Bookmark {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
url: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
forwarding_enabled: boolean;
|
|
26
|
+
owner_user_key?: string;
|
|
27
|
+
folders?: Array<{ id: string; name: string; icon?: string | null; shared_teams?: Array<{ id: string; name: string }> }>;
|
|
28
|
+
tags?: Array<{ id: string; name: string }>;
|
|
29
|
+
shared_teams?: Array<{ id: string; name: string }>;
|
|
30
|
+
bookmark_type?: 'own' | 'shared';
|
|
31
|
+
last_accessed_at?: string | null | undefined;
|
|
32
|
+
access_count?: number;
|
|
33
|
+
created_at?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface BookmarkTableViewProps {
|
|
37
|
+
bookmarks: Bookmark[];
|
|
38
|
+
selectedBookmarks: Set<string>;
|
|
39
|
+
onSelect: (id: string) => void;
|
|
40
|
+
onSelectAll: () => void;
|
|
41
|
+
onEdit: (bookmark: Bookmark) => void;
|
|
42
|
+
onDelete: (id: string, name?: string) => void;
|
|
43
|
+
onCopyUrl: (bookmark: Bookmark) => void;
|
|
44
|
+
onShare?: (bookmark: Bookmark) => void;
|
|
45
|
+
onOpen?: (bookmark: Bookmark) => void;
|
|
46
|
+
bulkMode: boolean;
|
|
47
|
+
user: any;
|
|
48
|
+
t: any;
|
|
49
|
+
compact?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function BookmarkTableView({
|
|
53
|
+
bookmarks,
|
|
54
|
+
selectedBookmarks,
|
|
55
|
+
onSelect,
|
|
56
|
+
onSelectAll,
|
|
57
|
+
onEdit,
|
|
58
|
+
onDelete,
|
|
59
|
+
onCopyUrl,
|
|
60
|
+
onShare,
|
|
61
|
+
onOpen,
|
|
62
|
+
bulkMode,
|
|
63
|
+
user: _user,
|
|
64
|
+
t,
|
|
65
|
+
compact = false,
|
|
66
|
+
}: BookmarkTableViewProps) {
|
|
67
|
+
const [sortColumn, setSortColumn] = useState<'title' | 'url' | 'last_accessed' | null>(null);
|
|
68
|
+
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
|
69
|
+
|
|
70
|
+
const sortedBookmarks = useMemo(() => {
|
|
71
|
+
if (!sortColumn) return bookmarks;
|
|
72
|
+
|
|
73
|
+
return [...bookmarks].sort((a, b) => {
|
|
74
|
+
let aVal: any;
|
|
75
|
+
let bVal: any;
|
|
76
|
+
|
|
77
|
+
switch (sortColumn) {
|
|
78
|
+
case 'title':
|
|
79
|
+
aVal = a.title.toLowerCase();
|
|
80
|
+
bVal = b.title.toLowerCase();
|
|
81
|
+
break;
|
|
82
|
+
case 'url':
|
|
83
|
+
aVal = a.url.toLowerCase();
|
|
84
|
+
bVal = b.url.toLowerCase();
|
|
85
|
+
break;
|
|
86
|
+
case 'last_accessed':
|
|
87
|
+
aVal = a.last_accessed_at ? new Date(a.last_accessed_at).getTime() : 0;
|
|
88
|
+
bVal = b.last_accessed_at ? new Date(b.last_accessed_at).getTime() : 0;
|
|
89
|
+
break;
|
|
90
|
+
default:
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
|
|
95
|
+
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
|
|
96
|
+
return 0;
|
|
97
|
+
});
|
|
98
|
+
}, [bookmarks, sortColumn, sortDirection]);
|
|
99
|
+
|
|
100
|
+
function handleSort(column: 'title' | 'url' | 'last_accessed') {
|
|
101
|
+
if (sortColumn === column) {
|
|
102
|
+
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
|
103
|
+
} else {
|
|
104
|
+
setSortColumn(column);
|
|
105
|
+
setSortDirection('asc');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getSortIcon(column: 'title' | 'url' | 'last_accessed') {
|
|
110
|
+
const iconSize = compact ? 'h-3 w-3' : 'h-4 w-4';
|
|
111
|
+
if (sortColumn !== column) {
|
|
112
|
+
return <ArrowUpDown className={`${iconSize} text-gray-400`} />;
|
|
113
|
+
}
|
|
114
|
+
return sortDirection === 'asc' ? (
|
|
115
|
+
<ArrowUp className={`${iconSize} text-primary`} />
|
|
116
|
+
) : (
|
|
117
|
+
<ArrowDown className={`${iconSize} text-primary`} />
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function formatCreated(dateString?: string | null) {
|
|
122
|
+
if (!dateString) return '-';
|
|
123
|
+
const date = new Date(dateString);
|
|
124
|
+
return date.toLocaleDateString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cellClass = compact ? 'px-2 py-1.5' : 'px-4 py-3';
|
|
128
|
+
const headClass = compact ? 'text-[10px]' : 'text-xs';
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<Card className={compact ? 'overflow-x-auto' : ''}>
|
|
132
|
+
<Table>
|
|
133
|
+
<TableHeader>
|
|
134
|
+
<TableRow>
|
|
135
|
+
{bulkMode && (
|
|
136
|
+
<TableHead className={cellClass}>
|
|
137
|
+
<button
|
|
138
|
+
onClick={onSelectAll}
|
|
139
|
+
className="text-primary"
|
|
140
|
+
aria-label={t('bookmarks.selectAll')}
|
|
141
|
+
>
|
|
142
|
+
{selectedBookmarks.size === bookmarks.length ? (
|
|
143
|
+
<CheckSquare className={compact ? 'h-4 w-4' : 'h-5 w-5'} />
|
|
144
|
+
) : (
|
|
145
|
+
<Square className={compact ? 'h-4 w-4' : 'h-5 w-5'} />
|
|
146
|
+
)}
|
|
147
|
+
</button>
|
|
148
|
+
</TableHead>
|
|
149
|
+
)}
|
|
150
|
+
<TableHead className={cellClass}>
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => handleSort('title')}
|
|
153
|
+
className={`flex items-center gap-2 ${headClass} font-semibold uppercase tracking-wide hover:text-foreground`}
|
|
154
|
+
>
|
|
155
|
+
{t('bookmarks.name')}
|
|
156
|
+
{getSortIcon('title')}
|
|
157
|
+
</button>
|
|
158
|
+
</TableHead>
|
|
159
|
+
{compact ? (
|
|
160
|
+
<>
|
|
161
|
+
<TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.folders')}</TableHead>
|
|
162
|
+
<TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.tags')}</TableHead>
|
|
163
|
+
<TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.clicks')}</TableHead>
|
|
164
|
+
<TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.lastOpened')}</TableHead>
|
|
165
|
+
<TableHead className={`${cellClass} ${headClass} font-semibold uppercase tracking-wide`}>{t('bookmarks.sortRecentlyAdded')}</TableHead>
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<>
|
|
169
|
+
<TableHead className={cellClass}>
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => handleSort('url')}
|
|
172
|
+
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide hover:text-foreground`}
|
|
173
|
+
>
|
|
174
|
+
{t('bookmarks.url')}
|
|
175
|
+
{getSortIcon('url')}
|
|
176
|
+
</button>
|
|
177
|
+
</TableHead>
|
|
178
|
+
<TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
|
|
179
|
+
{t('bookmarks.folders')}
|
|
180
|
+
</TableHead>
|
|
181
|
+
<TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
|
|
182
|
+
{t('bookmarks.tags')}
|
|
183
|
+
</TableHead>
|
|
184
|
+
<TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
|
|
185
|
+
{t('bookmarks.shared')}
|
|
186
|
+
</TableHead>
|
|
187
|
+
<TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
|
|
188
|
+
{t('bookmarks.clicks')}
|
|
189
|
+
</TableHead>
|
|
190
|
+
<TableHead className={cellClass}>
|
|
191
|
+
<button
|
|
192
|
+
onClick={() => handleSort('last_accessed')}
|
|
193
|
+
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide hover:text-foreground`}
|
|
194
|
+
>
|
|
195
|
+
{t('bookmarks.lastOpened')}
|
|
196
|
+
{getSortIcon('last_accessed')}
|
|
197
|
+
</button>
|
|
198
|
+
</TableHead>
|
|
199
|
+
<TableHead className={`${cellClass} text-xs font-semibold uppercase tracking-wide`}>
|
|
200
|
+
{t('bookmarks.sortRecentlyAdded')}
|
|
201
|
+
</TableHead>
|
|
202
|
+
</>
|
|
203
|
+
)}
|
|
204
|
+
<TableHead className={`${cellClass} text-right ${headClass} font-semibold uppercase tracking-wide`}>
|
|
205
|
+
{t('common.actions')}
|
|
206
|
+
</TableHead>
|
|
207
|
+
</TableRow>
|
|
208
|
+
</TableHeader>
|
|
209
|
+
<TableBody>
|
|
210
|
+
{sortedBookmarks.map((bookmark) => {
|
|
211
|
+
const totalSharedTeams = (bookmark.shared_teams?.length || 0) +
|
|
212
|
+
(bookmark.folders?.reduce((sum, f) => sum + (f.shared_teams?.length || 0), 0) || 0);
|
|
213
|
+
const isShared = totalSharedTeams > 0;
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<TableRow
|
|
217
|
+
key={bookmark.id}
|
|
218
|
+
className={`group ${selectedBookmarks.has(bookmark.id) ? 'bg-primary/10' : ''} ${compact ? 'h-10' : ''}`}
|
|
219
|
+
data-state={selectedBookmarks.has(bookmark.id) ? 'selected' : undefined}
|
|
220
|
+
>
|
|
221
|
+
{bulkMode && (
|
|
222
|
+
<TableCell className={cellClass}>
|
|
223
|
+
<button
|
|
224
|
+
onClick={() => onSelect(bookmark.id)}
|
|
225
|
+
className="text-primary"
|
|
226
|
+
aria-label={t('bookmarks.selectAll')}
|
|
227
|
+
>
|
|
228
|
+
{selectedBookmarks.has(bookmark.id) ? (
|
|
229
|
+
<CheckSquare className={`${compact ? 'h-4 w-4' : 'h-5 w-5'}`} />
|
|
230
|
+
) : (
|
|
231
|
+
<Square className={`${compact ? 'h-4 w-4' : 'h-5 w-5'}`} />
|
|
232
|
+
)}
|
|
233
|
+
</button>
|
|
234
|
+
</TableCell>
|
|
235
|
+
)}
|
|
236
|
+
<TableCell className={cellClass}>
|
|
237
|
+
<a
|
|
238
|
+
href={safeHref(bookmark.url)}
|
|
239
|
+
target="_blank"
|
|
240
|
+
rel="noopener noreferrer"
|
|
241
|
+
onClick={(e) => { if (onOpen) { e.preventDefault(); onOpen(bookmark); } }}
|
|
242
|
+
className={`flex items-center ${compact ? 'gap-2' : 'gap-3'} group/title hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded`}
|
|
243
|
+
>
|
|
244
|
+
<div className={`flex-shrink-0 ${compact ? 'w-6 h-6' : 'w-8 h-8'} rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/20 flex items-center justify-center border border-blue-100 dark:border-blue-800/50 overflow-hidden`}>
|
|
245
|
+
<Favicon url={bookmark.url} size={compact ? 12 : 16} />
|
|
246
|
+
</div>
|
|
247
|
+
<div className={`font-semibold text-gray-900 dark:text-white ${compact ? 'text-xs' : 'text-[15px]'} truncate`}>
|
|
248
|
+
{bookmark.title}
|
|
249
|
+
</div>
|
|
250
|
+
</a>
|
|
251
|
+
</TableCell>
|
|
252
|
+
{compact ? (
|
|
253
|
+
<>
|
|
254
|
+
<TableCell className={cellClass}>
|
|
255
|
+
<div className="flex flex-wrap gap-0.5 max-w-[120px]">
|
|
256
|
+
{bookmark.folders && bookmark.folders.length > 0 ? (
|
|
257
|
+
bookmark.folders.slice(0, 1).map((folder) => (
|
|
258
|
+
<span key={folder.id} className="text-xs text-muted-foreground truncate">
|
|
259
|
+
<FolderIcon iconName={folder.icon} size={10} className="inline mr-0.5" />
|
|
260
|
+
{folder.name}
|
|
261
|
+
</span>
|
|
262
|
+
))
|
|
263
|
+
) : (
|
|
264
|
+
<span className="text-xs text-muted-foreground">-</span>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</TableCell>
|
|
268
|
+
<TableCell className={cellClass}>
|
|
269
|
+
<div className="flex flex-wrap gap-0.5 max-w-[100px] truncate">
|
|
270
|
+
{bookmark.tags && bookmark.tags.length > 0 ? (
|
|
271
|
+
<span className="text-xs text-muted-foreground truncate" title={bookmark.tags.map(t => t.name).join(', ')}>
|
|
272
|
+
{bookmark.tags.slice(0, 2).map(t => t.name).join(', ')}
|
|
273
|
+
{bookmark.tags.length > 2 ? ` +${bookmark.tags.length - 2}` : ''}
|
|
274
|
+
</span>
|
|
275
|
+
) : (
|
|
276
|
+
<span className="text-xs text-muted-foreground">-</span>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
</TableCell>
|
|
280
|
+
<TableCell className={cellClass}>
|
|
281
|
+
<span className="text-xs text-muted-foreground">
|
|
282
|
+
{typeof bookmark.access_count === 'number' ? bookmark.access_count : '-'}
|
|
283
|
+
</span>
|
|
284
|
+
</TableCell>
|
|
285
|
+
<TableCell className={cellClass}>
|
|
286
|
+
{bookmark.last_accessed_at ? (
|
|
287
|
+
<Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
|
|
288
|
+
<span className="text-xs text-muted-foreground cursor-help">
|
|
289
|
+
{formatRelativeTime(bookmark.last_accessed_at)}
|
|
290
|
+
</span>
|
|
291
|
+
</Tooltip>
|
|
292
|
+
) : (
|
|
293
|
+
<span className="text-xs text-muted-foreground">{t('bookmarks.never')}</span>
|
|
294
|
+
)}
|
|
295
|
+
</TableCell>
|
|
296
|
+
<TableCell className={cellClass}>
|
|
297
|
+
<span className="text-xs text-muted-foreground">
|
|
298
|
+
{formatCreated(bookmark.created_at)}
|
|
299
|
+
</span>
|
|
300
|
+
</TableCell>
|
|
301
|
+
</>
|
|
302
|
+
) : (
|
|
303
|
+
<>
|
|
304
|
+
<TableCell className={cellClass}>
|
|
305
|
+
<a
|
|
306
|
+
href={safeHref(bookmark.url)}
|
|
307
|
+
target="_blank"
|
|
308
|
+
rel="noopener noreferrer"
|
|
309
|
+
className="text-sm text-muted-foreground hover:text-foreground truncate max-w-xs block"
|
|
310
|
+
>
|
|
311
|
+
{bookmark.url}
|
|
312
|
+
</a>
|
|
313
|
+
</TableCell>
|
|
314
|
+
<TableCell className={cellClass}>
|
|
315
|
+
<div className="flex flex-wrap gap-1">
|
|
316
|
+
{bookmark.folders && bookmark.folders.length > 0 ? (
|
|
317
|
+
bookmark.folders.slice(0, 2).map((folder) => (
|
|
318
|
+
<Badge key={folder.id} variant="secondary" className="text-xs font-medium bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
|
319
|
+
<FolderIcon iconName={folder.icon} size={12} className="mr-1" />
|
|
320
|
+
{folder.name}
|
|
321
|
+
</Badge>
|
|
322
|
+
))
|
|
323
|
+
) : (
|
|
324
|
+
<span className="text-xs text-muted-foreground">-</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</TableCell>
|
|
328
|
+
<TableCell className={cellClass}>
|
|
329
|
+
<div className="flex flex-wrap gap-1">
|
|
330
|
+
{bookmark.tags && bookmark.tags.length > 0 ? (
|
|
331
|
+
bookmark.tags.slice(0, 3).map((tag) => (
|
|
332
|
+
<Badge key={tag.id} variant="secondary" className="text-xs font-medium bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300">
|
|
333
|
+
<TagIcon className="h-3 w-3 mr-1" />
|
|
334
|
+
{tag.name}
|
|
335
|
+
</Badge>
|
|
336
|
+
))
|
|
337
|
+
) : (
|
|
338
|
+
<span className="text-xs text-muted-foreground">-</span>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
</TableCell>
|
|
342
|
+
<TableCell className={cellClass}>
|
|
343
|
+
{isShared ? (
|
|
344
|
+
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-md">
|
|
345
|
+
<Share2 className="h-3 w-3" />
|
|
346
|
+
{totalSharedTeams > 0
|
|
347
|
+
? t('bookmarks.sharedWithTeams', { count: totalSharedTeams, teams: totalSharedTeams === 1 ? t('common.team') : t('common.teams') })
|
|
348
|
+
: t('bookmarks.shared')}
|
|
349
|
+
</span>
|
|
350
|
+
) : (
|
|
351
|
+
<span className="text-xs text-muted-foreground">-</span>
|
|
352
|
+
)}
|
|
353
|
+
</TableCell>
|
|
354
|
+
<TableCell className={cellClass}>
|
|
355
|
+
<span className="text-xs text-muted-foreground">
|
|
356
|
+
{typeof bookmark.access_count === 'number' ? bookmark.access_count : '-'}
|
|
357
|
+
</span>
|
|
358
|
+
</TableCell>
|
|
359
|
+
<TableCell className={cellClass}>
|
|
360
|
+
{bookmark.last_accessed_at ? (
|
|
361
|
+
<Tooltip content={formatFullDateTime(bookmark.last_accessed_at)}>
|
|
362
|
+
<span className="text-xs text-muted-foreground cursor-help">
|
|
363
|
+
{formatRelativeTime(bookmark.last_accessed_at)}
|
|
364
|
+
</span>
|
|
365
|
+
</Tooltip>
|
|
366
|
+
) : (
|
|
367
|
+
<span className="text-xs text-muted-foreground">{t('bookmarks.never')}</span>
|
|
368
|
+
)}
|
|
369
|
+
</TableCell>
|
|
370
|
+
<TableCell className={cellClass}>
|
|
371
|
+
<span className="text-xs text-muted-foreground">
|
|
372
|
+
{formatCreated(bookmark.created_at)}
|
|
373
|
+
</span>
|
|
374
|
+
</TableCell>
|
|
375
|
+
</>
|
|
376
|
+
)}
|
|
377
|
+
<TableCell className={`${cellClass} opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity duration-200`}>
|
|
378
|
+
<div className={`flex items-center justify-end ${compact ? 'gap-1' : 'gap-2'}`}>
|
|
379
|
+
{bookmark.forwarding_enabled && (
|
|
380
|
+
<Tooltip content={`${window.location.origin}/go/${bookmark.slug}`}>
|
|
381
|
+
<Button variant="ghost" size="sm" icon={Copy} className={`flex-shrink-0 h-8 w-8 p-0`} onClick={() => onCopyUrl(bookmark)} aria-label={t('bookmarks.copyUrl')} />
|
|
382
|
+
</Tooltip>
|
|
383
|
+
)}
|
|
384
|
+
{onOpen ? (
|
|
385
|
+
<Tooltip content={t('bookmarks.open')}>
|
|
386
|
+
<Button variant="ghost" size="sm" icon={ExternalLink} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onOpen(bookmark)} aria-label={t('bookmarks.open')} />
|
|
387
|
+
</Tooltip>
|
|
388
|
+
) : (
|
|
389
|
+
<Tooltip content={t('bookmarks.open')}>
|
|
390
|
+
<a href={safeHref(bookmark.url)} target="_blank" rel="noopener noreferrer" className="flex-shrink-0">
|
|
391
|
+
<Button variant="ghost" size="sm" icon={ExternalLink} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} aria-label={t('bookmarks.open')} />
|
|
392
|
+
</a>
|
|
393
|
+
</Tooltip>
|
|
394
|
+
)}
|
|
395
|
+
{bookmark.bookmark_type === 'own' && (
|
|
396
|
+
<>
|
|
397
|
+
{onShare && (
|
|
398
|
+
<Tooltip content={t('sharing.shareBookmark')}>
|
|
399
|
+
<Button variant="ghost" size="sm" icon={Share2} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onShare(bookmark)} aria-label={t('sharing.shareBookmark')} />
|
|
400
|
+
</Tooltip>
|
|
401
|
+
)}
|
|
402
|
+
<Tooltip content={t('common.edit')}>
|
|
403
|
+
<Button variant="ghost" size="sm" icon={Edit} className={`flex-shrink-0 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onEdit(bookmark)} aria-label={t('common.edit')} />
|
|
404
|
+
</Tooltip>
|
|
405
|
+
<Tooltip content={t('common.delete')}>
|
|
406
|
+
<Button variant="ghost" size="sm" icon={Trash2} className={`flex-shrink-0 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 ${compact ? 'h-8 w-8 p-0' : 'h-8 w-8 p-0'}`} onClick={() => onDelete(bookmark.id, bookmark.title)} aria-label={t('common.delete')} />
|
|
407
|
+
</Tooltip>
|
|
408
|
+
</>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
411
|
+
</TableCell>
|
|
412
|
+
</TableRow>
|
|
413
|
+
);
|
|
414
|
+
})}
|
|
415
|
+
</TableBody>
|
|
416
|
+
</Table>
|
|
417
|
+
</Card>
|
|
418
|
+
);
|
|
419
|
+
}
|