diff --git a/examples/nextjs-ts-user-managment/README.md b/examples/nextjs-ts-user-managment/README.md index 6db86b08fa9..03edf37b0bf 100644 --- a/examples/nextjs-ts-user-managment/README.md +++ b/examples/nextjs-ts-user-managment/README.md @@ -23,12 +23,15 @@ create policy "Public profiles are viewable by everyone." on profiles for select create policy "Users can insert their own profile." on profiles for insert with check (auth.uid() = id); create policy "Users can update own profile." on profiles for update using (auth.uid() = id); - -- Set up Realtime! begin; drop publication if exists supabase_realtime; create publication supabase_realtime; commit; - alter publication supabase_realtime add table profiles; + +-- Set up Storage! +insert into storage.buckets (id, name) values ('avatars', 'avatars'); +create policy "Avatar images are publicly accessible." on storage.objects for select using ( bucket_id = 'avatars' ); +create policy "Anyone can upload an avatar." on storage.objects for insert using ( bucket_id = 'avatars' ); ``` diff --git a/examples/nextjs-ts-user-managment/components/Account.tsx b/examples/nextjs-ts-user-managment/components/Account.tsx index 684e4668274..b5a9e208989 100644 --- a/examples/nextjs-ts-user-managment/components/Account.tsx +++ b/examples/nextjs-ts-user-managment/components/Account.tsx @@ -1,10 +1,18 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, ChangeEvent } from 'react' import { supabase } from '../lib/supabaseClient' -import { Profile } from '../lib/constants' +import UploadButton from '../components/UploadButton' +import Avatar from './Avatar' import { AuthSession } from '@supabase/supabase-js' +import { DEFAULT_AVATARS_BUCKET, Profile } from '../lib/constants' -export default function Account({ session }: { session: AuthSession }) { +export default function Account({ + session, +}: { + session: AuthSession +}) { const [loading, setLoading] = useState(true) + const [uploading, setUploading] = useState(false) + const [avatar, setAvatar] = useState(null) const [username, setUsername] = useState(null) const [website, setWebsite] = useState(null) @@ -17,7 +25,46 @@ export default function Account({ session }: { session: AuthSession }) { if (error) console.log('Error logging out:', error.message) } + async function uploadAvatar(event: ChangeEvent) { + try { + setUploading(true) + + if (!event.target.files || event.target.files.length == 0) { + throw 'You must select an image to upload.' + } + + const user = supabase.auth.user() + const file = event.target.files[0] + const fileExt = file.name.split('.').pop() + const fileName = `${session?.user.id}${Math.random()}.${fileExt}` + const filePath = `${DEFAULT_AVATARS_BUCKET}/${fileName}` + + let { error: uploadError } = await supabase.storage.uploadFile(filePath, file) + + if (uploadError) { + throw uploadError + } + + let { error: updateError } = await supabase.from('profiles').upsert({ + id: user!.id, + avatar_url: filePath, + }) + + if (updateError) { + throw updateError + } + + setAvatar(null) + setAvatar(filePath) + } catch (error) { + alert(error.message) + } finally { + setUploading(false) + } + } + function setProfile(profile: Profile) { + setAvatar(profile.avatar_url) setUsername(profile.username) setWebsite(profile.website) } @@ -25,15 +72,16 @@ export default function Account({ session }: { session: AuthSession }) { async function getProfile() { try { setLoading(true) + const user = supabase.auth.user() let { data, error } = await supabase - .from('profiles') + .from('profiles') .select(`username, website, avatar_url`) - .eq('id', session.user.id) + .eq('id', user!.id) .single() - if (error || !data) { - throw error || new Error('Profile not found') + if (error) { + throw error } setProfile(data) @@ -47,9 +95,10 @@ export default function Account({ session }: { session: AuthSession }) { async function updateProfile() { try { setLoading(true) + const user = supabase.auth.user() const updates = { - id: session.user.id, + id: user!.id, username, website, updated_at: new Date(), @@ -70,22 +119,13 @@ export default function Account({ session }: { session: AuthSession }) { } return ( -
+
- + setWebsite(e.target.value)} />
+
+ +
+
+ {avatar ? ( + + ) : ( +
?
+ )} +
+ +
+
diff --git a/examples/nextjs-ts-user-managment/components/Avatar.tsx b/examples/nextjs-ts-user-managment/components/Avatar.tsx new file mode 100644 index 00000000000..cdeefb813bb --- /dev/null +++ b/examples/nextjs-ts-user-managment/components/Avatar.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' +import { supabase } from '../lib/supabaseClient' + +export default function Avatar({ url, size }: { url: string | null; size: number }) { + const [avatarUrl, setAvatarUrl] = useState(null) + + useEffect(() => { + if (url) downloadImage(url) + }, [url]) + + async function downloadImage(path: string) { + try { + const { data, error } = await supabase.storage.downloadFile(path) + if (error) { + throw error + } + const url = URL.createObjectURL(data) + setAvatarUrl(url) + } catch (error) { + console.log('Error downloading image: ', error.message) + } + } + + return avatarUrl ? ( + + ) : ( +
+ ) +} diff --git a/examples/nextjs-ts-user-managment/components/Footer.tsx b/examples/nextjs-ts-user-managment/components/Footer.tsx new file mode 100644 index 00000000000..53371951be1 --- /dev/null +++ b/examples/nextjs-ts-user-managment/components/Footer.tsx @@ -0,0 +1,16 @@ +export default function Footer({ }) { + return ( +
+
+
+ Powered by + +
+
+ + + +
+
+ ) +} diff --git a/examples/nextjs-ts-user-managment/components/ProfileCard.tsx b/examples/nextjs-ts-user-managment/components/ProfileCard.tsx index fa9c754639c..ad560a44975 100644 --- a/examples/nextjs-ts-user-managment/components/ProfileCard.tsx +++ b/examples/nextjs-ts-user-managment/components/ProfileCard.tsx @@ -6,6 +6,7 @@ export default function ProfileCard({ profile }: { profile: Profile }) {

Username: {profile.username}

Website: {profile.website}

+

Avatar: {profile.avatar_url}

Last updated{' '} diff --git a/examples/nextjs-ts-user-managment/components/UploadButton.tsx b/examples/nextjs-ts-user-managment/components/UploadButton.tsx new file mode 100644 index 00000000000..dacf4155541 --- /dev/null +++ b/examples/nextjs-ts-user-managment/components/UploadButton.tsx @@ -0,0 +1,27 @@ +import { ChangeEventHandler } from 'react' + +export type UploadButtonProps = { + onUpload: ChangeEventHandler + loading: boolean +} + +export default function UploadButton(props: UploadButtonProps) { + return ( +

+ + +
+ ) +} diff --git a/examples/nextjs-ts-user-managment/lib/constants.ts b/examples/nextjs-ts-user-managment/lib/constants.ts index 7ba68149c3f..917ca244885 100644 --- a/examples/nextjs-ts-user-managment/lib/constants.ts +++ b/examples/nextjs-ts-user-managment/lib/constants.ts @@ -1,6 +1,8 @@ export const NEXT_PUBLIC_SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL export const NEXT_PUBLIC_SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_KEY +export const DEFAULT_AVATARS_BUCKET = 'avatars' + export type Profile = { id: string avatar_url: string diff --git a/examples/nextjs-ts-user-managment/pages/index.tsx b/examples/nextjs-ts-user-managment/pages/index.tsx index b9f63a48d57..7190f950b01 100644 --- a/examples/nextjs-ts-user-managment/pages/index.tsx +++ b/examples/nextjs-ts-user-managment/pages/index.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react' import { supabase } from '../lib/supabaseClient' import { AuthSession } from '@supabase/supabase-js' import { Profile } from '../lib/constants' +import Footer from '../components/Footer' export default function Home() { const [session, setSession] = useState(null) @@ -49,16 +50,22 @@ export default function Home() { alignItems: 'center', }} > -
-
-

Account

- {!session ? : } + {!session ? ( + + ) : ( +
+
+

Account

+ +
+
+

Public Profiles

+ {profiles.length > 0 && } +
-
-

Public Profiles

- {profiles.length > 0 && } -
-
+ )} + +
) } diff --git a/examples/nextjs-ts-user-managment/public/loader.svg b/examples/nextjs-ts-user-managment/public/loader.svg new file mode 100644 index 00000000000..e1a70c12758 --- /dev/null +++ b/examples/nextjs-ts-user-managment/public/loader.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/nextjs-ts-user-managment/public/logo.png b/examples/nextjs-ts-user-managment/public/logo.png new file mode 100644 index 00000000000..cba20968f07 Binary files /dev/null and b/examples/nextjs-ts-user-managment/public/logo.png differ diff --git a/examples/nextjs-ts-user-managment/styles/globals.css b/examples/nextjs-ts-user-managment/styles/globals.css index 34cf01b7e59..e5af8869660 100644 --- a/examples/nextjs-ts-user-managment/styles/globals.css +++ b/examples/nextjs-ts-user-managment/styles/globals.css @@ -2,12 +2,12 @@ html, body { --custom-font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; - --custom-bg-color: #1f1f1f; + --custom-bg-color: #101010; --custom-box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.8); --custom-color: #fff; --custom-color-brand: #24b47e; --custom-color-secondary: #666; - --custom-border: 1px solid #666; + --custom-border: 1px solid #333; --custom-border-radius: 5px; --custom-spacing: 5px; @@ -52,7 +52,6 @@ input[disabled] { .w-half { width: 50%; } - .block { display: block; width: 100%; @@ -61,7 +60,6 @@ input[disabled] { display: inline-block; width: 100%; } - .flex { display: flex; } @@ -74,6 +72,16 @@ input[disabled] { .flex.flex-1 { flex: 1 1 0; } +.flex-end { + justify-content: flex-end; +} +.flex-center { + justify-content: center; +} +.text-sm { + font-size: 0.8rem; + font-weight: 300; +} /* Button */ @@ -136,3 +144,179 @@ button.primary, transform: translateY(-50%); text-align: center; } + +.footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + flex-flow: row; + border-top: var(--custom-border); +} +.footer div { + padding: var(--custom-spacing); + display: flex; + align-items: center; + width: 100%; +} +.footer div > img { + height: 20px; + margin-left: 10px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.mainHeader { + width: 100%; + font-size: 1.3rem; + margin-bottom: 20px; +} + +.avatarPlaceholder { + border: var(--custom-border); + border-radius: var(--custom-border-radius); + width: 40px; + height: 40px; + background-color: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +/* Auth */ + +.authContainer { + display: flex; + /* flex-direction: column; */ + align-items: center; + justify-items: center; +} + +.authContainer > .authTitle { + width: 22rem; + margin-right: 40px; +} + +.authContainer > .authTitle > .header { + font-weight: 400; +} + +.authContainer > .authTitle > .description { + opacity: 50%; + font-weight: 300; + line-height: 1.6em; +} + +.authWidget { + background-color: #181818; + border: 1px solid #2a2a2a; + padding: 30px 30px; + border-radius: 5px; + width: 28rem; +} + +.authWidget > .description { + margin: 0; + font-size: 0.9rem; +} + +.authWidget > .inputField { + border: none; + padding: 10px 10px; +} + +.authWidget > .button { + display: flex; + align-items: center; + justify-content: center; + border: none; + background-color: #444444; + text-transform: none !important; + transition: all 0.2s ease; +} + +.authWidget > .button:hover { + background-color: #2a2a2a; +} + +.authWidget > .button > .loader { + width: 17px; + animation: spin 1s linear infinite; + filter: invert(1); +} + +/* Account */ + +.account { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.account > * > .avatarField { + display: flex; + align-items: center; + margin-bottom: 30px; +} + +.account > * > .avatarField > .avatarContainer { + margin-right: 20px; +} + +/* Profile Card */ + +.profileCard { + border-radius: 5px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #2a2a2a; + padding: 30px; +} + +.profileCard > .avatarContainer { + border-radius: 50%; + overflow: hidden; + margin-bottom: 30px; +} + +.profileCard > .avatar { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.profileCard > .userInfo { + width: 100%; + font-weight: 300; + display: flex; + flex-direction: column; + justify-content: center; +} + +.profileCard > .userInfo > p { + margin: 0; +} + +.profileCard > .userInfo > .username { + font-size: 1.3rem; + font-weight: 500; + margin-bottom: 10px; +} + +.profileCard > .userInfo > .website { + font-size: 0.9rem; + margin-bottom: 10px; + color: var(--custom-color-brand); + text-decoration: none; +}