init commit,
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { AuthSession } from '@supabase/supabase-js'
|
||||
import { Component, createEffect, createSignal } from 'solid-js'
|
||||
import Avatar from './Avatar'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
interface Props {
|
||||
session: AuthSession
|
||||
}
|
||||
|
||||
const Account: Component<Props> = ({ session }) => {
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [username, setUsername] = createSignal<string | null>(null)
|
||||
const [website, setWebsite] = createSignal<string | null>(null)
|
||||
const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
getProfile()
|
||||
})
|
||||
|
||||
const getProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
let { data, error, status } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username: username(),
|
||||
website: website(),
|
||||
avatar_url: avatarUrl(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-live="polite">
|
||||
<form onSubmit={updateProfile} class="form-widget">
|
||||
<Avatar
|
||||
url={avatarUrl()}
|
||||
size={150}
|
||||
onUpload={(e: Event, url: string) => {
|
||||
setAvatarUrl(url)
|
||||
updateProfile(e)
|
||||
}}
|
||||
/>
|
||||
<div>Email: {session.user.email}</div>
|
||||
<div>
|
||||
<label for="username">Name</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username() || ''}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
value={website() || ''}
|
||||
onChange={(e) => setWebsite(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="button primary block" disabled={loading()}>
|
||||
{loading() ? 'Saving ...' : 'Update profile'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="button block" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Account
|
@@ -0,0 +1,27 @@
|
||||
import { Component, createEffect, createSignal } from 'solid-js'
|
||||
import { supabase } from './supabaseClient'
|
||||
import { AuthSession } from '@supabase/supabase-js'
|
||||
import Account from './Account'
|
||||
import Auth from './Auth'
|
||||
|
||||
const App: Component = () => {
|
||||
const [session, setSession] = createSignal<AuthSession | null>(null)
|
||||
|
||||
createEffect(() => {
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session)
|
||||
})
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="container" style={{ padding: '50px 0 100px 0' }}>
|
||||
{!session() ? <Auth /> : <Account session={session()!} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
@@ -0,0 +1,53 @@
|
||||
import { Component, createSignal } from 'solid-js'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
const Auth: Component = () => {
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [email, setEmail] = createSignal('')
|
||||
|
||||
const handleLogin = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithOtp({ email: email() })
|
||||
if (error) throw error
|
||||
alert('Check your email for login link!')
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="row flex-center flex">
|
||||
<div class="col-6 form-widget" aria-live="polite">
|
||||
<h1 class="header">Supabase + SolidJS</h1>
|
||||
<p class="description">Sign in via magic link with your email below</p>
|
||||
<form class="form-widget" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
class="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email()}
|
||||
onChange={(e) => setEmail(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" class="button block" aria-live="polite">
|
||||
{loading() ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Auth
|
@@ -0,0 +1,96 @@
|
||||
import { Component, createEffect, createSignal, JSX } from 'solid-js'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
url: string | null
|
||||
onUpload: (event: Event, filePath: string) => void
|
||||
}
|
||||
|
||||
const Avatar: Component<Props> = (props) => {
|
||||
const [avatarUrl, setAvatarUrl] = createSignal<string | null>(null)
|
||||
const [uploading, setUploading] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (props.url) downloadImage(props.url)
|
||||
})
|
||||
|
||||
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)
|
||||
setAvatarUrl(url)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.log('Error downloading image: ', error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uploadAvatar: JSX.EventHandler<HTMLInputElement, Event> = async (event) => {
|
||||
try {
|
||||
setUploading(true)
|
||||
|
||||
const target = event.currentTarget
|
||||
if (!target?.files || target.files.length === 0) {
|
||||
throw new Error('You must select an image to upload.')
|
||||
}
|
||||
|
||||
const file = target.files[0]
|
||||
const fileExt = file.name.split('.').pop()
|
||||
const fileName = `${Math.random()}.${fileExt}`
|
||||
const filePath = `${fileName}`
|
||||
|
||||
let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
||||
|
||||
if (uploadError) {
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
props.onUpload(event, filePath)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
alert(error.message)
|
||||
}
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: props.size }} aria-live="polite">
|
||||
{avatarUrl() ? (
|
||||
<img
|
||||
src={avatarUrl()!}
|
||||
alt={avatarUrl() ? 'Avatar' : 'No image'}
|
||||
class="avatar image"
|
||||
style={{ height: `${props.size}px`, width: `${props.size}px` }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
class="avatar no-image"
|
||||
style={{ height: `${props.size}px`, width: `${props.size}px` }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ width: `${props.size}px` }}>
|
||||
<label class="button primary block" for="single">
|
||||
{uploading() ? 'Uploading ...' : 'Upload avatar'}
|
||||
</label>
|
||||
<span style="display:none">
|
||||
<input
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={uploading()}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Avatar
|
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,372 @@
|
||||
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: #101010;
|
||||
--custom-panel-color: #222;
|
||||
--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 #333;
|
||||
--custom-border-radius: 5px;
|
||||
--custom-spacing: 5px;
|
||||
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: var(--custom-font-family);
|
||||
background-color: var(--custom-bg-color);
|
||||
}
|
||||
|
||||
* {
|
||||
color: var(--custom-color);
|
||||
font-family: var(--custom-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Grid */
|
||||
|
||||
.container {
|
||||
width: 90%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.row [class^='col'] {
|
||||
float: left;
|
||||
margin: 0.5rem 2%;
|
||||
min-height: 0.125rem;
|
||||
}
|
||||
.col-1,
|
||||
.col-2,
|
||||
.col-3,
|
||||
.col-4,
|
||||
.col-5,
|
||||
.col-6,
|
||||
.col-7,
|
||||
.col-8,
|
||||
.col-9,
|
||||
.col-10,
|
||||
.col-11,
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
.col-1-sm {
|
||||
width: 4.33%;
|
||||
}
|
||||
.col-2-sm {
|
||||
width: 12.66%;
|
||||
}
|
||||
.col-3-sm {
|
||||
width: 21%;
|
||||
}
|
||||
.col-4-sm {
|
||||
width: 29.33%;
|
||||
}
|
||||
.col-5-sm {
|
||||
width: 37.66%;
|
||||
}
|
||||
.col-6-sm {
|
||||
width: 46%;
|
||||
}
|
||||
.col-7-sm {
|
||||
width: 54.33%;
|
||||
}
|
||||
.col-8-sm {
|
||||
width: 62.66%;
|
||||
}
|
||||
.col-9-sm {
|
||||
width: 71%;
|
||||
}
|
||||
.col-10-sm {
|
||||
width: 79.33%;
|
||||
}
|
||||
.col-11-sm {
|
||||
width: 87.66%;
|
||||
}
|
||||
.col-12-sm {
|
||||
width: 96%;
|
||||
}
|
||||
.row::after {
|
||||
content: '';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
.hidden-sm {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 33.75em) {
|
||||
/* 540px */
|
||||
.container {
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 45em) {
|
||||
/* 720px */
|
||||
.col-1 {
|
||||
width: 4.33%;
|
||||
}
|
||||
.col-2 {
|
||||
width: 12.66%;
|
||||
}
|
||||
.col-3 {
|
||||
width: 21%;
|
||||
}
|
||||
.col-4 {
|
||||
width: 29.33%;
|
||||
}
|
||||
.col-5 {
|
||||
width: 37.66%;
|
||||
}
|
||||
.col-6 {
|
||||
width: 46%;
|
||||
}
|
||||
.col-7 {
|
||||
width: 54.33%;
|
||||
}
|
||||
.col-8 {
|
||||
width: 62.66%;
|
||||
}
|
||||
.col-9 {
|
||||
width: 71%;
|
||||
}
|
||||
.col-10 {
|
||||
width: 79.33%;
|
||||
}
|
||||
.col-11 {
|
||||
width: 87.66%;
|
||||
}
|
||||
.col-12 {
|
||||
width: 96%;
|
||||
}
|
||||
.hidden-sm {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) {
|
||||
/* 960px */
|
||||
.container {
|
||||
width: 75%;
|
||||
max-width: 60rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
color: var(--custom-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border: var(--custom-border);
|
||||
padding: 8px;
|
||||
font-size: 0.9rem;
|
||||
background-color: var(--custom-bg-color);
|
||||
color: var(--custom-color);
|
||||
}
|
||||
|
||||
input[disabled] {
|
||||
color: var(--custom-color-secondary);
|
||||
}
|
||||
|
||||
/* Utils */
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.flex.column {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex.row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex.flex-1 {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
.flex-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.flex-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 300;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.font-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
.opacity-half {
|
||||
opacity: 50%;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
|
||||
button,
|
||||
.button {
|
||||
color: var(--custom-color);
|
||||
border: var(--custom-border);
|
||||
background-color: var(--custom-bg-color);
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
border-radius: var(--custom-border-radius);
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
button.primary,
|
||||
.button.primary {
|
||||
background-color: var(--custom-color-brand);
|
||||
border: 1px solid var(--custom-color-brand);
|
||||
}
|
||||
|
||||
/* Widgets */
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: var(--custom-border);
|
||||
border-radius: var(--custom-border-radius);
|
||||
padding: var(--custom-spacing);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: var(--custom-border-radius);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
.avatar.image {
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatar.no-image {
|
||||
background-color: #333;
|
||||
border: 1px solid rgb(200, 200, 200);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
max-width: 100%;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
border-top: var(--custom-border);
|
||||
background-color: var(--custom-bg-color);
|
||||
}
|
||||
.footer div {
|
||||
padding: var(--custom-spacing);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
.footer div > img {
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.footer > div:first-child {
|
||||
display: none;
|
||||
}
|
||||
.footer > div:nth-child(2) {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 60em) {
|
||||
/* 960px */
|
||||
.footer > div:first-child {
|
||||
display: flex;
|
||||
}
|
||||
.footer > div:nth-child(2) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@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: 35px;
|
||||
height: 35px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.form-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-widget > .button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background-color: #444444;
|
||||
text-transform: none !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-widget .button:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.form-widget .button > .loader {
|
||||
width: 17px;
|
||||
animation: spin 1s linear infinite;
|
||||
filter: invert(1);
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
/* @refresh reload */
|
||||
import { render } from 'solid-js/web'
|
||||
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
render(() => <App />, document.getElementById('root') as HTMLElement)
|
@@ -0,0 +1,40 @@
|
||||
export type Json = string | number | boolean | null | { [key: string]: Json } | Json[]
|
||||
|
||||
export interface Database {
|
||||
public: {
|
||||
Tables: {
|
||||
profiles: {
|
||||
Row: {
|
||||
id: string
|
||||
updated_at: string | null
|
||||
username: string | null
|
||||
avatar_url: string | null
|
||||
website: string | null
|
||||
}
|
||||
Insert: {
|
||||
id: string
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
avatar_url?: string | null
|
||||
website?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
updated_at?: string | null
|
||||
username?: string | null
|
||||
avatar_url?: string | null
|
||||
website?: string | null
|
||||
}
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { Database } from './schema'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey)
|
Reference in New Issue
Block a user