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