feat: contribute feed, reactions, and edit flow
This commit is contained in:
parent
caab3ab187
commit
40aed88525
4 changed files with 786 additions and 0 deletions
37
src/pages/api/contributions/[id]/edit.ts
Normal file
37
src/pages/api/contributions/[id]/edit.ts
Normal 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' } });
|
||||||
|
};
|
||||||
28
src/pages/api/contributions/[id]/react.ts
Normal file
28
src/pages/api/contributions/[id]/react.ts
Normal 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
527
src/pages/contribute.astro
Normal 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>
|
||||||
194
src/pages/contribute/edit/[id].astro
Normal file
194
src/pages/contribute/edit/[id].astro
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue