diff --git a/.env.example b/.env.example index 37367cc..86007be 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,6 @@ +# Netlify Database injects NETLIFY_DB_URL automatically. Do not commit it here. +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:8888 +BETTER_AUTH_ALLOWED_HOSTS=localhost:3000,localhost:8888,127.0.0.1:3000,127.0.0.1:8888 + NEXT_PUBLIC_INVITE_LINK= diff --git a/.gitignore b/.gitignore index cc87cba..7be53cb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,11 +26,17 @@ yarn-error.log* .pnpm-debug.log* # local env files -.env*.local +.env +.env.* +!.env.example # vercel .vercel +# netlify +.netlify +/deno.lock + # typescript *.tsbuildinfo next-env.d.ts diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d3a850 --- /dev/null +++ b/README.md @@ -0,0 +1,228 @@ +# SacTech website + +The community website for SacTech. It is a Next.js application deployed on Netlify, backed by Netlify Database and Drizzle ORM, with Better Auth providing email/password accounts and admin roles. + +The event flow is deliberately moderated: + +1. A person creates an account or signs in at `/auth`. +2. From `/account`, they open `/events/submit` and send an event for review. New events always start as `pending`, and the account page tracks their status. +3. A SacTech admin reviews the queue at `/admin/events` and approves or rejects each event. +4. Only `approved` events are returned to the public `/events` calendar. + +Authorization and status checks are enforced on the server. Hiding an admin link or changing a form value in the browser is not enough to bypass them. + +## Stack + +- Next.js 16 App Router and React 19 +- Netlify's Next.js runtime, configured by `netlify.toml` +- [Netlify Database](https://docs.netlify.com/build/data-and-storage/netlify-database/), a managed Postgres database +- [Drizzle ORM's native Netlify Database driver](https://orm.drizzle.team/docs/connect-netlify-db) +- [Better Auth](https://better-auth.com/docs/integrations/next) with its Drizzle adapter, email/password authentication, and Admin plugin + +Netlify Database is currently available only on Netlify's **Credit-based plans**. Database compute and bandwidth consume credits; review the current [billing and limits documentation](https://docs.netlify.com/build/data-and-storage/netlify-database/billing-and-usage/) before enabling it for the project. + +## Local development + +### Prerequisites + +- Node.js 22.13 or newer (`.nvmrc` pins the project's version) +- Corepack and pnpm 10.33 +- A supported local platform for Netlify Database + +Install dependencies: + +```sh +corepack enable +pnpm install +``` + +Create a local environment file: + +```sh +cp .env.example .env.local +openssl rand -base64 32 +``` + +Put the generated value in `BETTER_AUTH_SECRET`. Better Auth requires a high-entropy secret of at least 32 characters. The local values should look like this: + +```dotenv +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:8888 +BETTER_AUTH_ALLOWED_HOSTS=localhost:3000,localhost:8888,127.0.0.1:3000,127.0.0.1:8888 + +# Optional Slack/community invitation used by the existing site UI. +NEXT_PUBLIC_INVITE_LINK= +``` + +Do not add `NETLIFY_DB_URL` to this file. Netlify supplies it to the application automatically. + +Start the app in the first terminal: + +```sh +pnpm dev +``` + +This runs `netlify dev`, which starts the local Postgres-compatible database and proxies the Next.js server at . Keep it running. Directly running `pnpm dev:next` bypasses the Netlify development environment and therefore does not provide the database connection. + +In a second terminal, apply the committed migrations to the running local database: + +```sh +pnpm db:migrate +pnpm db:status +``` + +The local database does not apply migrations automatically. Run `pnpm db:migrate` after cloning, after pulling migrations, and after generating a new migration. The command only targets the local database by default; Netlify applies remote migrations during deploys. + +## Environment variables + +| Variable | Where | Purpose | +| --- | --- | --- | +| `BETTER_AUTH_SECRET` | Local and Netlify | Private, high-entropy secret used by Better Auth for encryption and hashing. Never prefix it with `NEXT_PUBLIC_`. | +| `BETTER_AUTH_URL` | Local and Netlify | Full canonical origin. Use `http://localhost:8888` locally and the primary HTTPS site URL in production. | +| `BETTER_AUTH_ALLOWED_HOSTS` | Local and Netlify | Comma-separated Better Auth host allowlist. Values are hostnames, optionally with ports or wildcards, and do not include URL paths. | +| `NETLIFY_DB_URL` | Supplied by Netlify | Database connection string selected for the local, preview, or production database branch. Do not commit or manually configure it for normal app execution. | +| `NEXT_PUBLIC_INVITE_LINK` | Optional | Public community invitation displayed by the site. It is intentionally browser-visible. | + +### Production and deploy-preview hosts + +Set `BETTER_AUTH_URL` to the canonical production origin. Replace `SITE` with the exact Netlify site name: + +```dotenv +BETTER_AUTH_URL=https://SITE.netlify.app +``` + +Allow the site's default Netlify hostname, that site's deploy-preview/branch-deploy hosts, and any real custom domains: + +```dotenv +BETTER_AUTH_ALLOWED_HOSTS=SITE.netlify.app,*--SITE.netlify.app,www.YOUR-DOMAIN.example,YOUR-DOMAIN.example +``` + +The `*--SITE.netlify.app` pattern covers URLs such as `deploy-preview-42--SITE.netlify.app` while remaining scoped to this Netlify site. Do **not** use `*.netlify.app`: Better Auth automatically adds allowed hosts to its trusted origins, so that broad pattern would trust unrelated and potentially hostile Netlify sites. + +Better Auth resolves each allowed preview hostname dynamically. The canonical `BETTER_AUTH_URL` remains the fallback and stable production identity. + +## Schema and migrations + +The Drizzle configuration reads both schemas: + +- `db/auth-schema.ts` contains Better Auth's users, sessions, accounts, and verification records. +- `db/schema.ts` contains the SacTech event and moderation model. + +After changing either schema, generate a SQL migration: + +```sh +pnpm db:generate +``` + +Review the generated SQL under `netlify/database/migrations`, then run the app and apply it locally: + +```sh +# Terminal 1 +pnpm dev + +# Terminal 2 +pnpm db:migrate +``` + +Commit the schema change, generated SQL, and Drizzle migration metadata together. Netlify recognizes committed SQL files in `netlify/database/migrations` and applies them automatically: + +- to the isolated database branch before a deploy preview becomes available; and +- to production immediately before the new deploy is published. + +A failed migration blocks that deploy. Do not add `pnpm db:migrate` to the Netlify build command, edit a migration that has already shipped, or use `drizzle-kit push` against production. Add a new, preferably backwards-compatible migration instead. For breaking changes, use an expand/migrate/contract sequence. See Netlify's [migration lifecycle and guidance](https://docs.netlify.com/build/data-and-storage/netlify-database/migrations/). + +If Better Auth configuration or plugins change the auth model, regenerate its Drizzle schema first, review the result, and then generate the SQL migration: + +```sh +pnpm auth:generate +pnpm db:generate +``` + +## Create the first admin + +Apply the auth migrations before bootstrapping an admin. The Better Auth CLI needs a persistent database and `NETLIFY_DB_URL` in the same shell; it cannot use the connection that exists only inside the separately running Next.js process. + +For production, a Team Owner can open Netlify's **Database** view, select the production branch, and use **Copy connection string**. Expose that value temporarily as `NETLIFY_DB_URL` in a trusted shell; do not save it in `.env.local` or the repository. Under Netlify's current access rules, a Developer receives a read-only production connection string and cannot use it to create the admin. + +Once `NETLIFY_DB_URL` is available in that shell, run: + +```sh +pnpm auth:create-admin --email admin@example.com --name "SacTech Admin" --role admin +``` + +Let the CLI prompt for the password instead of passing `--password`, which can expose it in shell history or process listings. The CLI creates the account through Better Auth, hashes its password, and marks the email verified by default. Remove a temporarily exported production `NETLIFY_DB_URL` from the shell when finished. + +Alternatively, promote an existing account without handling its password: + +1. Have the person create their account normally. +2. In Netlify, open the project and choose **Database**. +3. Select the correct database branch—use the production branch only when intentionally granting live access—and choose **View/edit**. +4. Open the `user` table, find the exact email address, and change its `role` value to `admin`. +5. Have the person sign out and back in before opening `/admin/events`. + +Changes made through the production database editor take effect immediately. Verify the branch, account, and new role before saving. Under Netlify's current [database access rules](https://docs.netlify.com/build/data-and-storage/netlify-database/access-control/), only a Team Owner can edit the production branch. + +## Deploy to Netlify + +1. Push the repository, including `netlify.toml` and all generated migrations, to the Git provider Netlify will use. +2. In Netlify, choose **Add new project** and import the repository. The checked-in configuration runs `pnpm build`, publishes `.next`, and selects Node 22.13. +3. Under **Project configuration → Environment variables**, add `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `BETTER_AUTH_ALLOWED_HOSTS`, and the optional `NEXT_PUBLIC_INVITE_LINK`. Configure the values for every deploy context that should support authentication. +4. Deploy the site. + +Because `@netlify/database` is a project dependency, Netlify uses [package-based provisioning](https://docs.netlify.com/build/data-and-storage/netlify-database/getting-started/): on the first deploy it creates the database if needed, injects the branch-specific `NETLIFY_DB_URL`, and applies committed migrations as part of the deploy lifecycle. A database does not need to be created manually first. It can still be provisioned from the Netlify Database page if the team prefers to do that before the first deploy. + +After the production deploy succeeds: + +1. Confirm the production database branch and migrations in Netlify's **Database** view. +2. Create or promote the first admin. +3. Test account creation, event submission, moderation, and the public calendar. +4. Verify a deploy preview separately; it uses an isolated database branch and its own preview hostname. + +## Scripts + +| Command | Purpose | +| --- | --- | +| `pnpm dev` | Start Netlify Dev, the local database, and the Next.js app on port 8888. | +| `pnpm dev:next` | Start only Next.js on port 3000; useful for UI-only work, but no Netlify database is injected. | +| `pnpm build` | Create a production Next.js build. | +| `pnpm start` | Serve an already-built Next.js app. | +| `pnpm lint` | Run ESLint. | +| `pnpm typecheck` | Run TypeScript without emitting files. | +| `pnpm test` | Run the Vitest test suite once. | +| `pnpm db:generate` | Generate SQL migrations from the Drizzle schemas. | +| `pnpm db:migrate` | Apply pending migrations to the running local Netlify database. | +| `pnpm db:status` | Show local database connection and migration status. | +| `pnpm auth:generate` | Regenerate Better Auth's Drizzle schema after auth-model changes. | +| `pnpm auth:create-admin` | Create an initial Better Auth admin when `NETLIFY_DB_URL` is available. | + +Before opening a pull request, run: + +```sh +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +## Security notes + +- Keep `BETTER_AUTH_SECRET` and `NETLIFY_DB_URL` out of Git, logs, screenshots, client components, and `NEXT_PUBLIC_*` variables. Rotate any value that is exposed. +- Keep the Better Auth host allowlist narrow. Add exact custom domains and the site-specific `*--SITE.netlify.app` preview pattern only. +- Protect deploy previews appropriately. Preview database branches are isolated, but can contain copied production-shaped data and run server code with preview-scoped environment variables. +- Grant the `admin` role sparingly. Approval and rejection actions mutate public content and are enforced from the server session's role. +- Review every migration before committing it and test it on a local database and deploy preview before production. +- The current setup enables email/password sign-up but does not configure an email delivery provider. Add verified-email and password-reset delivery before treating possession of an email address as verified identity. +- Account creation and event submission are intentionally open. Add project-appropriate rate limiting or abuse controls before a high-traffic public launch. +- Event links are restricted to `http://` and `https://`, submitted dates are interpreted in `America/Los_Angeles`, and only approved rows are queried for the public calendar. Preserve those checks when extending the workflow. + +## Official references + +- [Netlify Next.js starter `netlify.toml`](https://github.com/netlify-templates/next-platform-starter/blob/main/netlify.toml) +- [Netlify Database local development](https://docs.netlify.com/build/data-and-storage/netlify-database/local-development/) +- [Netlify Database migrations](https://docs.netlify.com/build/data-and-storage/netlify-database/migrations/) +- [Drizzle with Netlify Database](https://orm.drizzle.team/docs/connect-netlify-db) +- [Better Auth Next.js integration](https://better-auth.com/docs/integrations/next) +- [Better Auth Drizzle adapter](https://better-auth.com/docs/adapters/drizzle) +- [Better Auth dynamic base URL](https://better-auth.com/docs/guides/dynamic-base-url) +- [Better Auth CLI and `create-admin`](https://better-auth.com/docs/concepts/cli) +- [Better Auth Admin plugin](https://better-auth.com/docs/plugins/admin) diff --git a/app/account/account.module.css b/app/account/account.module.css new file mode 100644 index 0000000..eccde30 --- /dev/null +++ b/app/account/account.module.css @@ -0,0 +1,187 @@ +.page { + margin: 0 auto; + max-width: var(--site-width); + padding: clamp(3rem, 7vw, 6rem) var(--site-gutter) var(--section-space); +} + +.hero { + align-items: end; + background: var(--color-navy); + border-bottom: 0.45rem solid var(--color-bridge); + color: var(--color-cream); + display: grid; + gap: 2rem; + grid-template-columns: minmax(0, 1fr) auto; + padding: clamp(2rem, 5vw, 4rem); +} + +.eyebrow { + color: var(--color-bridge); + font-size: 0.76rem; + font-weight: 850; + letter-spacing: 0.14em; + margin: 0 0 0.7rem; + text-transform: uppercase; +} + +.hero h1, +.sectionHeading h2 { + font-family: var(--font-display); + letter-spacing: -0.05em; + line-height: 1; +} + +.hero h1 { + font-size: clamp(2.5rem, 6vw, 4.75rem); + margin: 0; +} + +.hero > div:first-child > p:last-child { + color: rgb(255 245 229 / 82%); + margin: 1rem 0 0; + max-width: 52ch; +} + +.accountActions { + align-items: stretch; + display: flex; + flex-direction: column; + gap: 0.65rem; + min-width: 12rem; +} + +.accountActions :where(a, button) { + align-items: center; + border: 2px solid var(--color-cream); + display: inline-flex; + font-weight: 800; + justify-content: center; + min-height: 3rem; + padding: 0.65rem 1rem; + text-decoration: none; +} + +.primaryAction { + background: var(--color-bridge); + border-color: var(--color-bridge) !important; + color: var(--color-navy-deep); +} + +.secondaryAction { + background: transparent; + color: var(--color-cream); +} + +.accountActions button { + background: transparent; + color: var(--color-cream); + cursor: pointer; +} + +.submissions { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding-top: clamp(3rem, 7vw, 5rem); +} + +.sectionHeading .eyebrow { + color: var(--color-bridge-deep); +} + +.sectionHeading h2 { + font-size: clamp(2rem, 4vw, 3.25rem); + margin: 0; +} + +.emptyState, +.submissionCard { + background: var(--color-paper); + border: 1px solid var(--color-line); + border-top: 0.3rem solid var(--color-bridge); + padding: clamp(1.25rem, 3vw, 2rem); +} + +.emptyState h3 { + font-family: var(--font-display); + font-size: 1.5rem; + margin: 0; +} + +.emptyState p { + color: var(--color-ink-muted); + margin: 0.65rem 0; +} + +.emptyState a { + font-weight: 800; +} + +.submissionList { + display: grid; + gap: 1rem; + list-style: none; + margin: 0; + padding: 0; +} + +.cardHeading { + align-items: start; + display: flex; + gap: 1rem; + justify-content: space-between; +} + +.cardHeading h3 { + font-family: var(--font-display); + font-size: clamp(1.25rem, 3vw, 1.75rem); + margin: 0; +} + +.cardHeading p { + color: var(--color-ink-muted); + margin: 0.35rem 0 0; +} + +.cardHeading span { + border: 2px solid var(--color-line); + font-size: 0.78rem; + font-weight: 850; + padding: 0.35rem 0.55rem; + white-space: nowrap; +} + +.cardHeading span[data-status="approved"] { + border-color: var(--color-river); +} + +.cardHeading span[data-status="rejected"] { + border-color: var(--color-poppy-deep); +} + +.reviewNote { + background: var(--color-cream); + border-left: 0.25rem solid var(--color-poppy); + margin-top: 1rem; + padding: 0.85rem 1rem; +} + +.reviewNote p { + margin: 0.25rem 0 0; +} + +@media (max-width: 44rem) { + .hero { + align-items: stretch; + grid-template-columns: 1fr; + } + + .accountActions { + min-width: 0; + } + + .cardHeading { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/app/account/page.tsx b/app/account/page.tsx new file mode 100644 index 0000000..4651048 --- /dev/null +++ b/app/account/page.tsx @@ -0,0 +1,93 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getSubmissionsForUser } from "@/lib/events/queries"; +import { requireSession, sessionIsAdmin } from "@/lib/session"; +import { SignOutButton } from "./sign-out-button"; +import style from "./account.module.css"; + +export const metadata: Metadata = { + title: "Your account", + description: "Manage your SacTech community event submissions.", +}; + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", + timeStyle: "short", + timeZone: "America/Los_Angeles", +}); + +const statusLabels = { + approved: "Approved", + pending: "Pending review", + rejected: "Needs changes", +} as const; + +export default async function AccountPage() { + const session = await requireSession(); + const submissions = await getSubmissionsForUser(session.user.id); + const isAdmin = sessionIsAdmin(session); + + return ( +
+
+
+

