| 1 | import { useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | import { Icon } from "@iconify/react"; |
| 3 | |
| 4 | const tok = |
| 5 | (color: string) => |
| 6 | ({ children }: { children: ReactNode }) => ( |
| 7 | <span style={{ color }}>{children}</span> |
| 8 | ); |
| 9 | const K = tok("var(--alc-code-keyword)"); |
| 10 | const S = tok("var(--alc-code-string)"); |
| 11 | const F = tok("var(--alc-code-fn)"); |
| 12 | const T = tok("var(--alc-code-type)"); |
| 13 | const V = tok("var(--alc-code-var)"); |
| 14 | const C = ({ children }: { children: ReactNode }) => ( |
| 15 | <span style={{ color: "var(--alc-code-comment)", fontStyle: "italic" }}> |
| 16 | {children} |
| 17 | </span> |
| 18 | ); |
| 19 | |
| 20 | interface BindRow { |
| 21 | id: string; |
| 22 | call: ReactNode; |
| 23 | resource: { |
| 24 | label: string; |
| 25 | sub: string; |
| 26 | kind: "s3" | "ddb" | "ddb-stream" | "sqs"; |
| 27 | }; |
| 28 | arrow: { kind: "iam" | "stream"; label: string }; |
| 29 | } |
| 30 | |
| 31 | const ROWS: BindRow[] = [ |
| 32 | { |
| 33 | id: "get", |
| 34 | call: ( |
| 35 | <> |
| 36 | <K>const</K> getPhoto = <K>yield</K>* <V>S3</V>.<V>GetObject</V>. |
| 37 | <F>bind</F>(<T>Photos</T>); |
| 38 | </> |
| 39 | ), |
| 40 | resource: { label: "Photos", sub: "S3.Bucket", kind: "s3" }, |
| 41 | arrow: { kind: "iam", label: "Allow s3:GetObject" }, |
| 42 | }, |
| 43 | { |
| 44 | id: "put", |
| 45 | call: ( |
| 46 | <> |
| 47 | <K>const</K> putJob = <K>yield</K>* <V>DynamoDB</V>.<V>PutItem</V>. |
| 48 | <F>bind</F>(<T>Jobs</T>); |
| 49 | </> |
| 50 | ), |
| 51 | resource: { label: "Jobs", sub: "DynamoDB.Table", kind: "ddb" }, |
| 52 | arrow: { kind: "iam", label: "Allow dynamodb:PutItem" }, |
| 53 | }, |
| 54 | { |
| 55 | id: "stream", |
| 56 | call: ( |
| 57 | <> |
| 58 | <K>yield</K>* <V>DynamoDB</V>.<F>stream</F>(<T>Jobs</T>).<F>process</F> |
| 59 | (handler); |
| 60 | </> |
| 61 | ), |
| 62 | resource: { |
| 63 | label: "Jobs.stream", |
| 64 | sub: "EventSourceMapping", |
| 65 | kind: "ddb-stream", |
| 66 | }, |
| 67 | arrow: { kind: "stream", label: "EventSource" }, |
| 68 | }, |
| 69 | ]; |
| 70 | |
| 71 | function ResourceIcon({ kind }: { kind: BindRow["resource"]["kind"] }) { |
| 72 | const icon = |
| 73 | kind === "s3" |
| 74 | ? "logos:aws-s3" |
| 75 | : kind === "ddb" |
| 76 | ? "logos:aws-dynamodb" |
| 77 | : kind === "ddb-stream" |
| 78 | ? "logos:aws-lambda" |
| 79 | : "logos:aws-sqs"; |
| 80 | return ( |
| 81 | <div |
| 82 | style={{ |
| 83 | width: 28, |
| 84 | height: 28, |
| 85 | borderRadius: 6, |
| 86 | overflow: "hidden", |
| 87 | display: "inline-flex", |
| 88 | alignItems: "center", |
| 89 | justifyContent: "center", |
| 90 | flexShrink: 0, |
| 91 | }} |
| 92 | > |
| 93 | <Icon icon={icon} width={28} height={28} aria-hidden /> |
| 94 | </div> |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | export default function BindingsToIAM() { |
| 99 | const wrapRef = useRef<HTMLDivElement>(null); |
| 100 | const [active, setActive] = useState(-1); |
| 101 | |
| 102 | useEffect(() => { |
| 103 | const reduced = window.matchMedia( |
| 104 | "(prefers-reduced-motion: reduce)", |
| 105 | ).matches; |
| 106 | let timer: ReturnType<typeof setTimeout> | null = null; |
| 107 | let cancelled = false; |
| 108 | |
| 109 | const cycle = (i: number) => { |
| 110 | if (cancelled) return; |
| 111 | setActive(i); |
| 112 | if (reduced) { |
| 113 | setActive(ROWS.length - 1); |
| 114 | return; |
| 115 | } |
| 116 | const next = (i + 1) % (ROWS.length + 1); // +1 = "all visible" pause frame |
| 117 | const dwell = i === ROWS.length - 1 ? 2200 : 1300; |
| 118 | timer = setTimeout(() => cycle(next === ROWS.length ? -1 : next), dwell); |
| 119 | }; |
| 120 | |
| 121 | const obs = new IntersectionObserver( |
| 122 | (entries) => { |
| 123 | for (const e of entries) { |
| 124 | if (e.isIntersecting) { |
| 125 | cycle(0); |
| 126 | obs.disconnect(); |
| 127 | } |
| 128 | } |
| 129 | }, |
| 130 | { threshold: 0.2 }, |
| 131 | ); |
| 132 | if (wrapRef.current) obs.observe(wrapRef.current); |
| 133 | return () => { |
| 134 | cancelled = true; |
| 135 | if (timer) clearTimeout(timer); |
| 136 | obs.disconnect(); |
| 137 | }; |
| 138 | }, []); |
| 139 | |
| 140 | return ( |
| 141 | <div ref={wrapRef} className="bindings-iam" data-nosnippet=""> |
| 142 | {/* LEFT — Lambda code */} |
| 143 | <div className="alc-code-block bindings-iam__code"> |
| 144 | <div className="alc-code-block__header"> |
| 145 | <span |
| 146 | className="alc-code-block__dot" |
| 147 | style={{ background: "var(--alc-danger)" }} |
| 148 | /> |
| 149 | <span |
| 150 | className="alc-code-block__dot" |
| 151 | style={{ background: "var(--alc-warn)" }} |
| 152 | /> |
| 153 | <span |
| 154 | className="alc-code-block__dot" |
| 155 | style={{ background: "var(--alc-accent-bright)" }} |
| 156 | /> |
| 157 | <span className="alc-code-block__filename">src/JobApi.ts</span> |
| 158 | </div> |
| 159 | <pre className="alc-code-block__pre"> |
| 160 | <K>export default</K> <V>AWS</V>.<V>Lambda</V>.<F>Function</F> |
| 161 | {"("} |
| 162 | {"\n "} |
| 163 | <S>"JobApi"</S>,{"\n "} |
| 164 | <V>Effect</V>.<F>gen</F>(<K>function</K>* () {"{"} |
| 165 | {"\n "} |
| 166 | {ROWS.map((r, i) => ( |
| 167 | <span key={r.id}> |
| 168 | <span |
| 169 | data-row={r.id} |
| 170 | className="bindings-iam__bind" |
| 171 | style={{ |
| 172 | padding: "1px 5px", |
| 173 | margin: "0 -5px", |
| 174 | borderRadius: 4, |
| 175 | background: |
| 176 | active === i ? "rgba(74, 110, 60, 0.22)" : "transparent", |
| 177 | boxShadow: |
| 178 | active === i ? "0 0 0 1px rgba(74, 110, 60, 0.55)" : "none", |
| 179 | transition: "background 280ms ease, box-shadow 280ms ease", |
| 180 | }} |
| 181 | > |
| 182 | {r.call} |
| 183 | </span> |
| 184 | {"\n "} |
| 185 | </span> |
| 186 | ))} |
| 187 | {"\n "} |
| 188 | <K>return</K> {"{"} |
| 189 | {"\n "} |
| 190 | <V>fetch</V>: (<V>req</V>) {"=>"} <V>Effect</V>.<F>gen</F>( |
| 191 | <K>function</K>* () {"{"} |
| 192 | {"\n "} |
| 193 | <K>const</K> photo = <K>yield</K>* <F>getPhoto</F>({"{ "} |
| 194 | <V>key</V>: <V>req</V>.<V>key</V> {"}"}); |
| 195 | {"\n "} |
| 196 | <K>yield</K>* <F>putJob</F>({"{ "} |
| 197 | <V>id</V>: <V>req</V>.<V>id</V>, <V>photo</V> {"}"}); |
| 198 | {"\n "} |
| 199 | <K>return</K> <K>new</K> <T>Response</T>(<S>"ok"</S>); |
| 200 | {"\n "} |
| 201 | {"}),"} |
| 202 | {"\n "} |
| 203 | {"};"} |
| 204 | {"\n "} |
| 205 | {"}),"} |
| 206 | {"\n"} |
| 207 | {");"} |
| 208 | </pre> |
| 209 | </div> |
| 210 | |
| 211 | {/* MIDDLE — Animated arrows + labels */} |
| 212 | <div className="bindings-iam__arrows" aria-hidden> |
| 213 | <svg viewBox="0 0 200 280" preserveAspectRatio="none"> |
| 214 | <defs> |
| 215 | <marker |
| 216 | id="bia-arrow-active" |
| 217 | viewBox="0 0 8 8" |
| 218 | refX="7" |
| 219 | refY="4" |
| 220 | markerWidth="6" |
| 221 | markerHeight="6" |
| 222 | orient="auto" |
| 223 | > |
| 224 | <path d="M0 0 L8 4 L0 8 Z" fill="var(--alc-accent-deep)" /> |
| 225 | </marker> |
| 226 | <marker |
| 227 | id="bia-arrow-idle" |
| 228 | viewBox="0 0 8 8" |
| 229 | refX="7" |
| 230 | refY="4" |
| 231 | markerWidth="6" |
| 232 | markerHeight="6" |
| 233 | orient="auto" |
| 234 | > |
| 235 | <path d="M0 0 L8 4 L0 8 Z" fill="var(--alc-fg-3)" /> |
| 236 | </marker> |
| 237 | </defs> |
| 238 | {ROWS.map((r, i) => { |
| 239 | const y = 50 + i * 90; |
| 240 | const isActive = active === i; |
| 241 | const stroke = isActive |
| 242 | ? "var(--alc-accent-deep)" |
| 243 | : "var(--alc-fg-3)"; |
| 244 | const marker = isActive |
| 245 | ? "url(#bia-arrow-active)" |
| 246 | : "url(#bia-arrow-idle)"; |
| 247 | return ( |
| 248 | <g |
| 249 | key={r.id} |
| 250 | opacity={isActive ? 1 : 0.55} |
| 251 | style={{ transition: "opacity 320ms ease" }} |
| 252 | > |
| 253 | <path |
| 254 | d={`M 0 ${y} L 190 ${y}`} |
| 255 | stroke={stroke} |
| 256 | strokeWidth={isActive ? 1.8 : 1.1} |
| 257 | fill="none" |
| 258 | markerEnd={marker} |
| 259 | strokeDasharray={r.arrow.kind === "stream" ? "5 4" : "0"} |
| 260 | style={{ |
| 261 | transition: "stroke 280ms ease, stroke-width 280ms ease", |
| 262 | }} |
| 263 | /> |
| 264 | <text |
| 265 | x="100" |
| 266 | y={y - 8} |
| 267 | textAnchor="middle" |
| 268 | fontFamily="var(--alc-font-mono)" |
| 269 | fontSize="10" |
| 270 | fill={isActive ? "var(--alc-accent-deep)" : "var(--alc-fg-3)"} |
| 271 | style={{ transition: "fill 280ms ease" }} |
| 272 | > |
| 273 | {r.arrow.label} |
| 274 | </text> |
| 275 | </g> |
| 276 | ); |
| 277 | })} |
| 278 | </svg> |
| 279 | </div> |
| 280 | |
| 281 | {/* RIGHT — Resources */} |
| 282 | <div className="bindings-iam__resources"> |
| 283 | {ROWS.map((r, i) => { |
| 284 | const isActive = active === i; |
| 285 | return ( |
| 286 | <div |
| 287 | key={r.id} |
| 288 | className="bindings-iam__resource" |
| 289 | style={{ |
| 290 | borderColor: isActive |
| 291 | ? "var(--alc-accent-deep)" |
| 292 | : "var(--alc-hairline)", |
| 293 | boxShadow: isActive |
| 294 | ? "0 0 0 2px rgba(74, 110, 60, 0.18)" |
| 295 | : "none", |
| 296 | transition: "border-color 280ms ease, box-shadow 280ms ease", |
| 297 | }} |
| 298 | > |
| 299 | <ResourceIcon kind={r.resource.kind} /> |
| 300 | <div style={{ flex: 1, minWidth: 0 }}> |
| 301 | <div className="bindings-iam__resource-label"> |
| 302 | {r.resource.label} |
| 303 | </div> |
| 304 | <div className="bindings-iam__resource-sub"> |
| 305 | {r.resource.sub} |
| 306 | </div> |
| 307 | <div |
| 308 | className="bindings-iam__resource-iam" |
| 309 | data-stream={r.arrow.kind === "stream" ? "true" : undefined} |
| 310 | > |
| 311 | {r.arrow.kind === "stream" ? "↻" : "→"} {r.arrow.label} |
| 312 | </div> |
| 313 | </div> |
| 314 | </div> |
| 315 | ); |
| 316 | })} |
| 317 | </div> |
| 318 | </div> |
| 319 | ); |
| 320 | } |