first commit

This commit is contained in:
Frank John Begornia
2025-11-02 00:23:22 +08:00
commit e2955debb7
18 changed files with 13383 additions and 0 deletions

View 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>

View 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 yetstart 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>

View 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>

View 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 }}&quot;</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 }}&quot;</dd>
</div>
<div>
<dt class="text-slate-500">Safe Zone</dt>
<dd>{{ template.safeZoneInches ?? 0 }}&quot;</dd>
</div>
</dl>
</button>
</div>
</section>
</template>