Skip to content

Commit 61705d3

Browse files
radosukalaclaude
andcommitted
Hall Phase 2a: operator publishing tool + DB-driven posts
Posts move from in-code (src/lib/seed-posts.ts) to the database, with a full operator publishing UI at /inside/admin/posts. After this, every Hall change can be documented as a post created from the browser instead of a code commit. What ships: - New schema: hall_post_assets (links uploaded media to posts). Migration 0001_giant_thunderbird.sql applied to shared Neon. - Operator authorization via ADMIN_EMAILS env var allowlist (src/lib/operator.ts). Simple, zero-schema. When the operator team grows, this moves to a per-member role table. - Post mutations (src/lib/posts.ts) — list, fetch, create, update, delete with shared types feeding both admin UI and public ship feed. - Vercel Blob upload helper (src/lib/blob.ts) for hero images / videos. Allowed mime types: image/png, jpeg, gif, webp, svg+xml, video/mp4, webm, quicktime. Asset metadata stored in hall_post_assets. - Admin layout at /inside/admin (auth-gated to operators only). - /inside/admin/posts — list of all posts with edit links + new post CTA. Visibility badges, slug previews. - /inside/admin/posts/new — markdown editor + hero media uploader + receipts inputs (AI prompts/duration/lines/summary, commit URL, demo URL). Visibility selector (public / members / patrons). - /inside/admin/posts/[id]/edit — same editor, prefilled, with delete action. - Server actions (src/app/inside/admin/posts/_actions.ts): createPostAction, updatePostAction, deletePostAction, uploadHeroAction. All gated by getOperatorSession(). - Public refactor: src/app/page.tsx (ship feed) and src/app/p/[slug] (post detail) now read from DB via listPublishedPosts() and getPostBySlug(). Sitemap also DB-driven. - src/lib/seed-posts.ts deleted — no more in-code post array. - One-shot script scripts/seed-first-post.ts to insert the first post ("Hall is live — built in one session, in public") into the DB. Already executed against shared Neon; idempotent (skips if exists). Vercel env vars to add for Hall: - BLOB_READ_WRITE_TOKEN — Vercel Blob token (auto-set if Blob is enabled in the Vercel project settings) - ADMIN_EMAILS — comma-separated list of operator emails (e.g. "rado@our.one") Phase 2b (next session): proposals + voting UI, treasury dashboard, comments + reactions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 097db08 commit 61705d3

20 files changed

Lines changed: 1384 additions & 497 deletions

File tree

package-lock.json