Your SacTech account

+

Welcome, {session.user.name}.

+

+ Submit community events, then follow their review status here. +

+
+
+ + Submit an event + + {isAdmin && ( + + Review events + + )} + +
+
+ +
+
+

Your submissions

+

Events you have sent us

+
+ + {submissions.length === 0 ? ( +
+

No events submitted yet

+

+ When you send an event for review, its status will appear here. +

+ Share the first one → +
+ ) : ( +
    + {submissions.map((submission) => ( +
  • +
    +
    +

    {submission.title}

    +

    {dateFormatter.format(submission.startsAt)}

    +
    + + {statusLabels[submission.status]} + +
    + {submission.moderationNote && ( +
    + Admin note +

    {submission.moderationNote}

    +
    + )} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/account/sign-out-button.tsx b/app/account/sign-out-button.tsx new file mode 100644 index 0000000..0d5b9ef --- /dev/null +++ b/app/account/sign-out-button.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { authClient } from "@/lib/auth-client"; +import style from "@/app/auth/auth-form.module.css"; + +const AUTH_ROUTE = "/auth"; + +interface SignOutButtonProps { + className?: string; +} + +export function SignOutButton({ className }: SignOutButtonProps) { + const router = useRouter(); + const [isPending, setIsPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + async function handleSignOut() { + setIsPending(true); + setErrorMessage(null); + + try { + const result = await authClient.signOut(); + + if (result.error) { + setErrorMessage( + result.error.message ?? "We could not sign you out. Please try again.", + ); + return; + } + + // This fixed relative path keeps post-auth navigation on this origin. + router.replace(AUTH_ROUTE); + router.refresh(); + } catch { + setErrorMessage( + "We could not reach the account service. Check your connection and try again.", + ); + } finally { + setIsPending(false); + } + } + + return ( +
+ +
+ {errorMessage &&

{errorMessage}

} +
+
+ ); +} diff --git a/app/admin/events/admin-events.module.css b/app/admin/events/admin-events.module.css new file mode 100644 index 0000000..0fbf224 --- /dev/null +++ b/app/admin/events/admin-events.module.css @@ -0,0 +1,398 @@ +.page { + background: var(--color-cream); + min-height: 100vh; +} + +.hero { + background: var(--color-navy-deep); + border-bottom: 0.45rem solid var(--color-bridge); + color: var(--color-cream); +} + +.heroInner { + margin: 0 auto; + max-width: var(--site-width); + padding: clamp(3.5rem, 7vw, 5.5rem) var(--site-gutter); +} + +.eyebrow, +.queueEyebrow, +.submittedAt { + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.13em; + margin: 0 0 0.75rem; + text-transform: uppercase; +} + +.eyebrow { + color: var(--color-bridge); +} + +.hero h1, +.queueHeader h2, +.emptyState h3, +.cardHeader h3, +.description h4, +.moderationHeading h4 { + font-family: var(--font-display); + font-weight: 900; + letter-spacing: -0.04em; +} + +.hero h1 { + font-size: clamp(3rem, 7vw, 5.5rem); + line-height: 0.97; + margin: 0; + max-width: 12ch; +} + +.heroInner > p:last-child { + color: rgb(255 245 229 / 84%); + font-size: clamp(1.05rem, 2vw, 1.2rem); + margin: 1.4rem 0 0; + max-width: 43rem; +} + +.queue { + margin: 0 auto; + max-width: var(--site-width); + padding: clamp(3.5rem, 7vw, 6rem) var(--site-gutter) var(--section-space); +} + +.queueHeader { + align-items: end; + border-bottom: 1px solid var(--color-line); + display: flex; + gap: 2rem; + justify-content: space-between; + padding-bottom: 1.5rem; +} + +.queueEyebrow { + color: var(--color-bridge-deep); +} + +.queueHeader h2 { + font-size: clamp(2.25rem, 5vw, 4rem); + line-height: 1; + margin: 0; +} + +.queueCount { + align-items: center; + background: var(--color-navy); + color: var(--color-paper); + display: inline-flex; + font-weight: 750; + gap: 0.45rem; + margin: 0; + padding: 0.65rem 0.85rem; + white-space: nowrap; +} + +.queueCount strong { + color: var(--color-bridge); + font-family: var(--font-display); + font-size: 1.3rem; +} + +.eventList { + display: grid; + gap: 2rem; + list-style: none; + margin: 2rem 0 0; + padding: 0; +} + +.eventCard { + background: var(--color-paper); + border: 1px solid var(--color-line); + border-top: 0.45rem solid var(--color-bridge); + box-shadow: var(--shadow-raised); + overflow: hidden; + padding: clamp(1.35rem, 4vw, 2.5rem); +} + +.cardHeader { + align-items: start; + display: flex; + gap: 1.5rem; + justify-content: space-between; +} + +.submittedAt { + color: var(--color-bridge-deep); +} + +.cardHeader h3 { + font-size: clamp(1.85rem, 4vw, 3rem); + line-height: 1.04; + margin: 0; +} + +.pendingBadge { + background: var(--color-bridge); + color: var(--color-navy-deep); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0.1em; + padding: 0.45rem 0.65rem; + text-transform: uppercase; +} + +.details { + display: grid; + gap: 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 2rem 0 0; +} + +.details > div { + border-top: 1px solid var(--color-line); + display: grid; + gap: 0.25rem; + grid-template-columns: minmax(7rem, 0.35fr) minmax(0, 1fr); + padding: 0.8rem 0; +} + +.details > div:nth-child(odd) { + padding-right: 1.5rem; +} + +.details > div:nth-child(even) { + border-left: 1px solid var(--color-line); + padding-left: 1.5rem; +} + +.details dt { + color: var(--color-ink-muted); + font-size: 0.78rem; + font-weight: 850; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.details dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.submitter { + display: flex; + flex-direction: column; +} + +.submitter a, +.eventLink { + color: var(--color-poppy-deep); + font-weight: 800; + text-underline-offset: 0.2rem; +} + +.description { + border-top: 1px solid var(--color-line); + margin-top: 0.25rem; + padding-top: 1.25rem; +} + +.description h4, +.moderationHeading h4 { + font-size: 1.28rem; + line-height: 1.2; + margin: 0; +} + +.description p { + color: var(--color-ink-muted); + margin: 0.65rem 0 0; + white-space: pre-wrap; +} + +.eventLink { + display: inline-flex; + gap: 0.5rem; + margin-top: 1.25rem; + min-height: 2.75rem; + align-items: center; +} + +.moderationForm { + background: var(--color-cream); + border: 1px solid var(--color-line); + border-left: 0.4rem solid var(--color-river); + display: grid; + gap: 1rem; + margin-top: 1.75rem; + padding: clamp(1rem, 3vw, 1.5rem); +} + +.moderationHeading p { + color: var(--color-ink-muted); + margin: 0.35rem 0 0; +} + +.noteField { + display: grid; + gap: 0.4rem; +} + +.noteField label { + font-weight: 850; +} + +.noteField textarea { + background: #fff; + border: 2px solid #a98d53; + border-radius: var(--radius-small); + color: var(--color-navy-deep); + line-height: 1.5; + padding: 0.7rem 0.8rem; + resize: vertical; + width: 100%; +} + +.noteField textarea:hover:not(:disabled) { + border-color: var(--color-bridge-deep); +} + +.noteField textarea[aria-invalid="true"] { + border-color: var(--color-poppy-deep); +} + +.noteField textarea:disabled { + cursor: wait; + opacity: 0.64; +} + +.noteField p { + color: var(--color-ink-muted); + font-size: 0.88rem; + margin: 0; +} + +.buttonRow { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.buttonRow button { + border: 2px solid; + cursor: pointer; + font-weight: 850; + min-height: 3rem; + padding: 0.6rem 0.9rem; +} + +.approveButton { + background: var(--color-river-deep); + border-color: var(--color-river-deep); + color: var(--color-paper); +} + +.approveButton:hover:not(:disabled) { + background: var(--color-navy); + border-color: var(--color-navy); +} + +.rejectButton { + background: transparent; + border-color: var(--color-poppy-deep); + color: var(--color-poppy-deep); +} + +.rejectButton:hover:not(:disabled) { + background: var(--color-poppy-deep); + color: var(--color-paper); +} + +.buttonRow button:disabled { + cursor: wait; + opacity: 0.64; +} + +.errorMessage, +.successMessage { + font-weight: 750; + margin: 0; + padding: 0.75rem 0.85rem; +} + +.errorMessage { + background: rgb(232 86 47 / 10%); + border-left: 0.3rem solid var(--color-poppy-deep); + color: var(--color-poppy-deep); +} + +.successMessage { + background: rgb(5 99 106 / 10%); + border-left: 0.3rem solid var(--color-river); + color: var(--color-river-deep); +} + +.emptyState { + align-items: center; + background: var(--color-paper); + border: 1px solid var(--color-line); + border-top: 0.4rem solid var(--color-bridge); + display: grid; + gap: 1.25rem; + grid-template-columns: auto minmax(0, 1fr); + margin-top: 2rem; + padding: clamp(1.5rem, 4vw, 2.5rem); +} + +.emptyMark { + background: var(--color-navy); + color: var(--color-bridge); + display: grid; + font-family: var(--font-display); + font-size: 2rem; + font-weight: 900; + height: 4.5rem; + place-items: center; + width: 4.5rem; +} + +.emptyState h3 { + font-size: clamp(1.65rem, 3vw, 2.35rem); + line-height: 1.1; + margin: 0; +} + +.emptyState p { + color: var(--color-ink-muted); + margin: 0.4rem 0 0; +} + +@media (max-width: 740px) { + .queueHeader, + .cardHeader { + align-items: start; + flex-direction: column; + } + + .details { + grid-template-columns: 1fr; + } + + .details > div:nth-child(odd), + .details > div:nth-child(even) { + border-left: 0; + padding-left: 0; + padding-right: 0; + } +} + +@media (max-width: 500px) { + .details > div { + grid-template-columns: 1fr; + } + + .buttonRow, + .buttonRow button { + width: 100%; + } +} diff --git a/app/admin/events/moderation-form.tsx b/app/admin/events/moderation-form.tsx new file mode 100644 index 0000000..7d3ea43 --- /dev/null +++ b/app/admin/events/moderation-form.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useActionState } from "react"; +import { moderateEvent } from "@/lib/events/actions"; +import { initialModerationFormState } from "@/lib/events/state"; +import style from "./admin-events.module.css"; + +interface ModerationFormProps { + eventId: string; + eventTitle: string; +} + +export function ModerationForm({ eventId, eventTitle }: ModerationFormProps) { + const moderateBoundEvent = moderateEvent.bind(null, eventId); + const [state, formAction, pending] = useActionState( + moderateBoundEvent, + initialModerationFormState, + ); + const noteId = `moderation-${eventId}-note`; + const noteHintId = `${noteId}-hint`; + + return ( +
+
+

Review decision

+

Approval publishes this event to the calendar.

+
+ +
+ +