@mdguggenbichler/slugbase-core 0.0.30 → 0.0.32

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 (35) hide show
  1. package/frontend/src/components/FilterChips.tsx +5 -3
  2. package/frontend/src/components/StatCard.tsx +82 -5
  3. package/frontend/src/components/bookmarks/BookmarkCard.tsx +317 -210
  4. package/frontend/src/components/bookmarks/BookmarkTableView.tsx +47 -23
  5. package/frontend/src/components/collections/CollectionToolbar.tsx +294 -0
  6. package/frontend/src/components/collections/README.md +44 -0
  7. package/frontend/src/components/collections/index.ts +2 -0
  8. package/frontend/src/components/dashboard/DashboardHeader.tsx +16 -0
  9. package/frontend/src/components/dashboard/MostUsedTagsSection.tsx +49 -0
  10. package/frontend/src/components/dashboard/PinnedSection.tsx +110 -0
  11. package/frontend/src/components/dashboard/QuickAccessSection.tsx +120 -0
  12. package/frontend/src/components/dashboard/README.md +35 -0
  13. package/frontend/src/components/dashboard/StatsCardsRow.tsx +78 -0
  14. package/frontend/src/components/dashboard/index.ts +17 -0
  15. package/frontend/src/config/docs.ts +19 -0
  16. package/frontend/src/config/mode.ts +6 -4
  17. package/frontend/src/locales/de.json +2 -0
  18. package/frontend/src/locales/en.json +1 -0
  19. package/frontend/src/locales/es.json +2 -0
  20. package/frontend/src/locales/fr.json +2 -0
  21. package/frontend/src/locales/it.json +2 -0
  22. package/frontend/src/locales/ja.json +2 -0
  23. package/frontend/src/locales/nl.json +2 -0
  24. package/frontend/src/locales/pl.json +2 -0
  25. package/frontend/src/locales/pt.json +2 -0
  26. package/frontend/src/locales/ru.json +2 -0
  27. package/frontend/src/locales/zh.json +2 -0
  28. package/frontend/src/pages/Bookmarks.tsx +97 -214
  29. package/frontend/src/pages/Dashboard.tsx +99 -216
  30. package/frontend/src/pages/Folders.tsx +181 -251
  31. package/frontend/src/pages/Profile.tsx +3 -2
  32. package/frontend/src/pages/Signup.tsx +3 -2
  33. package/frontend/src/pages/Tags.tsx +87 -145
  34. package/frontend/src/pages/admin/AdminLayout.tsx +2 -1
  35. package/package.json +1 -1
@@ -4,15 +4,14 @@ import { useTranslation } from 'react-i18next';
4
4
  import api from '../api/client';
5
5
  import ConfirmDialog from '../components/ui/ConfirmDialog';
6
6
  import { useConfirmDialog } from '../hooks/useConfirmDialog';
7
- import { Plus, Edit, Trash2, Tag as TagIcon, LayoutGrid, List, ChevronLeft, ChevronRight } from 'lucide-react';
7
+ import { Plus, Edit, Trash2, Tag as TagIcon, ChevronLeft, ChevronRight } from 'lucide-react';
8
8
  import TagModal from '../components/modals/TagModal';
9
9
  import Button from '../components/ui/Button';
10
- import Select from '../components/ui/Select';
11
- import { PageHeader } from '../components/PageHeader';
10
+ import { CollectionToolbar } from '../components/collections';
12
11
  import { EmptyState } from '../components/EmptyState';
13
12
  import { PageLoadingSkeleton } from '../components/ui/PageLoadingSkeleton';
14
13
  import { useAppConfig } from '../contexts/AppConfigContext';
15
- import { FilterChips } from '../components/FilterChips';
14
+ import { Card } from '../components/ui/card';
16
15
 
17
16
  interface Tag {
18
17
  id: string;
@@ -39,9 +38,6 @@ export default function Tags() {
39
38
  const saved = localStorage.getItem('tags-view-mode');
40
39
  return (saved === 'list' || saved === 'card') ? saved : 'card';
41
40
  });
42
- const [compactMode, setCompactMode] = useState(() => {
43
- return localStorage.getItem('tags-compact-mode') === 'true';
44
- });
45
41
 
46
42
  const sortParam = searchParams.get('sort');
47
43
  const sortBy = (sortParam === 'recently_added' || sortParam === 'alphabetical') ? sortParam : DEFAULT_SORT;
@@ -57,10 +53,6 @@ export default function Tags() {
57
53
  localStorage.setItem('tags-view-mode', viewMode);
58
54
  }, [viewMode]);
59
55
 
60
- useEffect(() => {
61
- localStorage.setItem('tags-compact-mode', compactMode.toString());
62
- }, [compactMode]);
63
-
64
56
  useEffect(() => {
65
57
  loadTags();
66
58
  }, [sortBy, page, pageSize]);
