-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #18 from SIT-DigiCre/feature/link-card
リンクカードの実装
- Loading branch information
Showing
6 changed files
with
308 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import { NextResponse } from "next/server"; | ||
|
||
export async function GET(request: Request) { | ||
const { searchParams } = new URL(request.url); | ||
const url = searchParams.get("url"); | ||
|
||
if (!url) { | ||
return NextResponse.json( | ||
{ | ||
status: "error", | ||
message: "URLパラメータは必須です", | ||
}, | ||
{ status: 400 } | ||
); | ||
} | ||
|
||
try { | ||
new URL(url); | ||
} catch (e) { | ||
return NextResponse.json( | ||
{ | ||
status: "error", | ||
message: "URLが不正です", | ||
}, | ||
{ status: 400 } | ||
); | ||
} | ||
|
||
const controller = new AbortController(); | ||
const signal = controller.signal; | ||
const timeoutId = setTimeout(() => { | ||
controller.abort(); | ||
}, 5000); | ||
|
||
try { | ||
// ref: https://github.com/traPtitech/traQ/blob/a8035bd3fac0fa4ae531d3b11c1ccbddc76822d2/service/ogp/parser/domain.go#L15 | ||
// X(Twitter)のOGPを取得するのにuserAgentの中にbotという文字列が入っている必要がある | ||
// Spotifyの新しいOGPを取得するのにuserAgentの中にcurl-botという文字列が入っている必要がある | ||
const userAgent = | ||
"digichat-ogp-fetcher-curl-bot; contact: github.com/SIT-DigiCre/digichat"; | ||
const response = await fetch(url, { | ||
headers: { | ||
"User-Agent": userAgent, | ||
}, | ||
signal, | ||
}); | ||
const html = await response.text(); | ||
|
||
return NextResponse.json({ | ||
status: "success", | ||
html, | ||
}); | ||
} catch (error) { | ||
console.log(error); | ||
if (signal.aborted) { | ||
return NextResponse.json( | ||
{ | ||
status: "error", | ||
message: "リクエストがタイムアウトしました", | ||
}, | ||
{ status: 504 } | ||
); | ||
} else { | ||
return NextResponse.json( | ||
{ | ||
status: "error", | ||
message: "ページ情報を取得できませんでした", | ||
}, | ||
{ status: 500 } | ||
); | ||
} | ||
} finally { | ||
clearTimeout(timeoutId); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import LinkCard from "#/components/LinkCard"; | ||
import { Stack, Title } from "@mantine/core"; | ||
|
||
const LabPage = () => { | ||
return ( | ||
<div> | ||
<Title order={2}>コンポーネントのプレビュー用</Title> | ||
<Stack gap="md"> | ||
<LinkCard href="https://github.com/SIT-DigiCre/digichat" /> | ||
<LinkCard href="https://x.com/sitdigicre" /> | ||
<LinkCard href="https://x.com/sitdigicre/status/1874380689183052086" /> | ||
<LinkCard href="a" /> | ||
</Stack> | ||
</div> | ||
); | ||
}; | ||
|
||
export default LabPage; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
.card { | ||
display: flex; | ||
flex-direction: row; | ||
justify-content: flex-start; | ||
width: 100%; | ||
height: 150px; | ||
overflow: hidden; | ||
border: 1px solid #ccc; | ||
border-radius: 5px; | ||
|
||
@media (width < 768px) { | ||
height: 90px; | ||
} | ||
|
||
&[data-no-image="true"] { | ||
.card-image-section { | ||
display: none; | ||
} | ||
|
||
.card-content-section { | ||
width: 100%; | ||
} | ||
} | ||
} | ||
|
||
.card-image-section { | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
width: 200px; | ||
height: 150px; | ||
|
||
@media (width < 768px) { | ||
width: 120px; | ||
height: 90px; | ||
} | ||
} | ||
|
||
.card-image { | ||
width: 100%; | ||
height: 100%; | ||
object-fit: cover; | ||
background-color: #f0f0f0; | ||
} | ||
|
||
.card-content-section { | ||
display: flex; | ||
flex-direction: column; | ||
gap: 0.5rem; | ||
width: calc(100% - 200px); | ||
padding: 1rem; | ||
|
||
@media (width < 768px) { | ||
gap: 0.25rem; | ||
width: calc(100% - 120px); | ||
padding: 0.5rem; | ||
} | ||
} | ||
|
||
.card-title { | ||
overflow: hidden; | ||
font-size: 1rem; | ||
font-weight: 600; | ||
text-wrap: nowrap; | ||
} | ||
|
||
.card-description { | ||
display: -webkit-box; | ||
overflow: hidden; | ||
font-size: 0.75rem; | ||
color: var(--mantine-color-dimmed); | ||
-webkit-line-clamp: 4; | ||
-webkit-box-orient: vertical; | ||
|
||
@media (width < 768px) { | ||
-webkit-line-clamp: 2; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
"use client"; | ||
|
||
import { Card, Image, Skeleton, Text } from "@mantine/core"; | ||
import { useEffect, useState } from "react"; | ||
|
||
import { fetchOGPData } from "#/libs/fetch-ogp"; | ||
import styles from "./LinkCard.module.css"; | ||
|
||
type OGPData = { | ||
title: string; | ||
description: string; | ||
image: string | null; | ||
url: string; | ||
}; | ||
|
||
const LinkCard = ({ href }: { href: string }) => { | ||
const [OGPData, setOGPData] = useState<OGPData | null>(null); | ||
|
||
useEffect(() => { | ||
fetchOGPData(href).then((data) => { | ||
setOGPData(data); | ||
}); | ||
}, [href]); | ||
|
||
return ( | ||
<Card | ||
shadow="sm" | ||
padding={0} | ||
className={styles.card} | ||
data-noimage={OGPData?.image ? "false" : "true"} | ||
component="a" | ||
href={OGPData?.url} | ||
target="_blank" | ||
rel="noopener noreferrer" | ||
> | ||
<Card.Section className={styles["card-content-section"]}> | ||
{OGPData ? ( | ||
<Text className={styles["card-title"]}>{OGPData.title}</Text> | ||
) : ( | ||
<Skeleton height={30} /> | ||
)} | ||
{OGPData ? ( | ||
<Text className={styles["card-description"]}> | ||
{OGPData.description} | ||
</Text> | ||
) : ( | ||
<Skeleton height={64} mt="sm" /> | ||
)} | ||
</Card.Section> | ||
<Card.Section className={styles["card-image-section"]}> | ||
{OGPData ? ( | ||
OGPData.image && ( | ||
<Image | ||
src={OGPData.image} | ||
alt={OGPData.title} | ||
className={styles["card-image"]} | ||
fit="cover" | ||
/> | ||
) | ||
) : ( | ||
<Skeleton height={200} radius={0} /> | ||
)} | ||
</Card.Section> | ||
</Card> | ||
); | ||
}; | ||
|
||
export default LinkCard; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
type APIResponse = | ||
| { status: "success"; html: string } | ||
| { status: "error"; message: string }; | ||
|
||
// DOMParserはクライアント側でしか利用できないため、HTML取得後の処理はクライアント側で行う | ||
export const fetchOGPData = async (href: string) => { | ||
try { | ||
const response = await fetch( | ||
`/api/fetch-ogp?url=${encodeURIComponent(href)}` | ||
); | ||
const data: APIResponse = await response.json(); | ||
|
||
if (data.status === "success") { | ||
const parser = new DOMParser(); | ||
const doc = parser.parseFromString(data.html, "text/html"); | ||
|
||
// タイトル: og:title > title > URL | ||
const ogTitleElement = doc.querySelector( | ||
'meta[property="og:title"]' | ||
) as HTMLMetaElement | null; | ||
const title = ogTitleElement?.content || doc.title || href; | ||
|
||
// 説明文: og:description > description > body.textContent | ||
const ogDescriptionElement = doc.querySelector( | ||
'meta[property="og:description"]' | ||
) as HTMLMetaElement | null; | ||
const descriptionElement = doc.querySelector( | ||
'meta[name="description"]' | ||
) as HTMLMetaElement | null; | ||
const textContent = doc.body.textContent; | ||
const description = | ||
ogDescriptionElement?.content || | ||
descriptionElement?.content || | ||
textContent?.slice(0, 100) || | ||
"No Content"; | ||
|
||
const ogImageElement = doc.querySelector( | ||
'meta[property="og:image"]' | ||
) as HTMLMetaElement | null; | ||
const image = ogImageElement?.content || null; | ||
|
||
return { | ||
title, | ||
description, | ||
image, | ||
url: href, | ||
}; | ||
} else { | ||
// サーバーサイドで取得に際し何らかのエラーが発生した場合、またはタイムアウトした場合 | ||
return { | ||
title: href, | ||
description: data.message, | ||
image: null, | ||
url: href, | ||
}; | ||
} | ||
} catch (error) { | ||
// サーバーサイドへのリクエストに失敗した場合 | ||
console.error(error); | ||
return { | ||
title: href, | ||
description: | ||
"OGPの取得に失敗しました。コンソールログを確認してください。", | ||
image: null, | ||
url: href, | ||
}; | ||
} | ||
}; |