-
Notifications
You must be signed in to change notification settings - Fork 14
Global Author list #2432
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Global Author list #2432
Changes from all commits
016e8ab
b370ca1
e764374
1732cfe
611d15b
d07e390
3fd2a7f
2f57933
fed8277
12623fc
75703ff
187d739
979b7da
11e689e
21a83e3
dd4553a
0793769
434bbae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import fs from "fs"; | ||
| import { type NextRequest, NextResponse } from "next/server"; | ||
| import path from "path"; | ||
|
|
||
| export const dynamic = "force-static"; | ||
| export const revalidate = 3600; // Revalidate every hour | ||
|
|
||
| interface Person { | ||
| slug: string; | ||
| name: string; | ||
| imageUrl: string; | ||
| } | ||
|
|
||
| type PeopleIndex = Record<string, Person>; | ||
|
|
||
| /** | ||
| * Load the people index from the generated JSON file | ||
| */ | ||
| function loadPeopleIndex(): PeopleIndex { | ||
| try { | ||
| const indexPath = path.join(process.cwd(), "people-index.json"); | ||
|
|
||
| if (!fs.existsSync(indexPath)) { | ||
| console.warn("people-index.json not found. Run 'npm run generate:people' first."); | ||
| return {}; | ||
| } | ||
|
|
||
| const content = fs.readFileSync(indexPath, "utf-8"); | ||
| return JSON.parse(content); | ||
| } catch (error) { | ||
| console.error("Failed to load people index:", error); | ||
| return {}; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * GET /api/people | ||
| * | ||
| * Returns the people index for use in TinaCMS and client components. | ||
| * | ||
| * Query params: | ||
| * - slug: Optional. If provided, returns a single person. | ||
| * - search: Optional. If provided, filters people by name. | ||
| */ | ||
| export async function GET(request: NextRequest) { | ||
| const searchParams = request.nextUrl.searchParams; | ||
| const slug = searchParams.get("slug"); | ||
| const search = searchParams.get("search"); | ||
|
|
||
| const peopleIndex = loadPeopleIndex(); | ||
|
|
||
| // If a specific slug is requested, return just that person | ||
| if (slug) { | ||
| const person = peopleIndex[slug]; | ||
|
|
||
| if (!person) { | ||
| return NextResponse.json({ error: "Person not found" }, { status: 404 }); | ||
| } | ||
|
|
||
| return NextResponse.json(person); | ||
| } | ||
|
|
||
| // Convert to array for easier filtering and sorting | ||
| let people = Object.values(peopleIndex); | ||
|
|
||
| // If search is provided, filter by name or slug | ||
| if (search) { | ||
| const searchLower = search.toLowerCase(); | ||
| people = people.filter((person) => person.name.toLowerCase().includes(searchLower) || person.slug.includes(searchLower)); | ||
| } | ||
|
|
||
| // Sort by name | ||
| people.sort((a, b) => a.name.localeCompare(b.name)); | ||
|
|
||
| return NextResponse.json({ | ||
| people, | ||
| total: people.length, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,13 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState, useMemo } from "react"; | ||
| import { useSearchParams } from "next/navigation"; | ||
| import Breadcrumbs from "@/components/Breadcrumbs"; | ||
| import Spinner from "@/components/Spinner"; | ||
| import { useEffect, useMemo, useState } from "react"; | ||
| import { FaUserCircle } from "react-icons/fa"; | ||
| import Breadcrumbs from "@/components/Breadcrumbs"; | ||
| import RuleList from "@/components/rule-list"; | ||
| import Spinner from "@/components/Spinner"; | ||
| import Pagination from "@/components/ui/pagination"; | ||
| import { selectLatestRuleFilesByPath } from "@/utils/selectLatestRuleFilesByPath"; | ||
|
|
||
| const Tabs = { | ||
| MODIFIED: "modified", | ||
|
|
@@ -38,12 +39,15 @@ export default function UserRulesClientPage({ ruleCount }) { | |
| const [itemsPerPageAuthored, setItemsPerPageAuthored] = useState(20); | ||
| const FETCH_PAGE_SIZE = 10; | ||
|
|
||
| const resolveAuthor = async (): Promise<string> => { | ||
| const resolveAuthor = async (): Promise<{ slug: string; fullName: string }> => { | ||
| const res = await fetch(`./api/crm/employees?query=${encodeURIComponent(queryStringRulesAuthor)}`); | ||
| if (!res.ok) throw new Error("Failed to resolve author"); | ||
| const profile = await res.json(); | ||
| setAuthor(profile); | ||
| return profile.fullName as string; | ||
| return { | ||
| slug: profile.slug || profile.fullName?.toLowerCase().replace(/\s+/g, "-") || "", | ||
| fullName: profile.fullName || "", | ||
| }; | ||
|
Comment on lines
+47
to
+50
|
||
| }; | ||
|
|
||
| useEffect(() => { | ||
|
|
@@ -54,8 +58,9 @@ export default function UserRulesClientPage({ ruleCount }) { | |
|
|
||
| // Try to resolve author for authored rules (needs CRM) | ||
| try { | ||
| const resolvedAuthorName = await resolveAuthor(); | ||
| await Promise.all([loadAllAuthoredRules(resolvedAuthorName as string), lastModifiedPromise]); | ||
| const resolvedAuthor = await resolveAuthor(); | ||
| const authorSlug = resolvedAuthor.slug; | ||
| await Promise.all([loadAllAuthoredRules(authorSlug), lastModifiedPromise]); | ||
| } catch (err) { | ||
| // CRM failed, but still wait for last modified rules | ||
| console.error("Failed to resolve author from CRM:", err); | ||
|
|
@@ -98,7 +103,7 @@ export default function UserRulesClientPage({ ruleCount }) { | |
| }; | ||
|
|
||
| // Function to load ALL authored rules (not just one page) - WITH BATCHING | ||
| const loadAllAuthoredRules = async (authorName: string) => { | ||
| const loadAllAuthoredRules = async (authorSlug: string) => { | ||
| setLoadingAuthored(true); | ||
| setAuthoredRules([]); | ||
| let cursor: string | null = null; | ||
|
|
@@ -114,7 +119,7 @@ export default function UserRulesClientPage({ ruleCount }) { | |
| pageCount++; | ||
|
|
||
| const params = new URLSearchParams(); | ||
| params.set("authorTitle", authorName || ""); | ||
| params.set("authorSlug", authorSlug || ""); | ||
| params.set("first", FETCH_PAGE_SIZE.toString()); | ||
| if (cursor) params.set("after", cursor); | ||
|
|
||
|
|
@@ -132,7 +137,7 @@ export default function UserRulesClientPage({ ruleCount }) { | |
| title: fullRule.title, | ||
| uri: fullRule.uri, | ||
| body: fullRule.body, | ||
| authors: fullRule.authors?.map((a: any) => (a && a.title ? { title: a.title } : null)).filter((a: any): a is { title: string } => a !== null) || [], | ||
| authors: Array.isArray(fullRule.authors) ? fullRule.authors.map((a: any) => a?.author).filter(Boolean) : [], | ||
| lastUpdated: fullRule.lastUpdated, | ||
| lastUpdatedBy: fullRule.lastUpdatedBy, | ||
| })); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,81 +1,51 @@ | ||
| "use client"; | ||
|
|
||
| import Image from "next/image"; | ||
| import { useCallback, useEffect, useMemo, useState } from "react"; | ||
| import { tinaField } from "tinacms/dist/react"; | ||
| import { useEffect, useMemo, useState } from "react"; | ||
| import { Card } from "@/components/ui/card"; | ||
| import { usePeopleIndex, useResolveAuthors } from "@/lib/people/usePeople"; | ||
|
|
||
| interface Author { | ||
| title?: string; | ||
| url?: string; | ||
| img?: string; | ||
| noimage?: boolean; | ||
| author?: string | null; | ||
| } | ||
|
|
||
| interface AuthorsCardProps { | ||
| authors?: Author[]; | ||
| authors?: Author[] | null; | ||
| } | ||
|
|
||
| export default function AuthorsCard({ authors }: AuthorsCardProps) { | ||
| const resolvedAuthors: Author[] = useMemo(() => authors || [], [authors]); | ||
| const placeholderImg = `${process.env.NEXT_PUBLIC_BASE_PATH}/uploads/ssw-employee-profile-placeholder-sketch.jpg`; | ||
|
|
||
| const getImgSource = useCallback( | ||
| (author: Author): string => { | ||
| const { noimage, img, url } = author; | ||
|
|
||
| if (noimage) return placeholderImg; | ||
|
|
||
| if (img?.includes("http")) return img; | ||
|
|
||
| if (url?.includes("ssw.com.au/people")) { | ||
| // Extract the part after '/people/' | ||
| const match = url.match(/people\/([^/?#]+)/); | ||
| const slug = match ? match[1] : null; | ||
|
|
||
| if (slug) { | ||
| // Capitalize each word | ||
| const formattedTitle = slug | ||
| .split("-") | ||
| .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) | ||
| .join("-"); | ||
|
|
||
| return `https://raw.githubusercontent.com/SSWConsulting/SSW.People.Profiles/main/${formattedTitle}/Images/${formattedTitle}-Profile.jpg`; | ||
| } | ||
| } | ||
|
|
||
| if (url?.includes("github.com/")) { | ||
| const gitHubUsername = url.split("github.com/").pop(); | ||
| return `https://avatars.githubusercontent.com/${gitHubUsername}`; | ||
| } | ||
|
|
||
| return placeholderImg; | ||
| }, | ||
| [placeholderImg] | ||
| ); | ||
| const placeholderImg = `${process.env.NEXT_PUBLIC_BASE_PATH || ""}/uploads/ssw-employee-profile-placeholder-sketch.jpg`; | ||
|
|
||
| // Extract slugs from authors array | ||
| const slugs = useMemo(() => { | ||
| if (!authors || authors.length === 0) return []; | ||
| return authors.filter((a) => a?.author).map((a) => a.author as string); | ||
| }, [authors]); | ||
|
|
||
| // Resolve slugs to full person data | ||
| const { people: peopleIndex } = usePeopleIndex(); | ||
| const { authors: resolvedAuthors, loading } = useResolveAuthors(slugs); | ||
|
|
||
| // Build display authors - only link to indexed people | ||
| const displayAuthors = useMemo(() => { | ||
| return resolvedAuthors.map((person) => ({ | ||
| name: person.name, | ||
| url: person.slug in peopleIndex ? `https://ssw.com.au/people/${person.slug}` : null, | ||
| imageUrl: person.imageUrl || placeholderImg, | ||
| })); | ||
| }, [resolvedAuthors, placeholderImg, peopleIndex]); | ||
|
|
||
| const [imgSrcList, setImgSrcList] = useState<string[]>([]); | ||
|
|
||
| useEffect(() => { | ||
| if (resolvedAuthors.length > 0) { | ||
| setImgSrcList(resolvedAuthors.map(getImgSource)); | ||
| } | ||
| }, [resolvedAuthors, getImgSource]); | ||
|
|
||
| useEffect(() => { | ||
| if (resolvedAuthors.length > 0) { | ||
| resolvedAuthors.forEach((author) => { | ||
| if (!author.title) { | ||
| console.warn(`Profile title is missing for author with URL: ${author.url}`); | ||
| } | ||
| }); | ||
| if (displayAuthors.length > 0) { | ||
| setImgSrcList(displayAuthors.map((a) => a.imageUrl || placeholderImg)); | ||
| } | ||
| }, [resolvedAuthors]); | ||
| }, [displayAuthors, placeholderImg]); | ||
|
Comment on lines
40
to
+44
|
||
|
|
||
| const handleImageError = (index: number) => { | ||
| setImgSrcList((prev) => { | ||
| const updated = [...prev]; | ||
| // If the placeholder image also fails, set to empty string to prevent infinite loop | ||
| if (updated[index] === placeholderImg) { | ||
| updated[index] = ""; | ||
| return updated; | ||
|
|
@@ -85,23 +55,46 @@ export default function AuthorsCard({ authors }: AuthorsCardProps) { | |
| }); | ||
| }; | ||
|
|
||
| if (!authors || authors.length === 0) { | ||
| return <></>; | ||
| if (displayAuthors.length === 0) { | ||
| return null; | ||
| } | ||
|
|
||
| if (loading) { | ||
| return ( | ||
| <Card title="Authors"> | ||
| <div className="flex flex-row flex-wrap p-2"> | ||
| <span className="text-gray-400 text-sm">Loading authors...</span> | ||
| </div> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Card title="Authors"> | ||
| <div className="flex flex-row flex-wrap"> | ||
| {resolvedAuthors.map((author, index) => { | ||
| const title = author.title ?? "Unknown"; | ||
| {displayAuthors.map((author, index) => { | ||
| const title = author.name; | ||
| const imgSrc = imgSrcList[index]; | ||
|
|
||
| return ( | ||
| <div className="px-2 flex items-center my-2 justify-center" key={`author_${index}`}> | ||
| {/* @ts-expect-error tinacms types are wrong */} | ||
| <div className="w-12 h-12 overflow-hidden rounded-full relative" data-tina-field={tinaField(authors?.[index], "title")}> | ||
| <a href={author.url} target="_blank" rel="noopener noreferrer nofollow"> | ||
| {imgSrc?.trim() && ( | ||
| <div className="w-12 h-12 overflow-hidden rounded-full relative"> | ||
| {author.url ? ( | ||
| <a href={author.url} target="_blank" rel="noopener noreferrer nofollow"> | ||
| {imgSrc?.trim() && ( | ||
| <Image | ||
| src={imgSrc} | ||
| alt={title} | ||
| title={title} | ||
| fill | ||
| className="object-cover object-top" | ||
| onError={() => handleImageError(index)} | ||
| unoptimized | ||
| /> | ||
| )} | ||
| </a> | ||
| ) : ( | ||
| imgSrc?.trim() && ( | ||
| <Image | ||
| src={imgSrc} | ||
| alt={title} | ||
|
|
@@ -111,8 +104,8 @@ export default function AuthorsCard({ authors }: AuthorsCardProps) { | |
| onError={() => handleImageError(index)} | ||
| unoptimized | ||
| /> | ||
| )} | ||
| </a> | ||
| ) | ||
| )} | ||
| </div> | ||
| </div> | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
dynamic = "force-static"export combined withrevalidate = 3600may not work as expected. When you useforce-static, Next.js will try to statically generate this route at build time, but the route depends onpeople-index.jsonwhich is generated during the build process. If the file doesn't exist at build time, the route will return an empty object. Consider usingdynamic = "force-dynamic"or removing thedynamicexport to allow Next.js to handle caching appropriately with ISR (Incremental Static Regeneration) via therevalidatesetting.