- Implemented `useDesignPersistence` composable for managing design records. - Enhanced `useSlipmatDesigner` to support loading designs from JSON. - Created global authentication middleware for route protection. - Added Supabase client plugin for database interactions. - Developed API endpoints for fetching, saving, and retrieving designs. - Introduced utility functions for Auth0 token verification and Supabase client retrieval. - Updated Nuxt configuration to include Auth0 and Supabase environment variables. - Added necessary dependencies for Auth0 and Supabase. - Enhanced TypeScript configuration for improved type support.
345 lines
11 KiB
Vue
345 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from "vue";
|
||
import { useAuth0 } from "@auth0/auth0-vue";
|
||
import { useRuntimeConfig } from "nuxt/app";
|
||
|
||
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 { useDesignPersistence } from "../../composables/useDesignPersistence";
|
||
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||
|
||
const runtimeConfig = useRuntimeConfig();
|
||
const auth0 = process.client ? useAuth0() : null;
|
||
|
||
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 {
|
||
designs,
|
||
fetchDesigns,
|
||
saveDesign,
|
||
isSaving,
|
||
isLoading: isLibraryLoading,
|
||
lastError,
|
||
isAuthenticated,
|
||
} = useDesignPersistence();
|
||
|
||
const projectName = ref("Untitled Slipmat");
|
||
const saveMessage = ref<string | null>(null);
|
||
const saveError = ref<string | null>(null);
|
||
const activeDesignId = ref<string | null>(null);
|
||
|
||
const userProfile = computed(() => auth0?.user.value ?? null);
|
||
|
||
const canSave = computed(
|
||
() => isAuthenticated.value && !!previewUrl.value && !isExporting.value
|
||
);
|
||
|
||
const handleTemplateSelect = (templateId: string) => {
|
||
selectTemplate(templateId);
|
||
};
|
||
|
||
const handleExport = async () => {
|
||
await exportDesign();
|
||
};
|
||
|
||
const handleProjectNameUpdate = (value: string) => {
|
||
projectName.value = value;
|
||
};
|
||
|
||
const formatTimestamp = (value: string | null) => {
|
||
if (!value) {
|
||
return "Never";
|
||
}
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return "Unknown";
|
||
}
|
||
return new Intl.DateTimeFormat(undefined, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(date);
|
||
};
|
||
|
||
const handleSaveDesign = async () => {
|
||
if (!canSave.value) {
|
||
return;
|
||
}
|
||
saveError.value = null;
|
||
|
||
try {
|
||
const exported = await exportDesign();
|
||
if (!exported) {
|
||
return;
|
||
}
|
||
|
||
const saved = await saveDesign({
|
||
id: activeDesignId.value ?? undefined,
|
||
name:
|
||
projectName.value.trim() ||
|
||
`Slipmat ${new Intl.DateTimeFormat(undefined, {
|
||
month: "short",
|
||
day: "numeric",
|
||
year: "numeric",
|
||
}).format(new Date())}`,
|
||
design: exported,
|
||
});
|
||
|
||
activeDesignId.value = saved.id;
|
||
saveMessage.value = "Design saved to your library.";
|
||
setTimeout(() => {
|
||
saveMessage.value = null;
|
||
}, 4000);
|
||
} catch (error) {
|
||
saveError.value = error instanceof Error ? error.message : String(error);
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
if (!auth0) {
|
||
return;
|
||
}
|
||
|
||
await auth0.logout({
|
||
logoutParams: {
|
||
returnTo:
|
||
runtimeConfig.public.auth0?.baseUrl ??
|
||
(process.client ? window.location.origin : undefined),
|
||
},
|
||
});
|
||
};
|
||
|
||
if (process.client && auth0) {
|
||
watch(
|
||
() => auth0.isAuthenticated.value,
|
||
async (authenticated) => {
|
||
if (authenticated) {
|
||
try {
|
||
await fetchDesigns();
|
||
} catch (error) {
|
||
// Surface error through reactive ref
|
||
saveError.value = error instanceof Error ? error.message : String(error);
|
||
}
|
||
} else {
|
||
designs.value = [];
|
||
activeDesignId.value = null;
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
watch(lastError, (value) => {
|
||
if (value) {
|
||
saveError.value = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (auth0?.isAuthenticated.value) {
|
||
try {
|
||
await fetchDesigns();
|
||
} catch (error) {
|
||
saveError.value = error instanceof Error ? error.message : String(error);
|
||
}
|
||
}
|
||
});
|
||
</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="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||
<div 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>
|
||
</div>
|
||
<div
|
||
v-if="userProfile"
|
||
class="flex items-center gap-3 rounded-2xl border border-slate-800/70 bg-slate-900/70 px-4 py-3 shadow-lg shadow-slate-950/40"
|
||
>
|
||
<div class="text-right">
|
||
<p class="text-[0.7rem] uppercase tracking-[0.3em] text-slate-500">
|
||
Signed in
|
||
</p>
|
||
<p class="text-sm font-semibold text-white">
|
||
{{ userProfile.name ?? userProfile.email ?? "Authenticated" }}
|
||
</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"
|
||
@click="handleLogout"
|
||
>
|
||
Log out
|
||
</button>
|
||
</div>
|
||
</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"
|
||
:project-name="projectName"
|
||
:is-saving="isSaving"
|
||
:can-save="canSave"
|
||
@export="handleExport"
|
||
@download-preview="downloadPreview"
|
||
@download-production="downloadProduction"
|
||
@update:projectName="handleProjectNameUpdate"
|
||
@save="handleSaveDesign"
|
||
/>
|
||
|
||
<p
|
||
v-if="saveMessage"
|
||
class="rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200"
|
||
>
|
||
{{ saveMessage }}
|
||
</p>
|
||
<p
|
||
v-else-if="saveError"
|
||
class="rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-200"
|
||
>
|
||
{{ saveError }}
|
||
</p>
|
||
|
||
<div 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">
|
||
Saved Projects
|
||
</h3>
|
||
<p class="mt-1 text-xs text-slate-500">Synced securely to Supabase</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="rounded-lg border border-slate-700 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-300 transition hover:border-sky-500/70 hover:text-sky-200 disabled:cursor-not-allowed disabled:border-slate-700/60 disabled:text-slate-500"
|
||
:disabled="isLibraryLoading"
|
||
@click="fetchDesigns"
|
||
>
|
||
{{ isLibraryLoading ? "Refreshing…" : "Refresh" }}
|
||
</button>
|
||
</header>
|
||
|
||
<div v-if="isLibraryLoading" class="mt-4 text-sm text-slate-400">
|
||
Loading your library…
|
||
</div>
|
||
<div v-else-if="!designs.length" class="mt-4 text-sm text-slate-500">
|
||
Nothing saved yet—hit “Save to Library” once you love your design.
|
||
</div>
|
||
<ul v-else class="mt-4 space-y-3">
|
||
<li
|
||
v-for="design in designs"
|
||
:key="design.id"
|
||
class="flex items-center justify-between gap-4 rounded-xl border border-slate-800/70 bg-slate-950/70 px-3 py-3"
|
||
>
|
||
<div>
|
||
<p class="text-sm font-semibold text-slate-100">{{ design.name }}</p>
|
||
<p class="text-xs text-slate-500">
|
||
Updated {{ formatTimestamp(design.updatedAt) }}
|
||
</p>
|
||
</div>
|
||
<a
|
||
v-if="design.previewUrl"
|
||
:href="design.previewUrl"
|
||
target="_blank"
|
||
rel="noopener"
|
||
class="text-xs font-semibold uppercase tracking-wide text-sky-400 transition hover:text-sky-200"
|
||
>
|
||
Preview
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</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>
|