Ligonier Code Challenge.

This commit is contained in:
bnilsen 2026-05-07 15:36:37 -04:00
commit 86fa02eec9
21 changed files with 4912 additions and 0 deletions

12
app/api/picsum.js Normal file
View file

@ -0,0 +1,12 @@
export async function getPicsumData() {
try {
const response = await fetch('https://picsum.photos/v2/list');
if (!response.ok) {
throw new Error('Picsum API unavailable.');
}
const data = await response.json();
return data;
} catch (error) {
console.error("Could not fetch data:", error);
}
}

21
app/app.css Normal file
View file

@ -0,0 +1,21 @@
@import url('https://googleapis.com');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-libre: "Libre Baskerville", serif;
}
html,
body {
font-size: 18px;
@apply bg-[#faf7f2]}
h1 {
font-size: 24px;
}
.logo {
fill: #364B00;
}

View file

@ -0,0 +1,14 @@
import { useState } from 'react';
export function ControlBar({ isGallery, handleClick }) {
return(
<div className="hidden md:flex ml-auto">
<button className="hover:bg-[#839b39] bg-[#789031] text-white p-2 rounded-lg mr-20 xl:mr-50 min-w-[130px]"
onClick={handleClick}
>
{isGallery ? 'List' : 'Gallery'} View
</button>
</div>
);
}

15
app/ligonier/footer.tsx Normal file
View file

@ -0,0 +1,15 @@
export function Footer() {
return (
<div className="flex justify-center">
<div className="ml-10 mr-10 mb-5 md:ml-10 md:mr-10 flex flex-col max-w-[600px]">
<strong className="font-libre">2 Corinthians 3:18</strong>
<p className="font-libre">
And we all, with unveiled face, beholding the glory of the Lord, are being transformed into the same image from one degree of glory to another. For this comes from the Lord who is the Spirit.
</p>
<strong className="font-libre mt-2 w-full text-center">
<em>Soli Deo Gloria</em>
</strong>
</div>
</div>
);
}

14
app/ligonier/header.tsx Normal file
View file

@ -0,0 +1,14 @@
import { Logo } from "./logo";
import { ControlBar } from "./control_bar"
export function Header({ isGallery, handleClick }) {
return (
<div className="header flex flex-row m-5 mr-10 md:mr-0 ml-10 md:ml-20 xl:ml-50 gap-5 items-center">
<a href="https://www.ligonier.org">
<Logo />
</a>
<h1 className="font-libre">Ligonier Code Challenge - Ben Nilsen</h1>
<ControlBar isGallery={isGallery} handleClick={handleClick} />
</div>
);
}

8
app/ligonier/logo.tsx Normal file
View file

@ -0,0 +1,8 @@
export function Logo() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" data-testid="icon-24-logo" role="img" aria-label="image-icon" fill="transparent" className="logo block fill-body-copy transition-colors group-hover:fill-cool-copy">
<path fillRule="evenodd" d="M19.847 8.32c-2.354-3.467-5.173-6.317-7.61-7.233L12 1l-.239.087c-2.435.916-5.255 3.766-7.608 7.232C1.823 11.793.013 15.86 0 19.34c.004.741.432 1.325.95 1.75 1.57 1.26 4.464 1.889 6.767 1.91.473 0 .92-.03 1.33-.096 1.433-.243 2.547-.603 2.953-.585.407-.018 1.52.342 2.95.585.412.066.859.096 1.332.096a14.713 14.713 0 0 0 4.827-.861c.747-.28 1.414-.615 1.941-1.05.517-.425.945-1.009.95-1.75-.013-3.48-1.823-7.546-4.153-11.02zm-7.684-.638c-.1-.006-.235-.19-.235-.19l.078-1.034c.061-.804-.013-1.916.058-1.843.2.208.438 1.45.415 1.806-.023.357-.124 1.247-.316 1.26zm9.998 12.31c-1.03.891-3.872 1.612-5.879 1.59-.413 0-.793-.027-1.105-.078a13.74 13.74 0 0 1-.688-.13c-.235-.058-.904-.2-1.298-.771-.259-.375-.522-1.532.07-2.002.952-.758 1.89.145 3.053.013 2.791-.316 2.753-2.055 4.725-3.18-3.737-.992-6.825.257-7.523 1.654-.6 1.198-.913.706-.906.303l-.002-.944c0-.414.012-1.235.697-1.712.715-.501.89-.136 2.367-.254 2.128-.171 2.412-2.084 3.638-2.884-1.213.078-4.517-1.334-6.135 2.127-.106.227-.472.494-.602.741-.007-1.328-.06-1.733-.001-2.277.035-.336.27-1.18.676-1.254.32-.056 1.024-.111 1.398-.194.995-.221 2.167-1.365 2.108-2.952-.878.364-3.086-.322-3.987 2.348-.088.191-.23.6-.307.823.038-.35-.157-2.087-.057-2.777.085-.088 1.536-1.277.539-3.097-.426-.774-.95-1.7-.95-1.7s-.523.926-.947 1.7c-.998 1.82.462 3.018.547 3.105 0 0-.011 2.177-.039 2.35-.094.622-.379-.974-.794-1.507-.497-.642-1.002-.993-1.96-1.114-.565-.07-.627.011-1.589-.13 1.061 3.496 2.75 2.87 3.675 3.079.396.09.59 1.326.59 1.326.029-.222-.01 2.1.005 2.108-.721-.419-1.527-3.06-3.678-2.91-1.608.111-2.852-.012-2.946-.065 1.087 1.924 1.852 3.598 5.36 3.106.693-.099 1.131 1.02 1.14 1.255 0 0 .05 1.015-.027 1.74-.073.695-.492.104-.92-.342-.428-.447-2.041-2.152-4.802-1.74-1.538.23-1.338.16-2.645-.021.592.787 2.323 4.303 5.53 2.952 1.815-.764 3.089.434 2.392 2.194-.142.358-.627.703-1.405.91-.21.044-.425.086-.656.123a7.19 7.19 0 0 1-1.106.079c-1.342.001-3.045-.29-4.343-.775-.65-.239-1.196-.532-1.536-.815-.35-.293-.44-.517-.436-.654-.012-2.974 1.67-6.91 3.91-10.222C7.436 5.937 10.125 3.31 12 2.514c1.875.796 4.563 3.423 6.687 6.603 2.242 3.311 3.922 7.248 3.91 10.222.004.137-.086.36-.436.653zm-7.696-2.847c.002-.001.988-1.56 3.894-1.524.17.001.556.042.736.061 0 0 .041.053-.157.073-.865.117-2.852.94-3.313 1.151-.328.145-.938.426-1.146.43-.042 0-.059-.076-.014-.19zm3.152-5.107c-.129.196-2.282.576-3.699 1.659 0 0-.112-.07-.083-.137.946-1.777 3.24-1.542 3.782-1.522zm-4.239-1.926c.295-1.069 1.543-1.55 2.455-1.649l.17-.018c-.243.453-1.684.852-2.534 1.783 0 0-.113-.033-.09-.116zm-2.761-.17c0 .099-.122.08-.122.08s-1.153-.9-2.278-1.635c-.055-.022-.05-.068-.05-.068 1.355.135 2.339.934 2.45 1.623zm-.369 3.576c0 .054-.098.069-.098.069-.109-.008-.995-.49-1.713-.842-.883-.431-1.881-.69-1.881-.69 1.817-.203 2.94.29 3.58 1.233.068.104.104.153.112.23zm-5.324 2.588c.008-.059 1.086-.22 1.483-.22 2.117 0 3.16 1.2 3.181 1.29 0 .052-.08.08-.088.08-.247-.007-2.71-1.002-4.51-1.108 0 0-.064-.024-.066-.042z">
</path>
</svg>
);
}

