Ligonier Code Challenge.
This commit is contained in:
commit
86fa02eec9
21 changed files with 4912 additions and 0 deletions
4
.dockerignore
Normal file
4
.dockerignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.DS_Store
|
||||
.env
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
87
README.md
Normal file
87
README.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
12
app/api/picsum.js
Normal file
12
app/api/picsum.js
Normal 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
21
app/app.css
Normal 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;
|
||||
}
|
||||
14
app/ligonier/control_bar.tsx
Normal file
14
app/ligonier/control_bar.tsx
Normal 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
15
app/ligonier/footer.tsx
Normal 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
14
app/ligonier/header.tsx
Normal 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
8
app/ligonier/logo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/ligonier/photo_card.tsx
Normal file
30
app/ligonier/photo_card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
app/ligonier/photo_gallery.tsx
Normal file
36
app/ligonier/photo_gallery.tsx
Normal 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
75
app/root.tsx
Normal 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
3
app/routes.ts
Normal 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
25
app/routes/home.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4467
package-lock.json
generated
Normal file
4467
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "ligonier-demo",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/node": "7.14.0",
|
||||
"@react-router/serve": "7.14.0",
|
||||
"isbot": "^5.1.36",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router": "7.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "7.14.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.3"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico.bak
Normal file
BIN
public/favicon.ico.bak
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
react-router.config.ts
Normal file
7
react-router.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: true,
|
||||
} satisfies Config;
|
||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue