feat: implement pwa, add dockerfiles for web and cli, and introduce folder selection with wasm error handling.

This commit is contained in:
2026-02-14 19:44:19 -05:00
parent 91e7af0c04
commit 48aa59540a
17 changed files with 7637 additions and 127 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,2 @@
.venv/
wiki

16
Dockerfile.cli Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
# Install system dependencies for trimesh/shapely if needed
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY convert_nametags.py .
ENTRYPOINT ["python", "convert_nametags.py"]

29
webapp/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Build stage
FROM node:18-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Production stage
FROM nginx:stable-alpine
# Copy the build output
COPY --from=build /app/dist /usr/share/nginx/html
# Custom nginx config to handle WASM and SPA routing
RUN printf "server {\n\
listen 80;\n\
location / {\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
location ~* \\.wasm$ {\n\
root /usr/share/nginx/html;\n\
default_type application/wasm;\n\
}\n\
}" > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

92
webapp/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.5auj614cprc"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,11 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
<meta name="theme-color" content="#1a1a1a" />
<meta name="description" content="Convert SVG files to 3D printable nametags offline." />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG to Nametag Converter</title>
</head>

4084
webapp/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@
"react-dropzone": "^15.0.0",
"react-icons": "^5.5.0",
"svg-path-parser": "^1.1.0",
"three": "^0.182.0"
"three": "^0.182.0",
"vite-plugin-pwa": "^1.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",

BIN
webapp/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -59,6 +59,33 @@ header h1 {
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
/* For folder button positioning */
}
.folder-select-btn {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.05);
/* Slight visibility */
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.5rem;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
}
.folder-select-btn:hover {
background: var(--primary);
color: white;
border-color: var(--primary);
transform: scale(1.05);
}
.dropzone:hover,

View File

@@ -3,7 +3,7 @@ import { useDropzone } from 'react-dropzone'
import { saveAs } from 'file-saver'
import JSZip from 'jszip'
import { FaCloudUploadAlt, FaFileExport, FaCheckCircle, FaSpinner, FaTrash } from 'react-icons/fa'
import { MdError, Md3dRotation } from 'react-icons/md'
import { MdError, Md3dRotation, MdFolderOpen } from 'react-icons/md'
import './App.css'
import { useManifold, convertFile } from './logic/useNametagConverter'
@@ -24,6 +24,21 @@ function App() {
const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop })
const onFolderSelect = (e) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFiles = Array.from(e.target.files);
const svgs = selectedFiles.filter(f => f.name.toLowerCase().endsWith('.svg') && !f.name.startsWith('._') && !f.name.startsWith('.'));
if (svgs.length > 0) {
setFiles(prev => [...prev, ...svgs]);
addLog(`Added ${svgs.length} SVG files from folder.`);
} else {
addLog(`No SVG files found in selected folder.`);
}
}
// Reset
e.target.value = null;
}
const processQueue = async () => {
if (!manifold) {
alert("WASM Engine still loading... please wait a moment.")
@@ -83,6 +98,27 @@ function App() {
<main>
<div {...getRootProps()} className={`dropzone ${isDragActive ? 'active' : ''}`}>
<input {...getInputProps()} />
{/* Folder Selection Button */}
<div
className="folder-select-btn"
onClick={(e) => e.stopPropagation()}
title="Select Folder to Batch Process"
>
<label htmlFor="folder-input" style={{ cursor: 'pointer', display: 'flex' }}>
<MdFolderOpen size={28} />
</label>
<input
id="folder-input"
type="file"
webkitdirectory=""
directory=""
multiple
style={{ display: 'none' }}
onChange={onFolderSelect}
/>
</div>
<div className="dropzone-content">
<FaCloudUploadAlt size={64} color={isDragActive ? "#00aeef" : "#666"} />
{isDragActive ?

View File

@@ -12,20 +12,25 @@ export const useManifold = () => {
useEffect(() => {
const init = async () => {
if (!wasmModule) {
// Fix: Explicitly tell Manifold where to find the WASM file
// distinct from the bundle path
wasmModule = await Module({
locateFile: (path) => {
if (path.endsWith('.wasm')) {
return '/manifold.wasm';
try {
if (!wasmModule) {
// Fix: Explicitly tell Manifold where to find the WASM file
// distinct from the bundle path
wasmModule = await Module({
locateFile: (path) => {
if (path.endsWith('.wasm')) {
return '/manifold.wasm';
}
return path;
}
return path;
}
});
wasmModule.setup();
});
wasmModule.setup();
}
setManifold(wasmModule);
} catch (e) {
console.error("WASM Init Failed:", e);
// We could set an error state here, but for now logging prevents crash
}
setManifold(wasmModule);
};
init();
}, []);
@@ -158,7 +163,7 @@ export const convertFile = async (file, manifold, addLog) => {
// Constants
const BG_THICK = 3.0;
const TXT_THICK = 2.0;
const TXT_Z = 4.0; // Raised by 4mm
const TXT_Z = 3.0; // Starts at 3mm, Center at 4mm
return {
black: createMeshBlob(paths.black, BG_THICK, 0),

View File

@@ -1,7 +1,44 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
devOptions: {
enabled: true
},
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg', 'manifold.wasm'],
manifest: {
name: 'SVG Nametag Converter',
short_name: 'Nametag 3D',
description: 'Convert SVG files to 3D printable nametags offline.',
theme_color: '#1a1a1a',
background_color: '#1a1a1a',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'],
maximumFileSizeToCacheInBytes: 41943040, // 40 MB, just to be safe for WASM
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true
}
})
],
})