Create your Link in Bio for Bluesky

OGP画像の生成改善 (#17)

authored by mkizka.dev and committed by

GitHub 2d3f6a65 4f54d912

+77 -39
+1 -1
Dockerfile
··· 22 22 ENV NODE_ENV="production" 23 23 COPY --from=build /app/node_modules /app/node_modules 24 24 COPY --from=build /app/build /app/build 25 - COPY --from=build /app/public /app/public 25 + COPY --from=build /app/font /app/font 26 26 COPY --from=build /app/dist /app/dist 27 27 COPY --from=build /app/prisma /app/prisma 28 28 COPY --from=build /app/package.json /app/
+21 -7
app/features/board/card/profile-card.tsx
··· 2 2 import { UserIcon } from "@heroicons/react/24/solid"; 3 3 import type { User } from "@prisma/client"; 4 4 import { Link } from "@remix-run/react"; 5 + import { useState } from "react"; 5 6 import { useTranslation } from "react-i18next"; 6 7 8 + import { Button } from "~/components/button"; 7 9 import { Card } from "~/components/card"; 8 10 9 11 import { BlueskyIcon } from "./icons/bluesky"; ··· 35 37 }; 36 38 37 39 export function ProfileCard({ user, url, showEditButton }: ProfileCardProps) { 40 + const [loading, setLoading] = useState(false); 38 41 const { t } = useTranslation(); 39 42 const shareText = t("profile-card.share-text", { 40 43 url, 41 44 displayName: user.displayName, 42 45 }); 46 + 47 + const handlePost = async () => { 48 + setLoading(true); 49 + await fetch(`${url}/og.png`); 50 + open( 51 + `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`, 52 + "_blank", 53 + "noreferrer", 54 + ); 55 + setLoading(false); 56 + 57 + void umami.track("click-share-link"); 58 + }; 59 + 43 60 return ( 44 61 <Card> 45 62 <div className="card-body gap-2"> ··· 72 89 Bluesky 73 90 </a> 74 91 )} 75 - <a 92 + <Button 76 93 className="btn btn-square btn-neutral" 77 - href={`https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`} 78 - target="_blank" 79 - rel="noreferrer" 80 - data-umami-event="click-share-link" 81 - data-umami-event-handle={user.handle} 94 + loading={loading} 95 + onClick={handlePost} 82 96 > 83 97 <ShareIcon className="size-6" /> 84 - </a> 98 + </Button> 85 99 </div> 86 100 </div> 87 101 <div>
+14 -6
app/features/board/share-modal.tsx
··· 22 22 const { t } = useTranslation(); 23 23 const [searchParams, setSearchParams] = useSearchParams(); 24 24 const [copied, setCopied] = useState(false); 25 + const [loading, setLoading] = useState(false); 25 26 const shareText = t("share-modal.share-text", { url }); 26 27 27 28 useEffect(() => { ··· 48 49 } 49 50 }; 50 51 51 - const handlePost = () => { 52 + const handlePost = async () => { 53 + setLoading(true); 54 + await fetch(`${url}/og.png`); 55 + open( 56 + `https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`, 57 + "_blank", 58 + "noreferrer", 59 + ); 60 + setLoading(false); 61 + 52 62 trackShareModal("post-to-bluesky"); 53 63 }; 54 64 ··· 71 81 <h3 className="text-lg font-bold">{t("share-modal.title")}</h3> 72 82 <p>{t("share-modal.description")}</p> 73 83 <div className="flex flex-col gap-4 py-4 sm:flex-row"> 74 - <a 84 + <Button 75 85 className="btn-bluesky btn flex-1 text-base-100" 76 - href={`https://bsky.app/intent/compose?text=${encodeURIComponent(shareText)}`} 77 - target="_blank" 78 - rel="noreferrer" 86 + loading={loading} 79 87 onClick={handlePost} 80 88 > 81 89 <BlueskyIcon className="size-6" /> 82 90 {t("share-modal.post-to-bluesky")} 83 - </a> 91 + </Button> 84 92 <Button className="flex-1" onClick={handleCopy}> 85 93 {copied ? ( 86 94 <ClipboardDocumentCheckIcon className="size-6" />
+41 -25
app/routes/$handle.og[.png].tsx
··· 1 1 import type { LoaderFunctionArgs } from "@remix-run/node"; 2 2 import { ImageResponse } from "@vercel/og"; 3 3 import fs from "fs"; 4 - import path from "path"; 5 - import { fileURLToPath } from "url"; 6 4 7 5 import { userService } from "~/server/service/userService"; 8 - import { env } from "~/utils/env"; 9 6 import { required } from "~/utils/required"; 10 7 11 - // https://github.com/orgs/vercel/discussions/1567#discussioncomment-5854851 12 - const fontData = fs.readFileSync( 13 - path.join(fileURLToPath(import.meta.url), "../../../public/Murecho-Bold.ttf"), 14 - ); 8 + const fontData = fs.readFileSync("./fonts/Murecho-Bold.ttf"); 15 9 16 10 export async function loader({ params }: LoaderFunctionArgs) { 17 11 const user = await userService.findOrFetchUser({ ··· 21 15 // eslint-disable-next-line @typescript-eslint/only-throw-error 22 16 throw new Response(null, { status: 404 }); 23 17 } 18 + // 19 + // カード内の割合 20 + // 100px(padding) + 200px(avatar) + 50px(mariginLeft) + 650px(handle/displayName) + 100px(padding) = 1100px 21 + // 24 22 return new ImageResponse( 25 23 ( 26 24 <div 27 25 style={{ 28 - height: "100%", 29 - width: "100%", 26 + width: "1200px", 27 + height: "630px", 30 28 display: "flex", 31 29 flexDirection: "column", 32 30 alignItems: "center", ··· 38 36 <div 39 37 style={{ 40 38 display: "flex", 41 - padding: "4rem", 42 - width: "90%", 43 - height: "90%", 44 - backgroundColor: "#f2f2f2", 39 + padding: "100px", 40 + width: "1100px", 41 + height: "500px", 42 + backgroundColor: "#ffffff", 45 43 borderRadius: "1rem", 46 44 // https://tailwindcss.com/docs/box-shadow 47 45 boxShadow: ··· 55 53 }} 56 54 > 57 55 {user.avatar ? ( 58 - <img width={300} height={300} src={user.avatar} /> 56 + <img 57 + src={user.avatar} 58 + style={{ 59 + width: "200px", 60 + height: "200px", 61 + }} 62 + /> 59 63 ) : ( 60 64 <div 61 65 style={{ 62 - width: "250px", 63 - height: "250px", 66 + width: "200px", 67 + height: "200px", 64 68 backgroundColor: "#aaa", 65 69 }} 66 70 /> ··· 69 73 style={{ 70 74 display: "flex", 71 75 flexDirection: "column", 72 - marginLeft: "4rem", 76 + marginLeft: "50px", 77 + width: "650px", 73 78 }} 74 79 > 75 80 <p 76 81 style={{ 77 - fontSize: "5rem", 82 + fontSize: "4rem", 78 83 fontWeight: "bold", 84 + textOverflow: "ellipsis", 85 + whiteSpace: "nowrap", 86 + overflow: "hidden", 79 87 }} 80 88 > 81 89 {user.displayName} ··· 83 91 <p 84 92 style={{ 85 93 fontSize: "3rem", 94 + textOverflow: "ellipsis", 95 + whiteSpace: "nowrap", 96 + overflow: "hidden", 86 97 color: "#6b7280", 87 98 marginTop: "-1rem", 88 99 }} ··· 91 102 </p> 92 103 </div> 93 104 </div> 94 - <img 95 - width={150} 96 - height={150} 97 - src={env.PUBLIC_URL + "/icon.png"} 105 + <div 98 106 style={{ 107 + fontSize: "3rem", 108 + fontWeight: "bold", 99 109 position: "absolute", 100 - right: "3rem", 101 - top: "3rem", 110 + right: "4rem", 111 + bottom: "2rem", 102 112 }} 103 - /> 113 + > 114 + Linkat 115 + </div> 104 116 </div> 105 117 </div> 106 118 ), 107 119 { 108 120 width: 1200, 109 121 height: 630, 122 + headers: { 123 + // Cloudflareに10分間キャッシュさせる 124 + "cache-control": "public, s-maxage=600, max-age=0", 125 + }, 110 126 fonts: [ 111 127 { 112 128 name: "Murecho",
public/Murecho-Bold.ttf fonts/Murecho-Bold.ttf
public/icon.png

This is a binary file and will not be displayed.