mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 14:14:13 +01:00
Add upload and share functionality and minor improvements
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
||||
import {mergeDocuments, unpartition} from "@gltf-transform/functions";
|
||||
import {retrieveFile} from "../tools/upload-file.ts";
|
||||
|
||||
let io = new WebIO();
|
||||
export let extrasNameKey = "__yacv_name";
|
||||
@@ -140,7 +141,7 @@ async function fetchOrRead(url: string | Blob) {
|
||||
});
|
||||
} else {
|
||||
// Fetch the URL
|
||||
return fetch(url);
|
||||
return retrieveFile(url);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -55,8 +55,14 @@ export class NetworkManager extends EventTarget {
|
||||
else name = `blob-${Math.random()}`;
|
||||
name = name.replace('.glb', '').replace('.gltf', '');
|
||||
} else {
|
||||
// Get the last part of the URL as the "name" of the model
|
||||
name = url.split("/").pop();
|
||||
// If there is a #name parameter in the URL, use it as the name
|
||||
let urlObj = new URL(url);
|
||||
let hashParams = new URLSearchParams(urlObj.hash.slice(1));
|
||||
if (hashParams.has("name")) {
|
||||
name = hashParams.get("name") || `unknown-${Math.random()}`;
|
||||
} else { // Default to the last part of the URL as the "name" of the model
|
||||
name = url.split("/").pop();
|
||||
}
|
||||
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
||||
// Use a head request to get the hash of the file
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// These are the default values for the settings, which are overridden below
|
||||
import {ungzip} from "pako";
|
||||
import {b64UrlDecode} from "../tools/b64.ts";
|
||||
import {retrieveFile} from "../tools/upload-file.ts";
|
||||
|
||||
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
||||
export const settings = (async () => {
|
||||
@@ -42,7 +43,6 @@ export const settings = (async () => {
|
||||
|
||||
// Playground settings
|
||||
pg_code: "", // Automatically loaded and executed code for the playground
|
||||
pg_code_url: "", // URL to load the code from (overrides pg_code)
|
||||
pg_opacity_loading: -1, // Opacity of the code during first load and run (< 0 is 0.0 if preload and 0.9 if not)
|
||||
pg_opacity_loaded: 0.9, // Opacity of the code after it has been run for the first time
|
||||
};
|
||||
@@ -60,21 +60,6 @@ export const settings = (async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Grab the code from the URL if it is set
|
||||
if (settings.pg_code_url.length > 0) {
|
||||
// If the code URL is set, override the code
|
||||
try {
|
||||
const response = await fetch(settings.pg_code_url);
|
||||
if (response.ok) {
|
||||
settings.pg_code = await response.text();
|
||||
} else {
|
||||
console.warn("Failed to load code from URL:", settings.pg_code_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching code from URL:", settings.pg_code_url, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible)
|
||||
for (let i = 0; i < settings.preload.length; i++) {
|
||||
let url = settings.preload[i];
|
||||
@@ -105,17 +90,22 @@ export const settings = (async () => {
|
||||
|
||||
// Auto-decompress the code and other playground settings
|
||||
if (settings.pg_code.length > 0) {
|
||||
// pg_code has a few possible formats: URL, base64url+gzipped, or raw code (try them in that order)
|
||||
try {
|
||||
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
|
||||
} catch (error) {
|
||||
console.log("pg_code is not base64url+gzipped, assuming raw code. Decoding error:", error);
|
||||
new URL(settings.pg_code); // Check if it's a valid absolute URL
|
||||
settings.pg_code = await (await retrieveFile(settings.pg_code)).text();
|
||||
} catch (error1) { // Not a valid URL, try base64url+gzipped
|
||||
try {
|
||||
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
|
||||
} catch (error2) { // Not base64url+gzipped, assume it's raw code
|
||||
console.log("pg_code is not a URL (", error1, ") or base64url+gzipped (", error2, "), using it as raw code:", settings.pg_code);
|
||||
}
|
||||
}
|
||||
if (settings.pg_opacity_loading < 0) {
|
||||
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
|
||||
settings.pg_opacity_loading = settings.preload.length > 0 ? 0.0 : 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
})()
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
mdiFolderOpen,
|
||||
mdiPlay,
|
||||
mdiReload,
|
||||
mdiShare
|
||||
mdiShare,
|
||||
mdiUpload
|
||||
} from "@mdi/js";
|
||||
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
|
||||
// @ts-expect-error
|
||||
@@ -25,6 +26,7 @@ import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
|
||||
import {settings} from "../misc/settings.ts";
|
||||
// @ts-expect-error
|
||||
import playgroundStartupCode from './PlaygroundStartup.py?raw';
|
||||
import {uploadFile} from "./upload-file.ts";
|
||||
|
||||
const model = defineModel<{ code: string, firstTime: boolean }>({required: true}); // Initial code should only be set on first load!
|
||||
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
||||
@@ -139,9 +141,13 @@ function onModelData(modelData: string) {
|
||||
const binaryData = Base64.toUint8Array(modelData.slice(i + 1)); // Extract the base64 part
|
||||
console.assert(binaryData.slice(0, 4).toString() == "103,108,84,70", // Ugly...
|
||||
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
|
||||
// - Save for upload and share link feature
|
||||
builtModelsGlb[modelMetadata.name] = binaryData;
|
||||
// - Create a Blob from the binary data to be used as a URL
|
||||
const blob = new Blob([binaryData], {type: 'model/gltf-binary'});
|
||||
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata XXX: revoked on App.vue
|
||||
} else {
|
||||
delete builtModelsGlb[modelMetadata.name]; // Remove from built models if it's a remove request
|
||||
}
|
||||
// - Emit the event with the model metadata and URL
|
||||
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
|
||||
@@ -158,15 +164,14 @@ function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) {
|
||||
setupPyodide(false, loadSnapshot); // Reinitialize Pyodide
|
||||
}
|
||||
|
||||
function shareLink() {
|
||||
function shareLinkCommon(added: Record<string, string>, forgotten: Array<string>) {
|
||||
const baseUrl = window.location
|
||||
const searchParams = new URLSearchParams(baseUrl.search);
|
||||
searchParams.delete('pg_code_url'); // Remove any existing pg_code parameter
|
||||
searchParams.delete('pg_code'); // Remove any existing pg_code parameter
|
||||
for (const k of forgotten) searchParams.delete(k);
|
||||
const hashParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
|
||||
hashParams.delete('pg_code_url') // Would overwrite the pg_code parameter
|
||||
hashParams.set('pg_code', b64UrlEncode(gzip(model.value.code, {level: 9}))); // Compress and encode the code
|
||||
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`; // Prefer hash to GET
|
||||
for (const k of forgotten) hashParams.delete(k);
|
||||
for (const k in added) hashParams.append(k, added[k]); // Prefer hash to GET
|
||||
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`;
|
||||
output(`Share link ready: ${shareUrl}\n`)
|
||||
if (navigator.clipboard?.writeText === undefined) {
|
||||
output("Clipboard API not available. Please copy the link manually.\n");
|
||||
@@ -178,6 +183,35 @@ function shareLink() {
|
||||
}
|
||||
}
|
||||
|
||||
function shareLink() {
|
||||
shareLinkCommon({'pg_code': b64UrlEncode(gzip(model.value.code, {level: 9}))}, ['pg_code']);
|
||||
}
|
||||
|
||||
const builtModelsGlb: Record<string, Uint8Array> = {}; // Store built models to support uploading
|
||||
async function uploadAndShareLink() {
|
||||
try {
|
||||
output("Uploading files...\n");
|
||||
|
||||
// Upload code.py
|
||||
const codeBlob = new Blob([model.value.code], {type: 'text/x-python'});
|
||||
const newParams: Record<string, string> = {
|
||||
'pg_code': await uploadFile('code.py', new Uint8Array(await codeBlob.arrayBuffer()))
|
||||
};
|
||||
|
||||
// Upload all models
|
||||
for (const name in builtModelsGlb) {
|
||||
const glb: any = builtModelsGlb[name];
|
||||
newParams['preload'] = await uploadFile(name + '.glb', glb);
|
||||
}
|
||||
|
||||
// Build share URL
|
||||
return shareLinkCommon(newParams, ['pg_code'])
|
||||
} catch (e) {
|
||||
output(`Error uploading/sharing files: ${e}. Falling back to private share link.\n`);
|
||||
return shareLink(); // Fallback to private share link if upload fails
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot() {
|
||||
throw new Error("Not implemented yet!"); // TODO: Implement snapshot saving
|
||||
}
|
||||
@@ -230,38 +264,48 @@ onMounted(() => {
|
||||
location="bottom">Opacity of the editor (0 = hidden, 1 = fully visible)</v-tooltip>
|
||||
</span>
|
||||
|
||||
<span style="margin-right: -8px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<span style="padding-left: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<v-btn icon disabled @click="saveSnapshot()">
|
||||
<svg-icon :path="mdiContentSave" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-tooltip activator="parent"
|
||||
location="bottom">Save current state to a snapshot for fast startup (WIP)</v-tooltip>
|
||||
</span>
|
||||
<span style="margin-left: -8px"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<span style="padding-right: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<v-btn icon disabled @click="loadSnapshot()">
|
||||
<svg-icon :path="mdiFolderOpen" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-tooltip activator="parent" location="bottom">Load snapshot for fast startup (WIP)</v-tooltip>
|
||||
</span>
|
||||
|
||||
<v-btn icon @click="resetWorker()">
|
||||
<v-btn icon @click="shareLink()" style="padding-left: 12px;">
|
||||
<svg-icon :path="mdiShare" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Share link that automatically runs the code.<br/>Only people
|
||||
with the link can see the code.
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="uploadAndShareLink()" style="padding-right: 12px">
|
||||
<svg-icon :path="mdiShare" type="mdi" style="position: absolute; scale: 75%; top: 6px;"/>
|
||||
<svg-icon :path="mdiUpload" type="mdi" style="position: absolute; scale: 75%; bottom: 6px;"/>
|
||||
<v-tooltip activator="parent" location="bottom">Uploads all models and code and then shares a link to them.<br/>Useful
|
||||
to view the models while the playground loads, but uses third-party storage.
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="resetWorker()" style="padding-left: 12px;">
|
||||
<svg-icon :path="mdiReload" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Reset Pyodide worker (this forgets all previous state and will
|
||||
take a little while)
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="runCode()" :disabled="running">
|
||||
<v-btn icon @click="runCode()" :disabled="running" style="padding-right: 12px">
|
||||
<svg-icon :path="mdiPlay" type="mdi"/>
|
||||
<Loading v-if="running" style="position: absolute; top: -16%; left: -16%"/><!-- Ugly positioning -->
|
||||
<Loading v-if="running" style="position: absolute; top: -16%; left: -28%"/><!-- Ugly positioning -->
|
||||
<v-tooltip activator="parent" location="bottom">Run code</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="shareLink()">
|
||||
<svg-icon :path="mdiShare" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Share link that auto-runs the code</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="emit('close')">
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>
|
||||
|
||||
84
frontend/tools/upload-file.ts
Normal file
84
frontend/tools/upload-file.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { encrypt } from "tanmayo7lock";
|
||||
|
||||
async function check(lockerName: string) {
|
||||
const fileUrl = `https://vouz-backend.onrender.com/api/check_key`;
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({name: encrypt(lockerName), key: encrypt(lockerName)}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to get file URL: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||
const status = await response.json();
|
||||
return {response, status};
|
||||
}
|
||||
|
||||
export async function uploadFile(name: string, data: Uint8Array): Promise<string> {
|
||||
// "Free" storage, let's see how long it lasts...
|
||||
// Create a locker
|
||||
const lockerUrl = `https://vouz-backend.onrender.com/api/locker`
|
||||
const lockerName = `yacv-pg-${name}-${Date.now()}`; // Unique locker name
|
||||
let responsePromise = fetch(lockerUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({name: encrypt(lockerName), passkey: encrypt(lockerName)}),
|
||||
});
|
||||
|
||||
// The previous request never answers 🤮
|
||||
responsePromise.then((response) => console.warn(`Locker creation response: ${response.status} ${response.statusText} -- ${response.headers.get('Content-Type')}`));
|
||||
// Instead, poll the check endpoint until the locker is created
|
||||
let i: number;
|
||||
for (i = 0; i < 10; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 250)); // Wait a bit before checking
|
||||
try {
|
||||
let {status} = await check(lockerName);
|
||||
if (status && status.data && status.data.length == 0) break // Locker is created
|
||||
} catch (e) { // Ignore errors, they will be thrown later
|
||||
}
|
||||
}
|
||||
if (i >= 10) throw new Error(`Failed to create locker after 10 attempts: ${lockerName}`);
|
||||
|
||||
// Upload file to the locker
|
||||
const uploadUrl = `https://vouz-backend.onrender.com/api/upload`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([data], {type: 'application/octet-stream'}), name);
|
||||
formData.append("name", encrypt(lockerName));
|
||||
formData.append("passkey", encrypt(lockerName));
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to upload file: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||
|
||||
// Fake URL for retrieveFile to work
|
||||
return "https://vouz.tech#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||
}
|
||||
|
||||
/** Given any URL, it retrieves the file, with custom code for the vouz.tech locker. */
|
||||
export async function retrieveFile(url: string): Promise<Response> {
|
||||
let realUrl = url;// Normal fetch if the URL is not a vouz.tech locker URL
|
||||
if (url.indexOf("https://vouz.tech#") !== -1) { // Check if the URL is a vouz.tech locker URL
|
||||
// Parse the URL to get the locker name and file name
|
||||
const urlObj = new URL(url);
|
||||
const hashParams = new URLSearchParams(urlObj.hash.slice(1)); // Remove the leading '#'
|
||||
const lockerName = hashParams.get('locker') || (() => {
|
||||
throw new Error("Locker name not found in URL hash")
|
||||
})();
|
||||
const name = hashParams.get('name') || (() => {
|
||||
throw new Error("File name not found in URL hash")
|
||||
})();
|
||||
// Get the URL of the uploaded file
|
||||
let {status} = await check(lockerName);
|
||||
if (!status || !status.data || status.data.length == 0 || !status.data[0].url) {
|
||||
throw new Error(`No file URL found in response: ${JSON.stringify(status)}`);
|
||||
}
|
||||
console.debug("File access requested successfully, URL:", status.data[0].url);
|
||||
realUrl = "https://corsproxy.io/?url=" + status.data[0].url + "#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||
}
|
||||
return await fetch(realUrl);
|
||||
}
|
||||
Reference in New Issue
Block a user