@@ -171,83 +163,43 @@ export default function Tags() {
171
163
 
172
164
  return (
173
165
  <div className="space-y-6 pb-24">
174
- {/* Sticky controls bar: header + toolbar - stays visible when scrolling */}
175
- <div className="sticky top-0 z-40 space-y-4 pb-4 -mx-4 sm:-mx-6 lg:-mx-8 px-4 sm:px-6 lg:px-8 pt-0 -mt-8 bg-background shadow-sm">
176
- <PageHeader
177
- className="pt-4"
178
- title={`${t('tags.title')} (${totalTags})`}
179
- subtitle={
180
- hasActiveFilters || totalTags > pageSize
181
- ? t('bookmarks.showingXOfY', { x: sortedTags.length, y: totalTags })
182
- : undefined
183
- }
184
- actions={
185
- <Button onClick={handleCreate} icon={Plus}>
186
- {t('tags.create')}
187
- </Button>
188
- }
189
- />
190
-
191
- <FilterChips
192
- chips={filterChips}
193
- onRemove={handleRemoveFilter}
194
- onClearAll={handleResetFilters}
195
- clearAllLabel={t('bookmarks.clearAllFilters')}
196
- clearAllAriaLabel={t('bookmarks.clearAllFilters')}
197
- />
198
-
199
- {/* Toolbar: Sort, Page size, View Modes - same card style as Bookmarks/Folders */}
200
- <div className="flex flex-wrap items-center gap-3 bg-card rounded-lg border border-border p-4 shadow-sm">
201
- <div className="flex items-center gap-2">
202
- <Select
203
- value={sortBy}
204
- onChange={(value) => updateParams({ sort: value as SortOption })}
205
- options={sortOptions}
206
- className="min-w-[160px]"
207
- />
208
- </div>
209
- <div className="flex items-center gap-2">
210
- <Select
211
- value={String(pageSize)}
212
- onChange={(value) => updateParams({ limit: value })}
213
- options={PAGE_SIZE_OPTIONS.map((n) => ({ value: String(n), label: String(n) }))}
214
- className="min-w-[80px]"
215
- />
216
- <span className="text-sm text-muted-foreground whitespace-nowrap">{t('bookmarks.perPage')}</span>
217
- </div>
218
- <div className="flex items-center gap-2 border-l border-border pl-3 ml-auto">
219
- <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-1 border border-border">
220
- <button
221
- onClick={() => setViewMode('card')}
222
- className={`p-1.5 rounded transition-colors ${
223
- viewMode === 'card' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
224
- }`}
225
- title={t('tags.viewCard')}
226
- >
227
- <LayoutGrid className="h-4 w-4" />
228
- </button>
229
- <button
230
- onClick={() => setViewMode('list')}
231
- className={`p-1.5 rounded transition-colors ${
232
- viewMode === 'list' ? 'bg-background text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'
233
- }`}
234
- title={t('tags.viewList')}
235
- >
236
- <List className="h-4 w-4" />
237
- </button>
238
- </div>
239
- <button
240
- onClick={() => setCompactMode(!compactMode)}
241
- className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${
242
- compactMode ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground hover:bg-accent'
243
- }`}
244
- title={t('tags.compactMode')}
245
- >
246
- {t('tags.compactMode')}
247
- </button>
248
- </div>
249
- </div>
250
- </div>
166
+ <CollectionToolbar
167
+ title={t('tags.title')}
168
+ count={totalTags}
169
+ subtitle={
170
+ hasActiveFilters || totalTags > pageSize
171
+ ? t('bookmarks.showingXOfY', { x: sortedTags.length, y: totalTags })
172
+ : undefined
173
+ }
174
+ createButton={{ label: t('tags.create'), onClick: handleCreate }}
175
+ filterChips={{
176
+ chips: filterChips,
177
+ onRemove: handleRemoveFilter,
178
+ onClearAll: handleResetFilters,
179
+ clearAllLabel: t('bookmarks.clearAllFilters'),
180
+ clearAllAriaLabel: t('bookmarks.clearAllFilters'),
181
+ }}
182
+ sort={{
183
+ value: sortBy,
184
+ onChange: (value) => updateParams({ sort: value as SortOption }),
185
+ options: sortOptions,
186
+ className: 'min-w-[160px]',
187
+ }}
188
+ perPage={{
189
+ value: pageSize,
190
+ onChange: (value) => {
191
+ updateParams({ limit: String(value) });
192
+ },
193
+ options: [...PAGE_SIZE_OPTIONS],
194
+ label: t('bookmarks.perPage'),
195
+ }}
196
+ viewMode={{
197
+ value: viewMode,
198
+ onChange: setViewMode,
199
+ cardLabel: t('tags.viewCard'),
200
+ listLabel: t('tags.viewList'),
201
+ }}
202
+ />
251
203
 
252
204
  {/* Tags Display */}
253
205
  {sortedTags.length === 0 ? (
@@ -262,55 +214,48 @@ export default function Tags() {
262
214
  }
263
215
  />
264
216
  ) : viewMode === 'card' ? (
265
- <div className={`grid grid-cols-1 gap-3 items-stretch ${
266
- compactMode
267
- ? 'sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
268
- : 'sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6'
269
- }`}>
217
+ <div className="grid gap-3 items-start [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
270
218
  {sortedTags.map((tag) => (
271
- <div
219
+ <Card
272
220
  key={tag.id}
273
- className={`group bg-card rounded-lg border border-border hover:border-primary/70 hover:bg-muted/50 hover:shadow-md transition-all duration-200 flex flex-col h-full min-h-0 ${compactMode ? 'p-2.5 min-h-[160px]' : 'p-2.5 min-h-[140px]'}`}
221
+ className="group relative flex flex-col cursor-pointer rounded-lg border bg-card/95 dark:bg-card/90 transition-[border-color,box-shadow] duration-150 border-border/80 hover:border-primary/80 hover:shadow-[0_2px_6px_rgba(0,0,0,0.06)] dark:border-border/70 dark:hover:border-primary/80 dark:hover:shadow-[0_2px_6px_rgba(0,0,0,0.25)] px-3 pt-0 pb-1.5 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
274
222
  >
275
223
  <Link
276
224
  to={`${prefix}/bookmarks?tag_id=${tag.id}`}
277
- className="flex-1 flex flex-col min-w-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
278
- >
279
- <div className="space-y-3 flex-1 flex flex-col">
280
- <div className="flex items-start gap-3">
281
- <div className={`flex-shrink-0 ${compactMode ? 'w-9 h-9' : 'w-10 h-10'} rounded-xl bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/20 flex items-center justify-center border border-purple-100 dark:border-purple-800/50`}>
282
- <TagIcon className={`${compactMode ? 'h-4 w-4' : 'h-5 w-5'} text-purple-600 dark:text-purple-400`} />
283
- </div>
284
- <div className="flex-1 min-w-0 pt-0.5">
285
- <h3 className={`${compactMode ? 'text-xs' : 'text-sm'} font-medium text-foreground truncate`}>
286
- {tag.name}
287
- </h3>
288
- </div>
289
- </div>
225
+ className="absolute inset-0 rounded-lg z-0 focus:outline-none"
226
+ aria-label={tag.name}
227
+ />
228
+ <header className="flex-shrink-0 flex items-center gap-1.5 min-w-0 pt-3 relative z-10">
229
+ <div className="flex-shrink-0 w-7 h-7 rounded-md bg-background/90 dark:bg-muted/20 flex items-center justify-center border border-border/50 overflow-hidden">
230
+ <TagIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
290
231
  </div>
291
- </Link>
292
- <div className={`flex gap-1.5 pt-2.5 mt-auto shrink-0 border-t border-border ${compactMode ? 'pt-2' : ''}`}>
293
- <Button
294
- variant="ghost"
295
- size="sm"
296
- icon={Edit}
297
- iconClassName="h-3.5 w-3.5 stroke-[1.5]"
298
- onClick={() => handleEdit(tag)}
299
- className="flex-1 h-8 min-w-0 text-xs"
300
- >
301
- {t('common.edit')}
302
- </Button>
303
- <Button
304
- variant="ghost"
305
- size="sm"
306
- icon={Trash2}
307
- iconClassName="h-3.5 w-3.5 stroke-[1.5]"
308
- onClick={() => handleDelete(tag.id)}
309
- title={t('common.delete')}
310
- className="h-8 w-8 p-0 flex-shrink-0 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
311
- />
232
+ <div className="flex-1 min-w-0 min-h-0 overflow-hidden">
233
+ <h3 className="text-sm font-semibold text-foreground line-clamp-2 break-words leading-snug tracking-tight min-h-0">
234
+ {tag.name}
235
+ </h3>
312
236
  </div>
313
- </div>
237
+ </header>
238
+ <footer className="flex-shrink-0 flex items-center justify-end gap-0.5 h-6 min-h-[24px] pt-2.5 relative z-10 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity duration-150 w-[52px] ml-auto">
239
+ <Button
240
+ variant="ghost"
241
+ size="sm"
242
+ icon={Edit}
243
+ iconClassName="h-3.5 w-3.5 stroke-[1.5]"
244
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleEdit(tag); }}
245
+ className="h-6 w-6 p-0 min-w-6 text-muted-foreground hover:text-foreground"
246
+ title={t('common.edit')}
247
+ />
248
+ <Button
249
+ variant="ghost"
250
+ size="sm"
251
+ icon={Trash2}
252
+ iconClassName="h-3.5 w-3.5 stroke-[1.5]"
253
+ onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDelete(tag.id); }}
254
+ className="h-6 w-6 p-0 min-w-6 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
255
+ title={t('common.delete')}
256
+ />
257
+ </footer>
258
+ </Card>
314
259
  ))}
315
260
  </div>
316
261
  ) : (
@@ -318,10 +263,10 @@ export default function Tags() {
318
263
  <table className="w-full">
319
264
  <thead className="bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
320
265
  <tr>
321
- <th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-left ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
266
+ <th className="px-4 py-3 text-left text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
322
267
  {t('tags.name')}
323
268
  </th>
324
- <th className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'} text-right ${compactMode ? 'text-[10px]' : 'text-xs'} font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide`}>
269
+ <th className="px-4 py-3 text-right text-xs font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
325
270
  {t('common.actions')}
326
271
  </th>
327
272
  </tr>
@@ -330,32 +275,29 @@ export default function Tags() {
330
275
  {sortedTags.map((tag) => (
331
276
  <tr
332
277
  key={tag.id}
333
- className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${compactMode ? 'h-10' : ''}`}
278
+ className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
334
279
  >
335
- <td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
280
+ <td className="px-4 py-3">
336
281
  <Link
337
282
  to={`${prefix}/bookmarks?tag_id=${tag.id}`}
338
- className={`flex items-center ${compactMode ? 'gap-2' : 'gap-3'} hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded`}
283
+ className="flex items-center gap-3 hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
339
284
  >
340
- <div className={`flex-shrink-0 ${compactMode ? 'w-6 h-6' : 'w-8 h-8'} rounded-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/20 flex items-center justify-center border border-purple-100 dark:border-purple-800/50`}>
341
- <TagIcon className={`${compactMode ? 'h-3 w-3' : 'h-4 w-4'} text-purple-600 dark:text-purple-400`} />
285
+ <div className="flex-shrink-0 w-8 h-8 rounded-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/20 flex items-center justify-center border border-purple-100 dark:border-purple-800/50">
286
+ <TagIcon className="h-4 w-4 text-purple-600 dark:text-purple-400" />
342
287
  </div>
343
- <div className={`font-medium text-gray-900 dark:text-white ${compactMode ? 'text-xs' : 'text-[15px]'}`}>
288
+ <div className="font-medium text-gray-900 dark:text-white text-[15px]">
344
289
  {tag.name}
345
290
  </div>
346
291
  </Link>
347
292
  </td>
348
- {!compactMode && (
349
- <td className="px-4 py-3 text-xs text-muted-foreground">—</td>
350
- )}
351
- <td className={`${compactMode ? 'px-2 py-1.5' : 'px-4 py-3'}`}>
352
- <div className={`flex items-center justify-end ${compactMode ? 'gap-1' : 'gap-2'}`}>
293
+ <td className="px-4 py-3">
294
+ <div className="flex items-center justify-end gap-2">
353
295
  <Button
354
296
  variant="ghost"
355
297
  size="sm"
356
298
  icon={Edit}
357
299
  onClick={() => handleEdit(tag)}
358
- className={compactMode ? 'px-1 h-6' : 'px-2'}
300
+ className="px-2"
359
301
  />
360
302
  <Button
361
303
  variant="ghost"
@@ -363,7 +305,7 @@ export default function Tags() {
363
305
  icon={Trash2}
364
306
  onClick={() => handleDelete(tag.id)}
365
307
  title={t('common.delete')}
366
- className={`text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 ${compactMode ? 'px-1 h-6' : 'px-2'}`}
308
+ className="text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 px-2"
367
309
  />
368
310
  </div>
369
311
  </td>
@@ -1,6 +1,7 @@
1
1
  import { useTranslation } from 'react-i18next';
2
2
  import { Outlet } from 'react-router-dom';
3
3
  import { ExternalLink } from 'lucide-react';
4
+ import { getDocsApiReferenceUrl } from '../../config/docs';
4
5
 
5
6
  export default function AdminLayout() {
6
7
  const { t } = useTranslation();
@@ -25,7 +26,7 @@ export default function AdminLayout() {
25
26
  </p>
26
27
  </div>
27
28
  <a
28
- href="/api-docs"
29
+ href={getDocsApiReferenceUrl()}
29
30
  target="_blank"
30
31
  rel="noopener noreferrer"
31
32
  className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-primary hover:text-primary/90 transition-colors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mdguggenbichler/slugbase-core",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "SlugBase core: backend and frontend entrypoints for self-hosted and cloud apps",
5
5
  "type": "module",
6
6
  "exports": {