Compare commits
1 Commits
feat/dev
...
dump-branc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d91925fad |
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Auth0 configuration
|
||||||
|
AUTH0_DOMAIN=
|
||||||
|
AUTH0_CLIENT_ID=
|
||||||
|
AUTH0_CLIENT_SECRET=
|
||||||
|
AUTH0_AUDIENCE=
|
||||||
|
AUTH0_SCOPE=openid profile email
|
||||||
|
AUTH0_BASE_URL=http://localhost:3000
|
||||||
|
AUTH0_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||||
|
|
||||||
|
# Supabase configuration
|
||||||
|
SUPABASE_URL=
|
||||||
|
SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
SUPABASE_STORAGE_BUCKET=designs
|
||||||
48
README.md
48
README.md
@@ -7,17 +7,55 @@ Nuxt 4 single-page experience for creating custom slipmat artwork. Users pick a
|
|||||||
- Template presets for popular vinyl diameters with bleed & safe-zone guides
|
- Template presets for popular vinyl diameters with bleed & safe-zone guides
|
||||||
- Fabric.js-powered editor: drag, scale, rotate, and stack elements inside a clipped circular canvas
|
- Fabric.js-powered editor: drag, scale, rotate, and stack elements inside a clipped circular canvas
|
||||||
- Tools for adding text, circles, rectangles, and uploading artwork
|
- Tools for adding text, circles, rectangles, and uploading artwork
|
||||||
- Live preview card with instant web-resolution snapshot
|
- Live preview card with instant web-resolution snapshot and turntable mockup
|
||||||
- One-click export that produces both preview and print PNGs and offers direct downloads
|
- One-click export that produces both preview and print PNGs and offers direct downloads
|
||||||
|
- Auth0-protected workspace with Supabase-backed “Save to Library” persistence
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cp .env.example .env # set Auth0 + Supabase credentials
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit [http://localhost:3000](http://localhost:3000) to start designing.
|
Visit [http://localhost:3000](http://localhost:3000) to start designing. Authentication is required for every route except the Auth0 callback.
|
||||||
|
|
||||||
|
### Auth0 & Supabase configuration
|
||||||
|
|
||||||
|
1. **Auth0 application**
|
||||||
|
- Create a SPA application and note the domain, client ID, and client secret.
|
||||||
|
- Add `http://localhost:3000` to the allowed callback, logout, and web origins.
|
||||||
|
- If you plan to call custom APIs, configure an audience and scope (defaults to `openid profile email`).
|
||||||
|
|
||||||
|
2. **Supabase project**
|
||||||
|
- Create a storage bucket named `designs` (or change `SUPABASE_STORAGE_BUCKET`).
|
||||||
|
- Create a Postgres table for persisted projects:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
create table public.designs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
user_id text not null,
|
||||||
|
name text not null,
|
||||||
|
template_id text not null,
|
||||||
|
preview_path text not null,
|
||||||
|
preview_url text,
|
||||||
|
design_json jsonb not null,
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index designs_user_id_updated_at_idx
|
||||||
|
on public.designs (user_id, updated_at desc);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Grant read/write access to the AUTH0 users via Row Level Security (example policy: `user_id = auth.jwt()->>'sub'`).
|
||||||
|
- Generate an anon key and service role key; place them in `.env` along with the project URL.
|
||||||
|
|
||||||
|
3. **Environment**
|
||||||
|
- Populate `.env` using `.env.example` as a guide.
|
||||||
|
- Restart the dev server after changes.
|
||||||
|
|
||||||
## 🛠️ Production Builds
|
## 🛠️ Production Builds
|
||||||
|
|
||||||
@@ -37,17 +75,19 @@ Nuxt outputs the server bundle into `.output/`. You can serve it with `node .out
|
|||||||
- _Web preview_: 1024×1024 PNG suitable for the storefront and checkout review.
|
- _Web preview_: 1024×1024 PNG suitable for the storefront and checkout review.
|
||||||
- _Print-ready PNG_: Exact template resolution (e.g. 3600×3600 for a 12" slipmat at 300 DPI) for production.
|
- _Print-ready PNG_: Exact template resolution (e.g. 3600×3600 for a 12" slipmat at 300 DPI) for production.
|
||||||
|
|
||||||
High-res exports remain client-side (no upload yet). Wire the `exportDesign` / `productionBlob` values into your backend to submit orders.
|
Saving a project now serializes the Fabric.js canvas, uploads the preview PNG to Supabase Storage, and stores metadata within the `designs` table for quick retrieval.
|
||||||
|
|
||||||
## 📦 Tech Stack
|
## 📦 Tech Stack
|
||||||
|
|
||||||
- [Nuxt 4](https://nuxt.com/) + Vite
|
- [Nuxt 4](https://nuxt.com/) + Vite
|
||||||
- [Fabric.js 6](http://fabricjs.com/) for canvas editing
|
- [Fabric.js 6](http://fabricjs.com/) for canvas editing
|
||||||
- Tailwind CSS (via `@tailwindcss/vite`) for styling utilities
|
- Tailwind CSS (via `@tailwindcss/vite`) for styling utilities
|
||||||
|
- [Auth0 Vue SDK](https://auth0.com/docs/libraries/auth0-vue) for authentication
|
||||||
|
- [Supabase](https://supabase.com/) (Postgres + Storage) for persistence
|
||||||
|
|
||||||
## 🧭 Next Ideas
|
## 🧭 Next Ideas
|
||||||
|
|
||||||
- Persist projects (auth + cloud storage)
|
- Project version history and “duplicate design” shortcuts
|
||||||
- CMYK color profile previews & bleed handling
|
- CMYK color profile previews & bleed handling
|
||||||
- 3D platter preview using Three.js
|
- 3D platter preview using Three.js
|
||||||
- Admin dashboard for incoming print jobs
|
- Admin dashboard for incoming print jobs
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ const props = defineProps<{
|
|||||||
templateLabel: string;
|
templateLabel: string;
|
||||||
productionPixels: number;
|
productionPixels: number;
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
|
projectName: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
canSave: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "download-preview"): void;
|
(e: "download-preview"): void;
|
||||||
(e: "download-production"): void;
|
(e: "download-production"): void;
|
||||||
(e: "export"): void;
|
(e: "export"): void;
|
||||||
|
(e: "update:projectName", value: string): void;
|
||||||
|
(e: "save"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const viewMode = ref<"flat" | "turntable">("flat");
|
const viewMode = ref<"flat" | "turntable">("flat");
|
||||||
@@ -23,6 +28,14 @@ const handleSelectView = (mode: "flat" | "turntable") => {
|
|||||||
const handleExport = () => emit("export");
|
const handleExport = () => emit("export");
|
||||||
const handleDownloadPreview = () => emit("download-preview");
|
const handleDownloadPreview = () => emit("download-preview");
|
||||||
const handleDownloadProduction = () => emit("download-production");
|
const handleDownloadProduction = () => emit("download-production");
|
||||||
|
const handleProjectNameInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
emit("update:projectName", target.value);
|
||||||
|
};
|
||||||
|
const handleSave = () => emit("save");
|
||||||
|
const isSaveDisabled = computed(
|
||||||
|
() => !props.previewUrl || props.isSaving || !props.canSave
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -95,6 +108,30 @@ const handleDownloadProduction = () => emit("download-production");
|
|||||||
No preview yet—start designing!
|
No preview yet—start designing!
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||||
|
<label class="flex flex-col gap-2">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||||
|
Project Name
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="w-full rounded-xl border border-slate-800 bg-slate-900 px-3 py-2 text-sm text-slate-100 shadow-inner shadow-black/20 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||||
|
:value="props.projectName"
|
||||||
|
:disabled="props.isSaving"
|
||||||
|
maxlength="120"
|
||||||
|
placeholder="e.g. Midnight Mix Vol. 1"
|
||||||
|
@input="handleProjectNameInput"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="h-11 rounded-xl bg-emerald-500 px-6 text-sm font-semibold text-emerald-50 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-slate-700/70 disabled:text-slate-400"
|
||||||
|
:disabled="isSaveDisabled"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
{{ props.isSaving ? "Saving…" : "Save to Library" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-3">
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
35
app/pages/auth/callback.vue
Normal file
35
app/pages/auth/callback.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth0 = useAuth0();
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const result = await auth0.handleRedirectCallback();
|
||||||
|
const target = result?.appState?.target ?? "/";
|
||||||
|
await router.replace(target);
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="flex min-h-screen flex-col items-center justify-center bg-slate-950 px-4 text-center text-slate-100"
|
||||||
|
>
|
||||||
|
<div class="max-w-sm space-y-4">
|
||||||
|
<h1 class="text-lg font-semibold">Signing you in…</h1>
|
||||||
|
<p class="text-sm text-slate-400">
|
||||||
|
Please wait while we complete authentication and return you to your work.
|
||||||
|
</p>
|
||||||
|
<p v-if="errorMessage" class="rounded-md border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="mx-auto h-12 w-12 animate-spin rounded-full border-4 border-slate-800 border-t-sky-500" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<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 DesignerCanvas from "~/components/designer/DesignerCanvas.vue";
|
||||||
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
import DesignerPreview from "~/components/designer/DesignerPreview.vue";
|
||||||
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
import DesignerToolbar from "~/components/designer/DesignerToolbar.vue";
|
||||||
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
import TemplatePicker from "~/components/designer/TemplatePicker.vue";
|
||||||
|
|
||||||
|
import { useDesignPersistence } from "../../composables/useDesignPersistence";
|
||||||
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
import { useSlipmatDesigner } from "~/composables/useSlipmatDesigner";
|
||||||
|
|
||||||
|
const runtimeConfig = useRuntimeConfig();
|
||||||
|
const auth0 = process.client ? useAuth0() : null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
templates,
|
templates,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
@@ -38,6 +46,27 @@ const {
|
|||||||
resetZoom,
|
resetZoom,
|
||||||
} = useSlipmatDesigner();
|
} = 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) => {
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
selectTemplate(templateId);
|
selectTemplate(templateId);
|
||||||
};
|
};
|
||||||
@@ -45,12 +74,115 @@ const handleTemplateSelect = (templateId: string) => {
|
|||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
await exportDesign();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen bg-slate-950 pb-16 text-slate-100">
|
<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">
|
<div class="mx-auto max-w-6xl px-4 pt-12 sm:px-6 lg:px-8">
|
||||||
<header class="space-y-3">
|
<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">
|
<p class="text-sm uppercase tracking-[0.35em] text-sky-400">
|
||||||
Slipmatz Designer
|
Slipmatz Designer
|
||||||
</p>
|
</p>
|
||||||
@@ -62,6 +194,27 @@ const handleExport = async () => {
|
|||||||
preview and a print-ready PNG at exact specs. Everything stays within a
|
preview and a print-ready PNG at exact specs. Everything stays within a
|
||||||
circular safe zone to ensure clean results on vinyl.
|
circular safe zone to ensure clean results on vinyl.
|
||||||
</p>
|
</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>
|
</header>
|
||||||
|
|
||||||
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
|
<section class="mt-10 grid gap-8 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
@@ -97,10 +250,77 @@ const handleExport = async () => {
|
|||||||
:template-label="templateLabel"
|
:template-label="templateLabel"
|
||||||
:production-pixels="productionPixelSize"
|
:production-pixels="productionPixelSize"
|
||||||
:is-exporting="isExporting"
|
:is-exporting="isExporting"
|
||||||
|
:project-name="projectName"
|
||||||
|
:is-saving="isSaving"
|
||||||
|
:can-save="canSave"
|
||||||
@export="handleExport"
|
@export="handleExport"
|
||||||
@download-preview="downloadPreview"
|
@download-preview="downloadPreview"
|
||||||
@download-production="downloadProduction"
|
@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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
|
|||||||
153
composables/useDesignPersistence.ts
Normal file
153
composables/useDesignPersistence.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { useAuth0 } from "@auth0/auth0-vue";
|
||||||
|
import { useNuxtApp, useRuntimeConfig } from "nuxt/app";
|
||||||
|
import type { $Fetch } from "ofetch";
|
||||||
|
|
||||||
|
import type { ExportedDesign } from "./useSlipmatDesigner";
|
||||||
|
|
||||||
|
type DesignRecord = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
template_id: string;
|
||||||
|
preview_url: string | null;
|
||||||
|
preview_path?: string | null;
|
||||||
|
design_json?: unknown;
|
||||||
|
notes?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SavedDesign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
templateId: string;
|
||||||
|
previewUrl: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
designJson?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveOptions = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
design: ExportedDesign;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDesignRecord = (record: DesignRecord): SavedDesign => ({
|
||||||
|
id: record.id,
|
||||||
|
name: record.name,
|
||||||
|
templateId: record.template_id,
|
||||||
|
previewUrl: record.preview_url ?? null,
|
||||||
|
notes: record.notes ?? undefined,
|
||||||
|
createdAt: record.created_at ?? null,
|
||||||
|
updatedAt: record.updated_at ?? null,
|
||||||
|
designJson: record.design_json,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDesignPersistence = () => {
|
||||||
|
const runtime = useRuntimeConfig();
|
||||||
|
const audience = runtime.public.auth0?.audience;
|
||||||
|
|
||||||
|
const auth0 = process.client ? useAuth0() : null;
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const fetcher = nuxtApp.$fetch as $Fetch;
|
||||||
|
|
||||||
|
const designs = ref<SavedDesign[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const lastError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => auth0?.isAuthenticated.value ?? false);
|
||||||
|
|
||||||
|
const acquireToken = async (): Promise<string> => {
|
||||||
|
if (!auth0) {
|
||||||
|
throw new Error("Auth0 client is not available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth0.getAccessTokenSilently({
|
||||||
|
authorizationParams: audience
|
||||||
|
? {
|
||||||
|
audience,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDesigns = async () => {
|
||||||
|
if (!auth0 || !isAuthenticated.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
lastError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await acquireToken();
|
||||||
|
const response = await fetcher<DesignRecord[]>("/api/designs", {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
designs.value = Array.isArray(response)
|
||||||
|
? response.map(mapDesignRecord)
|
||||||
|
: [];
|
||||||
|
} catch (error) {
|
||||||
|
lastError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDesign = async ({ id, name, design, notes }: SaveOptions) => {
|
||||||
|
if (!auth0 || !isAuthenticated.value) {
|
||||||
|
throw new Error("User must be authenticated before saving a design.");
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
lastError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await acquireToken();
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
templateId: design.templateId,
|
||||||
|
previewDataUrl: design.previewUrl,
|
||||||
|
productionDataUrl: design.productionUrl,
|
||||||
|
designJson: design.canvasJson,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetcher<DesignRecord>("/api/designs", {
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = mapDesignRecord(response);
|
||||||
|
const next = designs.value.filter((item) => item.id !== saved.id);
|
||||||
|
designs.value = [saved, ...next];
|
||||||
|
return saved;
|
||||||
|
} catch (error) {
|
||||||
|
lastError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
designs,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
lastError,
|
||||||
|
isAuthenticated,
|
||||||
|
fetchDesigns,
|
||||||
|
saveDesign,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ export interface ExportedDesign {
|
|||||||
productionBlob: Blob;
|
productionBlob: Blob;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
canvasJson: FabricCanvasJSON;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DISPLAY_SIZE = 720;
|
const DISPLAY_SIZE = 720;
|
||||||
@@ -63,6 +64,7 @@ type FabricCircle = FabricNamespace.Circle;
|
|||||||
type FabricRect = FabricNamespace.Rect;
|
type FabricRect = FabricNamespace.Rect;
|
||||||
type FabricTextbox = FabricNamespace.Textbox;
|
type FabricTextbox = FabricNamespace.Textbox;
|
||||||
type FabricObject = FabricNamespace.Object;
|
type FabricObject = FabricNamespace.Object;
|
||||||
|
type FabricCanvasJSON = ReturnType<FabricCanvas["toJSON"]>;
|
||||||
|
|
||||||
type CanvasReadyPayload = {
|
type CanvasReadyPayload = {
|
||||||
canvas: FabricCanvas;
|
canvas: FabricCanvas;
|
||||||
@@ -638,6 +640,8 @@ export const useSlipmatDesigner = () => {
|
|||||||
}
|
}
|
||||||
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
productionObjectUrl.value = URL.createObjectURL(productionDataBlob);
|
||||||
|
|
||||||
|
const canvasJson = currentCanvas.toJSON() as FabricCanvasJSON;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previewUrl: previewDataUrl,
|
previewUrl: previewDataUrl,
|
||||||
previewBlob: previewDataBlob,
|
previewBlob: previewDataBlob,
|
||||||
@@ -645,12 +649,30 @@ export const useSlipmatDesigner = () => {
|
|||||||
productionBlob: productionDataBlob,
|
productionBlob: productionDataBlob,
|
||||||
templateId: selectedTemplate.value.id,
|
templateId: selectedTemplate.value.id,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
canvasJson,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
isExporting.value = false;
|
isExporting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDesignFromJson = async (designJson: FabricCanvasJSON) => {
|
||||||
|
const fabric = fabricApi.value;
|
||||||
|
const currentCanvas = canvas.value;
|
||||||
|
if (!fabric || !currentCanvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
currentCanvas.loadFromJSON(designJson, () => {
|
||||||
|
currentCanvas.renderAll();
|
||||||
|
maintainStaticLayerOrder();
|
||||||
|
schedulePreviewRefresh();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const downloadPreview = async () => {
|
const downloadPreview = async () => {
|
||||||
if (!previewUrl.value) {
|
if (!previewUrl.value) {
|
||||||
await refreshPreview();
|
await refreshPreview();
|
||||||
@@ -717,6 +739,7 @@ export const useSlipmatDesigner = () => {
|
|||||||
resetZoom,
|
resetZoom,
|
||||||
clearDesign,
|
clearDesign,
|
||||||
exportDesign,
|
exportDesign,
|
||||||
|
loadDesignFromJson,
|
||||||
downloadPreview,
|
downloadPreview,
|
||||||
downloadProduction,
|
downloadProduction,
|
||||||
refreshPreview,
|
refreshPreview,
|
||||||
|
|||||||
44
middleware/auth.global.ts
Normal file
44
middleware/auth.global.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useAuth0 } from "@auth0/auth0-vue";
|
||||||
|
import { watch } from "vue";
|
||||||
|
import { abortNavigation, defineNuxtRouteMiddleware } from "nuxt/app";
|
||||||
|
|
||||||
|
const waitUntilLoaded = async (isLoading: { value: boolean }) => {
|
||||||
|
if (!isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const stop = watch(
|
||||||
|
() => isLoading.value,
|
||||||
|
(value) => {
|
||||||
|
if (!value) {
|
||||||
|
stop();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (process.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path.startsWith("/auth/callback")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth0 = useAuth0();
|
||||||
|
await waitUntilLoaded(auth0.isLoading);
|
||||||
|
|
||||||
|
if (auth0.isAuthenticated.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth0.loginWithRedirect({
|
||||||
|
appState: { target: to.fullPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
return abortNavigation();
|
||||||
|
});
|
||||||
44
middleware/auth.ts
Normal file
44
middleware/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useAuth0 } from "@auth0/auth0-vue";
|
||||||
|
import { watch } from "vue";
|
||||||
|
import { abortNavigation } from "#app";
|
||||||
|
|
||||||
|
const waitUntilLoaded = async (isLoading: { value: boolean }) => {
|
||||||
|
if (!isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const stop = watch(
|
||||||
|
() => isLoading.value,
|
||||||
|
(value) => {
|
||||||
|
if (!value) {
|
||||||
|
stop();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.path.startsWith("/auth/callback")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth0 = useAuth0();
|
||||||
|
await waitUntilLoaded(auth0.isLoading);
|
||||||
|
|
||||||
|
if (auth0.isAuthenticated.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth0.loginWithRedirect({
|
||||||
|
appState: { target: to.fullPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
return abortNavigation();
|
||||||
|
});
|
||||||
@@ -5,6 +5,30 @@ export default defineNuxtConfig({
|
|||||||
compatibilityDate: "2025-07-15",
|
compatibilityDate: "2025-07-15",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
css: ["./app/assets/css/main.css"],
|
css: ["./app/assets/css/main.css"],
|
||||||
|
runtimeConfig: {
|
||||||
|
auth0: {
|
||||||
|
clientSecret: process.env.AUTH0_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
supabase: {
|
||||||
|
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
auth0: {
|
||||||
|
domain: process.env.AUTH0_DOMAIN,
|
||||||
|
clientId: process.env.AUTH0_CLIENT_ID,
|
||||||
|
audience: process.env.AUTH0_AUDIENCE,
|
||||||
|
scope: process.env.AUTH0_SCOPE ?? "openid profile email",
|
||||||
|
baseUrl: process.env.AUTH0_BASE_URL ?? "http://localhost:3000",
|
||||||
|
redirectUri:
|
||||||
|
process.env.AUTH0_REDIRECT_URI ?? "http://localhost:3000/auth/callback",
|
||||||
|
},
|
||||||
|
supabase: {
|
||||||
|
url: process.env.SUPABASE_URL,
|
||||||
|
anonKey: process.env.SUPABASE_ANON_KEY,
|
||||||
|
storageBucket: process.env.SUPABASE_STORAGE_BUCKET ?? "designs",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
|
|||||||
193
package-lock.json
generated
193
package-lock.json
generated
@@ -7,12 +7,48 @@
|
|||||||
"name": "slipmatz-web",
|
"name": "slipmatz-web",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth0/auth0-vue": "^2.4.0",
|
||||||
|
"@supabase/supabase-js": "^2.48.0",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"fabric": "^6.0.2",
|
"fabric": "^6.0.2",
|
||||||
|
"jose": "^5.9.6",
|
||||||
"nuxt": "^4.2.0",
|
"nuxt": "^4.2.0",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@auth0/auth0-spa-js": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-Lu3dBius0CMRHNAWtw/RyIZH0b5B4jV9ZlVjpp5s7A11AO/XyABkNl0VW7Cz5ZHpAkXEba1CMnkxDG1/9LNIqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browser-tabs-lock": "^1.2.15",
|
||||||
|
"dpop": "^2.1.1",
|
||||||
|
"es-cookie": "~1.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@auth0/auth0-vue": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth0/auth0-vue/-/auth0-vue-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-12iLvojP8Pvxqu2Abxzksp0HqlSovGiAUhWrppnOaJP02MZEBQo+c/IwM6VbM0edNk+eqqjX5u96iw5peaCPSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth0/auth0-spa-js": "^2.1.3",
|
||||||
|
"vue": "^3.2.41"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue-router": "^4.0.12"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"vue-router": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -3081,6 +3117,85 @@
|
|||||||
"integrity": "sha512-IGytNtnUnPIobIbOq5Y6LIlqiHNX+vnToQIS7lj6L5819C+rA8TXRDkkG8vePsiBOGcoW9R6i+dp2YBUKdB09Q==",
|
"integrity": "sha512-IGytNtnUnPIobIbOq5Y6LIlqiHNX+vnToQIS7lj6L5819C+rA8TXRDkkG8vePsiBOGcoW9R6i+dp2YBUKdB09Q==",
|
||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-cXDtu1U0LeZj/xfnFoV7yCze37TcbNo8FCxy1FpqhMbB9u9QxxDSW6pA5gm/07Ei7m260Lof4CZx67Cu6DPeig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-t1jOvArBsOINyqaRee1xJ3gryXLvkBzqnKfi6q3YRzzhJbGS6eXz0pXR5fqmJeB01fLC+1njpf3YhMszdPEF7g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/node-fetch": {
|
||||||
|
"version": "2.6.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||||
|
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-AwhpYlSvJ+PSnPmIK8sHj7NGDyDENYfQGKrMtpVIEzQA2ApUjgpUGxzXWN4Z0wEtLQsvv7g4y9HVad9Hzo1TNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-rCs1zmLe7of7hj4s7G9z8rTqzWuNVtmwDr3FiCRCJFawEoa+RQO1xpZGbdeuVvVmKDyVN6b542Okci+117y/LQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@types/phoenix": "^1.6.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-n17P0JbjHOlxqJpkaGFOn97i3EusEKPEbWOpuk1r4t00Wg06B8Z4GUiq0O0n1vUpjiMgJUkLIMuBVp+bEgunzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.78.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.78.0.tgz",
|
||||||
|
"integrity": "sha512-xYMRNBFmKp2m1gMuwcp/gr/HlfZKqjye1Ib8kJe29XJNsgwsfO/f8skxnWiscFKTlkOKLuBexNgl5L8dzGt6vA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.78.0",
|
||||||
|
"@supabase/functions-js": "2.78.0",
|
||||||
|
"@supabase/node-fetch": "2.6.15",
|
||||||
|
"@supabase/postgrest-js": "2.78.0",
|
||||||
|
"@supabase/realtime-js": "2.78.0",
|
||||||
|
"@supabase/storage-js": "2.78.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.16",
|
"version": "4.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||||
@@ -3364,18 +3479,42 @@
|
|||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.18.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz",
|
||||||
|
"integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/parse-path": {
|
"node_modules/@types/parse-path": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz",
|
||||||
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/phoenix": {
|
||||||
|
"version": "1.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||||
|
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/resolve": {
|
"node_modules/@types/resolve": {
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||||
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unhead/vue": {
|
"node_modules/@unhead/vue": {
|
||||||
"version": "2.0.19",
|
"version": "2.0.19",
|
||||||
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz",
|
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.19.tgz",
|
||||||
@@ -4172,6 +4311,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-tabs-lock": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": ">=4.17.21"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.27.0",
|
"version": "4.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
|
||||||
@@ -5574,6 +5723,15 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dpop": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -5668,6 +5826,12 @@
|
|||||||
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
|
"integrity": "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-cookie": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -7044,6 +7208,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "5.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
|
||||||
|
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -10502,8 +10675,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD",
|
"license": "0BSD"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
@@ -10574,6 +10746,12 @@
|
|||||||
"node": ">=20.18.1"
|
"node": ">=20.18.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unenv": {
|
"node_modules/unenv": {
|
||||||
"version": "2.0.0-rc.24",
|
"version": "2.0.0-rc.24",
|
||||||
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
|
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
|
||||||
@@ -11754,6 +11932,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,18 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth0/auth0-vue": "^2.4.0",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
|
"@supabase/supabase-js": "^2.48.0",
|
||||||
"fabric": "^6.0.2",
|
"fabric": "^6.0.2",
|
||||||
|
"jose": "^5.9.6",
|
||||||
"nuxt": "^4.2.0",
|
"nuxt": "^4.2.0",
|
||||||
|
"zod": "^3.23.8",
|
||||||
"tailwindcss": "^4.1.16",
|
"tailwindcss": "^4.1.16",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
plugins/auth0.client.ts
Normal file
48
plugins/auth0.client.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createAuth0 } from "@auth0/auth0-vue";
|
||||||
|
import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
|
||||||
|
|
||||||
|
declare module "@auth0/auth0-vue" {
|
||||||
|
interface AppState {
|
||||||
|
target?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const auth0Config = config.public.auth0;
|
||||||
|
|
||||||
|
if (!auth0Config?.domain || !auth0Config?.clientId) {
|
||||||
|
if (process.dev) {
|
||||||
|
console.warn(
|
||||||
|
"Auth0 configuration is incomplete. Set AUTH0_DOMAIN and AUTH0_CLIENT_ID in your environment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUri = auth0Config.redirectUri ?? `${auth0Config.baseUrl}/auth/callback`;
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(
|
||||||
|
createAuth0({
|
||||||
|
domain: auth0Config.domain,
|
||||||
|
clientId: auth0Config.clientId,
|
||||||
|
authorizationParams: {
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
audience: auth0Config.audience || undefined,
|
||||||
|
scope: auth0Config.scope,
|
||||||
|
},
|
||||||
|
cacheLocation: "localstorage",
|
||||||
|
useRefreshTokens: true,
|
||||||
|
onRedirectCallback: (appState) => {
|
||||||
|
const targetPath = appState?.target ?? "/";
|
||||||
|
if (nuxtApp.$router) {
|
||||||
|
nuxtApp.$router.replace(targetPath).catch(() => {
|
||||||
|
window.location.assign(targetPath);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
window.location.assign(targetPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
27
plugins/supabase.client.ts
Normal file
27
plugins/supabase.client.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { defineNuxtPlugin, useRuntimeConfig } from "nuxt/app";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const { url, anonKey } = useRuntimeConfig().public.supabase;
|
||||||
|
|
||||||
|
if (!url || !anonKey) {
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
console.warn(
|
||||||
|
"Supabase configuration is incomplete. Set SUPABASE_URL and SUPABASE_ANON_KEY in your environment."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.provide("supabase", null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(url, anonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
storageKey: "slipmatz.supabase.auth",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
nuxtApp.provide("supabase", supabase);
|
||||||
|
nuxtApp.vueApp.config.globalProperties.$supabase = supabase;
|
||||||
|
});
|
||||||
47
server/api/designs/[id].get.ts
Normal file
47
server/api/designs/[id].get.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createError } from "h3";
|
||||||
|
|
||||||
|
import { requireAuth0User } from "../../utils/auth0";
|
||||||
|
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||||
|
|
||||||
|
type DesignRecord = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
template_id: string;
|
||||||
|
preview_url: string | null;
|
||||||
|
preview_path: string | null;
|
||||||
|
design_json: unknown;
|
||||||
|
notes?: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireAuth0User(event);
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
const id = event.context.params?.id;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Design id is required." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await supabase
|
||||||
|
.from("designs")
|
||||||
|
.select("id, user_id, name, template_id, preview_url, preview_path, design_json, notes, created_at, updated_at")
|
||||||
|
.eq("id", id)
|
||||||
|
.eq("user_id", user.sub)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to load design: ${response.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "Design not found." });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as DesignRecord;
|
||||||
|
});
|
||||||
39
server/api/designs/index.get.ts
Normal file
39
server/api/designs/index.get.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createError, getQuery } from "h3";
|
||||||
|
|
||||||
|
import { requireAuth0User } from "../../utils/auth0";
|
||||||
|
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||||
|
|
||||||
|
type DesignSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
template_id: string;
|
||||||
|
preview_url: string | null;
|
||||||
|
preview_path: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireAuth0User(event);
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
|
const limit = query.limit ? Number.parseInt(String(query.limit), 10) : 20;
|
||||||
|
const sanitizedLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20;
|
||||||
|
|
||||||
|
const response = await supabase
|
||||||
|
.from("designs")
|
||||||
|
.select("id, name, template_id, preview_url, preview_path, created_at, updated_at")
|
||||||
|
.eq("user_id", user.sub)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
.limit(sanitizedLimit);
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to fetch designs: ${response.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (response.data as DesignSummary[]) ?? [];
|
||||||
|
});
|
||||||
115
server/api/designs/index.post.ts
Normal file
115
server/api/designs/index.post.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Buffer } from "node:buffer";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createError, readBody } from "h3";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { requireAuth0User } from "../../utils/auth0";
|
||||||
|
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
templateId: z.string().min(1).max(64),
|
||||||
|
previewDataUrl: z.string().regex(/^data:image\/png;base64,/),
|
||||||
|
productionDataUrl: z.string().regex(/^data:image\/png;base64,/).optional(),
|
||||||
|
designJson: z.record(z.any()),
|
||||||
|
notes: z.string().max(640).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RequestPayload = z.infer<typeof requestSchema>;
|
||||||
|
|
||||||
|
type DesignRecord = {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
template_id: string;
|
||||||
|
preview_path: string;
|
||||||
|
preview_url: string | null;
|
||||||
|
design_json: unknown;
|
||||||
|
notes?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataUrlToBuffer = (dataUrl: string): Buffer => {
|
||||||
|
const [, base64Data] = dataUrl.split(",");
|
||||||
|
if (!base64Data) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "Invalid image data URI." });
|
||||||
|
}
|
||||||
|
return Buffer.from(base64Data, "base64");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireAuth0User(event);
|
||||||
|
const rawBody = await readBody<RequestPayload>(event);
|
||||||
|
const parsed = requestSchema.safeParse(rawBody);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Invalid request body.",
|
||||||
|
data: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = parsed.data;
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const bucket = config.public.supabase?.storageBucket;
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Supabase storage bucket is not configured.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = getSupabaseServiceClient();
|
||||||
|
|
||||||
|
const designId = body.id ?? randomUUID();
|
||||||
|
const filePath = `previews/${user.sub}/${designId}.png`;
|
||||||
|
|
||||||
|
const uploadResult = await supabase.storage
|
||||||
|
.from(bucket)
|
||||||
|
.upload(filePath, dataUrlToBuffer(body.previewDataUrl), {
|
||||||
|
contentType: "image/png",
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadResult.error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to upload preview: ${uploadResult.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: publicUrlData } = supabase.storage.from(bucket).getPublicUrl(filePath);
|
||||||
|
const previewUrl = publicUrlData.publicUrl ?? null;
|
||||||
|
|
||||||
|
const record: DesignRecord = {
|
||||||
|
id: designId,
|
||||||
|
user_id: user.sub,
|
||||||
|
name: body.name,
|
||||||
|
template_id: body.templateId,
|
||||||
|
preview_path: filePath,
|
||||||
|
preview_url: previewUrl,
|
||||||
|
design_json: body.designJson,
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertResult = await supabase
|
||||||
|
.from("designs")
|
||||||
|
.upsert(record, { onConflict: "id" })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (upsertResult.error) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Failed to save design: ${upsertResult.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return upsertResult.data;
|
||||||
|
});
|
||||||
82
server/utils/auth0.ts
Normal file
82
server/utils/auth0.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { createError, getHeader, type H3Event } from "h3";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
import {
|
||||||
|
createRemoteJWKSet,
|
||||||
|
jwtVerify,
|
||||||
|
type JWTPayload,
|
||||||
|
type JWTVerifyOptions,
|
||||||
|
} from "jose";
|
||||||
|
|
||||||
|
const globalKey = Symbol.for("slipmatz.auth0.jwks");
|
||||||
|
|
||||||
|
type GlobalWithJwks = typeof globalThis & {
|
||||||
|
[globalKey]?: ReturnType<typeof createRemoteJWKSet>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getJwks = (issuer: string) => {
|
||||||
|
const globalScope = globalThis as GlobalWithJwks;
|
||||||
|
if (!globalScope[globalKey]) {
|
||||||
|
globalScope[globalKey] = createRemoteJWKSet(
|
||||||
|
new URL(`${issuer}.well-known/jwks.json`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return globalScope[globalKey]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Auth0TokenPayload = JWTPayload & {
|
||||||
|
sub: string;
|
||||||
|
scope?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyAccessToken = async (token: string): Promise<Auth0TokenPayload> => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const domain = config.public.auth0?.domain;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Auth0 domain is not configured.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const issuer = `https://${domain}/`;
|
||||||
|
const jwks = getJwks(issuer);
|
||||||
|
|
||||||
|
const options: JWTVerifyOptions = {
|
||||||
|
issuer,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.public.auth0?.audience) {
|
||||||
|
options.audience = config.public.auth0.audience;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, jwks, options);
|
||||||
|
if (!payload.sub) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: "Invalid access token payload.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as Auth0TokenPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAuth0User = async (event: H3Event): Promise<Auth0TokenPayload> => {
|
||||||
|
const header = getHeader(event, "authorization");
|
||||||
|
if (!header) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "Authorization header missing." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!match) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "Invalid authorization format." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = match[1];
|
||||||
|
try {
|
||||||
|
return await verifyAccessToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
throw createError({ statusCode: 401, statusMessage: "Access token verification failed." });
|
||||||
|
}
|
||||||
|
};
|
||||||
34
server/utils/supabase.ts
Normal file
34
server/utils/supabase.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { createError } from "h3";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
|
const globalKey = Symbol.for("slipmatz.supabase.serviceClient");
|
||||||
|
|
||||||
|
type GlobalWithSupabase = typeof globalThis & {
|
||||||
|
[globalKey]?: SupabaseClient;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSupabaseServiceClient = (): SupabaseClient => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const supabaseUrl = config.public.supabase?.url;
|
||||||
|
const serviceRoleKey = config.supabase?.serviceRoleKey;
|
||||||
|
|
||||||
|
if (!supabaseUrl || !serviceRoleKey) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "Supabase environment variables are not configured.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = globalThis as GlobalWithSupabase;
|
||||||
|
if (!scope[globalKey]) {
|
||||||
|
scope[globalKey] = createClient(supabaseUrl, serviceRoleKey, {
|
||||||
|
auth: {
|
||||||
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return scope[globalKey]!;
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
// https://nuxt.com/docs/guide/concepts/typescript
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user