feat: add zoomable images to blog posts using react-photo-view

This commit is contained in:
Mauricio Siu 2025-02-28 01:28:45 -06:00
parent d03d30aa76
commit 7429b4995e
7 changed files with 72 additions and 12 deletions

View File

@ -0,0 +1,26 @@
"use client";
import Image from "next/image";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
interface ZoomableImageProps {
src: string;
alt: string;
className?: string;
}
export function ZoomableImage({ src, alt, className }: ZoomableImageProps) {
return (
<PhotoProvider>
<PhotoView src={src}>
<Image
src={src}
alt={alt}
fill
className={`object-cover cursor-zoom-in ${className || ""}`}
/>
</PhotoView>
</PhotoProvider>
);
}

View File

@ -10,6 +10,7 @@ import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import rehypeRaw from "rehype-raw"; import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { ZoomableImage } from "./components/ZoomableImage";
type Props = { type Props = {
params: { locale: string; slug: string }; params: { locale: string; slug: string };
@ -114,7 +115,7 @@ export default async function BlogPostPage({ params }: Props) {
), ),
img: ({ node, src, alt }) => ( img: ({ node, src, alt }) => (
<div className="relative w-full h-64 my-6 rounded-lg overflow-hidden"> <div className="relative w-full h-64 my-6 rounded-lg overflow-hidden">
<Image src={src || ""} alt={alt || ""} fill className="object-cover" /> {src && <ZoomableImage src={src} alt={alt || ""} />}
</div> </div>
), ),
}; };
@ -164,13 +165,11 @@ export default async function BlogPostPage({ params }: Props) {
</div> </div>
</div> </div>
{post.feature_image && ( {post.feature_image && (
<div className="relative h-96 w-full rounded-lg overflow-hidden mb-8"> <div className="relative w-full h-[400px] mb-8">
<Image <ZoomableImage
src={post.feature_image} src={post.feature_image}
alt={post.title} alt={post.title}
fill className="rounded-lg"
className="object-cover"
priority
/> />
</div> </div>
)} )}

View File

@ -73,7 +73,7 @@ export default async function BlogPage({
/> />
{filteredPosts.length === 0 ? ( {filteredPosts.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12 min-h-[20vh] flex items-center justify-center">
<p className="text-xl text-muted-foreground"> <p className="text-xl text-muted-foreground">
{search || selectedTag ? t("noResults") : t("noPosts")} {search || selectedTag ? t("noResults") : t("noPosts")}
</p> </p>

View File

@ -3,7 +3,7 @@ import { Inter, Lexend } from "next/font/google";
import "@/styles/tailwind.css"; import "@/styles/tailwind.css";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server"; import { getMessages } from "next-intl/server";
import "react-photo-view/dist/react-photo-view.css";
import { Footer } from "@/components/Footer"; import { Footer } from "@/components/Footer";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import type { Metadata } from "next"; import type { Metadata } from "next";

View File

@ -1,6 +1,25 @@
import { getPosts } from "@/lib/ghost"; import { getPosts } from "@/lib/ghost";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
function escapeXml(unsafe: string): string {
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case "<":
return "&lt;";
case ">":
return "&gt;";
case "&":
return "&amp;";
case "'":
return "&apos;";
case '"':
return "&quot;";
default:
return c;
}
});
}
export async function GET() { export async function GET() {
const posts = await getPosts(); const posts = await getPosts();
@ -17,13 +36,14 @@ export async function GET() {
(post) => ` (post) => `
<item> <item>
<title><![CDATA[${post.title}]]></title> <title><![CDATA[${post.title}]]></title>
<link>https://dokploy.com/blog/${post.slug}</link> <link>https://dokploy.com/blog/${escapeXml(post.slug)}</link>
<guid>https://dokploy.com/blog/${post.slug}</guid> <guid>https://dokploy.com/blog/${escapeXml(post.slug)}</guid>
<description><![CDATA[${post.excerpt}]]></description> <description><![CDATA[${post.excerpt}]]></description>
<content:encoded><![CDATA[${post.html}]]></content:encoded>
<pubDate>${new Date(post.published_at).toUTCString()}</pubDate> <pubDate>${new Date(post.published_at).toUTCString()}</pubDate>
${ ${
post.feature_image post.feature_image
? `<enclosure url="${post.feature_image}" type="image/jpeg" />` ? `<enclosure url="${escapeXml(post.feature_image)}" type="image/jpeg" />`
: "" : ""
} }
${ ${
@ -39,7 +59,7 @@ export async function GET() {
return new NextResponse(rss, { return new NextResponse(rss, {
headers: { headers: {
"Content-Type": "application/xml", "Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "s-maxage=3600, stale-while-revalidate", "Cache-Control": "s-maxage=3600, stale-while-revalidate",
}, },
}); });

View File

@ -12,6 +12,7 @@
}, },
"browserslist": "defaults, not ie <= 11", "browserslist": "defaults, not ie <= 11",
"dependencies": { "dependencies": {
"react-photo-view": "^1.2.7",
"@headlessui/react": "^1.7.17", "@headlessui/react": "^1.7.17",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-accordion": "^1.2.1",

View File

@ -173,6 +173,9 @@ importers:
react-ga4: react-ga4:
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.0 version: 2.1.0
react-photo-view:
specifier: ^1.2.7
version: 1.2.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
tailwind-merge: tailwind-merge:
specifier: ^2.2.2 specifier: ^2.2.2
version: 2.4.0 version: 2.4.0
@ -3025,6 +3028,12 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
react-photo-view@1.2.7:
resolution: {integrity: sha512-MfOWVPxuibncRLaycZUNxqYU8D9IA+rbGDDaq6GM8RIoGJal592hEJoRAyRSI7ZxyyJNJTLMUWWL3UIXHJJOpw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
react-remove-scroll-bar@2.3.6: react-remove-scroll-bar@2.3.6:
resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -6811,6 +6820,11 @@ snapshots:
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-photo-view@1.2.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-remove-scroll-bar@2.3.6(@types/react@18.3.5)(react@18.2.0): react-remove-scroll-bar@2.3.6(@types/react@18.3.5)(react@18.2.0):
dependencies: dependencies:
react: 18.2.0 react: 18.2.0