View file

@ -0,0 +1,30 @@
import { useState } from 'react';
// A Card, rendering an image and toggling photographer information when clicked
export function PhotoCard({ data }) {
const [infoToggled, setInfoToggled] = useState(false);
function handleClick() {
setInfoToggled(!infoToggled);
}
if (data["id"] == 1) {
console.log(data);
}
return (
<div>
<div className="relative flex justify-center">
<div onClick={handleClick} className="overflow-hidden rounded-2xl">
<img className={`transition-blur duration-500 ease-in-out ${infoToggled ? 'blur-sm' : 'blur-none'}`}
src={data["download_url"]}
/>
</div>
<p className={`font-libre absolute self-center text-center bg-mist-500/70 p-2 rounded-lg text-white transition-opacity duration-500 ease-in-out ${infoToggled ? 'opacity-100' : 'opacity-0'}`}
onClick={handleClick}
>
Photographer: {data['author']}
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,36 @@
import { useLoaderData } from 'react-router';
import { useState } from 'react';
import { Header } from './header';
import { Footer } from './footer';
import { ControlBar } from './control_bar';
import { PhotoCard } from './photo_card';
export function PhotoGallery({ numColumns }) {
const imageData = useLoaderData();
const [isGallery, setGallery] = useState(false);
return (
<div>
<Header isGallery={isGallery} handleClick={()=> setGallery(!isGallery)} />
<Photos
imageData={imageData}
isGallery={isGallery}
/>
<Footer />
</div>
);
}
function Photos({ imageData, isGallery}) {
const listClassNames = "flex flex-wrap"
const galleryClassNames = "grid md:grid-cols-2 xl:grid-cols-3"
const photoCards = imageData.map(image =>
<PhotoCard
key={image.id}
data={image}
/>);
return (
<div className={`${isGallery ? galleryClassNames : listClassNames} md:m-20 md:mt-10 m-10 gap-10 mt-10 xl:ml-50 xl:mr-50`}>
{photoCards}
</div>
);
}

75
app/root.tsx Normal file
View file

@ -0,0 +1,75 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

3
app/routes.ts Normal file
View file

@ -0,0 +1,3 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;

25
app/routes/home.tsx Normal file
View file

@ -0,0 +1,25 @@
import type { Route } from "./+types/home";
import { getPicsumData } from "../api/picsum";
import { PhotoGallery } from "../ligonier/photo_gallery";
export function meta({}: Route.MetaArgs) {
return [
{ title: "Ligonier Code Challenge" },
{ name: "description", content: "Welcome to React Router!" },
];
}
export async function loader(
{request,
}: Route.LoaderArgs) {
const data = await getPicsumData();
return data;
}
export default function Home() {
return (
<PhotoGallery
numColumns={5}
/>
);
}