diff options
| author | MohamedBassem <me@mbassem.com> | 2025-08-03 23:35:06 -0700 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2025-08-03 23:59:45 -0700 |
| commit | c68e5099797d5b49ed6441ce04d7c77105327f73 (patch) | |
| tree | 296fe5f473f46d802fcf94fa203ca37672112c30 /apps/web/components/dashboard | |
| parent | 03aa17200ed80c2978bf496991c6afbb5a04258b (diff) | |
| download | karakeep-c68e5099797d5b49ed6441ce04d7c77105327f73.tar.zst | |
feat(web): Add special cards for specific websites. Fixes #1344
Diffstat (limited to 'apps/web/components/dashboard')
9 files changed, 465 insertions, 2 deletions
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<string>("cached"); + const availableRenderers = contentRendererRegistry.getRenderers(bookmark); + const defaultSection = + availableRenderers.length > 0 ? availableRenderers[0].id : "cached"; + const [section, setSection] = useState<string>(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 = <RendererComponent bookmark={bookmark} />; + } else if (section === "cached") { content = ( <ScrollArea className="h-full"> <ReaderView @@ -109,6 +119,20 @@ export default function LinkContentSection({ </SelectTrigger> <SelectContent> <SelectGroup> + {/* Custom renderers first */} + {availableRenderers.map((renderer) => { + const IconComponent = renderer.icon; + return ( + <SelectItem key={renderer.id} value={renderer.id}> + <div className="flex items-center"> + <IconComponent className="mr-2 h-4 w-4" /> + {renderer.name} + </div> + </SelectItem> + ); + })} + + {/* Default renderers */} <SelectItem value="cached"> <div className="flex items-center"> <BookOpen className="mr-2 h-4 w-4" /> 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 ( + <div className="relative h-full w-full overflow-auto"> + <div className="mx-auto flex max-w-2xl flex-col items-center p-6"> + {/* Product Image */} + {imageUrl && ( + <div className="mb-6 w-full max-w-md"> + <img + src={imageUrl} + alt={title || "Amazon Product"} + className="h-auto max-h-96 w-full rounded-lg object-contain shadow-lg" + /> + </div> + )} + + {/* Product Info Card */} + <div className="w-full rounded-lg border bg-card p-6 shadow-sm"> + {/* Title */} + {title && ( + <h2 className="mb-4 line-clamp-3 text-xl font-semibold">{title}</h2> + )} + + {/* Description */} + {description && ( + <p className="mb-6 line-clamp-4 text-muted-foreground"> + {description} + </p> + )} + + {/* Product Details */} + <div className="mb-6 space-y-3"> + <div className="flex items-center gap-2 text-sm"> + <span className="font-medium">ASIN:</span> + <span className="font-mono text-muted-foreground"> + {productInfo.asin} + </span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="font-medium">Domain:</span> + <span className="text-muted-foreground"> + amazon.{productInfo.domain} + </span> + </div> + </div> + + {/* Action Buttons */} + <div className="flex gap-3"> + <a + href={bookmark.content.url} + target="_blank" + rel="noopener noreferrer" + className="flex flex-1 items-center justify-center gap-2 rounded-md bg-[#FF9900] px-4 py-2 text-center font-medium text-white transition-colors hover:bg-[#FF9900]/90" + > + <ShoppingCart size={16} /> + View on Amazon + </a> + <a + href={`https://www.amazon.${productInfo.domain}/dp/${productInfo.asin}`} + target="_blank" + rel="noopener noreferrer" + className="rounded-md border border-border px-4 py-2 text-sm transition-colors hover:bg-accent" + > + Direct Link + </a> + </div> + + {/* Amazon Disclaimer */} + <p className="mt-4 text-center text-xs text-muted-foreground"> + Product information from Amazon. Prices and availability may vary. + </p> + </div> + </div> + </div> + ); +} + +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 <div>Custom content for MyWebsite</div>; +} + +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 ( + <div className="relative h-full w-full overflow-hidden"> + <div className="absolute inset-0 h-full w-full"> + <iframe + src={embedUrl} + title="TikTok video" + className="h-full w-full border-0" + allow="encrypted-media" + sandbox="allow-scripts allow-same-origin allow-popups" + /> + </div> + </div> + ); +} + +export const tikTokRenderer: ContentRenderer = { + id: "tiktok", + name: "TikTok", + icon: Video, + canRender: canRenderTikTok, + component: TikTokRendererComponent, + priority: 10, +}; diff --git a/apps/web/components/dashboard/preview/content-renderers/XRenderer.tsx b/apps/web/components/dashboard/preview/content-renderers/XRenderer.tsx new file mode 100644 index 00000000..e3e43843 --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/XRenderer.tsx @@ -0,0 +1,58 @@ +import { MessageSquare } from "lucide-react"; +import { Tweet } from "react-tweet"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { ContentRenderer } from "./types"; + +function extractTweetId(url: string): string | null { + const patterns = [ + /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/, + /(?:twitter\.com|x\.com)\/i\/web\/status\/(\d+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; +} + +function canRenderX(bookmark: ZBookmark): boolean { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return false; + } + + const url = bookmark.content.url; + return extractTweetId(url) !== null; +} + +function XRendererComponent({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return null; + } + + const tweetId = extractTweetId(bookmark.content.url); + if (!tweetId) { + return null; + } + + return ( + <div className="relative h-full w-full overflow-auto"> + <div className="flex justify-center p-4"> + <Tweet id={tweetId} /> + </div> + </div> + ); +} + +export const xRenderer: ContentRenderer = { + id: "x", + name: "X (Twitter)", + icon: MessageSquare, + canRender: canRenderX, + component: XRendererComponent, + priority: 10, +}; diff --git a/apps/web/components/dashboard/preview/content-renderers/YouTubeRenderer.tsx b/apps/web/components/dashboard/preview/content-renderers/YouTubeRenderer.tsx new file mode 100644 index 00000000..992eb71e --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/YouTubeRenderer.tsx @@ -0,0 +1,66 @@ +import { Play } from "lucide-react"; + +import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { ContentRenderer } from "./types"; + +function extractYouTubeVideoId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/, + /youtube\.com\/v\/([^&\n?#]+)/, + /youtube\.com\/shorts\/([^&\n?#]+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return match[1]; + } + } + return null; +} + +function canRenderYouTube(bookmark: ZBookmark): boolean { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return false; + } + + const url = bookmark.content.url; + return extractYouTubeVideoId(url) !== null; +} + +function YouTubeRendererComponent({ bookmark }: { bookmark: ZBookmark }) { + if (bookmark.content.type !== BookmarkTypes.LINK) { + return null; + } + + const videoId = extractYouTubeVideoId(bookmark.content.url); + if (!videoId) { + return null; + } + + const embedUrl = `https://www.youtube.com/embed/${videoId}`; + + return ( + <div className="relative h-full w-full overflow-hidden"> + <div className="absolute inset-0 h-full w-full"> + <iframe + src={embedUrl} + title="YouTube video player" + allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" + allowFullScreen + className="h-full w-full border-0" + /> + </div> + </div> + ); +} + +export const youTubeRenderer: ContentRenderer = { + id: "youtube", + name: "YouTube", + icon: Play, + canRender: canRenderYouTube, + component: YouTubeRendererComponent, + priority: 10, +}; diff --git a/apps/web/components/dashboard/preview/content-renderers/index.ts b/apps/web/components/dashboard/preview/content-renderers/index.ts new file mode 100644 index 00000000..effd0aa9 --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/index.ts @@ -0,0 +1,13 @@ +import { amazonRenderer } from "./AmazonRenderer"; +import { contentRendererRegistry } from "./registry"; +import { tikTokRenderer } from "./TikTokRenderer"; +import { xRenderer } from "./XRenderer"; +import { youTubeRenderer } from "./YouTubeRenderer"; + +contentRendererRegistry.register(youTubeRenderer); +contentRendererRegistry.register(xRenderer); +contentRendererRegistry.register(amazonRenderer); +contentRendererRegistry.register(tikTokRenderer); + +export { contentRendererRegistry }; +export * from "./types"; diff --git a/apps/web/components/dashboard/preview/content-renderers/registry.ts b/apps/web/components/dashboard/preview/content-renderers/registry.ts new file mode 100644 index 00000000..1e920eaf --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/registry.ts @@ -0,0 +1,26 @@ +import { ZBookmark } from "@karakeep/shared/types/bookmarks"; + +import { ContentRenderer, ContentRendererRegistry } from "./types"; + +class ContentRendererRegistryImpl implements ContentRendererRegistry { + private renderers: Map<string, ContentRenderer> = new Map< + string, + ContentRenderer + >(); + + register(renderer: ContentRenderer): void { + this.renderers.set(renderer.id, renderer); + } + + getRenderers(bookmark: ZBookmark): ContentRenderer[] { + return [...this.renderers.values()] + .filter((renderer) => renderer.canRender(bookmark)) + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); + } + + getAllRenderers(): ContentRenderer[] { + return [...this.renderers.values()]; + } +} + +export const contentRendererRegistry = new ContentRendererRegistryImpl(); diff --git a/apps/web/components/dashboard/preview/content-renderers/types.ts b/apps/web/components/dashboard/preview/content-renderers/types.ts new file mode 100644 index 00000000..8894c65f --- /dev/null +++ b/apps/web/components/dashboard/preview/content-renderers/types.ts @@ -0,0 +1,16 @@ +import { ZBookmark } from "@karakeep/shared/types/bookmarks"; + +export interface ContentRenderer { + id: string; + name: string; + icon: React.ComponentType<{ className?: string }>; + canRender: (bookmark: ZBookmark) => boolean; + component: React.ComponentType<{ bookmark: ZBookmark }>; + priority?: number; +} + +export interface ContentRendererRegistry { + register: (renderer: ContentRenderer) => void; + getRenderers: (bookmark: ZBookmark) => ContentRenderer[]; + getAllRenderers: () => ContentRenderer[]; +} |
