From 40aed8852505a3146ef1c91b2bddedcc27281f94 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 18 Apr 2026 22:50:11 +0200 Subject: [PATCH] feat: contribute feed, reactions, and edit flow --- src/pages/api/contributions/[id]/edit.ts | 37 ++ src/pages/api/contributions/[id]/react.ts | 28 ++ src/pages/contribute.astro | 527 ++++++++++++++++++++++ src/pages/contribute/edit/[id].astro | 194 ++++++++ 4 files changed, 786 insertions(+) create mode 100644 src/pages/api/contributions/[id]/edit.ts create mode 100644 src/pages/api/contributions/[id]/react.ts create mode 100644 src/pages/contribute.astro create mode 100644 src/pages/contribute/edit/[id].astro diff --git a/src/pages/api/contributions/[id]/edit.ts b/src/pages/api/contributions/[id]/edit.ts new file mode 100644 index 0000000..34ab113 --- /dev/null +++ b/src/pages/api/contributions/[id]/edit.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; +import { getContributionById, updateContribution } from '../../../../lib/db'; +import { withinMinutes } from '../../../../lib/markdown'; + +export const POST: APIRoute = ({ locals, params, request }) => { + const user = locals.user; + const id = Number(params.id); + + if (!id || isNaN(id)) { + return new Response('Not found', { status: 404 }); + } + + const contribution = getContributionById(id); + if (!contribution || contribution.hidden_at) { + return new Response('Not found', { status: 404 }); + } + if (contribution.user_id !== user.id) { + return new Response('Forbidden', { status: 403 }); + } + if (!withinMinutes(contribution.created_at, 10)) { + return new Response('Edit window closed', { status: 403 }); + } + + return new Response(null, { status: 200 }); +}; + +export const GET: APIRoute = async ({ locals, params }) => { + const user = locals.user; + const id = Number(params.id); + + const contribution = getContributionById(id); + if (!contribution || contribution.user_id !== user.id || !withinMinutes(contribution.created_at, 10)) { + return new Response(null, { status: 302, headers: { Location: '/contribute' } }); + } + + return new Response(null, { status: 302, headers: { Location: '/contribute' } }); +}; diff --git a/src/pages/api/contributions/[id]/react.ts b/src/pages/api/contributions/[id]/react.ts new file mode 100644 index 0000000..169c371 --- /dev/null +++ b/src/pages/api/contributions/[id]/react.ts @@ -0,0 +1,28 @@ +import type { APIRoute } from 'astro'; +import { addReaction, removeReaction, hasReaction, getContributionById } from '../../../../lib/db'; + +export const POST: APIRoute = ({ locals, params, request }) => { + const user = locals.user; + const id = Number(params.id); + + if (!id || isNaN(id)) { + return new Response('Not found', { status: 404 }); + } + + const contribution = getContributionById(id); + if (!contribution || contribution.hidden_at) { + return new Response('Not found', { status: 404 }); + } + + if (hasReaction(user.id, id)) { + removeReaction(user.id, id); + } else { + addReaction(user.id, id); + } + + const referer = request.headers.get('referer') ?? '/contribute'; + return new Response(null, { + status: 302, + headers: { Location: `${referer}#c-${id}` }, + }); +}; diff --git a/src/pages/contribute.astro b/src/pages/contribute.astro new file mode 100644 index 0000000..b3d92ea --- /dev/null +++ b/src/pages/contribute.astro @@ -0,0 +1,527 @@ +--- +import AppLayout from '../layouts/AppLayout.astro'; +import { getContributions, getReactedIds } from '../lib/db'; +import { renderMd, fmtDateTime, withinMinutes } from '../lib/markdown'; +import type { ContributionType } from '../lib/db'; + +const user = Astro.locals.user; + +const rawType = Astro.url.searchParams.get('type') as ContributionType | null; +const validTypes: ContributionType[] = ['idea', 'inspiration', 'question']; +const filterType = rawType && validTypes.includes(rawType) ? rawType : undefined; + +const rawSort = Astro.url.searchParams.get('sort'); +const sort = rawSort === 'top' ? 'top' : 'newest'; + +const contributions = getContributions({ type: filterType, sort }); +const reactedIds = getReactedIds(user.id); + +// Handle POST (new contribution) +let postError: string | null = null; +if (Astro.request.method === 'POST') { + const data = await Astro.request.formData(); + const type = data.get('type') as string; + const body = String(data.get('body') ?? '').trim(); + + if (!validTypes.includes(type as ContributionType)) { + postError = 'Select a type.'; + } else if (body.length < 10) { + postError = 'Your contribution must be at least 10 characters.'; + } else if (body.length > 2000) { + postError = 'Keep it under 2000 characters.'; + } else { + const { createContribution } = await import('../lib/db'); + createContribution({ user_id: user.id, type: type as ContributionType, body_md: body }); + return Astro.redirect('/contribute'); + } +} + +const typeLabels: Record = { + idea: 'Idea', + inspiration: 'Inspiration', + question: 'Question', +}; + +const typeColors: Record = { + idea: 'var(--pigment-copper)', + inspiration: 'var(--pigment-ochre)', + question: 'var(--pigment-indigo)', +}; +--- + +
+ + + +
+ + +
+

Add something

+ {postError && ( + + )} +
+
+ {validTypes.map((t) => ( + + ))} +
+
+ + +
+ +
+
+ + +
+
+ All + {validTypes.map((t) => ( + + {typeLabels[t]} + + ))} +
+ +
+ + +
    + {contributions.length === 0 && ( +
  1. + No contributions yet. Be the first to post. +
  2. + )} + {contributions.map((c) => { + const isMine = c.user_id === user.id; + const canEdit = isMine && withinMinutes(c.created_at, 10); + const isReacted = reactedIds.has(c.id); + return ( +
  3. +
    + + {typeLabels[c.type]} + + + {c.edited_at && ( + edited + )} +
    + +
    + + +
  4. + ); + })} +
+ +
+
+
+ + diff --git a/src/pages/contribute/edit/[id].astro b/src/pages/contribute/edit/[id].astro new file mode 100644 index 0000000..a3159b0 --- /dev/null +++ b/src/pages/contribute/edit/[id].astro @@ -0,0 +1,194 @@ +--- +import AppLayout from '../../../layouts/AppLayout.astro'; +import { getContributionById, updateContribution } from '../../../lib/db'; +import { withinMinutes } from '../../../lib/markdown'; + +const user = Astro.locals.user; +const id = Number(Astro.params.id); + +const contribution = getContributionById(id); + +if ( + !contribution || + contribution.user_id !== user.id || + contribution.hidden_at || + !withinMinutes(contribution.created_at, 10) +) { + return Astro.redirect('/contribute'); +} + +let error: string | null = null; + +if (Astro.request.method === 'POST') { + const data = await Astro.request.formData(); + const body = String(data.get('body') ?? '').trim(); + + if (body.length < 10) { + error = 'Contribution must be at least 10 characters.'; + } else if (body.length > 2000) { + error = 'Keep it under 2000 characters.'; + } else { + updateContribution(id, body); + return Astro.redirect(`/contribute#c-${id}`); + } +} +--- + +
+ + +
+

Edit contribution

+

+ You have 10 minutes from the time of posting to edit. After that, it locks. +

+ + {error && ( + + )} + +
+
+ + +
+
+ Cancel + +
+
+
+
+
+ +