From 78cd05f9b7cef0ec2ef951746d39d16ba0a36fc2 Mon Sep 17 00:00:00 2001 From: Jack Hall Date: Sun, 29 Mar 2026 09:38:13 -0500 Subject: [PATCH 1/2] fix: enable ip_forward in startup sysctl config GCE's hardening sysctl (60-gce-network-security.conf) explicitly sets net.ipv4.ip_forward=0, which blocks container port forwarding after VM restart. Rename sysctl config to 99-cscs-podman.conf so it loads last and overrides the GCE default. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/startup.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/startup.sh b/backend/startup.sh index cc3ea8c..0c96e69 100644 --- a/backend/startup.sh +++ b/backend/startup.sh @@ -41,7 +41,10 @@ export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y podman -echo "net.ipv4.ip_unprivileged_port_start=80" > /etc/sysctl.d/ports.conf +cat > /etc/sysctl.d/99-cscs-podman.conf < Date: Mon, 30 Mar 2026 08:17:37 -0500 Subject: [PATCH 2/2] feat: dynamic book club page backed by PocketBase Replace the static book club page with a dynamic React component that fetches books from a new PocketBase `books` collection. Moderators can manage books through a new admin UI at /app/books. - Add `books` collection migration with cover image, status, and metadata fields - Add optional book relation to events collection for linking book-club events - Add BookClubPage component with current book, completed books grid, and cover images - Add BookForm with file upload support for cover images - Add moderator book management pages (/app/books, /app/create-book) - Add Books sidebar item in AppLayout for moderators - Replace hardcoded current book on schedule page with dynamic CurrentBookBadge - Add comprehensive tests (23 new, 80 total passing) - Run Prettier formatting across codebase Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/work-on-issue/SKILL.md | 39 +- AUDIT.md | 30 +- AUTHENTICATION.md | 3 + backend/MIGRATIONS.md | 35 +- backend/RBAC_SETUP.md | 21 +- .../1764523465_created_events.js | 237 ++++++------- .../1764526176_updated_events.js | 333 ++++++++++-------- .../pb_migrations/1764526671_updated_users.js | 51 +-- .../1764527567_updated_events.js | 58 +-- .../pb_migrations/1764527610_updated_users.js | 77 ++-- .../pb_migrations/1764528168_updated_users.js | 45 ++- .../pb_migrations/1774800965_created_books.js | 180 ++++++++++ ...1774800966_updated_events_book_relation.js | 30 ++ doc/adr/README.md | 24 +- podman-compose.yaml | 9 +- src/components/AccountDashboard.tsx | 48 +-- src/components/AppLayout.tsx | 31 +- src/components/BookClubPage.test.tsx | 221 ++++++++++++ src/components/BookClubPage.tsx | 273 ++++++++++++++ src/components/BookForm.test.tsx | 225 ++++++++++++ src/components/BookForm.tsx | 257 ++++++++++++++ src/components/BookFormWrapper.tsx | 45 +++ src/components/CurrentBookBadge.tsx | 49 +++ src/components/EventFormWrapper.tsx | 18 +- src/components/Header.tsx | 4 +- src/components/LoginForm.tsx | 32 +- src/components/RegisterForm.tsx | 44 +-- src/components/catalyst/navbar.tsx | 113 ++++-- src/components/catalyst/sidebar-layout.tsx | 34 +- src/components/catalyst/sidebar.tsx | 165 ++++++--- src/components/catalyst/table.tsx | 144 +++++--- src/lib/pocketbase.ts | 59 ++++ src/pages/account.astro | 12 +- src/pages/app/books.astro | 217 ++++++++++++ src/pages/app/create-book.astro | 14 + src/pages/app/create-event.astro | 10 +- src/pages/app/dashboard.astro | 63 ++-- src/pages/app/index.astro | 6 +- src/pages/book-club.astro | 198 +---------- src/pages/login.astro | 31 +- src/pages/register.astro | 31 +- src/pages/schedule.astro | 8 +- src/pages/verify-email.astro | 49 ++- src/stores/authStore.ts | 11 +- src/test/mocks/factories.ts | 52 +++ src/test/mocks/index.ts | 3 + src/test/mocks/pocketbase.ts | 26 +- 47 files changed, 2736 insertions(+), 929 deletions(-) create mode 100644 backend/pb_migrations/1774800965_created_books.js create mode 100644 backend/pb_migrations/1774800966_updated_events_book_relation.js create mode 100644 src/components/BookClubPage.test.tsx create mode 100644 src/components/BookClubPage.tsx create mode 100644 src/components/BookForm.test.tsx create mode 100644 src/components/BookForm.tsx create mode 100644 src/components/BookFormWrapper.tsx create mode 100644 src/components/CurrentBookBadge.tsx create mode 100644 src/pages/app/books.astro create mode 100644 src/pages/app/create-book.astro diff --git a/.claude/skills/work-on-issue/SKILL.md b/.claude/skills/work-on-issue/SKILL.md index cb28dc2..4c9f86e 100644 --- a/.claude/skills/work-on-issue/SKILL.md +++ b/.claude/skills/work-on-issue/SKILL.md @@ -109,11 +109,13 @@ Use the appropriate type: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `c ```markdown ## Summary + - <1-3 bullet points describing the changes> Closes #$ARGUMENTS ## Test plan + - [ ] `npm run build` passes - [ ] `npm run lint` passes - [ ] Manual verification of @@ -130,6 +132,7 @@ Generated with [Claude Code](https://claude.com/claude-code) After the PR is created, wait for CI checks to complete and fix any failures. 1. Watch for CI completion: + ``` gh pr checks --repo The-Read-Onlys/cscs.dev --watch ``` @@ -138,13 +141,17 @@ After the PR is created, wait for CI checks to complete and fix any failures. 3. **If any check fails** (max 3 fix attempts): a. Identify failures: - ``` - gh pr checks --repo The-Read-Onlys/cscs.dev --json name,state,bucket - ``` + + ``` + gh pr checks --repo The-Read-Onlys/cscs.dev --json name,state,bucket + ``` + b. Get failure logs: - ``` - gh run view --repo The-Read-Onlys/cscs.dev --log-failed - ``` + + ``` + gh run view --repo The-Read-Onlys/cscs.dev --log-failed + ``` + c. Fix the failures in the worktree. d. Re-run Step 6 (Verify) locally to confirm the fix. e. Stage specific files, commit with a message like `fix: resolve CI failure in `, and push. @@ -165,13 +172,13 @@ Ask the user: "Should I close issue #$ARGUMENTS now, or let the PR merge close i ## Error Handling -| Scenario | Action | -|---|---| -| Issue not found | Stop immediately, tell the user | -| Worktree name conflict | Ask user for alternative name | -| Build fails | Fix errors, re-run build | -| Lint fails | Fix errors, re-run lint | -| Push rejected | Pull with rebase, resolve conflicts, push again | -| PR already exists for branch | Show existing PR URL, ask user how to proceed | -| CI check fails | Inspect logs, fix errors, push fix, re-monitor (max 3 attempts) | -| CI monitoring timeout | Tell user to check CI manually, provide PR link | +| Scenario | Action | +| ---------------------------- | --------------------------------------------------------------- | +| Issue not found | Stop immediately, tell the user | +| Worktree name conflict | Ask user for alternative name | +| Build fails | Fix errors, re-run build | +| Lint fails | Fix errors, re-run lint | +| Push rejected | Pull with rebase, resolve conflicts, push again | +| PR already exists for branch | Show existing PR URL, ask user how to proceed | +| CI check fails | Inspect logs, fix errors, push fix, re-monitor (max 3 attempts) | +| CI monitoring timeout | Tell user to check CI manually, provide PR link | diff --git a/AUDIT.md b/AUDIT.md index e329766..70399b6 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -12,14 +12,14 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ## Project Stats -| Metric | Count | -|--------|-------| -| Pages | 13 (5 public, 8 authenticated/app) | -| React Components | 12 custom + 28 Catalyst UI Kit | -| Blog Posts | 3 published | -| Test Files | 2 (EventForm: 50+ tests, PocketBase RSVP) | -| NPM Scripts | 13 | -| CI Pipeline Steps | 6 (lint, format, test, build, storybook) | +| Metric | Count | +| ----------------- | ----------------------------------------- | +| Pages | 13 (5 public, 8 authenticated/app) | +| React Components | 12 custom + 28 Catalyst UI Kit | +| Blog Posts | 3 published | +| Test Files | 2 (EventForm: 50+ tests, PocketBase RSVP) | +| NPM Scripts | 13 | +| CI Pipeline Steps | 6 (lint, format, test, build, storybook) | --- @@ -43,16 +43,19 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### HIGH — Broken Links / Placeholder Pages #### 1. Footer "About" link is a placeholder + - **File**: `src/components/Footer.tsx:5` - **Issue**: `href: "#"` — navigates nowhere - **Fix**: Create `/about` page, update link #### 2. Footer "Contact" link is a placeholder + - **File**: `src/components/Footer.tsx:9` - **Issue**: `href: "#"` — navigates nowhere - **Fix**: Create `/contact` page, update link #### 3. Newsletter "privacy policy" link is a placeholder + - **File**: `src/components/Newsletter.tsx:92` - **Issue**: `href="#"` — text says "Read our privacy policy" but links nowhere - **Fix**: Create `/privacy` page, update link. Especially important since the site collects emails and has user registration. @@ -60,6 +63,7 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### HIGH — Functionality Gaps #### 4. Newsletter form may not work in production + - **File**: `src/components/Newsletter.tsx:67` - **Issue**: Uses `data-netlify="true"` for Netlify Forms, but the site infrastructure uses Podman/PocketBase — no evidence of Netlify deployment. Form submissions may silently fail. - **Fix**: Integrate with PocketBase (newsletter collection) or a third-party email service. Verify form submissions are actually captured. @@ -67,21 +71,25 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### MEDIUM — Code Quality #### 5. Hero images lack proper decorative attributes + - **File**: `src/components/Hero.tsx:132,142,150,160,168` - **Issue**: 5 Unsplash images have `alt=""` but no `role="presentation"` or `aria-hidden="true"` to explicitly mark as decorative. - **Fix**: Add `role="presentation" aria-hidden="true"` to each decorative image. #### 6. Copyright year is hardcoded + - **File**: `src/components/Footer.tsx:90` - **Issue**: `© 2025 College Station Computer Science` — will be stale in future years - **Fix**: Use `{new Date().getFullYear()}` for dynamic year #### 7. Catalyst link component TODO not resolved + - **File**: `src/components/catalyst/link.tsx:2` - **Issue**: `// TODO: Update this component to use your client-side framework's link` - **Fix**: Since this is a static Astro site, remove the TODO or document that `` tags are the correct approach. #### 8. Field naming inconsistency: `time_zone` vs `timeZone` + - **Referenced in**: `IMPROVEMENTS.md` Phase 3 backlog - **Issue**: PocketBase uses `time_zone` (snake_case) while JS convention is `timeZone` (camelCase). No clear mapping layer. - **Fix**: Standardize naming and add explicit mapping in `src/lib/pocketbase.ts` @@ -89,6 +97,7 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### MEDIUM — Testing #### 9. Limited test coverage + - **Current**: Only `EventForm.test.tsx` (50+ tests) and `pocketbase.test.ts` (RSVP functions) - **Missing**: LoginForm, RegisterForm, AccountDashboard, Header, ScheduleEvents, Newsletter, Footer - **Fix**: Prioritize tests for auth components (LoginForm, RegisterForm) and ScheduleEvents @@ -96,11 +105,13 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### MEDIUM — Documentation #### 10. CLAUDE.md says "No test suite configured" — inaccurate + - **File**: `CLAUDE.md` - **Issue**: States "No test suite is configured" and "No test infrastructure — manual testing required." This is outdated — the project has Vitest, React Testing Library, 50+ tests, and CI-integrated testing. - **Fix**: Update CLAUDE.md with test commands and remove the outdated notes. #### 11. CLAUDE.md missing auth, events, and app page documentation + - **File**: `CLAUDE.md` - **Issue**: Documents the original static site but not: login/register/account pages, PocketBase integration, auth store, EventForm, ScheduleEvents, AppLayout, Storybook, container setup. - **Fix**: Add sections for backend integration, auth architecture, new pages, and new components. @@ -108,14 +119,17 @@ Comprehensive audit of the CSCS community website (cscs.dev) covering project he ### LOW — Enhancements #### 12. No RSS feed for blog + - **Issue**: Blog has 3 posts and content collections but no RSS feed - **Fix**: Install `@astrojs/rss`, create `src/pages/rss.xml.ts`, add autodiscovery `` tag #### 13. No web analytics + - **Issue**: No tracking configured (Google Analytics, Plausible, etc.) - **Fix**: Choose privacy-friendly analytics, add to Layout, update privacy policy #### 14. No deployment documentation or CI/CD deploy step + - **Issue**: `astro.config.mjs` sets site to `https://cscs.dev` but there's no hosting configuration, no deploy step in CI, and no production environment docs. - **Fix**: Document production hosting, add CI deploy step, document environment variable management. diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md index 972e0bf..cce1df1 100644 --- a/AUTHENTICATION.md +++ b/AUTHENTICATION.md @@ -34,6 +34,7 @@ podman-compose up ``` This will: + - Start PocketBase on `http://localhost:8080` - Start the Astro dev server on `http://localhost:4321` - Client-side code will automatically connect to `http://localhost:8080` @@ -53,6 +54,7 @@ cp .env.example .env ``` Add to `.env`: + ```env PUBLIC_POCKETBASE_URL=http://localhost:8080 ``` @@ -219,6 +221,7 @@ Alternatively, you can manually verify users via Admin Dashboard (Collections SendGrid wraps all email links with click tracking URLs (e.g., `url8394.cscs.dev`). This is normal behavior and the link will redirect to the actual verification URL after tracking. To disable link tracking (optional): + 1. Login to SendGrid dashboard 2. Go to Settings → Tracking 3. Disable "Click Tracking" diff --git a/backend/MIGRATIONS.md b/backend/MIGRATIONS.md index b57e907..f685ca7 100644 --- a/backend/MIGRATIONS.md +++ b/backend/MIGRATIONS.md @@ -21,6 +21,7 @@ podman-compose up ``` **Steps:** + 1. Login to the Admin Dashboard 2. Go to "Collections" 3. Create or modify collections using the UI @@ -29,6 +30,7 @@ podman-compose up 6. Commit them to git **Advantages:** + - Visual interface for schema design - Automatic migration generation - No syntax errors @@ -46,11 +48,14 @@ cd backend This creates a file like `1234567890_migration_name.js` with the structure: ```javascript -migrate((app) => { - // Upgrade operations -}, (app) => { - // Downgrade operations (optional) -}) +migrate( + (app) => { + // Upgrade operations + }, + (app) => { + // Downgrade operations (optional) + }, +); ``` ### 3. Testing Migrations @@ -78,6 +83,7 @@ Your production workflow is already configured: 4. **Backups**: Your GCE block storage undergoes scheduled backups **Migration Flow:** + ``` Local Changes → Git Commit → GitHub → CI/CD Build → GCE VM → Container Restart → Migrations Apply ``` @@ -85,6 +91,7 @@ Local Changes → Git Commit → GitHub → CI/CD Build → GCE VM → Container ## Best Practices ### DO: + - ✅ Use Admin Dashboard for schema changes (automigrate) - ✅ Commit all migration files to git - ✅ Test migrations locally before deploying @@ -93,6 +100,7 @@ Local Changes → Git Commit → GitHub → CI/CD Build → GCE VM → Container - ✅ Keep migrations small and focused ### DON'T: + - ❌ Manually edit the production database - ❌ Delete migration files after they've been applied - ❌ Modify migration files after committing @@ -103,7 +111,7 @@ Local Changes → Git Commit → GitHub → CI/CD Build → GCE VM → Container ### Creating a New Collection -1. Navigate to http://localhost:8080/_/ +1. Navigate to http://localhost:8080/\_/ 2. Collections → New Collection 3. Choose collection type (Base, Auth, View) 4. Add fields with appropriate types @@ -120,6 +128,7 @@ Local Changes → Git Commit → GitHub → CI/CD Build → GCE VM → Container ### Creating Relationships When creating a relation field: + - **Type**: Relation - **Collection**: Select the target collection - **Cascade Delete**: Check if deleting parent should delete children @@ -128,6 +137,7 @@ When creating a relation field: ### Adding Indexes For performance or uniqueness: + 1. Edit collection 2. Scroll to "Indexes" section 3. Add index SQL: `CREATE INDEX idx_name ON table (column)` @@ -136,6 +146,7 @@ For performance or uniqueness: ## Example: Events and RSVPs Schema ### Events Collection + ``` Collection Type: Base Name: events @@ -153,6 +164,7 @@ Fields: ``` ### RSVPs Collection + ``` Collection Type: Base Name: rsvps @@ -171,6 +183,7 @@ CREATE UNIQUE INDEX idx_event_user ON rsvps (event, user) For each collection, set appropriate rules: **Events:** + - List: `@request.auth.id != ""` (authenticated users can list) - View: `@request.auth.id != ""` (authenticated users can view) - Create: `@request.auth.id != ""` (authenticated users can create) @@ -178,6 +191,7 @@ For each collection, set appropriate rules: - Delete: `@request.auth.id != ""` (authenticated users can delete) **RSVPs:** + - List: `@request.auth.id != ""` (authenticated users can list) - View: `@request.auth.id != ""` (authenticated users can view) - Create: `@request.auth.id != "" && @request.data.user = @request.auth.id` (users can only RSVP for themselves) @@ -187,22 +201,26 @@ For each collection, set appropriate rules: ## Troubleshooting ### Migration fails on production + 1. Check logs: `podman logs pocketbase` 2. Verify migration file syntax 3. Check if migration was already partially applied 4. Use `migrate history-sync` to clean orphaned entries ### Migration file not generated + 1. Ensure `--automigrate` flag is enabled (default) 2. Check file permissions on `pb_migrations/` directory 3. Restart PocketBase after making changes ### Schema out of sync + 1. Use `./pocketbase migrate collections` to create snapshot 2. Review and apply the generated migration 3. Commit to git ### Testing locally before production + ```bash # 1. Backup your local database cp backend/pb_data/data.db backend/pb_data/data.db.backup @@ -219,15 +237,16 @@ cp backend/pb_data/data.db.backup backend/pb_data/data.db ## TypeScript Integration PocketBase generates TypeScript definitions automatically: + - Location: `backend/pb_data/types.d.ts` - Updated when collections change - Use these types in your frontend code: ```typescript -import type { EventsResponse, RsvpsResponse } from '../backend/pb_data/types'; +import type { EventsResponse, RsvpsResponse } from "../backend/pb_data/types"; // Type-safe record access -const event: EventsResponse = await pb.collection('events').getOne('RECORD_ID'); +const event: EventsResponse = await pb.collection("events").getOne("RECORD_ID"); ``` ## Resources diff --git a/backend/RBAC_SETUP.md b/backend/RBAC_SETUP.md index ffe8c53..8b37df8 100644 --- a/backend/RBAC_SETUP.md +++ b/backend/RBAC_SETUP.md @@ -5,17 +5,20 @@ This guide explains how to set up Role-Based Access Control for the CSCS.dev sit ## User Roles ### 1. Super Admins (PocketBase Admins) + - **Access**: https://api.cscs.dev/_/ (Admin Dashboard) - **Purpose**: Full system administration, manage collections, migrations, settings - **Authentication**: Separate from regular users - **Create**: `pocketbase admin create email@example.com password` ### 2. Regular Users (users collection) + - **Access**: Frontend login at https://cscs.dev/login - **Default role**: `user` - **Permissions**: View events, RSVP to events, manage own profile ### 3. Moderators (users with role="moderator") + - **Access**: Frontend login (same as regular users) - **Permissions**: All regular user permissions + create/edit/delete events - **Upgrade**: Super admin changes user's `role` field to "moderator" via dashboard @@ -24,7 +27,7 @@ This guide explains how to set up Role-Based Access Control for the CSCS.dev sit ### Step 1: Add role field to users collection -1. Open Admin Dashboard: http://localhost:8080/_/ +1. Open Admin Dashboard: http://localhost:8080/\_/ 2. Go to Collections → users 3. Click "Edit" on the users collection 4. Add new field: @@ -97,6 +100,7 @@ user = @request.auth.id ## Making a User a Moderator ### Via Admin Dashboard (Recommended) + 1. Login to https://api.cscs.dev/_/ 2. Go to Collections → users 3. Find the user @@ -105,6 +109,7 @@ user = @request.auth.id 6. Save ### Via CLI (if needed) + ```bash # SSH into production server ssh -i ~/.ssh/id_rsa jackvincenthall@34.67.31.86 @@ -118,10 +123,10 @@ sudo podman exec pocketbase-pocketbase pocketbase admin update USER_EMAIL --role ### Check if user is moderator (TypeScript) ```typescript -import { pb } from './lib/pocketbase'; +import { pb } from "./lib/pocketbase"; // Check current user role -const isModerator = pb.authStore.model?.role === 'moderator'; +const isModerator = pb.authStore.model?.role === "moderator"; // Show/hide moderator features if (isModerator) { @@ -133,19 +138,17 @@ if (isModerator) { ### Example: Conditional UI rendering ```tsx -import { useAuth } from '../stores/authStore'; +import { useAuth } from "../stores/authStore"; function EventsList() { const { user } = useAuth(); - const isModerator = user?.role === 'moderator'; + const isModerator = user?.role === "moderator"; return (
- {isModerator && ( - - )} + {isModerator && } - {events.map(event => ( + {events.map((event) => (

{event.title}

{isModerator && ( diff --git a/backend/pb_migrations/1764523465_created_events.js b/backend/pb_migrations/1764523465_created_events.js index 48bb73b..b37ae46 100644 --- a/backend/pb_migrations/1764523465_created_events.js +++ b/backend/pb_migrations/1764523465_created_events.js @@ -1,121 +1,122 @@ /// -migrate((app) => { - const collection = new Collection({ - "createRule": null, - "deleteRule": null, - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text724990059", - "max": 0, - "min": 0, - "name": "title", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "date2482226890", - "max": "", - "min": "", - "name": "datetime", - "presentable": false, - "required": false, - "system": false, - "type": "date" - }, - { - "hidden": false, - "id": "geoPoint1587448267", - "name": "location", - "presentable": false, - "required": false, - "system": false, - "type": "geoPoint" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1843675174", - "max": 0, - "min": 0, - "name": "description", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "hidden": false, - "id": "text1874629670", - "max": 0, - "min": 0, - "name": "tags", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "id": "pbc_1687431684", - "indexes": [ - "CREATE UNIQUE INDEX `idx_4dL89HCxNF` ON `events` (`title`)" - ], - "listRule": null, - "name": "events", - "system": false, - "type": "base", - "updateRule": null, - "viewRule": null - }); +migrate( + (app) => { + const collection = new Collection({ + createRule: null, + deleteRule: null, + fields: [ + { + autogeneratePattern: "[a-z0-9]{15}", + hidden: false, + id: "text3208210256", + max: 15, + min: 15, + name: "id", + pattern: "^[a-z0-9]+$", + presentable: false, + primaryKey: true, + required: true, + system: true, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text724990059", + max: 0, + min: 0, + name: "title", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + hidden: false, + id: "date2482226890", + max: "", + min: "", + name: "datetime", + presentable: false, + required: false, + system: false, + type: "date", + }, + { + hidden: false, + id: "geoPoint1587448267", + name: "location", + presentable: false, + required: false, + system: false, + type: "geoPoint", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text1843675174", + max: 0, + min: 0, + name: "description", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text1874629670", + max: 0, + min: 0, + name: "tags", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + hidden: false, + id: "autodate2990389176", + name: "created", + onCreate: true, + onUpdate: false, + presentable: false, + system: false, + type: "autodate", + }, + { + hidden: false, + id: "autodate3332085495", + name: "updated", + onCreate: true, + onUpdate: true, + presentable: false, + system: false, + type: "autodate", + }, + ], + id: "pbc_1687431684", + indexes: ["CREATE UNIQUE INDEX `idx_4dL89HCxNF` ON `events` (`title`)"], + listRule: null, + name: "events", + system: false, + type: "base", + updateRule: null, + viewRule: null, + }); - return app.save(collection); -}, (app) => { - const collection = app.findCollectionByNameOrId("pbc_1687431684"); + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); - return app.delete(collection); -}) + return app.delete(collection); + }, +); diff --git a/backend/pb_migrations/1764526176_updated_events.js b/backend/pb_migrations/1764526176_updated_events.js index 8c39347..26dfe4e 100644 --- a/backend/pb_migrations/1764526176_updated_events.js +++ b/backend/pb_migrations/1764526176_updated_events.js @@ -1,154 +1,181 @@ /// -migrate((app) => { - const collection = app.findCollectionByNameOrId("pbc_1687431684") - - // remove field - collection.fields.removeById("date2482226890") - - // remove field - collection.fields.removeById("geoPoint1587448267") - - // add field - collection.fields.addAt(4, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text2363381545", - "max": 0, - "min": 0, - "name": "type", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - // add field - collection.fields.addAt(5, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text1587448267", - "max": 0, - "min": 0, - "name": "location", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - // add field - collection.fields.addAt(6, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text2623739950", - "max": 0, - "min": 0, - "name": "location_details", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - // add field - collection.fields.addAt(7, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text223791402", - "max": 0, - "min": 0, - "name": "time_zone", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - // add field - collection.fields.addAt(8, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text2862495610", - "max": 0, - "min": 0, - "name": "date", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - // add field - collection.fields.addAt(9, new Field({ - "autogeneratePattern": "", - "hidden": false, - "id": "text1872009285", - "max": 0, - "min": 0, - "name": "time", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - })) - - return app.save(collection) -}, (app) => { - const collection = app.findCollectionByNameOrId("pbc_1687431684") - - // add field - collection.fields.addAt(2, new Field({ - "hidden": false, - "id": "date2482226890", - "max": "", - "min": "", - "name": "datetime", - "presentable": false, - "required": false, - "system": false, - "type": "date" - })) - - // add field - collection.fields.addAt(3, new Field({ - "hidden": false, - "id": "geoPoint1587448267", - "name": "location", - "presentable": false, - "required": false, - "system": false, - "type": "geoPoint" - })) - - // remove field - collection.fields.removeById("text2363381545") - - // remove field - collection.fields.removeById("text1587448267") - - // remove field - collection.fields.removeById("text2623739950") - - // remove field - collection.fields.removeById("text223791402") - - // remove field - collection.fields.removeById("text2862495610") - - // remove field - collection.fields.removeById("text1872009285") - - return app.save(collection) -}) +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); + + // remove field + collection.fields.removeById("date2482226890"); + + // remove field + collection.fields.removeById("geoPoint1587448267"); + + // add field + collection.fields.addAt( + 4, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text2363381545", + max: 0, + min: 0, + name: "type", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + // add field + collection.fields.addAt( + 5, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text1587448267", + max: 0, + min: 0, + name: "location", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + // add field + collection.fields.addAt( + 6, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text2623739950", + max: 0, + min: 0, + name: "location_details", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + // add field + collection.fields.addAt( + 7, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text223791402", + max: 0, + min: 0, + name: "time_zone", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + // add field + collection.fields.addAt( + 8, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text2862495610", + max: 0, + min: 0, + name: "date", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + // add field + collection.fields.addAt( + 9, + new Field({ + autogeneratePattern: "", + hidden: false, + id: "text1872009285", + max: 0, + min: 0, + name: "time", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }), + ); + + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); + + // add field + collection.fields.addAt( + 2, + new Field({ + hidden: false, + id: "date2482226890", + max: "", + min: "", + name: "datetime", + presentable: false, + required: false, + system: false, + type: "date", + }), + ); + + // add field + collection.fields.addAt( + 3, + new Field({ + hidden: false, + id: "geoPoint1587448267", + name: "location", + presentable: false, + required: false, + system: false, + type: "geoPoint", + }), + ); + + // remove field + collection.fields.removeById("text2363381545"); + + // remove field + collection.fields.removeById("text1587448267"); + + // remove field + collection.fields.removeById("text2623739950"); + + // remove field + collection.fields.removeById("text223791402"); + + // remove field + collection.fields.removeById("text2862495610"); + + // remove field + collection.fields.removeById("text1872009285"); + + return app.save(collection); + }, +); diff --git a/backend/pb_migrations/1764526671_updated_users.js b/backend/pb_migrations/1764526671_updated_users.js index d6a9a60..66ba964 100644 --- a/backend/pb_migrations/1764526671_updated_users.js +++ b/backend/pb_migrations/1764526671_updated_users.js @@ -1,29 +1,32 @@ /// -migrate((app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // add field - collection.fields.addAt(8, new Field({ - "hidden": false, - "id": "select1466534506", - "maxSelect": 1, - "name": "role", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "user", - "moderator" - ] - })) + // add field + collection.fields.addAt( + 8, + new Field({ + hidden: false, + id: "select1466534506", + maxSelect: 1, + name: "role", + presentable: false, + required: false, + system: false, + type: "select", + values: ["user", "moderator"], + }), + ); - return app.save(collection) -}, (app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // remove field - collection.fields.removeById("select1466534506") + // remove field + collection.fields.removeById("select1466534506"); - return app.save(collection) -}) + return app.save(collection); + }, +); diff --git a/backend/pb_migrations/1764527567_updated_events.js b/backend/pb_migrations/1764527567_updated_events.js index 31ea8e0..25d2ec0 100644 --- a/backend/pb_migrations/1764527567_updated_events.js +++ b/backend/pb_migrations/1764527567_updated_events.js @@ -1,28 +1,40 @@ /// -migrate((app) => { - const collection = app.findCollectionByNameOrId("pbc_1687431684") +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); - // update collection data - unmarshal({ - "createRule": "@request.auth.id != \"\" && @request.auth.role = \"moderator\"", - "deleteRule": "@request.auth.id != \"\" && @request.auth.role = \"moderator\"", - "listRule": "", - "updateRule": "@request.auth.id != \"\" && @request.auth.role = \"moderator\"", - "viewRule": "" - }, collection) + // update collection data + unmarshal( + { + createRule: + '@request.auth.id != "" && @request.auth.role = "moderator"', + deleteRule: + '@request.auth.id != "" && @request.auth.role = "moderator"', + listRule: "", + updateRule: + '@request.auth.id != "" && @request.auth.role = "moderator"', + viewRule: "", + }, + collection, + ); - return app.save(collection) -}, (app) => { - const collection = app.findCollectionByNameOrId("pbc_1687431684") + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); - // update collection data - unmarshal({ - "createRule": null, - "deleteRule": null, - "listRule": null, - "updateRule": null, - "viewRule": null - }, collection) + // update collection data + unmarshal( + { + createRule: null, + deleteRule: null, + listRule: null, + updateRule: null, + viewRule: null, + }, + collection, + ); - return app.save(collection) -}) + return app.save(collection); + }, +); diff --git a/backend/pb_migrations/1764527610_updated_users.js b/backend/pb_migrations/1764527610_updated_users.js index 6ea549a..1c909a0 100644 --- a/backend/pb_migrations/1764527610_updated_users.js +++ b/backend/pb_migrations/1764527610_updated_users.js @@ -1,42 +1,45 @@ /// -migrate((app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // update field - collection.fields.addAt(8, new Field({ - "hidden": false, - "id": "select1466534506", - "maxSelect": 1, - "name": "role", - "presentable": false, - "required": true, - "system": false, - "type": "select", - "values": [ - "user", - "moderator" - ] - })) + // update field + collection.fields.addAt( + 8, + new Field({ + hidden: false, + id: "select1466534506", + maxSelect: 1, + name: "role", + presentable: false, + required: true, + system: false, + type: "select", + values: ["user", "moderator"], + }), + ); - return app.save(collection) -}, (app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // update field - collection.fields.addAt(8, new Field({ - "hidden": false, - "id": "select1466534506", - "maxSelect": 1, - "name": "role", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "user", - "moderator" - ] - })) + // update field + collection.fields.addAt( + 8, + new Field({ + hidden: false, + id: "select1466534506", + maxSelect: 1, + name: "role", + presentable: false, + required: false, + system: false, + type: "select", + values: ["user", "moderator"], + }), + ); - return app.save(collection) -}) + return app.save(collection); + }, +); diff --git a/backend/pb_migrations/1764528168_updated_users.js b/backend/pb_migrations/1764528168_updated_users.js index 27cb550..0e7f96d 100644 --- a/backend/pb_migrations/1764528168_updated_users.js +++ b/backend/pb_migrations/1764528168_updated_users.js @@ -1,22 +1,33 @@ /// -migrate((app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // update collection data - unmarshal({ - "createRule": "@request.body.email:isset = true && @request.body.password:isset = true && (@request.body.role:isset = false || @request.body.role = 'user')", - "updateRule": "id = @request.auth.id && (@request.body.role:isset = false || @request.body.role = role)" - }, collection) + // update collection data + unmarshal( + { + createRule: + "@request.body.email:isset = true && @request.body.password:isset = true && (@request.body.role:isset = false || @request.body.role = 'user')", + updateRule: + "id = @request.auth.id && (@request.body.role:isset = false || @request.body.role = role)", + }, + collection, + ); - return app.save(collection) -}, (app) => { - const collection = app.findCollectionByNameOrId("_pb_users_auth_") + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("_pb_users_auth_"); - // update collection data - unmarshal({ - "createRule": "", - "updateRule": "id = @request.auth.id" - }, collection) + // update collection data + unmarshal( + { + createRule: "", + updateRule: "id = @request.auth.id", + }, + collection, + ); - return app.save(collection) -}) + return app.save(collection); + }, +); diff --git a/backend/pb_migrations/1774800965_created_books.js b/backend/pb_migrations/1774800965_created_books.js new file mode 100644 index 0000000..7f0ce53 --- /dev/null +++ b/backend/pb_migrations/1774800965_created_books.js @@ -0,0 +1,180 @@ +/// +migrate( + (app) => { + const collection = new Collection({ + id: "pbc_books", + name: "books", + type: "base", + system: false, + fields: [ + { + autogeneratePattern: "[a-z0-9]{15}", + hidden: false, + id: "text3208210256", + max: 15, + min: 15, + name: "id", + pattern: "^[a-z0-9]+$", + presentable: false, + primaryKey: true, + required: true, + system: true, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_title", + max: 0, + min: 0, + name: "title", + pattern: "", + presentable: true, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_author", + max: 0, + min: 0, + name: "author", + pattern: "", + presentable: false, + primaryKey: false, + required: true, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_description", + max: 0, + min: 0, + name: "description", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + hidden: false, + id: "file_book_cover", + maxSelect: 1, + maxSize: 5242880, + mimeTypes: ["image/jpeg", "image/png", "image/webp"], + name: "cover_image", + presentable: false, + protected: false, + required: false, + system: false, + thumbs: ["200x300"], + type: "file", + }, + { + hidden: false, + id: "select_book_status", + maxSelect: 1, + name: "status", + presentable: false, + required: true, + system: false, + type: "select", + values: ["reading", "completed"], + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_purchase_link", + max: 0, + min: 0, + name: "purchase_link", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_start_date", + max: 0, + min: 0, + name: "start_date", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + autogeneratePattern: "", + hidden: false, + id: "text_book_end_date", + max: 0, + min: 0, + name: "end_date", + pattern: "", + presentable: false, + primaryKey: false, + required: false, + system: false, + type: "text", + }, + { + hidden: false, + id: "number_book_sort_order", + max: null, + min: null, + name: "sort_order", + onlyInt: true, + presentable: false, + required: false, + system: false, + type: "number", + }, + { + hidden: false, + id: "autodate_created", + name: "created", + onCreate: true, + onUpdate: false, + presentable: false, + system: false, + type: "autodate", + }, + { + hidden: false, + id: "autodate_updated", + name: "updated", + onCreate: true, + onUpdate: true, + presentable: false, + system: false, + type: "autodate", + }, + ], + indexes: [], + listRule: "", + viewRule: "", + createRule: '@request.auth.id != "" && @request.auth.role = "moderator"', + updateRule: '@request.auth.id != "" && @request.auth.role = "moderator"', + deleteRule: '@request.auth.id != "" && @request.auth.role = "moderator"', + }); + + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("pbc_books"); + return app.delete(collection); + }, +); diff --git a/backend/pb_migrations/1774800966_updated_events_book_relation.js b/backend/pb_migrations/1774800966_updated_events_book_relation.js new file mode 100644 index 0000000..6302c6b --- /dev/null +++ b/backend/pb_migrations/1774800966_updated_events_book_relation.js @@ -0,0 +1,30 @@ +/// +migrate( + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); + + collection.fields.addAt( + 11, + new Field({ + cascadeDelete: false, + collectionId: "pbc_books", + hidden: false, + id: "relation_book", + maxSelect: 1, + minSelect: 0, + name: "book", + presentable: false, + required: false, + system: false, + type: "relation", + }), + ); + + return app.save(collection); + }, + (app) => { + const collection = app.findCollectionByNameOrId("pbc_1687431684"); + collection.fields.removeById("relation_book"); + return app.save(collection); + }, +); diff --git a/doc/adr/README.md b/doc/adr/README.md index 2ab0071..0432522 100644 --- a/doc/adr/README.md +++ b/doc/adr/README.md @@ -4,16 +4,16 @@ This directory contains Architecture Decision Records (ADRs) for the cscs.dev pr To propose a new ADR, copy [template.md](template.md) and fill in the sections. -| ADR | Title | Status | -|-----|-------|--------| -| [0001](0001-record-architecture-decisions.md) | Record Architecture Decisions | Accepted | -| [0002](0002-static-site-generation-with-astro.md) | Static Site Generation with Astro | Accepted | -| [0003](0003-frontend-hosting-on-netlify.md) | Frontend Hosting on Netlify | Accepted | -| [0004](0004-pocketbase-as-backend.md) | PocketBase as Backend | Accepted | -| [0005](0005-backend-hosting-on-gce-e2-micro.md) | Backend Hosting on GCE e2-micro | Accepted | -| [0006](0006-container-runtime-podman-with-quadlet.md) | Container Runtime — Podman with Quadlet | Accepted | -| [0007](0007-persistent-data-on-separate-gce-disk.md) | Persistent Data on Separate GCE Disk | Accepted | +| ADR | Title | Status | +| ----------------------------------------------------------- | --------------------------------------------- | -------- | +| [0001](0001-record-architecture-decisions.md) | Record Architecture Decisions | Accepted | +| [0002](0002-static-site-generation-with-astro.md) | Static Site Generation with Astro | Accepted | +| [0003](0003-frontend-hosting-on-netlify.md) | Frontend Hosting on Netlify | Accepted | +| [0004](0004-pocketbase-as-backend.md) | PocketBase as Backend | Accepted | +| [0005](0005-backend-hosting-on-gce-e2-micro.md) | Backend Hosting on GCE e2-micro | Accepted | +| [0006](0006-container-runtime-podman-with-quadlet.md) | Container Runtime — Podman with Quadlet | Accepted | +| [0007](0007-persistent-data-on-separate-gce-disk.md) | Persistent Data on Separate GCE Disk | Accepted | | [0008](0008-container-registry-google-artifact-registry.md) | Container Registry — Google Artifact Registry | Accepted | -| [0009](0009-ci-cd-github-actions-and-netlify.md) | CI/CD — GitHub Actions and Netlify | Accepted | -| [0010](0010-backend-deployment-via-manual-script.md) | Backend Deployment via Manual Script | Accepted | -| [0011](0011-local-development-with-podman-compose.md) | Local Development with Podman Compose | Accepted | +| [0009](0009-ci-cd-github-actions-and-netlify.md) | CI/CD — GitHub Actions and Netlify | Accepted | +| [0010](0010-backend-deployment-via-manual-script.md) | Backend Deployment via Manual Script | Accepted | +| [0011](0011-local-development-with-podman-compose.md) | Local Development with Podman Compose | Accepted | diff --git a/podman-compose.yaml b/podman-compose.yaml index a69a308..1467c95 100644 --- a/podman-compose.yaml +++ b/podman-compose.yaml @@ -25,7 +25,14 @@ services: # For now, defaults are fine - TZ=UTC # Override CMD to explicitly use mounted volumes for data and migrations - command: ["pocketbase", "serve", "--http=0.0.0.0:8080", "--dir=/pb_data", "--migrationsDir=/pb_migrations"] + command: + [ + "pocketbase", + "serve", + "--http=0.0.0.0:8080", + "--dir=/pb_data", + "--migrationsDir=/pb_migrations", + ] restart: unless-stopped cscs: diff --git a/src/components/AccountDashboard.tsx b/src/components/AccountDashboard.tsx index ca83615..0ec2249 100644 --- a/src/components/AccountDashboard.tsx +++ b/src/components/AccountDashboard.tsx @@ -1,18 +1,22 @@ -import { useAuth } from '../stores/authStore'; -import { logout } from '../lib/pocketbase'; -import { Button } from './catalyst/button'; -import { Heading, Subheading } from './catalyst/heading'; -import { Text } from './catalyst/text'; -import { DescriptionList, DescriptionTerm, DescriptionDetails } from './catalyst/description-list'; -import { Divider } from './catalyst/divider'; -import { Badge } from './catalyst/badge'; +import { useAuth } from "../stores/authStore"; +import { logout } from "../lib/pocketbase"; +import { Button } from "./catalyst/button"; +import { Heading, Subheading } from "./catalyst/heading"; +import { Text } from "./catalyst/text"; +import { + DescriptionList, + DescriptionTerm, + DescriptionDetails, +} from "./catalyst/description-list"; +import { Divider } from "./catalyst/divider"; +import { Badge } from "./catalyst/badge"; export default function AccountDashboard() { const { user, isLoading } = useAuth(); async function handleLogout() { await logout(); - window.location.href = '/'; + window.location.href = "/"; } if (isLoading) { @@ -25,8 +29,8 @@ export default function AccountDashboard() { if (!user) { // Redirect to login if not authenticated - if (typeof window !== 'undefined') { - window.location.href = '/login'; + if (typeof window !== "undefined") { + window.location.href = "/login"; } return null; } @@ -37,8 +41,8 @@ export default function AccountDashboard() {
Account Dashboard {user.role && ( - - {user.role === 'moderator' ? 'Moderator' : 'User'} + + {user.role === "moderator" ? "Moderator" : "User"} )}
@@ -66,25 +70,29 @@ export default function AccountDashboard() { Account Status {user.verified ? ( - Verified + + Verified + ) : ( - Not Verified + + Not Verified + )} Member Since - {new Date(user.created).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', + {new Date(user.created).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", })} -
+
diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 96e4174..4665517 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -112,6 +112,24 @@ function ArrowRightStartOnRectangleIcon() { ); } +function BookOpenIcon() { + return ( + + + + ); +} + function UserCircleIcon() { return ( Events + {isModerator && ( + + + Books + + )} diff --git a/src/components/BookClubPage.test.tsx b/src/components/BookClubPage.test.tsx new file mode 100644 index 0000000..e409f45 --- /dev/null +++ b/src/components/BookClubPage.test.tsx @@ -0,0 +1,221 @@ +/** + * BookClubPage Component Tests + * + * Tests cover: + * - Loading state + * - Current book display + * - Completed books grid + * - Empty states + * - Error handling + * - Moderator actions + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import BookClubPage from "./BookClubPage"; +import { createMockBook } from "../test/mocks"; + +// Mock pocketbase functions +const mockGetCurrentBook = vi.fn(); +const mockGetCompletedBooks = vi.fn(); +const mockGetBookCoverUrl = vi.fn(); + +vi.mock("../lib/pocketbase", () => ({ + getCurrentBook: (...args: unknown[]) => mockGetCurrentBook(...args), + getCompletedBooks: (...args: unknown[]) => mockGetCompletedBooks(...args), + getBookCoverUrl: (...args: unknown[]) => mockGetBookCoverUrl(...args), +})); + +// Mock auth store +const mockUseAuth = vi.fn(); + +vi.mock("../stores/authStore", () => ({ + useAuth: () => mockUseAuth(), +})); + +describe("BookClubPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetCurrentBook.mockResolvedValue(null); + mockGetCompletedBooks.mockResolvedValue([]); + mockGetBookCoverUrl.mockReturnValue(null); + mockUseAuth.mockReturnValue({ user: null }); + }); + + it("shows loading spinner initially", () => { + // Keep the promises pending + mockGetCurrentBook.mockReturnValue(new Promise(() => {})); + mockGetCompletedBooks.mockReturnValue(new Promise(() => {})); + + render(); + expect(screen.getByText("Loading books...")).toBeInTheDocument(); + }); + + it("renders current book when available", async () => { + const book = createMockBook({ + title: "Designing Data-Intensive Applications", + author: "Martin Kleppmann", + description: "A deep dive into distributed systems.", + status: "reading", + }); + mockGetCurrentBook.mockResolvedValue(book); + + render(); + + await waitFor(() => { + expect( + screen.getByText("Designing Data-Intensive Applications"), + ).toBeInTheDocument(); + }); + expect(screen.getByText("by Martin Kleppmann")).toBeInTheDocument(); + expect( + screen.getByText("A deep dive into distributed systems."), + ).toBeInTheDocument(); + expect(screen.getByText("Currently Reading")).toBeInTheDocument(); + }); + + it("renders empty state when no current book", async () => { + mockGetCurrentBook.mockResolvedValue(null); + + render(); + + await waitFor(() => { + expect( + screen.getByText(/No book is currently being read/), + ).toBeInTheDocument(); + }); + }); + + it("renders completed books grid", async () => { + const books = [ + createMockBook({ + title: "Book One", + author: "Author One", + description: "First book.", + status: "completed", + sort_order: 1, + }), + createMockBook({ + title: "Book Two", + author: "Author Two", + description: "Second book.", + status: "completed", + sort_order: 2, + }), + ]; + mockGetCompletedBooks.mockResolvedValue(books); + + render(); + + await waitFor(() => { + expect(screen.getByText("Book One")).toBeInTheDocument(); + }); + expect(screen.getByText("Book Two")).toBeInTheDocument(); + expect(screen.getByText("by Author One")).toBeInTheDocument(); + expect(screen.getByText("by Author Two")).toBeInTheDocument(); + expect(screen.getByText("Books We've Read")).toBeInTheDocument(); + }); + + it("does not show completed section when no completed books", async () => { + mockGetCompletedBooks.mockResolvedValue([]); + + render(); + + await waitFor(() => { + expect(screen.getByText("What We're About")).toBeInTheDocument(); + }); + expect(screen.queryByText("Books We've Read")).not.toBeInTheDocument(); + }); + + it("handles API error gracefully", async () => { + mockGetCurrentBook.mockRejectedValue(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Error loading books")).toBeInTheDocument(); + }); + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + it("shows manage books link for moderators", async () => { + mockUseAuth.mockReturnValue({ + user: { id: "1", role: "moderator" }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Manage Books")).toBeInTheDocument(); + }); + expect(screen.getByText("Manage Books").closest("a")).toHaveAttribute( + "href", + "/app/books", + ); + }); + + it("hides manage books link for regular users", async () => { + mockUseAuth.mockReturnValue({ + user: { id: "1", role: "user" }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("What We're About")).toBeInTheDocument(); + }); + expect(screen.queryByText("Manage Books")).not.toBeInTheDocument(); + }); + + it("renders purchase link when available", async () => { + const book = createMockBook({ + title: "Test Book", + author: "Test Author", + status: "reading", + purchase_link: "https://example.com/buy", + }); + mockGetCurrentBook.mockResolvedValue(book); + + render(); + + await waitFor(() => { + expect(screen.getByText("Find this book →")).toBeInTheDocument(); + }); + }); + + it("renders cover image when available", async () => { + const book = createMockBook({ + title: "Test Book", + author: "Test Author", + status: "reading", + cover_image: "cover.jpg", + }); + mockGetCurrentBook.mockResolvedValue(book); + mockGetBookCoverUrl.mockReturnValue( + "http://localhost:8080/api/files/pbc_books/123/cover.jpg", + ); + + render(); + + await waitFor(() => { + const img = screen.getByAltText("Cover of Test Book"); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute( + "src", + "http://localhost:8080/api/files/pbc_books/123/cover.jpg", + ); + }); + }); + + it("always renders static sections", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Book Club")).toBeInTheDocument(); + }); + expect(screen.getByText("What We're About")).toBeInTheDocument(); + expect(screen.getByText("Interested in Joining?")).toBeInTheDocument(); + expect(screen.getByText("Read Our Blog")).toBeInTheDocument(); + expect(screen.getByText("Join Newsletter")).toBeInTheDocument(); + }); +}); diff --git a/src/components/BookClubPage.tsx b/src/components/BookClubPage.tsx new file mode 100644 index 0000000..bc5334f --- /dev/null +++ b/src/components/BookClubPage.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from "react"; +import { + getCurrentBook, + getCompletedBooks, + getBookCoverUrl, + type BookData, +} from "../lib/pocketbase"; +import { useAuth } from "../stores/authStore"; + +export default function BookClubPage() { + const { user } = useAuth(); + const isModerator = user?.role === "moderator"; + + const [currentBook, setCurrentBook] = useState(null); + const [completedBooks, setCompletedBooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchBooks() { + try { + const [current, completed] = await Promise.all([ + getCurrentBook(), + getCompletedBooks(), + ]); + setCurrentBook(current); + setCompletedBooks(completed); + } catch (err) { + console.error("Error fetching books:", err); + setError(err instanceof Error ? err.message : "Failed to load books"); + } finally { + setIsLoading(false); + } + } + + fetchBooks(); + }, []); + + return ( +
+ {/* Hero Section */} +
+ + +
+
+

+ Book Club +

+

+ Learning about Computer Science, Product Development, and AI + through collaborative reading and discussion. +

+
+
+
+ + {/* Main Content */} +
+
+ {/* About Section */} +
+

+ What We're About +

+
+

+ Our book club brings together a group of friends passionate + about deepening their understanding of technology, product + development, and artificial intelligence. We meet regularly to + discuss technical literature, share insights, and learn from + each other's perspectives. +

+
+
+ + {/* Moderator Actions */} + {isModerator && ( +
+ )} + + {/* Dynamic Content */} + {isLoading ? ( +
+
+

+ Loading books... +

+
+ ) : error ? ( +
+ + + +

+ Error loading books +

+

+ {error} +

+
+ ) : ( + <> + {/* Current Book */} + {currentBook ? ( +
+ ) : ( +
+

+ No book is currently being read. Check back soon for our + next selection! +

+
+ )} + + {/* Past Books */} + {completedBooks.length > 0 && ( +
+

+ Books We've Read +

+
+ {completedBooks.map((book) => ( +
+
+ {book.cover_image && ( + {`Cover + )} +
+

+ {book.title} +

+

+ by {book.author} +

+ {book.description && ( +

+ {book.description} +

+ )} + {book.purchase_link && ( + + Find this book → + + )} +
+
+
+ ))} +
+
+ )} + + )} + + {/* Membership Notice */} +
+

+ Interested in Joining? +

+

+ We're currently not accepting new members, but we may open up + membership in the future. Stay tuned by following our updates! +

+ +
+
+
+
+ ); +} diff --git a/src/components/BookForm.test.tsx b/src/components/BookForm.test.tsx new file mode 100644 index 0000000..cbfb15e --- /dev/null +++ b/src/components/BookForm.test.tsx @@ -0,0 +1,225 @@ +/** + * BookForm Component Tests + * + * Tests cover: + * - Form rendering (all fields present) + * - Input handling + * - Create submission + * - Edit mode (loading existing book) + * - Error handling + * - Callbacks (onSuccess, onCancel) + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import BookForm from "./BookForm"; +import { createMockBook } from "../test/mocks"; + +const { mockCreate, mockUpdate, mockGetOne, mockGetURL } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockGetOne: vi.fn(), + mockGetURL: vi.fn(), +})); + +vi.mock("../lib/pocketbase", () => ({ + pb: { + collection: vi.fn(() => ({ + create: mockCreate, + update: mockUpdate, + getOne: mockGetOne, + })), + files: { + getURL: mockGetURL, + }, + }, +})); + +describe("BookForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreate.mockImplementation((data) => + Promise.resolve({ id: "new_book", ...data }), + ); + mockUpdate.mockImplementation((id, data) => + Promise.resolve({ id, ...data }), + ); + mockGetOne.mockRejectedValue(new Error("Not found")); + mockGetURL.mockReturnValue("http://localhost:8080/cover.jpg"); + }); + + describe("Form Rendering", () => { + it("renders all form fields", () => { + render(); + + expect(screen.getByLabelText(/title/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/author/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/description/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/status/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/purchase link/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/start date/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/end date/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/sort order/i)).toBeInTheDocument(); + }); + + it("shows Create Book button for new books", () => { + render(); + expect( + screen.getByRole("button", { name: /create book/i }), + ).toBeInTheDocument(); + }); + + it("shows cancel button when onCancel provided", () => { + render( {}} />); + expect( + screen.getByRole("button", { name: /cancel/i }), + ).toBeInTheDocument(); + }); + }); + + describe("Input Handling", () => { + it("updates title field on input", async () => { + const user = userEvent.setup(); + render(); + + const titleInput = screen.getByLabelText(/title/i); + await user.clear(titleInput); + await user.type(titleInput, "Test Book Title"); + + expect(titleInput).toHaveValue("Test Book Title"); + }); + + it("updates author field on input", async () => { + const user = userEvent.setup(); + render(); + + const authorInput = screen.getByLabelText(/author/i); + await user.clear(authorInput); + await user.type(authorInput, "Test Author"); + + expect(authorInput).toHaveValue("Test Author"); + }); + }); + + describe("Create Mode", () => { + it("submits form data for new book", async () => { + const user = userEvent.setup(); + const onSuccess = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/title/i), "New Book"); + await user.type(screen.getByLabelText(/author/i), "New Author"); + + const submitBtn = screen.getByRole("button", { name: /create book/i }); + await user.click(submitBtn); + + await waitFor(() => { + expect(mockCreate).toHaveBeenCalledTimes(1); + }); + + // Verify FormData was passed + const callArg = mockCreate.mock.calls[0][0]; + expect(callArg).toBeInstanceOf(FormData); + expect(callArg.get("title")).toBe("New Book"); + expect(callArg.get("author")).toBe("New Author"); + }); + }); + + describe("Edit Mode", () => { + it("loads existing book data", async () => { + const book = createMockBook({ + title: "Existing Book", + author: "Existing Author", + description: "A description", + status: "completed", + }); + mockGetOne.mockResolvedValue(book); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/title/i)).toHaveValue("Existing Book"); + }); + expect(screen.getByLabelText(/author/i)).toHaveValue("Existing Author"); + expect( + screen.getByRole("button", { name: /update book/i }), + ).toBeInTheDocument(); + }); + + it("submits update for existing book", async () => { + const user = userEvent.setup(); + const book = createMockBook({ + title: "Existing Book", + author: "Existing Author", + }); + mockGetOne.mockResolvedValue(book); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText(/title/i)).toHaveValue("Existing Book"); + }); + + const submitBtn = screen.getByRole("button", { name: /update book/i }); + await user.click(submitBtn); + + await waitFor(() => { + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate).toHaveBeenCalledWith(book.id, expect.any(FormData)); + }); + }); + }); + + describe("Error Handling", () => { + it("displays API errors", async () => { + const user = userEvent.setup(); + mockCreate.mockRejectedValue(new Error("Validation failed")); + + render(); + + await user.type(screen.getByLabelText(/title/i), "Book"); + await user.type(screen.getByLabelText(/author/i), "Author"); + await user.click(screen.getByRole("button", { name: /create book/i })); + + await waitFor(() => { + expect(screen.getByText("Validation failed")).toBeInTheDocument(); + }); + }); + + it("displays error when loading book fails", async () => { + mockGetOne.mockRejectedValue(new Error("Book not found")); + + render(); + + await waitFor(() => { + expect(screen.getByText("Book not found")).toBeInTheDocument(); + }); + }); + }); + + describe("Callbacks", () => { + it("calls onSuccess after successful creation", async () => { + const user = userEvent.setup(); + const onSuccess = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/title/i), "Book"); + await user.type(screen.getByLabelText(/author/i), "Author"); + await user.click(screen.getByRole("button", { name: /create book/i })); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + }); + + it("calls onCancel when cancel button clicked", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/BookForm.tsx b/src/components/BookForm.tsx new file mode 100644 index 0000000..3f1c90b --- /dev/null +++ b/src/components/BookForm.tsx @@ -0,0 +1,257 @@ +import { useState, useEffect } from "react"; +import { pb } from "../lib/pocketbase"; +import { Button } from "./catalyst/button"; +import { Field, Label } from "./catalyst/fieldset"; +import { Input } from "./catalyst/input"; +import { Textarea } from "./catalyst/textarea"; +import { Select } from "./catalyst/select"; + +interface BookFormProps { + bookId?: string; + onSuccess?: () => void; + onCancel?: () => void; +} + +interface BookFormData { + title: string; + author: string; + description: string; + status: "reading" | "completed"; + purchase_link: string; + start_date: string; + end_date: string; + sort_order: number; +} + +export default function BookForm({ + bookId, + onSuccess, + onCancel, +}: BookFormProps) { + const [formData, setFormData] = useState({ + title: "", + author: "", + description: "", + status: "reading", + purchase_link: "", + start_date: "", + end_date: "", + sort_order: 0, + }); + const [coverImageFile, setCoverImageFile] = useState(null); + const [existingCoverUrl, setExistingCoverUrl] = useState(null); + const [error, setError] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingBook, setIsLoadingBook] = useState(!!bookId); + + useEffect(() => { + if (bookId) { + loadBook(); + } + }, [bookId]); + + async function loadBook() { + try { + const book = await pb.collection("books").getOne(bookId!); + setFormData({ + title: book.title || "", + author: book.author || "", + description: book.description || "", + status: book.status || "reading", + purchase_link: book.purchase_link || "", + start_date: book.start_date || "", + end_date: book.end_date || "", + sort_order: book.sort_order || 0, + }); + if (book.cover_image) { + setExistingCoverUrl(pb.files.getURL(book, book.cover_image)); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load book"); + } finally { + setIsLoadingBook(false); + } + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setIsLoading(true); + + try { + const data = new FormData(); + data.append("title", formData.title); + data.append("author", formData.author); + data.append("description", formData.description); + data.append("status", formData.status); + data.append("purchase_link", formData.purchase_link); + data.append("start_date", formData.start_date); + data.append("end_date", formData.end_date); + data.append("sort_order", String(formData.sort_order)); + + if (coverImageFile) { + data.append("cover_image", coverImageFile); + } + + if (bookId) { + await pb.collection("books").update(bookId, data); + } else { + await pb.collection("books").create(data); + } + + if (onSuccess) { + onSuccess(); + } else { + window.location.href = "/app/books"; + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save book"); + } finally { + setIsLoading(false); + } + } + + function handleChange(field: keyof BookFormData, value: string | number) { + setFormData((prev) => ({ ...prev, [field]: value })); + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] ?? null; + setCoverImageFile(file); + } + + if (isLoadingBook) { + return ( +
+
+

+ Loading book... +

+
+ ); + } + + return ( +
+ {error && ( +
+

{error}

+
+ )} + + + + handleChange("title", e.target.value)} + required + /> + + + + + handleChange("author", e.target.value)} + required + /> + + + + +