构建并部署您的第一个应用

MemFire Cloud应用提供了构建产品所需的所有后端服务,结合了静态网站托管能量,能够快速创建并部署您的应用。本教程是创建并部署您的第一个应用的指南。 本教程提供了使用MemFire Cloud和React构建简单用户管理应用的步骤。

你将会构建什么?

在本指南结束时,您将拥有一个允许用户登录和管理个人信息的应用: 1

让我们开始吧。

创建应用

首先,我们使用Create React App来初始化一个名为 的应用程序memfire-react:

npx create-react-app memfire-react --use-npm
cd memfire-react

然后通过MemFire Cloud构建应用所需的后端服务:

  1. 登录cloud.memfiredb.com
  2. 进入我的应用
  3. 点击“创建应用”
  4. 输入应用信息
  5. 等待应用启动

2

现在我们的应用程序还未连接到MemFire Cloud上的后端服务,先在终端启动一下看看。

npm start

得到如下界面:

3

客户端连接

获取API秘钥

  1. 进入应用
  2. 在总览概况界面查看项目API秘钥信息

4

应用提供两个密钥。

  • anon:在浏览器环境下使用是安全的,通常只允许访问注册登录等接口。
  • service_role :只能在服务器上使用。这个密钥可以绕过行级安全。 注意:service_role secret可以绕过任何安全策略完全访问您的数据。这些密钥必须保密,并且要在服务器环境中使用,决不能在客户端或浏览器上使用。

设置环境变量

在应用程序根目录下新建.env文件,将环境变量保存在其中。环境变量包括API秘钥中的anon 秘钥和网址。

REACT_APP_SUPABASE_URL=YOUR_URL
REACT_APP_SUPABASE_ANON_KEY=YOUR_ANON_KEY

安装依赖

然后让我们安装唯一的附加依赖项:supabase-js

npm install @supabase/supabase-js

客户端连接

创建一个src/supabaseClient.js文件来初始化 Supabase 客户端。

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

接下来更新src/app.js文件,检验一下我们的客户端是否能够正常连接。

import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'

export default function Home() {
  const [session, setSession] = useState(null)

  useEffect(() => {
    setSession(supabase.auth.session())

    supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session)
    })
  }, [])
  return (
    <div className="container" style={{ padding: '50px 0 100px 0' }}>
      {!session ? "未登录" : "已登录"}
    </div>
  )
}

npm start启动应用。 5

可以看到,我们的应用程序通过客户端与MemFire Cloud的服务建立了连接,当前属于未登录状态。

用户登录

登录组件

让我们设置一个 React 组件src/Auth.js来管理登录和注册。我们将使用 Magic Links,因此用户可以使用他们的电子邮件登录,而无需使用密码。

import { useState } from 'react'
import { supabase } from './supabaseClient'

export default function Auth() {
  const [loading, setLoading] = useState(false)
  const [email, setEmail] = useState('')

  const handleLogin = async (email) => {
    try {
      setLoading(true)
      const { error } = await supabase.auth.signIn({ email })
      if (error) throw error
      alert('Check your email for the login link!')
    } catch (error) {
      alert(error.error_description || error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="row flex flex-center">
      <div className="col-6 form-widget">
        <h1 className="header">Supabase + React</h1>
        <p className="description">Sign in via magic link with your email below</p>
        <div>
          <input
            className="inputField"
            type="email"
            placeholder="Your email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <button
            onClick={(e) => {
              e.preventDefault()
              handleLogin(email)
            }}
            className={'button block'}
            disabled={loading}
          >
            {loading ? <span>Loading</span> : <span>Send magic link</span>}
          </button>
        </div>
      </div>
    </div>
  )
}

邮件模板

发送给用户的邮件内容都由邮件模板生成,MemFile Cloud目前支持:

  • 确认注册
  • 重置密码
  • 邀请用户
  • 魔术链接

登录组件使用了魔术链接,其模板生成的邮件将会发送到用户邮箱里。

当然,用户可以通过修改模板来自定义邮件内容。 我们尝试下修改魔术链接的模板,来看看是否生效。 6

需要注意的是,变更配置会重启应用服务。应用重启之后,配置才会生效。接下来执行重置密码操作。

认证设置

当用户点击邮件内魔术链接进行登录时,是需要跳转到我们应用的登录界面的。这里需要在认证设置中进行相关URL重定向的配置。

因为我们最终的应用会在本地的3000端口启动,所以这里我们暂时将url设置为http://localhost:3000/。后面我们会配合静态网站托管功能进行说明。

除此之外,在此界面也可以自定义使用我们自己的smtp服务器。 7

加载组件

更新src/app.js文件,使用我们的登录组件。

import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './Auth'

export default function Home() {
  const [session, setSession] = useState(null)

  useEffect(() => {
    setSession(supabase.auth.session())

    supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session)
    })
  }, [])
  return (
    <div className="container" style={{ padding: '50px 0 100px 0' }}>
      {!session ? <Auth /> : "已登录"}
    </div>
  )
}

