feat: contribute feed, reactions, and edit flow

This commit is contained in:
Jonathan 2026-04-18 22:50:11 +02:00
parent caab3ab187
commit 40aed88525
4 changed files with 786 additions and 0 deletions

View file

@ -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' } });
};

View file

@ -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}` },
});
};

527
src/pages/contribute.astro Normal file
View file

@ -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<ContributionType, string> = {
idea: 'Idea',
inspiration: 'Inspiration',
question: 'Question',
};
const typeColors: Record<ContributionType, string> = {
idea: 'var(--pigment-copper)',
inspiration: 'var(--pigment-ochre)',
question: 'var(--pigment-indigo)',
};
---
<AppLayout title="Contribute" user={user}>
<div class="page">
<header class="page-header">
<p class="label-sm eyebrow">Contribute</p>
<h1 class="display-md page-title">The feed.</h1>
<p class="lead subtitle">
Share an idea, a question, or something that inspired you.
Everything posted here is visible to everyone in the hub.
</p>
</header>
<div class="layout">
<!-- Post form -->
<section class="post-section">
<h2 class="label-sm section-heading">Add something</h2>
{postError && (
<p class="form-error body-sm" role="alert">{postError}</p>
)}
<form method="POST" class="post-form" novalidate>
<div class="type-select">
{validTypes.map((t) => (
<label class="type-label">
<input type="radio" name="type" value={t} class="sr-only" required />
<span class="type-chip label-sm"
style={`--chip-color: ${typeColors[t]}`}
>
{typeLabels[t]}
</span>
</label>
))}
</div>
<div class="field">
<label for="body" class="sr-only">Contribution</label>
<textarea
id="body"
name="body"
class="textarea body-md"
placeholder="Share what is on your mind. Markdown supported."
rows="4"
required
maxlength="2000"
></textarea>
</div>
<div class="form-footer">
<span class="label-sm char-hint">Markdown supported. Max 2000 characters.</span>
<button type="submit" class="btn-primary label-sm">Post</button>
</div>
</form>
</section>
<!-- Feed controls -->
<div class="feed-controls">
<div class="filter-tabs">
<a
href="/contribute"
class:list={['filter-tab label-sm', { active: !filterType }]}
>All</a>
{validTypes.map((t) => (
<a
href={`/contribute?type=${t}${sort === 'top' ? '&sort=top' : ''}`}
class:list={['filter-tab label-sm', { active: filterType === t }]}
>
{typeLabels[t]}
</a>
))}
</div>
<div class="sort-tabs">
<a
href={`/contribute${filterType ? `?type=${filterType}&` : '?'}sort=newest`}
class:list={['sort-tab label-sm', { active: sort === 'newest' }]}
>Newest</a>
<a
href={`/contribute${filterType ? `?type=${filterType}&` : '?'}sort=top`}
class:list={['sort-tab label-sm', { active: sort === 'top' }]}
>Most acknowledged</a>
</div>
</div>
<!-- Feed -->
<ol class="feed" reversed aria-live="polite">
{contributions.length === 0 && (
<li class="empty-state body-md">
No contributions yet. Be the first to post.
</li>
)}
{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 (
<li class="contribution" id={`c-${c.id}`}>
<div class="c-header">
<span
class="c-type label-sm"
style={`color: ${typeColors[c.type]}`}
>
{typeLabels[c.type]}
</span>
<time class="label-sm c-time" datetime={c.created_at}>
{fmtDateTime(c.created_at)}
</time>
{c.edited_at && (
<span class="label-sm c-edited">edited</span>
)}
</div>
<div class="c-body prose" set:html={renderMd(c.body_md)} />
<div class="c-footer">
<span class="c-author label-sm">
{c.author_name}
<span class="c-org">· {c.author_organisation}</span>
</span>
<div class="c-actions">
{canEdit && (
<a href={`/contribute/edit/${c.id}`} class="action-link label-sm">
Edit
</a>
)}
<form method="POST" action={`/api/contributions/${c.id}/react`} class="react-form">
<button
type="submit"
class:list={['react-btn label-sm', { reacted: isReacted }]}
aria-label={isReacted ? 'Remove acknowledgement' : 'Acknowledge'}
>
+1 {c.reaction_count > 0 ? `· ${c.reaction_count}` : ''}
</button>
</form>
</div>
</div>
</li>
);
})}
</ol>
</div>
</div>
</AppLayout>
<style>
.page {
padding: var(--space-12) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
/* ── Header ──────────────────────────────────────────────────────── */
.page-header {
max-width: 44rem;
margin-bottom: var(--space-10);
}
.eyebrow {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-3);
}
.page-title { margin-bottom: var(--space-5); }
.subtitle {
color: var(--on-surface-variant);
max-width: var(--reading-max);
margin: 0;
}
/* ── Layout ──────────────────────────────────────────────────────── */
.layout {
display: flex;
flex-direction: column;
gap: var(--space-8);
max-width: 52rem;
}
/* ── Post form ───────────────────────────────────────────────────── */
.section-heading {
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-muted);
margin-bottom: var(--space-4);
}
.form-error {
padding: var(--space-3) var(--space-4);
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-sm);
color: var(--pigment-terracotta);
margin-bottom: var(--space-4);
}
.post-form {
display: flex;
flex-direction: column;
gap: var(--space-4);
background: var(--surface-container-lowest);
padding: var(--space-6);
border-radius: var(--radius-md);
}
/* Type chips */
.type-select {
display: flex;
gap: var(--space-2);
}
.type-label {
cursor: pointer;
}
.type-chip {
display: inline-block;
padding: 0.25em var(--space-3);
border-radius: var(--radius-full);
background: var(--surface-container);
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
user-select: none;
}
.type-label input:checked + .type-chip {
background: var(--surface-container-highest);
color: var(--chip-color);
font-weight: 600;
}
.type-label:hover .type-chip {
background: var(--surface-container-high);
}
/* Textarea */
.textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--background);
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
outline: none;
resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
box-sizing: border-box;
line-height: var(--leading-relaxed);
}
.textarea:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.textarea::placeholder { color: var(--on-surface-muted); }
.form-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
.char-hint {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
}
.btn-primary {
padding: var(--space-2) var(--space-5);
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 600;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary:hover { opacity: 0.9; }
/* ── Feed controls ───────────────────────────────────────────────── */
.feed-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
.filter-tabs,
.sort-tabs {
display: flex;
gap: var(--space-1);
}
.filter-tab,
.sort-tab {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-sm);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
transition: background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.filter-tab:hover,
.sort-tab:hover {
background: var(--surface-container-low);
color: var(--on-surface-variant);
border-bottom: none;
}
.filter-tab.active,
.sort-tab.active {
background: var(--surface-container);
color: var(--on-surface);
}
/* ── Feed items ──────────────────────────────────────────────────── */
.feed {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.contribution {
padding: var(--space-6) 0;
border-top: var(--ghost-border);
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.contribution:last-child {
border-bottom: var(--ghost-border);
}
.c-header {
display: flex;
align-items: center;
gap: var(--space-3);
}
.c-type {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
font-weight: 600;
}
.c-time {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
}
.c-edited {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
font-style: italic;
}
/* ── Prose in feed ───────────────────────────────────────────────── */
.prose :global(p) {
margin: 0 0 var(--space-3);
color: var(--on-surface-variant);
line-height: var(--leading-relaxed);
font-size: var(--text-body-md);
}
.prose :global(p:last-child) { margin-bottom: 0; }
.prose :global(strong) {
font-weight: 600;
color: var(--on-surface);
}
.prose :global(em) { font-style: italic; }
.prose :global(code) {
font-family: var(--font-mono);
font-size: 0.875em;
background: var(--surface-container);
padding: 0.15em 0.4em;
border-radius: var(--radius-sm);
}
/* ── Footer ──────────────────────────────────────────────────────── */
.c-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
}
.c-author {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
}
.c-org {
color: var(--on-surface-muted);
font-weight: 400;
}
.c-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.action-link {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.action-link:hover {
color: var(--on-surface-variant);
border-bottom: none;
}
.react-form { display: inline; }
.react-btn {
background: none;
border: var(--ghost-border);
border-radius: var(--radius-full);
padding: 0.2em var(--space-3);
font-family: var(--font-sans);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
color: var(--on-surface-muted);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
}
.react-btn:hover {
background: var(--surface-container-low);
color: var(--on-surface-variant);
}
.react-btn.reacted {
background: var(--surface-container);
color: var(--secondary);
font-weight: 600;
}
/* ── Empty state ─────────────────────────────────────────────────── */
.empty-state {
padding: var(--space-8) 0;
color: var(--on-surface-muted);
border-top: var(--ghost-border);
border-bottom: var(--ghost-border);
}
/* ── Screen reader only ──────────────────────────────────────────── */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
</style>

View file

@ -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}`);
}
}
---
<AppLayout title="Edit contribution" user={user}>
<div class="page">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/contribute" class="crumb label-sm">Contribute</a>
<span class="crumb-sep" aria-hidden="true"></span>
<span class="crumb label-sm crumb-current">Edit</span>
</nav>
<div class="content">
<h1 class="headline-md title">Edit contribution</h1>
<p class="body-md subtitle">
You have 10 minutes from the time of posting to edit. After that, it locks.
</p>
{error && (
<p class="form-error body-sm" role="alert">{error}</p>
)}
<form method="POST" class="edit-form" novalidate>
<div class="field">
<label for="body" class="label-sm field-label">Content</label>
<textarea
id="body"
name="body"
class="textarea body-md"
rows="6"
required
maxlength="2000"
>{contribution.body_md}</textarea>
</div>
<div class="form-actions">
<a href="/contribute" class="cancel-link body-sm">Cancel</a>
<button type="submit" class="btn-primary body-md">Save</button>
</div>
</form>
</div>
</div>
</AppLayout>
<style>
.page {
padding: var(--space-10) var(--space-20) var(--space-16);
max-width: var(--content-max);
margin: 0 auto;
}
.breadcrumb {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-8);
}
.crumb {
color: var(--on-surface-muted);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
a.crumb:hover { color: var(--on-surface-variant); border-bottom: none; }
.crumb-sep { color: var(--on-surface-muted); }
.crumb-current { color: var(--on-surface-variant); }
.content {
max-width: 36rem;
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.title {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
margin: 0;
}
.subtitle {
color: var(--on-surface-variant);
margin: 0;
}
.form-error {
padding: var(--space-3) var(--space-4);
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-sm);
color: var(--pigment-terracotta);
margin: 0;
}
.edit-form {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.field-label {
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
background: var(--surface-container-lowest);
border: var(--ghost-border);
border-radius: var(--radius-sm);
font-family: var(--font-sans);
font-size: var(--text-body-md);
color: var(--on-surface);
outline: none;
resize: vertical;
transition: border-color var(--duration-fast) var(--ease-standard),
box-shadow var(--duration-fast) var(--ease-standard);
box-sizing: border-box;
line-height: var(--leading-relaxed);
}
.textarea:focus {
border-color: var(--secondary);
box-shadow: 0 0 0 3px rgba(120, 95, 83, 0.12);
}
.form-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-4);
}
.cancel-link {
color: var(--on-surface-muted);
text-decoration: none;
border-bottom: none;
transition: color var(--duration-fast) var(--ease-standard);
}
.cancel-link:hover { color: var(--on-surface-variant); border-bottom: none; }
.btn-primary {
padding: var(--space-3) var(--space-6);
background: linear-gradient(180deg, var(--secondary) 0%, var(--secondary-dim) 100%);
color: var(--on-secondary);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-size: var(--text-body-md);
font-weight: 600;
cursor: pointer;
transition: opacity var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover { opacity: 0.9; }
</style>