One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #219 from EvanTechDev/feature/create-opengraph-image-layout-in-share/id

Add Open Graph image generator and adjust sidebar width/layout

authored by

Evan Huang and committed by
GitHub
8839068c 52fb4dca

+145 -6
+115
app/(app)/share/[id]/opengraph-image.tsx
··· 1 + import { ImageResponse } from "next/og" 2 + import { APP_TITLE } from "@/app/layout" 3 + 4 + export const size = { 5 + width: 1200, 6 + height: 630, 7 + } 8 + 9 + export const contentType = "image/png" 10 + 11 + const instrumentSansPromises = new Map<number, Promise<ArrayBuffer>>() 12 + 13 + async function loadInstrumentSans(weight: 400 | 500) { 14 + const cached = instrumentSansPromises.get(weight) 15 + if (cached) { 16 + return cached 17 + } 18 + 19 + const promise = (async () => { 20 + const cssResponse = await fetch( 21 + `https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@${weight}&display=swap`, 22 + ) 23 + const css = await cssResponse.text() 24 + const fontUrlMatch = css.match(/src: url\(([^)]+)\) format\('(opentype|truetype|woff2)'\)/) 25 + 26 + if (!fontUrlMatch?.[1]) { 27 + throw new Error(`Unable to resolve Instrument Sans ${weight} font URL`) 28 + } 29 + 30 + const fontResponse = await fetch(fontUrlMatch[1]) 31 + return fontResponse.arrayBuffer() 32 + })() 33 + 34 + instrumentSansPromises.set(weight, promise) 35 + return promise 36 + } 37 + 38 + export default async function OpenGraphImage() { 39 + const [instrumentSans400, instrumentSans500] = await Promise.all([ 40 + loadInstrumentSans(400), 41 + loadInstrumentSans(500), 42 + ]) 43 + 44 + return new ImageResponse( 45 + ( 46 + <div 47 + style={{ 48 + width: "100%", 49 + height: "100%", 50 + background: "#ffffff", 51 + display: "flex", 52 + flexDirection: "column", 53 + alignItems: "flex-start", 54 + justifyContent: "space-between", 55 + padding: "72px", 56 + color: "#000000", 57 + }} 58 + > 59 + <svg 60 + width="57" 61 + height="100" 62 + viewBox="324.5 178.12 367.99 643.88" 63 + xmlns="http://www.w3.org/2000/svg" 64 + > 65 + <g transform="translate(0,1000) scale(0.1,-0.1)" fill="#000000" stroke="none"> 66 + <path d="M4960 8206 c-87 -24 -164 -70 -231 -136 -101 -101 -149 -217 -149 -360 0 -144 48 -259 150 -360 102 -102 218 -150 360 -150 140 0 264 53 365 156 194 198 194 508 0 709 -67 69 -165 125 -253 144 -68 14 -184 13 -242 -3z" /> 67 + <path d="M3616 6859 c-109 -26 -239 -117 -307 -215 -97 -141 -111 -350 -34 -510 61 -126 166 -217 305 -264 55 -19 82 -21 175 -18 102 3 115 6 185 39 147 70 239 172 281 311 17 57 21 88 18 182 -4 109 -5 115 -46 198 -68 136 -202 245 -343 277 -54 13 -180 12 -234 0z" /> 68 + <path d="M4963 6855 c-228 -64 -383 -263 -383 -493 0 -149 45 -259 149 -363 105 -105 212 -149 362 -149 188 0 345 90 443 254 70 117 85 297 35 434 -48 130 -170 250 -306 302 -75 29 -225 37 -300 15z" /> 69 + <path d="M4940 5491 c-91 -29 -142 -61 -211 -130 -103 -103 -149 -214 -149 -361 0 -328 308 -570 629 -495 279 66 450 358 373 636 -46 164 -177 299 -340 349 -83 26 -224 26 -302 1z" /> 70 + <path d="M4980 4149 c-81 -16 -188 -76 -255 -145 -97 -100 -145 -215 -145 -354 0 -147 46 -258 149 -361 105 -105 212 -149 362 -149 455 0 680 547 358 869 -121 122 -296 174 -469 140z" /> 71 + <path d="M3601 2784 c-116 -31 -242 -125 -306 -229 -65 -105 -87 -283 -50 -410 61 -215 263 -365 490 -365 134 0 244 43 343 135 118 109 162 211 162 376 0 160 -46 267 -159 371 -103 96 -213 139 -351 137 -41 0 -99 -7 -129 -15z" /> 72 + <path d="M4959 2785 c-85 -23 -162 -69 -229 -135 -102 -101 -150 -216 -150 -360 0 -147 57 -278 162 -374 205 -187 515 -181 709 13 157 157 193 397 91 600 -56 112 -196 223 -326 257 -63 17 -195 17 -257 -1z" /> 73 + <path d="M6311 2784 c-76 -20 -146 -60 -212 -122 -113 -104 -159 -211 -159 -371 0 -189 74 -329 228 -431 103 -68 259 -97 385 -71 130 28 271 129 336 245 86 151 86 361 1 512 -38 65 -141 164 -208 198 -109 55 -256 71 -371 40z" /> 74 + </g> 75 + </svg> 76 + <div 77 + style={{ 78 + display: "flex", 79 + flexDirection: "column", 80 + alignItems: "flex-start", 81 + marginBottom: "20px", 82 + }} 83 + > 84 + <div 85 + style={{ 86 + fontSize: 72, 87 + fontWeight: 500, 88 + fontFamily: "Instrument Sans", 89 + lineHeight: 1.1, 90 + }} 91 + > 92 + {APP_TITLE} 93 + </div> 94 + </div> 95 + </div> 96 + ), 97 + { 98 + ...size, 99 + fonts: [ 100 + { 101 + name: "Instrument Sans", 102 + data: instrumentSans400, 103 + style: "normal", 104 + weight: 400, 105 + }, 106 + { 107 + name: "Instrument Sans", 108 + data: instrumentSans500, 109 + style: "normal", 110 + weight: 500, 111 + }, 112 + ], 113 + }, 114 + ) 115 + }
+2 -2
components/app/event/event-preview.tsx
··· 219 219 imageOptions: { 220 220 hideBackgroundDots: true, 221 221 imageSize: 0.4, 222 - margin: 2, 222 + margin: 10, 223 223 crossOrigin: "anonymous", 224 224 }, 225 225 }); ··· 348 348 imageOptions: { 349 349 hideBackgroundDots: true, 350 350 imageSize: 0.4, 351 - margin: 2, 351 + margin: 10, 352 352 }, 353 353 }); 354 354 const qrBlob = await qrCode.getRawData("png");
+1 -1
components/app/sidebar/sidebar.tsx
··· 206 206 style={{ "--sidebar-calendar-width": "17rem" } as CSSProperties} 207 207 className={cn( 208 208 "border-r bg-background overflow-y-auto transition-all duration-300 ease-in-out", 209 - isCollapsed ? "w-0 opacity-0 overflow-hidden" : "w-60 opacity-100", 209 + isCollapsed ? "w-0 opacity-0 overflow-hidden" : "w-[248px] opacity-100", 210 210 )} 211 211 onTransitionEnd={(event) => { 212 212 if (
+27 -3
components/auth/login-form.tsx
··· 19 19 className, 20 20 ...props 21 21 }: React.ComponentPropsWithoutRef<"div">) { 22 - const { signIn, setActive } = useSignIn(); 22 + const { isLoaded, signIn, setActive } = useSignIn(); 23 23 const [email, setEmail] = useState(""); 24 24 const [password, setPassword] = useState(""); 25 25 const [isLoading, setIsLoading] = useState(false); ··· 73 73 setError(""); 74 74 75 75 try { 76 + if (!isLoaded || !signIn) { 77 + setError("Auth service is still loading. Please try again in a moment."); 78 + return; 79 + } 80 + 76 81 const result = await signIn.create({ 77 82 identifier: email, 78 83 password, ··· 80 85 81 86 if (result.status === "complete") { 82 87 await setActive({ session: result.createdSessionId }); 83 - router.push("/app"); 88 + router.replace("/app"); 89 + router.refresh(); 90 + window.location.href = "/app"; 84 91 } 85 92 } catch (err: any) { 86 93 setError(err.errors?.[0]?.longMessage || "Login failed. Please try again."); ··· 101 108 setError("Please complete the CAPTCHA verification."); 102 109 return; 103 110 } 104 - signIn.authenticateWithRedirect({ 111 + if (!isLoaded || !signIn) { 112 + setError("Auth service is still loading. Please try again in a moment."); 113 + return; 114 + } 115 + 116 + const redirect = 117 + signIn.authenticateWithRedirect ?? 118 + (signIn as unknown as { authWithRedirect?: typeof signIn.authenticateWithRedirect }) 119 + .authWithRedirect ?? 120 + (signIn as unknown as { authenticatorWithRedirect?: typeof signIn.authenticateWithRedirect }) 121 + .authenticatorWithRedirect; 122 + 123 + if (!redirect) { 124 + setError("OAuth is unavailable right now. Please refresh and try again."); 125 + return; 126 + } 127 + 128 + redirect.call(signIn, { 105 129 strategy, 106 130 redirectUrl: "/sign-in/sso-callback", 107 131 redirectUrlComplete: "/app",