Skip to content

Commit c9062c0

Browse files
authored
Merge pull request #10 from CodeBoxxTechSchool/ml/blog-posts-content
Ml/blog posts content
2 parents 417c1b0 + 7ddee7b commit c9062c0

10 files changed

Lines changed: 395 additions & 33 deletions

File tree

node_modules/.package-lock.json

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"format:check": "prettier --check ."
1212
},
1313
"dependencies": {
14+
"@portabletext/react": "^6.2.0",
1415
"bootstrap": "^5.3.8",
1516
"react": "^18.3.1",
1617
"react-bootstrap": "^2.10.10",

readme.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ npm run dev
3131
| ------------ | ------------------------- | ------------------------------------------------------------------ |
3232
| `/` | `src/pages/Home.jsx` | Sections 01–07, WSJ and Forge 20 bands, Codi drawer, enroll drawer |
3333
| `/blog` | `src/pages/Blog.jsx` | CodeBlog index, Sanity-backed |
34+
| `/blog/:slug` | `src/pages/BlogPost.jsx` | Standalone post page, renders the post's own content |
3435
| `/financing` | `src/pages/Financing.jsx` | Academy financing options |
3536
| `/ventures` | `src/pages/Ventures.jsx` | CodeBoxx Ventures |
3637

@@ -61,7 +62,7 @@ consumes the API, it doesn't scaffold a Studio):
6162

6263
| Document type | Fields | Consumed by |
6364
| -------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
64-
| `post` | `title`, `category`, `author`, `publishedAt`, `excerpt`, `url` | `useSanityPosts` — Blog page |
65+
| `post` | `title`, `slug`, `category`, `author`, `publishedAt`, `excerpt`, `content` (Portable Text/rich text), `featuredImage` (optional), `url` (optional external reference — not the content source) | `useSanityPosts` — Blog page; `useSanityPost(slug, seed)` — the post's own page at `/blog/:slug` |
6566
| `teamMember` | `name`, `role`, `linkedin` (url), `photo` (image), `group` (`"studio"` \| `"academy"`), `order` (number) | `useSanityTeam(group, seed)` — Studio team (`#codeboxx .person`) and Academy team (`#academy .person`), same type filtered by `group` |
6667
| `partnerLogo` | `name`, `logo` (image), `order` (number) | `useSanityLogos(seed)` — the `.client-slider` partner logos; however many documents exist is however many slides show |
6768
| `cohortIntake` | `program` (`"fsd"` \| `"aidev"`), `date`, `location`, `status` (`"Open"` \| `"Waitlist"` \| `"Planned"`) | `useIntakes(seed)` in `src/lib/intakes.js` — the `#intake` calendar rows |
@@ -71,6 +72,28 @@ placeholder for a real admissions API later, and `IntakeCalendar` only ever impo
7172
`useIntakes` and expects a `{ fsd: [...], aidev: [...] }` return shape — swapping the data
7273
source later means rewriting `src/lib/intakes.js` only, with no changes to `Home.jsx`.
7374

75+
Each post's `content` field is Sanity's standard Portable Text (rich text) — currently
76+
text-only in the schema (headings, bold/italic, links, lists, quotes), no inline images
77+
yet; that's a deliberate, easy-to-extend-later scope call, not a limitation of the
78+
approach. It renders via `@portabletext/react` (the one dependency this project adds
79+
beyond a plain `fetch` — a small, official rendering library, not an API client, so it
80+
doesn't conflict with the rest of `sanity.js` staying SDK-free) with `components`
81+
overrides in `src/pages/BlogPost.jsx` mapping block/list/mark types onto this site's
82+
existing typography classes (`pbody`, `h2`, `ptitle`, etc.) instead of unstyled defaults.
83+
84+
`featuredImage` (optional) is the post page's hero background, via inline `style`
85+
since the URL is per-post data — stacking a `linear-gradient(rgba(0,0,0,.6), ...)`
86+
scrim with `url(...)` in one `background-image`, reusing the scrim-over-photo
87+
technique already used by the homepage `.hero` (`_components.scss`). It also backs
88+
the `/blog` listing card's cover (`Blog.jsx`, via the same `<image-slot src>` prop
89+
already used for team photos/logos). `sanityImageUrl(url, { w, q })` in `sanity.js`
90+
appends Sanity's CDN resize/quality query params so each use requests only the pixel
91+
size it renders (large for the hero, small for the card thumbnail) — no
92+
`@sanity/image-url` package needed. Posts without a `featuredImage` keep the plain
93+
navy hero, unchanged. The article section below the hero (`.post-content-band` in
94+
`_blog.scss`) has its own subtle top-fade gradient, independent of `featuredImage`
95+
plain CSS, not photo-based.
96+
7497
Team member and partner logo entries carry a stable `id` (the Sanity document `_id`) that's
7598
used as both the React list key and the `<image-slot>` element's `id` attribute. Don't
7699
switch that back to an array index — `image-slot` persists locally-dropped images keyed by

src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { BrowserRouter, Routes, Route } from 'react-router-dom';
33
import Home from './pages/Home';
44
import Blog from './pages/Blog';
5+
import BlogPost from './pages/BlogPost';
56
import Financing from './pages/Financing';
67
import Ventures from './pages/Ventures';
78

@@ -11,6 +12,7 @@ export default function App() {
1112
<Routes>
1213
<Route path="/" element={<Home />} />
1314
<Route path="/blog" element={<Blog />} />
15+
<Route path="/blog/:slug" element={<BlogPost />} />
1416
<Route path="/financing" element={<Financing />} />
1517
<Route path="/ventures" element={<Ventures />} />
1618
<Route path="*" element={<Home />} />

src/lib/sanity.js

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,50 @@ export async function fetchCollection(type, groqTail = '') {
2020
});
2121
if (!res.ok) throw new Error('Sanity ' + res.status + ' on ' + type);
2222
const body = await res.json();
23-
return body.result || [];
23+
return body.result;
2424
}
2525

