New Dispatches tab — full CRUD matching the existing pattern (?tab= querystring, plain HTML POST, hidden action field, redirect-with-?msg=). Author select is restricted to Fenja-role users (defaults to the current admin). Create form has a status toggle (draft / publish on save). publish_dispatch stamps published_at via the existing helper; archive preserves it. Body is a monospace textarea so admins can see markdown without proportional kerning confusion. Participants tab gains a per-row Edit link. When ?tab=participants&edit=ID is set, the table is replaced by <UserEditTab>: title input, comma- separated focus_tags input (parsed server-side via parseFocusTags), a pull_quote textarea with a 200-char live counter, and a read-only member_number display (set on role transition to cab). The inline role dropdown + deactivate stay on the table. EventsTab — adds audience, duration_label, action_label, notes_url inputs. Kind <select> now labels office_hours as 'Studio hours' and exposes working_session as the new fifth option. Admin action-link / action-cell styles were missing on admin/index.astro (they were defined only inside per-tab components); added to the page stylesheet so the new Participants Edit link inherits the same look. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
9.3 KiB
Text
218 lines
9.3 KiB
Text
---
|
|
import type { Event } from '../../lib/db';
|
|
import { fmtDateTime } from '../../lib/markdown';
|
|
|
|
interface Props {
|
|
events: Event[];
|
|
editing: Event | null;
|
|
viewing: Event | null;
|
|
viewingRsvps: { going: number; interested: number; declined: number } | null;
|
|
}
|
|
|
|
const { events, editing, viewing, viewingRsvps } = Astro.props;
|
|
|
|
const KIND_LABEL = {
|
|
dinner: 'Dinner',
|
|
office_hours: 'Studio hours',
|
|
summit: 'Summit',
|
|
virtual: 'Virtual',
|
|
working_session: 'Working session',
|
|
} as const;
|
|
|
|
function toInputValue(sql: string | null | undefined): string {
|
|
if (!sql) return '';
|
|
return sql.replace(' ', 'T').slice(0, 16);
|
|
}
|
|
|
|
const formAction = editing ? 'update_event' : 'create_event';
|
|
---
|
|
<div class="tab-content">
|
|
|
|
{viewing && viewingRsvps ? (
|
|
<section class="section">
|
|
<a href="/admin?tab=events" class="back-link label-sm">← Back to events</a>
|
|
<h2 class="label-sm section-heading">RSVPs — {viewing.title}</h2>
|
|
<p class="body-sm muted">{fmtDateTime(viewing.starts_at)} · {viewing.location}</p>
|
|
<dl class="rsvp-summary">
|
|
<div><dt class="label-sm">Going</dt><dd class="rsvp-count">{viewingRsvps.going}</dd></div>
|
|
<div><dt class="label-sm">Interested</dt><dd class="rsvp-count">{viewingRsvps.interested}</dd></div>
|
|
<div><dt class="label-sm">Declined</dt><dd class="rsvp-count">{viewingRsvps.declined}</dd></div>
|
|
</dl>
|
|
</section>
|
|
) : (
|
|
<>
|
|
<section class="section">
|
|
<h2 class="label-sm section-heading">{editing ? 'Edit event' : 'New event'}</h2>
|
|
<form method="POST" class="invite-form" novalidate>
|
|
<input type="hidden" name="action" value={formAction} />
|
|
{editing && <input type="hidden" name="event_id" value={editing.id} />}
|
|
|
|
<div class="form-grid">
|
|
<div class="field">
|
|
<label for="title" class="label-sm field-label">Title</label>
|
|
<input type="text" id="title" name="title" class="input body-md" required value={editing?.title ?? ''} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="slug" class="label-sm field-label">Slug (URL)</label>
|
|
<input type="text" id="slug" name="slug" class="input body-md" required value={editing?.slug ?? ''} readonly={!!editing} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="kind" class="label-sm field-label">Kind</label>
|
|
<select id="kind" name="kind" class="select body-md" required>
|
|
<option value="dinner" selected={editing?.kind === 'dinner'}>Dinner</option>
|
|
<option value="office_hours" selected={editing?.kind === 'office_hours'}>Studio hours</option>
|
|
<option value="working_session" selected={editing?.kind === 'working_session'}>Working session</option>
|
|
<option value="summit" selected={editing?.kind === 'summit'}>Summit</option>
|
|
<option value="virtual" selected={editing?.kind === 'virtual'}>Virtual</option>
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label for="location" class="label-sm field-label">Location</label>
|
|
<input type="text" id="location" name="location" class="input body-md" value={editing?.location ?? ''} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="starts_at" class="label-sm field-label">Starts at (UTC)</label>
|
|
<input type="datetime-local" id="starts_at" name="starts_at" class="input body-md" required value={toInputValue(editing?.starts_at)} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="ends_at" class="label-sm field-label">Ends at (optional)</label>
|
|
<input type="datetime-local" id="ends_at" name="ends_at" class="input body-md" value={toInputValue(editing?.ends_at)} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="capacity" class="label-sm field-label">Capacity (optional)</label>
|
|
<input type="number" id="capacity" name="capacity" class="input body-md" value={editing?.capacity ?? ''} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="photo_url" class="label-sm field-label">Photo URL (optional, for past events)</label>
|
|
<input type="text" id="photo_url" name="photo_url" class="input body-md" value={editing?.photo_url ?? ''} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="audience" class="label-sm field-label">Audience (e.g. "Members only")</label>
|
|
<input type="text" id="audience" name="audience" class="input body-md" value={editing?.audience ?? ''} />
|
|
</div>
|
|
<div class="field">
|
|
<label for="duration_label" class="label-sm field-label">Duration label</label>
|
|
<input type="text" id="duration_label" name="duration_label" class="input body-md" value={editing?.duration_label ?? ''} placeholder="e.g. 30 minutes, 7pm onwards" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="action_label" class="label-sm field-label">Action label (optional)</label>
|
|
<input type="text" id="action_label" name="action_label" class="input body-md" value={editing?.action_label ?? ''} placeholder="Override the default for this event kind" />
|
|
</div>
|
|
<div class="field">
|
|
<label for="notes_url" class="label-sm field-label">Notes URL (optional)</label>
|
|
<input type="url" id="notes_url" name="notes_url" class="input body-md" value={editing?.notes_url ?? ''} placeholder="https://…" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="description" class="label-sm field-label">Description</label>
|
|
<textarea id="description" name="description" class="input body-md" rows="3">{editing?.description ?? ''}</textarea>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Create event'}</button>
|
|
{editing && <a href="/admin?tab=events" class="action-link label-sm">Cancel</a>}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="section">
|
|
<h2 class="label-sm section-heading">All events</h2>
|
|
{events.length === 0 ? (
|
|
<p class="body-sm empty-msg">No events yet.</p>
|
|
) : (
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="label-sm">Title</th>
|
|
<th class="label-sm">Kind</th>
|
|
<th class="label-sm">When</th>
|
|
<th class="label-sm">Location</th>
|
|
<th class="label-sm">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{events.map(ev => (
|
|
<tr>
|
|
<td class="body-sm">{ev.title}</td>
|
|
<td class="body-sm muted">{KIND_LABEL[ev.kind]}</td>
|
|
<td class="body-sm muted">{fmtDateTime(ev.starts_at)}</td>
|
|
<td class="body-sm muted">{ev.location || '—'}</td>
|
|
<td class="action-cell">
|
|
<a href={`/admin?tab=events&view=${ev.id}`} class="action-link label-sm">RSVPs</a>
|
|
<a href={`/admin?tab=events&edit=${ev.id}`} class="action-link label-sm">Edit</a>
|
|
<form method="POST" class="inline-form">
|
|
<input type="hidden" name="action" value="delete_event" />
|
|
<input type="hidden" name="event_id" value={ev.id} />
|
|
<button type="submit" class="danger-btn label-sm" onclick="return confirm('Delete this event?')">Delete</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
|
|
<style>
|
|
.back-link {
|
|
color: var(--on-surface-muted);
|
|
text-decoration: none;
|
|
border-bottom: none;
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
align-self: flex-start;
|
|
}
|
|
.back-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
|
|
|
.rsvp-summary {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: var(--space-6);
|
|
margin: var(--space-4) 0 0;
|
|
}
|
|
.rsvp-summary div {
|
|
background: var(--surface-container-low);
|
|
padding: var(--space-5);
|
|
border-radius: var(--radius-md);
|
|
}
|
|
.rsvp-summary dt {
|
|
letter-spacing: var(--tracking-wider);
|
|
text-transform: uppercase;
|
|
color: var(--on-surface-muted);
|
|
margin: 0 0 var(--space-2) 0;
|
|
}
|
|
.rsvp-summary dd {
|
|
font-family: var(--font-serif);
|
|
font-size: 2.5rem;
|
|
color: var(--on-surface);
|
|
margin: 0;
|
|
}
|
|
|
|
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
|
|
|
|
.action-cell {
|
|
display: flex;
|
|
gap: var(--space-3);
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.action-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--on-surface-muted);
|
|
text-decoration: none;
|
|
border-bottom: none;
|
|
letter-spacing: var(--tracking-wide);
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
font-family: var(--font-sans);
|
|
transition: color var(--duration-fast) var(--ease-standard);
|
|
padding: 0;
|
|
}
|
|
.action-link:hover { color: var(--on-surface-variant); border-bottom: none; }
|
|
</style>
|