From c68e5099797d5b49ed6441ce04d7c77105327f73 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Sun, 3 Aug 2025 23:35:06 -0700 Subject: feat(web): Add special cards for specific websites. Fixes #1344 --- .../dashboard/preview/LinkContentSection.tsx | 28 ++++- .../preview/content-renderers/AmazonRenderer.tsx | 137 +++++++++++++++++++++ .../dashboard/preview/content-renderers/README.md | 55 +++++++++ .../preview/content-renderers/TikTokRenderer.tsx | 68 ++++++++++ .../preview/content-renderers/XRenderer.tsx | 58 +++++++++ .../preview/content-renderers/YouTubeRenderer.tsx | 66 ++++++++++ .../dashboard/preview/content-renderers/index.ts | 13 ++ .../preview/content-renderers/registry.ts | 26 ++++ .../dashboard/preview/content-renderers/types.ts | 16 +++ 9 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/dashboard/preview/content-renderers/AmazonRenderer.tsx create mode 100644 apps/web/components/dashboard/preview/content-renderers/README.md create mode 100644 apps/web/components/dashboard/preview/content-renderers/TikTokRenderer.tsx create mode 100644 apps/web/components/dashboard/preview/content-renderers/XRenderer.tsx create mode 100644 apps/web/components/dashboard/preview/content-renderers/YouTubeRenderer.tsx create mode 100644 apps/web/components/dashboard/preview/content-renderers/index.ts create mode 100644 apps/web/components/dashboard/preview/content-renderers/registry.ts create mode 100644 apps/web/components/dashboard/preview/content-renderers/types.ts (limited to 'apps/web/components/dashboard') diff --git a/apps/web/components/dashboard/preview/LinkContentSection.tsx b/apps/web/components/dashboard/preview/LinkContentSection.tsx index eefec701..3855cb2a 100644 --- a/apps/web/components/dashboard/preview/LinkContentSection.tsx +++ b/apps/web/components/dashboard/preview/LinkContentSection.tsx @@ -25,6 +25,7 @@ import { ZBookmarkedLink, } from "@karakeep/shared/types/bookmarks"; +import { contentRendererRegistry } from "./content-renderers"; import ReaderView from "./ReaderView"; function FullPageArchiveSection({ link }: { link: ZBookmarkedLink }) { @@ -74,14 +75,23 @@ export default function LinkContentSection({ bookmark: ZBookmark; }) { const { t } = useTranslation(); - const [section, setSection] = useState("cached"); + const availableRenderers = contentRendererRegistry.getRenderers(bookmark); + const defaultSection = + availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; + const [section, setSection] = useState(defaultSection); if (bookmark.content.type != BookmarkTypes.LINK) { throw new Error("Invalid content type"); } let content; - if (section === "cached") { + + // Check if current section is a custom renderer + const customRenderer = availableRenderers.find((r) => r.id === section); + if (customRenderer) { + const RendererComponent = customRenderer.component; + content = ; + } else if (section === "cached") { content = ( + {/* Custom renderers first */} + {availableRenderers.map((renderer) => { + const IconComponent = renderer.icon; + return ( + +
+ + {renderer.name} +
+
+ ); + })} + + {/* Default renderers */}
diff --git a/apps/web/components/dashboard/preview/content-renderers/AmazonRenderer.tsx b/apps/web/components/dashboard/preview/content-renderers/AmazonRenderer.tsx new file mode 100644 index 00000000..aaf33565 --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/AmazonRenderer.tsx @@ -0,0 +1,137 @@ +import { ShoppingCart } from "lucide-react"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { ContentRenderer } from "./types"; + +function extractAmazonProductInfo( + url: string, +): { asin: string; domain: string } | null { + const patterns = [ + // Standard product URLs + /amazon\.([a-z.]+)\/.*\/dp\/([A-Z0-9]{10})/, + /amazon\.([a-z.]+)\/dp\/([A-Z0-9]{10})/, + // Shortened URLs + /amazon\.([a-z.]+)\/gp\/product\/([A-Z0-9]{10})/, + // Mobile URLs + /amazon\.([a-z.]+)\/.*\/product\/([A-Z0-9]{10})/, + // International variations + /amazon\.([a-z.]+)\/.*\/([A-Z0-9]{10})/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return { + domain: match[1], + asin: match[2], + }; + } + } + return null; +} + +function canRenderAmazon(bookmark: ZBookmark): boolean { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return false; + } + + const url = bookmark.content.url; + return extractAmazonProductInfo(url) !== null; +} + +function AmazonRendererComponent({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return null; + } + + const productInfo = extractAmazonProductInfo(bookmark.content.url); + if (!productInfo) { + return null; + } + + const { title, description, imageUrl } = bookmark.content; + + return ( +
+
+ {/* Product Image */} + {imageUrl && ( +
+ {title +
+ )} + + {/* Product Info Card */} +
+ {/* Title */} + {title && ( +

{title}

+ )} + + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Product Details */} +
+
+ ASIN: + + {productInfo.asin} + +
+
+ Domain: + + amazon.{productInfo.domain} + +
+
+ + {/* Action Buttons */} + + + {/* Amazon Disclaimer */} +

+ Product information from Amazon. Prices and availability may vary. +

+
+
+
+ ); +} + +export const amazonRenderer: ContentRenderer = { + id: "amazon", + name: "Amazon Product", + icon: ShoppingCart, + canRender: canRenderAmazon, + component: AmazonRendererComponent, + priority: 10, +}; diff --git a/apps/web/components/dashboard/preview/content-renderers/README.md b/apps/web/components/dashboard/preview/content-renderers/README.md new file mode 100644 index 00000000..9606b403 --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/README.md @@ -0,0 +1,55 @@ +# Content-Aware Renderers + +This directory contains the content-aware rendering system for LinkContentPreview. It allows for special rendering of different types of links based on their URL patterns. + +## Architecture + +The system consists of: + +1. **Types** (`types.ts`): Defines the `ContentRenderer` interface +2. **Registry** (`registry.ts`): Manages registration and retrieval of renderers +3. **Individual Renderers**: Each renderer handles a specific type of content + +## Creating a New Renderer + +To add support for a new website or content type: + +1. Create a new file (e.g., `MyWebsiteRenderer.tsx`) +2. Implement the `ContentRenderer` interface: + +```typescript +import { ContentRenderer } from "./types"; +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; +import { MyIcon } from "lucide-react"; + +function canRenderMyWebsite(bookmark: ZBookmark): boolean { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return false; + } + + // Add your URL pattern matching logic here + return bookmark.content.url.includes("mywebsite.com"); +} + +function MyWebsiteRendererComponent({ bookmark }: { bookmark: ZBookmark }) { + // Your custom rendering logic here + return
Custom content for MyWebsite
; +} + +export const myWebsiteRenderer: ContentRenderer = { + id: "mywebsite", + name: "My Website", + icon: MyIcon, + canRender: canRenderMyWebsite, + component: MyWebsiteRendererComponent, + priority: 10, // Higher priority = appears first in dropdown +}; +``` + +3. Register your renderer in `index.ts`: + +```typescript +import { myWebsiteRenderer } from "./MyWebsiteRenderer"; + +contentRendererRegistry.register(myWebsiteRenderer); +``` diff --git a/apps/web/components/dashboard/preview/content-renderers/TikTokRenderer.tsx b/apps/web/components/dashboard/preview/content-renderers/TikTokRenderer.tsx new file mode 100644 index 00000000..ecd0c956 --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/TikTokRenderer.tsx @@ -0,0 +1,68 @@ +import { Video } from "lucide-react"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { ContentRenderer } from "./types"; + +function extractTikTokVideoId(url: string): string | null { + const patterns = [ + /tiktok\.com\/@[^/]+\/video\/(\d+)/, + /tiktok\.com\/t\/([A-Za-z0-9]+)/, + /vm\.tiktok\.com\/([A-Za-z0-9]+)/, + /tiktok\.com\/v\/(\d+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; +} + +function canRenderTikTok(bookmark: ZBookmark): boolean { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return false; + } + + const url = bookmark.content.url; + return extractTikTokVideoId(url) !== null; +} + +function TikTokRendererComponent({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return null; + } + + const videoId = extractTikTokVideoId(bookmark.content.url); + if (!videoId) { + return null; + } + + // TikTok embed URL format + const embedUrl = `https://www.tiktok.com/embed/v2/${videoId}`; + + return ( +
+
+