Skip to content

Commit e46d444

Browse files
ralyodioclaude
andauthored
Add /videos gallery + per-domain mp4 reel uploads from the dashboard (#10)
Public /videos page (sibling of /badges) shows a curated pair of brand reels (served from public/videos) plus any uploaded moshcoding.com reels. Page owners upload their own mp4 reels per parked domain from a new dashboard "Videos" tab. Storage reuses the existing DATA_DIR Railway-volume pattern (same volume genart caches images on): reel bytes live on the volume, metadata in a new Turso `media` table. Files stream through /api/media/[id] with HTTP range support so <video> can seek. Upload/delete are gated to the domain owner (or admin) via a shared session->account resolver extracted to lib/api. - lib/media.ts: volume storage (mp4/webm/mov, 100MB cap) - media table + CRUD in lib/db.ts - /api/media (POST upload, GET list) + /api/media/[id] (GET range-stream, DELETE) - app/videos public gallery; dashboard Videos tab - Nav + landing + README links Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d512499 commit e46d444

15 files changed

Lines changed: 520 additions & 18 deletions

File tree

.env.example

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ RESEND_FROM=moshcoding <noreply@moshcoding.com>
4545
# OpenAI — AI hero image for ?style=<genre> tenant pages (gpt-image-*).
4646
OPENAI_API_KEY=sk-xxx
4747
OPENAI_IMAGE_MODEL=gpt-image-2
48-
# Where generated images are cached. Mount a Railway volume here to persist
49-
# across deploys (otherwise images regenerate after each deploy).
48+
# Where generated images are cached AND uploaded reels (dashboard → Videos) are
49+
# stored. Mount a Railway volume here to persist across deploys (otherwise
50+
# generated images regenerate and uploaded videos are lost after each deploy).
5051
DATA_DIR=.data
52+
# Max size per uploaded reel, in bytes (default 100 MB).
53+
# MEDIA_MAX_BYTES=104857600

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ while (alive) {
1717

1818
## Brand kit
1919

20-
Everything in `images/` and `videos/` is the visual identity.
20+
Everything in `images/` and `videos/` is the visual identity. A curated pair of
21+
brand reels is served at [`/videos`](https://moshcoding.com/videos) (from
22+
`public/videos/`); page owners can also upload their own mp4 reels per parked
23+
domain from the dashboard.
2124

2225
| Asset | File |
2326
|-------|------|

app/api/account/route.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
3-
import { getAccountById, updateAccountProfile, updateAccountConfig, findOrCreateAccountByEmail, setAccountDomain, listParkedDomains } from "@/lib/db";
2+
import { getAccountById, updateAccountProfile, updateAccountConfig, setAccountDomain, listParkedDomains } from "@/lib/db";
3+
import { resolveAccountId } from "@/lib/api";
44
import { normalizeHandle, normalizeUrl, coerceRgba, parseHashtags, safeDomain } from "@/lib/config";
55
import { payUrl } from "@/lib/coinpay";
66
import { provisionTenant } from "@/lib/provision";
@@ -12,16 +12,6 @@ export const dynamic = "force-dynamic";
1212
const PLATFORMS = ["x", "bluesky", "instagram", "tiktok", "github", "youtube"];
1313
const TEXT_FIELDS = ["brand", "headline", "tagline", "sub"] as const;
1414

15-
/** Resolves the tenant account for the session: native (acct:) or CoinPay (by email). */
16-
async function resolveAccountId(req: NextRequest): Promise<string | null> {
17-
if (!authConfigured()) return null;
18-
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
19-
if (!s) return null;
20-
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
21-
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
22-
return null;
23-
}
24-
2515
function cleanWallet(v: unknown): string | null {
2616
if (typeof v !== "string") return null;
2717
const w = v.trim();

app/api/media/[id]/route.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { Readable } from "node:stream";
3+
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
4+
import { accountOwnsDomain, getMedia, deleteMedia } from "@/lib/db";
5+
import { mediaSize, mediaStream, deleteMediaFile } from "@/lib/media";
6+
7+
export const runtime = "nodejs";
8+
export const dynamic = "force-dynamic";
9+
10+
// Stream a reel with HTTP range support so <video> can seek/scrub.
11+
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
12+
const { id } = await ctx.params;
13+
const m = await getMedia(id);
14+
if (!m) return NextResponse.json({ error: "Not found" }, { status: 404 });
15+
16+
const size = mediaSize(m.filename);
17+
if (size == null) return NextResponse.json({ error: "File missing" }, { status: 404 });
18+
19+
const baseHeaders: Record<string, string> = {
20+
"content-type": m.content_type,
21+
"accept-ranges": "bytes",
22+
"cache-control": "public, max-age=31536000, immutable",
23+
};
24+
25+
const range = req.headers.get("range");
26+
const m2 = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
27+
if (m2) {
28+
let start = m2[1] ? parseInt(m2[1], 10) : 0;
29+
let end = m2[2] ? parseInt(m2[2], 10) : size - 1;
30+
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) {
31+
return new NextResponse(null, {
32+
status: 416,
33+
headers: { "content-range": `bytes */${size}`, "accept-ranges": "bytes" },
34+
});
35+
}
36+
end = Math.min(end, size - 1);
37+
const stream = Readable.toWeb(mediaStream(m.filename, start, end)) as ReadableStream;
38+
return new NextResponse(stream, {
39+
status: 206,
40+
headers: {
41+
...baseHeaders,
42+
"content-range": `bytes ${start}-${end}/${size}`,
43+
"content-length": String(end - start + 1),
44+
},
45+
});
46+
}
47+
48+
const stream = Readable.toWeb(mediaStream(m.filename)) as ReadableStream;
49+
return new NextResponse(stream, { status: 200, headers: { ...baseHeaders, "content-length": String(size) } });
50+
}
51+
52+
// Delete a reel (owner of its domain, or admin).
53+
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
54+
const accountId = await resolveAccountId(req);
55+
if (!accountId) return unauthorized();
56+
const { id } = await ctx.params;
57+
const m = await getMedia(id);
58+
if (!m) return NextResponse.json({ error: "Not found" }, { status: 404 });
59+
if (!(await accountOwnsDomain(accountId, m.dn))) return bad("You don't own that domain.", 403);
60+
61+
const removed = await deleteMedia(id);
62+
if (removed) deleteMediaFile(removed.filename);
63+
return NextResponse.json({ ok: true });
64+
}

