feat: implement pwa, add dockerfiles for web and cli, and introduce folder selection with wasm error handling.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
.venv/
|
||||
|
||||
wiki
|
||||
|
||||
16
Dockerfile.cli
Normal file
16
Dockerfile.cli
Normal 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
29
webapp/Dockerfile
Normal 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;"]
|
||||
1
webapp/dev-dist/registerSW.js
Normal file
1
webapp/dev-dist/registerSW.js
Normal 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
92
webapp/dev-dist/sw.js
Normal 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} didn’t 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: [/^\/$/]
|
||||
}));
|
||||
|
||||
}));
|
||||
3395
webapp/dev-dist/workbox-5a5d9309.js
Normal file
3395
webapp/dev-dist/workbox-5a5d9309.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
4084
webapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
BIN
webapp/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
webapp/public/pwa-192x192.png
Normal file
BIN
webapp/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
webapp/public/pwa-512x512.png
Normal file
BIN
webapp/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -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 |
@@ -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,
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user