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

This is a binary file and will not be displayed.