npm start启动应用。 8

接下来让我们输入用户的邮箱,向用户发送魔术链接,使得用户能够通过魔术链接登录应用。 9

用户主页

接下来需要制作用户主页界面,用来显示并修改用户的信息。 用户信息包括用户名、头像,个人网址等。

数据库表

  1. 创建数据库表 我们通过数据库表界面新建数据表。 10

对应SQL语句类似于:

-- Create a table for public "profiles"
create table profiles (
  id uuid references auth.users not null,
  updated_at timestamp with time zone,
  username text unique,
  avatar_url text,
  website text,

  primary key (id),
  unique(username)
);
  1. 修改表权限 为了用户信息安全,我们希望用户能够看到所有用户的信息,但只能添加以及修改自己的信息。

MemFire Cloud提供了RLS(ROW Level Security,基于行的安全策略) ,限制数据库用户查看表数据权限。RLS的使用 详见文档

我们在表权限界面,启用RLS,通过创建权限策略,赋予profiles表所有用户可访问权限、以及只允许用户添加修改自身数据的权限。 11 可以看到,MemFire Cloud将一些常用的策略做成了模板:

  • 为所有用户启动访问权限
  • 仅允许身份验证用户启用访问权限
  • 根据电子邮箱为用户启用访问权限
  • 根据用户ID为用户启用访问权限
  • 自定义使用兼容PostgreSQL的策略规则 用户可根据实际项目的需要,灵活配置表权限。

  • 创建表索引 如有需要,同样可以为我们的数据库表创建表索引。 12

主页组件

让我们为个人主页创建一个新组件src/Account.js

import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'

