diff --git a/apps/marketplace/app/protected/[partnerslug]/items/[slug]/page.tsx b/apps/marketplace/app/protected/[partnerslug]/items/[slug]/page.tsx index 2db3d990204..957939aeda6 100644 --- a/apps/marketplace/app/protected/[partnerslug]/items/[slug]/page.tsx +++ b/apps/marketplace/app/protected/[partnerslug]/items/[slug]/page.tsx @@ -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, }} /> diff --git a/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/page.tsx b/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/page.tsx index 938aba9a184..ec8f660ce24 100644 --- a/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/page.tsx +++ b/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/page.tsx @@ -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 ? ( + + {item.url} + + ) : ( + 'No URL provided' + ), + }, + ] + : []), { - label: 'Listing URL', - value: item.link ? ( + label: 'Documentation URL', + value: item.documentation_url ? ( - {item.link} + {item.documentation_url} ) : ( 'No URL provided' diff --git a/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/review-decision-form.tsx b/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/review-decision-form.tsx index cec54caf17c..8001947ef83 100644 --- a/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/review-decision-form.tsx +++ b/apps/marketplace/app/protected/[partnerslug]/reviews/[itemId]/review-decision-form.tsx @@ -215,7 +215,7 @@ export function ReviewDecisionForm({ ) : null} diff --git a/apps/marketplace/app/protected/[partnerslug]/reviews/page.tsx b/apps/marketplace/app/protected/[partnerslug]/reviews/page.tsx index 1bd99b55dfc..8c131f4c75b 100644 --- a/apps/marketplace/app/protected/[partnerslug]/reviews/page.tsx +++ b/apps/marketplace/app/protected/[partnerslug]/reviews/page.tsx @@ -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) { + {reviewRows.length === 0 ? (
- No pending reviews right now. + {statusFilter === 'pending_review' && !hasValidItemIdFilter + ? 'No pending reviews right now.' + : 'No reviews match these filters.'}
) : (
+ Partner Item Slug Status diff --git a/apps/marketplace/app/protected/[partnerslug]/reviews/reviews-filters.tsx b/apps/marketplace/app/protected/[partnerslug]/reviews/reviews-filters.tsx new file mode 100644 index 00000000000..daf044ce63f --- /dev/null +++ b/apps/marketplace/app/protected/[partnerslug]/reviews/reviews-filters.tsx @@ -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 ( +
+ + { + const value = event.target.value + setNextItemId(value) + pushFilters(nextStatus, value) + }} + /> +
+ ) +} diff --git a/apps/marketplace/app/protected/actions.ts b/apps/marketplace/app/protected/actions.ts index 3de3a579bd2..350a410c0ff 100644 --- a/apps/marketplace/app/protected/actions.ts +++ b/apps/marketplace/app/protected/actions.ts @@ -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> + 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 => { + 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') diff --git a/apps/marketplace/components/item-editor-split-view.tsx b/apps/marketplace/components/item-editor-split-view.tsx index 02f3a04c8cd..65ce643a2bb 100644 --- a/apps/marketplace/components/item-editor-split-view.tsx +++ b/apps/marketplace/components/item-editor-split-view.tsx @@ -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(baseValues) @@ -103,12 +120,41 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) { const reviewControl = props.mode === 'edit' && props.reviewRequest ? ( - props.reviewRequest.hasOpenReview ? ( - props.reviewRequest.isApproved ? ( - Approved - ) : props.reviewRequest.latestReviewStatus === 'rejected' ? ( - Rejected - ) : props.reviewRequest.openReviewStatusLabel ? ( + props.reviewRequest.isApproved ? ( + Approved + ) : props.reviewRequest.latestReviewStatus === 'rejected' ? ( + + + + + +
+

Review feedback

+

+ {props.reviewRequest.latestReviewNotes?.trim() + ? props.reviewRequest.latestReviewNotes + : 'No rejection reason was provided for this review.'} +

+
+
+
+ + + + + +
+
+
+ ) : props.reviewRequest.hasOpenReview ? ( + props.reviewRequest.openReviewStatusLabel ? ( {props.reviewRequest.openReviewStatusLabel} ) : ( Review requested @@ -129,7 +175,7 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
- +
@@ -162,7 +208,7 @@ export function ItemEditorSplitView(props: ItemEditorSplitViewProps) {
-
+
@@ -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 ?? ''), }, ]} /> diff --git a/apps/marketplace/components/item-files-uploader.tsx b/apps/marketplace/components/item-files-uploader.tsx index 2009ff8412c..e2377b79c12 100644 --- a/apps/marketplace/components/item-files-uploader.tsx +++ b/apps/marketplace/components/item-files-uploader.tsx @@ -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({ diff --git a/apps/marketplace/components/item-form.tsx b/apps/marketplace/components/item-form.tsx index 06fc9aa3eb2..432229593eb 100644 --- a/apps/marketplace/components/item-form.tsx +++ b/apps/marketplace/components/item-form.tsx @@ -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 @@ -86,14 +179,27 @@ export function ItemForm(props: ItemFormProps) { const [submitResult, setSubmitResult] = useState(null) const [isWaitingForAutoUpload, setIsWaitingForAutoUpload] = useState(false) const [removedFileIds, setRemovedFileIds] = useState([]) + const [templateZipFile, setTemplateZipFile] = useState(null) + const [existingTemplateFiles, setExistingTemplateFiles] = useState([]) + const [selectedTemplateFiles, setSelectedTemplateFiles] = useState([]) + const [initialTemplateFilesFieldValue, setInitialTemplateFilesFieldValue] = useState([]) + const templateZipInputRef = useRef(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( () => ({ 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({ @@ -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 => { + 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 (
@@ -371,22 +652,126 @@ export function ItemForm(props: ItemFormProps) { />
+ {itemType === 'oauth' ? ( +
+ ( + + + + + + )} + /> +
+ ) : ( +
+ + Template package (.zip) + + + ) : ( + 'Template package (.zip)' + ) + } + description="Upload a zip containing registry-item.json, functions/, and schemas/." + > + { + const nextFile = event.target.files?.[0] + setTemplateZipFile(nextFile ?? null) + const nextTemplateFiles = nextFile ? [] : initialTemplateFilesFieldValue + form.setValue('template_files', nextTemplateFiles, { + shouldDirty: Boolean(nextFile), + shouldTouch: true, + }) + }} + /> + {hasTemplateFilesForTree ? ( +
+ ( + + )} + /> +
+ ) : null} + {templateZipFile ? ( +

+ Selected package: {templateZipFile.name} +

+ ) : null} +
+
+ )} +
( @@ -412,7 +797,7 @@ export function ItemForm(props: ItemFormProps) { disabled={fieldsDisabled} onRemovedFileIdsChange={setRemovedFileIds} onAutoUploadComplete={handleAutoUploadComplete} - onPreviewFilesChange={onPreviewFilesChange} + onPreviewFilesChange={handlePreviewFilesChange} />
@@ -430,7 +815,7 @@ export function ItemForm(props: ItemFormProps) { ) : null}
-
+
{isDirty && ( + + ) : null} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d8d4bb6367..a2692d50020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: