feat: add design persistence functionality with Auth0 and Supabase integration

- 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.
This commit is contained in:
Frank John Begornia
2025-11-07 00:01:52 +08:00
parent e2955debb7
commit 4d91925fad
20 changed files with 1242 additions and 19 deletions

View File

@@ -1,11 +1,19 @@
<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,
@@ -38,6 +46,27 @@ const {
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);
};
@@ -45,23 +74,147 @@ const handleTemplateSelect = (templateId: string) => {
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="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 well 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 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 well 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)]">
@@ -97,10 +250,77 @@ const handleExport = async () => {
: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">