Files
slipmatz-web/app/pages/index.vue
Frank John Begornia 4d91925fad 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.
2025-11-07 00:01:52 +08:00

345 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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)]">
<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>