app/api/media/route.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { randomBytes } from "node:crypto";
3+
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
4+
import { accountOwnsDomain, addMedia, listMedia, type Media } from "@/lib/db";
5+
import { safeDomain } from "@/lib/config";
6+
import { writeMedia, ALLOWED_TYPES, MAX_UPLOAD_BYTES } from "@/lib/media";
7+
8+
export const runtime = "nodejs";
9+
export const dynamic = "force-dynamic";
10+
11+
/** Public view of a reel — the streaming URL + safe metadata. */
12+
function mediaView(m: Media) {
13+
return {
14+
id: m.id,
15+
dn: m.dn,
16+
title: m.title || m.orig_name || "reel",
17+
content_type: m.content_type,
18+
size: m.size,
19+
created_at: m.created_at,
20+
url: `/api/media/${m.id}`,
21+
};
22+
}
23+
24+
// List reels for a domain (public — reels are public content).
25+
export async function GET(req: NextRequest) {
26+
const dn = safeDomain(req.nextUrl.searchParams.get("dn") || "moshcoding.com");
27+
if (!dn) return bad("valid dn required");
28+
const rows = await listMedia(dn);
29+
return NextResponse.json({ dn, media: rows.map(mediaView) });
30+
}
31+
32+
// Upload a reel to a parked domain the signed-in account owns (or as admin).
33+
export async function POST(req: NextRequest) {
34+
const accountId = await resolveAccountId(req);
35+
if (!accountId) return unauthorized();
36+
37+
const form = await req.formData().catch(() => null);
38+
if (!form) return bad("expected multipart/form-data");
39+
40+
const dn = safeDomain(form.get("dn"));
41+
if (!dn) return bad("valid dn required");
42+
if (!(await accountOwnsDomain(accountId, dn))) {
43+
return bad("You don't own that domain.", 403);
44+
}
45+
46+
const file = form.get("file");
47+
if (!(file instanceof File) || file.size === 0) return bad("file required");
48+
49+
const type = file.type || "video/mp4";
50+
const ext = ALLOWED_TYPES[type];
51+
if (!ext) return bad("Only mp4, webm or mov videos are allowed.");
52+
if (file.size > MAX_UPLOAD_BYTES) {
53+
return bad(`File too large (max ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
54+
}
55+
56+
const bytes = Buffer.from(await file.arrayBuffer());
57+
const id = randomBytes(16).toString("hex");
58+
const filename = `${id}${ext}`;
59+
await writeMedia(filename, bytes);
60+
61+
const title = String(form.get("title") || "").trim().slice(0, 120) || file.name;
62+
const media = await addMedia({
63+
id,
64+
dn,
65+
accountId,
66+
filename,
67+
origName: file.name,
68+
title,
69+
contentType: type,
70+
size: bytes.length,
71+
});
72+
return NextResponse.json({ media: mediaView(media) }, { status: 201 });
73+
}

app/dashboard/page.tsx

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import { useEffect, useState, useCallback } from "react";
2+
import { useEffect, useState, useCallback, useRef } from "react";
33
import { copyText } from "@/lib/clipboard";
44

55
type Org = { id: string; name: string };
@@ -29,7 +29,7 @@ export default function Dashboard() {
2929
const [teamOrg, setTeamOrg] = useState("");
3030
const [projName, setProjName] = useState("");
3131
const [projTeam, setProjTeam] = useState("");
32-
const [tab, setTab] = useState<"page" | "waitlist" | "auctions" | "webhooks" | "affiliates">("page");
32+
const [tab, setTab] = useState<"page" | "videos" | "waitlist" | "auctions" | "webhooks" | "affiliates">("page");
3333

3434
const say = (t: string, ok = true) => setMsg({ t, ok });
3535

@@ -84,6 +84,7 @@ export default function Dashboard() {
8484

8585
<div className="tabs">
8686
<button className={`tab${tab === "page" ? " on" : ""}`} onClick={() => setTab("page")}>Domains</button>
87+
<button className={`tab${tab === "videos" ? " on" : ""}`} onClick={() => setTab("videos")}>Videos</button>
8788
<button className={`tab${tab === "waitlist" ? " on" : ""}`} onClick={() => setTab("waitlist")}>Waitlist</button>
8889
<button className={`tab${tab === "auctions" ? " on" : ""}`} onClick={() => setTab("auctions")}>Auctions</button>
8990
<button className={`tab${tab === "webhooks" ? " on" : ""}`} onClick={() => setTab("webhooks")}>Webhooks</button>
@@ -96,6 +97,8 @@ export default function Dashboard() {
9697
<DomainWebhooksPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
9798
) : tab === "auctions" ? (
9899
<AuctionsPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
100+
) : tab === "videos" ? (
101+
<VideosPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
99102
) : tab === "waitlist" ? (
100103
<WaitlistPanel onError={(m) => say(m, false)} />
101104
) : (
@@ -462,6 +465,115 @@ function AccountPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (
462465
);
463466
}
464467

468+
function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) {
469+
const [domains, setDomains] = useState<any[] | undefined>(undefined);
470+
const [active, setActive] = useState<string | null>(null);
471+
const [reels, setReels] = useState<any[] | null>(null);
472+
const [title, setTitle] = useState("");
473+
const [file, setFile] = useState<File | null>(null);
474+
const [busy, setBusy] = useState(false);
475+
const inputRef = useRef<HTMLInputElement>(null);
476+
477+
const load = async (dn: string) => {
478+
setActive(dn); setReels(null);
479+
try {
480+
const r = await fetch(`/api/media?dn=${encodeURIComponent(dn)}`);
481+
const d = await r.json();
482+
if (!r.ok) throw new Error(d.error);
483+
setReels(d.media || []);
484+
} catch (e: any) { onError(e.message || "Failed to load."); setReels([]); }
485+
};
486+
487+
useEffect(() => {
488+
fetch("/api/account").then((r) => r.json()).then((d) => {
489+
const list = [...(d.parkedDomains || [])];
490+
// Admins can also populate the public moshcoding.com /videos gallery.
491+
if (d.account?.is_admin && !list.some((x: any) => x.domain === "moshcoding.com")) {
492+
list.unshift({ domain: "moshcoding.com" });
493+
}
494+
setDomains(list);
495+
if (list[0]) load(list[0].domain);
496+
}).catch(() => setDomains([]));
497+
// eslint-disable-next-line react-hooks/exhaustive-deps
498+
}, []);
499+
500+
const upload = async () => {
501+
if (!active || !file) return;
502+
setBusy(true);
503+
try {
504+
const fd = new FormData();
505+
fd.append("dn", active);
506+
fd.append("file", file);
507+
if (title.trim()) fd.append("title", title.trim());
508+
const r = await fetch("/api/media", { method: "POST", body: fd });
509+
const d = await r.json();
510+
if (!r.ok) throw new Error(d.error);
511+
onOk("Reel uploaded. 🤘");
512+
setTitle(""); setFile(null);
513+
if (inputRef.current) inputRef.current.value = "";
514+
await load(active);
515+
} catch (e: any) { onError(e.message || "Upload failed."); } finally { setBusy(false); }
516+
};
517+
518+
const del = async (id: string) => {
519+
if (typeof window !== "undefined" && !window.confirm("Delete this reel?")) return;
520+
setBusy(true);
521+
try {
522+
const r = await fetch(`/api/media/${id}`, { method: "DELETE" });
523+
const d = await r.json().catch(() => ({}));
524+
if (!r.ok) throw new Error(d.error || "Delete failed.");
525+
onOk("Deleted.");
526+
if (active) await load(active);
527+
} catch (e: any) { onError(e.message || "Delete failed."); } finally { setBusy(false); }
528+
};
529+
530+
if (domains === undefined) return <section className="card2"><p className="sub">Loading…</p></section>;
531+
if (!domains.length) {
532+
return <section className="card2"><h2>Videos</h2><p className="sub">No parked domains yet — claim one on the “Domains” tab and you can upload reels for it here.</p></section>;
533+
}
534+
535+
return (
536+
<section className="card2">
537+
<h2>Videos</h2>
538+
<p className="sub">Upload mp4 reels per parked domain. Reels for moshcoding.com also show on the public <a href="/videos">/videos</a> gallery.</p>
539+
<div className="tabs" style={{ flexWrap: "wrap" }}>
540+
{domains.map((d) => (
541+
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain)}>{d.domain}</button>
542+
))}
543+
</div>
544+
{active && (
545+
<>
546+
<h3 className="ed-h">Upload a reel <span className="muted">(mp4 / webm / mov, max 100 MB)</span></h3>
547+
<div className="row"><input className="inp" placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} /></div>
548+
<div className="row">
549+
<input ref={inputRef} className="inp" type="file" accept="video/mp4,video/webm,video/quicktime" onChange={(e) => setFile(e.target.files?.[0] || null)} />
550+
<button className="btn2" disabled={busy || !file} onClick={upload}>{busy ? "Uploading…" : "Upload"}</button>
551+
</div>
552+
553+
<h3 className="ed-h" style={{ marginTop: 18 }}>Reels ({reels ? reels.length : "…"})</h3>
554+
{reels === null ? (
555+
<p className="sub">Loading…</p>
556+
) : reels.length === 0 ? (
557+
<p className="sub">No reels yet — upload one above.</p>
558+
) : (
559+
<div className="video-grid">
560+
{reels.map((m) => (
561+
<figure key={m.id} className="video-cell">
562+
<video src={m.url} controls preload="metadata" playsInline />
563+
<figcaption className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
564+
<span>{m.title}</span>
565+
<button className="btn2 ghost" disabled={busy} onClick={() => del(m.id)}>Delete</button>
566+
</figcaption>
567+
</figure>
568+
))}
569+
</div>
570+
)}
571+
</>
572+
)}
573+
</section>
574+
);
575+
}
576+
465577
function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
466578
const [domains, setDomains] = useState<any[] | undefined>(undefined);
467579
const [active, setActive] = useState<string | null>(null);

app/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,14 @@ h2 { font-family: var(--display); font-weight: 400; text-transform: uppercase; f
330330
.badge-cell:hover { border-color: var(--acid); transform: translateY(-2px); }
331331
.badge-cell img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }
332332

333+
/* /videos gallery + dashboard reel tiles */
334+
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 24px; }
335+
.video-cell { margin: 0; background: #111214; border: 1px solid #262629; border-radius: 12px; overflow: hidden; transition: border-color .15s ease; }
336+
.video-cell:hover { border-color: var(--acid); }
337+
.video-cell video { display: block; width: 100%; max-height: 60vh; background: #000; aspect-ratio: 16 / 9; object-fit: contain; }
338+
.video-cell.portrait video { aspect-ratio: 9 / 16; }
339+
.video-cell figcaption { font-family: var(--mono); font-size: 11px; color: var(--ash); padding: 8px 10px; text-transform: uppercase; letter-spacing: .08em; word-break: break-word; }
340+
333341
/* Tenant "Post it" share row */
334342
.share { max-width: 460px; margin: 18px auto 0; width: 100%; }
335343
.share-h { font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ash); margin: 0 0 8px; text-align: center; }

0 commit comments

Comments
 (0)