Skip to content

Commit 1e441b2

Browse files
authored
Merge pull request #34 from codersforcauses/issue-5-Create_Resources_Page
Issue 5 create resources page
2 parents 43d91e3 + 0a09e04 commit 1e441b2

28 files changed

Lines changed: 763 additions & 3 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,4 +291,5 @@ dist
291291
# misc
292292
.DS_Store
293293

294-
opt/
294+
opt/
295+
server/media/

client/next.config.mjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ const isWindowsDevContainer = () =>
88
/** @type {import('next').NextConfig} */
99
const nextConfig = {
1010
reactStrictMode: true,
11+
images: {
12+
remotePatterns: [
13+
// Local development only. Replace this with the production media/CDN
14+
// hostname before deployment.
15+
{
16+
protocol: "http",
17+
hostname: "localhost",
18+
port: "8000",
19+
pathname: "/media/**",
20+
},
21+
],
22+
},
1123
// dumb fix for windows docker
1224
webpack: isWindowsDevContainer()
1325
? (config) => {
136 KB
Loading

client/src/hooks/apiService.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
2+
3+
import api from "@/lib/api";
4+
5+
export interface Resource {
6+
id: string;
7+
image: string | null;
8+
name: string;
9+
date_made: string;
10+
author: string;
11+
slug: string;
12+
summary: string;
13+
type: "page" | "file";
14+
body: string;
15+
file_url: string | null;
16+
file_name: string | null;
17+
}
18+
19+
export const getResources = (
20+
args?: Omit<UseQueryOptions<Resource[]>, "queryKey" | "queryFn">,
21+
) => {
22+
return useQuery<Resource[], Error>({
23+
...args,
24+
queryKey: ["resources"],
25+
queryFn: async () => {
26+
const res = await api.get("resources/collection/");
27+
return res.data as Resource[];
28+
},
29+
});
30+
// TODO: Error handling
31+
};
32+
33+
export async function fetchResourceFromSlug(slug: string): Promise<Resource[]> {
34+
const res = await api.get("resources/collection/", {
35+
params: {
36+
slug,
37+
},
38+
});
39+
40+
return res.data as Resource[];
41+
} //had to create to use in GetServerSideProps since it needs to be async since it runs outside react
42+
43+
export function getResourceFromSlug(slug: string) {
44+
return useQuery<Resource[], Error>({
45+
queryKey: ["resources", slug],
46+
queryFn: async () => {
47+
try {
48+
const res = await fetchResourceFromSlug(slug);
49+
return res as Resource[];
50+
} catch (error) {
51+
throw new Error("Failed to load"); //create instance of error
52+
}
53+
},
54+
enabled: !!slug,
55+
});
56+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { ArrowLeft, Calendar, FileText } from "lucide-react";
2+
import { GetServerSideProps } from "next";
3+
import Link from "next/link";
4+
5+
import { fetchResourceFromSlug, Resource } from "@/hooks/apiService";
6+
7+
//implement getPDFbySlug async function that will take a slug and return pdf url
8+
9+
interface Props {
10+
resource: Resource[] | null; //resource defined in api service
11+
}
12+
13+
const DEFAULT_PDF =
14+
"https://www.rd.usda.gov/sites/default/files/pdf-sample_0.pdf";
15+
16+
//used in pages router to fetch data for interface Props at request time
17+
export const getServerSideProps: GetServerSideProps<Props> = async ({
18+
params,
19+
}) => {
20+
const slug = params?.slug as string | undefined;
21+
22+
if (!slug) {
23+
return {
24+
notFound: true,
25+
};
26+
}
27+
try {
28+
const resource = await fetchResourceFromSlug(slug);
29+
30+
if (resource.length === 0) {
31+
return {
32+
notFound: true,
33+
};
34+
}
35+
36+
return {
37+
props: {
38+
resource,
39+
}, //returns props object that page component will recieve
40+
};
41+
} catch (error) {
42+
return {
43+
props: {
44+
resource: null,
45+
},
46+
};
47+
}
48+
};
49+
50+
function ResourceHeader({ resource }: { resource: Resource }) {
51+
return (
52+
<header className="mb-8">
53+
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
54+
<span className="inline-flex items-center gap-1.5">
55+
<Calendar size={16} />
56+
{resource.date_made}
57+
</span>
58+
<span className="inline-flex items-center gap-1.5 rounded-full bg-[#C000CC]/10 px-2.5 py-0.5 text-xs font-semibold capitalize text-[#C000CC]">
59+
<FileText size={13} />
60+
{resource.type}
61+
</span>
62+
</div>
63+
64+
<h1 className="mt-3 text-3xl font-bold tracking-tight text-foreground sm:text-4xl">
65+
{resource.name}
66+
</h1>
67+
68+
{resource.author ? (
69+
<p className="mt-2 text-sm text-muted-foreground">
70+
By {resource.author}
71+
</p>
72+
) : null}
73+
74+
<div className="mt-5 h-1 w-16 rounded-full bg-[#C000CC]" />
75+
76+
{resource.summary ? (
77+
<p className="mt-5 max-w-3xl text-base leading-relaxed text-muted-foreground">
78+
{resource.summary}
79+
</p>
80+
) : null}
81+
</header>
82+
);
83+
}
84+
85+
function ResourceContent({ resource }: { resource: Resource[] | null }) {
86+
if (!resource) {
87+
return (
88+
<embed
89+
src={DEFAULT_PDF}
90+
type="application/pdf"
91+
className="h-[85vh] w-full"
92+
/>
93+
);
94+
}
95+
if (resource[0].type == "file" && resource[0].file_url != null) {
96+
return (
97+
<iframe
98+
src={resource[0].file_url}
99+
title="PDF Viewer"
100+
className="h-[85vh] w-full rounded-lg border border-border/60"
101+
/>
102+
);
103+
}
104+
if (resource[0].type == "page") {
105+
return (
106+
<article className="whitespace-pre-line py-8 text-base leading-relaxed text-foreground">
107+
{resource[0].body}
108+
</article>
109+
);
110+
}
111+
return (
112+
<div className="px-6 py-8 text-muted-foreground">
113+
Unsupported resource type
114+
</div>
115+
);
116+
}
117+
118+
export default function PDFViewer({ resource }: Props) {
119+
return (
120+
<main className="min-h-screen bg-muted/30 pb-12 pt-8">
121+
<div className="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
122+
<Link
123+
href="/resources"
124+
className="group mb-6 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-[#C000CC] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
125+
>
126+
<ArrowLeft
127+
size={16}
128+
className="transition-transform group-hover:-translate-x-0.5"
129+
/>
130+
Back to Resources
131+
</Link>
132+
133+
{resource ? <ResourceHeader resource={resource[0]} /> : null}
134+
135+
<ResourceContent resource={resource} />
136+
</div>
137+
</main>
138+
);
139+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {
2+
ChevronLeft,
3+
ChevronRight,
4+
ChevronsLeft,
5+
ChevronsRight,
6+
Search,
7+
} from "lucide-react";
8+
import { useState } from "react";
9+
10+
import { ContentCard } from "@/components/content-card";
11+
import { getResources, Resource } from "@/hooks/apiService";
12+
13+
type Direction = "double_left" | "left" | "double_right" | "right";
14+
15+
function ArrowButton({ direction }: { direction: Direction }) {
16+
const buttonClass =
17+
"flex items-center justify-center w-8 h-8 bg-white border border-[#F1F1F1] rounded-lg";
18+
19+
const iconClass = "h-4 w-4 text-[#CCCCCC]";
20+
21+
const icons = {
22+
double_left: <ChevronsLeft className={iconClass} />,
23+
left: <ChevronLeft className={iconClass} />,
24+
double_right: <ChevronsRight className={iconClass} />,
25+
right: <ChevronRight className={iconClass} />,
26+
};
27+
28+
return <button className={buttonClass}> {icons[direction]} </button>;
29+
}
30+
31+
function ResourceCard({ item }: { item: Resource }) {
32+
const href = `/resources/${item.slug}`;
33+
34+
// TODO: Use the horizontal card component for the mobile layout once it is available.
35+
return (
36+
<ContentCard
37+
title={item.name}
38+
description={item.summary}
39+
author={item.author}
40+
dateTime={item.date_made}
41+
href={href}
42+
imageSrc={item.image || "/default-resource-img.jpg"}
43+
imageAlt={item.name}
44+
buttonLabel="View"
45+
className="min-h-[28rem] w-full max-w-none [&_h3]:text-xl [&_p]:text-sm"
46+
/>
47+
);
48+
}
49+
50+
function ResourcesCards({ searchTerm }: { searchTerm: string }) {
51+
const { data, isLoading } = getResources();
52+
53+
if (isLoading || data === undefined) return <p>Loading...</p>;
54+
55+
const query = searchTerm.trim().toLowerCase();
56+
57+
const filteredResources = data.filter((item) => {
58+
return item.name.toLowerCase().includes(query);
59+
});
60+
61+
if (filteredResources.length === 0) {
62+
return <p>No resources match your search.</p>;
63+
}
64+
65+
return filteredResources.map((item) => (
66+
<ResourceCard key={item.id} item={item} />
67+
));
68+
}
69+
70+
export default function Resources() {
71+
const [searchTerm, setSearchTerm] = useState("");
72+
73+
return (
74+
<main>
75+
<div className="mx-auto flex w-full max-w-[90rem] flex-col gap-8 px-4 pt-6 sm:px-8 lg:px-16 lg:pt-8">
76+
{/* Title + Search */}
77+
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
78+
<h1 className="text-2xl font-bold">Resources</h1>
79+
80+
{/* Search Box */}
81+
<div className="relative w-full md:w-auto">
82+
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
83+
<input
84+
type="text"
85+
placeholder="Search..."
86+
value={searchTerm}
87+
onChange={(event) => setSearchTerm(event.target.value)}
88+
className="w-full rounded-lg border border-[#F1F1F1] py-2 pl-9 pr-4 md:w-72"
89+
/>
90+
</div>
91+
</div>
92+
{/* Cards */}
93+
<div className="md grid w-full justify-items-center gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
94+
<ResourcesCards searchTerm={searchTerm} />
95+
</div>
96+
</div>
97+
</main>
98+
);
99+
}

server/api/resources/__init__.py

Whitespace-only changes.

server/api/resources/admin.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.contrib import admin
2+
from .models import Resource
3+
4+
5+
# Register your models here.
6+
admin.site.register(Resource)

server/api/resources/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ResourcesConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "api.resources"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 5.1.14 on 2026-06-22 07:52
2+
3+
import uuid
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = []
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="Resource",
16+
fields=[
17+
(
18+
"id",
19+
models.UUIDField(
20+
default=uuid.uuid4,
21+
editable=False,
22+
primary_key=True,
23+
serialize=False,
24+
),
25+
),
26+
("name", models.CharField(max_length=255)),
27+
("slug", models.SlugField(max_length=255, unique=True)),
28+
("summary", models.TextField(blank=True)),
29+
(
30+
"type",
31+
models.CharField(
32+
choices=[("page", "Page"), ("file", "File")], max_length=10
33+
),
34+
),
35+
("body", models.TextField(blank=True)),
36+
("file_url", models.URLField(blank=True)),
37+
("file_name", models.CharField(blank=True, max_length=255)),
38+
],
39+
),
40+
]

0 commit comments

Comments
 (0)