export default function Account({ session }) {
  const [loading, setLoading] = useState(true)
  const [username, setUsername] = useState(null)
  const [website, setWebsite] = useState(null)
  const [avatar_url, setAvatarUrl] = useState(null)

  useEffect(() => {
    getProfile()
  }, [session])

  async function getProfile() {
    try {
      setLoading(true)
      const user = supabase.auth.user()

      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) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  async function updateProfile({ username, website, avatar_url }) {
    try {
      setLoading(true)
      const user = supabase.auth.user()

      const updates = {
        id: user.id,
        username,
        website,
        avatar_url,
        updated_at: new Date(),
      }

      let { error } = await supabase.from('profiles').upsert(updates, {
        returning: 'minimal', // Don't return the value after inserting
      })

      if (error) {
        throw error
      }
    } catch (error) {
      alert(error.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="form-widget">
      <div>
        <label htmlFor="email">Email</label>
        <input id="email" type="text" value={session.user.email} disabled />
      </div>
      <div>
        <label htmlFor="username">Name</label>
        <input
          id="username"
          type="text"
          value={username || ''}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div>
        <label htmlFor="website">Website</label>
        <input
          id="website"
          type="website"
          value={website || ''}
          onChange={(e) => setWebsite(e.target.value)}
        />
      </div>

      <div>
        <button
          className="button block primary"
          onClick={() => updateProfile({ username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? 'Loading ...' : 'Update'}
        </button>
      </div>

      <div>
        <button className="button block" onClick={() => supabase.auth.signOut()}>
          Sign Out
        </button>
      </div>
    </div>
  )
}

加载组件

更新src/app.js文件,使用我们的主页组件。

import { useState, useEffect } from 'react'
import { supabase } from './supabaseClient'
import Auth from './Auth'
import Account from './Account'

export default function Home() {
  const [session, setSession] = useState(null)

  useEffect(() => {
    setSession(supabase.auth.session())

    supabase.auth.onAuthStateChange((_event, session) => {
      setSession(session)
    })
  }, [])

  return (
    <div className="container" style={{ padding: '50px 0 100px 0' }}>
      {!session ? <Auth /> : <Account key={session.user.id} session={session} />}
    </div>
  )
}

npm start启动应用。 13

更新样式

可以看到界面实在是不怎么优雅,更新下样式,让它好看一些。 修改src/index.css文件。

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);
}

重新启动后,获得新界面。 14

上传头像

接下来我们希望用户能在自己的主页上传头像。

云存储

MemFire Cloud提供云存储功能,使得存储大文件变得简单。

Bucket是云存储中文件和文件夹的容器,可以将它理解成“超级文件夹”。

为了存储用户的头像,我们新建一个名为avatarsBucket。与数据库表类似,云存储同样做了访问权限限制。所以我们需要添加规则允许登录用户访问此Bucket15

上传组件

新建一个上传组件src/Avatar.js

import { useEffect, useState } from 'react'
import { supabase } from './supabaseClient'

export default function Avatar({ url, size, onUpload }) {
  const [avatarUrl, setAvatarUrl] = useState(null)
  const [uploading, setUploading] = useState(false)

  useEffect(() => {
    if (url) downloadImage(url)
  }, [url])

  async function downloadImage(path) {
    try {
      const { data, error } = await supabase.storage.from('avatars').download(path)
      if (error) {
        throw error
      }
      const url = URL.createObjectURL(data)
      setAvatarUrl(url)
    } catch (error) {
      console.log('Error downloading image: ', error.message)
    }
  }


  async function uploadAvatar(event) {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select an image to upload.')
      }

      const file = event.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
      }

      onUpload(filePath)
    } catch (error) {
      alert(error.message)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div>
      {avatarUrl ? (
        <img
          src={avatarUrl}
          alt="Avatar"
          className="avatar image"
          style={{ height: size, width: size }}
        />
      ) : (
        <div className="avatar no-image" style={{ height: size, width: size }} />
      )}
      <div style={{ width: size }}>
        <label className="button primary block" htmlFor="single">
          {uploading ? 'Uploading ...' : 'Upload'}
        </label>
        <input
          style={{
            visibility: 'hidden',
            position: 'absolute',
          }}
          type="file"
          id="single"
          accept="image/*"
          onChange={uploadAvatar}
          disabled={uploading}
        />
      </div>
    </div>
  )
}

加载组件

在主页组件中添加上传组件,以便我们能上传头像。 修改src/Account.js

// Import the new component
import Avatar from './Avatar'

// ...

return (
  <div className="form-widget">
    {/* Add to the body */}
    <Avatar
      url={avatar_url}
      size={150}
      onUpload={(url) => {
        setAvatarUrl(url)
        updateProfile({ username, website, avatar_url: url })
      }}
    />
    {/* ... */}
  </div>
)

启动应用。 16

更新主页

用户主页以及上传功能都已完成,接下来我们更新用户信息(包括其头像),查看是否成功。 17

用户的信息包括头像,都已成功存储到MemFire Cloud平台上。

到这步,我们这个简单的应用程序就已开发完毕了。

部署应用

静态网站托管

MemFire Cloud提供静态网站托管服务,实现一键将静态网站资源部署在云端。 18

应用打包

在终端将我们的应用打包。

npm run build

执行成功后,生成build目录,存放着打包后的代码。 进入目录,将index.html文件中静态文件的绝对路径改为相对路径。

cd build
sed -i -e "s/\/static/\.\/static/g" index.html

在build目录下继续打包

zip -r build.zip *

请注意:

  • index.html中静态文件路径必须为相对路径
  • 压缩包必须为zip格式包,且必须在目录内进行打包。

上传压缩包

build.zip上传至静态网站托管界面。 19

通过默认域名即可访问我们的应用! 19-1

认证设置

最后别忘了,用默认域名替换认证设置中的网址。 20

做的好!

我们已经拥有了一个能在互联网上允许用户登录和更新用户信息的应用程序。 回顾开发流程,我们只利用react开发了几个组件,就快速搭建了这样一个应用程序。 后端服务,诸如:

  • 数据库服务,不需要开发!
  • 用户认证服务,不需要开发!
  • 数据权限服务,不需要开发!
  • 云存储服务,不需要开发! 甚至通过静态网站托管能力,能够一键式部署应用,不需要再去使用新的服务器和域名。

这一切通过MemFire Cloud即可实现,可以说是丝滑般的开发体验。

results matching ""

    No results matching ""