diff options
| -rw-r--r-- | apps/web/app/settings/stats/page.tsx | 90 | ||||
| -rw-r--r-- | apps/web/lib/i18n/locales/en/translation.json | 4 | ||||
| -rw-r--r-- | packages/open-api/karakeep-openapi-spec.json | 32 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/models/users.ts | 13 |
5 files changed, 146 insertions, 1 deletions
diff --git a/apps/web/app/settings/stats/page.tsx b/apps/web/app/settings/stats/page.tsx index 599e5362..944d1c59 100644 --- a/apps/web/app/settings/stats/page.tsx +++ b/apps/web/app/settings/stats/page.tsx @@ -11,18 +11,30 @@ import { Archive, BarChart3, BookOpen, + Chrome, Clock, + Code, Database, FileText, Globe, Hash, Heart, + HelpCircle, Highlighter, Image, Link, List, + Rss, + Smartphone, TrendingUp, + Upload, + Zap, } from "lucide-react"; +import { z } from "zod"; + +import { zBookmarkSourceSchema } from "@karakeep/shared/types/bookmarks"; + +type BookmarkSource = z.infer<typeof zBookmarkSourceSchema>; function formatBytes(bytes: number): string { if (bytes === 0) return "0 Bytes"; @@ -47,6 +59,45 @@ const hourLabels = Array.from({ length: 24 }, (_, i) => i === 0 ? "12 AM" : i < 12 ? `${i} AM` : i === 12 ? "12 PM" : `${i - 12} PM`, ); +function formatSourceName(source: BookmarkSource | null): string { + if (!source) return "Unknown"; + const sourceMap: Record<BookmarkSource, string> = { + api: "API", + web: "Web", + extension: "Browser Extension", + cli: "CLI", + mobile: "Mobile App", + singlefile: "SingleFile", + rss: "RSS Feed", + import: "Import", + }; + return sourceMap[source]; +} + +function getSourceIcon(source: BookmarkSource | null): React.ReactNode { + const iconProps = { className: "h-4 w-4 text-muted-foreground" }; + switch (source) { + case "api": + return <Zap {...iconProps} />; + case "web": + return <Globe {...iconProps} />; + case "extension": + return <Chrome {...iconProps} />; + case "cli": + return <Code {...iconProps} />; + case "mobile": + return <Smartphone {...iconProps} />; + case "singlefile": + return <FileText {...iconProps} />; + case "rss": + return <Rss {...iconProps} />; + case "import": + return <Upload {...iconProps} />; + default: + return <HelpCircle {...iconProps} />; + } +} + function SimpleBarChart({ data, maxValue, @@ -439,6 +490,45 @@ export default function StatsPage() { )} </CardContent> </Card> + + {/* Bookmark Sources */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Zap className="h-5 w-5" /> + {t("settings.stats.bookmark_sources.title")} + </CardTitle> + </CardHeader> + <CardContent> + {stats.bookmarksBySource.length > 0 ? ( + <div className="space-y-3"> + {stats.bookmarksBySource.map( + (source: { + source: BookmarkSource | null; + count: number; + }) => ( + <div + key={source.source || "unknown"} + className="flex items-center justify-between" + > + <div className="flex items-center gap-2"> + {getSourceIcon(source.source)} + <span className="max-w-[200px] truncate text-sm"> + {formatSourceName(source.source)} + </span> + </div> + <Badge variant="secondary">{source.count}</Badge> + </div> + ), + )} + </div> + ) : ( + <p className="text-sm text-muted-foreground"> + {t("settings.stats.bookmark_sources.empty")} + </p> + )} + </CardContent> + </Card> </div> {/* Activity Patterns */} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index f582cd9b..4fc58be7 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -181,6 +181,10 @@ }, "storage_breakdown": { "title": "Storage Breakdown" + }, + "bookmark_sources": { + "title": "Bookmark Sources", + "empty": "No source data available" } }, "ai": { diff --git a/packages/open-api/karakeep-openapi-spec.json b/packages/open-api/karakeep-openapi-spec.json index 2e791fbf..4f846cef 100644 --- a/packages/open-api/karakeep-openapi-spec.json +++ b/packages/open-api/karakeep-openapi-spec.json @@ -3397,6 +3397,35 @@ ] }, "maxItems": 10 + }, + "bookmarksBySource": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string", + "nullable": true, + "enum": [ + "api", + "web", + "cli", + "mobile", + "extension", + "singlefile", + "rss", + "import" + ] + }, + "count": { + "type": "number" + } + }, + "required": [ + "source", + "count" + ] + } } }, "required": [ @@ -3411,7 +3440,8 @@ "totalAssetSize", "assetsByType", "bookmarkingActivity", - "tagUsage" + "tagUsage", + "bookmarksBySource" ] } } diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts index 758b757d..830fe87b 100644 --- a/packages/shared/types/users.ts +++ b/packages/shared/types/users.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { zBookmarkSourceSchema } from "./bookmarks"; + export const PASSWORD_MIN_LENGTH = 8; export const PASSWORD_MAX_LENGTH = 100; @@ -91,6 +93,12 @@ export const zUserStatsResponseSchema = z.object({ }), ) .max(10), + bookmarksBySource: z.array( + z.object({ + source: zBookmarkSourceSchema.nullable(), + count: z.number(), + }), + ), }); export const zUserSettingsSchema = z.object({ diff --git a/packages/trpc/models/users.ts b/packages/trpc/models/users.ts index a327e7db..7e6be7a5 100644 --- a/packages/trpc/models/users.ts +++ b/packages/trpc/models/users.ts @@ -506,6 +506,7 @@ export class User implements PrivacyAware { [{ thisYear }], bookmarkTimestamps, tagUsage, + bookmarksBySource, ] = await Promise.all([ // Basic counts this.ctx.db @@ -677,6 +678,17 @@ export class User implements PrivacyAware { .groupBy(bookmarkTags.name) .orderBy(desc(count())) .limit(10), + + // Bookmarks by source + this.ctx.db + .select({ + source: bookmarks.source, + count: count(), + }) + .from(bookmarks) + .where(eq(bookmarks.userId, this.user.id)) + .groupBy(bookmarks.source) + .orderBy(desc(count())), ]); // Process bookmarks by type @@ -735,6 +747,7 @@ export class User implements PrivacyAware { byDayOfWeek: dailyActivity, }, tagUsage, + bookmarksBySource, }; } |
