Skip to content

Ship: landing page + collab + custom domains + forms + analytics + clone + favicon#11

Merged
ronreiter merged 15 commits into
mainfrom
ship/session-features
Apr 24, 2026
Merged

Ship: landing page + collab + custom domains + forms + analytics + clone + favicon#11
ronreiter merged 15 commits into
mainfrom
ship/session-features

Conversation

@ronreiter
Copy link
Copy Markdown
Owner

Summary

Bundles this session's work on top of ab0b24d (the previous main):

  • Collab UI: in-editor Share button, shared-modal extraction, role switcher, self-leave
  • Logout: UserMenu in every header (dashboard / team / trash / editor)
  • Public landing page at / (was a redirect to /dashboard)
  • Prompt tightening: Tabler brand-mark logo, ban emojis, ban colons in YAML titles, form conventions, favicon suggestion
  • Editor hardening: tolerate malformed YAML front-matter instead of crashing the page
  • Custom domains: site_domains table, CRUD API, host-based middleware rewrite, domains tab in share modal. DNS + platform-side attachment still manual.
  • Forms: form_submissions table, public POST /api/forms/:slug/:name, /sites/:id/forms viewer with per-form filter
  • Analytics: page_views table, fire-and-forget recording on HTML hits, /sites/:id/analytics (totals + top pages + top referrers + daily chart + recent hits)
  • Clone: cloneSite, POST /api/sites/:id/clone, duplicate icon on every dashboard card
  • Favicon: favicon_icon column, curated Tabler picker (~55 icons with search), dynamic /s/:slug/favicon.svg, injected into every published HTML

DB migrations 0002–0005 have been applied to production already; this PR only ships the code.

Test plan

  • Landing page renders at spaceforge.dev/
  • Signed-in users see "Dashboard" CTA; signed-out see "Sign in"
  • Logout menu visible in every header
  • Publish → share → forms → analytics → favicon flows work on at least one site

🤖 Generated with Claude Code

ronreiter and others added 15 commits April 24, 2026 15:19
Share now reachable from the editor TopBar instead of only the
dashboard. Extracted ShareSiteModal into src/ui/ so both callers
mount the same component, and added a role switcher per collaborator
row. Collaborators can now remove themselves from a shared site via a
Leave action; removeSiteCollaborator allows self-removal without
admin rights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a UserMenu dropdown (avatar + email + Sign out) into the shared
AppHeader so every dashboard-level page has a visible logout
affordance. Clerk mode signs out via useClerk().signOut(); dev-auth
mode shows the item disabled with a hint. The Clerk-dependent child
component is only mounted outside dev mode so useClerk() never runs
without a provider.

app/page.tsx is now a real landing page instead of a blind redirect —
hero + features + how-it-works + file-protocol snippet + final CTA,
built from Mantine components with the existing neon-on-dark theme.
Signed-in users see a "Dashboard" CTA, signed-out users see "Sign in".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
System prompts for both browser models (Gemma + Qwen) and the
server-side agent loop now:
- require a Tabler icon next to the site name in _header.njk's brand
  link (never a plain string, never an emoji, never an <img>)
- ban Unicode emoji characters anywhere in generated .md / .njk /
  front-matter fields — always use <i class="ti ti-*"></i> instead
- ban colons inside YAML front-matter values (title, description,
  subtitle, …) because a second colon on the same line breaks the
  parser; use em-dash / hyphen instead, or wrap in double quotes

The critic in the agent loop flags missing brand icons and stray
emoji so the loop self-corrects.

parseFrontMatter() now catches YAMLException and falls back to an
empty data object + raw body, so the editor can still open a page
saved with malformed front-matter instead of showing a runtime
error overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New site_domains table (domain PK, site_id FK). Owners/admins can
attach or detach domains via GET/POST/DELETE
/api/sites/:id/domains; addSiteDomain normalizes + validates, and
rejects domains already pointing at another site.

The middleware gains a host-based rewrite that runs before the
existing auth gate. For hostnames outside APP_HOSTS (spaceforge.dev,
localhost, *.vercel.app) it calls lookupSlugByDomain and rewrites
the request to /s/<slug>/<path>, so any custom domain pointed at
Spaceforge serves the published artifact without touching route
handlers. Apex/app-host requests stay a pure passthrough (no DB read).

ShareSiteModal gets a Tabs split — Collaborators + Custom domains —
so the whole "who can see this / where does it live" story is in one
place.

DNS and TLS are terminated outside the app (Vercel / CDN); attaching
a domain on the platform is still a manual step. This commit only
records the mapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…page

task dev blew up with:
  Error: Functions cannot be passed directly to Client Components
  unless you explicitly expose it with "use server"
  <... component={function LinkComponent} href=... >

The landing page (app/page.tsx) is a Server Component and was passing
next/link's Link function into Mantine <Anchor> / <Button> via the
"component" prop. Next 16's RSC serialization rejects that. Since the
landing page never gains from client-side navigation — one link per
surface, all plain hrefs — switching to native <a> is the cleanest fix:
  - <Anchor component={Link} href=...>   →  <Anchor href=...>
  - <Button component={Link} href=...>   →  <Button component="a" href=...>
Dropped the unused import Link from "next/link".

DashboardView, TrashView, TeamView, FormSubmissionsView keep their
component={Link} usage — all are 'use client' components, so the
Link function reference stays inside the client boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New form_submissions table (id, site_id, form_name, data jsonb,
user_agent, ip, created_at). Sites POST to
/api/forms/:slug/:name — a public endpoint that accepts multipart,
url-encoded, or JSON; stores the submission; and 303-redirects back
to the referer with ?submitted=<name> (or returns 201 JSON when the
caller asks for it).

