Skip to content

Commit 03926e5

Browse files
committed
Use Firestore REST for server content
1 parent ccabc26 commit 03926e5

2 files changed

Lines changed: 157 additions & 81 deletions

File tree

components/Sidebar.tsx

Lines changed: 20 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,8 @@ import Image from "next/image";
33
import PopularArticles from "./PopularArticles";
44
import { Button } from "./ui/button";
55
import magazineCover from "@/public/logos/LAP-Logo-Color.png";
6-
import { db } from "@/lib/firebase";
6+
import { getPublishedArticles } from "@/lib/content";
77
import { SITE_NAME } from "@/lib/seo";
8-
import {
9-
collection,
10-
getDocs,
11-
query,
12-
where,
13-
orderBy,
14-
Timestamp,
15-
} from "firebase/firestore";
168

179
type PopularArticle = {
1810
id: string;
@@ -27,41 +19,26 @@ type PopularArticle = {
2719

2820
async function getPopularArticles(): Promise<PopularArticle[]> {
2921
try {
30-
// Fetch authors for mapping names
31-
const authorsSnapshot = await getDocs(collection(db, "authors"));
32-
const authorsMap = new Map(
33-
authorsSnapshot.docs.map((d) => [d.id, d.data().name]),
34-
);
22+
const articles = await getPublishedArticles();
3523

36-
const articlesQuery = query(
37-
collection(db, "articles"),
38-
where("popularity", "==", true),
39-
where("publish", "==", true),
40-
orderBy("popularityRank", "asc"),
41-
);
42-
43-
const snapshot = await getDocs(articlesQuery);
44-
45-
return snapshot.docs.map((doc) => {
46-
const data = doc.data();
47-
return {
48-
id: doc.id,
49-
title: data.title || "",
50-
slug: data.slug || "",
51-
authorName:
52-
data.authorName ||
53-
authorsMap.get(data.authorUID) ||
54-
"Unknown Team Member",
55-
popularity: data.popularity || false,
56-
publish: data.publish || false,
57-
date:
58-
data.date instanceof Timestamp
59-
? data.date.toDate().toISOString()
60-
: typeof data.date === "string"
61-
? data.date
62-
: new Date().toISOString(),
63-
};
64-
});
24+
return articles
25+
.filter((article) => article.popularity)
26+
.sort(
27+
(a, b) =>
28+
(a.popularityRank ?? Number.MAX_SAFE_INTEGER) -
29+
(b.popularityRank ?? Number.MAX_SAFE_INTEGER) ||
30+
b.date.getTime() - a.date.getTime(),
31+
)
32+
.map((article) => ({
33+
id: article.id,
34+
title: article.title,
35+
slug: article.slug,
36+
authorName: article.authorName || "Unknown Team Member",
37+
popularity: article.popularity,
38+
publish: article.publish,
39+
date: article.date.toISOString(),
40+
popularityRank: article.popularityRank,
41+
}));
6542
} catch (error) {
6643
console.error("Error fetching popular articles:", error);
6744
return [];

lib/content.ts

Lines changed: 137 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
11
import "server-only";
22

3-
import { db } from "@/lib/firebase";
4-
import {
5-
collection,
6-
getDocs,
7-
limit,
8-
orderBy,
9-
query,
10-
where,
11-
type DocumentData,
12-
type QueryDocumentSnapshot,
13-
} from "firebase/firestore";
143
import {
154
DEFAULT_OG_IMAGE_PATH,
165
SITE_NAME,
@@ -58,6 +47,37 @@ const VIDEO_URL_FIELDS = [
5847

5948
type SocialMap = Record<string, string>;
6049

50+
type FirestoreValue = {
51+
stringValue?: string;
52+
booleanValue?: boolean;
53+
integerValue?: string;
54+
doubleValue?: number;
55+
timestampValue?: string;
56+
mapValue?: {
57+
fields?: Record<string, FirestoreValue>;
58+
};
59+
arrayValue?: {
60+
values?: FirestoreValue[];
61+
};
62+
referenceValue?: string;
63+
nullValue?: null;
64+
};
65+
66+
type FirestoreDocument = {
67+
name: string;
68+
fields?: Record<string, FirestoreValue>;
69+
};
70+
71+
type FirestoreListResponse = {
72+
documents?: FirestoreDocument[];
73+
nextPageToken?: string;
74+
};
75+
76+
type ContentDocument = {
77+
id: string;
78+
data: Record<string, unknown>;
79+
};
80+
6181
export type AuthorRecord = {
6282
docId: string;
6383
uid: string;
@@ -256,10 +276,97 @@ function buildVideoRecord(
256276
};
257277
}
258278

259-
function normalizeAuthorDoc(
260-
doc: QueryDocumentSnapshot<DocumentData>,
261-
): AuthorRecord {
262-
const data = doc.data() as Record<string, unknown>;
279+
function getRequiredFirebaseConfig() {
280+
const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY;
281+
const projectId = process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID;
282+
283+
if (!apiKey || !projectId) {
284+
throw new Error("Missing Firebase public API key or project ID.");
285+
}
286+
287+
return { apiKey, projectId };
288+
}
289+
290+
function decodeFirestoreValue(value: FirestoreValue): unknown {
291+
if ("stringValue" in value) return value.stringValue || "";
292+
if ("booleanValue" in value) return Boolean(value.booleanValue);
293+
if ("integerValue" in value) return Number(value.integerValue || 0);
294+
if ("doubleValue" in value) return Number(value.doubleValue || 0);
295+
if ("timestampValue" in value) return value.timestampValue || "";
296+
if ("referenceValue" in value) return value.referenceValue || "";
297+
if ("arrayValue" in value) {
298+
return (value.arrayValue?.values || []).map((entry) =>
299+
decodeFirestoreValue(entry),
300+
);
301+
}
302+
if ("mapValue" in value) {
303+
return decodeFirestoreFields(value.mapValue?.fields || {});
304+
}
305+
306+
return null;
307+
}
308+
309+
function decodeFirestoreFields(fields: Record<string, FirestoreValue>) {
310+
return Object.entries(fields).reduce<Record<string, unknown>>(
311+
(acc, [key, value]) => {
312+
acc[key] = decodeFirestoreValue(value);
313+
return acc;
314+
},
315+
{},
316+
);
317+
}
318+
319+
function getDocumentId(documentName: string) {
320+
return documentName.split("/").pop() || documentName;
321+
}
322+
323+
async function getCollectionDocuments(
324+
collectionId: "articles" | "authors",
325+
): Promise<ContentDocument[]> {
326+
const { apiKey, projectId } = getRequiredFirebaseConfig();
327+
const documents: ContentDocument[] = [];
328+
let pageToken: string | undefined;
329+
330+
do {
331+
const params = new URLSearchParams({
332+
key: apiKey,
333+
pageSize: "100",
334+
});
335+
336+
if (pageToken) {
337+
params.set("pageToken", pageToken);
338+
}
339+
340+
const response = await fetch(
341+
`https://firestore.googleapis.com/v1/projects/${encodeURIComponent(
342+
projectId,
343+
)}/databases/(default)/documents/${collectionId}?${params.toString()}`,
344+
{
345+
next: { revalidate: 300 },
346+
},
347+
);
348+
349+
if (!response.ok) {
350+
throw new Error(
351+
`Firestore REST request failed for ${collectionId}: ${response.status} ${response.statusText}`,
352+
);
353+
}
354+
355+
const payload = (await response.json()) as FirestoreListResponse;
356+
documents.push(
357+
...(payload.documents || []).map((document) => ({
358+
id: getDocumentId(document.name),
359+
data: decodeFirestoreFields(document.fields || {}),
360+
})),
361+
);
362+
pageToken = payload.nextPageToken;
363+
} while (pageToken);
364+
365+
return documents;
366+
}
367+
368+
function normalizeAuthorDoc(doc: ContentDocument): AuthorRecord {
369+
const data = doc.data;
263370
const name = pickString(data, ["name"]) || "Unknown Author";
264371

265372
return {
@@ -289,10 +396,10 @@ function createAuthorLookup(authors: AuthorRecord[]) {
289396
}
290397

291398
function normalizeArticleDoc(
292-
doc: QueryDocumentSnapshot<DocumentData>,
399+
doc: ContentDocument,
293400
authorLookup: Map<string, AuthorRecord>,
294401
): ArticleRecord {
295-
const data = doc.data() as Record<string, unknown>;
402+
const data = doc.data;
296403
const authorUID =
297404
pickString(data, ["authorUID", "authorUid", "authorId"]) || "";
298405
const author = authorLookup.get(authorUID);
@@ -364,19 +471,19 @@ export function buildTopicSummaries(articles: ArticleRecord[]): TopicSummary[] {
364471
}
365472

366473
export async function getAllAuthors() {
367-
const snapshot = await getDocs(collection(db, "authors"));
368-
return snapshot.docs
369-
.map((doc) => normalizeAuthorDoc(doc))
474+
const documents = await getCollectionDocuments("authors");
475+
return documents
476+
.map((document) => normalizeAuthorDoc(document))
370477
.sort((a, b) => a.name.localeCompare(b.name));
371478
}
372479

373480
export async function getPublishedArticles(limitCount?: number) {
374481
const authors = await getAllAuthors();
375482
const authorLookup = createAuthorLookup(authors);
376-
const snapshot = await getDocs(query(collection(db, "articles"), orderBy("date", "desc")));
483+
const documents = await getCollectionDocuments("articles");
377484

378-
const articles = snapshot.docs
379-
.map((doc) => normalizeArticleDoc(doc, authorLookup))
485+
const articles = documents
486+
.map((document) => normalizeArticleDoc(document, authorLookup))
380487
.filter((article) => article.publish);
381488

382489
const sorted = sortArticlesDescending(articles);
@@ -386,15 +493,12 @@ export async function getPublishedArticles(limitCount?: number) {
386493
export async function getPublishedArticleBySlug(slug: string) {
387494
const authors = await getAllAuthors();
388495
const authorLookup = createAuthorLookup(authors);
389-
const snapshot = await getDocs(
390-
query(collection(db, "articles"), where("slug", "==", slug), limit(1)),
391-
);
496+
const documents = await getCollectionDocuments("articles");
497+
const document = documents.find((entry) => entry.data.slug === slug);
392498

393-
if (snapshot.empty) {
394-
return null;
395-
}
499+
if (!document) return null;
396500

397-
const article = normalizeArticleDoc(snapshot.docs[0], authorLookup);
501+
const article = normalizeArticleDoc(document, authorLookup);
398502
return article.publish ? article : null;
399503
}
400504

@@ -404,15 +508,10 @@ export async function getPublishedArticlesForAuthor(authorUID: string) {
404508
}
405509

406510
export async function getAuthorBySlug(slug: string) {
407-
const snapshot = await getDocs(
408-
query(collection(db, "authors"), where("slug", "==", slug), limit(1)),
409-
);
410-
411-
if (snapshot.empty) {
412-
return null;
413-
}
511+
const documents = await getCollectionDocuments("authors");
512+
const document = documents.find((entry) => entry.data.slug === slug);
414513

415-
return normalizeAuthorDoc(snapshot.docs[0]);
514+
return document ? normalizeAuthorDoc(document) : null;
416515
}
417516

418517
export async function getPublishedTopicBySlug(topicSlug: string) {

0 commit comments

Comments
 (0)