Lines changed: 85 additions & 458 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@auth/drizzle-adapter": "^1.11.1",
2727
"@neondatabase/serverless": "^1.0.2",
2828
"@next/third-parties": "^16.2.1",
29+
"@vercel/blob": "^2.3.3",
2930
"drizzle-orm": "^0.45.1",
3031
"next": "16.1.6",
3132
"next-auth": "^5.0.0-beta.30",
Lines changed: 42 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import type { PostCardData } from "@/components/post-card";
2-
31
/**
4-
* Seed posts shipped with Hall on day one.
2+
* One-shot script to seed the very first Hall post into the DB.
3+
*
4+
* Run: npx dotenv -e .env.local -- tsx scripts/seed-first-post.ts
55
*
6-
* In Phase 2 these will move to the database and operators will publish via
7-
* the publishing tool. For Phase 1 they're hard-coded so Hall has content
8-
* the moment it deploys.
6+
* Idempotent: skips if a post with the slug already exists.
97
*/
10-
export const SEED_POSTS: PostCardData[] = [
11-
{
12-
slug: "hall-launches",
13-
title: "Hall is live — built in one session, in public",
14-
bodyMd: `Welcome to Our.one / Hall — the governance platform for Our.one. This is the first post on Hall, written by Hall, about Hall. The convention starts here.
8+
9+
import { findPostBySlug, createPost } from "../src/lib/posts";
10+
11+
const SEED = {
12+
slug: "hall-launches",
13+
title: "Hall is live — built in one session, in public",
14+
bodyMd: `Welcome to Our.one / Hall — the governance platform for Our.one. This is the first post on Hall, written by Hall, about Hall. The convention starts here.
1515
1616
## What just shipped
1717
@@ -40,20 +40,35 @@ People are watching gamers, watching makers, watching influencers. Most never pa
4040
If you're a Founding Patron preorder, you'll get an email when the Stripe checkout lights up. If you're a member, your $30/year unlocks everything below the fold on every post and a vote on every proposal. If you're new — sign in to look around, or [become a Founding Patron](https://our.one/join) at our.one.
4141
4242
This is the start. See you in the feed.`,
43-
heroUrl: null,
44-
heroKind: null,
45-
authorName: "Rado Sukala",
46-
productSlug: "hall",
47-
aiSession: {
48-
prompts: 2,
49-
durationMin: 90,
50-
lines: 1200,
51-
summary: "Scaffold Next.js + auth, design ship-feed card",
52-
},
53-
commitUrl: "https://github.com/Our-One/hall",
54-
demoUrl: null,
55-
voteRef: null,
56-
publishedAt: new Date("2026-04-23T07:30:00Z"),
57-
visibility: "public",
43+
heroUrl: null,
44+
heroKind: null,
45+
productSlug: "hall",
46+
aiSession: {
47+
prompts: 2,
48+
durationMin: 90,
49+
lines: 1200,
50+
summary: "Scaffold Next.js + auth, design ship-feed card",
51+
},
52+
commitUrl: "https://github.com/Our-One/hall",
53+
demoUrl: null,
54+
visibility: "public" as const,
55+
publishedAt: new Date("2026-04-23T07:30:00Z"),
56+
} satisfies Parameters<typeof createPost>[0];
57+
58+
async function main() {
59+
const existing = await findPostBySlug(SEED.slug);
60+
if (existing) {
61+
console.log(`Post '${SEED.slug}' already exists. Skipping.`);
62+
return;
63+
}
64+
const created = await createPost(SEED);
65+
console.log(`Created post '${created.slug}' (id ${created.id})`);
66+
}
67+
68+
main().then(
69+
() => process.exit(0),
70+
(err) => {
71+
console.error(err);
72+
process.exit(1);
5873
},
59-
];
74+
);

src/app/inside/admin/layout.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { redirect } from "next/navigation";
2+
import Link from "next/link";
3+
import { getOperatorSession } from "@/lib/operator";
4+
5+
export default async function AdminLayout({
6+
children,
7+
}: {
8+
children: React.ReactNode;
9+
}) {
10+
const session = await getOperatorSession();
11+
if (!session) {
12+
redirect("/inside");
13+
}
14+
15+
return (
16+
<div className="border-t border-stone-200 bg-[#F5F2EB]">
17+
<div className="mx-auto max-w-[64rem] px-6 py-6">
18+
<div className="flex items-center gap-5 font-sans text-xs">
19+
<span className="text-[10px] font-medium uppercase tracking-[0.2em] text-stone-500">
20+
Operator
21+
</span>
22+
<Link href="/inside/admin/posts" className="text-stone-700 hover:text-stone-900">
23+
Posts
24+
</Link>
25+
<Link href="/inside/admin/posts/new" className="text-stone-700 hover:text-stone-900">
26+
New post
27+
</Link>
28+
</div>
29+
</div>
30+
{children}
31+
</div>
32+
);
33+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { notFound } from "next/navigation";
2+
import { getPostById } from "@/lib/posts";
3+
import { PostEditor } from "@/components/post-editor";
4+
import { updatePostAction, deletePostAction } from "../../_actions";
5+
6+
interface Props {
7+
params: Promise<{ id: string }>;
8+
searchParams: Promise<{ saved?: string }>;
9+
}
10+
11+
export const metadata = { title: "Edit post" };
12+
13+
export default async function EditPostPage({ params, searchParams }: Props) {
14+
const { id } = await params;
15+
const { saved } = await searchParams;
16+
const post = await getPostById(id);
17+
if (!post) notFound();
18+
19+
const update = updatePostAction.bind(null, id);
20+
const remove = deletePostAction.bind(null, id);
21+
22+
return (
23+
<div className="px-6 py-12 md:py-16">
24+
<div className="mx-auto max-w-[44rem]">
25+
<h1 className="font-serif text-3xl font-bold tracking-tight text-stone-900 md:text-4xl">
26+
Edit post
27+
</h1>
28+
<p className="mt-3 font-sans text-sm text-stone-600">
29+
Editing &ldquo;{post.title}&rdquo; — public at /p/{post.slug}
30+
</p>
31+
32+
<div className="mt-10">
33+
<PostEditor
34+
initial={post}
35+
action={update}
36+
onDelete={remove}
37+
saved={saved === "1"}
38+
/>
39+
</div>
40+
</div>
41+
</div>
42+
);
43+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { redirect } from "next/navigation";
5+
import { getOperatorSession } from "@/lib/operator";
6+
import { createPost, updatePost, deletePost, findPostBySlug } from "@/lib/posts";
7+
import { uploadAsset } from "@/lib/blob";
8+
import { getDb } from "@/db/client";
9+
import { users } from "@/db/external/auth";
10+
import { eq } from "drizzle-orm";
11+
12+
interface PostFormValues {
13+
slug: string;
14+
title: string;
15+
bodyMd: string;
16+
heroUrl: string | null;
17+
heroKind: string | null;
18+
productSlug: string | null;
19+
aiSessionPrompts: number | null;
20+
aiSessionDurationMin: number | null;
21+
aiSessionLines: number | null;
22+
aiSessionSummary: string | null;
23+
commitUrl: string | null;
24+
demoUrl: string | null;
25+
visibility: "public" | "members" | "patrons";
26+
}
27+
28+
function parseValues(form: FormData): PostFormValues {
29+
const numOrNull = (v: FormDataEntryValue | null) => {
30+
if (v === null || v === "") return null;
31+
const n = Number(v);
32+
return Number.isFinite(n) ? n : null;
33+
};
34+
const strOrNull = (v: FormDataEntryValue | null) => {
35+
if (v === null) return null;
36+
const s = String(v).trim();
37+
return s.length === 0 ? null : s;
38+
};
39+
const visRaw = String(form.get("visibility") ?? "public");
40+
const visibility: PostFormValues["visibility"] =
41+
visRaw === "members" || visRaw === "patrons" ? visRaw : "public";
42+
43+
return {
44+
slug: String(form.get("slug") ?? "").trim(),
45+
title: String(form.get("title") ?? "").trim(),
46+
bodyMd: String(form.get("bodyMd") ?? ""),
47+
heroUrl: strOrNull(form.get("heroUrl")),
48+
heroKind: strOrNull(form.get("heroKind")),
49+
productSlug: strOrNull(form.get("productSlug")),
50+
aiSessionPrompts: numOrNull(form.get("aiSessionPrompts")),
51+
aiSessionDurationMin: numOrNull(form.get("aiSessionDurationMin")),
52+
aiSessionLines: numOrNull(form.get("aiSessionLines")),
53+
aiSessionSummary: strOrNull(form.get("aiSessionSummary")),
54+
commitUrl: strOrNull(form.get("commitUrl")),
55+
demoUrl: strOrNull(form.get("demoUrl")),
56+
visibility,
57+
};
58+
}
59+
60+
function buildAiSession(v: PostFormValues) {
61+
if (
62+
v.aiSessionPrompts === null &&
63+
v.aiSessionDurationMin === null &&
64+
v.aiSessionLines === null &&
65+
!v.aiSessionSummary
66+
) {
67+
return null;
68+
}
69+
return {
70+
prompts: v.aiSessionPrompts ?? 0,
71+
durationMin: v.aiSessionDurationMin ?? 0,
72+
lines: v.aiSessionLines ?? 0,
73+
summary: v.aiSessionSummary ?? undefined,
74+
};
75+
}
76+
77+
async function ensureUserId(email: string): Promise<string | null> {
78+
const db = getDb();
79+
const [u] = await db
80+
.select({ id: users.id })
81+
.from(users)
82+
.where(eq(users.email, email))
83+
.limit(1);
84+
return u?.id ?? null;
85+
}
86+
87+
export async function createPostAction(form: FormData) {
88+
const session = await getOperatorSession();
89+
if (!session?.user?.email) {
90+
throw new Error("Not authorized");
91+
}
92+
const v = parseValues(form);
93+
if (!v.title || !v.slug || !v.bodyMd) {
94+
throw new Error("Title, slug, and body are required");
95+
}
96+
const existing = await findPostBySlug(v.slug);
97+
if (existing) {
98+
throw new Error(`A post already exists at /p/${v.slug}`);
99+
}
100+
101+
const authorId = await ensureUserId(session.user.email);
102+
103+
const created = await createPost({
104+
slug: v.slug,
105+
title: v.title,
106+
bodyMd: v.bodyMd,
107+
heroUrl: v.heroUrl,
108+
heroKind: v.heroKind,
109+
productSlug: v.productSlug,
110+
aiSession: buildAiSession(v),
111+
commitUrl: v.commitUrl,
112+
demoUrl: v.demoUrl,
113+
authorId,
114+
visibility: v.visibility,
115+
});
116+
117+
revalidatePath("/");
118+
revalidatePath("/inside/admin/posts");
119+
redirect(`/inside/admin/posts/${created.id}/edit`);
120+
}
121+
122+
export async function updatePostAction(id: string, form: FormData) {
123+
const session = await getOperatorSession();
124+
if (!session?.user?.email) {
125+
throw new Error("Not authorized");
126+
}
127+
const v = parseValues(form);
128+
if (!v.title || !v.slug || !v.bodyMd) {
129+
throw new Error("Title, slug, and body are required");
130+
}
131+
132+
await updatePost(id, {
133+
slug: v.slug,
134+
title: v.title,
135+
bodyMd: v.bodyMd,
136+
heroUrl: v.heroUrl,
137+
heroKind: v.heroKind,
138+
productSlug: v.productSlug,
139+
aiSession: buildAiSession(v),
140+
commitUrl: v.commitUrl,
141+
demoUrl: v.demoUrl,
142+
visibility: v.visibility,
143+
});
144+
145+
revalidatePath("/");
146+
revalidatePath(`/p/${v.slug}`);
147+
revalidatePath("/inside/admin/posts");
148+
redirect(`/inside/admin/posts/${id}/edit?saved=1`);
149+
}
150+
151+
export async function deletePostAction(id: string) {
152+
const session = await getOperatorSession();
153+
if (!session?.user?.email) {
154+
throw new Error("Not authorized");
155+
}
156+
await deletePost(id);
157+
revalidatePath("/");
158+
revalidatePath("/inside/admin/posts");
159+
redirect("/inside/admin/posts");
160+
}
161+
162+
export async function uploadHeroAction(form: FormData): Promise<{ url: string; kind: "image" | "video" } | { error: string }> {
163+
const session = await getOperatorSession();
164+
if (!session?.user?.email) {
165+
return { error: "Not authorized" };
166+
}
167+
const file = form.get("file");
168+
if (!(file instanceof File) || file.size === 0) {
169+
return { error: "No file selected" };
170+
}
171+
const authorId = await ensureUserId(session.user.email);
172+
if (!authorId) {
173+
return { error: "User record not found" };
174+
}
175+
try {
176+
const result = await uploadAsset({
177+
file,
178+
uploadedById: authorId,
179+
altText: String(form.get("altText") ?? "") || null,
180+
});
181+
return { url: result.url, kind: result.kind };
182+
} catch (err) {
183+
const msg = err instanceof Error ? err.message : "Upload failed";
184+
return { error: msg };
185+
}
186+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { PostEditor } from "@/components/post-editor";
2+
import { createPostAction } from "../_actions";
3+
4+
export const metadata = { title: "New post" };
5+
6+
export default function NewPostPage() {
7+
return (
8+
<div className="px-6 py-12 md:py-16">
9+
<div className="mx-auto max-w-[44rem]">
10+
<h1 className="font-serif text-3xl font-bold tracking-tight text-stone-900 md:text-4xl">
11+
New post
12+
</h1>
13+
<p className="mt-3 font-sans text-sm text-stone-600">
14+
Document what just shipped. Written here, posted to the public ship feed at /, and (when relevant) the marketing /build-log.
15+
</p>
16+
17+
<div className="mt-10">
18+
<PostEditor action={createPostAction} />
19+
</div>
20+
</div>
21+
</div>
22+
);
23+
}

0 commit comments

Comments
 (0)