aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/dashboard
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components/dashboard')
-rw-r--r--apps/web/components/dashboard/preview/LinkContentSection.tsx28
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/AmazonRenderer.tsx137
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/README.md55
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/TikTokRenderer.tsx68
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/XRenderer.tsx58
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/YouTubeRenderer.tsx66
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/index.ts13
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/registry.ts26
-rw-r--r--apps/web/components/dashboard/preview/content-renderers/types.ts16
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[];
+}