Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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