mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
Partials are currently defined via MDX includes. This PR switches to pre-compile-time partials, which have a new syntax: ``` <$Partial path="path/to/file.mdx" /> ``` ## Rationale This produces two improvements: 1. Partial substitution can occur in pipelines that don't use MDX compilation. For example, we can now do partial substitution before building the search index, so partial content will also be indexed. 2. After the App Router migration, the MDXProviders should've been deprecated, but were kept around for the sole reason of making partials work, and leading to us shipping unnecessary client-side code. We get a minor decrease in overall client bundle size (5.74 MB to 5.6 MB) by getting rid of the Providers. ## Breaking changes Besides the change to partial syntax, the arguments are also less powerful than before because we are doing string substitution and don't have the full power of JS. Defining string variables is still possible (documented in the Contributing guide), and since that's all we actually do in practice, this shouldn't be too cumbersome. There is always the escape hatch of making a custom component for more complex content reuse cases.
729 lines
19 KiB
Plaintext
729 lines
19 KiB
Plaintext
---
|
|
title: 'Build a User Management App with SvelteKit'
|
|
description: 'Learn how to use Supabase in your SvelteKit App.'
|
|
---
|
|
|
|
<$Partial path="quickstart_intro.mdx" />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/sveltekit-user-management).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" />
|
|
|
|
## Building the app
|
|
|
|
Let's start building the Svelte app from scratch.
|
|
|
|
### Initialize a Svelte app
|
|
|
|
We can use the [SvelteKit Skeleton Project](https://kit.svelte.dev/docs) to initialize an app called `supabase-sveltekit` (for this tutorial we will be using TypeScript):
|
|
|
|
```bash
|
|
npm create svelte@latest supabase-sveltekit
|
|
cd supabase-sveltekit
|
|
npm install
|
|
```
|
|
|
|
Then install the Supabase client library: [supabase-js](https://github.com/supabase/supabase-js)
|
|
|
|
```bash
|
|
npm install @supabase/supabase-js
|
|
```
|
|
|
|
And finally we want to save the environment variables in a `.env`.
|
|
All we need are the `SUPABASE_URL` and the `SUPABASE_KEY` key that you copied [earlier](#get-the-api-keys).
|
|
|
|
```bash .env
|
|
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
|
|
PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_KEY"
|
|
```
|
|
|
|
Optionally, add `src/styles.css` with the [CSS from the example](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/sveltekit-user-management/src/styles.css).
|
|
|
|
### Creating a Supabase client for SSR
|
|
|
|
The `ssr` package configures Supabase to use Cookies, which is required for server-side languages and frameworks.
|
|
|
|
Install the Supabase packages:
|
|
|
|
```bash
|
|
npm install @supabase/ssr @supabase/supabase-js
|
|
```
|
|
|
|
Creating a Supabase client with the `ssr` package automatically configures it to use Cookies. This means your user's session is available throughout the entire SvelteKit stack - page, layout, server, hooks.
|
|
|
|
Add the code below to your `src/hooks.server.ts` to initialize the client on the server:
|
|
|
|
```ts src/hooks.server.ts
|
|
// src/hooks.server.ts
|
|
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public'
|
|
import { createServerClient } from '@supabase/ssr'
|
|
import type { Handle } from '@sveltejs/kit'
|
|
|
|
export const handle: Handle = async ({ event, resolve }) => {
|
|
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
|
cookies: {
|
|
getAll: () => event.cookies.getAll(),
|
|
/**
|
|
* SvelteKit's cookies API requires `path` to be explicitly set in
|
|
* the cookie options. Setting `path` to `/` replicates previous/
|
|
* standard behavior.
|
|
*/
|
|
setAll: (cookiesToSet) => {
|
|
cookiesToSet.forEach(({ name, value, options }) => {
|
|
event.cookies.set(name, value, { ...options, path: '/' })
|
|
})
|
|
},
|
|
},
|
|
})
|
|
|
|
/**
|
|
* Unlike `supabase.auth.getSession()`, which returns the session _without_
|
|
* validating the JWT, this function also calls `getUser()` to validate the
|
|
* JWT before returning the session.
|
|
*/
|
|
event.locals.safeGetSession = async () => {
|
|
const {
|
|
data: { session },
|
|
} = await event.locals.supabase.auth.getSession()
|
|
if (!session) {
|
|
return { session: null, user: null }
|
|
}
|
|
|
|
const {
|
|
data: { user },
|
|
error,
|
|
} = await event.locals.supabase.auth.getUser()
|
|
if (error) {
|
|
// JWT validation has failed
|
|
return { session: null, user: null }
|
|
}
|
|
|
|
return { session, user }
|
|
}
|
|
|
|
return resolve(event, {
|
|
filterSerializedResponseHeaders(name) {
|
|
return name === 'content-range' || name === 'x-supabase-api-version'
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
<$Partial path="get_session_warning.mdx" />
|
|
|
|
If you are using TypeScript the compiler might complain about `event.locals.supabase` and `event.locals.safeGetSession`, this can be fixed by updating your `src/app.d.ts` with the content below:
|
|
|
|
```ts src/app.d.ts
|
|
// src/app.d.ts
|
|
|
|
import { SupabaseClient, Session } from '@supabase/supabase-js'
|
|
|
|
declare global {
|
|
namespace App {
|
|
interface Locals {
|
|
supabase: SupabaseClient
|
|
safeGetSession(): Promise<{ session: Session | null; user: User | null }>
|
|
}
|
|
interface PageData {
|
|
session: Session | null
|
|
user: User | null
|
|
}
|
|
// interface Error {}
|
|
// interface Platform {}
|
|
}
|
|
}
|
|
```
|
|
|
|
Create a new `src/routes/+layout.server.ts` file to handle the session on the server-side.
|
|
|
|
```ts src/routes/+layout.server.ts
|
|
// src/routes/+layout.server.ts
|
|
import type { LayoutServerLoad } from './$types'
|
|
|
|
export const load: LayoutServerLoad = async ({ locals: { safeGetSession }, cookies }) => {
|
|
const { session, user } = await safeGetSession()
|
|
|
|
return {
|
|
session,
|
|
user,
|
|
cookies: cookies.getAll(),
|
|
}
|
|
}
|
|
```
|
|
|
|
<Admonition type="tip">
|
|
|
|
Start your dev server (`npm run dev`) in order to generate the `./$types` files we are referencing in our project.
|
|
|
|
</Admonition>
|
|
|
|
Create a new `src/routes/+layout.ts` file to handle the session and the `supabase` object on the client-side.
|
|
|
|
```ts src/routes/+layout.ts
|
|
// src/routes/+layout.ts
|
|
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
|
|
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
|
|
import type { LayoutLoad } from './$types'
|
|
|
|
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
|
depends('supabase:auth')
|
|
|
|
const supabase = isBrowser()
|
|
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
|
global: {
|
|
fetch,
|
|
},
|
|
})
|
|
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
|
global: {
|
|
fetch,
|
|
},
|
|
cookies: {
|
|
getAll() {
|
|
return data.cookies
|
|
},
|
|
},
|
|
})
|
|
|
|
/**
|
|
* It's fine to use `getSession` here, because on the client, `getSession` is
|
|
* safe, and on the server, it reads `session` from the `LayoutData`, which
|
|
* safely checked the session using `safeGetSession`.
|
|
*/
|
|
const {
|
|
data: { session },
|
|
} = await supabase.auth.getSession()
|
|
|
|
return { supabase, session }
|
|
}
|
|
```
|
|
|
|
Update your `src/routes/+layout.svelte`:
|
|
|
|
```svelte src/routes/+layout.svelte
|
|
<!-- src/routes/+layout.svelte -->
|
|
<script lang="ts">
|
|
import '../styles.css'
|
|
import { invalidate } from '$app/navigation'
|
|
import { onMount } from 'svelte'
|
|
|
|
export let data
|
|
|
|
let { supabase, session } = data
|
|
$: ({ supabase, session } = data)
|
|
|
|
onMount(() => {
|
|
const { data } = supabase.auth.onAuthStateChange((event, newSession) => {
|
|
if (newSession?.expires_at !== session?.expires_at) {
|
|
invalidate('supabase:auth')
|
|
}
|
|
})
|
|
|
|
return () => data.subscription.unsubscribe()
|
|
})
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>User Management</title>
|
|
</svelte:head>
|
|
|
|
<div class="container" style="padding: 50px 0 100px 0">
|
|
<slot />
|
|
</div>
|
|
```
|
|
|
|
## Set up a login page
|
|
|
|
Create a magic link login/signup page for your application:
|
|
|
|
```svelte src/routes/+page.svelte
|
|
<!-- src/routes/+page.svelte -->
|
|
<script lang="ts">
|
|
import { enhance } from '$app/forms'
|
|
import type { ActionData, SubmitFunction } from './$types.js'
|
|
|
|
export let form: ActionData;
|
|
|
|
let loading = false
|
|
|
|
const handleSubmit: SubmitFunction = () => {
|
|
loading = true
|
|
return async ({ update }) => {
|
|
update()
|
|
loading = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>User Management</title>
|
|
</svelte:head>
|
|
|
|
<form class="row flex flex-center" method="POST" use:enhance={handleSubmit}>
|
|
<div class="col-6 form-widget">
|
|
<h1 class="header">Supabase + SvelteKit</h1>
|
|
<p class="description">Sign in via magic link with your email below</p>
|
|
{#if form?.message !== undefined}
|
|
<div class="success {form?.success ? '' : 'fail'}">
|
|
{form?.message}
|
|
</div>
|
|
{/if}
|
|
<div>
|
|
<label for="email">Email address</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
class="inputField"
|
|
type="email"
|
|
placeholder="Your email"
|
|
value={form?.email ?? ''}
|
|
/>
|
|
</div>
|
|
{#if form?.errors?.email}
|
|
<span class="flex items-center text-sm error">
|
|
{form?.errors?.email}
|
|
</span>
|
|
{/if}
|
|
<div>
|
|
<button class="button primary block">
|
|
{ loading ? 'Loading' : 'Send magic link' }
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
```
|
|
|
|
Create a `src/routes/+page.server.ts` file that will handle our magic link form when submitted.
|
|
|
|
```ts src/routes/+page.server.ts
|
|
// src/routes/+page.server.ts
|
|
import { fail, redirect } from '@sveltejs/kit'
|
|
import type { Actions, PageServerLoad } from './$types'
|
|
|
|
export const load: PageServerLoad = async ({ url, locals: { safeGetSession } }) => {
|
|
const { session } = await safeGetSession()
|
|
|
|
// if the user is already logged in return them to the account page
|
|
if (session) {
|
|
redirect(303, '/account')
|
|
}
|
|
|
|
return { url: url.origin }
|
|
}
|
|
|
|
export const actions: Actions = {
|
|
default: async (event) => {
|
|
const {
|
|
url,
|
|
request,
|
|
locals: { supabase },
|
|
} = event
|
|
const formData = await request.formData()
|
|
const email = formData.get('email') as string
|
|
const validEmail = /^[\w-\.+]+@([\w-]+\.)+[\w-]{2,8}$/.test(email)
|
|
|
|
if (!validEmail) {
|
|
return fail(400, { errors: { email: 'Please enter a valid email address' }, email })
|
|
}
|
|
|
|
const { error } = await supabase.auth.signInWithOtp({ email })
|
|
|
|
if (error) {
|
|
return fail(400, {
|
|
success: false,
|
|
email,
|
|
message: `There was an issue, Please contact support.`,
|
|
})
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Please check your email for a magic link to log into the website.',
|
|
}
|
|
},
|
|
}
|
|
```
|
|
|
|
### Email template
|
|
|
|
Change the email template to support a server-side authentication flow.
|
|
|
|
Before we proceed, let's change the email template to support sending a token hash:
|
|
|
|
- Go to the [Auth templates](/dashboard/project/_/auth/templates) page in your dashboard.
|
|
- Select `Confirm signup` template.
|
|
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
|
|
- Repeat the previous step for `Magic link` template.
|
|
|
|
<Admonition type="tip">
|
|
|
|
Did you know? You could also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
|
|
|
|
</Admonition>
|
|
|
|
### Confirmation endpoint
|
|
|
|
As we are working in a server-side rendering (SSR) environment, it is necessary to create a server endpoint responsible for exchanging the `token_hash` for a session.
|
|
|
|
In the following code snippet, we perform the following steps:
|
|
|
|
- Retrieve the `token_hash` sent back from the Supabase Auth server using the `token_hash` query parameter.
|
|
- Exchange this `token_hash` for a session, which we store in storage (in this case, cookies).
|
|
- Finally, the user is redirected to the `account` page or the `error` page.
|
|
|
|
```ts src/routes/auth/confirm/+server.ts
|
|
// src/routes/auth/confirm/+server.ts
|
|
import type { EmailOtpType } from '@supabase/supabase-js'
|
|
import { redirect } from '@sveltejs/kit'
|
|
|
|
import type { RequestHandler } from './$types'
|
|
|
|
export const GET: RequestHandler = async ({ url, locals: { supabase } }) => {
|
|
const token_hash = url.searchParams.get('token_hash')
|
|
const type = url.searchParams.get('type') as EmailOtpType | null
|
|
const next = url.searchParams.get('next') ?? '/account'
|
|
|
|
/**
|
|
* Clean up the redirect URL by deleting the Auth flow parameters.
|
|
*
|
|
* `next` is preserved for now, because it's needed in the error case.
|
|
*/
|
|
const redirectTo = new URL(url)
|
|
redirectTo.pathname = next
|
|
redirectTo.searchParams.delete('token_hash')
|
|
redirectTo.searchParams.delete('type')
|
|
|
|
if (token_hash && type) {
|
|
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
|
|
if (!error) {
|
|
redirectTo.searchParams.delete('next')
|
|
redirect(303, redirectTo)
|
|
}
|
|
}
|
|
|
|
redirectTo.pathname = '/auth/error'
|
|
redirect(303, redirectTo)
|
|
}
|
|
```
|
|
|
|
### Authentication error page
|
|
|
|
If there is an error with confirming the token you will be redirect to this error page.
|
|
|
|
```svelte src/routes/auth/error/+page.svelte
|
|
<p>Login error</p>
|
|
```
|
|
|
|
### Account page
|
|
|
|
After a user is signed in, they need to be able to edit their profile details and manage their account.
|
|
Create a new `src/routes/account/+page.svelte` file with the content below.
|
|
|
|
```svelte src/routes/account/+page.svelte
|
|
<!-- src/routes/account/+page.svelte -->
|
|
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import type { SubmitFunction } from '@sveltejs/kit';
|
|
|
|
export let data
|
|
export let form
|
|
|
|
let { session, supabase, profile } = data
|
|
$: ({ session, supabase, profile } = data)
|
|
|
|
let profileForm: HTMLFormElement
|
|
let loading = false
|
|
let fullName: string = profile?.full_name ?? ''
|
|
let username: string = profile?.username ?? ''
|
|
let website: string = profile?.website ?? ''
|
|
let avatarUrl: string = profile?.avatar_url ?? ''
|
|
|
|
const handleSubmit: SubmitFunction = () => {
|
|
loading = true
|
|
return async () => {
|
|
loading = false
|
|
}
|
|
}
|
|
|
|
const handleSignOut: SubmitFunction = () => {
|
|
loading = true
|
|
return async ({ update }) => {
|
|
loading = false
|
|
update()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="form-widget">
|
|
<form
|
|
class="form-widget"
|
|
method="post"
|
|
action="?/update"
|
|
use:enhance={handleSubmit}
|
|
bind:this={profileForm}
|
|
>
|
|
<div>
|
|
<label for="email">Email</label>
|
|
<input id="email" type="text" value={session.user.email} disabled />
|
|
</div>
|
|
|
|
<div>
|
|
<label for="fullName">Full Name</label>
|
|
<input id="fullName" name="fullName" type="text" value={form?.fullName ?? fullName} />
|
|
</div>
|
|
|
|
<div>
|
|
<label for="username">Username</label>
|
|
<input id="username" name="username" type="text" value={form?.username ?? username} />
|
|
</div>
|
|
|
|
<div>
|
|
<label for="website">Website</label>
|
|
<input id="website" name="website" type="url" value={form?.website ?? website} />
|
|
</div>
|
|
|
|
<div>
|
|
<input
|
|
type="submit"
|
|
class="button block primary"
|
|
value={loading ? 'Loading...' : 'Update'}
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
</form>
|
|
|
|
<form method="post" action="?/signout" use:enhance={handleSignOut}>
|
|
<div>
|
|
<button class="button block" disabled={loading}>Sign Out</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
```
|
|
|
|
Now create the associated `src/routes/account/+page.server.ts` file that will handle loading our data from the server through the `load` function
|
|
and handle all our form actions through the `actions` object.
|
|
|
|
```ts
|
|
import { fail, redirect } from '@sveltejs/kit'
|
|
import type { Actions, PageServerLoad } from './$types'
|
|
|
|
export const load: PageServerLoad = async ({ locals: { supabase, safeGetSession } }) => {
|
|
const { session } = await safeGetSession()
|
|
|
|
if (!session) {
|
|
redirect(303, '/')
|
|
}
|
|
|
|
const { data: profile } = await supabase
|
|
.from('profiles')
|
|
.select(`username, full_name, website, avatar_url`)
|
|
.eq('id', session.user.id)
|
|
.single()
|
|
|
|
return { session, profile }
|
|
}
|
|
|
|
export const actions: Actions = {
|
|
update: async ({ request, locals: { supabase, safeGetSession } }) => {
|
|
const formData = await request.formData()
|
|
const fullName = formData.get('fullName') as string
|
|
const username = formData.get('username') as string
|
|
const website = formData.get('website') as string
|
|
const avatarUrl = formData.get('avatarUrl') as string
|
|
|
|
const { session } = await safeGetSession()
|
|
|
|
const { error } = await supabase.from('profiles').upsert({
|
|
id: session?.user.id,
|
|
full_name: fullName,
|
|
username,
|
|
website,
|
|
avatar_url: avatarUrl,
|
|
updated_at: new Date(),
|
|
})
|
|
|
|
if (error) {
|
|
return fail(500, {
|
|
fullName,
|
|
username,
|
|
website,
|
|
avatarUrl,
|
|
})
|
|
}
|
|
|
|
return {
|
|
fullName,
|
|
username,
|
|
website,
|
|
avatarUrl,
|
|
}
|
|
},
|
|
signout: async ({ locals: { supabase, safeGetSession } }) => {
|
|
const { session } = await safeGetSession()
|
|
if (session) {
|
|
await supabase.auth.signOut()
|
|
redirect(303, '/')
|
|
}
|
|
},
|
|
}
|
|
```
|
|
|
|
### Launch!
|
|
|
|
Now that we have all the pages in place, run this in a terminal window:
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
And then open the browser to [localhost:5173](http://localhost:5173) and you should see the completed app.
|
|
|
|

|
|
|
|
## Bonus: Profile photos
|
|
|
|
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like photos and videos.
|
|
|
|
### Create an upload widget
|
|
|
|
Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component called `Avatar.svelte` in the `src/routes/account` directory:
|
|
|
|
```svelte src/routes/account/Avatar.svelte
|
|
<!-- src/routes/account/Avatar.svelte -->
|
|
<script lang="ts">
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { createEventDispatcher } from 'svelte'
|
|
|
|
export let size = 10
|
|
export let url: string
|
|
export let supabase: SupabaseClient
|
|
|
|
let avatarUrl: string | null = null
|
|
let uploading = false
|
|
let files: FileList
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
const downloadImage = async (path: string) => {
|
|
try {
|
|
const { data, error } = await supabase.storage.from('avatars').download(path)
|
|
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
const url = URL.createObjectURL(data)
|
|
avatarUrl = url
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
console.log('Error downloading image: ', error.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
const uploadAvatar = async () => {
|
|
try {
|
|
uploading = true
|
|
|
|
if (!files || files.length === 0) {
|
|
throw new Error('You must select an image to upload.')
|
|
}
|
|
|
|
const file = files[0]
|
|
const fileExt = file.name.split('.').pop()
|
|
const filePath = `${Math.random()}.${fileExt}`
|
|
|
|
const { error } = await supabase.storage.from('avatars').upload(filePath, file)
|
|
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
url = filePath
|
|
setTimeout(() => {
|
|
dispatch('upload')
|
|
}, 100)
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
alert(error.message)
|
|
}
|
|
} finally {
|
|
uploading = false
|
|
}
|
|
}
|
|
|
|
$: if (url) downloadImage(url)
|
|
</script>
|
|
|
|
<div>
|
|
{#if avatarUrl}
|
|
<img
|
|
src={avatarUrl}
|
|
alt={avatarUrl ? 'Avatar' : 'No image'}
|
|
class="avatar image"
|
|
style="height: {size}em; width: {size}em;"
|
|
/>
|
|
{:else}
|
|
<div class="avatar no-image" style="height: {size}em; width: {size}em;" />
|
|
{/if}
|
|
<input type="hidden" name="avatarUrl" value={url} />
|
|
|
|
<div style="width: {size}em;">
|
|
<label class="button primary block" for="single">
|
|
{uploading ? 'Uploading ...' : 'Upload'}
|
|
</label>
|
|
<input
|
|
style="visibility: hidden; position:absolute;"
|
|
type="file"
|
|
id="single"
|
|
accept="image/*"
|
|
bind:files
|
|
on:change={uploadAvatar}
|
|
disabled={uploading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the Account page:
|
|
|
|
```svelte src/routes/account/+page.svelte
|
|
<!-- src/routes/account/+page.svelte -->
|
|
<script lang="ts">
|
|
// Import the new component
|
|
import Avatar from './Avatar.svelte'
|
|
</script>
|
|
|
|
<div class="form-widget">
|
|
<form
|
|
class="form-widget"
|
|
method="post"
|
|
action="?/update"
|
|
use:enhance={handleSubmit}
|
|
bind:this={profileForm}
|
|
>
|
|
<!-- Add to body -->
|
|
<Avatar
|
|
{supabase}
|
|
bind:url={avatarUrl}
|
|
size={10}
|
|
on:upload={() => {
|
|
profileForm.requestSubmit();
|
|
}}
|
|
/>
|
|
|
|
<!-- Other form elements -->
|
|
</form>
|
|
</div>
|
|
```
|
|
|
|
At this stage you have a fully functional application!
|