diff --git a/src/components/admin/DispatchesTab.astro b/src/components/admin/DispatchesTab.astro new file mode 100644 index 0000000..c66fcab --- /dev/null +++ b/src/components/admin/DispatchesTab.astro @@ -0,0 +1,175 @@ +--- +import type { DispatchWithAuthor, UserPublic } from '../../lib/db'; +import { fmtDateTime } from '../../lib/markdown'; +import { dispatchKindLabel } from '../../lib/format'; + +interface Props { + dispatches: DispatchWithAuthor[]; + editing: DispatchWithAuthor | null; + fenjaUsers: UserPublic[]; + currentUserId: number; +} + +const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props; + +const STATUS_LABEL: Record = { + draft: 'Draft', + published: 'Published', + archived: 'Archived', +}; + +const formAction = editing ? 'update_dispatch' : 'create_dispatch'; +const defaultAuthorId = editing?.author_id ?? currentUserId; +--- +
+ +
+

{editing ? 'Edit dispatch' : 'New dispatch'}

+ +
+ + {editing && } + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + {!editing && ( +
+
+ + +
+
+ )} + +
+ + {editing && Cancel} +
+
+
+ +
+

All dispatches

+ {dispatches.length === 0 ? ( +

No dispatches yet.

+ ) : ( + + + + + + + + + + + + + {dispatches.map(d => ( + + + + + + + + + ))} + +
TitleKindAuthorStatusPublishedActions
{d.title}{dispatchKindLabel(d.kind)}{d.author_name}{STATUS_LABEL[d.status]}{d.published_at ? fmtDateTime(d.published_at) : '—'} + Edit + {d.status === 'draft' && ( +
+ + + +
+ )} + {d.status === 'published' && ( +
+ + + +
+ )} +
+ + + +
+
+ )} +
+ +
+ + diff --git a/src/components/admin/EventsTab.astro b/src/components/admin/EventsTab.astro index 0d69d24..2af5188 100644 --- a/src/components/admin/EventsTab.astro +++ b/src/components/admin/EventsTab.astro @@ -12,10 +12,11 @@ interface Props { const { events, editing, viewing, viewingRsvps } = Astro.props; const KIND_LABEL = { - dinner: 'Dinner', - office_hours: 'Office hours', - summit: 'Summit', - virtual: 'Virtual', + dinner: 'Dinner', + office_hours: 'Studio hours', + summit: 'Summit', + virtual: 'Virtual', + working_session: 'Working session', } as const; function toInputValue(sql: string | null | undefined): string { @@ -58,10 +59,11 @@ const formAction = editing ? 'update_event' : 'create_event';
@@ -84,6 +86,22 @@ const formAction = editing ? 'update_event' : 'create_event';
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/src/components/admin/UserEditTab.astro b/src/components/admin/UserEditTab.astro new file mode 100644 index 0000000..10ba806 --- /dev/null +++ b/src/components/admin/UserEditTab.astro @@ -0,0 +1,90 @@ +--- +import type { UserPublic } from '../../lib/db'; +import { readFocusTags } from '../../lib/format'; + +interface Props { + member: UserPublic; +} + +const { member } = Astro.props; +const tagsStr = readFocusTags(member.focus_tags).join(', '); +--- +
+
+ ← Back to participants +

Edit member — {member.name}

+ +
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + {(member.pull_quote ?? '').length} / 200 +
+ +
+ + Cancel +
+
+ +

+ Role transitions and deactivation live in the participants table. + A member-number is allocated the first time a user becomes CAB and is never reused. +

+
+
+ + + + diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index e7c8b23..dd8c35e 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -2,24 +2,29 @@ import AppLayout from '../../layouts/AppLayout.astro'; import { getAllInvites, getAllUsersPublic, revokeInvite, - createInvite, updateUserRole, deactivateUser, - getAllJoinRequests, + createInvite, updateUserRole, deactivateUser, updateUserAdminFields, + getUserPublicById, getAllJoinRequests, createPulse, updatePulse, publishPulse, closePulse, deletePulse, getAllPulses, getPulseById, getPulseWithCounts, createRoadmapItem, updateRoadmapItem, deleteRoadmapItem, setRoadmapAttributions, getAllRoadmapItems, getRoadmapItem, createEvent, updateEvent, deleteEvent, getAllEvents, getEventBySlug, getEventRsvpCount, getEventById, + createDispatch, updateDispatch, publishDispatch, archiveDispatch, + deleteDispatch, getAllDispatchesForAdmin, getDispatchById, recordActivity, getAllActivityForAdmin, } from '../../lib/db'; import { generateInviteToken, inviteExpiresAt } from '../../lib/auth'; import { fmtDate } from '../../lib/markdown'; +import { parseFocusTags } from '../../lib/format'; import { notifyPulseOpened } from '../../lib/notify'; import PulsesTab from '../../components/admin/PulsesTab.astro'; import RoadmapTab from '../../components/admin/RoadmapTab.astro'; import EventsTab from '../../components/admin/EventsTab.astro'; import ActivityTab from '../../components/admin/ActivityTab.astro'; -import type { Role, RoadmapStatus, EventKind } from '../../lib/db'; +import DispatchesTab from '../../components/admin/DispatchesTab.astro'; +import UserEditTab from '../../components/admin/UserEditTab.astro'; +import type { Role, RoadmapStatus, EventKind, DispatchKind, DispatchStatus } from '../../lib/db'; const user = Astro.locals.user; @@ -75,6 +80,50 @@ if (Astro.request.method === 'POST') { if (userId && userId !== user.id) deactivateUser(userId); return Astro.redirect('/admin?tab=participants&msg=deactivated'); + // ── User profile edit (title / pull_quote / focus_tags) ───── + } else if (action === 'update_user_admin') { + const userId = Number(data.get('user_id')); + if (userId) { + const title = String(data.get('title') ?? '').trim() || null; + const pullQuote = String(data.get('pull_quote') ?? '').trim() || null; + const tagsInput = String(data.get('focus_tags') ?? ''); + const focusTags = parseFocusTags(tagsInput); + updateUserAdminFields(userId, { title, pull_quote: pullQuote, focus_tags: focusTags }); + } + return Astro.redirect(`/admin?tab=participants&edit=${userId}&msg=user_updated`); + + // ── Dispatches ─────────────────────────────────────────────── + } else if (action === 'create_dispatch' || action === 'update_dispatch') { + const title = String(data.get('title') ?? '').trim(); + const body = String(data.get('body') ?? ''); + const excerpt = String(data.get('excerpt') ?? '').trim() || null; + const kind = String(data.get('kind') ?? '') as DispatchKind; + const authorId = Number(data.get('author_id')); + const status = String(data.get('status') ?? 'draft') as DispatchStatus; + + if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) { + formError = 'Title, body, and a valid kind are required.'; + } else if (action === 'create_dispatch') { + createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status }); + return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created'); + } else { + const id = Number(data.get('dispatch_id')); + if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id }); + return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`); + } + } else if (action === 'publish_dispatch') { + const id = Number(data.get('dispatch_id')); + if (id) publishDispatch(id); + return Astro.redirect('/admin?tab=dispatches&msg=dispatch_published'); + } else if (action === 'archive_dispatch') { + const id = Number(data.get('dispatch_id')); + if (id) archiveDispatch(id); + return Astro.redirect('/admin?tab=dispatches&msg=dispatch_archived'); + } else if (action === 'delete_dispatch') { + const id = Number(data.get('dispatch_id')); + if (id) deleteDispatch(id); + return Astro.redirect('/admin?tab=dispatches&msg=dispatch_deleted'); + // ── Pulses ─────────────────────────────────────────────────── } else if (action === 'create_pulse' || action === 'update_pulse') { const question = String(data.get('question') ?? '').trim(); @@ -229,6 +278,12 @@ const joinRequests = getAllJoinRequests(); const editId = Number(Astro.url.searchParams.get('edit') ?? 0) || null; const viewId = Number(Astro.url.searchParams.get('view') ?? 0) || null; +const fenjaUsers = users.filter(u => u.role === 'fenja'); +const editingUser = tab === 'participants' && editId ? getUserPublicById(editId) : null; + +const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : []; +const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null; + // Per-tab data const pulses = tab === 'pulses' ? getAllPulses() : []; const pulseEditing = tab === 'pulses' && editId ? getPulseById(editId) : null; @@ -248,21 +303,27 @@ const eventViewingRsvps = tab === 'events' && viewId && eventViewing const activityRows = tab === 'activity' ? getAllActivityForAdmin(200) : []; const MSGS: Record = { - revoked: 'Invite revoked.', - updated: 'Role updated.', - deactivated: 'User deactivated.', - pulse_created: 'Pulse saved.', - pulse_updated: 'Pulse updated.', - pulse_published: 'Pulse published — members notified.', - pulse_closed: 'Pulse closed.', - pulse_deleted: 'Pulse deleted.', - roadmap_created: 'Roadmap item saved.', - roadmap_updated: 'Roadmap item updated.', - roadmap_deleted: 'Roadmap item deleted.', - roadmap_moved: 'Roadmap reordered.', - event_created: 'Event saved.', - event_updated: 'Event updated.', - event_deleted: 'Event deleted.', + revoked: 'Invite revoked.', + updated: 'Role updated.', + deactivated: 'User deactivated.', + user_updated: 'Member profile updated.', + pulse_created: 'Pulse saved.', + pulse_updated: 'Pulse updated.', + pulse_published: 'Pulse published — members notified.', + pulse_closed: 'Pulse closed.', + pulse_deleted: 'Pulse deleted.', + roadmap_created: 'Roadmap item saved.', + roadmap_updated: 'Roadmap item updated.', + roadmap_deleted: 'Roadmap item deleted.', + roadmap_moved: 'Roadmap reordered.', + event_created: 'Event saved.', + event_updated: 'Event updated.', + event_deleted: 'Event deleted.', + dispatch_created: 'Dispatch saved.', + dispatch_updated: 'Dispatch updated.', + dispatch_published: 'Dispatch published.', + dispatch_archived: 'Dispatch archived.', + dispatch_deleted: 'Dispatch deleted.', }; actionMsg = Astro.url.searchParams.get('msg'); --- @@ -279,6 +340,7 @@ actionMsg = Astro.url.searchParams.get('msg'); Pulses Roadmap Events + Dispatches Invitations Participants @@ -397,7 +459,11 @@ actionMsg = Astro.url.searchParams.get('msg'); )} - {tab === 'participants' && ( + {tab === 'participants' && editingUser && ( + + )} + + {tab === 'participants' && !editingUser && (

All participants

@@ -436,7 +502,8 @@ actionMsg = Astro.url.searchParams.get('msg'); {u.last_seen_at ? fmtDate(u.last_seen_at) : 'Never'} - + +
Edit {u.id !== user.id && (
@@ -509,6 +576,10 @@ actionMsg = Astro.url.searchParams.get('msg'); )} + {tab === 'dispatches' && ( + + )} +
@@ -791,4 +862,21 @@ actionMsg = Astro.url.searchParams.get('msg'); .danger-btn:hover { background: rgba(185, 107, 88, 0.08); } + + .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); + font-size: var(--text-label-md); + transition: color var(--duration-fast) var(--ease-standard); + padding: 0; + } + .action-link:hover { color: var(--on-surface-variant); border-bottom: none; }