Skip to content

Commit

Permalink
Merge pull request #18 from SIT-DigiCre/feature/link-card
Browse files Browse the repository at this point in the history
リンクカードの実装
  • Loading branch information
newt239 authored Jan 4, 2025
2 parents c4bc6a6 + 392e9a2 commit 642fd0e
Show file tree
Hide file tree
Showing 6 changed files with 308 additions and 0 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ POSTGRES_PASSWORD=postgres
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
URL=http://localhost:3000
75 changes: 75 additions & 0 deletions src/app/api/fetch-ogp/route.ts
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);
}
}
18 changes: 18 additions & 0 deletions src/app/lab/page.tsx
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;
78 changes: 78 additions & 0 deletions src/components/LinkCard.module.css
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;
}
}
68 changes: 68 additions & 0 deletions src/components/LinkCard.tsx
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;
68 changes: 68 additions & 0 deletions src/libs/fetch-ogp.ts
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,
};
}
};

0 comments on commit 642fd0e

Please sign in to comment.