mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
review process and filters
This commit is contained in:
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'
|
||||
|
||||
+1
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+30
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user