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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ npm run test:smoke

## Docker lifecycle

Start local services with `npm run docker:up` and stop them with `npm run docker:down`. This retains named volumes. The smoke test uses a separate Compose project and removes its volumes automatically. Run `docker compose down --volumes` only when intentionally resetting local PostgreSQL and AIStor data. Start the profile-gated application container with `docker compose --profile app up --build`.
Start local services with `npm run docker:up`, apply migrations with `npm run prisma:deploy`, and optionally add local sample records with `npm run db:seed:examples`. Stop services with `npm run docker:down`; this retains named volumes. The example seed is limited to local database hosts. The smoke test uses a separate Compose project and removes its volumes automatically. Run `docker compose down --volumes` only when intentionally resetting local PostgreSQL and AIStor data. Start the profile-gated application container with `docker compose --profile app up --build`.

## Change checklist

Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,22 @@ This repository contains the development foundation and initial App Router shell
npm run docker:up
```

5. Run the available local checks.
5. Apply the local database migrations and, if desired, insert three repeatable
example bounties with local SVG pictures.

```sh
npm run prisma:deploy
npm run db:seed:examples
```

6. Run the available local checks.

```sh
npm run verify
npm run test:smoke
```

6. Stop services when finished. `docker:down` preserves local volumes; use `docker compose down --volumes` only when you intentionally want to discard local database and object-storage data.
7. Stop services when finished. `docker:down` preserves local volumes; use `docker compose down --volumes` only when you intentionally want to discard local database and object-storage data.

```sh
npm run docker:down
Expand All @@ -62,13 +70,15 @@ To install dependencies, start local services, and launch the development server

## Database migrations

Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. The initial account migration creates the `User` table with a unique email address and password hash. Once a model is approved, create and review a development migration with:
Prisma is configured for PostgreSQL and reads `DATABASE_URL` from `.env`. The migrations create the account and bounty tables, including an optional picture reference on each bounty. Create and review a development migration with:

```sh
npm run prisma:migrate -- --name describe_the_change
```

Use `npm run prisma:deploy` only in a controlled deployment workflow against an approved target—never against production from a workstation.
Use `npm run prisma:deploy` to apply reviewed migrations to the local development database. For a remote deployment, run it only in a controlled workflow against the approved target—never against production from a workstation.

`npm run db:seed:examples` inserts or refreshes three local example bounties and a demo poster. It refuses database hosts other than localhost or the local Compose service, and can be rerun without duplicating the examples.

## CI and GitHub configuration

Expand Down
53 changes: 38 additions & 15 deletions app/posts/PostsList.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import styles from './posts.module.css'
import Image from 'next/image'

export type PostPreview = {
id: string
title: string
location: string
}
import type { PostCardData } from './post-data'
import styles from './posts.module.css'

type PostsListProps = {
posts: PostPreview[]
posts: PostCardData[]
}

