Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ app/graphql/*.d.ts

# Descriptors
app/descriptors

# Playwright MCP
.playwright-mcp/
1 change: 1 addition & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions app/components/Navbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ const navItems = computed<NavigationMenuItem[][]>(() => [
label: 'Create',
onSelect: () => isCreateModalOpen.value = true,
},
...(accountId.value
? [{
label: 'Studio',
to: `/${routePrefix.value}/studio`,
}]
: []),
],
].map(item => item.map(i => ({
...i,
Expand Down
76 changes: 76 additions & 0 deletions app/components/bulkOperations/BulkStepper.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<script setup lang="ts">
import type { StepConfig } from '~/types/bulkOperations'

const props = defineProps<{
steps: StepConfig[]
currentStep: number
completedSteps: number[]
maxStepReached?: number
}>()

const emit = defineEmits<{
stepClick: [step: number]
}>()

function isCompleted(index: number) {
return props.completedSteps.includes(index)
}

function isCurrent(index: number) {
return props.currentStep === index
}

function isReachable(index: number) {
return !isCurrent(index) && !isCompleted(index) && props.maxStepReached !== undefined && index <= props.maxStepReached
}

function isClickable(index: number) {
return isCompleted(index) || isReachable(index)
}

function handleClick(index: number) {
if (isClickable(index)) {
emit('stepClick', index)
}
}
</script>

<template>
<div class="flex items-center gap-2">
<template v-for="(step, index) in steps" :key="index">
<button
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200"
:class="{
'bg-primary text-white': isCurrent(index),
'bg-primary/10 text-primary cursor-pointer hover:bg-primary/20': isCompleted(index) && !isCurrent(index),
'bg-muted text-foreground cursor-pointer hover:bg-muted/80': isReachable(index),
'bg-muted text-muted-foreground': !isCurrent(index) && !isCompleted(index) && !isReachable(index),
'cursor-default': !isClickable(index) && !isCurrent(index),
}"
:disabled="!isClickable(index) && !isCurrent(index)"
@click="handleClick(index)"
>
<UIcon
v-if="isCompleted(index) && !isCurrent(index)"
name="i-heroicons-check-20-solid"
class="w-4 h-4"
/>
<UIcon
v-else-if="step.icon"
:name="step.icon"
class="w-4 h-4"
/>
<span v-else class="w-5 h-5 rounded-full border-2 flex items-center justify-center text-xs" :class="isCurrent(index) ? 'border-white' : 'border-current'">
{{ index + 1 }}
</span>
<span class="hidden sm:inline">{{ step.label }}</span>
</button>

<div
v-if="index < steps.length - 1"
class="h-px flex-1 max-w-8 transition-colors duration-200"
:class="isCompleted(index) ? 'bg-primary' : 'bg-border'"
/>
</template>
</div>
</template>
57 changes: 57 additions & 0 deletions app/components/bulkOperations/BulkWizardFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
withDefaults(defineProps<{
backDisabled?: boolean
continueDisabled?: boolean
continueLabel?: string
loading?: boolean
showBack?: boolean
}>(), {
continueLabel: 'Continue',
showBack: true,
})

const emit = defineEmits<{
back: []
continue: []
}>()

const isMac = computed(() => {
if (import.meta.server)
return false
return navigator.platform.toUpperCase().includes('MAC')
})

const shortcutHint = computed(() => isMac.value ? '⌘+Enter' : 'Ctrl+Enter')
Comment on lines +18 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shortcut does not work here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has not been solved yet, even with the code changes.

</script>

<template>
<div class="shrink-0 flex items-center justify-between px-6 py-4 border-t border-border bg-background shadow-[0_-2px_10px_rgba(0,0,0,0.05)]">
<div>
<UButton
v-if="showBack"
variant="ghost"
icon="i-heroicons-arrow-left"
:disabled="backDisabled"
@click="emit('back')"
>
Back
</UButton>
</div>

<div class="flex items-center gap-3">
<slot name="cost-estimate" />

<UButton
color="primary"
:loading="loading"
:disabled="continueDisabled || loading"
@click="emit('continue')"
>
{{ continueLabel }}
<template #trailing>
<kbd class="hidden sm:inline text-xs opacity-60 ml-1">{{ shortcutHint }}</kbd>
</template>
</UButton>
</div>
</div>
</template>
85 changes: 85 additions & 0 deletions app/components/bulkOperations/BulkWizardLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { StepConfig } from '~/types/bulkOperations'

const props = withDefaults(defineProps<{
title: string
steps: StepConfig[]
currentStep: number
completedSteps: number[]
maxStepReached?: number
canProceed?: boolean
continueLabel?: string
loading?: boolean
backLink?: string
showFooter?: boolean
compact?: boolean
}>(), {
canProceed: false,
continueLabel: 'Continue',
showFooter: true,
})

const emit = defineEmits<{
back: []
continue: []
stepClick: [step: number]
}>()

const router = useRouter()

function handleBackLink() {
if (props.backLink) {
router.push(props.backLink)
}
else {
router.back()
}
}
</script>

<template>
<div class="flex flex-col h-full">
<div class="flex items-center gap-4 px-6 py-4 border-b border-border bg-background shrink-0">
<template v-if="!compact">
<UButton
variant="ghost"
icon="i-heroicons-arrow-left"
size="sm"
@click="handleBackLink"
/>

<h1 class="text-lg font-semibold shrink-0">
{{ title }}
</h1>
</template>

<BulkStepper
:steps="steps"
:current-step="currentStep"
:completed-steps="completedSteps"
:max-step-reached="maxStepReached"
class="flex-1 justify-center"
@step-click="emit('stepClick', $event)"
/>
</div>

<div class="flex-1 overflow-y-auto">
<slot />
</div>

<BulkWizardFooter
v-if="showFooter"
:back-disabled="currentStep === 0"
:continue-disabled="!canProceed"
:continue-label="continueLabel"
:loading="loading"
:show-back="currentStep > 0"
@back="emit('back')"
@continue="emit('continue')"
>
<template #cost-estimate>
<slot name="cost-estimate" />
</template>
</BulkWizardFooter>
</div>
</template>
127 changes: 127 additions & 0 deletions app/components/collection/CollectionDisplay.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { AssetHubChain } from '~/plugins/sdk.client'

const props = defineProps<{
name: string
description: string
image: string
banner: string
owner: string
supply: string
claimed: string
floor: number | null
chain: AssetHubChain
collectionId: string
readOnly?: boolean
}>()

const bannerUrl = computed(() => {
return props.banner ? toOriginalContentUrl(sanitizeIpfsUrl(props.banner)) : ''
})

const mockNfts = computed(() => {
const names = [
'Nebula Drift',
'Solar Whisper',
'Quantum Bloom',
'Astral Echo',
'Cosmic Seed',
'Void Walker',
'Star Forge',
'Lunar Tide',
'Photon Veil',
'Dark Matter',
'Celestial Shard',
'Plasma Wave',
]
return names.map((name, i) => ({
tokenId: i + 1,
collectionId: Number(props.collectionId),
chain: props.chain,
name: `${name} #${i + 1}`,
price: i % 3 === 0 ? String((1.5 + i * 0.25) * 1e10) : null,
currentOwner: props.owner,
}))
})
</script>

<template>
<div>
<!-- Banner Section -->
<div class="relative w-full min-h-[340px] flex flex-col justify-end rounded-xl overflow-hidden">
<div
class="absolute inset-0 w-full h-full bg-muted"
:style="bannerUrl ? {
backgroundImage: `url('${bannerUrl}')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
} : {}"
/>

<div class="relative flex items-center px-8 py-8 z-10">
<div class="flex flex-col items-center">
<div class="w-36 h-36 rounded-xl overflow-hidden bg-linear-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 border-4 border-white dark:border-gray-900 shadow-xl">
<img
v-if="image"
:src="sanitizeIpfsUrl(image)"
:alt="name"
class="w-full h-full object-cover"
>
<div v-else class="w-full h-full flex items-center justify-center">
<UIcon name="i-heroicons-photo" class="w-12 h-12 text-gray-400" />
</div>
</div>
</div>
</div>
</div>

<div class="w-full">
<div class="flex justify-between flex-col md:flex-row gap-12">
<div class="flex flex-col flex-1">
<div class="my-4">
<div class="text-2xl font-bold mb-2">
{{ name }}
</div>
<div v-if="owner" class="flex items-center gap-1 text-muted-foreground">
<UserInfo :avatar-size="26" :address="owner" class="min-w-0" />
</div>
</div>
<MarkdownPreview v-if="description" :source="description" />
</div>

<!-- Quick Stats -->
<div class="pt-4 w-auto md:w-60 space-y-3">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Minted</span>
<span class="font-medium font-mono text-gray-900 dark:text-white">{{ claimed || 0 }} / {{ Number(supply) >= Number.MAX_SAFE_INTEGER ? '∞' : (supply || 0) }}</span>
</div>

<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Floor Price</span>
<span class="font-medium text-gray-900 dark:text-white">
<Money v-if="floor" inline :value="floor" />
<span v-else class="text-gray-400 dark:text-gray-500">--</span>
</span>
</div>
</div>
</div>
</div>

<USeparator class="my-12" />

<!-- Items Grid (read-only, no selection/hover) -->
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 md:gap-6">
<TokenCard
v-for="nft in mockNfts"
:key="`preview-${nft.tokenId}`"
:token-id="nft.tokenId"
:collection-id="nft.collectionId"
:chain="nft.chain"
:name="nft.name"
:price="nft.price"
:current-owner="nft.currentOwner"
:hide-hover-action="readOnly"
/>
</div>
</div>
</template>
Loading