ATProto Social Bookmark
at main 139 lines 4.5 kB view raw
1import { useComputedColorScheme } from "@mantine/core"; 2import Image from "next/image"; 3import React, { useEffect, useState, useMemo, useCallback } from "react"; 4 5interface ArticleImageProps { 6 src?: string | null; 7 url: string; 8 alt?: string; 9 priority?: boolean; 10} 11 12// 通常画像 13const normalizeUrl = (url?: string | null) => { 14 if (!url || typeof url !== 'string') return null; 15 if (url.startsWith('//')) return `https:${url}`; 16 return url; 17}; 18 19const isValidUrl = (url?: string | null) => { 20 if (!url || typeof url !== 'string') return false; 21 // データURI、Blob、またはhttp/httpsで始まる絶対URL、あるいはスラッシュで始まる相対パス 22 return url.startsWith('http') || url.startsWith('/') || url.startsWith('data:') || url.startsWith('blob:'); 23}; 24 25// YouTube動画IDを抽出 26const extractYoutubeId = (url: string): string | null => { 27 const match = url.match( 28 /(?:youtube\.com\/(?:[^/]+\/.+\/|(?:v|embed)\/|.*[?&]v=)|youtu\.be\/)([^"&?/\s]{11})/ 29 ); 30 return match ? match[1] : null; 31}; 32 33// ニコニコ動画IDを抽出 34const extractNicoId = (url: string): string | null => { 35 const match = url.match(/(?:nicovideo\.jp\/watch\/|nico\.ms\/)(sm\d+)/); 36 return match ? match[1] : null; 37}; 38 39const ArticleImage: React.FC<ArticleImageProps> = ({ src, url, alt = "Article Image", priority = false }) => { 40 const computedColorScheme = useComputedColorScheme("light"); 41 const [mounted, setMounted] = useState(false); 42 const [unoptimized, setUnoptimized] = useState(false); 43 const [errorCount, setErrorCount] = useState(0); 44 45 const dummyUrl = useMemo(() => { 46 return mounted && computedColorScheme === "dark" 47 ? "https://dummyimage.com/360x180/333/ccc.png&text=++no+image++" 48 : "https://dummyimage.com/360x180/ced4da/ffffff.png&text=++no+image++"; 49 }, [mounted, computedColorScheme]); 50 51 const currentSrc = useMemo(() => { 52 if (errorCount >= 2) return dummyUrl; 53 const normalized = normalizeUrl(src); 54 return isValidUrl(normalized) ? normalized! : dummyUrl; 55 }, [src, dummyUrl, errorCount]); 56 57 useEffect(() => { 58 // 非同期にすることで同期呼び出し警告を回避 59 const timer = setTimeout(() => setMounted(true), 0); 60 return () => clearTimeout(timer); 61 }, []); 62 63 // src が変わったらエラーカウントをリセット 64 useEffect(() => { 65 const timer = setTimeout(() => { 66 setErrorCount(0); 67 setUnoptimized(false); 68 }, 0); 69 return () => clearTimeout(timer); 70 }, [src]); 71 72 const youtubeId = useMemo(() => extractYoutubeId(url), [url]); 73 const nicoId = useMemo(() => extractNicoId(url), [url]); 74 75 const handleError = useCallback(() => { 76 if (currentSrc === dummyUrl) return; 77 78 if (errorCount === 0) { 79 // 1回目のエラー: 最適化を無効にしてリトライ 80 setUnoptimized(true); 81 setErrorCount(1); 82 } else if (errorCount === 1) { 83 // 2回目のエラー: ダミー画像に切り替え 84 setErrorCount(2); 85 setUnoptimized(false); 86 } 87 }, [currentSrc, dummyUrl, errorCount]); 88 89 // YouTubeの場合 90 if (youtubeId) { 91 return ( 92 <div style={{ position: "relative", width: "100%", height: "100%" }}> 93 <iframe 94 width="100%" 95 height="100%" 96 src={`https://www.youtube.com/embed/${youtubeId}`} 97 title="YouTube video player" 98 frameBorder="0" 99 allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" 100 allowFullScreen 101 style={{ objectFit: "cover", position: "absolute", top: 0, left: 0 }} 102 /> 103 </div> 104 ); 105 } 106 107 // ニコニコ動画の場合 108 if (nicoId) { 109 return ( 110 <div style={{ position: "relative", width: "100%", height: "100%" }}> 111 <iframe 112 width="100%" 113 height="100%" 114 src={`https://embed.nicovideo.jp/watch/${nicoId}`} 115 title="Niconico video player" 116 frameBorder="0" 117 allow="autoplay; fullscreen" 118 allowFullScreen 119 style={{ objectFit: "cover", position: "absolute", top: 0, left: 0 }} 120 /> 121 </div> 122 ); 123 } 124 125 return ( 126 <Image 127 src={currentSrc} 128 alt={alt} 129 fill 130 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw" 131 style={{ objectFit: "cover" }} 132 priority={priority} 133 onError={handleError} 134 unoptimized={unoptimized || currentSrc.startsWith('data:') || currentSrc.startsWith('blob:')} 135 /> 136 ); 137}; 138 139export default ArticleImage;