c99f40bb20
Release pipeline / Get version (push) Has been cancelled
Release pipeline / Get Chart Name (push) Has been cancelled
Release pipeline / tests (push) Has been cancelled
Release pipeline / builds (push) Has been cancelled
Release pipeline / builds-ghcr (push) Has been cancelled
Release pipeline / test-deployments (push) Has been cancelled
Release pipeline / deploy (push) Has been cancelled
Release pipeline / Helm chart oci (push) Has been cancelled
Release pipeline / npm (push) Has been cancelled
Release pipeline / snyk (push) Has been cancelled
227 lines
6.7 KiB
Vue
227 lines
6.7 KiB
Vue
<template>
|
|
<div class="min-h-screen flex items-center justify-center bg-foundation-page p-4">
|
|
<div class="bg-foundation p-8 rounded-xl shadow-lg border border-primary-muted max-w-lg w-full">
|
|
<h1 class="text-3xl font-bold mb-4 text-center text-primary">Speckle Viewer Lite</h1>
|
|
<p class="text-sm text-foreground-2 mb-8 text-center">
|
|
Upload an IFC file to instantly convert and view it in 3D.
|
|
</p>
|
|
|
|
<div
|
|
class="border-2 border-dashed rounded-xl p-10 flex flex-col items-center justify-center transition-colors text-center cursor-pointer"
|
|
:class="isDragging ? 'border-primary bg-primary/10' : 'border-outline-2 hover:border-primary-muted'"
|
|
@dragover.prevent="isDragging = true"
|
|
@dragleave.prevent="isDragging = false"
|
|
@drop.prevent="onDrop"
|
|
@click="!uploadStatus && fileInput.click()"
|
|
>
|
|
<template v-if="uploadStatus">
|
|
<div class="text-primary font-medium animate-pulse">{{ uploadStatus }}</div>
|
|
<div v-if="progress > 0" class="w-full bg-outline-3 rounded-full mt-4 h-2 max-w-xs transition-all overflow-hidden relative">
|
|
<div class="bg-primary h-full transition-all" :style="{ width: `${progress}%` }"></div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<!-- Using standard SVG to minimize dependencies on unimported icons -->
|
|
<svg class="h-10 w-10 text-primary mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
|
</svg>
|
|
<div class="font-medium text-foreground">Click to upload or drag and drop</div>
|
|
<p class="text-xs text-foreground-2 mt-2">IFC files only</p>
|
|
</template>
|
|
</div>
|
|
|
|
<input
|
|
ref="fileInput"
|
|
type="file"
|
|
class="hidden"
|
|
accept=".ifc"
|
|
@change="onFileSelect"
|
|
/>
|
|
|
|
<div v-if="errorMsg" class="mt-4 p-4 bg-danger-muted text-danger rounded-lg text-sm text-center">
|
|
{{ errorMsg }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useNuxtApp } from '#app'
|
|
import { gql } from '@apollo/client/core'
|
|
|
|
definePageMeta({ layout: 'empty' })
|
|
|
|
const router = useRouter()
|
|
const isDragging = ref(false)
|
|
const uploadStatus = ref('')
|
|
const progress = ref(0)
|
|
const fileInput = ref()
|
|
const errorMsg = ref('')
|
|
|
|
const { $apollo } = useNuxtApp()
|
|
|
|
const onDrop = (e: DragEvent) => {
|
|
isDragging.value = false
|
|
if (uploadStatus.value) return
|
|
const file = e.dataTransfer?.files[0]
|
|
if (file) handleFile(file)
|
|
}
|
|
|
|
const onFileSelect = (e: Event) => {
|
|
if (uploadStatus.value) return
|
|
const target = e.target as HTMLInputElement
|
|
const file = target.files?.[0]
|
|
if (file) {
|
|
handleFile(file)
|
|
// reset input so the same file could be selected again
|
|
target.value = ''
|
|
}
|
|
}
|
|
|
|
const handleFile = async (file: File) => {
|
|
if (!file.name.toLowerCase().endsWith('.ifc')) {
|
|
errorMsg.value = 'Please upload a valid .ifc file.'
|
|
return
|
|
}
|
|
|
|
try {
|
|
errorMsg.value = ''
|
|
uploadStatus.value = 'Creating Bucket...'
|
|
progress.value = 5
|
|
|
|
// 1. Create a public project
|
|
const projectMut = await $apollo.defaultClient.mutate({
|
|
mutation: gql`
|
|
mutation ProjectCreate($input: ProjectCreateInput!) {
|
|
projectMutations {
|
|
create(input: $input) {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
input: {
|
|
name: file.name.substring(0, file.name.lastIndexOf('.')),
|
|
description: "Public Upload",
|
|
visibility: "PUBLIC"
|
|
}
|
|
}
|
|
})
|
|
const projectId = projectMut.data.projectMutations.create.id
|
|
|
|
// 2. Generate Upload URL
|
|
uploadStatus.value = 'Preparing upload...'
|
|
progress.value = 10
|
|
|
|
const uploadUrlMut = await $apollo.defaultClient.mutate({
|
|
mutation: gql`
|
|
mutation GenerateUploadUrl($input: GenerateFileUploadUrlInput!) {
|
|
fileUploadMutations {
|
|
generateUploadUrl(input: $input) {
|
|
fileId
|
|
url
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
input: {
|
|
projectId,
|
|
fileName: file.name
|
|
}
|
|
}
|
|
})
|
|
|
|
const { url, fileId } = uploadUrlMut.data.fileUploadMutations.generateUploadUrl
|
|
|
|
// 3. Create Model Branch
|
|
uploadStatus.value = 'Creating model...'
|
|
progress.value = 15
|
|
const modelMut = await $apollo.defaultClient.mutate({
|
|
mutation: gql`
|
|
mutation ModelCreate($input: CreateModelInput!) {
|
|
modelMutations {
|
|
create(input: $input) {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
input: {
|
|
projectId,
|
|
name: "Upload"
|
|
}
|
|
}
|
|
})
|
|
const modelId = modelMut.data.modelMutations.create.id
|
|
|
|
// 4. Upload File directly to Presigned URL
|
|
uploadStatus.value = 'Uploading file...'
|
|
progress.value = 20
|
|
|
|
const etag = await new Promise<string>((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open('PUT', url, true)
|
|
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) {
|
|
progress.value = 20 + Math.floor((e.loaded / e.total) * 70) // up to 90%
|
|
}
|
|
}
|
|
|
|
xhr.onload = () => {
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
progress.value = 90
|
|
resolve(xhr.getResponseHeader('ETag') || '"none"') // Fallback if Minio strips Etag from headers without CORS
|
|
} else {
|
|
reject(new Error('Upload failed with status: ' + xhr.status))
|
|
}
|
|
}
|
|
xhr.onerror = () => reject(new Error('Network error during upload.'))
|
|
xhr.send(file)
|
|
})
|
|
|
|
// 5. Start File Import
|
|
uploadStatus.value = 'Finalizing conversion...'
|
|
progress.value = 95
|
|
|
|
await $apollo.defaultClient.mutate({
|
|
mutation: gql`
|
|
mutation StartFileImport($input: StartFileImportInput!) {
|
|
fileUploadMutations {
|
|
startFileImport(input: $input) {
|
|
id
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
input: {
|
|
projectId,
|
|
modelId,
|
|
fileId,
|
|
etag,
|
|
}
|
|
}
|
|
})
|
|
|
|
uploadStatus.value = 'Redirecting to viewer...'
|
|
progress.value = 100
|
|
|
|
setTimeout(() => {
|
|
router.push(`/projects/${projectId}/models/${modelId}`)
|
|
}, 500)
|
|
|
|
} catch (error: any) {
|
|
console.error(error)
|
|
errorMsg.value = error.message || 'An error occurred during upload. Check console.'
|
|
uploadStatus.value = ''
|
|
progress.value = 0
|
|
}
|
|
}
|
|
</script>
|