first commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
55
README.md
Normal file
55
README.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Slipmatz Designer
|
||||||
|
|
||||||
|
Nuxt 4 single-page experience for creating custom slipmat artwork. Users pick a vinyl template, layer text/images/shapes on a circular Fabric.js canvas, generate a polished preview, and export both a web-friendly and production-ready PNG (e.g. 12" @ 300 DPI ⇒ 3600×3600 px).
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- Template presets for popular vinyl diameters with bleed & safe-zone guides
|
||||||
|
- Fabric.js-powered editor: drag, scale, rotate, and stack elements inside a clipped circular canvas
|
||||||
|
- Tools for adding text, circles, rectangles, and uploading artwork
|
||||||
|
- Live preview card with instant web-resolution snapshot
|
||||||
|
- One-click export that produces both preview and print PNGs and offers direct downloads
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit [http://localhost:3000](http://localhost:3000) to start designing.
|
||||||
|
|
||||||
|
## 🛠️ Production Builds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # compile client + server bundles
|
||||||
|
npm run preview # optional: preview the built output
|
||||||
|
```
|
||||||
|
|
||||||
|
Nuxt outputs the server bundle into `.output/`. You can serve it with `node .output/server/index.mjs` or deploy through your preferred Node hosting provider.
|
||||||
|
|
||||||
|
## 🧩 Export Workflow
|
||||||
|
|
||||||
|
1. Choose a slipmat template to match the order (bleed/safe-zone update automatically).
|
||||||
|
2. Compose the design with the toolbar tools—the canvas enforces a circular clip.
|
||||||
|
3. Click **Generate Files** to render both preview and print PNGs.
|
||||||
|
4. Use the download buttons to retrieve the assets:
|
||||||
|
- _Web preview_: 1024×1024 PNG suitable for the storefront and checkout review.
|
||||||
|
- _Print-ready PNG_: Exact template resolution (e.g. 3600×3600 for a 12" slipmat at 300 DPI) for production.
|
||||||
|
|
||||||
|
High-res exports remain client-side (no upload yet). Wire the `exportDesign` / `productionBlob` values into your backend to submit orders.
|
||||||
|
|
||||||
|
## 📦 Tech Stack
|
||||||
|
|
||||||
|
- [Nuxt 4](https://nuxt.com/) + Vite
|
||||||
|
- [Fabric.js 6](http://fabricjs.com/) for canvas editing
|
||||||
|
- Tailwind CSS (via `@tailwindcss/vite`) for styling utilities
|
||||||
|
|
||||||
|
## 🧭 Next Ideas
|
||||||
|
|
||||||
|
- Persist projects (auth + cloud storage)
|
||||||
|
- CMYK color profile previews & bleed handling
|
||||||
|
- 3D platter preview using Three.js
|
||||||
|
- Admin dashboard for incoming print jobs
|
||||||
|
|
||||||
|
Contributions & ideas are welcome—have fun crafting vinyl eye candy! 🎛️
|
||||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
1
app/assets/css/main.css
Normal file
1
app/assets/css/main.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
151
app/components/designer/DesignerCanvas.vue
Normal file
151
app/components/designer/DesignerCanvas.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type { Canvas as FabricCanvas } from "fabric";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
size: number;
|
||||||
|
registerCanvas: (payload: {
|
||||||
|
canvas: FabricCanvas;
|
||||||
|
fabric: typeof import("fabric");
|
||||||
|
}) => void;
|
||||||
|
unregisterCanvas: () => void;
|
||||||
|
backgroundColor: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const canvasElement = ref<HTMLCanvasElement | null>(null);
|
||||||
|
const containerElement = ref<HTMLDivElement | null>(null);
|
||||||
|
const isReady = ref(false);
|
||||||
|
|
||||||
|
let fabricCanvas: FabricCanvas | null = null;
|
||||||
|
let fabricModule: typeof import("fabric") | null = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const syncCanvasDomStyles = () => {
|
||||||
|
if (!fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvases = [fabricCanvas.lowerCanvasEl, fabricCanvas.upperCanvasEl];
|
||||||
|
canvases.forEach((element) => {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.classList.add("rounded-full");
|
||||||
|
element.style.borderRadius = "9999px";
|
||||||
|
element.style.backgroundColor = "transparent";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCssDimensions = (dimension?: number) => {
|
||||||
|
if (!fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetSize =
|
||||||
|
dimension ??
|
||||||
|
containerElement.value?.clientWidth ??
|
||||||
|
containerElement.value?.clientHeight ??
|
||||||
|
null;
|
||||||
|
if (!targetSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fabricCanvas.setDimensions(
|
||||||
|
{
|
||||||
|
width: targetSize,
|
||||||
|
height: targetSize,
|
||||||
|
},
|
||||||
|
{ cssOnly: true }
|
||||||
|
);
|
||||||
|
fabricCanvas.calcOffset();
|
||||||
|
fabricCanvas.requestRenderAll();
|
||||||
|
syncCanvasDomStyles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeContainer = () => {
|
||||||
|
if (!containerElement.value || !fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dimension = Math.min(entry.contentRect.width, entry.contentRect.height);
|
||||||
|
updateCssDimensions(dimension);
|
||||||
|
});
|
||||||
|
resizeObserver.observe(containerElement.value);
|
||||||
|
updateCssDimensions();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvas = async () => {
|
||||||
|
if (typeof window === "undefined" || !canvasElement.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fabricModule = await import("fabric");
|
||||||
|
|
||||||
|
const { Canvas } = fabricModule;
|
||||||
|
fabricCanvas = new Canvas(canvasElement.value, {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
selection: true,
|
||||||
|
preserveObjectStacking: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
fabricCanvas.setDimensions({ width: props.size, height: props.size });
|
||||||
|
|
||||||
|
props.registerCanvas({ canvas: fabricCanvas, fabric: fabricModule });
|
||||||
|
observeContainer();
|
||||||
|
syncCanvasDomStyles();
|
||||||
|
isReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setupCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
props.unregisterCanvas();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
resizeObserver = null;
|
||||||
|
fabricCanvas = null;
|
||||||
|
fabricModule = null;
|
||||||
|
isReady.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.size,
|
||||||
|
(next, prev) => {
|
||||||
|
if (!isReady.value || next === prev || !fabricCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fabricCanvas.setDimensions({ width: next, height: next });
|
||||||
|
updateCssDimensions();
|
||||||
|
fabricCanvas.renderAll();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="relative mx-auto flex max-w-[min(720px,100%)] items-center justify-center overflow-hidden rounded-3xl border border-slate-700/60 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/40"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-square w-full">
|
||||||
|
<div class="absolute inset-4 sm:inset-5 md:inset-6 lg:inset-8">
|
||||||
|
<div ref="containerElement" class="relative h-full w-full">
|
||||||
|
<canvas
|
||||||
|
ref="canvasElement"
|
||||||
|
class="absolute inset-0 h-full w-full rounded-full"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="!isReady"
|
||||||
|
class="absolute inset-0 grid place-items-center rounded-full bg-slate-900/70"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-slate-400">Loading canvas…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
117
app/components/designer/DesignerPreview.vue
Normal file
117
app/components/designer/DesignerPreview.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
previewUrl: string | null;
|
||||||
|
templateLabel: string;
|
||||||
|
productionPixels: number;
|
||||||
|
isExporting: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "download-preview"): void;
|
||||||
|
(e: "download-production"): void;
|
||||||
|
(e: "export"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const viewMode = ref<"flat" | "turntable">("flat");
|
||||||
|
|
||||||
|
const isFlat = computed(() => viewMode.value === "flat");
|
||||||
|
|
||||||
|
const handleSelectView = (mode: "flat" | "turntable") => {
|
||||||
|
viewMode.value = mode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = () => emit("export");
|
||||||
|
const handleDownloadPreview = () => emit("download-preview");
|
||||||
|
const handleDownloadProduction = () => emit("download-production");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/40">
|
||||||
|
<header class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Output Preview
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-slate-300">
|
||||||
|
{{ templateLabel }} • {{ productionPixels }}×{{ productionPixels }} px
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-sky-500/60 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-sky-100 transition hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:border-slate-600 disabled:text-slate-500"
|
||||||
|
:disabled="props.isExporting"
|
||||||
|
@click="handleExport"
|
||||||
|
>
|
||||||
|
{{ props.isExporting ? "Exporting…" : "Generate Files" }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
|
||||||
|
:class="isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
|
||||||
|
@click="handleSelectView('flat')"
|
||||||
|
>
|
||||||
|
Flat View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl border px-3 py-2 text-xs font-semibold uppercase tracking-wide transition"
|
||||||
|
:class="!isFlat ? 'border-sky-500 bg-sky-500/10 text-sky-200' : 'border-slate-800 bg-slate-900 text-slate-400 hover:border-slate-700/80 hover:text-slate-200'"
|
||||||
|
@click="handleSelectView('turntable')"
|
||||||
|
>
|
||||||
|
Turntable View
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 aspect-square overflow-hidden rounded-2xl border border-slate-800 bg-slate-950">
|
||||||
|
<template v-if="props.previewUrl">
|
||||||
|
<div v-if="isFlat" class="h-full w-full">
|
||||||
|
<img
|
||||||
|
:src="props.previewUrl"
|
||||||
|
alt="Slipmat preview flat"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="relative h-full w-full bg-linear-to-br from-slate-900 via-slate-950 to-black">
|
||||||
|
<img
|
||||||
|
src="/turntable-mockup.svg"
|
||||||
|
alt="Turntable illustration"
|
||||||
|
class="pointer-events-none h-full w-full object-contain"
|
||||||
|
/>
|
||||||
|
<div class="absolute left-[16%] top-[18%] h-[64%] w-[48%] -rotate-2 overflow-hidden rounded-full shadow-xl shadow-black/40">
|
||||||
|
<div class="absolute inset-0 bg-slate-900/40" />
|
||||||
|
<img
|
||||||
|
:src="props.previewUrl"
|
||||||
|
alt="Slipmat preview turntable"
|
||||||
|
class="h-full w-full object-cover opacity-95 mix-blend-screen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-full items-center justify-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No preview yet—start designing!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl bg-slate-800 px-4 py-3 text-sm font-medium text-slate-100 transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:bg-slate-800/70 disabled:text-slate-500"
|
||||||
|
:disabled="!props.previewUrl"
|
||||||
|
@click="handleDownloadPreview"
|
||||||
|
>
|
||||||
|
Download Web Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-xl bg-sky-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:bg-slate-600/70"
|
||||||
|
:disabled="props.isExporting"
|
||||||
|
@click="handleDownloadProduction"
|
||||||
|
>
|
||||||
|
Download Print-Ready PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
238
app/components/designer/DesignerToolbar.vue
Normal file
238
app/components/designer/DesignerToolbar.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
onAddText: () => void;
|
||||||
|
onAddCircle: () => void;
|
||||||
|
onAddRectangle: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onImportImage: (file: File) => Promise<void>;
|
||||||
|
onFillChange: (fill: string) => void;
|
||||||
|
onStrokeChange: (stroke: string) => void;
|
||||||
|
activeFill: string | null;
|
||||||
|
activeStroke: string | null;
|
||||||
|
canStyleSelection: boolean;
|
||||||
|
zoom: number;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
onZoomChange: (zoom: number) => void;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onZoomReset: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "request-image"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const fillValue = ref(props.activeFill ?? "#111827");
|
||||||
|
const strokeValue = ref(props.activeStroke ?? "#3b82f6");
|
||||||
|
const zoomSliderValue = ref(Math.round(props.zoom * 100));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeFill,
|
||||||
|
(next) => {
|
||||||
|
fillValue.value = next ?? "#111827";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.activeStroke,
|
||||||
|
(next) => {
|
||||||
|
strokeValue.value = next ?? "#3b82f6";
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.zoom,
|
||||||
|
(next) => {
|
||||||
|
zoomSliderValue.value = Math.round(next * 100);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const openFilePicker = () => {
|
||||||
|
if (!fileInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileInput.value.value = "";
|
||||||
|
fileInput.value.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = input.files;
|
||||||
|
if (!files || !files.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [file] = files;
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await props.onImportImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stylingDisabled = computed(() => !props.canStyleSelection);
|
||||||
|
|
||||||
|
const handleFillChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const value = input.value;
|
||||||
|
fillValue.value = value;
|
||||||
|
props.onFillChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStrokeChange = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const value = input.value;
|
||||||
|
strokeValue.value = value;
|
||||||
|
props.onStrokeChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomInput = (event: Event) => {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const value = Number(input.value);
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
zoomSliderValue.value = value;
|
||||||
|
props.onZoomChange(value / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomLabel = computed(() => `${Math.round(props.zoom * 100)}%`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="rounded-2xl border border-slate-800/60 bg-slate-900/80 p-4 shadow-lg shadow-slate-950/30">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Canvas Tools
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-red-500/40 px-3 py-1 text-xs font-medium text-red-300 transition hover:bg-red-500/10"
|
||||||
|
@click="props.onClear"
|
||||||
|
>
|
||||||
|
Clear Canvas
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
@click="props.onAddText"
|
||||||
|
>
|
||||||
|
<span class="mb-2 text-3xl font-semibold">T</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide">Add Text</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
@click="props.onAddCircle"
|
||||||
|
>
|
||||||
|
<span class="mb-2 text-3xl font-semibold">●</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide">Add Circle</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
@click="props.onAddRectangle"
|
||||||
|
>
|
||||||
|
<span class="mb-2 text-3xl font-semibold">▭</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide">Add Rectangle</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex flex-col items-center justify-center rounded-xl border border-slate-700/60 bg-slate-800/80 px-4 py-6 text-sm font-medium text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
@click="openFilePicker"
|
||||||
|
>
|
||||||
|
<span class="mb-2 text-3xl font-semibold">⇪</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide">Upload Image</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Styling
|
||||||
|
</h4>
|
||||||
|
<span
|
||||||
|
v-if="stylingDisabled"
|
||||||
|
class="text-[11px] font-medium uppercase tracking-wide text-slate-500"
|
||||||
|
>
|
||||||
|
Select an object
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Fill
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||||||
|
:disabled="stylingDisabled"
|
||||||
|
:value="fillValue"
|
||||||
|
@input="handleFillChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Stroke
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
class="h-10 w-full cursor-pointer rounded-lg border border-slate-700/70 bg-slate-800/80 p-1"
|
||||||
|
:disabled="stylingDisabled"
|
||||||
|
:value="strokeValue"
|
||||||
|
@input="handleStrokeChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 rounded-xl border border-slate-800/60 bg-slate-900/70 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
View
|
||||||
|
</h4>
|
||||||
|
<span class="text-[11px] font-medium uppercase tracking-wide text-slate-300">
|
||||||
|
{{ zoomLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||||||
|
@click="props.onZoomOut"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="flex-1 accent-sky-500"
|
||||||
|
:min="Math.round(props.minZoom * 100)"
|
||||||
|
:max="Math.round(props.maxZoom * 100)"
|
||||||
|
step="5"
|
||||||
|
:value="zoomSliderValue"
|
||||||
|
@input="handleZoomInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 items-center justify-center rounded-lg border border-slate-700/60 bg-slate-800/80 text-lg font-bold text-slate-100 transition hover:border-sky-500/70 hover:bg-sky-500/10"
|
||||||
|
@click="props.onZoomIn"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 w-full rounded-lg border border-slate-700/60 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:border-sky-500/70 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
@click="props.onZoomReset"
|
||||||
|
>
|
||||||
|
Reset Zoom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
68
app/components/designer/TemplatePicker.vue
Normal file
68
app/components/designer/TemplatePicker.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SlipmatTemplate } from "~/composables/useSlipmatDesigner";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
templates: SlipmatTemplate[];
|
||||||
|
selectedTemplateId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "select", templateId: string): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleSelect = (templateId: string) => {
|
||||||
|
emit("select", templateId);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-100">Slipmat Template</h2>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
Pick the vinyl size and print spec that matches this order.
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<button
|
||||||
|
v-for="template in props.templates"
|
||||||
|
:key="template.id"
|
||||||
|
type="button"
|
||||||
|
class="group rounded-xl border border-slate-700/70 bg-slate-900/60 p-4 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"
|
||||||
|
:class="{
|
||||||
|
'border-sky-400/80 bg-sky-500/10 shadow-lg shadow-sky-500/20':
|
||||||
|
template.id === props.selectedTemplateId,
|
||||||
|
}"
|
||||||
|
@click="handleSelect(template.id)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-base font-medium text-slate-100">
|
||||||
|
{{ template.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="template.id === props.selectedTemplateId"
|
||||||
|
class="rounded-full bg-sky-500/20 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-sky-200"
|
||||||
|
>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl class="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs text-slate-300">
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Diameter</dt>
|
||||||
|
<dd>{{ template.diameterInches }}"</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Resolution</dt>
|
||||||
|
<dd>{{ template.dpi }} DPI</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Bleed</dt>
|
||||||
|
<dd>{{ template.bleedInches ?? 0 }}"</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-slate-500">Safe Zone</dt>
|
||||||
|
<dd>{{ template.safeZoneInches ?? 0 }}"</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
5
app/composables/useSlipmatDesigner.ts
Normal file
5
app/composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from "vue";
|
||||||
|
|
||||||
|
// import type { fabric as FabricNamespace } from "fabric";
|
||||||
|
|
||||||
|
export * from "../../composables/useSlipmatDesigner";
|
||||||
124
app/pages/index.vue
Normal file
124
app/pages/index.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
||||||
|
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||||
|
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
||||||
|
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
||||||
|
|
||||||
|
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||||||
|
|
||||||
|
const {
|
||||||
|
templates,
|
||||||
|
selectedTemplate,
|
||||||
|
selectTemplate,
|
||||||
|
displaySize,
|
||||||
|
templateLabel,
|
||||||
|
productionPixelSize,
|
||||||
|
previewUrl,
|
||||||
|
registerCanvas,
|
||||||
|
unregisterCanvas,
|
||||||
|
addTextbox,
|
||||||
|
addShape,
|
||||||
|
addImageFromFile,
|
||||||
|
clearDesign,
|
||||||
|
downloadPreview,
|
||||||
|
downloadProduction,
|
||||||
|
exportDesign,
|
||||||
|
isExporting,
|
||||||
|
activeFillColor,
|
||||||
|
activeStrokeColor,
|
||||||
|
canStyleSelection,
|
||||||
|
setActiveFillColor,
|
||||||
|
setActiveStrokeColor,
|
||||||
|
zoomLevel,
|
||||||
|
minZoom,
|
||||||
|
maxZoom,
|
||||||
|
setZoom,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
} = useSlipmatDesigner();
|
||||||
|
|
||||||
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
|
selectTemplate(templateId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
await exportDesign();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
||||||
|
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
||||||
|
<header class="space-y-3">
|
||||||
|
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">
|
||||||
|
Slipmatz Designer
|
||||||
|
</p>
|
||||||
|
<h1 class="text-3xl font-bold text-white sm:text-4xl">
|
||||||
|
Craft custom slipmats ready for the pressing plant.
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-3xl text-base text-slate-300">
|
||||||
|
Pick a template, drop in artwork, and we’ll generate both a high-fidelity
|
||||||
|
preview and a print-ready PNG at exact specs. Everything stays within a
|
||||||
|
circular safe zone to ensure clean results on vinyl.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<TemplatePicker
|
||||||
|
:templates="templates"
|
||||||
|
:selected-template-id="selectedTemplate.id"
|
||||||
|
@select="handleTemplateSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DesignerToolbar
|
||||||
|
:on-add-text="addTextbox"
|
||||||
|
:on-add-circle="() => addShape('circle')"
|
||||||
|
:on-add-rectangle="() => addShape('rect')"
|
||||||
|
:on-clear="clearDesign"
|
||||||
|
:on-import-image="addImageFromFile"
|
||||||
|
:on-fill-change="setActiveFillColor"
|
||||||
|
:on-stroke-change="setActiveStrokeColor"
|
||||||
|
:active-fill="activeFillColor"
|
||||||
|
:active-stroke="activeStrokeColor"
|
||||||
|
:can-style-selection="canStyleSelection"
|
||||||
|
:zoom="zoomLevel"
|
||||||
|
:min-zoom="minZoom"
|
||||||
|
:max-zoom="maxZoom"
|
||||||
|
:on-zoom-change="setZoom"
|
||||||
|
:on-zoom-in="zoomIn"
|
||||||
|
:on-zoom-out="zoomOut"
|
||||||
|
:on-zoom-reset="resetZoom"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DesignerPreview
|
||||||
|
:preview-url="previewUrl"
|
||||||
|
:template-label="templateLabel"
|
||||||
|
:production-pixels="productionPixelSize"
|
||||||
|
:is-exporting="isExporting"
|
||||||
|
@export="handleExport"
|
||||||
|
@download-preview="downloadPreview"
|
||||||
|
@download-production="downloadProduction"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<div class="rounded-3xl border border-slate-800/60 bg-linear-to-br from-slate-900 via-slate-950 to-black p-6 shadow-2xl shadow-slate-950/60">
|
||||||
|
<DesignerCanvas
|
||||||
|
:size="displaySize"
|
||||||
|
:background-color="selectedTemplate.backgroundColor"
|
||||||
|
:register-canvas="registerCanvas"
|
||||||
|
:unregister-canvas="unregisterCanvas"
|
||||||
|
/>
|
||||||
|
<p class="mt-4 text-sm text-slate-400">
|
||||||
|
Safe zone and bleed guides update automatically when you switch
|
||||||
|
templates. Use the toolbar to layer text, shapes, and imagery inside the
|
||||||
|
circular boundary.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
751
composables/useSlipmatDesigner.ts
Normal file
751
composables/useSlipmatDesigner.ts
Normal file
@@ -0,0 +1,751 @@
|
|||||||
|
import { computed, ref, shallowRef, watch } from "vue";
|
||||||
|
|
||||||
|
import type * as FabricNamespace from "fabric";
|
||||||
|
type FabricModule = typeof import("fabric");
|
||||||
|
|
||||||
|
export interface SlipmatTemplate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
diameterInches: number;
|
||||||
|
dpi: number;
|
||||||
|
bleedInches?: number;
|
||||||
|
safeZoneInches?: number;
|
||||||
|
backgroundColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportedDesign {
|
||||||
|
previewUrl: string;
|
||||||
|
previewBlob: Blob;
|
||||||
|
productionUrl: string;
|
||||||
|
productionBlob: Blob;
|
||||||
|
templateId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISPLAY_SIZE = 720;
|
||||||
|
const PREVIEW_SIZE = 1024;
|
||||||
|
const MIN_ZOOM = 0.5;
|
||||||
|
const MAX_ZOOM = 2;
|
||||||
|
const ZOOM_STEP = 0.1;
|
||||||
|
|
||||||
|
const TEMPLATE_PRESETS: SlipmatTemplate[] = [
|
||||||
|
{
|
||||||
|
id: "lp-12",
|
||||||
|
name: "12\" LP",
|
||||||
|
diameterInches: 12,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.125,
|
||||||
|
safeZoneInches: 0.25,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ep-10",
|
||||||
|
name: "10\" EP",
|
||||||
|
diameterInches: 10,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.125,
|
||||||
|
safeZoneInches: 0.25,
|
||||||
|
backgroundColor: "#f7f7f7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single-7",
|
||||||
|
name: "7\" Single",
|
||||||
|
diameterInches: 7,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0.1,
|
||||||
|
safeZoneInches: 0.2,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type FabricCanvas = FabricNamespace.Canvas;
|
||||||
|
type FabricCircle = FabricNamespace.Circle;
|
||||||
|
type FabricRect = FabricNamespace.Rect;
|
||||||
|
type FabricTextbox = FabricNamespace.Textbox;
|
||||||
|
type FabricObject = FabricNamespace.Object;
|
||||||
|
|
||||||
|
type CanvasReadyPayload = {
|
||||||
|
canvas: FabricCanvas;
|
||||||
|
fabric: FabricModule;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_TEMPLATE: SlipmatTemplate = TEMPLATE_PRESETS[0] ?? {
|
||||||
|
id: "custom",
|
||||||
|
name: "Custom",
|
||||||
|
diameterInches: 12,
|
||||||
|
dpi: 300,
|
||||||
|
bleedInches: 0,
|
||||||
|
safeZoneInches: 0,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSlipmatDesigner = () => {
|
||||||
|
const templates = ref<SlipmatTemplate[]>(TEMPLATE_PRESETS);
|
||||||
|
const selectedTemplate = ref<SlipmatTemplate>(FALLBACK_TEMPLATE);
|
||||||
|
|
||||||
|
const fabricApi = shallowRef<FabricModule | null>(null);
|
||||||
|
const canvas = shallowRef<FabricCanvas | null>(null);
|
||||||
|
const backgroundCircle = shallowRef<FabricCircle | null>(null);
|
||||||
|
const safeZoneCircle = shallowRef<FabricCircle | null>(null);
|
||||||
|
|
||||||
|
const previewUrl = ref<string | null>(null);
|
||||||
|
const previewBlob = shallowRef<Blob | null>(null);
|
||||||
|
const productionBlob = shallowRef<Blob | null>(null);
|
||||||
|
const productionObjectUrl = ref<string | null>(null);
|
||||||
|
const isExporting = ref(false);
|
||||||
|
const activeFillColor = ref<string | null>(null);
|
||||||
|
const activeStrokeColor = ref<string | null>(null);
|
||||||
|
const hasStyleableSelection = ref(false);
|
||||||
|
const zoomLevel = ref(1);
|
||||||
|
|
||||||
|
let previewRefreshScheduled = false;
|
||||||
|
|
||||||
|
const displaySize = computed(() => DISPLAY_SIZE);
|
||||||
|
|
||||||
|
const productionPixelSize = computed(() =>
|
||||||
|
Math.round(selectedTemplate.value.diameterInches * selectedTemplate.value.dpi)
|
||||||
|
);
|
||||||
|
|
||||||
|
const templateLabel = computed(
|
||||||
|
() =>
|
||||||
|
`${selectedTemplate.value.name} (${selectedTemplate.value.diameterInches}\" @ ${selectedTemplate.value.dpi} DPI)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const zoomPercent = computed(() => Math.round(zoomLevel.value * 100));
|
||||||
|
|
||||||
|
const clampZoom = (value: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
|
||||||
|
|
||||||
|
const schedulePreviewRefresh = () => {
|
||||||
|
if (previewRefreshScheduled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewRefreshScheduled = true;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
requestAnimationFrame(async () => {
|
||||||
|
previewRefreshScheduled = false;
|
||||||
|
await refreshPreview();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maintainStaticLayerOrder = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (backgroundCircle.value) {
|
||||||
|
backgroundCircle.value.set({ radius: currentCanvas.getWidth() / 2 });
|
||||||
|
}
|
||||||
|
if (backgroundCircle.value) {
|
||||||
|
currentCanvas.sendObjectToBack(backgroundCircle.value);
|
||||||
|
}
|
||||||
|
if (safeZoneCircle.value) {
|
||||||
|
currentCanvas.bringObjectToFront(safeZoneCircle.value);
|
||||||
|
}
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStaticObject = (object: FabricObject | null) =>
|
||||||
|
object === backgroundCircle.value || object === safeZoneCircle.value;
|
||||||
|
|
||||||
|
const getSelectedObjects = (): FabricObject[] => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const activeObjects = currentCanvas.getActiveObjects() as FabricObject[];
|
||||||
|
if (activeObjects && activeObjects.length) {
|
||||||
|
return activeObjects;
|
||||||
|
}
|
||||||
|
const activeObject = currentCanvas.getActiveObject() as FabricObject | null;
|
||||||
|
return activeObject ? [activeObject] : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleableSelection = (): FabricObject[] =>
|
||||||
|
getSelectedObjects().filter((object) => {
|
||||||
|
if (!object || isStaticObject(object)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const candidate = object as FabricObject & {
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
fill?: unknown;
|
||||||
|
stroke?: unknown;
|
||||||
|
};
|
||||||
|
return typeof candidate.set === "function" && ("fill" in candidate || "stroke" in candidate);
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSelectedStyleState = () => {
|
||||||
|
const styleable = getStyleableSelection();
|
||||||
|
hasStyleableSelection.value = styleable.length > 0;
|
||||||
|
if (!styleable.length) {
|
||||||
|
activeFillColor.value = null;
|
||||||
|
activeStrokeColor.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = styleable[0] as FabricObject & {
|
||||||
|
fill?: unknown;
|
||||||
|
stroke?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
activeFillColor.value =
|
||||||
|
primary && typeof primary.fill === "string" ? primary.fill : null;
|
||||||
|
activeStrokeColor.value =
|
||||||
|
primary && typeof primary.stroke === "string" ? primary.stroke : null;
|
||||||
|
|
||||||
|
canvas.value?.requestRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canStyleSelection = computed(() => hasStyleableSelection.value);
|
||||||
|
|
||||||
|
const refreshPreview = async () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const multiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||||
|
const url = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
previewUrl.value = url;
|
||||||
|
previewBlob.value = await dataUrlToBlob(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyTemplateToCanvas = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!currentCanvas || !fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = currentCanvas.getWidth();
|
||||||
|
const { backgroundColor, safeZoneInches = 0, diameterInches } =
|
||||||
|
selectedTemplate.value;
|
||||||
|
|
||||||
|
const bgCircle =
|
||||||
|
backgroundCircle.value ??
|
||||||
|
new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: size / 2,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
bgCircle.set({ fill: backgroundColor });
|
||||||
|
bgCircle.set({ radius: size / 2 });
|
||||||
|
|
||||||
|
if (!backgroundCircle.value) {
|
||||||
|
backgroundCircle.value = bgCircle;
|
||||||
|
currentCanvas.add(bgCircle);
|
||||||
|
}
|
||||||
|
currentCanvas.sendObjectToBack(bgCircle);
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
|
||||||
|
const safeRatio = Math.max(
|
||||||
|
0,
|
||||||
|
(diameterInches - safeZoneInches * 2) / diameterInches
|
||||||
|
);
|
||||||
|
|
||||||
|
const safeCircleRadius = (size / 2) * safeRatio;
|
||||||
|
|
||||||
|
const safeCircle =
|
||||||
|
safeZoneCircle.value ??
|
||||||
|
new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: safeCircleRadius,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "rgba(0,0,0,0)",
|
||||||
|
stroke: "#4b5563",
|
||||||
|
strokeDashArray: [8, 8],
|
||||||
|
strokeWidth: 1,
|
||||||
|
selectable: false,
|
||||||
|
evented: false,
|
||||||
|
hoverCursor: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
safeCircle.set({ radius: safeCircleRadius });
|
||||||
|
|
||||||
|
if (!safeZoneCircle.value) {
|
||||||
|
safeZoneCircle.value = safeCircle;
|
||||||
|
currentCanvas.add(safeCircle);
|
||||||
|
}
|
||||||
|
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
|
||||||
|
currentCanvas.clipPath = new fabric.Circle({
|
||||||
|
left: size / 2,
|
||||||
|
top: size / 2,
|
||||||
|
radius: size / 2,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
absolutePositioned: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentCanvas.renderAll();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerCanvas = ({ canvas: instance, fabric }: CanvasReadyPayload) => {
|
||||||
|
fabricApi.value = fabric;
|
||||||
|
canvas.value = instance;
|
||||||
|
|
||||||
|
instance.setDimensions({ width: DISPLAY_SIZE, height: DISPLAY_SIZE });
|
||||||
|
instance.preserveObjectStacking = true;
|
||||||
|
|
||||||
|
const refreshEvents = [
|
||||||
|
"object:added",
|
||||||
|
"object:modified",
|
||||||
|
"object:removed",
|
||||||
|
"object:skewing",
|
||||||
|
"object:scaling",
|
||||||
|
"object:rotating",
|
||||||
|
"object:moving",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const handleMutation = () => {
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
refreshEvents.forEach((eventName) => {
|
||||||
|
instance.on(eventName, handleMutation);
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on("after:render", () => schedulePreviewRefresh());
|
||||||
|
|
||||||
|
const selectionEvents = [
|
||||||
|
"selection:created",
|
||||||
|
"selection:updated",
|
||||||
|
"selection:cleared",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
selectionEvents.forEach((eventName) => {
|
||||||
|
instance.on(eventName, () => updateSelectedStyleState());
|
||||||
|
});
|
||||||
|
|
||||||
|
instance.on("mouse:wheel", (opt) => {
|
||||||
|
const { e } = opt;
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const direction = delta > 0 ? -ZOOM_STEP : ZOOM_STEP;
|
||||||
|
const nextZoom = zoomLevel.value + direction;
|
||||||
|
const pointer = instance.getPointer(e);
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const point = new fabric.Point(pointer.x, pointer.y);
|
||||||
|
setZoom(nextZoom, { point });
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTemplateToCanvas();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
setZoom(zoomLevel.value);
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unregisterCanvas = () => {
|
||||||
|
canvas.value?.dispose();
|
||||||
|
canvas.value = null;
|
||||||
|
fabricApi.value = null;
|
||||||
|
backgroundCircle.value = null;
|
||||||
|
safeZoneCircle.value = null;
|
||||||
|
activeFillColor.value = null;
|
||||||
|
activeStrokeColor.value = null;
|
||||||
|
hasStyleableSelection.value = false;
|
||||||
|
zoomLevel.value = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const centerPoint = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return { x: DISPLAY_SIZE / 2, y: DISPLAY_SIZE / 2 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: currentCanvas.getWidth() / 2,
|
||||||
|
y: currentCanvas.getHeight() / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTextbox = () => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
const textbox: FabricTextbox = new fabric.Textbox("Your Text", {
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "#111827",
|
||||||
|
fontSize: 36,
|
||||||
|
fontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif",
|
||||||
|
editable: true,
|
||||||
|
width: DISPLAY_SIZE * 0.5,
|
||||||
|
textAlign: "center",
|
||||||
|
});
|
||||||
|
currentCanvas.add(textbox);
|
||||||
|
currentCanvas.setActiveObject(textbox);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addShape = (shape: "circle" | "rect") => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
let object: FabricCircle | FabricRect;
|
||||||
|
|
||||||
|
if (shape === "circle") {
|
||||||
|
object = new fabric.Circle({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
radius: DISPLAY_SIZE * 0.18,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
fill: "rgba(59, 130, 246, 0.4)",
|
||||||
|
stroke: "#3b82f6",
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
object = new fabric.Rect({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
width: DISPLAY_SIZE * 0.3,
|
||||||
|
height: DISPLAY_SIZE * 0.18,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
rx: 12,
|
||||||
|
ry: 12,
|
||||||
|
fill: "rgba(16, 185, 129, 0.35)",
|
||||||
|
stroke: "#10b981",
|
||||||
|
strokeWidth: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCanvas.add(object);
|
||||||
|
currentCanvas.setActiveObject(object);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImageFromFile = async (file: File) => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await readFileAsDataUrl(file);
|
||||||
|
|
||||||
|
const img = await fabric.Image.fromURL(dataUrl, {
|
||||||
|
crossOrigin: "anonymous",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { x, y } = centerPoint();
|
||||||
|
img.set({
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
originX: "center",
|
||||||
|
originY: "center",
|
||||||
|
selectable: true,
|
||||||
|
clipPath: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxSize = DISPLAY_SIZE * 0.8;
|
||||||
|
const scale = Math.min(
|
||||||
|
1,
|
||||||
|
maxSize / Math.max(img.width ?? maxSize, img.height ?? maxSize)
|
||||||
|
);
|
||||||
|
img.scale(scale);
|
||||||
|
currentCanvas.add(img);
|
||||||
|
currentCanvas.setActiveObject(img);
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveFillColor = (color: string) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = getStyleableSelection();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.forEach((object) => {
|
||||||
|
(object as FabricObject & { set: (key: string, value: unknown) => void }).set(
|
||||||
|
"fill",
|
||||||
|
color
|
||||||
|
);
|
||||||
|
});
|
||||||
|
activeFillColor.value = color;
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActiveStrokeColor = (color: string) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = getStyleableSelection();
|
||||||
|
if (!targets.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targets.forEach((object) => {
|
||||||
|
const target = object as FabricObject & {
|
||||||
|
set: (key: string, value: unknown) => void;
|
||||||
|
strokeWidth?: number;
|
||||||
|
};
|
||||||
|
target.set("stroke", color);
|
||||||
|
if (target.strokeWidth === undefined || target.strokeWidth === 0) {
|
||||||
|
target.set("strokeWidth", 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
activeStrokeColor.value = color;
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setZoom = (value: number, options?: { point?: FabricNamespace.Point }) => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
if (!currentCanvas || !fabric) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clamped = clampZoom(value);
|
||||||
|
|
||||||
|
const targetPoint =
|
||||||
|
options?.point ??
|
||||||
|
new fabric.Point(
|
||||||
|
currentCanvas.getWidth() / 2,
|
||||||
|
currentCanvas.getHeight() / 2
|
||||||
|
);
|
||||||
|
|
||||||
|
currentCanvas.zoomToPoint(targetPoint, clamped);
|
||||||
|
|
||||||
|
if (!options?.point) {
|
||||||
|
const viewport = currentCanvas.viewportTransform;
|
||||||
|
if (viewport) {
|
||||||
|
const width = currentCanvas.getWidth();
|
||||||
|
const height = currentCanvas.getHeight();
|
||||||
|
viewport[4] = (width - width * clamped) / 2;
|
||||||
|
viewport[5] = (height - height * clamped) / 2;
|
||||||
|
currentCanvas.setViewportTransform(viewport);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomLevel.value = clamped;
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
currentCanvas.requestRenderAll();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => setZoom(zoomLevel.value + ZOOM_STEP);
|
||||||
|
|
||||||
|
const zoomOut = () => setZoom(zoomLevel.value - ZOOM_STEP);
|
||||||
|
|
||||||
|
const resetZoom = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (currentCanvas) {
|
||||||
|
currentCanvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
|
||||||
|
}
|
||||||
|
setZoom(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDesign = () => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const staticObjects = new Set<FabricObject | null>([
|
||||||
|
backgroundCircle.value,
|
||||||
|
safeZoneCircle.value,
|
||||||
|
]);
|
||||||
|
currentCanvas
|
||||||
|
.getObjects()
|
||||||
|
.filter(
|
||||||
|
(object: FabricObject | null): object is FabricObject =>
|
||||||
|
!staticObjects.has(object)
|
||||||
|
)
|
||||||
|
.forEach((object: FabricObject) => currentCanvas.remove(object));
|
||||||
|
currentCanvas.discardActiveObject();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTemplate = (templateId: string) => {
|
||||||
|
const template = templates.value.find((entry) => entry.id === templateId);
|
||||||
|
if (template) {
|
||||||
|
selectedTemplate.value = template;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportDesign = async (): Promise<ExportedDesign | null> => {
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!currentCanvas) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isExporting.value = true;
|
||||||
|
try {
|
||||||
|
const previewMultiplier = PREVIEW_SIZE / currentCanvas.getWidth();
|
||||||
|
const previewDataUrl = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier: previewMultiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
const previewDataBlob = await dataUrlToBlob(previewDataUrl);
|
||||||
|
|
||||||
|
const productionSize = productionPixelSize.value;
|
||||||
|
const productionMultiplier = productionSize / currentCanvas.getWidth();
|
||||||
|
const productionDataUrl = currentCanvas.toDataURL({
|
||||||
|
format: "png",
|
||||||
|
multiplier: productionMultiplier,
|
||||||
|
enableRetinaScaling: true,
|
||||||
|
});
|
||||||
|
const productionDataBlob = await dataUrlToBlob(productionDataUrl);
|
||||||
|
|
||||||
|
previewUrl.value = previewDataUrl;
|
||||||
|
previewBlob.value = previewDataBlob;
|
||||||
|
productionBlob.value = productionDataBlob;
|
||||||
|
|
||||||
|
if (productionObjectUrl.value) {
|
||||||
|
URL.revokeObjectURL(productionObjectUrl.value);
|
||||||
|
}
|
||||||
|
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
||||||
|
|
||||||
|
return {
|
||||||
|
previewUrl: previewDataUrl,
|
||||||
|
previewBlob: previewDataBlob,
|
||||||
|
productionUrl: productionDataUrl,
|
||||||
|
productionBlob: productionDataBlob,
|
||||||
|
templateId: selectedTemplate.value.id,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
isExporting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPreview = async () => {
|
||||||
|
if (!previewUrl.value) {
|
||||||
|
await refreshPreview();
|
||||||
|
}
|
||||||
|
if (!previewUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerDownload(previewUrl.value, `${selectedTemplate.value.id}-preview.png`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadProduction = async () => {
|
||||||
|
if (!productionObjectUrl.value) {
|
||||||
|
const exportResult = await exportDesign();
|
||||||
|
if (!exportResult) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!productionObjectUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerDownload(
|
||||||
|
productionObjectUrl.value,
|
||||||
|
`${selectedTemplate.value.id}-production.png`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedTemplate, () => {
|
||||||
|
resetZoom();
|
||||||
|
applyTemplateToCanvas();
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
updateSelectedStyleState();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
selectedTemplate,
|
||||||
|
selectTemplate,
|
||||||
|
displaySize,
|
||||||
|
productionPixelSize,
|
||||||
|
templateLabel,
|
||||||
|
previewUrl,
|
||||||
|
previewBlob,
|
||||||
|
productionBlob,
|
||||||
|
productionObjectUrl,
|
||||||
|
isExporting,
|
||||||
|
activeFillColor,
|
||||||
|
activeStrokeColor,
|
||||||
|
canStyleSelection,
|
||||||
|
zoomLevel,
|
||||||
|
zoomPercent,
|
||||||
|
minZoom: MIN_ZOOM,
|
||||||
|
maxZoom: MAX_ZOOM,
|
||||||
|
registerCanvas,
|
||||||
|
unregisterCanvas,
|
||||||
|
addTextbox,
|
||||||
|
addShape,
|
||||||
|
addImageFromFile,
|
||||||
|
setActiveFillColor,
|
||||||
|
setActiveStrokeColor,
|
||||||
|
setZoom,
|
||||||
|
zoomIn,
|
||||||
|
zoomOut,
|
||||||
|
resetZoom,
|
||||||
|
clearDesign,
|
||||||
|
exportDesign,
|
||||||
|
downloadPreview,
|
||||||
|
downloadProduction,
|
||||||
|
refreshPreview,
|
||||||
|
schedulePreviewRefresh,
|
||||||
|
applyTemplateToCanvas,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFileAsDataUrl = (file: File): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataUrlToBlob = async (dataUrl: string): Promise<Blob> => {
|
||||||
|
const response = await fetch(dataUrl);
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = (url: string, filename: string) => {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.rel = "noopener";
|
||||||
|
anchor.click();
|
||||||
|
};
|
||||||
11
nuxt.config.ts
Normal file
11
nuxt.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-07-15",
|
||||||
|
devtools: { enabled: true },
|
||||||
|
css: ["./app/assets/css/main.css"],
|
||||||
|
vite: {
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
},
|
||||||
|
});
|
||||||
11759
package-lock.json
generated
Normal file
11759
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "slipmatz-web",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"fabric": "^6.0.2",
|
||||||
|
"nuxt": "^4.2.0",
|
||||||
|
"tailwindcss": "^4.1.16",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
34
public/turntable-mockup.svg
Normal file
34
public/turntable-mockup.svg
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="1600" height="1200" viewBox="0 0 1600 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1600" height="1200" rx="48" fill="#0B0F1A"/>
|
||||||
|
<rect x="64" y="64" width="1472" height="1072" rx="36" fill="#111827" stroke="#1F2937" stroke-width="8"/>
|
||||||
|
<rect x="120" y="120" width="1360" height="960" rx="28" fill="#0F172A" stroke="#1E293B" stroke-width="6"/>
|
||||||
|
<circle cx="560" cy="600" r="420" fill="#05070E" stroke="#1F2937" stroke-width="18"/>
|
||||||
|
<circle cx="560" cy="600" r="360" fill="#0A101C" stroke="#0EA5E9" stroke-width="10" stroke-dasharray="6 20" opacity="0.65"/>
|
||||||
|
<circle cx="560" cy="600" r="210" fill="#020409" opacity="0.35"/>
|
||||||
|
<circle cx="560" cy="600" r="16" fill="#CBD5F5"/>
|
||||||
|
<g filter="url(#deckGlow)">
|
||||||
|
<path d="M1032 280h260c30 0 54 24 54 54v532c0 30-24 54-54 54h-260" stroke="#1E293B" stroke-width="12" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#tonearmShadow)">
|
||||||
|
<path d="M1000 360h200c24 0 44 20 44 44v80c0 24-20 44-44 44h-92l-72 240c-8 26-32 44-60 44-34 0-62-28-62-62V422c0-34 28-62 62-62Z" fill="#E2E8F0"/>
|
||||||
|
<path d="M1116 472c12 0 22 10 22 22s-10 22-22 22-22-10-22-22 10-22 22-22Z" fill="#1E293B"/>
|
||||||
|
<rect x="1120" y="600" width="24" height="120" rx="10" fill="#1E293B"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<rect x="320" y="240" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<rect x="320" y="900" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<rect x="960" y="900" width="96" height="96" rx="24" fill="#0EA5E9" opacity="0.35"/>
|
||||||
|
<circle cx="352" cy="760" r="36" fill="#1E293B"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="deckGlow" x="980" y="260" width="409" height="680" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="12" result="blur"/>
|
||||||
|
<feBlend in="SourceGraphic" in2="blur" mode="overlay"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="tonearmShadow" x="792" y="332" width="472" height="480" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="26" flood-opacity="0.4" flood-color="#000"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user