Middleware publicPatterns now whitelists /api/forms/* so it works
for signed-out visitors. The custom-domain host rewrite skips
/api/* and /_next/* so forms still reach the handler under a
custom host.

Nunjucks render gains an optional `site` context ({ slug, name }).
The publish pipeline threads it through so layouts/markdown can
reference `{{ site.slug }}` — the canonical form action shape is
`/api/forms/{{ site.slug }}/<name>`.

Editor gets a Forms button in the TopBar and a dedicated
/sites/:id/forms view that lists submissions with per-form filters
and per-field rows. Middleware switches to the Node.js runtime
because pg/drizzle/@clerk/nextjs pull in node:crypto.

System prompts (Gemma + Qwen + server agent loop) now instruct the
model to wire forms to the endpoint, include a honeypot field, and
use proper labels / input types.

Acceptance verified end-to-end against a locally published site:
submit → row persists with all fields → surfaces correctly in the
UI table under the dark/neon theme.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New page_views table (id, site_id, path, referrer, user_agent, ip,
host, created_at). The public /s/:slug/... serving route records one
row per HTML hit via recordViewBestEffort — fire-and-forget, never
awaits, swallows DB errors so analytics cannot extend user-facing
latency or take the request down with it. Non-HTML asset requests
are ignored so we don't double-count the 20 secondary GETs that
come with a page load. Obvious crawler UAs (googlebot, headless
chrome, monitoring bots, …) are dropped at insert time.

getAnalyticsSummary returns totals (24h / 7d / 30d), top pages
and top referrers over 30 days, a daily series for the bar chart,
and the 50 most recent hits. Surfaced at /sites/:id/analytics —
editor TopBar gains an Analytics button next to Forms.

Middleware config merged runtime: 'nodejs' into the config object
so Next 16 + Turbopack actually honors it (a separate top-level
runtime export was being ignored in dev, bringing back the edge
crypto error).

Acceptance verified end-to-end locally: 5 real-UA hits produce
expected totals, top-page / top-referrer breakdowns, a daily chart
entry, and a populated recent-hits table in the dark-themed UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cloneSite() takes a source site the caller can see (owning team or
collaborator grant), validates the requested slug is fresh, inserts a
new sites row with the source's templateId, and deep-copies every
site_files row via cloneFilesBetweenSites — downloads each source
blob and re-uploads it under drafts/<new-id>/<path> so the clone is
fully independent. Does NOT carry over site_versions, chat_messages,
site_collaborators, or site_domains — a clone starts as a clean
unpublished draft the new owner iterates on.

POST /api/sites/:id/clone { slug, name } returns 201 with the new
SiteSummary. Dashboard grows a duplicate icon on every site card
(both team-owned and shared-with-me) that opens a Mantine modal
pre-filling "<slug>-copy" and "<name> (copy)"; on confirm it
navigates straight to the new site's editor.

Acceptance verified end-to-end locally: cloned a 6-file published
site, confirmed the new row in the DB, all 6 file paths match the
source, and spot-checked three files byte-for-byte identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New favicon_icon column on sites (nullable text, stores a Tabler icon
name without the ti- prefix). Editors manage it via a curated
FaviconModal — ~55 brand-appropriate icons grouped into Brand marks /
Food & drink / Nature / Tech / Places & things / Letters & abstract,
with a search box, a live "current" preview chip, and a Reset action.

The favicon is served dynamically from
/s/<slug>/favicon.svg: the public site route special-cases the path,
looks up the chosen icon, and renders an SVG string on the fly via
lib/favicon/render. The SVG wraps the Tabler stroke-path in a dark
rounded square with the neon accent so it reads at tab size. The
response is s-maxage=300 with SWR so it's cheap but still updates
within a few minutes of a change.

Each published HTML now carries <link rel="icon"
href="/s/<slug>/favicon.svg"> injected by injectFrameworkServer so
the brand mark shows on the site without the user writing any head
markup themselves. The model prompts (Gemma + Qwen + server agent
loop) are updated to suggest a favicon name at the end of their
reply instead of writing a link tag.

Server-side SVG renderer loads each palette icon's __iconNode via
static imports from @tabler/icons-react/dist/esm/icons — React
stays out of the server bundle, only ~55 tiny iconNode arrays land.
A root-level src/tabler-icon-modules.d.ts declares the wildcard
module shape tsc was missing.

Acceptance verified end-to-end locally: unset → default rocket
served; PUT /api/sites/:id/favicon { icon: "bread" } → 200 and the
public /favicon.svg swaps to bread; PUT with an icon outside the
palette → 400. Screenshot confirms the rendered brand-styled bread
favicon.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dashboard/team/trash pages already have UserMenu via AppHeader,
but the editor's TopBar was missing it — anyone spending their time
inside a site had no visible way to sign out. Thread the auth user
and isDevAuth flag from app/sites/[siteId]/page.tsx → SiteEditor →
App.chrome → TopBar, and mount <UserMenu> next to the color-scheme
toggle. Absent props (the localStorage-only tests) just skip the
menu, so the old embeddings still render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spaceforge Building Building Preview, Comment Apr 24, 2026 2:04pm

@ronreiter ronreiter merged commit 833fd7d into main Apr 24, 2026
2 of 3 checks passed
@ronreiter ronreiter deleted the ship/session-features branch April 24, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant