Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ RESEND_FROM=moshcoding <noreply@moshcoding.com>
# OpenAI — AI hero image for ?style=<genre> tenant pages (gpt-image-*).
OPENAI_API_KEY=sk-xxx
OPENAI_IMAGE_MODEL=gpt-image-2
# Where generated images are cached. Mount a Railway volume here to persist
# across deploys (otherwise images regenerate after each deploy).
# Where generated images are cached AND uploaded reels (dashboard → Videos) are
# stored. Mount a Railway volume here to persist across deploys (otherwise
# generated images regenerate and uploaded videos are lost after each deploy).
DATA_DIR=.data
# Max size per uploaded reel, in bytes (default 100 MB).
# MEDIA_MAX_BYTES=104857600
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ while (alive) {

## Brand kit

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

| Asset | File |
|-------|------|
Expand Down
14 changes: 2 additions & 12 deletions app/api/account/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { getAccountById, updateAccountProfile, updateAccountConfig, findOrCreateAccountByEmail, setAccountDomain, listParkedDomains } from "@/lib/db";
import { getAccountById, updateAccountProfile, updateAccountConfig, setAccountDomain, listParkedDomains } from "@/lib/db";
import { resolveAccountId } from "@/lib/api";
import { normalizeHandle, normalizeUrl, coerceRgba, parseHashtags, safeDomain } from "@/lib/config";
import { payUrl } from "@/lib/coinpay";
import { provisionTenant } from "@/lib/provision";
Expand All @@ -12,16 +12,6 @@ export const dynamic = "force-dynamic";
const PLATFORMS = ["x", "bluesky", "instagram", "tiktok", "github", "youtube"];
const TEXT_FIELDS = ["brand", "headline", "tagline", "sub"] as const;

/** Resolves the tenant account for the session: native (acct:) or CoinPay (by email). */
async function resolveAccountId(req: NextRequest): Promise<string | null> {
if (!authConfigured()) return null;
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
}

function cleanWallet(v: unknown): string | null {
if (typeof v !== "string") return null;
const w = v.trim();
Expand Down
64 changes: 64 additions & 0 deletions app/api/media/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { Readable } from "node:stream";
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
import { accountOwnsDomain, getMedia, deleteMedia } from "@/lib/db";
import { mediaSize, mediaStream, deleteMediaFile } from "@/lib/media";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Stream a reel with HTTP range support so <video> can seek/scrub.
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const m = await getMedia(id);
if (!m) return NextResponse.json({ error: "Not found" }, { status: 404 });

const size = mediaSize(m.filename);
if (size == null) return NextResponse.json({ error: "File missing" }, { status: 404 });

const baseHeaders: Record<string, string> = {
"content-type": m.content_type,
"accept-ranges": "bytes",
"cache-control": "public, max-age=31536000, immutable",
};

const range = req.headers.get("range");
const m2 = range ? /^bytes=(\d*)-(\d*)$/.exec(range.trim()) : null;
if (m2) {
let start = m2[1] ? parseInt(m2[1], 10) : 0;
let end = m2[2] ? parseInt(m2[2], 10) : size - 1;
if (Number.isNaN(start) || Number.isNaN(end) || start > end || start >= size) {
return new NextResponse(null, {
status: 416,
headers: { "content-range": `bytes */${size}`, "accept-ranges": "bytes" },
});
}
end = Math.min(end, size - 1);
const stream = Readable.toWeb(mediaStream(m.filename, start, end)) as ReadableStream;
return new NextResponse(stream, {
status: 206,
headers: {
...baseHeaders,
"content-range": `bytes ${start}-${end}/${size}`,
"content-length": String(end - start + 1),
},
});
}

const stream = Readable.toWeb(mediaStream(m.filename)) as ReadableStream;
return new NextResponse(stream, { status: 200, headers: { ...baseHeaders, "content-length": String(size) } });
}

// Delete a reel (owner of its domain, or admin).
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();
const { id } = await ctx.params;
const m = await getMedia(id);
if (!m) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!(await accountOwnsDomain(accountId, m.dn))) return bad("You don't own that domain.", 403);

const removed = await deleteMedia(id);
if (removed) deleteMediaFile(removed.filename);
return NextResponse.json({ ok: true });
}
73 changes: 73 additions & 0 deletions app/api/media/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "node:crypto";
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
import { accountOwnsDomain, addMedia, listMedia, type Media } from "@/lib/db";
import { safeDomain } from "@/lib/config";
import { writeMedia, ALLOWED_TYPES, MAX_UPLOAD_BYTES } from "@/lib/media";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** Public view of a reel — the streaming URL + safe metadata. */
function mediaView(m: Media) {
return {
id: m.id,
dn: m.dn,
title: m.title || m.orig_name || "reel",
content_type: m.content_type,
size: m.size,
created_at: m.created_at,
url: `/api/media/${m.id}`,
};
}

// List reels for a domain (public — reels are public content).
export async function GET(req: NextRequest) {
const dn = safeDomain(req.nextUrl.searchParams.get("dn") || "moshcoding.com");
if (!dn) return bad("valid dn required");
const rows = await listMedia(dn);
return NextResponse.json({ dn, media: rows.map(mediaView) });
}

// Upload a reel to a parked domain the signed-in account owns (or as admin).
export async function POST(req: NextRequest) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();

const form = await req.formData().catch(() => null);
if (!form) return bad("expected multipart/form-data");

const dn = safeDomain(form.get("dn"));
if (!dn) return bad("valid dn required");
if (!(await accountOwnsDomain(accountId, dn))) {
return bad("You don't own that domain.", 403);
}

const file = form.get("file");
if (!(file instanceof File) || file.size === 0) return bad("file required");

const type = file.type || "video/mp4";
const ext = ALLOWED_TYPES[type];
if (!ext) return bad("Only mp4, webm or mov videos are allowed.");
if (file.size > MAX_UPLOAD_BYTES) {
return bad(`File too large (max ${Math.floor(MAX_UPLOAD_BYTES / (1024 * 1024))} MB).`);
}

const bytes = Buffer.from(await file.arrayBuffer());
const id = randomBytes(16).toString("hex");
const filename = `${id}${ext}`;
await writeMedia(filename, bytes);

const title = String(form.get("title") || "").trim().slice(0, 120) || file.name;
const media = await addMedia({
id,
dn,
accountId,
filename,
origName: file.name,
title,
contentType: type,
size: bytes.length,
});
return NextResponse.json({ media: mediaView(media) }, { status: 201 });
}
116 changes: 114 additions & 2 deletions app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { copyText } from "@/lib/clipboard";

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

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

Expand Down Expand Up @@ -84,6 +84,7 @@ export default function Dashboard() {

<div className="tabs">
<button className={`tab${tab === "page" ? " on" : ""}`} onClick={() => setTab("page")}>Domains</button>
<button className={`tab${tab === "videos" ? " on" : ""}`} onClick={() => setTab("videos")}>Videos</button>
<button className={`tab${tab === "waitlist" ? " on" : ""}`} onClick={() => setTab("waitlist")}>Waitlist</button>
<button className={`tab${tab === "auctions" ? " on" : ""}`} onClick={() => setTab("auctions")}>Auctions</button>
<button className={`tab${tab === "webhooks" ? " on" : ""}`} onClick={() => setTab("webhooks")}>Webhooks</button>
Expand All @@ -96,6 +97,8 @@ export default function Dashboard() {
<DomainWebhooksPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
) : tab === "auctions" ? (
<AuctionsPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
) : tab === "videos" ? (
<VideosPanel onError={(m) => say(m, false)} onOk={(m) => say(m, true)} />
) : tab === "waitlist" ? (
<WaitlistPanel onError={(m) => say(m, false)} />
) : (
Expand Down Expand Up @@ -418,6 +421,115 @@ function AccountPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (
);
}

function VideosPanel({ onError, onOk }: { onError: (m: string) => void; onOk: (m: string) => void }) {
const [domains, setDomains] = useState<any[] | undefined>(undefined);
const [active, setActive] = useState<string | null>(null);
const [reels, setReels] = useState<any[] | null>(null);
const [title, setTitle] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);

const load = async (dn: string) => {
setActive(dn); setReels(null);
try {
const r = await fetch(`/api/media?dn=${encodeURIComponent(dn)}`);
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setReels(d.media || []);
} catch (e: any) { onError(e.message || "Failed to load."); setReels([]); }
};

useEffect(() => {
fetch("/api/account").then((r) => r.json()).then((d) => {
const list = [...(d.parkedDomains || [])];
// Admins can also populate the public moshcoding.com /videos gallery.
if (d.account?.is_admin && !list.some((x: any) => x.domain === "moshcoding.com")) {
list.unshift({ domain: "moshcoding.com" });
}
setDomains(list);
if (list[0]) load(list[0].domain);
}).catch(() => setDomains([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const upload = async () => {
if (!active || !file) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("dn", active);
fd.append("file", file);
if (title.trim()) fd.append("title", title.trim());
const r = await fetch("/api/media", { method: "POST", body: fd });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
onOk("Reel uploaded. 🤘");
setTitle(""); setFile(null);
if (inputRef.current) inputRef.current.value = "";
await load(active);
} catch (e: any) { onError(e.message || "Upload failed."); } finally { setBusy(false); }
};

const del = async (id: string) => {
if (typeof window !== "undefined" && !window.confirm("Delete this reel?")) return;
setBusy(true);
try {
const r = await fetch(`/api/media/${id}`, { method: "DELETE" });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || "Delete failed.");
onOk("Deleted.");
if (active) await load(active);
} catch (e: any) { onError(e.message || "Delete failed."); } finally { setBusy(false); }
};

if (domains === undefined) return <section className="card2"><p className="sub">Loading…</p></section>;
if (!domains.length) {
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>;
}

return (
<section className="card2">
<h2>Videos</h2>
<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>
<div className="tabs" style={{ flexWrap: "wrap" }}>
{domains.map((d) => (
<button key={d.domain} className={`tab${active === d.domain ? " on" : ""}`} onClick={() => load(d.domain)}>{d.domain}</button>
))}
</div>
{active && (
<>
<h3 className="ed-h">Upload a reel <span className="muted">(mp4 / webm / mov, max 100 MB)</span></h3>
<div className="row"><input className="inp" placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} /></div>
<div className="row">
<input ref={inputRef} className="inp" type="file" accept="video/mp4,video/webm,video/quicktime" onChange={(e) => setFile(e.target.files?.[0] || null)} />
<button className="btn2" disabled={busy || !file} onClick={upload}>{busy ? "Uploading…" : "Upload"}</button>
</div>

<h3 className="ed-h" style={{ marginTop: 18 }}>Reels ({reels ? reels.length : "…"})</h3>
{reels === null ? (
<p className="sub">Loading…</p>
) : reels.length === 0 ? (
<p className="sub">No reels yet — upload one above.</p>
) : (
<div className="video-grid">
{reels.map((m) => (
<figure key={m.id} className="video-cell">
<video src={m.url} controls preload="metadata" playsInline />
<figcaption className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<span>{m.title}</span>
<button className="btn2 ghost" disabled={busy} onClick={() => del(m.id)}>Delete</button>
</figcaption>
</figure>
))}
</div>
)}
</>
)}
</section>
);
}

function WaitlistPanel({ onError }: { onError: (m: string) => void }) {
const [domains, setDomains] = useState<any[] | undefined>(undefined);
const [active, setActive] = useState<string | null>(null);
Expand Down
8 changes: 8 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,14 @@ h2 { font-family: var(--display); font-weight: 400; text-transform: uppercase; f
.badge-cell:hover { border-color: var(--acid); transform: translateY(-2px); }
.badge-cell img { max-width: 100%; max-height: 100%; object-fit: contain; display: block; }

/* /videos gallery + dashboard reel tiles */
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 24px; }
.video-cell { margin: 0; background: #111214; border: 1px solid #262629; border-radius: 12px; overflow: hidden; transition: border-color .15s ease; }
.video-cell:hover { border-color: var(--acid); }
.video-cell video { display: block; width: 100%; max-height: 60vh; background: #000; aspect-ratio: 16 / 9; object-fit: contain; }
.video-cell.portrait video { aspect-ratio: 9 / 16; }
.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; }

/* Tenant "Post it" share row */
.share { max-width: 460px; margin: 18px auto 0; width: 100%; }
.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; }
Expand Down
Loading
Loading