diff --git a/.gitignore b/.gitignore index c54784db..7ddda1d0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ app/graphql/*.d.ts # Descriptors app/descriptors + +# Playwright MCP +.playwright-mcp/ diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 99246819..15d35082 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -21,6 +21,7 @@ @theme { --font-sans: 'Geist', ui-sans-serif, system-ui, sans-serif; --font-serif: 'Roboto Serif', serif; + --font-mono: 'Geist Mono', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; } @theme static { diff --git a/app/components/Navbar.vue b/app/components/Navbar.vue index ab9dca8f..24f46c7f 100644 --- a/app/components/Navbar.vue +++ b/app/components/Navbar.vue @@ -43,6 +43,12 @@ const navItems = computed(() => [ label: 'Create', onSelect: () => isCreateModalOpen.value = true, }, + ...(accountId.value + ? [{ + label: 'Studio', + to: `/${routePrefix.value}/studio`, + }] + : []), ], ].map(item => item.map(i => ({ ...i, diff --git a/app/components/bulkOperations/BulkStepper.vue b/app/components/bulkOperations/BulkStepper.vue new file mode 100644 index 00000000..97c92a9c --- /dev/null +++ b/app/components/bulkOperations/BulkStepper.vue @@ -0,0 +1,76 @@ + + + diff --git a/app/components/bulkOperations/BulkWizardFooter.vue b/app/components/bulkOperations/BulkWizardFooter.vue new file mode 100644 index 00000000..da452705 --- /dev/null +++ b/app/components/bulkOperations/BulkWizardFooter.vue @@ -0,0 +1,57 @@ + + + diff --git a/app/components/bulkOperations/BulkWizardLayout.vue b/app/components/bulkOperations/BulkWizardLayout.vue new file mode 100644 index 00000000..5e897879 --- /dev/null +++ b/app/components/bulkOperations/BulkWizardLayout.vue @@ -0,0 +1,85 @@ + + + diff --git a/app/components/collection/CollectionDisplay.vue b/app/components/collection/CollectionDisplay.vue new file mode 100644 index 00000000..7e8e305f --- /dev/null +++ b/app/components/collection/CollectionDisplay.vue @@ -0,0 +1,88 @@ + + + diff --git a/app/components/collection/admin/AdminItemDetail.vue b/app/components/collection/admin/AdminItemDetail.vue new file mode 100644 index 00000000..e5dca31d --- /dev/null +++ b/app/components/collection/admin/AdminItemDetail.vue @@ -0,0 +1,122 @@ + + + diff --git a/app/components/collection/admin/AdminSelectionBar.vue b/app/components/collection/admin/AdminSelectionBar.vue new file mode 100644 index 00000000..7d9bed1e --- /dev/null +++ b/app/components/collection/admin/AdminSelectionBar.vue @@ -0,0 +1,100 @@ + + + diff --git a/app/components/collection/admin/AdminSidebar.vue b/app/components/collection/admin/AdminSidebar.vue new file mode 100644 index 00000000..d8b197c3 --- /dev/null +++ b/app/components/collection/admin/AdminSidebar.vue @@ -0,0 +1,150 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarActions.vue b/app/components/collection/admin/AdminSidebarActions.vue new file mode 100644 index 00000000..2b4b7dcf --- /dev/null +++ b/app/components/collection/admin/AdminSidebarActions.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarDetails.vue b/app/components/collection/admin/AdminSidebarDetails.vue new file mode 100644 index 00000000..c52a6399 --- /dev/null +++ b/app/components/collection/admin/AdminSidebarDetails.vue @@ -0,0 +1,97 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarEarnings.vue b/app/components/collection/admin/AdminSidebarEarnings.vue new file mode 100644 index 00000000..a47845c6 --- /dev/null +++ b/app/components/collection/admin/AdminSidebarEarnings.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarIdentity.vue b/app/components/collection/admin/AdminSidebarIdentity.vue new file mode 100644 index 00000000..31dc5324 --- /dev/null +++ b/app/components/collection/admin/AdminSidebarIdentity.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarTeam.vue b/app/components/collection/admin/AdminSidebarTeam.vue new file mode 100644 index 00000000..0e6548c8 --- /dev/null +++ b/app/components/collection/admin/AdminSidebarTeam.vue @@ -0,0 +1,41 @@ + + + diff --git a/app/components/collection/admin/AdminSidebarVisibility.vue b/app/components/collection/admin/AdminSidebarVisibility.vue new file mode 100644 index 00000000..cf2d41ae --- /dev/null +++ b/app/components/collection/admin/AdminSidebarVisibility.vue @@ -0,0 +1,38 @@ + + + diff --git a/app/components/collection/admin/FloatingManageButton.vue b/app/components/collection/admin/FloatingManageButton.vue new file mode 100644 index 00000000..d7f2e211 --- /dev/null +++ b/app/components/collection/admin/FloatingManageButton.vue @@ -0,0 +1,25 @@ + + + diff --git a/app/components/common/card/TokenCard.client.vue b/app/components/common/card/TokenCard.client.vue index 0650ccc1..ef0e9334 100644 --- a/app/components/common/card/TokenCard.client.vue +++ b/app/components/common/card/TokenCard.client.vue @@ -11,6 +11,14 @@ const props = defineProps<{ price?: string | null currentOwner?: string | null hideHoverAction?: boolean + selectionMode?: boolean + isSelected?: boolean + studioMode?: boolean +}>() + +const emit = defineEmits<{ + select: [tokenId: number, collectionId: number] + itemClick: [tokenId: number, collectionId: number] }>() const { @@ -93,9 +101,11 @@ watchEffect(() => {
diff --git a/app/components/create/modal/SuccessCollection.vue b/app/components/create/modal/SuccessCollection.vue index cafcaed1..28851376 100644 --- a/app/components/create/modal/SuccessCollection.vue +++ b/app/components/create/modal/SuccessCollection.vue @@ -31,7 +31,7 @@ const modalData = computed(() => { window.open(getSubscanNftUrl(collectionId, props.result.prefix), '_blank') } else { - router.push(`/${props.result?.prefix}/collection/${collectionId}`) + router.push(`/${props.result?.prefix}/studio/${collectionId}`) } }, }, diff --git a/app/components/dashboard/DashboardCollectionCard.vue b/app/components/dashboard/DashboardCollectionCard.vue new file mode 100644 index 00000000..bbcdc5d5 --- /dev/null +++ b/app/components/dashboard/DashboardCollectionCard.vue @@ -0,0 +1,95 @@ + + + diff --git a/app/components/explore/NftsGrid.vue b/app/components/explore/NftsGrid.vue index 721ccba3..1a622b41 100644 --- a/app/components/explore/NftsGrid.vue +++ b/app/components/explore/NftsGrid.vue @@ -8,6 +8,9 @@ interface Props { gridClass?: string prefix?: AssetHubChain hideHoverAction?: boolean + selectionMode?: boolean + selectedIds?: Set + studioMode?: boolean } const props = withDefaults(defineProps(), { @@ -17,7 +20,11 @@ const props = withDefaults(defineProps(), { noItemsFoundMessage: 'Try adjusting your search or filters to see more results.', }) -const emit = defineEmits(['totalCountChange']) +const emit = defineEmits<{ + totalCountChange: [count: number] + select: [tokenId: number, collectionId: number] + itemClick: [tokenId: number, collectionId: number] +}>() // Use the NFTs infinite query composable const { @@ -60,6 +67,11 @@ watch(totalCount, (newCount) => { :price="nft.price" :current-owner="nft.currentOwner" :hide-hover-action="hideHoverAction" + :selection-mode="selectionMode" + :is-selected="selectedIds?.has(`${nft.collectionId}-${nft.tokenId}`)" + :studio-mode="studioMode" + @select="emit('select', $event, nft.collectionId)" + @item-click="(tokenId, colId) => emit('itemClick', tokenId, colId)" /> diff --git a/app/components/massmint/types.ts b/app/components/massmint/types.ts index c7c4ffea..e7c35c5a 100644 --- a/app/components/massmint/types.ts +++ b/app/components/massmint/types.ts @@ -33,3 +33,15 @@ export interface NFTToMint { } export interface NFTS { [id: string]: NFT } + +export interface MassMintFile { + id: string + file: File + thumbnailUrl: string + order: number + uploadStatus: 'local' | 'uploading' | 'uploaded' | 'error' + name?: string + description?: string + price?: number + attributes?: Array<{ trait_type: string, value: string }> +} diff --git a/app/components/massmint/wizard/FilePreviewModal.vue b/app/components/massmint/wizard/FilePreviewModal.vue new file mode 100644 index 00000000..83e3c8c8 --- /dev/null +++ b/app/components/massmint/wizard/FilePreviewModal.vue @@ -0,0 +1,98 @@ + + + diff --git a/app/components/massmint/wizard/MassMintItemPanel.vue b/app/components/massmint/wizard/MassMintItemPanel.vue new file mode 100644 index 00000000..133628ce --- /dev/null +++ b/app/components/massmint/wizard/MassMintItemPanel.vue @@ -0,0 +1,305 @@ + + + diff --git a/app/components/massmint/wizard/MassMintWizard.vue b/app/components/massmint/wizard/MassMintWizard.vue new file mode 100644 index 00000000..2029eb5c --- /dev/null +++ b/app/components/massmint/wizard/MassMintWizard.vue @@ -0,0 +1,125 @@ + + + diff --git a/app/components/massmint/wizard/MetadataPreviewTable.vue b/app/components/massmint/wizard/MetadataPreviewTable.vue new file mode 100644 index 00000000..e9302886 --- /dev/null +++ b/app/components/massmint/wizard/MetadataPreviewTable.vue @@ -0,0 +1,113 @@ + + + diff --git a/app/components/massmint/wizard/steps/MetadataStep.vue b/app/components/massmint/wizard/steps/MetadataStep.vue new file mode 100644 index 00000000..336439f1 --- /dev/null +++ b/app/components/massmint/wizard/steps/MetadataStep.vue @@ -0,0 +1,592 @@ + + + diff --git a/app/components/massmint/wizard/steps/MintStep.vue b/app/components/massmint/wizard/steps/MintStep.vue new file mode 100644 index 00000000..35fe5949 --- /dev/null +++ b/app/components/massmint/wizard/steps/MintStep.vue @@ -0,0 +1,295 @@ + + + diff --git a/app/components/massmint/wizard/steps/ReviewStep.vue b/app/components/massmint/wizard/steps/ReviewStep.vue new file mode 100644 index 00000000..bb605dd3 --- /dev/null +++ b/app/components/massmint/wizard/steps/ReviewStep.vue @@ -0,0 +1,202 @@ + + + diff --git a/app/components/massmint/wizard/steps/UploadStep.vue b/app/components/massmint/wizard/steps/UploadStep.vue new file mode 100644 index 00000000..4a0f711f --- /dev/null +++ b/app/components/massmint/wizard/steps/UploadStep.vue @@ -0,0 +1,296 @@ + + + diff --git a/app/components/studio/ItemSlideOver.vue b/app/components/studio/ItemSlideOver.vue new file mode 100644 index 00000000..0b2b0f23 --- /dev/null +++ b/app/components/studio/ItemSlideOver.vue @@ -0,0 +1,262 @@ + + + diff --git a/app/components/studio/StudioActionBar.vue b/app/components/studio/StudioActionBar.vue new file mode 100644 index 00000000..0554ea02 --- /dev/null +++ b/app/components/studio/StudioActionBar.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/components/studio/StudioEmptyState.vue b/app/components/studio/StudioEmptyState.vue new file mode 100644 index 00000000..6a9fdb6f --- /dev/null +++ b/app/components/studio/StudioEmptyState.vue @@ -0,0 +1,24 @@ + + + diff --git a/app/components/studio/StudioKeyboardOverlay.vue b/app/components/studio/StudioKeyboardOverlay.vue new file mode 100644 index 00000000..0a50c88d --- /dev/null +++ b/app/components/studio/StudioKeyboardOverlay.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/components/studio/StudioSidebar.vue b/app/components/studio/StudioSidebar.vue new file mode 100644 index 00000000..32decb44 --- /dev/null +++ b/app/components/studio/StudioSidebar.vue @@ -0,0 +1,207 @@ + + + diff --git a/app/composables/bulkOperations/useBulkOperationWizard.ts b/app/composables/bulkOperations/useBulkOperationWizard.ts new file mode 100644 index 00000000..8fd717f7 --- /dev/null +++ b/app/composables/bulkOperations/useBulkOperationWizard.ts @@ -0,0 +1,71 @@ +import type { StepConfig } from '~/types/bulkOperations' + +interface WizardOptions { + steps: StepConfig[] + onComplete?: () => void +} + +export function useBulkOperationWizard(options: WizardOptions) { + const store = useBulkOperationsStore() + + const completedSteps = computed(() => { + const completed: number[] = [] + for (let i = 0; i < store.currentStep; i++) { + completed.push(i) + } + return completed + }) + + const isFirstStep = computed(() => store.currentStep === 0) + const isLastStep = computed(() => store.currentStep === options.steps.length - 1) + + function handleNext() { + if (isLastStep.value && options.onComplete) { + options.onComplete() + } + else { + store.nextStep() + } + } + + function handleBack() { + store.prevStep() + } + + function handleKeydown(event: KeyboardEvent) { + const target = event.target as HTMLElement + const isEditableTarget = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable + + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + if (isEditableTarget) + return + event.preventDefault() + if (store.canProceed) { + handleNext() + } + } + if (event.key === 'Escape') { + if (isEditableTarget) + return + event.preventDefault() + handleBack() + } + } + + onMounted(() => { + window.addEventListener('keydown', handleKeydown) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleKeydown) + }) + + return { + store, + completedSteps, + isFirstStep, + isLastStep, + handleNext, + handleBack, + } +} diff --git a/app/composables/collection/useAdminSidebar.ts b/app/composables/collection/useAdminSidebar.ts new file mode 100644 index 00000000..23551e3f --- /dev/null +++ b/app/composables/collection/useAdminSidebar.ts @@ -0,0 +1,75 @@ +export function useAdminSidebar() { + const route = useRoute() + + const isOpen = ref(route.query.admin === 'true') + const selectionMode = ref(false) + const selectedItemIds = ref>(new Set()) + const selectedItemForDetail = ref(null) + + const sidebarView = computed<'default' | 'selection' | 'itemDetail'>(() => { + if (selectedItemForDetail.value) + return 'itemDetail' + if (selectionMode.value) + return 'selection' + return 'default' + }) + + const selectedCount = computed(() => selectedItemIds.value.size) + + function toggleSelection(id: string) { + const newSet = new Set(selectedItemIds.value) + if (newSet.has(id)) { + newSet.delete(id) + } + else { + newSet.add(id) + } + selectedItemIds.value = newSet + } + + function selectAll(ids: string[]) { + selectedItemIds.value = new Set(ids) + } + + function clearSelection() { + selectedItemIds.value = new Set() + } + + function closeItemDetail() { + selectedItemForDetail.value = null + } + + function toggleSelectionMode() { + selectionMode.value = !selectionMode.value + if (!selectionMode.value) { + clearSelection() + } + } + + function closeSidebar() { + isOpen.value = false + selectionMode.value = false + clearSelection() + closeItemDetail() + } + + function openSidebar() { + isOpen.value = true + } + + return { + isOpen, + selectionMode, + selectedItemIds, + selectedItemForDetail, + sidebarView, + selectedCount, + toggleSelection, + selectAll, + clearSelection, + closeItemDetail, + toggleSelectionMode, + closeSidebar, + openSidebar, + } +} diff --git a/app/composables/dashboard/useCreatorDashboard.ts b/app/composables/dashboard/useCreatorDashboard.ts new file mode 100644 index 00000000..4f9ffdee --- /dev/null +++ b/app/composables/dashboard/useCreatorDashboard.ts @@ -0,0 +1,97 @@ +import type { AssetHubChain } from '~/plugins/sdk.client' +import type { OnchainCollection } from '~/services/oda' +import { fetchOdaCollection } from '~/services/oda' +import { isAssetHubChain } from '~/utils/chain' + +export interface DashboardCollection { + id: string + chain: AssetHubChain + metadata?: OnchainCollection['metadata'] + supply?: string + claimed?: string + floor: number | null +} + +export function useCreatorDashboard() { + const { accountId } = useAuth() + const { currentChain } = useChain() + + const collections = ref([]) + const loading = ref(false) + + const { data: collectionIds, isLoading: idsLoading } = useOwnedCollections( + computed(() => accountId.value || ''), + ) + + let requestId = 0 + + watch([collectionIds, currentChain], async ([ids, chain], _prev, onInvalidate) => { + if (!ids || ids.length === 0) { + collections.value = [] + loading.value = false + return + } + + const currentRequestId = ++requestId + let isCancelled = false + + onInvalidate(() => { + isCancelled = true + }) + + loading.value = true + + try { + // Only fetch for AssetHub chains (ahp, ahk, ahpas) + if (!isAssetHubChain(chain)) { + collections.value = [] + loading.value = false + return + } + + const results = await Promise.allSettled( + ids.map(id => fetchOdaCollection(chain, id)), + ) + + if (isCancelled || requestId !== currentRequestId) { + return + } + + collections.value = results + .map((result, index) => { + if (result.status === 'fulfilled' && result.value) { + return { + id: ids[index]!, + chain, + metadata: result.value.metadata, + supply: result.value.supply, + claimed: result.value.claimed, + floor: result.value.floor ?? null, + } as DashboardCollection + } + return null + }) + .filter((c): c is DashboardCollection => c !== null) + } + catch { + if (isCancelled || requestId !== currentRequestId) { + return + } + collections.value = [] + } + finally { + if (!isCancelled && requestId === currentRequestId) { + loading.value = false + } + } + }, { immediate: true }) + + const isLoading = computed(() => idsLoading.value || loading.value) + const hasCollections = computed(() => collections.value.length > 0) + + return { + collections, + isLoading, + hasCollections, + } +} diff --git a/app/composables/massmint/useMassMint.ts b/app/composables/massmint/useMassMint.ts index 48099733..d54c3902 100644 --- a/app/composables/massmint/useMassMint.ts +++ b/app/composables/massmint/useMassMint.ts @@ -38,7 +38,7 @@ export function useMassMint() { return progress.value.total > 0 && progress.value.stage !== 'complete' && progress.value.stage !== 'error' }) - async function prepareNftMetadata(nfts: NFTToMint[]) { + async function prepareNftMetadata(nfts: NFTToMint[], onFileProgress?: (index: number, total: number) => void) { error.value = null progress.value = { total: nfts.length, @@ -69,6 +69,7 @@ export function useMassMint() { }) progress.value.current = index + 1 + onFileProgress?.(index + 1, nfts.length) return { metadataUri: `ipfs://${metadataCid}`, diff --git a/app/composables/massmint/useMassMintForm.ts b/app/composables/massmint/useMassMintForm.ts index f255660e..b66be05d 100644 --- a/app/composables/massmint/useMassMintForm.ts +++ b/app/composables/massmint/useMassMintForm.ts @@ -2,26 +2,29 @@ import type { AssetHubChain } from '~/plugins/sdk.client' import { useNftPallets } from '~/composables/onchain/useNftPallets' -export function useMassMintForm() { +export function useMassMintForm(options?: { collectionId?: string, chain?: AssetHubChain }) { const { userCollection } = useNftPallets() // Wallet connection check const { getConnectedSubAccount } = storeToRefs(useWalletStore()) const isWalletConnected = computed(() => Boolean(getConnectedSubAccount.value?.address)) - // Form state + // Form state — pre-fill from route context if provided const state = reactive({ - collection: '', - blockchain: 'ahp' as AssetHubChain, // Default to Asset Hub Polkadot + collection: options?.collectionId ?? '', + blockchain: options?.chain ?? ('ahp' as AssetHubChain), }) + // If collection context is provided, skip fetching + const hasCollectionContext = Boolean(options?.collectionId && options?.chain) + // Fetch user collections dynamically const collections = ref>([]) const collectionsLoading = ref(false) // Fetch collections and user balance on component mount watchEffect(async () => { - if (!isWalletConnected.value) + if (hasCollectionContext || !isWalletConnected.value) return collectionsLoading.value = true diff --git a/app/composables/massmint/useMassMintWizard.ts b/app/composables/massmint/useMassMintWizard.ts new file mode 100644 index 00000000..f814da1a --- /dev/null +++ b/app/composables/massmint/useMassMintWizard.ts @@ -0,0 +1,139 @@ +import type { MassMintFile } from '~/components/massmint/types' +import { validFormats } from '~/composables/massmint/useZipValidator' +import { MetadataPath } from '~/types/bulkOperations' + +const MAX_FILES = 200 +const MAX_FILE_SIZE_MB = 512 + +export function useMassMintWizard() { + const uploadedFiles = ref([]) + const metadataPath = ref(MetadataPath.TEMPLATE) + const uniformName = ref('') + const startingNumber = ref(1) + const sharedDescription = ref('') + const templateUploaded = ref(false) + const templateDownloaded = ref(false) + const templateFileName = ref('') + const templateFileSize = ref(0) + + const fileCount = computed(() => uploadedFiles.value.length) + + const canProceedFromUpload = computed(() => uploadedFiles.value.length > 0) + + const canProceedFromMetadata = computed(() => { + if (metadataPath.value === MetadataPath.UNIFORM) { + return uniformName.value.trim().length > 0 + } + return templateUploaded.value && uploadedFiles.value.every(f => f.name && f.name.trim().length > 0) + }) + + function generateFileId(): string { + return Math.random().toString(36).slice(2, 10) + } + + function isValidFormat(fileName: string): boolean { + const ext = fileName.split('.').pop()?.toLowerCase() ?? '' + return validFormats.includes(ext) + } + + function addFiles(files: File[]) { + const remaining = MAX_FILES - uploadedFiles.value.length + const filesToAdd = files.slice(0, remaining) + + for (const file of filesToAdd) { + if (!isValidFormat(file.name)) { + warningMessage(`Skipped ${file.name} — unsupported format`) + continue + } + + const sizeMb = file.size / (1024 * 1024) + if (sizeMb > MAX_FILE_SIZE_MB) { + warningMessage(`Skipped ${file.name} — exceeds ${MAX_FILE_SIZE_MB}MB limit`) + continue + } + + const thumbnailUrl = URL.createObjectURL(file) + uploadedFiles.value.push({ + id: generateFileId(), + file, + thumbnailUrl, + order: uploadedFiles.value.length, + uploadStatus: 'local', + name: file.name.replace(/\.[^.]+$/, ''), + }) + } + + if (files.length > remaining) { + warningMessage(`Only ${remaining} more files can be added (max ${MAX_FILES})`) + } + } + + function removeFile(id: string) { + const index = uploadedFiles.value.findIndex(f => f.id === id) + if (index !== -1) { + const file = uploadedFiles.value[index]! + URL.revokeObjectURL(file.thumbnailUrl) + uploadedFiles.value.splice(index, 1) + // Re-order remaining files + uploadedFiles.value.forEach((f, i) => { + f.order = i + }) + } + } + + function reorderFiles(oldIndex: number, newIndex: number) { + const item = uploadedFiles.value.splice(oldIndex, 1)[0]! + uploadedFiles.value.splice(newIndex, 0, item) + uploadedFiles.value.forEach((f, i) => { + f.order = i + }) + } + + function clearFiles() { + uploadedFiles.value.forEach(f => URL.revokeObjectURL(f.thumbnailUrl)) + uploadedFiles.value = [] + } + + function applyUniformNames() { + uploadedFiles.value.forEach((file, index) => { + file.name = `${uniformName.value} #${startingNumber.value + index}` + if (sharedDescription.value) { + file.description = sharedDescription.value + } + }) + } + + function applySharedDescription() { + const trimmed = sharedDescription.value.trim() + uploadedFiles.value.forEach((file) => { + if (trimmed && !file.description?.trim()) { + file.description = trimmed + } + }) + } + + onUnmounted(() => { + uploadedFiles.value.forEach(f => URL.revokeObjectURL(f.thumbnailUrl)) + }) + + return { + uploadedFiles, + metadataPath, + uniformName, + startingNumber, + sharedDescription, + templateUploaded, + templateDownloaded, + templateFileName, + templateFileSize, + fileCount, + canProceedFromUpload, + canProceedFromMetadata, + addFiles, + removeFile, + reorderFiles, + clearFiles, + applyUniformNames, + applySharedDescription, + } +} diff --git a/app/composables/massmint/useTemplateGenerator.ts b/app/composables/massmint/useTemplateGenerator.ts new file mode 100644 index 00000000..b51a2919 --- /dev/null +++ b/app/composables/massmint/useTemplateGenerator.ts @@ -0,0 +1,67 @@ +import type { MassMintFile } from '~/components/massmint/types' + +export function useTemplateGenerator() { + /** + * Escapes a CSV field value according to RFC 4180 and prevents CSV injection + * - Doubles internal quotes + * - Wraps result in quotes + * - Prevents formula injection by prefixing dangerous characters with single quote + */ + function escapeCsvField(value: string): string { + if (!value) + return '""' + + let escaped = value + // Prevent CSV formula injection by prefixing values starting with =, +, -, or @ + if (/^[=+\-@]/.test(escaped)) { + escaped = `'${escaped}` + } + // Escape double quotes by doubling them + escaped = escaped.replace(/"/g, '""') + // Always wrap in quotes + return `"${escaped}"` + } + + function generateCsvTemplate(files: MassMintFile[]): string { + const header = 'filename,name,description,price' + const rows = files.map(f => + [ + escapeCsvField(f.file.name), + escapeCsvField(f.name || ''), + escapeCsvField(f.description || ''), + escapeCsvField(''), + ].join(','), + ) + return [header, ...rows].join('\n') + } + + function downloadCsvTemplate(files: MassMintFile[], collectionName?: string) { + const csv = generateCsvTemplate(files) + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + + // Sanitize filename: remove invalid filesystem characters + let sanitizedName = collectionName || '' + // biome-ignore lint/suspicious/noControlCharactersInRegex: need to strip control chars from filenames + // eslint-disable-next-line no-control-regex + sanitizedName = sanitizedName.replace(/[/\\?%*:|"<>\x00-\x1F]/g, '') + if (!sanitizedName.trim()) { + sanitizedName = 'massmint' + } + + const link = document.createElement('a') + link.href = url + link.download = `${sanitizedName}_massmint_template.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + // Defer URL revocation to ensure download completes + setTimeout(() => URL.revokeObjectURL(url), 500) + } + + return { + generateCsvTemplate, + downloadCsvTemplate, + } +} diff --git a/app/composables/studio/useItemSlideOver.ts b/app/composables/studio/useItemSlideOver.ts new file mode 100644 index 00000000..ff5fda48 --- /dev/null +++ b/app/composables/studio/useItemSlideOver.ts @@ -0,0 +1,20 @@ +export function useItemSlideOver() { + const currentItemId = ref(null) + + const isOpen = computed(() => currentItemId.value !== null) + + function openItem(id: string) { + currentItemId.value = id + } + + function closeItem() { + currentItemId.value = null + } + + return { + currentItemId, + isOpen, + openItem, + closeItem, + } +} diff --git a/app/composables/studio/useStudioCollection.ts b/app/composables/studio/useStudioCollection.ts new file mode 100644 index 00000000..219c836d --- /dev/null +++ b/app/composables/studio/useStudioCollection.ts @@ -0,0 +1,29 @@ +import type { ComputedRef, InjectionKey } from 'vue' +import type { SupportedChain } from '~/plugins/sdk.client' + +export interface StudioCollectionData { + id: string + chain: SupportedChain + name: string + description: string + image: string + banner: string + owner: string + supply: string + claimed: string + floor: number | null +} + +const STUDIO_COLLECTION_KEY: InjectionKey> = Symbol('studio-collection') + +export function provideStudioCollection(data: ComputedRef) { + provide(STUDIO_COLLECTION_KEY, data) +} + +export function useStudioCollection(): ComputedRef { + const data = inject(STUDIO_COLLECTION_KEY) + if (!data) { + throw new Error('useStudioCollection must be used within a studio page') + } + return data +} diff --git a/app/composables/studio/useStudioDetails.ts b/app/composables/studio/useStudioDetails.ts new file mode 100644 index 00000000..14002f4d --- /dev/null +++ b/app/composables/studio/useStudioDetails.ts @@ -0,0 +1,50 @@ +export function useStudioDetails(collection: ComputedRef<{ name: string, description: string, image: string, banner: string, owner: string }>) { + const description = ref(collection.value.description) + const logoFile = ref(null) + const bannerFile = ref(null) + const royaltyPercentage = ref(5) + const royaltyRecipient = ref(collection.value.owner) + const isPublished = ref(true) + + // Team mock data + const collaborators = ref([ + { address: collection.value.owner, role: 'Owner' }, + ]) + const inviteAddress = ref('') + + const isDirty = computed(() => { + return description.value !== collection.value.description + || logoFile.value !== null + || bannerFile.value !== null + }) + + function save() { + // Placeholder — would trigger wallet signature for on-chain data + } + + function addCollaborator() { + if (inviteAddress.value.trim()) { + collaborators.value.push({ address: inviteAddress.value.trim(), role: 'Collaborator' }) + inviteAddress.value = '' + } + } + + function removeCollaborator(index: number) { + collaborators.value.splice(index, 1) + } + + return { + description, + logoFile, + bannerFile, + royaltyPercentage, + royaltyRecipient, + isPublished, + collaborators, + inviteAddress, + isDirty, + save, + addCollaborator, + removeCollaborator, + } +} diff --git a/app/composables/studio/useStudioItems.ts b/app/composables/studio/useStudioItems.ts new file mode 100644 index 00000000..7558fa42 --- /dev/null +++ b/app/composables/studio/useStudioItems.ts @@ -0,0 +1,46 @@ +export function useStudioItems() { + const selectionMode = ref(false) + const selectedItemIds = ref>(new Set()) + const searchQuery = ref('') + const sortBy = ref('newest') + + const selectedCount = computed(() => selectedItemIds.value.size) + + function toggleSelection(id: string) { + const newSet = new Set(selectedItemIds.value) + if (newSet.has(id)) { + newSet.delete(id) + } + else { + newSet.add(id) + } + selectedItemIds.value = newSet + } + + function selectAll(ids: string[]) { + selectedItemIds.value = new Set(ids) + } + + function clearSelection() { + selectedItemIds.value = new Set() + } + + function toggleSelectionMode() { + selectionMode.value = !selectionMode.value + if (!selectionMode.value) { + clearSelection() + } + } + + return { + selectionMode, + selectedItemIds, + selectedCount, + searchQuery, + sortBy, + toggleSelection, + selectAll, + clearSelection, + toggleSelectionMode, + } +} diff --git a/app/composables/studio/useStudioKeyboard.ts b/app/composables/studio/useStudioKeyboard.ts new file mode 100644 index 00000000..27cf8133 --- /dev/null +++ b/app/composables/studio/useStudioKeyboard.ts @@ -0,0 +1,60 @@ +export function useStudioKeyboard(options?: { + onSave?: () => void + onSelectAll?: () => void + onEscape?: () => void +}) { + const showOverlay = ref(false) + + function handleKeydown(e: KeyboardEvent) { + const isMeta = e.metaKey || e.ctrlKey + + // ? → show shortcut overlay + if (e.key === '?' && !isMeta) { + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) + return + e.preventDefault() + showOverlay.value = !showOverlay.value + return + } + + // Escape → close overlay or delegate + if (e.key === 'Escape') { + if (showOverlay.value) { + showOverlay.value = false + return + } + options?.onEscape?.() + return + } + + // Cmd+S → save + if (isMeta && e.key === 's') { + e.preventDefault() + options?.onSave?.() + return + } + + // Cmd+A → select all + if (isMeta && e.key === 'a') { + // Only intercept if not in an input/textarea + const tag = (e.target as HTMLElement)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') + return + e.preventDefault() + options?.onSelectAll?.() + } + } + + onMounted(() => { + window.addEventListener('keydown', handleKeydown) + }) + + onUnmounted(() => { + window.removeEventListener('keydown', handleKeydown) + }) + + return { + showOverlay, + } +} diff --git a/app/composables/studio/useStudioNavGuard.ts b/app/composables/studio/useStudioNavGuard.ts new file mode 100644 index 00000000..7cbc75ca --- /dev/null +++ b/app/composables/studio/useStudioNavGuard.ts @@ -0,0 +1,38 @@ +export function useStudioNavGuard() { + const store = useBulkOperationsStore() + const router = useRouter() + const showWarning = ref(false) + let pendingRoute: string | null = null + + onBeforeRouteLeave((to, _from, next) => { + if (store.isActive) { + showWarning.value = true + pendingRoute = to.fullPath + next(false) + } + else { + next() + } + }) + + function confirmLeave() { + showWarning.value = false + store.reset() + if (pendingRoute) { + const route = pendingRoute + pendingRoute = null + router.push(route) + } + } + + function cancelLeave() { + showWarning.value = false + pendingRoute = null + } + + return { + showWarning, + confirmLeave, + cancelLeave, + } +} diff --git a/app/composables/useOwnedCollections.ts b/app/composables/useOwnedCollections.ts index 522b8036..7e5f63f7 100644 --- a/app/composables/useOwnedCollections.ts +++ b/app/composables/useOwnedCollections.ts @@ -6,7 +6,7 @@ export function useOwnedCollections(address: MaybeRef) { const { currentChain } = useChain() return useQuery({ - queryKey: ['ownedCollections', address], + queryKey: ['ownedCollections', address, currentChain], queryFn: async () => { const { data } = await $apolloClient.query({ query: collectionIdList, diff --git a/app/layouts/no-footer.vue b/app/layouts/no-footer.vue index f2aab3a1..5fa8e221 100644 --- a/app/layouts/no-footer.vue +++ b/app/layouts/no-footer.vue @@ -16,7 +16,7 @@ useHead({
-
+
diff --git a/app/layouts/studio.vue b/app/layouts/studio.vue new file mode 100644 index 00000000..002fcc58 --- /dev/null +++ b/app/layouts/studio.vue @@ -0,0 +1,16 @@ + + + diff --git a/app/pages/[chain]/collection/[collection_id].vue b/app/pages/[chain]/collection/[collection_id].vue index c3afd221..d0b3e4a4 100644 --- a/app/pages/[chain]/collection/[collection_id].vue +++ b/app/pages/[chain]/collection/[collection_id].vue @@ -1,298 +1,14 @@ diff --git a/app/pages/[chain]/collection/[collection_id]/index.vue b/app/pages/[chain]/collection/[collection_id]/index.vue new file mode 100644 index 00000000..9f3b8cad --- /dev/null +++ b/app/pages/[chain]/collection/[collection_id]/index.vue @@ -0,0 +1,331 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id].vue b/app/pages/[chain]/studio/[collection_id].vue new file mode 100644 index 00000000..39503ed2 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id].vue @@ -0,0 +1,167 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/airdrop.client.vue b/app/pages/[chain]/studio/[collection_id]/airdrop.client.vue new file mode 100644 index 00000000..5ef0560d --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/airdrop.client.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/details.vue b/app/pages/[chain]/studio/[collection_id]/details.vue new file mode 100644 index 00000000..ddc4efd7 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/details.vue @@ -0,0 +1,274 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/index.vue b/app/pages/[chain]/studio/[collection_id]/index.vue new file mode 100644 index 00000000..7dcd2a31 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/index.vue @@ -0,0 +1,190 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/list.client.vue b/app/pages/[chain]/studio/[collection_id]/list.client.vue new file mode 100644 index 00000000..4df4878e --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/list.client.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/massmint.client.vue b/app/pages/[chain]/studio/[collection_id]/massmint.client.vue new file mode 100644 index 00000000..048b2a52 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/massmint.client.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/preview.vue b/app/pages/[chain]/studio/[collection_id]/preview.vue new file mode 100644 index 00000000..02c48913 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/preview.vue @@ -0,0 +1,32 @@ + + + diff --git a/app/pages/[chain]/studio/[collection_id]/transfer.client.vue b/app/pages/[chain]/studio/[collection_id]/transfer.client.vue new file mode 100644 index 00000000..f76dd667 --- /dev/null +++ b/app/pages/[chain]/studio/[collection_id]/transfer.client.vue @@ -0,0 +1,30 @@ + + + diff --git a/app/pages/[chain]/studio/index.client.vue b/app/pages/[chain]/studio/index.client.vue new file mode 100644 index 00000000..1c2610ed --- /dev/null +++ b/app/pages/[chain]/studio/index.client.vue @@ -0,0 +1,97 @@ + + + diff --git a/app/stores/bulkOperations.ts b/app/stores/bulkOperations.ts new file mode 100644 index 00000000..799f1ccc --- /dev/null +++ b/app/stores/bulkOperations.ts @@ -0,0 +1,119 @@ +import type { AssetHubChain } from '~/plugins/sdk.client' +import type { BulkOperationItem } from '~/types/bulkOperations' +import { defineStore } from 'pinia' +import { BulkOperationType, MassMintStep } from '~/types/bulkOperations' + +export const useBulkOperationsStore = defineStore('bulkOperations', () => { + const operationType = ref(BulkOperationType.MASS_MINT) + const currentStep = ref(0) + const maxStepReached = ref(0) + const collectionId = ref('') + const chain = ref('ahp') + const isActive = ref(false) + + const { + items, + count, + itemsInChain, + getItem, + setItem, + removeItem, + clear: clearItems, + updateItem, + } = useCart({ + chain: computed(() => chain.value), + }) + + const totalSteps = computed(() => { + if (operationType.value === BulkOperationType.MASS_MINT) { + return 4 + } + return 3 + }) + + const canProceed = computed(() => { + if (operationType.value === BulkOperationType.MASS_MINT) { + switch (currentStep.value) { + case MassMintStep.UPLOAD: + return count.value > 0 + case MassMintStep.METADATA: + return true + case MassMintStep.REVIEW: + return true + case MassMintStep.MINT: + return false + default: + return false + } + } + return true + }) + + const progressPercentage = computed(() => { + return Math.round((currentStep.value / (totalSteps.value - 1)) * 100) + }) + + function initOperation(type: BulkOperationType, colId: string, ch: AssetHubChain) { + operationType.value = type + collectionId.value = colId + chain.value = ch + currentStep.value = 0 + maxStepReached.value = 0 + isActive.value = true + } + + function nextStep() { + if (currentStep.value < totalSteps.value - 1) { + currentStep.value++ + if (currentStep.value > maxStepReached.value) { + maxStepReached.value = currentStep.value + } + } + } + + function prevStep() { + if (currentStep.value > 0) { + currentStep.value-- + } + } + + function goToStep(step: number) { + if (step >= 0 && step < totalSteps.value && step <= maxStepReached.value) { + currentStep.value = step + } + } + + function reset() { + operationType.value = BulkOperationType.MASS_MINT + currentStep.value = 0 + maxStepReached.value = 0 + collectionId.value = '' + chain.value = 'ahp' + isActive.value = false + clearItems() + } + + return { + operationType, + currentStep, + maxStepReached, + collectionId, + chain, + isActive, + items, + count, + itemsInChain, + totalSteps, + canProceed, + progressPercentage, + getItem, + setItem, + removeItem, + updateItem, + initOperation, + nextStep, + prevStep, + goToStep, + reset, + } +}, { persist: true }) diff --git a/app/types/bulkOperations.ts b/app/types/bulkOperations.ts new file mode 100644 index 00000000..1b06d2b1 --- /dev/null +++ b/app/types/bulkOperations.ts @@ -0,0 +1,42 @@ +export enum BulkOperationType { + MASS_MINT = 'MASS_MINT', + AIRDROP = 'AIRDROP', + LIST = 'LIST', + DELIST = 'DELIST', + TRANSFER = 'TRANSFER', + EDIT = 'EDIT', +} + +export enum MassMintStep { + UPLOAD = 0, + METADATA = 1, + REVIEW = 2, + MINT = 3, +} + +export enum MintingState { + PRE_SIGN = 'PRE_SIGN', + SIGNING = 'SIGNING', + PROCESSING = 'PROCESSING', + SUCCESS = 'SUCCESS', + ERROR = 'ERROR', +} + +export enum MetadataPath { + TEMPLATE = 'TEMPLATE', + UNIFORM = 'UNIFORM', +} + +export interface BulkOperationItem { + id: string + chain: string + tokenId?: number + collectionId?: string + name?: string + image?: string +} + +export interface StepConfig { + label: string + icon?: string +} diff --git a/app/utils/fileFormatting.ts b/app/utils/fileFormatting.ts new file mode 100644 index 00000000..ca3b13c0 --- /dev/null +++ b/app/utils/fileFormatting.ts @@ -0,0 +1,19 @@ +export function formatFileSize(bytes: number): string { + if (bytes < 1024) + return `${bytes} B` + if (bytes < 1024 * 1024) + return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export function getFileTypeLabel(type: string): string { + if (type.startsWith('image/')) + return type.replace('image/', '').toUpperCase() + if (type.startsWith('video/')) + return 'Video' + if (type.startsWith('audio/')) + return 'Audio' + if (type.includes('gltf') || type.includes('glb')) + return '3D Model' + return 'File' +} diff --git a/package.json b/package.json index 94c156d1..b4677e60 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,8 @@ "valibot": "^1.1.0", "vue": "^3.5.22", "vue-chartjs": "^5.3.3", - "vue-dompurify-html": "^5.3.0" + "vue-dompurify-html": "^5.3.0", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@antfu/eslint-config": "^6.1.0", @@ -68,10 +69,12 @@ "@polkadot/apps-config": "^0.169.1", "@types/lodash": "^4.17.20", "@types/markdown-it": "^14.1.2", + "@types/papaparse": "^5.5.2", "@types/prismjs": "^1.26.5", "baseline-browser-mapping": "^2.9.19", "eslint": "^9.38.0", "husky": "^9.1.7", + "papaparse": "^5.5.3", "pino-pretty": "^13.1.1", "typescript": "^5.8.3", "vue-tsc": "^3.2.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35b364bd..032f291e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,7 +37,7 @@ importers: version: 2.1.0(magicast@0.5.0)(vite@7.1.12(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.8.3)) '@nuxt/ui': specifier: ^4.1.0 - version: 4.1.0(@babel/parser@7.29.0)(@emotion/is-prop-valid@1.2.2)(@netlify/blobs@9.1.2)(axios@1.13.1)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(idb-keyval@6.2.2)(ioredis@5.8.2)(jwt-decode@4.0.0)(magicast@0.5.0)(qrcode@1.5.3)(react-dom@19.1.1(react@18.3.1))(react@18.3.1)(superstruct@2.0.2)(typescript@5.8.3)(valibot@1.1.0(typescript@5.8.3))(vite@7.1.12(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3))(zod@4.1.12) + version: 4.1.0(@babel/parser@7.29.0)(@emotion/is-prop-valid@1.2.2)(@netlify/blobs@9.1.2)(axios@1.13.1)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(idb-keyval@6.2.2)(ioredis@5.8.2)(jwt-decode@4.0.0)(magicast@0.5.0)(qrcode@1.5.3)(react-dom@19.1.1(react@18.3.1))(react@18.3.1)(sortablejs@1.14.0)(superstruct@2.0.2)(typescript@5.8.3)(valibot@1.1.0(typescript@5.8.3))(vite@7.1.12(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3))(zod@4.1.12) '@nuxtjs/i18n': specifier: ^10.1.2 version: 10.1.2(@netlify/blobs@9.1.2)(@vue/compiler-dom@3.5.22)(db0@0.3.4)(eslint@9.38.0(jiti@2.6.1))(idb-keyval@6.2.2)(ioredis@5.8.2)(magicast@0.5.0)(rollup@4.52.5)(vue@3.5.22(typescript@5.8.3)) @@ -131,6 +131,9 @@ importers: vue-dompurify-html: specifier: ^5.3.0 version: 5.3.0(vue@3.5.22(typescript@5.8.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.5.22(typescript@5.8.3)) devDependencies: '@antfu/eslint-config': specifier: ^6.1.0 @@ -150,6 +153,9 @@ importers: '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + '@types/papaparse': + specifier: ^5.5.2 + version: 5.5.2 '@types/prismjs': specifier: ^1.26.5 version: 1.26.5 @@ -162,6 +168,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + papaparse: + specifier: ^5.5.3 + version: 5.5.3 pino-pretty: specifier: ^13.1.1 version: 13.1.1 @@ -4477,6 +4486,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/papaparse@5.5.2': + resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/parse-path@7.1.0': resolution: {integrity: sha512-EULJ8LApcVEPbrfND0cRQqutIOdiIgJ1Mgrhpy755r14xMohPTEpkV/k28SJvuOs9bHRFW8x+KeDAEPiGQPB9Q==} deprecated: This is a stub types definition. parse-path provides its own type definitions, so you do not need this installed. @@ -8356,6 +8368,9 @@ packages: pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -9183,6 +9198,9 @@ packages: resolution: {integrity: sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ==} engines: {node: '>=12'} + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -10272,6 +10290,11 @@ packages: typescript: optional: true + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} + peerDependencies: + vue: ^3.0.1 + wagmi@3.4.2: resolution: {integrity: sha512-ZPZUquVh75NCHvb0qI+SBegUzcFHGIGtIKCL6gtHLcYHMcEMllqZGXtkIpc98IUq5Vq7Qey4FSG4ohTtMfQ/Yw==} peerDependencies: @@ -12620,7 +12643,7 @@ snapshots: transitivePeerDependencies: - magicast - '@nuxt/ui@4.1.0(@babel/parser@7.29.0)(@emotion/is-prop-valid@1.2.2)(@netlify/blobs@9.1.2)(axios@1.13.1)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(idb-keyval@6.2.2)(ioredis@5.8.2)(jwt-decode@4.0.0)(magicast@0.5.0)(qrcode@1.5.3)(react-dom@19.1.1(react@18.3.1))(react@18.3.1)(superstruct@2.0.2)(typescript@5.8.3)(valibot@1.1.0(typescript@5.8.3))(vite@7.1.12(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3))(zod@4.1.12)': + '@nuxt/ui@4.1.0(@babel/parser@7.29.0)(@emotion/is-prop-valid@1.2.2)(@netlify/blobs@9.1.2)(axios@1.13.1)(change-case@5.4.4)(db0@0.3.4)(embla-carousel@8.6.0)(idb-keyval@6.2.2)(ioredis@5.8.2)(jwt-decode@4.0.0)(magicast@0.5.0)(qrcode@1.5.3)(react-dom@19.1.1(react@18.3.1))(react@18.3.1)(sortablejs@1.14.0)(superstruct@2.0.2)(typescript@5.8.3)(valibot@1.1.0(typescript@5.8.3))(vite@7.1.12(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.6.3(vue@3.5.22(typescript@5.8.3)))(vue@3.5.22(typescript@5.8.3))(zod@4.1.12)': dependencies: '@ai-sdk/vue': 2.0.81(vue@3.5.22(typescript@5.8.3))(zod@4.1.12) '@iconify/vue': 5.0.0(vue@3.5.22(typescript@5.8.3)) @@ -12638,7 +12661,7 @@ snapshots: '@tanstack/vue-virtual': 3.13.12(vue@3.5.22(typescript@5.8.3)) '@unhead/vue': 2.0.19(vue@3.5.22(typescript@5.8.3)) '@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.8.3)) - '@vueuse/integrations': 13.9.0(axios@1.13.1)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jwt-decode@4.0.0)(qrcode@1.5.3)(vue@3.5.22(typescript@5.8.3)) + '@vueuse/integrations': 13.9.0(axios@1.13.1)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jwt-decode@4.0.0)(qrcode@1.5.3)(sortablejs@1.14.0)(vue@3.5.22(typescript@5.8.3)) colortranslator: 5.0.0 consola: 3.4.2 defu: 6.1.4 @@ -17241,6 +17264,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/papaparse@5.5.2': + dependencies: + '@types/node': 25.0.10 + '@types/parse-path@7.1.0': dependencies: parse-path: 7.1.0 @@ -17981,7 +18008,7 @@ snapshots: '@vueuse/shared': 14.2.0(vue@3.5.22(typescript@5.8.3)) vue: 3.5.22(typescript@5.8.3) - '@vueuse/integrations@13.9.0(axios@1.13.1)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jwt-decode@4.0.0)(qrcode@1.5.3)(vue@3.5.22(typescript@5.8.3))': + '@vueuse/integrations@13.9.0(axios@1.13.1)(change-case@5.4.4)(fuse.js@7.1.0)(idb-keyval@6.2.2)(jwt-decode@4.0.0)(qrcode@1.5.3)(sortablejs@1.14.0)(vue@3.5.22(typescript@5.8.3))': dependencies: '@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.8.3)) '@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.8.3)) @@ -17993,6 +18020,7 @@ snapshots: idb-keyval: 6.2.2 jwt-decode: 4.0.0 qrcode: 1.5.3 + sortablejs: 1.14.0 '@vueuse/metadata@10.11.1': {} @@ -22514,6 +22542,8 @@ snapshots: pako@2.1.0: {} + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -23431,6 +23461,8 @@ snapshots: dependencies: is-plain-obj: 4.1.0 + sortablejs@1.14.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -24561,6 +24593,11 @@ snapshots: optionalDependencies: typescript: 5.8.3 + vuedraggable@4.1.0(vue@3.5.22(typescript@5.8.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.22(typescript@5.8.3) + wagmi@3.4.2(@safe-global/safe-apps-provider@0.18.6(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(@safe-global/safe-apps-sdk@9.1.0(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))(@tanstack/query-core@5.90.20)(@tanstack/react-query@5.90.20(react@18.3.1))(@types/react@19.2.2)(ox@0.11.3(typescript@5.8.3)(zod@4.1.12))(react@18.3.1)(typescript@5.8.3)(viem@2.45.2(bufferutil@4.1.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)): dependencies: '@tanstack/react-query': 5.90.20(react@18.3.1)