26-
// Sanity 'post' document -> the shape the Blog page renders.
26+
// Appends Sanity's image CDN resize/quality params to an asset URL, so callers
27+
// request only the pixel size they'll actually render (e.g. a small, low-quality
28+
// tile for a repeated background) instead of always paying for the full upload.
29+
export function sanityImageUrl(url, { w, q = 60 } = {}) {
30+
if (!url) return null;
31+
return url + '?w=' + w + '&q=' + q + '&auto=format';
32+
}
33+
34+
// Sanity 'post' document -> the shape Blog.jsx's cards and BlogPost.jsx's page
35+
// render. Both post queries below add a `{..., "featuredImageUrl": ...}` projection —
36+
// `...` keeps every raw field as-is (so `slug`/`content` need no query change) while
37+
// resolving the image asset reference to a plain URL. `url` is an optional external
38+
// reference now (not the content source), so it maps to `null` when absent rather
39+
// than a placeholder '#' href.
2740
function toPost(entry) {
2841
return {
2942
title: entry.title,
43+
slug: entry.slug?.current || entry.slug || '',
3044
category: entry.category,
3145
author: entry.author,
3246
date: (entry.publishedAt || entry.date || '').slice(0, 10),
3347
excerpt: entry.excerpt || entry.summary || '',
34-
url: entry.url || entry.canonicalUrl || '#',
48+
content: entry.content || null,
49+
featuredImage: entry.featuredImageUrl || null,
50+
url: entry.url || entry.canonicalUrl || null,
3551
};
3652
}
3753

54+
const FEATURED_IMAGE_PROJECTION = '{..., "featuredImageUrl": featuredImage.asset->url}';
55+
3856
export function useSanityPosts(seed = []) {
3957
const [posts, setPosts] = React.useState(seed);
4058
React.useEffect(() => {
4159
let live = true;
4260
if (!PROJECT_ID) return undefined;
43-
fetchCollection('post', ' | order(publishedAt desc) [0...50]')
61+
fetchCollection(
62+
'post',
63+
' | order(publishedAt desc) [0...50]' + FEATURED_IMAGE_PROJECTION
64+
)
4465
.then((rows) => {
45-
if (live && rows.length) setPosts(rows.map(toPost));
66+
if (live && rows && rows.length) setPosts(rows.map(toPost));
4667
})
4768
.catch((err) => console.warn('[sanity]', err.message));
4869
return () => {
@@ -52,6 +73,27 @@ export function useSanityPosts(seed = []) {
5273
return posts;
5374
}
5475

76+
// Fetches one post by slug for BlogPost.jsx (route: /blog/:slug). The slug comes
77+
// from a route param, so it's the first user-facing value in this codebase to feed
78+
// directly into a hand-built GROQ string — sanitized to [a-z0-9-] before use.
79+
export function useSanityPost(slug, seed = null) {
80+
const [post, setPost] = React.useState(seed);
81+
React.useEffect(() => {
82+
let live = true;
83+
const safeSlug = (slug || '').replace(/[^a-z0-9-]/g, '');
84+
if (!PROJECT_ID || !safeSlug) return undefined;
85+
fetchCollection('post', '[slug.current == "' + safeSlug + '"][0]' + FEATURED_IMAGE_PROJECTION)
86+
.then((entry) => {
87+
if (live && entry) setPost(toPost(entry));
88+
})
89+
.catch((err) => console.warn('[sanity]', err.message));
90+
return () => {
91+
live = false;
92+
};
93+
}, [slug]);
94+
return post;
95+
}
96+
5597
// Sanity 'teamMember' document (name, role, linkedin, photo image, group: "studio" |
5698
// "academy", order) -> the shape ServiceDetail's and Academy's people-grids render.
5799
// `id` carries the Sanity document _id — used as both the React key and the
@@ -81,7 +123,7 @@ export function useSanityTeam(group, seed = []) {
81123
'"] | order(order asc) {_id, name, role, linkedin, "photo": photo.asset->url}'
82124
)
83125
.then((rows) => {
84-
if (live && rows.length) setTeam(rows.map(toTeamMember));
126+
if (live && rows && rows.length) setTeam(rows.map(toTeamMember));
85127
})
86128
.catch((err) => console.warn('[sanity]', err.message));
87129
return () => {
@@ -104,7 +146,7 @@ export function useSanityLogos(seed = []) {
104146
if (!PROJECT_ID) return undefined;
105147
fetchCollection('partnerLogo', ' | order(order asc) {_id, name, "logo": logo.asset->url}')
106148
.then((rows) => {
107-
if (live && rows.length) setLogos(rows.map(toLogo));
149+
if (live && rows && rows.length) setLogos(rows.map(toLogo));
108150
})
109151
.catch((err) => console.warn('[sanity]', err.message));
110152
return () => {

0 commit comments

Comments
 (0)