Files
awesome-chatgpt-prompts-pro…/src/components/prompts/prompt-card.tsx
2026-02-01 21:59:13 +03:00

424 lines
16 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
import { useTranslations, useLocale } from "next-intl";
import { formatDistanceToNow } from "@/lib/date";
import { getPromptUrl } from "@/lib/urls";
import { ArrowBigUp, Lock, Copy, ImageIcon, Download, Play, BadgeCheck, Volume2, Link2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { CodeView } from "@/components/ui/code-view";
import { toast } from "sonner";
import { prettifyJson } from "@/lib/format";
import { PinButton } from "@/components/prompts/pin-button";
import { RunPromptButton } from "@/components/prompts/run-prompt-button";
import { VariableFillModal, hasVariables, renderContentWithVariables } from "@/components/prompts/variable-fill-modal";
import { ExamplesSlider } from "@/components/prompts/examples-slider";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { AudioPlayer } from "@/components/prompts/audio-player";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export interface PromptCardProps {
prompt: {
id: string;
slug?: string | null;
title: string;
description: string | null;
content: string;
type: string;
structuredFormat?: string | null;
mediaUrl: string | null;
isPrivate: boolean;
voteCount: number;
createdAt: Date;
author: {
id: string;
name: string | null;
username: string;
avatar: string | null;
verified?: boolean;
};
contributorCount?: number;
contributors?: Array<{
id: string;
username: string;
name: string | null;
avatar: string | null;
}>;
category: {
id: string;
name: string;
slug: string;
parent?: {
id: string;
name: string;
slug: string;
} | null;
} | null;
tags: Array<{
tag: {
id: string;
name: string;
slug: string;
color: string;
};
}>;
_count?: {
votes?: number;
contributors?: number;
outgoingConnections?: number;
incomingConnections?: number;
};
userExamples?: Array<{
id: string;
mediaUrl: string;
user: {
username: string;
name: string | null;
avatar: string | null;
};
}>;
};
showPinButton?: boolean;
isPinned?: boolean;
}
export function PromptCard({ prompt, showPinButton = false, isPinned = false }: PromptCardProps) {
const t = useTranslations("prompts");
const tCommon = useTranslations("common");
const locale = useLocale();
const outgoingCount = prompt._count?.outgoingConnections || 0;
const incomingCount = prompt._count?.incomingConnections || 0;
const isFlowStart = outgoingCount > 0 && incomingCount === 0;
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"copy" | "run">("copy");
const [imageError, setImageError] = useState(false);
const isStructuredInput = !!prompt.structuredFormat;
const isAudio = prompt.type === "AUDIO";
const isVideo = prompt.type === "VIDEO";
const hasMediaBackground = prompt.type === "IMAGE" || isVideo || (isStructuredInput && !!prompt.mediaUrl && !isAudio);
const videoRef = useRef<HTMLVideoElement>(null);
const [isVisible, setIsVisible] = useState(false);
// Autoplay video when visible in viewport
useEffect(() => {
if (!isVideo || !videoRef.current) return;
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting);
if (entry.isIntersecting) {
videoRef.current?.play().catch(() => {});
} else {
videoRef.current?.pause();
}
},
{ threshold: 0.3 }
);
observer.observe(videoRef.current);
return () => observer.disconnect();
}, [isVideo]);
const handleMouseEnter = () => {
// Video autoplay is now handled by IntersectionObserver
};
const handleMouseLeave = () => {
// Video pause is now handled by IntersectionObserver
};
const contentHasVariables = hasVariables(prompt.content);
const copyToClipboard = async (content: string) => {
await navigator.clipboard.writeText(content);
toast.success(tCommon("copiedToClipboard"));
};
const handleCopyClick = () => {
if (contentHasVariables) {
setModalMode("copy");
setModalOpen(true);
} else {
copyToClipboard(prompt.content);
}
};
const handleRunClick = () => {
setModalMode("run");
setModalOpen(true);
};
const handleDownloadSkill = async () => {
// Download .skill zip for skills
const base = prompt.slug ? `${prompt.id}_${prompt.slug}` : prompt.id;
const url = `/api/prompts/${base}/skill`;
try {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch");
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
// Use slug for filename
const slug = prompt.slug || prompt.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
a.download = `${slug}.skill`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
toast.success(t("downloadStarted"));
} catch {
toast.error(t("downloadFailed"));
}
};
return (
<div
className={`group border rounded-[var(--radius)] overflow-hidden hover:border-foreground/20 transition-colors flex flex-col ${hasMediaBackground || isAudio ? "" : "p-4"}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Image/Video Background for IMAGE/VIDEO type or STRUCTURED with media */}
{hasMediaBackground && (
<div className="relative bg-muted">
{prompt.mediaUrl && !imageError ? (
prompt.userExamples && prompt.userExamples.length > 0 ? (
<ExamplesSlider
examples={prompt.userExamples}
mainMediaUrl={prompt.mediaUrl}
title={prompt.title}
isVideo={isVideo}
/>
) : isVideo ? (
<video
ref={videoRef}
src={prompt.mediaUrl}
className="w-full object-cover"
style={{ maxHeight: "400px" }}
muted
loop
playsInline
preload="metadata"
/>
) : (
<img
src={prompt.mediaUrl}
alt={prompt.title}
className="w-full object-cover object-top"
style={{ maxHeight: "400px" }}
onError={() => setImageError(true)}
/>
)
) : (
<div className="h-32 flex items-center justify-center">
<ImageIcon className="h-8 w-8 text-muted-foreground/30" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/30 to-transparent pointer-events-none" />
{/* Badges overlay */}
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{isFlowStart && (
<div className="flex items-center gap-0.5 bg-background/80 backdrop-blur-sm rounded px-1.5 py-0.5">
<Link2 className="h-3 w-3 text-muted-foreground" />
<span className="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium text-muted-foreground">
{outgoingCount}
</span>
</div>
)}
<Badge variant="secondary" className="text-[10px] bg-background/80 backdrop-blur-sm">
{t(`types.${prompt.type.toLowerCase()}`)}
</Badge>
</div>
</div>
)}
{/* Audio Player for AUDIO type */}
{isAudio && (
<div className="p-3 pb-0">
{prompt.mediaUrl ? (
<AudioPlayer src={prompt.mediaUrl} compact />
) : (
<div className="h-12 flex items-center justify-center bg-muted rounded-lg">
<Volume2 className="h-5 w-5 text-muted-foreground/30" />
</div>
)}
</div>
)}
<div className={hasMediaBackground || isAudio ? "p-3 flex-1 flex flex-col" : "flex-1 flex flex-col"}>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-1 flex-1 min-w-0">
{prompt.isPrivate && <Lock className="h-3 w-3 text-muted-foreground shrink-0" />}
<Link href={getPromptUrl(prompt.id, prompt.slug)} prefetch={false} className="font-medium text-sm hover:underline line-clamp-1">
{prompt.title}
</Link>
</div>
{(!hasMediaBackground || isAudio) && (
<div className="flex items-center gap-1 shrink-0">
{isFlowStart && (
<div className="flex items-center gap-0.5 border rounded px-1.5 py-0.5">
<Link2 className="h-3 w-3 text-muted-foreground" />
<span className="flex items-center justify-center h-4 w-4 rounded-full bg-muted text-[9px] font-medium text-muted-foreground">
{outgoingCount}
</span>
</div>
)}
<Badge variant="outline" className="text-[10px]">
{t(`types.${prompt.type.toLowerCase()}`)}
</Badge>
</div>
)}
</div>
{/* Description */}
{prompt.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">{prompt.description}</p>
)}
{/* Content Preview - smaller for image prompts */}
<div className="relative flex-1 mb-3 min-h-0">
{isStructuredInput ? (
<CodeView
content={prompt.structuredFormat?.toLowerCase() === "json" ? prettifyJson(prompt.content) : prompt.content}
language={(prompt.structuredFormat?.toLowerCase() as "json" | "yaml") || "json"}
maxLines={hasMediaBackground ? 3 : 10}
fontSize="xs"
preview
/>
) : (
<pre className={`text-xs text-muted-foreground bg-muted p-2 rounded overflow-hidden font-mono h-full whitespace-pre-wrap break-words ${hasMediaBackground ? "line-clamp-2" : "line-clamp-4"}`}>
{contentHasVariables ? renderContentWithVariables(prompt.content) : prompt.content}
</pre>
)}
{showPinButton && (
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<PinButton promptId={prompt.id} initialPinned={isPinned} iconOnly />
</div>
)}
</div>
{/* Variable fill modal */}
{contentHasVariables && (
<VariableFillModal
content={prompt.content}
open={modalOpen}
onOpenChange={setModalOpen}
mode={modalMode}
/>
)}
{/* Tags */}
{prompt.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{prompt.tags.slice(0, 3).map(({ tag }) => (
<Link
key={tag.id}
href={`/tags/${tag.slug}`}
prefetch={false}
className="px-1.5 py-0.5 rounded text-[10px] hover:opacity-80 transition-opacity"
style={{ backgroundColor: tag.color + "15", color: tag.color }}
>
{tag.name}
</Link>
))}
{prompt.tags.length > 3 && (
<span className="text-[10px] text-muted-foreground">+{prompt.tags.length - 3}</span>
)}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between text-[11px] text-muted-foreground pt-2 border-t mt-auto">
<div className="flex items-center gap-1.5">
<Link href={`/@${prompt.author.username}`} prefetch={false} className="hover:text-foreground flex items-center gap-1.5">
<Avatar className="h-4 w-4">
<AvatarImage src={prompt.author.avatar || undefined} alt={prompt.author.username} />
<AvatarFallback className="text-[8px]">{prompt.author.username[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
@{prompt.author.username}
{prompt.author.verified && <BadgeCheck className="h-3 w-3 mt-0.5 text-primary shrink-0" />}
</Link>
{prompt.contributors && prompt.contributors.length > 0 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default hover:text-foreground">+{prompt.contributors.length}</span>
</TooltipTrigger>
<TooltipContent side="top" className="p-2">
<div className="space-y-1">
<div className="text-xs font-medium mb-1.5">{t("promptContributors")}</div>
{prompt.contributors.map((contributor) => (
<Link
key={contributor.id}
href={`/@${contributor.username}`}
prefetch={false}
className="flex items-center gap-2 hover:underline rounded px-1 py-0.5 -mx-1"
>
<Avatar className="h-4 w-4">
<AvatarImage src={contributor.avatar || undefined} />
<AvatarFallback className="text-[8px]">
{contributor.name?.charAt(0) || contributor.username.charAt(0)}
</AvatarFallback>
</Avatar>
<span className="text-xs">@{contributor.username}</span>
</Link>
))}
</div>
</TooltipContent>
</Tooltip>
) : prompt.contributorCount ? (
<span>+{prompt.contributorCount}</span>
) : null}
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5">
<ArrowBigUp className="h-3.5 w-3.5" />
{prompt.voteCount}
</span>
<button
onClick={handleCopyClick}
className="p-1 rounded hover:bg-accent"
>
<Copy className="h-3 w-3" />
</button>
{prompt.type === "SKILL" ? (
<button
onClick={handleDownloadSkill}
className="h-6 w-6 rounded hover:bg-accent flex items-center justify-center"
title={t("download")}
>
<Download className="h-3.5 w-3.5" />
</button>
) : contentHasVariables ? (
<button
onClick={handleRunClick}
className="h-6 w-6 rounded hover:bg-accent flex items-center justify-center"
>
<Play className="h-4 w-4" />
</button>
) : (
<RunPromptButton
content={prompt.content}
title={prompt.title}
description={prompt.description || undefined}
size="icon"
variant="ghost"
className="h-6 w-6"
categoryName={prompt.category?.name}
parentCategoryName={prompt.category?.parent?.name}
promptType={prompt.type as "TEXT" | "IMAGE" | "VIDEO" | "AUDIO" | "STRUCTURED" | "SKILL"}
/>
)}
</div>
</div>
</div>
</div>
);
}