Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 27 additions & 16 deletions client/functions/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ interface ProjectData {
}>;
}

// og:title を書き換える HTMLRewriter
class OGTitleRewriter {
private title: string;

Expand All @@ -32,7 +31,6 @@ class OGTitleRewriter {
}
}

// og:description を書き換える HTMLRewriter
class OGDescriptionRewriter {
private description: string;

Expand All @@ -47,7 +45,20 @@ class OGDescriptionRewriter {
}
}

// 日付を YYYY/MM/DD 形式にフォーマット(日本時間)
class OGImageRewriter {
private imageUrl: string;

constructor(imageUrl: string) {
this.imageUrl = imageUrl;
}

element(element: Element) {
if (element.getAttribute("property") === "og:image") {
element.setAttribute("content", this.imageUrl);
}
}
}

function formatDate(dateString: string): string {
const date = new Date(dateString);
return new Intl.DateTimeFormat("ja-JP", {
Expand All @@ -74,14 +85,12 @@ async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<P
}
}

// eventId が有効かチェック(21文字のnanoid)
function isValidEventId(path: string): boolean {
function isValidEventPath(path: string): boolean {
// 新パス /e/eventId または 旧パス /eventId の形式で、eventId が21文字の英数字・ハイフン・アンダースコア
const match = path.match(/^\/(?:e\/)?([A-Za-z0-9_-]{21})$/);
return !!match;
}

// パスから eventId を抽出
function extractEventId(path: string): string | null {
// 新パス /e/eventId または 旧パス /eventId から eventId を抽出
const match = path.match(/^\/(?:e\/)?([A-Za-z0-9_-]{21})$/);
Expand All @@ -104,8 +113,7 @@ export async function onRequest(context: EventContext<Env, any, any>): Promise<R
return await next();
}

// eventId パターンをチェック
if (!isValidEventId(path)) {
if (!isValidEventPath(path)) {
return await next();
}

Expand All @@ -117,33 +125,36 @@ export async function onRequest(context: EventContext<Env, any, any>): Promise<R
// 元のHTMLレスポンスを取得
const response = await next();

// HTMLでない場合はそのまま返す
const contentType = response.headers.get("content-type");

// 304 Not Modified の場合やHTMLでない場合はスキップ
// 304 Not Modified や HTML でない場合はスキップ
if (response.status === 304 || (!contentType?.includes("text/html") && contentType !== null)) {
return response;
}

// プロジェクト情報を取得
const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);

const defaultOgImageUrl = `${url.origin}/og-image.jpg`;

if (!projectData) {
return response;
// プロジェクトが見つからない場合はデフォルト画像を設定
return new HTMLRewriter()
.on('meta[property="og:image"]', new OGImageRewriter(defaultOgImageUrl))
.transform(response);
}

// og:title を書き換え
const ogTitle = `${projectData.name} - イツヒマ`;

// og:description を作成
const startDate = formatDate(projectData.startDate);
const endDate = formatDate(projectData.endDate);
const dateRange = startDate === endDate ? startDate : `${startDate} - ${endDate}`;

const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`;
const ogDescription = `日程範囲: ${dateRange}`;

const ogImageUrl = `${url.origin}/og/${eventId}`;

return new HTMLRewriter()
.on('meta[property="og:title"]', new OGTitleRewriter(ogTitle))
.on('meta[property="og:description"]', new OGDescriptionRewriter(ogDescription))
.on('meta[property="og:image"]', new OGImageRewriter(ogImageUrl))
.transform(response);
}
189 changes: 189 additions & 0 deletions client/functions/og/[eventId].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/// <reference types="@cloudflare/workers-types" />
import { ImageResponse } from "@cloudflare/pages-plugin-vercel-og/api";
// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX transform
import React from "react";

interface Env {
API_ENDPOINT: string;
}

interface ProjectData {
id: string;
name: string;
startDate: string;
endDate: string;
}

async function fetchProjectData(eventId: string, apiEndpoint: string): Promise<ProjectData | null> {
try {
const response = await fetch(`${apiEndpoint}/projects/${eventId}`);
if (!response.ok) {
return null;
}
return await response.json();
} catch (error) {
console.error("Failed to fetch project data:", error);
return null;
}
}

function formatDate(dateString: string): string {
const date = new Date(dateString);
return new Intl.DateTimeFormat("ja-JP", {
year: "numeric",
month: "2-digit",
day: "2-digit",
timeZone: "Asia/Tokyo",
})
.format(date)
.replace(/-/g, "/");
}

function truncateByWidth(text: string, maxWidth = 60): string {
let width = 0;
let result = "";

for (const char of text) {
// ASCII なら 1、それ以外は 2
const charWidth = char.charCodeAt(0) < 128 ? 1 : 2;

if (width + charWidth > maxWidth) {
break;
}

width += charWidth;
result += char;
}

if (result.length < text.length) {
result += "…";
}

return result;
}

export const onRequestGet: PagesFunction<Env, "eventId"> = async (context) => {
const { env, params } = context;
const eventId = params.eventId;

if (typeof eventId !== "string") {
console.error("eventId is not a string");
return new Response("Invalid event ID", { status: 400 });
}

if (!eventId || !/^[A-Za-z0-9_-]{21}$/.test(eventId)) {
return new Response("Invalid event ID", { status: 400 });
}

const projectData = await fetchProjectData(eventId, env.API_ENDPOINT);

if (!projectData) {
// プロジェクトがない場合はデフォルト画像にリダイレクト
const url = new URL(context.request.url);
return Response.redirect(`${url.origin}/og-image.jpg`, 302);
}

const fontData = await fetch(
"https://unpkg.com/@fontsource/[email protected]/files/m-plus-1p-japanese-700-normal.woff",
).then((res) => res.arrayBuffer());

const projectName = truncateByWidth(projectData.name, 72);

const startDate = formatDate(projectData.startDate);
const endDate = formatDate(projectData.endDate);
const dateRange = startDate === endDate ? startDate : `${startDate} 〜 ${endDate}`;

const response = new ImageResponse(
<div
style={{
display: "flex",
backgroundColor: "#0f82b1",
padding: "48px 56px",
width: "100%",
height: "100%",
fontFamily: "M PLUS 1p",
fontWeight: 700,
}}
>
<div
style={{
display: "flex",
backgroundColor: "#fff",
color: "#555",
flexDirection: "column",
borderRadius: "32px",
flex: 1,
alignItems: "center",
padding: "40px",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "24px",
justifyContent: "center",
alignItems: "center",
}}
>
<span
style={{
fontSize: "64px",
textAlign: "center",
wordBreak: "break-word",
}}
>
{projectName}
</span>
<span
style={{
fontSize: "40px",
textAlign: "center",
}}
>
{dateRange}
</span>
</div>
<div
style={{
color: "#0f82b1",
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "40px",
}}
>
<svg
width="48"
height="48"
viewBox="0 0 50 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-hidden="true"
>
<rect x="34" width="16" height="33" rx="2" fill="#0F82B1" />
<rect y="17" width="33" height="16" rx="2" fill="#0F82B1" />
<rect x="17" y="34" width="16" height="16" rx="2" fill="#0F82B1" />
</svg>
<div>イツヒマ</div>
</div>
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "M PLUS 1p",
data: fontData,
weight: 700,
style: "normal",
},
],
},
);

return response;
};
5 changes: 1 addition & 4 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,12 @@
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@cloudflare/pages-plugin-vercel-og": "^0.1.2",
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/react": "^6.1.15",
"@fullcalendar/timegrid": "^6.1.15",
"@hookform/resolvers": "^4.1.3",
"@mui/material": "^6.4.7",
"@tailwindcss/vite": "^4.0.13",
"dayjs": "^1.11.13",
"react": "^19.0.0",
Expand All @@ -37,7 +35,6 @@
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.3",
"globals": "^15.15.0",
"hono": "^4.9.6",
"typescript": "~5.7.2",
"vite": "^6.3.5"
Expand Down
5 changes: 3 additions & 2 deletions client/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ export default function HomePage() {
let errorMessage = "イベントの取得に失敗しました。";
try {
const data = await res.json();
if (data && typeof data.message === "string" && data.message.trim()) {
errorMessage = data.message.trim();
const err = data as unknown as { message: string }; // Middleware のレスポンスは Hono RPC の型に乗らない
if (typeof err.message === "string" && err.message.trim()) {
errorMessage = err.message.trim();
}
} catch (_) {
// レスポンスがJSONでない場合は無視
Expand Down
5 changes: 3 additions & 2 deletions client/src/pages/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export default function ProjectPage() {
projectName: string;
} | null>(null);

// TODO: グローバルにしないと、delete の際は遷移を伴うので表示されない
const [toast, setToast] = useState<{
message: string;
variant: "success" | "error";
Expand Down Expand Up @@ -215,9 +216,8 @@ export default function ProjectPage() {
projectName: name,
});
} else {
const { message } = await res.json();
setToast({
message,
message: "イベントの作成に失敗しました。",
variant: "error",
});
setTimeout(() => setToast(null), 3000);
Expand Down Expand Up @@ -604,6 +604,7 @@ export default function ProjectPage() {
if (!res.ok) {
throw new Error("削除に失敗しました。");
}
// TODO: トーストをグローバルにする
navigate("/home");
setToast({
message: "イベントを削除しました。",
Expand Down
Loading