Extending the existing platform at /Users/byegge/Documents/GitHub/WEBSITES/platform into a full Universal Music Vault — a centralized music catalog, publishing system, and streaming portal powering multiple artist sites (Yegge, Angershade, The Corruptive) with a shared backend.
The core audio streaming platform is already built and compiles cleanly. This plan adds:
cd platform
npm install react-markdown remark-gfm @tailwindcss/typography
prisma/schema.prismaNew enums (append after existing enums):
enum ReviewStatus { PENDING APPROVED REJECTED }
enum EntityType { TRACK ALBUM BLOG_POST MEDIA_FILE }
New fields on existing models:
Track: reviewStatus ReviewStatus @default(PENDING)Album: reviewStatus ReviewStatus @default(PENDING)ReviewQueueItem: targetVisibility Visibility? (needed so approve knows what visibility was requested)New models:
BlogPost — id, artistId, slug, title, content (markdown), embedUrl?, coverKey?,
seoTitle?, seoDescription?, visibility, publishedAt?, createdBy,
reviewStatus @default(PENDING), createdAt, updatedAt
@@unique([artistId, slug]) @@index([artistId, visibility, publishedAt])
BlogTag — id, name, slug @unique
BlogPostTag — @@id([blogPostId, tagId]) (join table)
MediaFile — id, artistId, filename, objectKey, mimeType, sizeBytes BigInt,
alt?, caption?, createdBy, createdAt
@@index([artistId, createdAt])
ReviewQueueItem — id, entityType, entityId, artistId, submittedBy, submittedAt @default(now()),
status @default(PENDING), reviewedBy?, reviewedAt?, notes?, targetVisibility?
@@index([status, submittedAt]) @@index([artistId, status])
Back-relations to add:
Artist: blogPosts BlogPost[], mediaFiles MediaFile[], reviewItems ReviewQueueItem[]User: blogPostsCreated BlogPost[] @relation("BlogPostCreator"), mediaFilesCreated MediaFile[] @relation("MediaFileCreator"), reviewsSubmitted ReviewQueueItem[] @relation("ReviewQueueSubmitter"), reviewsCompleted ReviewQueueItem[] @relation("ReviewQueueReviewer")Migration:
npx prisma migrate dev --name add_universal_music_vault
# Edit generated SQL to add backfill before running:
UPDATE "Track" SET "reviewStatus" = 'APPROVED' WHERE "visibility" IN ('PUBLIC','LISTED');
UPDATE "Album" SET "reviewStatus" = 'APPROVED' WHERE "visibility" IN ('PUBLIC','LISTED');
npx prisma generate
src/lib/r2.ts — append 4 helpers:getMediaSignedPutUrl(objectKey, contentType, expiresIn=300) // uses masters bucket
getMediaSignedGetUrl(objectKey, expiresIn=3600) // uses masters bucket
blogCoverKey(artistId, postId, ext) → `blog-covers/{artistId}/{postId}.{ext}`
mediaFileKey(artistId, fileId, filename) → `media/{artistId}/{fileId}/{filename}`
src/lib/permissions.ts — append:export function requiresReview(role?: string): boolean
// true if role === "COLLABORATOR"
export async function createReviewQueueItem(
entityType, entityId, artistId, submittedBy, targetVisibility?, tx?
): Promise<void>
// upsert-safe: only creates if no existing PENDING item for same entity
src/lib/utils.ts — append:// BigInt serializer for JSON responses containing sizeBytes
export const bigIntReplacer = (_: string, v: unknown) =>
typeof v === "bigint" ? v.toString() : v;
// Embed URL allowlist check
const ALLOWED_EMBED_HOSTS = ["youtube.com","youtu.be","vimeo.com","spotify.com","soundcloud.com","open.spotify.com","player.vimeo.com","www.youtube.com"];
export function isAllowedEmbedUrl(url: string): boolean
src/middleware.ts — add ADMIN_DOMAIN check before existing /admin check:const adminDomain = process.env.ADMIN_DOMAIN?.toLowerCase();
if (adminDomain && domain === adminDomain) {
return NextResponse.next(); // admin layout handles its own auth
}
tailwind.config.ts — plugins: [require("@tailwindcss/typography")].env.example — add ADMIN_DOMAIN=vault.yegge.comAll new routes follow existing conventions: auth() → assertArtistAccess() → Zod parse → Prisma → NextResponse.json().
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/artists/[artistId]/blog |
public sees PUBLIC+APPROVED | filtered by role |
| POST | /api/artists/[artistId]/blog |
write | if Collaborator: visibility held PRIVATE, reviewStatus=PENDING, queue item created |
| GET | /api/artists/[artistId]/blog/[postId] |
— | public only if PUBLIC+APPROVED |
| PATCH | /api/artists/[artistId]/blog/[postId] |
write | Collaborator visibility changes → queue; Admin → direct |
| DELETE | /api/artists/[artistId]/blog/[postId] |
admin | delete R2 coverKey too |
| POST | /api/artists/[artistId]/blog/[postId]/publish |
write | body: {visibility}; Collaborator queues, Admin publishes directly |
| POST | /api/artists/[artistId]/blog/[postId]/upload-cover |
write | returns {uploadUrl, objectKey} |
| GET/POST | /api/artists/[artistId]/blog/tags |
GET: open; POST: admin | upsert-on-name |
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/artists/[artistId]/media |
write | list MediaFiles |
| POST | /api/artists/[artistId]/media/upload-url |
write | body: {filename, contentType}; returns {uploadUrl, objectKey, fileId} |
| POST | /api/artists/[artistId]/media/confirm |
write | body: {fileId, objectKey, filename, mimeType, sizeBytes, alt?, caption?}; creates MediaFile; 409 on duplicate fileId |
| GET | /api/artists/[artistId]/media/[mediaId]/url |
write | returns short-lived signed GET URL |
| DELETE | /api/artists/[artistId]/media/[mediaId] |
write | deletes R2 object + DB record |
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /api/admin/review-queue |
admin | all PENDING items across all artists |
| POST | /api/admin/review-queue/[itemId]/approve |
admin | body: {targetVisibility?}; tx: approve item + update entity visibility |
| POST | /api/admin/review-queue/[itemId]/reject |
admin | body: {notes?}; tx: reject item + set entity reviewStatus=REJECTED |
src/components/ui/Textarea.tsx — same pattern as Input.tsx, min-h-[120px]src/components/ui/Select.tsx — same styling as Input, options: {value, label}[] propsrc/components/ui/Tabs.tsx — horizontal pill tabs, uses usePathname() for active statesrc/components/admin/ReviewQueuePanel.tsx — Client Component; table of pending items; approve/reject buttons; reject opens Modal with Textarea for notes; optimistic removal on actionsrc/components/admin/BlogPostEditor.tsx — Client Component; fields: title, slug (auto-from title), content (Textarea), embedUrl, seoTitle, seoDescription, visibility (Select), tag checkboxes, cover upload; preview tab shows react-markdown rendered output; submit: POST/PATCH blog APIsrc/components/admin/MediaLibraryGrid.tsx — Client Component; CSS grid of media cards; upload flow: file → upload-url → XHR to R2 (progress bar) → confirm → prepend to list; per-card: copy signed URL button, delete buttonsrc/components/artist/BlogCard.tsx — cover (16:9 aspect), title, excerpt (first 150 chars, markdown stripped), date, tags as Badge[]src/components/artist/BlogContent.tsx — "use client", react-markdown with remark-gfm, className="prose prose-invert max-w-none", iframe below content if embedUrl, sandbox="allow-scripts allow-same-origin allow-presentation"src/app/(admin)/admin/review-queue/page.tsx — Server Component; queries pending items; renders <ReviewQueuePanel>src/app/(admin)/admin/artists/[id]/blog/page.tsx — table of posts with status badges, links to new/editsrc/app/(admin)/admin/artists/[id]/blog/new/page.tsx — fetches all BlogTags, renders <BlogPostEditor>src/app/(admin)/admin/artists/[id]/blog/[postId]/edit/page.tsx — fetches post + tags, renders <BlogPostEditor post={...}>src/app/(admin)/admin/artists/[id]/media/page.tsx — fetches media files, renders <MediaLibraryGrid>src/app/(admin)/admin/layout.tsx — make async Server Component; query db.reviewQueueItem.count({ where: { status: "PENDING" } }); pass count to <Sidebar>src/components/admin/Sidebar.tsx — add pendingReviewCount?: number prop; add Review Queue nav item with yellow badge if count > 0src/app/(admin)/admin/artists/[id]/page.tsx — add <Tabs> at top: Albums | Blog | Mediasrc/app/(artist)/blog/page.tsx — Server Component; reads x-artist-id; queries PUBLIC+APPROVED posts; grid of <BlogCard>src/app/(artist)/blog/[slug]/page.tsx — Server Component; reads x-artist-id; queries by slug; generateMetadata; renders cover + <BlogContent>src/app/(artist)/layout.tsx — add Albums/Blog nav below existing headersrc/app/layout.tsx — title: "Universal Music Vault"bigIntReplacer in all routes returning MediaFile.sizeBytesvisibility = PRIVATE + reviewStatus = PENDING; approve uses targetVisibility stored in ReviewQueueItemcreateReviewQueueItem checks for existing PENDING item on same entity before insertingembedUrl validated server-side against ALLOWED_EMBED_HOSTS in isAllowedEmbedUrl()BlogTag slug is globally unique; POST tags endpoint upserts-on-slug instead of erroringreviewStatus = APPROVED in migration SQLvault.yegge.com middleware passes through; admin layout handles auth via its own auth() check (already present in (admin)/admin/layout.tsx)deleteMasterObject(post.coverKey) if setsrc/app/api/artists/[artistId]/blog/route.ts
src/app/api/artists/[artistId]/blog/[postId]/route.ts
src/app/api/artists/[artistId]/blog/[postId]/publish/route.ts
src/app/api/artists/[artistId]/blog/[postId]/upload-cover/route.ts
src/app/api/artists/[artistId]/blog/tags/route.ts
src/app/api/artists/[artistId]/media/route.ts
src/app/api/artists/[artistId]/media/upload-url/route.ts
src/app/api/artists/[artistId]/media/confirm/route.ts
src/app/api/artists/[artistId]/media/[mediaId]/route.ts
src/app/api/artists/[artistId]/media/[mediaId]/url/route.ts
src/app/api/admin/review-queue/route.ts
src/app/api/admin/review-queue/[itemId]/approve/route.ts
src/app/api/admin/review-queue/[itemId]/reject/route.ts
src/app/(admin)/admin/review-queue/page.tsx
src/app/(admin)/admin/artists/[id]/blog/page.tsx
src/app/(admin)/admin/artists/[id]/blog/new/page.tsx
src/app/(admin)/admin/artists/[id]/blog/[postId]/edit/page.tsx
src/app/(admin)/admin/artists/[id]/media/page.tsx
src/app/(artist)/blog/page.tsx
src/app/(artist)/blog/[slug]/page.tsx
src/components/ui/Textarea.tsx
src/components/ui/Select.tsx
src/components/ui/Tabs.tsx
src/components/admin/ReviewQueuePanel.tsx
src/components/admin/BlogPostEditor.tsx
src/components/admin/MediaLibraryGrid.tsx
src/components/artist/BlogCard.tsx
src/components/artist/BlogContent.tsx
prisma/schema.prisma — additive only (enums, fields, models)
src/middleware.ts — ADMIN_DOMAIN check
src/lib/r2.ts — 4 new helpers
src/lib/permissions.ts — requiresReview, createReviewQueueItem
src/lib/utils.ts — bigIntReplacer, isAllowedEmbedUrl
src/app/layout.tsx — title
src/app/(admin)/admin/layout.tsx — async, pending count query
src/app/(admin)/admin/artists/[id]/page.tsx — Tabs component
src/components/admin/Sidebar.tsx — Review Queue nav + badge
src/app/(artist)/layout.tsx — Albums/Blog nav links
tailwind.config.ts — typography plugin
.env.example — ADMIN_DOMAIN
package.json — new deps
npx prisma migrate dev runs clean — no errorsnpm run build passes with zero TypeScript errors/blog/blog, appears in review queue/blogvault.yegge.com (add to /etc/hosts for local test) → loads admin panel directly