diff --git a/src/app/conf/_design-system/utils/arrows-move-sideways.tsx b/src/app/conf/_design-system/utils/arrows-move-sideways.tsx new file mode 100644 index 0000000000..69531ea2c5 --- /dev/null +++ b/src/app/conf/_design-system/utils/arrows-move-sideways.tsx @@ -0,0 +1,36 @@ +export function arrowsMoveSideways(event: React.KeyboardEvent) { + if ( + event.key !== "ArrowLeft" && + event.key !== "ArrowRight" && + event.key !== "ArrowUp" && + event.key !== "ArrowDown" + ) { + return + } + + let repeat = 1 + + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const vertical = event.currentTarget.dataset.vertical + console.log({ vertical }) + repeat = vertical ? parseInt(vertical) : 1 + } + + let current = event.currentTarget + for (let i = 0; i < repeat; ++i) { + if (event.key === "ArrowLeft" || event.key === "ArrowUp") { + const previousElement = current.previousElementSibling + if (previousElement) { + current = previousElement as HTMLElement + } + } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { + const nextElement = current.nextElementSibling + if (nextElement) { + current = nextElement as HTMLElement + } + } + } + + event.preventDefault() + current.focus() +} diff --git a/src/components/blog-page/blog-card-picture.tsx b/src/components/blog-page/blog-card-picture.tsx new file mode 100644 index 0000000000..641875b759 --- /dev/null +++ b/src/components/blog-page/blog-card-picture.tsx @@ -0,0 +1,355 @@ +import { clsx } from "clsx" +import { type ReactNode, useEffect, useRef } from "react" + +const PIXEL_SIZE = 16 +const MAX_DPR = 2 +const UINT32_MAX = 0xffffffff + +interface BlogCardPictureProps { + seed: string + children?: ReactNode + className?: string +} + +type RgbColor = [number, number, number] + +interface GradientStop { + offset: number + color: RgbColor +} + +interface PreparedGradient { + cos: number + sin: number + minProjection: number + invProjectionRange: number + stops: GradientStop[] +} + +// TODO: Animate nicer on load +// TODO: Update seeding: The closer the post date the more different the gradient should be? +// TODO: Think: Should the category colors actually be connected to the gradient, so the tag doesn't ever look jarring? +export function BlogCardPicture({ + seed, + children, + className, +}: BlogCardPictureProps) { + const containerRef = useRef(null) + const canvasRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + const container = containerRef.current + if (!canvas || !container) { + return + } + + let frame = 0 + + const draw = () => { + const rect = container.getBoundingClientRect() + const width = Math.max(0, Math.round(rect.width)) + const height = Math.max(0, Math.round(rect.height)) + + if (width === 0 || height === 0) { + return + } + + const columns = Math.max(1, Math.ceil(width / PIXEL_SIZE)) + const rows = Math.max(1, Math.ceil(height / PIXEL_SIZE)) + const dpr = Math.min( + typeof window === "undefined" ? 1 : (window.devicePixelRatio ?? 1), + MAX_DPR, + ) + const canvasWidth = Math.round(width * dpr) + const canvasHeight = Math.round(height * dpr) + + if (canvas.width !== canvasWidth) { + canvas.width = canvasWidth + } + if (canvas.height !== canvasHeight) { + canvas.height = canvasHeight + } + + const displayWidth = `${width}px` + const displayHeight = `${height}px` + if (canvas.style.width !== displayWidth) { + canvas.style.width = displayWidth + } + if (canvas.style.height !== displayHeight) { + canvas.style.height = displayHeight + } + + const context = canvas.getContext("2d") + if (!context) { + return + } + + context.setTransform(1, 0, 0, 1, 0, 0) + context.scale(dpr, dpr) + context.clearRect(0, 0, width, height) + context.imageSmoothingEnabled = false + + const random = createSeededRandom(seed) + const angle = random() * Math.PI * 2 + const gradientStops = buildGradientStops(random) + const gradient = prepareGradient({ + angle, + stops: gradientStops, + columns, + rows, + }) + const jitterPrefix = `${seed}|` + + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const color = sampleGradientColor({ + gradient, + column, + row, + jitterPrefix, + }) + + const x = column * PIXEL_SIZE + const y = row * PIXEL_SIZE + const cellWidth = Math.min(PIXEL_SIZE, width - x) + const cellHeight = Math.min(PIXEL_SIZE, height - y) + + context.fillStyle = toCssColor(color) + context.fillRect(x, y, cellWidth, cellHeight) + } + } + } + + const drawWithAnimationFrame = () => { + cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(draw) + } + + drawWithAnimationFrame() + + const handleResize = () => { + drawWithAnimationFrame() + } + + window.addEventListener("resize", handleResize) + + return () => { + cancelAnimationFrame(frame) + window.removeEventListener("resize", handleResize) + } + }, [seed]) + + return ( +
+
+ ) +} + +function createSeededRandom(seed: string) { + let state = hashString(seed) || 1 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + state >>>= 0 + return state / UINT32_MAX + } +} + +function prepareGradient({ + angle, + stops, + columns, + rows, +}: { + angle: number + stops: GradientStop[] + columns: number + rows: number +}): PreparedGradient { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + + const corners: Array<[number, number]> = [ + [0, 0], + [columns, 0], + [0, rows], + [columns, rows], + ] + + let minProjection = Infinity + let maxProjection = -Infinity + + for (const [x, y] of corners) { + const projection = x * cos + y * sin + if (projection < minProjection) { + minProjection = projection + } + if (projection > maxProjection) { + maxProjection = projection + } + } + + const range = Math.max(maxProjection - minProjection, 1e-6) + + return { + cos, + sin, + minProjection, + invProjectionRange: 1 / range, + stops, + } +} + +function buildGradientStops(random: () => number): GradientStop[] { + const baseHue = random() * 360 + const stopOffsets = [0, clamp(0.25, 0.75, 0.44 + (random() - 0.5) * 0.22), 1] + + const hues = [ + normalizeHue(baseHue - 14 + (random() - 0.5) * 10), + normalizeHue(baseHue + 10 + (random() - 0.5) * 14), + normalizeHue(baseHue + 28 + (random() - 0.5) * 16), + ] + + const saturations = [ + clamp(0.45, 0.65, 0.54 + (random() - 0.5) * 0.08), + clamp(0.48, 0.7, 0.58 + (random() - 0.5) * 0.1), + clamp(0.42, 0.62, 0.5 + (random() - 0.5) * 0.08), + ] + + const lightness = [ + clamp(0.58, 0.75, 0.68 + (random() - 0.5) * 0.07), + clamp(0.52, 0.7, 0.6 + (random() - 0.5) * 0.07), + clamp(0.6, 0.78, 0.7 + (random() - 0.5) * 0.07), + ] + + return stopOffsets.map((offset, index) => ({ + offset, + color: hslToRgb(hues[index], saturations[index], lightness[index]), + })) +} + +function sampleGradientColor({ + gradient, + column, + row, + jitterPrefix, +}: { + gradient: PreparedGradient + column: number + row: number + jitterPrefix: string +}): RgbColor { + const x = column + 0.5 + const y = row + 0.5 + + const projection = x * gradient.cos + y * gradient.sin + const baseT = + (projection - gradient.minProjection) * gradient.invProjectionRange + const noise = hashString(`${jitterPrefix}${column}:${row}`) / UINT32_MAX + const t = clamp(0, 1, baseT + (noise - 0.5) * 0.06) + + return evaluateGradient(gradient.stops, t) +} + +function evaluateGradient(stops: GradientStop[], t: number): RgbColor { + if (stops.length === 0) { + return [200, 200, 200] + } + + if (t <= stops[0].offset) { + return stops[0].color + } + + for (let index = 1; index < stops.length; index += 1) { + const stop = stops[index] + const previous = stops[index - 1] + if (t <= stop.offset) { + const span = Math.max(stop.offset - previous.offset, 1e-6) + const amount = (t - previous.offset) / span + return mixColors(previous.color, stop.color, amount) + } + } + + return stops[stops.length - 1].color +} + +function mixColors( + [r1, g1, b1]: RgbColor, + [r2, g2, b2]: RgbColor, + amount: number, +): RgbColor { + return [ + r1 + (r2 - r1) * amount, + g1 + (g2 - g1) * amount, + b1 + (b2 - b1) * amount, + ] +} + +function toCssColor([r, g, b]: RgbColor) { + return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})` +} + +function hslToRgb(h: number, s: number, l: number): RgbColor { + const chroma = (1 - Math.abs(2 * l - 1)) * s + const huePrime = h / 60 + const x = chroma * (1 - Math.abs((huePrime % 2) - 1)) + + let r = 0 + let g = 0 + let b = 0 + + if (huePrime >= 0 && huePrime < 1) { + r = chroma + g = x + } else if (huePrime >= 1 && huePrime < 2) { + r = x + g = chroma + } else if (huePrime >= 2 && huePrime < 3) { + g = chroma + b = x + } else if (huePrime >= 3 && huePrime < 4) { + g = x + b = chroma + } else if (huePrime >= 4 && huePrime < 5) { + r = x + b = chroma + } else if (huePrime >= 5 && huePrime < 6) { + r = chroma + b = x + } + + const m = l - chroma / 2 + + return [(r + m) * 255, (g + m) * 255, (b + m) * 255] +} + +function normalizeHue(hue: number) { + return ((hue % 360) + 360) % 360 +} + +function clamp(min: number, max: number, value: number) { + return Math.min(max, Math.max(min, value)) +} + +function hashString(value: string) { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} diff --git a/src/components/blog-page/blog-card.tsx b/src/components/blog-page/blog-card.tsx new file mode 100644 index 0000000000..28f853c7e1 --- /dev/null +++ b/src/components/blog-page/blog-card.tsx @@ -0,0 +1,100 @@ +import { clsx } from "clsx" +import NextLink from "next/link" + +import ArrowDown from "@/app/conf/_design-system/pixelarticons/arrow-down.svg?svgr" +import { arrowsMoveSideways } from "@/app/conf/_design-system/utils/arrows-move-sideways" + +import { BlogTags } from "./blog-tags" +import { BlogCardPicture } from "./blog-card-picture" + +export interface BlogCardProps extends React.HTMLAttributes { + frontMatter: { + title: string + date: Date + tags: string[] + byline: string + } + route: string +} + +export function BlogCard({ + route, + frontMatter, + className, + ...rest +}: BlogCardProps) { + return ( + + + + +
+
+ {frontMatter.title} +
+
+ + +
+
+
+ ) +} + +export function BlogCardArrow({ className }: { className?: string }) { + return ( +
+ +
+ ) +} + +export function BlogCardFooterContent({ + byline, + date, + className, +}: { + byline: string + date: Date + className?: string +}) { + return ( + + {byline} + + + ) +} + +export { BlogCardPicture } from "./blog-card-picture" diff --git a/src/components/blog-page/blog-tag-colors.tsx b/src/components/blog-page/blog-tag-colors.tsx new file mode 100644 index 0000000000..0a69f779d6 --- /dev/null +++ b/src/components/blog-page/blog-tag-colors.tsx @@ -0,0 +1,9 @@ +export const blogTagColors: Record = { + newsletter: "#FFCCEF", + announcements: "#F80", + blog: "#012FFF", + foundation: "#5800FF", + spec: "#00C6AC", + grants: "#84BD01", + "in-the-news": "#3F3A3D", +} diff --git a/src/components/blog-page/blog-tags.tsx b/src/components/blog-page/blog-tags.tsx new file mode 100644 index 0000000000..99d4251a57 --- /dev/null +++ b/src/components/blog-page/blog-tags.tsx @@ -0,0 +1,21 @@ +import { Tag } from "@/app/conf/_design-system/tag" + +import { blogTagColors } from "./blog-tag-colors" + +export function BlogTags({ tags }: { tags: string[] }) { + return ( +
+ {tags.map(tag => { + const color = blogTagColors[tag] + if (!color) { + throw new Error(`No color found for tag: ${tag}`) + } + return ( + + {tag.replaceAll("-", " ")} + + ) + })} +
+ ) +} diff --git a/src/components/blog-page/blur-bean.webp b/src/components/blog-page/blur-bean.webp new file mode 100644 index 0000000000..26bccab68d Binary files /dev/null and b/src/components/blog-page/blur-bean.webp differ diff --git a/src/components/blog-page/featured-blog-posts.tsx b/src/components/blog-page/featured-blog-posts.tsx new file mode 100644 index 0000000000..3fd0060973 --- /dev/null +++ b/src/components/blog-page/featured-blog-posts.tsx @@ -0,0 +1,61 @@ +import { clsx } from "clsx" +import NextLink from "next/link" + +import { BlogCard, BlogCardArrow, BlogCardFooterContent } from "./blog-card" +import { BlogCardPicture } from "./blog-card-picture" +import { BlogMdxContent } from "./mdx-types" +import { BlogTags } from "./blog-tags" + +export interface FeaturedBlogPostsProps + extends React.HTMLAttributes { + blogs: BlogMdxContent[] +} + +export function FeaturedBlogPosts({ + className, + blogs, + ...props +}: FeaturedBlogPostsProps) { + const [firstFeatured, ...nextThree] = blogs + .filter(blog => blog.frontMatter.featured) + .sort((a, b) => b.frontMatter.date.getTime() - a.frontMatter.date.getTime()) + .slice(0, 4) + + return ( +
+ + +
+ +
+ {firstFeatured.frontMatter.title} +
+
+ + +
+
+
+
+ {nextThree.map(post => ( + + ))} +
+
+ ) +} diff --git a/src/components/blog-page/index.tsx b/src/components/blog-page/index.tsx new file mode 100644 index 0000000000..ce0fa42a7d --- /dev/null +++ b/src/components/blog-page/index.tsx @@ -0,0 +1,99 @@ +import NextLink from "next/link" + +import { Tag } from "@/app/conf/_design-system/tag" +import { arrowsMoveSideways } from "@/app/conf/_design-system/utils/arrows-move-sideways" + +import { blogTagColors } from "./blog-tag-colors" +import { BlogCard } from "./blog-card" +import { LookingForMore } from "./looking-for-more" +import { BlogMdxContent } from "./mdx-types" +import { FeaturedBlogPosts } from "./featured-blog-posts" +import { StripesDecoration } from "../../app/conf/_design-system/stripes-decoration" + +const mask = `url(${new URL("./blur-bean.webp", import.meta.url).href})` + +export interface BlogPageProps { + tags: Record + blogs: BlogMdxContent[] + currentTag: string +} + +export function BlogPage({ tags, blogs, currentTag }: BlogPageProps) { + return ( +
+
+ +
+

The GraphQL Blog

+

+ Insights, updates and best practices from across the GraphQL + community. Stay connected with the ideas and innovations shaping the + GraphQL ecosystem. +

+ +
+
+
+
+
+

+ {currentTag || "All Posts"} +

+
+

Categories

+
    + {Object.entries(tags) + .sort((a, b) => b[1] - a[1]) + .map(([tag, count], i) => ( + + + {tag.replaceAll("-", " ")} ({count}) + + + ))} +
+
+
+
+ {blogs.map( + page => + (!currentTag || page.frontMatter.tags.includes(currentTag)) && ( + + ), + )} +
+
+ +
+
+ ) +} + +function Stripes() { + return ( +
+ +
+ ) +} diff --git a/src/components/blog-page/looking-for-more.tsx b/src/components/blog-page/looking-for-more.tsx new file mode 100644 index 0000000000..2441ade4e7 --- /dev/null +++ b/src/components/blog-page/looking-for-more.tsx @@ -0,0 +1,36 @@ +import { Anchor } from "@/app/conf/_design-system/anchor" + +import ArrowDownIcon from "@/app/conf/_design-system/pixelarticons/arrow-down.svg?svgr" + +export function LookingForMore() { + return ( +
+
+
+

Looking for more?

+

+ Explore learning guides and best practices โ€” or browse for tools, + libraries and other resources. +

+
+
+ + Learn + + + + Resources + + +
+
+
+ ) +} diff --git a/src/components/blog-page/mdx-types.tsx b/src/components/blog-page/mdx-types.tsx new file mode 100644 index 0000000000..830ee9722d --- /dev/null +++ b/src/components/blog-page/mdx-types.tsx @@ -0,0 +1,10 @@ +export interface BlogMdxContent { + route: string + frontMatter: { + title: string + tags: string[] + byline: string + date: Date + featured: boolean + } +} diff --git a/src/components/index-page/data-colocation/index.tsx b/src/components/index-page/data-colocation/index.tsx index 1f54f51de2..620399412d 100644 --- a/src/components/index-page/data-colocation/index.tsx +++ b/src/components/index-page/data-colocation/index.tsx @@ -150,7 +150,7 @@ export function DataColocation() { return (
) } - -function arrowsMoveSideways(event: React.KeyboardEvent) { - if (event.key === "ArrowLeft" || event.key === "ArrowUp") { - const previousElement = event.currentTarget.previousElementSibling - if (previousElement) { - event.preventDefault() - ;(previousElement as HTMLElement).focus() - } - } else if (event.key === "ArrowRight" || event.key === "ArrowDown") { - const nextElement = event.currentTarget.nextElementSibling - if (nextElement) { - event.preventDefault() - ;(nextElement as HTMLElement).focus() - } - } -} diff --git a/src/pages/blog.mdx b/src/pages/blog.mdx index df349e7b40..546f70cd55 100644 --- a/src/pages/blog.mdx +++ b/src/pages/blog.mdx @@ -4,6 +4,7 @@ import { Tag, Card } from "@/components" import NextLink from "next/link" import { useRouter } from "next/router" import { clsx } from "clsx" +import { BlogPage } from "@/components/blog-page" export default function Blog() { const { asPath } = useRouter() @@ -22,68 +23,10 @@ export default function Blog() { acc[tag] += 1 return acc }, {}) - // - const tagList = ( -
- {Object.entries(tags) - .sort((a, b) => b[1] - a[1]) - .map(([tag, count]) => ( - - {tag.replaceAll("-", " ")} ({count}) - - ))} -
- ) - // - const blogList = blogs.map( - page => - (!currentTag || page.frontMatter.tags.includes(currentTag)) && ( - -
- {page.frontMatter.tags.map(tag => ( - {tag.replaceAll("-", " ")} - ))} -
-
- {page.frontMatter.title} -
-
- - - by {page.frontMatter.byline} -
- - Read more โ†’ - -
- ), - ) - return ( - <> -
-

Blog

-

Categories

- {tagList} -
-
- {blogList} -
- - ) + + return } diff --git a/src/pages/blog/2025-06-20-graphql-js-org.md b/src/pages/blog/2025-06-20-graphql-js-org.md index f989081ffa..a8983f3eaa 100644 --- a/src/pages/blog/2025-06-20-graphql-js-org.md +++ b/src/pages/blog/2025-06-20-graphql-js-org.md @@ -1,8 +1,9 @@ --- -title: ๐Ÿ†• Announcing graphql-js.org! +title: Announcing graphql-js.org! tags: ["announcements"] date: 2025-06-20 byline: GraphQL-js Maintainers +featured: true --- Dear Community, diff --git a/src/pages/blog/2025-09-04-multioption-inputs-with-oneof/index.mdx b/src/pages/blog/2025-09-04-multioption-inputs-with-oneof/index.mdx index ae7c552059..7c2728d576 100644 --- a/src/pages/blog/2025-09-04-multioption-inputs-with-oneof/index.mdx +++ b/src/pages/blog/2025-09-04-multioption-inputs-with-oneof/index.mdx @@ -3,6 +3,7 @@ title: Safer Multi-option Inputs with `@oneOf` tags: [announcements, spec] date: 2025-09-04 byline: Benjie Gillam +featured: true --- Weโ€™re excited to announce **[OneOf Input diff --git a/src/pages/blog/2025-09-08-announcing-graphql-ambassadors/index.md b/src/pages/blog/2025-09-08-announcing-graphql-ambassadors/index.md index 286a9e5157..7fc2074459 100644 --- a/src/pages/blog/2025-09-08-announcing-graphql-ambassadors/index.md +++ b/src/pages/blog/2025-09-08-announcing-graphql-ambassadors/index.md @@ -3,6 +3,7 @@ title: "Announcing Our GraphQL Ambassadors" tags: ["blog"] date: 2025-09-08 byline: Jem Gillam and Jory Burson +featured: true --- The GraphQL Foundation is thrilled to announce the launch of the GraphQL Ambassadors Program โ€” a new initiative to recognize and support community leaders who are helping to grow the GraphQL ecosystem worldwide. diff --git a/src/pages/blog/2025-09-08-september-edition.md b/src/pages/blog/2025-09-08-september-edition.md index f28e81a1d6..643ef6ddb4 100644 --- a/src/pages/blog/2025-09-08-september-edition.md +++ b/src/pages/blog/2025-09-08-september-edition.md @@ -3,6 +3,7 @@ title: "Announcing the September 2025 Edition of the GraphQL Specification" tags: ["spec"] date: 2025-09-08 byline: Lee Byron +featured: true --- Itโ€™s here: the [September 2025 edition of the GraphQL specification](https://spec.graphql.org/September2025/)!