review process and filters

This commit is contained in:
Saxon Fletcher committed 2026-03-04 14:06:34 +10:00
1 parent c1edb5834f
commit 88aa4eba0e
13 files changed
+918 -54

No files matched your search

@@ -27,7 +27,9 @@ export default async function EditItemPage({ params }: EditItemPageProps) {
const { data: item, error: itemError } = await supabase
.from('items')
.select('id, slug, title, summary, content, type, link, updated_at')
.select(
'id, slug, title, summary, content, type, url, registry_item_url, documentation_url, updated_at'
)
.eq('partner_id', partner.id)
.eq('slug', slug)
.maybeSingle()
@@ -54,7 +56,7 @@ export default async function EditItemPage({ params }: EditItemPageProps) {
const { data: latestReview, error: latestReviewError } = await supabase
.from('item_reviews')
.select('status')
.select('status, review_notes')
.eq('item_id', item.id)
.maybeSingle()
@@ -96,6 +98,7 @@ export default async function EditItemPage({ params }: EditItemPageProps) {
isApproved,
hasOpenReview,
latestReviewStatus: latestReview?.status ?? null,
latestReviewNotes: latestReview?.review_notes ?? null,
openReviewStatusLabel,
}}
/>
@@ -33,7 +33,7 @@ export default async function ReviewDetailPage({ params }: ReviewDetailPageProps
const { data: item, error: itemError } = await supabase
.from('items')
.select(
'id, slug, title, summary, link, content, type, updated_at, partner:partners(id, slug, title), review:item_reviews(status, featured, review_notes, reviewed_at)'
'id, slug, title, summary, url, registry_item_url, documentation_url, content, type, updated_at, partner:partners(id, slug, title), review:item_reviews(status, featured, review_notes, reviewed_at)'
)
.eq('id', parsedItemId)
.maybeSingle()
@@ -117,21 +117,41 @@ export default async function ReviewDetailPage({ params }: ReviewDetailPageProps
title={item.title || 'Untitled item'}
summary={item.summary}
content={item.content}
primaryActionUrl={item.type === 'template' ? item.registry_item_url : item.url}
files={marketplaceFiles}
partnerName={(item.partner as { title?: string } | null)?.title}
lastUpdatedAt={item.updated_at}
type={item.type}
metaFields={[
...(item.type === 'oauth'
? [
{
label: 'Listing URL',
value: item.url ? (
<a
href={item.url}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2"
>
{item.url}
</a>
) : (
'No URL provided'
),
},
]
: []),
{
label: 'Listing URL',
value: item.link ? (
label: 'Documentation URL',
value: item.documentation_url ? (
<a
href={item.link}
href={item.documentation_url}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2"
>
{item.link}
{item.documentation_url}
</a>
) : (
'No URL provided'
@@ -215,7 +215,7 @@ export function ReviewDecisionForm({
</Button>
) : null}
<Button type="primary" htmlType="submit" disabled={!isDirty || isPending}>
{isPending ? 'Saving...' : 'Save review'}
{isPending ? 'Sending...' : 'Send review'}
</Button>
</CardFooter>
</Card>
@@ -11,17 +11,43 @@ import {
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { ReviewsFilters } from './reviews-filters'
import { createClient } from '@/lib/supabase/server'
type ReviewsPageProps = {
params: {
partnerslug: string
}
searchParams?:
| {
status?: string
itemId?: string
}
| Promise<{
status?: string
itemId?: string
}>
}
export default async function ReviewsPage({ params }: ReviewsPageProps) {
const REVIEW_STATUSES = ['pending_review', 'approved', 'rejected', 'draft'] as const
export default async function ReviewsPage({ params, searchParams }: ReviewsPageProps) {
const { partnerslug } = params
const supabase = await createClient()
const resolvedSearchParams = searchParams ? await searchParams : undefined
const requestedStatus = resolvedSearchParams?.status ?? 'pending_review'
const statusFilter =
requestedStatus === 'all' ||
REVIEW_STATUSES.some((status) => status === requestedStatus)
? requestedStatus
: 'pending_review'
const itemIdFilter = resolvedSearchParams?.itemId?.trim() ?? ''
const parsedItemIdFilter = Number(itemIdFilter)
const hasValidItemIdFilter =
itemIdFilter.length > 0 &&
Number.isInteger(parsedItemIdFilter) &&
Number.isFinite(parsedItemIdFilter) &&
parsedItemIdFilter > 0
const { data: currentPartner, error: currentPartnerError } = await supabase
.from('partners')
@@ -33,20 +59,50 @@ export default async function ReviewsPage({ params }: ReviewsPageProps) {
notFound()
}
const { data: reviews, error: reviewsError } = await supabase
let reviewsQuery = supabase
.from('item_reviews')
.select('item_id, status, item:items(id, slug, title, partner:partners(slug, title))')
.eq('status', 'pending_review')
.select('item_id, status, item:items(id, slug, title, partner_id)')
.order('updated_at', { ascending: false })
if (statusFilter !== 'all') {
reviewsQuery = reviewsQuery.eq('status', statusFilter)
}
if (hasValidItemIdFilter) {
reviewsQuery = reviewsQuery.eq('item_id', parsedItemIdFilter)
}
const { data: reviews, error: reviewsError } = await reviewsQuery
if (reviewsError) {
throw new Error(reviewsError.message)
}
const partnerIds = Array.from(
new Set(
(reviews ?? [])
.map((review) => {
const item = Array.isArray(review.item) ? review.item[0] : review.item
return item?.partner_id
})
.filter((partnerId): partnerId is number => Number.isFinite(partnerId))
)
)
const { data: reviewPartners, error: reviewPartnersError } =
partnerIds.length > 0
? await supabase.from('partners').select('id, title').in('id', partnerIds)
: { data: [], error: null }
if (reviewPartnersError) {
throw new Error(reviewPartnersError.message)
}
const partnerTitleById = new Map((reviewPartners ?? []).map((partner) => [partner.id, partner.title]))
const reviewRows = (reviews ?? [])
.map((review) => {
const item = Array.isArray(review.item) ? review.item[0] : review.item
const partner = (item?.partner as { title?: string } | null) ?? null
if (!item?.id || !item.slug || !item.title) {
return null
@@ -57,7 +113,7 @@ export default async function ReviewsPage({ params }: ReviewsPageProps) {
itemId: item.id,
itemSlug: item.slug,
itemTitle: item.title,
partnerTitle: partner?.title ?? 'Unknown partner',
partnerTitle: partnerTitleById.get(item.partner_id) ?? 'Unknown partner',
status: review.status ?? 'pending_review',
}
})
@@ -78,15 +134,19 @@ export default async function ReviewsPage({ params }: ReviewsPageProps) {
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<ReviewsFilters status={statusFilter} itemId={itemIdFilter} />
{reviewRows.length === 0 ? (
<div className="rounded-lg border bg-card p-6 text-sm text-muted-foreground">
No pending reviews right now.
{statusFilter === 'pending_review' && !hasValidItemIdFilter
? 'No pending reviews right now.'
: 'No reviews match these filters.'}
</div>
) : (
<div className="rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Partner</TableHead>
<TableHead>Item</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Status</TableHead>
@@ -0,0 +1,87 @@
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import {
Select_Shadcn_ as Select,
SelectContent_Shadcn_ as SelectContent,
SelectItem_Shadcn_ as SelectItem,
SelectTrigger_Shadcn_ as SelectTrigger,
SelectValue_Shadcn_ as SelectValue,
} from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
type ReviewsFiltersProps = {
status: string
itemId: string
}
export function ReviewsFilters({ status, itemId }: ReviewsFiltersProps) {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const [nextStatus, setNextStatus] = useState(status)
const [nextItemId, setNextItemId] = useState(itemId)
useEffect(() => {
setNextStatus(status)
setNextItemId(itemId)
}, [status, itemId])
const pushFilters = (statusValue: string, itemIdValue: string) => {
const params = new URLSearchParams(searchParams.toString())
if (statusValue === 'pending_review') {
params.delete('status')
} else {
params.set('status', statusValue)
}
const trimmedItemId = itemIdValue.trim()
if (trimmedItemId.length === 0) {
params.delete('itemId')
} else {
params.set('itemId', trimmedItemId)
}
const query = params.toString()
router.push(query.length > 0 ? `${pathname}?${query}` : pathname)
}
return (
<div className="mb-4 flex flex-col gap-2 lg:flex-row lg:items-center">
<Select
value={nextStatus}
onValueChange={(value) => {
setNextStatus(value)
pushFilters(value, nextItemId)
}}
>
<SelectTrigger className="w-full md:w-[180px]" size="tiny">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="pending_review">Pending review</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
</SelectContent>
</Select>
<Input
value={nextItemId}
size="tiny"
type="number"
min={1}
placeholder="Filter by item ID"
className="w-full md:max-w-[220px]"
onChange={(event) => {
const value = event.target.value
setNextItemId(value)
pushFilters(nextStatus, value)
}}
/>
</div>
)
}
+204 -6
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import JSZip from 'jszip'
import { createClient } from '@/lib/supabase/server'
@@ -37,6 +38,138 @@ function parseNumberList(formData: FormData, key: string) {
)
}
function parseOptionalString(formData: FormData, key: string) {
const raw = formData.get(key)
if (typeof raw !== 'string') return null
const trimmed = raw.trim()
return trimmed ? trimmed : null
}
function parseTemplateZip(formData: FormData) {
const raw = formData.get('templateZip')
if (!(raw instanceof File) || raw.size === 0) {
return null
}
return raw
}
function normalizeTemplatePath(path: string, rootPrefix: string | null) {
const stripped = path.replace(/^\/+/, '')
if (!rootPrefix) return stripped
if (stripped === rootPrefix) return ''
const prefix = `${rootPrefix}/`
return stripped.startsWith(prefix) ? stripped.slice(prefix.length) : stripped
}
function shouldIgnoreTemplateEntry(path: string) {
const normalized = path.replace(/^\/+/, '')
const segments = normalized.split('/').filter(Boolean)
if (segments.length === 0) return true
// Ignore macOS/system artifacts commonly present in ZIP archives.
if (segments[0] === '__MACOSX') return true
const fileName = segments[segments.length - 1] ?? ''
if (fileName.toLowerCase() === '.ds_store') return true
if (fileName.startsWith('._')) return true
return false
}
async function uploadTemplatePackage({
zipFile,
supabase,
partnerId,
itemId,
}: {
zipFile: File
supabase: Awaited<ReturnType<typeof createClient>>
partnerId: number
itemId: number
}) {
const arrayBuffer = await zipFile.arrayBuffer()
const zip = await JSZip.loadAsync(arrayBuffer)
const entries = Object.values(zip.files).filter(
(entry) => !entry.dir && !shouldIgnoreTemplateEntry(entry.name)
)
if (entries.length === 0) {
throw new Error('Template package must contain files')
}
const topLevelDirs = new Set(entries.map((entry) => entry.name.split('/')[0]).filter(Boolean))
const rootPrefix = topLevelDirs.size === 1 ? Array.from(topLevelDirs)[0] ?? null : null
const normalizedEntries = entries
.map((entry) => ({
entry,
relativePath: normalizeTemplatePath(entry.name, rootPrefix),
}))
.filter((entry) => entry.relativePath.length > 0)
const hasRegistry = normalizedEntries.some((entry) => entry.relativePath === 'registry-item.json')
const hasFunctions = normalizedEntries.some((entry) => entry.relativePath.startsWith('functions/'))
const hasSchemas = normalizedEntries.some((entry) => entry.relativePath.startsWith('schemas/'))
if (!hasRegistry || !hasFunctions || !hasSchemas) {
throw new Error(
'Template package must include registry-item.json plus files in functions/ and schemas/'
)
}
const basePath = `${partnerId}/items/${itemId}/template`
const listStorageFilesRecursively = async (prefix = ''): Promise<string[]> => {
const targetPath = prefix ? `${basePath}/${prefix}` : basePath
const { data, error } = await supabase.storage.from('item_files').list(targetPath, {
limit: 1000,
sortBy: { column: 'name', order: 'asc' },
})
if (error || !data) return []
const nested = await Promise.all(
data.map(async (entry) => {
const isDirectory = entry.metadata == null
const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name
if (isDirectory) {
return listStorageFilesRecursively(nextPrefix)
}
return [`${basePath}/${nextPrefix}`]
})
)
return nested.flat()
}
const existingTemplatePaths = await listStorageFilesRecursively()
if (existingTemplatePaths.length > 0) {
const { error: removeError } = await supabase.storage.from('item_files').remove(existingTemplatePaths)
if (removeError) {
throw new Error(removeError.message)
}
}
for (const normalizedEntry of normalizedEntries) {
const blob = await normalizedEntry.entry.async('blob')
const objectPath = `${basePath}/${normalizedEntry.relativePath}`
const { error } = await supabase.storage.from('item_files').upload(objectPath, blob, {
upsert: true,
contentType: blob.type || undefined,
})
if (error) {
throw new Error(error.message)
}
}
const registryFilePath = `${basePath}/registry-item.json`
const {
data: { publicUrl },
} = supabase.storage.from('item_files').getPublicUrl(registryFilePath)
return publicUrl
}
export async function createPartnerAction(formData: FormData) {
const supabase = await createClient()
const {
@@ -175,8 +308,15 @@ export async function createItemDraftAction(formData: FormData) {
const slugInput = formData.get('slug')
const summary = formData.get('summary')
const content = formData.get('content')
const type = parseRequiredString(formData, 'type')
const link = parseRequiredString(formData, 'link')
const rawType = parseRequiredString(formData, 'type')
const type = rawType === 'oauth' ? 'oauth' : rawType === 'template' ? 'template' : null
const url = parseOptionalString(formData, 'url')
const templateZip = parseTemplateZip(formData)
const documentationUrl = formData.get('documentationUrl')
const normalizedDocumentationUrl =
typeof documentationUrl === 'string' && documentationUrl.trim()
? documentationUrl.trim()
: null
const intentRaw = formData.get('intent')
const intent = intentRaw === 'request_review' ? 'request_review' : 'save'
const slugSource = typeof slugInput === 'string' && slugInput.trim() ? slugInput : title
@@ -185,6 +325,15 @@ export async function createItemDraftAction(formData: FormData) {
if (!slug) {
throw new Error('Item slug cannot be empty')
}
if (!type) {
throw new Error('Invalid item type')
}
if (type === 'oauth' && !url) {
throw new Error('OAuth items require a listing URL')
}
if (type === 'template' && !templateZip) {
throw new Error('Template items require a template ZIP package')
}
const { data: item, error } = await supabase
.from('items')
@@ -195,7 +344,9 @@ export async function createItemDraftAction(formData: FormData) {
summary: typeof summary === 'string' ? summary : null,
content: typeof content === 'string' ? content : null,
type,
link,
url: type === 'oauth' ? url : null,
registry_item_url: null,
documentation_url: normalizedDocumentationUrl,
submitted_by: user.id,
})
.select('id, slug')
@@ -205,6 +356,23 @@ export async function createItemDraftAction(formData: FormData) {
throw new Error(error?.message ?? 'Unable to create item')
}
if (type === 'template' && templateZip) {
const registryItemUrl = await uploadTemplatePackage({
zipFile: templateZip,
supabase,
partnerId,
itemId: item.id,
})
const { error: templateUrlError } = await supabase
.from('items')
.update({ registry_item_url: registryItemUrl })
.eq('id', item.id)
if (templateUrlError) {
throw new Error(templateUrlError.message)
}
}
if (intent === 'request_review') {
const { error: reviewError } = await supabase.from('item_reviews').upsert(
{
@@ -248,13 +416,22 @@ export async function updateItemDraftAction(formData: FormData) {
}
const itemId = Number(parseRequiredString(formData, 'itemId'))
const partnerId = Number(parseRequiredString(formData, 'partnerId'))
const partnerSlug = parseRequiredString(formData, 'partnerSlug')
const name = parseRequiredString(formData, 'name')
const slugInput = formData.get('slug')
const summary = formData.get('summary')
const content = formData.get('content')
const link = parseRequiredString(formData, 'link')
const type = parseRequiredString(formData, 'type')
const url = parseOptionalString(formData, 'url')
const templateZip = parseTemplateZip(formData)
const existingRegistryItemUrl = parseOptionalString(formData, 'existingRegistryItemUrl')
const documentationUrl = formData.get('documentationUrl')
const normalizedDocumentationUrl =
typeof documentationUrl === 'string' && documentationUrl.trim()
? documentationUrl.trim()
: null
const rawType = parseRequiredString(formData, 'type')
const type = rawType === 'oauth' ? 'oauth' : rawType === 'template' ? 'template' : null
const removedFileIds = parseNumberList(formData, 'removedFileIds[]')
const slugSource = typeof slugInput === 'string' && slugInput.trim() ? slugInput : name
@@ -263,6 +440,25 @@ export async function updateItemDraftAction(formData: FormData) {
if (!slug) {
throw new Error('Item slug cannot be empty')
}
if (!type) {
throw new Error('Invalid item type')
}
if (type === 'oauth' && !url) {
throw new Error('OAuth items require a listing URL')
}
if (type === 'template' && !templateZip && !existingRegistryItemUrl) {
throw new Error('Template items require a template ZIP package')
}
const templateRegistryUrl =
type === 'template' && templateZip
? await uploadTemplatePackage({
zipFile: templateZip,
supabase,
partnerId,
itemId,
})
: existingRegistryItemUrl
const { data: item, error } = await supabase
.from('items')
@@ -271,8 +467,10 @@ export async function updateItemDraftAction(formData: FormData) {
slug,
summary: typeof summary === 'string' ? summary : null,
content: typeof content === 'string' ? content : null,
link,
url: type === 'oauth' ? url : null,
documentation_url: normalizedDocumentationUrl,
type,
registry_item_url: type === 'template' ? templateRegistryUrl : null,
})
.eq('id', itemId)
.select('slug')
@@ -1,7 +1,17 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Badge, Button, Card, CardDescription, CardHeader, CardTitle } from 'ui'
import {
Badge,
Button,
Card,
CardDescription,
CardHeader,
CardTitle,
Popover_Shadcn_ as Popover,
PopoverContent_Shadcn_ as PopoverContent,
PopoverTrigger_Shadcn_ as PopoverTrigger,
} from 'ui'
import { MarketplaceItem, type MarketplaceItemFile } from 'ui-patterns/MarketplaceItem'
import { requestItemReviewAction } from '@/app/protected/actions'
@@ -33,6 +43,7 @@ type ItemEditorSplitViewProps =
isApproved: boolean
hasOpenReview: boolean
latestReviewStatus?: string | null
latestReviewNotes?: string | null
openReviewStatusLabel?: string | null
}
}
@@ -78,7 +89,10 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
summary: props.item.summary ?? '',
content: props.item.content ?? '',
type: props.item.type === 'oauth' ? 'oauth' : 'template',
link: props.item.link,
url: props.item.url ?? '',
documentation_url: props.item.documentation_url ?? '',
files: [],
template_files: [],
}
: {
title: props.initialFormValues?.title ?? '',
@@ -86,7 +100,10 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
summary: props.initialFormValues?.summary ?? '',
content: props.initialFormValues?.content ?? '',
type: props.initialFormValues?.type === 'oauth' ? 'oauth' : 'template',
link: props.initialFormValues?.link ?? '',
url: props.initialFormValues?.url ?? '',
documentation_url: props.initialFormValues?.documentation_url ?? '',
files: [],
template_files: [],
}
const [previewValues, setPreviewValues] = useState<ItemFormValues>(baseValues)
@@ -103,12 +120,41 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
const reviewControl =
props.mode === 'edit' && props.reviewRequest ? (
props.reviewRequest.hasOpenReview ? (
props.reviewRequest.isApproved ? (
<Badge variant="success">Approved</Badge>
) : props.reviewRequest.latestReviewStatus === 'rejected' ? (
<Badge variant="destructive">Rejected</Badge>
) : props.reviewRequest.openReviewStatusLabel ? (
props.reviewRequest.isApproved ? (
<Badge variant="success">Approved</Badge>
) : props.reviewRequest.latestReviewStatus === 'rejected' ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-full"
>
<Badge variant="destructive">Rejected</Badge>
</button>
</PopoverTrigger>
<PopoverContent align="end" className="space-y-3">
<div className="space-y-2 mb-4">
<p className="heading-default">Review feedback</p>
<p className="text-sm text-foreground-light whitespace-pre-wrap pl-4 border-l-2">
{props.reviewRequest.latestReviewNotes?.trim()
? props.reviewRequest.latestReviewNotes
: 'No rejection reason was provided for this review.'}
</p>
</div>
<div>
<form action={requestItemReviewAction}>
<input type="hidden" name="itemId" value={props.reviewRequest.itemId} />
<input type="hidden" name="itemSlug" value={props.reviewRequest.itemSlug} />
<input type="hidden" name="partnerSlug" value={props.reviewRequest.partnerSlug} />
<Button htmlType="submit" type="secondary" className="w-full">
Re-request review
</Button>
</form>
</div>
</PopoverContent>
</Popover>
) : props.reviewRequest.hasOpenReview ? (
props.reviewRequest.openReviewStatusLabel ? (
<Badge variant="warning">{props.reviewRequest.openReviewStatusLabel}</Badge>
) : (
<Badge>Review requested</Badge>
@@ -129,7 +175,7 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
<div className="flex h-full min-h-full min-w-0">
<section className="w-4xl min-w-2xl overflow-y-auto border-r">
<Card className="flex h-full w-full flex-col rounded-none border-none">
<CardHeader className="shrink-0 border-b">
<CardHeader className="shrink-0 border-b px-6">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>
@@ -162,7 +208,7 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
</Card>
</section>
<section className="min-w-0 flex-1 h-full p-6 overflow-hidden bg-muted/20">
<section className="min-w-0 flex-1 h-full p-6 overflow-hidden bg-muted/50">
<div className="rounded-lg border bg-background shadow-sm h-full flex flex-col">
<div className="flex items-center h-10 border-b px-4 shrink-0">
<div className="flex items-center gap-1.5">
@@ -185,14 +231,29 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
title={previewValues.title || 'Untitled item'}
summary={previewValues.summary}
content={previewValues.content}
primaryActionUrl={
previewValues.type === 'oauth'
? previewValues.url
: props.mode === 'edit'
? props.item.registry_item_url
: null
}
files={previewFiles}
partnerName={props.partner.title}
lastUpdatedAt={props.mode === 'edit' ? props.item.updated_at : null}
type={previewValues.type}
metaFields={[
...(previewValues.type === 'oauth'
? [
{
label: 'Listing URL',
value: maybeRenderLink(previewValues.url ?? ''),
},
]
: []),
{
label: 'Listing URL',
value: maybeRenderLink(previewValues.link),
label: 'Documentation URL',
value: maybeRenderLink(previewValues.documentation_url ?? ''),
},
]}
/>
@@ -62,7 +62,7 @@ export function ItemFilesUploader({
const storagePath = useMemo(() => {
if (!itemId) return undefined
return `${partnerId}/items/${itemId}`
return `${partnerId}/items/${itemId}/files`
}, [itemId, partnerId])
const upload = useSupabaseUpload({
+401 -16
View File
@@ -1,10 +1,12 @@
'use client'
import JSZip from 'jszip'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import {
Button,
flattenTree,
Form_Shadcn_ as Form,
FormControl_Shadcn_ as FormControl,
FormField_Shadcn_ as FormField,
@@ -12,12 +14,15 @@ import {
RadioGroupStacked,
RadioGroupStackedItem,
TextArea_Shadcn_ as TextArea,
TreeView,
TreeViewItem,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { createItemDraftAction, updateItemDraftAction } from '@/app/protected/actions'
import { ItemFilesUploader, type ItemPreviewFile } from '@/components/item-files-uploader'
import { createClient } from '@/lib/supabase/client'
export type ItemFile = {
id: number
@@ -37,7 +42,9 @@ export type ItemInfo = {
summary: string | null
content: string | null
type: string
link: string
url: string | null
registry_item_url: string | null
documentation_url: string | null
}
type BaseProps = {
@@ -66,13 +73,99 @@ type SubmitResult = {
const itemTypeEnum = z.enum(['template', 'oauth'])
function areStringArraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
type TemplateTreeNode = {
name: string
children: TemplateTreeNode[]
metadata?: {
sourcePath: string
isFile: boolean
}
}
function getTemplatePathSegments(pathOrUrl: string) {
const trimmed = pathOrUrl.trim()
if (!trimmed) return []
return trimmed.split('/').filter(Boolean)
}
function isIgnoredTemplatePath(path: string) {
const normalized = path.replace(/^\/+/, '')
const segments = normalized.split('/').filter(Boolean)
if (segments.length === 0) return true
if (segments[0] === '__MACOSX') return true
const fileName = segments[segments.length - 1] ?? ''
if (fileName.toLowerCase() === '.ds_store') return true
if (fileName.startsWith('._')) return true
return false
}
function normalizeTemplatePath(path: string, rootPrefix: string | null) {
const stripped = path.replace(/^\/+/, '')
if (!rootPrefix) return stripped
if (stripped === rootPrefix) return ''
const prefix = `${rootPrefix}/`
return stripped.startsWith(prefix) ? stripped.slice(prefix.length) : stripped
}
function buildTemplateTree(paths: string[]) {
const root: TemplateTreeNode[] = []
paths.forEach((path) => {
const segments = getTemplatePathSegments(path)
if (segments.length === 0) return
let cursor = root
segments.forEach((segment, index) => {
const isLeaf = index === segments.length - 1
const existing = cursor.find((node) => node.name === segment)
if (existing) {
if (isLeaf) {
existing.metadata = { sourcePath: path, isFile: true }
}
cursor = existing.children
return
}
const nextNode: TemplateTreeNode = {
name: segment,
children: [],
metadata: isLeaf ? { sourcePath: path, isFile: true } : { sourcePath: path, isFile: false },
}
cursor.push(nextNode)
cursor = nextNode.children
})
})
return root
}
const itemFormSchema = z.object({
title: z.string().min(1, 'Item name is required'),
slug: z.string().optional(),
summary: z.string().optional(),
content: z.string().optional(),
type: itemTypeEnum,
link: z.string().url('Enter a valid URL'),
url: z
.string()
.optional()
.refine((value) => !value || Boolean(z.string().url().safeParse(value).success), {
message: 'Enter a valid URL',
}),
documentation_url: z
.string()
.optional()
.refine((value) => !value || Boolean(z.string().url().safeParse(value).success), {
message: 'Enter a valid URL',
}),
files: z.array(z.string()),
template_files: z.array(z.string()),
})
export type ItemFormValues = z.infer<typeof itemFormSchema>
@@ -86,14 +179,27 @@ export function ItemForm(props: ItemFormProps) {
const [submitResult, setSubmitResult] = useState<SubmitResult | null>(null)
const [isWaitingForAutoUpload, setIsWaitingForAutoUpload] = useState(false)
const [removedFileIds, setRemovedFileIds] = useState<number[]>([])
const [templateZipFile, setTemplateZipFile] = useState<File | null>(null)
const [existingTemplateFiles, setExistingTemplateFiles] = useState<string[]>([])
const [selectedTemplateFiles, setSelectedTemplateFiles] = useState<string[]>([])
const [initialTemplateFilesFieldValue, setInitialTemplateFilesFieldValue] = useState<string[]>([])
const templateZipInputRef = useRef<HTMLInputElement>(null)
const submitIntentRef = useRef<'save' | 'request_review'>('save')
const supabase = useMemo(() => createClient(), [])
const isCreateMode = props.mode === 'create'
const item = props.mode === 'edit' ? props.item : null
const itemId = isCreateMode ? submitResult?.itemId : item?.id
const initialFiles = props.mode === 'edit' ? props.initialFiles : []
const fieldsDisabled = isPending || isWaitingForAutoUpload
const initialFilesFieldValue = useMemo(
() =>
initialFiles
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((file) => file.file_path),
[initialFiles]
)
const defaultValues = useMemo<ItemFormValues>(
() => ({
title: item?.title ?? '',
@@ -101,9 +207,21 @@ export function ItemForm(props: ItemFormProps) {
summary: item?.summary ?? '',
content: item?.content ?? '',
type: item?.type === 'oauth' ? 'oauth' : 'template',
link: item?.link ?? '',
url: item?.url ?? '',
documentation_url: item?.documentation_url ?? '',
files: initialFilesFieldValue,
template_files: [],
}),
[item?.content, item?.link, item?.slug, item?.summary, item?.title, item?.type]
[
initialFilesFieldValue,
item?.content,
item?.documentation_url,
item?.slug,
item?.summary,
item?.title,
item?.type,
item?.url,
]
)
const form = useForm<ItemFormValues>({
@@ -112,11 +230,128 @@ export function ItemForm(props: ItemFormProps) {
})
const onValuesChange = props.onValuesChange
const onPreviewFilesChange = props.onPreviewFilesChange
const handlePreviewFilesChange = useCallback(
(files: ItemPreviewFile[]) => {
const normalizedFiles = files
.map((file) => file.description ?? file.name)
.slice()
.sort((a, b) => a.localeCompare(b))
const normalizedInitialFiles = initialFilesFieldValue
.slice()
.sort((a, b) => a.localeCompare(b))
form.setValue('files', normalizedFiles, {
shouldDirty: !areStringArraysEqual(normalizedFiles, normalizedInitialFiles),
shouldTouch: true,
})
onPreviewFilesChange?.(files)
},
[form, initialFilesFieldValue, onPreviewFilesChange]
)
useEffect(() => {
form.reset(defaultValues)
}, [defaultValues, form])
useEffect(() => {
if (isCreateMode || item?.type !== 'template' || !itemId) {
setExistingTemplateFiles([])
setSelectedTemplateFiles([])
setInitialTemplateFilesFieldValue([])
form.setValue('template_files', [], {
shouldDirty: false,
shouldTouch: false,
})
return
}
let isCancelled = false
const basePath = `${props.partner.id}/items/${itemId}/template`
const loadTemplateFiles = async () => {
const listRecursive = async (prefix = ''): Promise<string[]> => {
const targetPath = prefix ? `${basePath}/${prefix}` : basePath
const { data, error } = await supabase.storage.from('item_files').list(targetPath, {
limit: 1000,
sortBy: { column: 'name', order: 'asc' },
})
if (error || !data) return []
const nested = await Promise.all(
data.map(async (entry) => {
const isDirectory = entry.metadata == null
const nextPrefix = prefix ? `${prefix}/${entry.name}` : entry.name
if (isDirectory) {
return listRecursive(nextPrefix)
}
return [nextPrefix]
})
)
return nested.flat()
}
const files = await listRecursive()
if (isCancelled) return
setExistingTemplateFiles(files)
setInitialTemplateFilesFieldValue(files)
form.setValue('template_files', files, {
shouldDirty: false,
shouldTouch: false,
})
}
void loadTemplateFiles()
return () => {
isCancelled = true
}
}, [form, isCreateMode, item?.type, itemId, props.partner.id, supabase])
useEffect(() => {
if (!templateZipFile) {
setSelectedTemplateFiles([])
return
}
let isCancelled = false
const parseZip = async () => {
const arrayBuffer = await templateZipFile.arrayBuffer()
const zip = await JSZip.loadAsync(arrayBuffer)
const entries = Object.values(zip.files).filter(
(entry) => !entry.dir && !isIgnoredTemplatePath(entry.name)
)
const topLevelDirs = new Set(entries.map((entry) => entry.name.split('/')[0]).filter(Boolean))
const rootPrefix = topLevelDirs.size === 1 ? Array.from(topLevelDirs)[0] ?? null : null
const normalized = entries
.map((entry) => normalizeTemplatePath(entry.name, rootPrefix))
.filter((entry) => entry.length > 0)
.sort((a, b) => a.localeCompare(b))
if (isCancelled) return
setSelectedTemplateFiles(normalized)
form.setValue('template_files', normalized, {
shouldDirty: true,
shouldTouch: true,
})
}
void parseZip().catch(() => {
if (isCancelled) return
setSelectedTemplateFiles([])
form.setValue('template_files', [], {
shouldDirty: true,
shouldTouch: true,
})
})
return () => {
isCancelled = true
}
}, [form, templateZipFile])
useEffect(() => {
if (!onValuesChange) return
@@ -128,7 +363,12 @@ export function ItemForm(props: ItemFormProps) {
summary: value.summary ?? '',
content: value.content ?? '',
type: value.type === 'oauth' ? 'oauth' : 'template',
link: value.link ?? '',
url: value.url ?? '',
documentation_url: value.documentation_url ?? '',
files: (value.files ?? []).filter((entry): entry is string => typeof entry === 'string'),
template_files: (value.template_files ?? []).filter(
(entry): entry is string => typeof entry === 'string'
),
})
})
@@ -153,6 +393,22 @@ export function ItemForm(props: ItemFormProps) {
setError(null)
setSuccess(null)
if (parsed.data.type === 'template') {
const hasExistingRegistryFile = Boolean(item?.registry_item_url)
if (isCreateMode && !templateZipFile) {
setError('Upload a template ZIP package that includes registry-item.json.')
return
}
if (!isCreateMode && !templateZipFile && !hasExistingRegistryFile) {
setError('Upload a template ZIP package that includes registry-item.json.')
return
}
}
if (parsed.data.type === 'oauth' && !parsed.data.url?.trim()) {
setError('OAuth items require a listing URL.')
return
}
const formData = new FormData()
const intent = submitIntentRef.current
const trimmedSlug = parsed.data.slug?.trim()
@@ -162,9 +418,14 @@ export function ItemForm(props: ItemFormProps) {
formData.set('slug', trimmedSlug ?? '')
formData.set('summary', parsed.data.summary ?? '')
formData.set('type', parsed.data.type)
formData.set('link', parsed.data.link)
formData.set('url', parsed.data.type === 'oauth' ? parsed.data.url ?? '' : '')
formData.set('documentationUrl', parsed.data.documentation_url ?? '')
formData.set('content', parsed.data.content ?? '')
formData.set('intent', intent)
formData.set('existingRegistryItemUrl', item?.registry_item_url ?? '')
if (templateZipFile && parsed.data.type === 'template') {
formData.set('templateZip', templateZipFile)
}
if (isCreateMode) {
formData.set('title', parsed.data.title)
@@ -203,6 +464,15 @@ export function ItemForm(props: ItemFormProps) {
const handleCancel = () => {
form.reset(defaultValues)
setRemovedFileIds([])
setTemplateZipFile(null)
setSelectedTemplateFiles([])
if (templateZipInputRef.current) {
templateZipInputRef.current.value = ''
}
form.setValue('template_files', initialTemplateFilesFieldValue, {
shouldDirty: false,
shouldTouch: false,
})
setError(null)
setSuccess(null)
}
@@ -227,6 +497,17 @@ export function ItemForm(props: ItemFormProps) {
const titleLabel = isCreateMode ? 'Item name' : 'Item name'
const slugLabel = isCreateMode ? 'Slug (optional)' : 'Slug'
const isDirty = form.formState.isDirty
const itemType = form.watch('type')
const hasExistingTemplateFiles = existingTemplateFiles.length > 0
const templateFilesForTree =
selectedTemplateFiles.length > 0 || templateZipFile
? selectedTemplateFiles
: existingTemplateFiles
const hasTemplateFilesForTree = templateFilesForTree.length > 0
const templateTreeData = useMemo(
() => flattenTree({ name: '', children: buildTemplateTree(templateFilesForTree) }),
[templateFilesForTree]
)
return (
<Form {...form}>
@@ -371,22 +652,126 @@ export function ItemForm(props: ItemFormProps) {
/>
</div>
{itemType === 'oauth' ? (
<div className="p-6 pt-0">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Listing URL"
description="External URL for installation docs or listing destination."
>
<FormControl>
<Input
id="item-url"
type="url"
placeholder="https://example.com"
required
disabled={fieldsDisabled}
{...field}
/>
</FormControl>
</FormItemLayout>
)}
/>
</div>
) : (
<div className="p-6 pt-0">
<FormItemLayout
layout="vertical"
label={
hasExistingTemplateFiles ? (
<span className="inline-flex w-full items-center justify-between gap-3">
<span>Template package (.zip)</span>
<Button
htmlType="button"
type="outline"
disabled={fieldsDisabled}
className="h-6 px-2 text-xs"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
templateZipInputRef.current?.click()
}}
>
Replace
</Button>
</span>
) : (
'Template package (.zip)'
)
}
description="Upload a zip containing registry-item.json, functions/, and schemas/."
>
<Input
id="item-template-zip"
type="file"
accept=".zip,application/zip"
disabled={fieldsDisabled}
ref={templateZipInputRef}
className={hasExistingTemplateFiles ? 'sr-only' : undefined}
onChange={(event) => {
const nextFile = event.target.files?.[0]
setTemplateZipFile(nextFile ?? null)
const nextTemplateFiles = nextFile ? [] : initialTemplateFilesFieldValue
form.setValue('template_files', nextTemplateFiles, {
shouldDirty: Boolean(nextFile),
shouldTouch: true,
})
}}
/>
{hasTemplateFilesForTree ? (
<div className="mt-2 rounded-md border">
<TreeView
data={templateTreeData}
aria-label="Template files"
className="w-full py-1"
nodeRenderer={({
element,
isBranch,
isExpanded,
getNodeProps,
level,
isSelected,
}) => (
<TreeViewItem
{...getNodeProps()}
isExpanded={isExpanded}
isBranch={isBranch}
isSelected={isSelected}
level={level}
name={element.name}
/>
)}
/>
</div>
) : null}
{templateZipFile ? (
<p className="mt-2 text-xs text-muted-foreground">
Selected package: {templateZipFile.name}
</p>
) : null}
</FormItemLayout>
</div>
)}
<div className="p-6 pt-0">
<FormField
control={form.control}
name="link"
name="documentation_url"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Link"
description="External URL for installation docs or listing destination."
label="Documentation URL (optional)"
description="Direct link to setup or API documentation for this item."
>
<FormControl>
<Input
id="item-link"
id="item-documentation-url"
type="url"
placeholder="https://example.com"
required
placeholder="https://example.com/docs"
disabled={fieldsDisabled}
{...field}
/>
@@ -412,7 +797,7 @@ export function ItemForm(props: ItemFormProps) {
disabled={fieldsDisabled}
onRemovedFileIdsChange={setRemovedFileIds}
onAutoUploadComplete={handleAutoUploadComplete}
onPreviewFilesChange={onPreviewFilesChange}
onPreviewFilesChange={handlePreviewFilesChange}
/>
</div>
</FormItemLayout>
@@ -430,7 +815,7 @@ export function ItemForm(props: ItemFormProps) {
) : null}
</div>
<div className="shrink-0 border-t p-6">
<div className="shrink-0 border-t py-4 px-6">
<div className="flex justify-end gap-3">
{isDirty && (
<Button
+1
View File
@@ -21,6 +21,7 @@
"@supabase/supabase-js": "catalog:",
"class-variance-authority": "*",
"clsx": "^2.1.1",
"jszip": "^3.10.1",
"lucide-react": "^0.436.0",
"next": "catalog:",
"next-themes": "^0.4.6",
@@ -174,11 +174,17 @@ create table public.items (
summary text,
content text, -- markdown body
type public.marketplace_item_type not null,
link text not null,
url text,
registry_item_url text,
documentation_url text,
submitted_by uuid references auth.users (id) on delete set null default auth.uid(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint items_slug_format check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$')
constraint items_slug_format check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
constraint items_type_destination_check check (
(type = 'oauth' and url is not null and registry_item_url is null)
or (type = 'template' and url is null)
)
);
-- Marketplace categories (e.g. auth, analytics, cms, etc).
@@ -255,6 +261,7 @@ create policy "partners_select"
using (
public.is_supabase_team_member()
or public.is_partner_member(id)
or public.is_reviewer_member()
);
create policy "partners_insert"
@@ -1,11 +1,12 @@
import type { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { cn, NavMenu, NavMenuItem } from 'ui'
import { Button, cn, NavMenu, NavMenuItem } from 'ui'
import { PageContainer } from '../PageContainer'
import {
PageHeader,
PageHeaderAside,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderNavigationTabs,
@@ -41,6 +42,7 @@ export type MarketplaceItemProps = {
partnerName?: string
lastUpdatedAt?: string | Date | null
type?: string | null
primaryActionUrl?: string | null
metaFields?: MarketplaceItemMetaField[]
tabs?: MarketplaceItemTab[]
className?: string
@@ -64,6 +66,7 @@ export function MarketplaceItem({
partnerName,
lastUpdatedAt,
type,
primaryActionUrl,
metaFields,
tabs = [],
className,
@@ -105,6 +108,15 @@ export function MarketplaceItem({
<PageHeaderTitle>{title}</PageHeaderTitle>
<PageHeaderDescription>{summary || 'No summary provided.'}</PageHeaderDescription>
</PageHeaderSummary>
{primaryActionUrl ? (
<PageHeaderAside>
<Button asChild type="primary" size="small">
<a href={primaryActionUrl} target="_blank" rel="noreferrer">
Install
</a>
</Button>
</PageHeaderAside>
) : null}
</PageHeaderMeta>
<PageHeaderNavigationTabs>
<NavMenu>
+30
View File
@@ -768,6 +768,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
jszip:
specifier: ^3.10.1
version: 3.10.1
lucide-react:
specifier: ^0.436.0
version: 0.436.0(react@18.3.1)
@@ -12780,6 +12783,9 @@ packages:
resolution: {integrity: sha512-lCYzQrWzdnA68K03oMj/BUlBJrVBnslzDOgGFymAp49NmdGEJxGeN7sHh5mCva0nQkq+kkKSuru2zLf1m04+3A==}
engines: {node: '>=14.0.0'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
@@ -13465,6 +13471,9 @@ packages:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
katex@0.16.21:
resolution: {integrity: sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==}
hasBin: true
@@ -13550,6 +13559,9 @@ packages:
libpg-query@17.6.0:
resolution: {integrity: sha512-r4zOTcLTGYS5PlLQAicJ6Yi/tvZFag42YUuNEO8pi8bwt/ZZ4kj514J4QV5bOx0mZzPLF6agbfNXQVxGgmHR8g==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@@ -15145,6 +15157,9 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
papaparse@5.4.1:
resolution: {integrity: sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==}
@@ -30929,6 +30944,8 @@ snapshots:
imagescript@1.3.0: {}
immediate@3.0.6: {}
immer@10.1.1:
optional: true
@@ -31571,6 +31588,13 @@ snapshots:
object.assign: 4.1.7
object.values: 1.2.1
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
katex@0.16.21:
dependencies:
commander: 8.3.0
@@ -31664,6 +31688,10 @@ snapshots:
dependencies:
'@pgsql/types': 17.6.2
lie@3.3.0:
dependencies:
immediate: 3.0.6
lilconfig@2.1.0: {}
lilconfig@3.1.3: {}
@@ -34187,6 +34215,8 @@ snapshots:
pako@0.2.9: {}
pako@1.0.11: {}
papaparse@5.4.1: {}
param-case@3.0.4: