This commit is contained in:
157
app/components/AppNavbar.vue
Normal file
157
app/components/AppNavbar.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { user, backendUser, signOut, initAuth, isLoading } = useAuth()
|
||||
const loginModal = useLoginModal()
|
||||
const route = useRoute()
|
||||
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const showMenu = ref(false)
|
||||
|
||||
const displayName = computed(() => backendUser.value?.name || user.value?.displayName || backendUser.value?.email || user.value?.email || "Account")
|
||||
|
||||
const avatarInitials = computed(() => {
|
||||
const source = displayName.value
|
||||
if (!source) {
|
||||
return "S"
|
||||
}
|
||||
const parts = source.trim().split(/\s+/)
|
||||
if (parts.length === 1) {
|
||||
return parts[0].charAt(0).toUpperCase()
|
||||
}
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||
})
|
||||
|
||||
const openLoginModal = () => {
|
||||
loginModal.value = true
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut()
|
||||
showMenu.value = false
|
||||
} catch (error) {
|
||||
console.error('Sign out failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
if (showMenu.value) {
|
||||
showMenu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!dropdownRef.value) {
|
||||
return
|
||||
}
|
||||
if (!dropdownRef.value.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initAuth()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
closeMenu()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur shadow-sm">
|
||||
<div class="mx-auto max-w-6xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<NuxtLink to="/" class="text-lg font-semibold text-slate-900 transition hover:text-slate-600 sm:text-xl">
|
||||
TableJerseys
|
||||
</NuxtLink>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Show user info and logout when authenticated -->
|
||||
<div v-if="user && !isLoading" ref="dropdownRef" class="relative flex items-center gap-3">
|
||||
<span class="hidden text-sm text-slate-700 sm:inline">{{ backendUser?.email || user.email }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold uppercase text-white transition hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
@click.stop="toggleMenu"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="showMenu"
|
||||
>
|
||||
{{ avatarInitials }}
|
||||
</button>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="showMenu"
|
||||
class="absolute right-0 top-12 w-56 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl backdrop-blur"
|
||||
role="menu"
|
||||
>
|
||||
<p class="px-3 text-xs uppercase tracking-[0.25em] text-slate-500">Signed in as</p>
|
||||
<p class="px-3 text-sm font-medium text-slate-900">{{ displayName }}</p>
|
||||
<p class="px-3 text-xs text-slate-600">{{ backendUser?.email || user.email }}</p>
|
||||
<div class="my-3 h-px bg-slate-200"></div>
|
||||
<NuxtLink
|
||||
to="/profile"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
role="menuitem"
|
||||
>
|
||||
Profile
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/orders"
|
||||
class="flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
|
||||
role="menuitem"
|
||||
>
|
||||
Orders
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50"
|
||||
@click="handleSignOut"
|
||||
role="menuitem"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- Show login button when not authenticated -->
|
||||
<div v-else-if="!isLoading" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="openLoginModal"
|
||||
class="rounded-full border-2 border-slate-900 bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-white hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<div class="h-8 w-16 animate-pulse rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LoginModal />
|
||||
</nav>
|
||||
</template>
|
||||
122
app/components/LoginModal.vue
Normal file
122
app/components/LoginModal.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
const { signInWithEmail, signInWithGoogle, error } = useAuth();
|
||||
|
||||
const isOpen = useLoginModal();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const loginError = ref<string | null>(null);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const handleEmailLogin = async () => {
|
||||
try {
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithEmail(email.value, password.value);
|
||||
isOpen.value = false;
|
||||
// Reset form
|
||||
email.value = "";
|
||||
password.value = "";
|
||||
} catch (err) {
|
||||
loginError.value = "Login failed. Please check your credentials.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
loginError.value = null;
|
||||
isSubmitting.value = true;
|
||||
await signInWithGoogle();
|
||||
isOpen.value = false;
|
||||
} catch (err) {
|
||||
loginError.value = "Google login failed. Please try again.";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed left-0 top-0 z-50 flex min-h-screen w-full items-center justify-center bg-black/60 px-4"
|
||||
@click.self="isOpen = false"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-6 text-2xl font-bold text-slate-900">Sign In</h2>
|
||||
|
||||
<form @submit.prevent="handleEmailLogin" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700"
|
||||
>Password</label
|
||||
>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="mt-1 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-slate-900 focus:outline-none focus:ring-2 focus:ring-slate-900/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border-2 border-slate-900 bg-slate-900 px-4 py-2 text-white transition hover:bg-white hover:text-slate-900 disabled:opacity-50"
|
||||
>
|
||||
{{ isSubmitting ? "Signing in..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="my-4 flex items-center">
|
||||
<div class="flex-1 border-t border-slate-200"></div>
|
||||
<span class="px-3 text-sm text-slate-500">or</span>
|
||||
<div class="flex-1 border-t border-slate-200"></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleGoogleLogin"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-4 py-2 text-slate-700 transition hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
Sign in with Google
|
||||
</button>
|
||||
|
||||
<div v-if="loginError || error" class="mt-4 rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{{ loginError || error }}
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-sm text-slate-600">
|
||||
Need an account?
|
||||
<NuxtLink
|
||||
to="/register"
|
||||
class="font-semibold text-slate-900 hover:text-slate-700"
|
||||
@click="isOpen = false"
|
||||
>
|
||||
Create one instead
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<button
|
||||
@click="isOpen = false"
|
||||
class="mt-4 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
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>
|
||||
36
app/components/designer/DesignerPreview.vue
Normal file
36
app/components/designer/DesignerPreview.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
isCheckoutPending: boolean;
|
||||
checkoutPrice: number;
|
||||
checkoutError: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "checkout"): void;
|
||||
}>();
|
||||
|
||||
const handleCheckout = () => emit("checkout");
|
||||
|
||||
const priceLabel = computed(() => props.checkoutPrice.toFixed(2));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl border-2 border-emerald-600 bg-emerald-600 px-6 py-4 text-base font-semibold text-white transition hover:bg-white hover:text-emerald-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="props.isCheckoutPending"
|
||||
@click="handleCheckout"
|
||||
>
|
||||
{{ props.isCheckoutPending ? "Redirecting…" : `Buy This Design ($${priceLabel})` }}
|
||||
</button>
|
||||
<div
|
||||
v-if="props.checkoutError"
|
||||
class="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"
|
||||
>
|
||||
{{ props.checkoutError }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
242
app/components/designer/DesignerToolbar.vue
Normal file
242
app/components/designer/DesignerToolbar.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<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;
|
||||
onBackgroundChange: (background: string) => void;
|
||||
activeFill: string | null;
|
||||
activeStroke: string | null;
|
||||
activeBackground: string;
|
||||
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 backgroundValue = ref(props.activeBackground ?? "#ffffff");
|
||||
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.activeBackground,
|
||||
(next) => {
|
||||
backgroundValue.value = next ?? "#ffffff";
|
||||
}
|
||||
);
|
||||
|
||||
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 handleBackgroundChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value;
|
||||
backgroundValue.value = value;
|
||||
props.onBackgroundChange(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>
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<!-- Add Elements Group -->
|
||||
<div class="flex items-center gap-1 border-r border-slate-200 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-lg font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Text"
|
||||
@click="props.onAddText"
|
||||
>
|
||||
T
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Circle"
|
||||
@click="props.onAddCircle"
|
||||
>
|
||||
●
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Add Rectangle"
|
||||
@click="props.onAddRectangle"
|
||||
>
|
||||
▭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-xl font-bold text-slate-700 transition hover:bg-slate-200"
|
||||
title="Upload Image"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
🖼
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Color Pickers Group -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||
<label class="flex items-center gap-1.5" title="Canvas Background">
|
||||
<span class="text-xs text-slate-600">Canvas</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:value="backgroundValue"
|
||||
@input="handleBackgroundChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Fill Color">
|
||||
<span class="text-xs text-slate-600">Fill</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="fillValue"
|
||||
@input="handleFillChange"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5" title="Stroke Color">
|
||||
<span class="text-xs text-slate-600">Stroke</span>
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-12 cursor-pointer rounded border border-slate-300 bg-white p-0.5"
|
||||
:disabled="stylingDisabled"
|
||||
:value="strokeValue"
|
||||
@input="handleStrokeChange"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Zoom Controls -->
|
||||
<div class="flex items-center gap-2 border-r border-slate-200 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||
title="Zoom Out"
|
||||
@click="props.onZoomOut"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span class="min-w-12 text-center text-xs font-medium text-slate-700">
|
||||
{{ zoomLabel }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded border border-slate-300 bg-white text-sm font-bold text-slate-900 transition hover:bg-slate-50"
|
||||
title="Zoom In"
|
||||
@click="props.onZoomIn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
title="Reset Zoom"
|
||||
@click="props.onZoomReset"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-medium text-rose-700 transition hover:bg-rose-100"
|
||||
@click="props.onClear"
|
||||
>
|
||||
Clear Canvas
|
||||
</button>
|
||||
|
||||
<!-- Hidden File Input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="hidden"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</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-900">Jersey Template</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Pick the size and print spec that matches this order.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-1">
|
||||
<button
|
||||
v-for="template in props.templates"
|
||||
:key="template.id"
|
||||
type="button"
|
||||
class="group rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
|
||||
:class="{
|
||||
'border-slate-900 bg-slate-50 shadow-md':
|
||||
template.id === props.selectedTemplateId,
|
||||
}"
|
||||
@click="handleSelect(template.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base font-medium text-slate-900">
|
||||
{{ template.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="template.id === props.selectedTemplateId"
|
||||
class="rounded-full bg-slate-900 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-white"
|
||||
>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
<dl class="mt-3 grid grid-cols-4 gap-x-3 gap-y-2 text-xs text-slate-700">
|
||||
<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>
|
||||
Reference in New Issue
Block a user