Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/build-artifacts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,26 @@ jobs:
- name: Install dependencies
run: pip install pyyaml

# ────────────────────────────── NODE + PEOPLE INDEX ───────────────────
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.10.0

- name: Generate people index
run: |
pnpm install --frozen-lockfile
pnpm run generate:people
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# ────────────────────────────── CONTENT MAPPING FILES ─────────────────

- name: Generate rule‑category mapping files
working-directory: content-temp/scripts/tina-migration
run: python build-rule-category-map.py
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ rule-to-categories.json
orphaned_rules.json
archived-rules.json
redirects.json
people-index.json
public/uploads/people/
79 changes: 79 additions & 0 deletions app/api/people/route.ts
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";
Copy link

Copilot AI Feb 5, 2026

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 with revalidate = 3600 may not work as expected. When you use force-static, Next.js will try to statically generate this route at build time, but the route depends on people-index.json which is generated during the build process. If the file doesn't exist at build time, the route will return an empty object. Consider using dynamic = "force-dynamic" or removing the dynamic export to allow Next.js to handle caching appropriately with ISR (Incremental Static Regeneration) via the revalidate setting.

Suggested change
export const dynamic = "force-static";

Copilot uses AI. Check for mistakes.
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,
});
}
2 changes: 1 addition & 1 deletion app/api/rules/by-guid/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) {
title: node.title,
uri: node.uri,
body: node.body,
authors: node.authors?.map((a: any) => (a && a.title ? { title: a.title } : null)).filter((a: any): a is { title: string } => a !== null) || [],
authors: Array.isArray(node.authors) ? node.authors.map((a: any) => a?.author).filter(Boolean) : [],
}));

return NextResponse.json({ rules }, { status: 200 });
Expand Down
8 changes: 4 additions & 4 deletions app/api/tina/rules-by-author/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import client from "@/tina/__generated__/client";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const authorTitle = searchParams.get("authorTitle");
const authorSlug = searchParams.get("authorSlug");
const firstStr = searchParams.get("first");
const after = searchParams.get("after") || undefined;

if (!authorTitle) {
return NextResponse.json({ error: "authorTitle is required" }, { status: 400 });
if (!authorSlug) {
return NextResponse.json({ error: "authorSlug is required" }, { status: 400 });
}

const first = firstStr ? Number(firstStr) : undefined;

const result = await client.queries.rulesByAuthor({
authorTitle,
authorSlug,
first,
after,
});
Expand Down
25 changes: 15 additions & 10 deletions app/user/client-page.tsx
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",
Expand Down Expand Up @@ -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
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveAuthor function has a fallback slug generation that may not match the slug format used in the people index. The fallback profile.fullName?.toLowerCase().replace(/\s+/g, "-") is a simple conversion, but the actual slug generation in scripts/generate-people-index.js uses the nameToSlug function which also removes apostrophes and handles special characters differently. This inconsistency could lead to slug mismatches. Consider using a shared utility function for slug generation or ensuring the API response includes the correct slug.

Copilot uses AI. Check for mistakes.
};

useEffect(() => {
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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,
}));
Expand Down
127 changes: 60 additions & 67 deletions components/AuthorsCard.tsx
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
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imageUrl is already set to placeholderImg when building displayAuthors (line 33), so checking for it again in the effect (line 41) and when rendering (lines 83, 96) is redundant. You can simplify the logic since displayAuthors[i].imageUrl will never be falsy. This current implementation could cause confusion and unnecessary defensive checks.

Copilot uses AI. Check for mistakes.

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;
Expand All @@ -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}
Expand All @@ -111,8 +104,8 @@ export default function AuthorsCard({ authors }: AuthorsCardProps) {
onError={() => handleImageError(index)}
unoptimized
/>
)}
</a>
)
)}
</div>
</div>
);
Expand Down
Loading