ATProto Social Bookmark
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;