export function PostsList({ posts }: PostsListProps) {
if (posts.length === 0) {
return (
<p className={styles.emptyState} role="status">
No posts
No tasks are available right now.
</p>
)
}
Expand All @@ -23,13 +20,39 @@ export function PostsList({ posts }: PostsListProps) {
<ul className={styles.postsList} aria-label="Posts">
{posts.map((post) => (
<li className={styles.postCard} key={post.id}>
<div>
<h2>{post.title}</h2>
<p>{post.location}</p>
</div>
<button disabled type="button">
View details
</button>
<article className={styles.postContent}>
{post.imageUrl ? (
<div className={styles.postImage}>
<Image
alt={`${post.title} community task`}
className={styles.postImageAsset}
fill
unoptimized
sizes="(min-width: 48rem) 36vw, 100vw"
src={post.imageUrl}
/>
</div>
) : (
<div
aria-hidden="true"
className={`${styles.postImage} ${styles.postImageFallback}`}
>
<span>Community task</span>
</div>
)}
<div className={styles.postInfo}>
<h2>{post.title}</h2>
<p>{post.location}</p>
</div>
<button
aria-label={`More details about ${post.title} (coming soon)`}
className={styles.detailsButton}
disabled
type="button"
>
More details
</button>
</article>
</li>
))}
</ul>
Expand Down
8 changes: 5 additions & 3 deletions app/posts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { PostsList } from './PostsList'
import { samplePosts } from './post-data'
import { getPublicPostCards } from './post-data'
import styles from './posts.module.css'

export default function PostsPage() {
export default async function PostsPage() {
const posts = await getPublicPostCards()

return (
<main className={styles.postsPage}>
<section className={styles.postsBoard} aria-labelledby="posts-title">
Expand All @@ -18,7 +20,7 @@ export default function PostsPage() {
</button>
</div>
<div className={styles.listViewport}>
<PostsList posts={samplePosts} />
<PostsList posts={posts} />
</div>
</section>
</main>
Expand Down
111 changes: 89 additions & 22 deletions app/posts/post-data.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,91 @@
import type { PostPreview } from './PostsList'
export type PostListingRecord = {
id: string
title: string
location: string
imageUrl: string | null
maximumVolunteers: number
currentVolunteers: number
}

export const samplePosts: PostPreview[] = [
{
id: 'cedar-grove-cleanup',
title: 'Clean up Cedar Grove',
location: 'Chico, CA',
},
{
id: 'food-pantry-sorting',
title: 'Sort food pantry donations',
location: 'Downtown Chico, CA',
},
{
id: 'community-garden',
title: 'Water the community garden',
location: 'South Chico, CA',
},
{
id: 'trailhead-tidy',
title: 'Tidy the trailhead',
location: 'Upper Park, Chico, CA',
export type PostCardData = Pick<
PostListingRecord,
'id' | 'title' | 'location' | 'imageUrl'
>

export interface PostListingSource {
listPublicPosts(): Promise<PostListingRecord[]>
}

const databaseSource: PostListingSource = {
async listPublicPosts() {
const { prisma } = await import('../../lib/prisma')
const bounties = await prisma.bounty.findMany({
where: {
status: 'OPEN',
deadline: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
locationLabel: true,
imageUrl: true,
maxVolunteers: true,
_count: {
select: {
volunteerAttendances: {
where: { status: 'JOINED' },
},
},
},
},
})

return bounties.map((bounty) => ({
id: bounty.id,
title: bounty.title,
location: bounty.locationLabel,
imageUrl: bounty.imageUrl,
maximumVolunteers: bounty.maxVolunteers,
currentVolunteers: bounty._count.volunteerAttendances,
}))
},
]
}

/**
* Keep the database query on the server and return only fields needed by a
* public post card.
*/
export async function getPublicPostCards(
source: PostListingSource = databaseSource,
): Promise<PostCardData[]> {
const records = await source.listPublicPosts()

return records
.filter(
(record) =>
Number.isInteger(record.maximumVolunteers) &&
record.maximumVolunteers > 0 &&
Number.isInteger(record.currentVolunteers) &&
record.currentVolunteers >= 0 &&
record.currentVolunteers < record.maximumVolunteers,
)
.map(({ id, title, location, imageUrl }) => ({
id,
title,
location,
imageUrl: getSafeImageUrl(imageUrl),
}))
}

function getSafeImageUrl(imageUrl: string | null): string | null {
if (!imageUrl) return null
if (imageUrl.startsWith('/') && !imageUrl.startsWith('//')) return imageUrl

try {
const parsedImageUrl = new URL(imageUrl)
return parsedImageUrl.protocol === 'https:' ? imageUrl : null
} catch {
return null
}
}
71 changes: 53 additions & 18 deletions app/posts/posts.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,52 +44,82 @@
}

.listViewport {
height: clamp(19rem, 58dvh, 30rem);
overflow-y: auto;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
scrollbar-color: var(--accent) var(--canvas);
}

.postsList {
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
margin: 0;
padding: 0.75rem 0;
padding: 1rem 0 0;
list-style: none;
}

.postCard {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
min-height: 7.5rem;
padding: 1rem;
min-width: 0;
border: 1px solid var(--ink);
background: #fffefa;
}

.postContent {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100%;
}

.postImage {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
background: var(--accent-soft);
}

.postImageAsset {
object-fit: cover;
}

.postImageFallback {
display: grid;
place-items: center;
color: var(--muted);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
}

.postInfo {
padding: 1rem 1rem 0.75rem;
}

.postCard h2 {
margin: 0;
font-size: clamp(1.25rem, 4vw, 1.75rem);
font-size: clamp(1.25rem, 3vw, 1.75rem);
}

.postCard p {
margin-top: 0.5rem;
color: var(--muted);
}

.postCard button {
flex: 0 0 auto;
.detailsButton {
justify-self: start;
min-height: 2.75rem;
margin: 0 1rem 1rem;
padding: 0.5rem 0.75rem;
white-space: nowrap;
border: 1px solid var(--ink);
border-radius: 0;
color: var(--ink);
background: var(--canvas);
font: inherit;
font-weight: 700;
cursor: not-allowed;
opacity: 0.75;
}

.emptyState {
display: grid;
height: 100%;
margin: 0;
min-height: 12rem;
margin: 1rem 0 0;
place-items: center;
color: var(--muted);
}
Expand All @@ -102,4 +132,9 @@
.postsBoard {
padding: 1.5rem;
}

.postsList {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
}
Loading
Loading