Merge branch 'master' into fix/ahrefs-fixes

This commit is contained in:
Jonathan Summers-Muir committed 2022-06-17 00:02:22 +08:00
commit 327f7eff09
52 files changed
+874 -767

No files matched your search

+1
View File
@@ -1032,6 +1032,7 @@ h4.method-list-item-label {
h4.method-list-item-label .method-list-item-label-name {
font-family: var(--ifm-font-family-monospace);
font-size: 0.8rem;
margin-right: 4px;
padding: var(--ifm-code-padding-vertical) 0;
}
h4.method-list-item-label .method-list-item-label-badge {
+8 -8
View File
@@ -32,14 +32,14 @@ function walk(dir: string) {
const list = fs.readdirSync(dir)
list.forEach(function (file) {
file = dir + '/' + file
const stat = fs.statSync(file)
if (stat && stat.isDirectory()) {
/* Recurse into a subdirectory */
results = results.concat(walk(file))
} else {
/* Is a file */
results.push(file)
}
let slugs = []
fs.readdirSync(dir).forEach((file) => {
let absolute = join(dir, file)
if (fs.statSync(absolute).isDirectory()) {
fs.readdirSync(absolute).forEach((subFile) => slugs.push(file.concat('/' + subFile)))
}
})
})
return results
}
+14 -12
View File
@@ -28,7 +28,7 @@ const IncludedCheck = (props: any) => (
const Minus = (props: any) => (
<>
<svg
className="text-scale-1200 h-5 w-5 dark:text-white"
className="text-scale-600 h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@@ -71,27 +71,29 @@ export const PricingTableRowDesktop = (props: any) => {
return (
<tr className="divide-scale-600 dark:divide-scale-400 divide-x">
<th
className="text-scale-1200 flex items-center px-6 py-5 text-left text-sm font-normal dark:text-white"
className="text-scale-1200 flex items-center px-6 py-3 text-left text-sm font-normal dark:text-white"
scope="row"
>
<span>{feat.title} </span>
<span>{feat.title}</span>
{feat.tooltip && (
<span
className="hover:text-scale-800 ml-2 cursor-pointer dark:hover:text-white"
className="text-scale-900 hover:text-scale-1200 ml-2 cursor-pointer transition-colors"
data-tip={feat.tooltip}
>
<IconHelpCircle size="small" />
<IconHelpCircle size={14} strokeWidth={2} />
</span>
)}
</th>
{Object.values(feat.tiers).map((tier: any, i) => {
return (
<td key={i} className="px-6 py-5">
<td key={i} className="px-6">
{typeof tier === 'boolean' && tier === true ? (
<IncludedCheck tier={tier} />
) : typeof tier === 'boolean' && tier === false ? (
<Minus tier={tier} />
<div className="text-scale-900">
<Minus tier={tier} />
</div>
) : (
<span className="text-scale-1200 block text-sm dark:text-white">{tier}</span>
)}
@@ -113,7 +115,7 @@ export const PricingTableRowMobile = (props: any) => {
return (
<>
<table className="mt-8 w-full">
<caption className="bg-scale-50 dark:bg-dark-900 border-scale-200 dark:border-scale-600 text-scale-1200 border-t px-4 py-3 text-left text-sm font-medium dark:text-white">
<caption className="bg-scale-50 dark:bg-dark-900 border-scale-400 border-t px-4 py-3 text-left text-sm font-medium dark:text-white">
<div className="flex items-center gap-2">
{category.icon ? <ProductIcon icon={props.icon} /> : null}
<span className="text-scale-1200 font-normal">{category.title}</span>
@@ -129,16 +131,16 @@ export const PricingTableRowMobile = (props: any) => {
</th>
</tr>
</thead>
<tbody className="divide-scale-600 divide-y">
<tbody className="divide-scale-400 divide-y">
{category.features.map((feat: any, i: number) => {
return (
<tr key={i} className="border-t">
<th className="text-scale-1100 px-4 py-5 text-left text-sm font-normal" scope="row">
<tr key={i} className="border-scale-400 border-t">
<th className="text-scale-1100 px-4 py-3 text-left text-sm font-normal" scope="row">
<span>
<p>{feat.title}</p>
</span>
</th>
<td className="py-5 pr-4 text-right">
<td className="py-3 pr-4 text-right">
{typeof feat.tiers[tier] === 'boolean' && feat.tiers[tier] === true ? (
<div className="inline-block">
<IncludedCheck tier={tier} />
+43 -9
View File
@@ -45,9 +45,17 @@
"enterprise": "30 days Point in Time backup"
}
},
{
"title": "Point in time recovery",
"tiers": {
"free": false,
"pro": false,
"enterprise": true
}
},
{
"title": "Pausing",
"tooltip": "Unused projects that have no activity or API requests will be shut down. They can be reactivated by logging into the dashboard",
"tooltip": "Projects that have no activity or API requests will be paused. They can be reactivated via the dashboard",
"tiers": {
"free": "Paused after 1 week inactivity",
"pro": "Never",
@@ -55,11 +63,11 @@
}
},
{
"title": "Transfer limits",
"title": "Database egress",
"tooltip": "Good news, only download operations are counted towards the limit",
"tiers": {
"free": "Up to 2GB",
"pro": "50GB included, then $0.09 per GB",
"pro": "50GB included per month, then $0.09 per GB",
"enterprise": "Unlimited"
}
}
@@ -78,6 +86,15 @@
"enterprise": "Unlimited"
}
},
{
"title": "MAUs",
"tooltip": "Monthly Active Users; a user that has made an API request in the last month.",
"tiers": {
"free": "Up to 50,000 MAUs",
"pro": "100,000 included, then $0.00325 per MAU",
"enterprise": "1,000,000 included, then $0.00325 per MAU"
}
},
{
"title": "Social OAuth providers",
"tiers": {
@@ -150,7 +167,7 @@
}
},
{
"title": "Transfer limits",
"title": "Storage egress",
"tooltip": "Good news, only download operations are counted towards the limit",
"tiers": {
"free": "Up to 2 GB",
@@ -168,6 +185,7 @@
},
{
"title": "Max file upload size",
"tooltip": "You can change the upload size in the dashboard",
"tiers": {
"free": "Up to 50MB",
"pro": "Up to 5GB",
@@ -183,16 +201,16 @@
{
"title": "Runtime hours",
"tiers": {
"free": "100 hours",
"pro": "1,000 hours",
"free": "100 hours per month",
"pro": "1,000 hours per month",
"enterprise": "Unlimited"
}
},
{
"title": "Invocations",
"tiers": {
"free": "500K",
"pro": "2 Million",
"free": "500K per month",
"pro": "2 Million per month",
"enterprise": "Unlimited"
}
},
@@ -308,7 +326,7 @@
"title": "Metrics endpoint",
"tiers": {
"free": false,
"pro": false,
"pro": true,
"enterprise": true
}
},
@@ -343,6 +361,22 @@
"pro": false,
"enterprise": true
}
},
{
"title": "Designated customer success engineer ",
"tiers": {
"free": false,
"pro": false,
"enterprise": true
}
},
{
"title": "Security Questionnaire Help",
"tiers": {
"free": false,
"pro": false,
"enterprise": true
}
}
]
}
+83 -7
View File
@@ -37,12 +37,12 @@
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "10 (recommended)"
"value": "30 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "50 (recommended)"
"value": "75 (recommended)"
}
]
},
@@ -61,7 +61,7 @@
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "500 (recommended)"
"value": "150 (recommended)"
}
]
},
@@ -80,7 +80,7 @@
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "1000 (recommended)"
"value": "300 (recommended)"
}
]
},
@@ -99,7 +99,7 @@
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "2000 (recommended)"
"value": "600 (recommended)"
}
]
},
@@ -113,12 +113,88 @@
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "400 (recommended)"
"value": "350 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "4000 (recommended)"
"value": "1200 (recommended)"
}
]
},
{
"columns": [
{ "key": "plan", "title": "Plan", "value": "4XL" },
{ "key": "pricing", "title": "Pricing", "value": "$950" },
{ "key": "cpu", "title": "CPU", "value": "16-core ARM (dedicated)" },
{ "key": "memory", "title": "Memory", "value": "64 GB" },
{ "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 4,750 Mbps" },
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "420 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "2800 (recommended)"
}
]
},
{
"columns": [
{ "key": "plan", "title": "Plan", "value": "8XL" },
{ "key": "pricing", "title": "Pricing", "value": "$1,860" },
{ "key": "cpu", "title": "CPU", "value": "32-core ARM (dedicated)" },
{ "key": "memory", "title": "Memory", "value": "128 GB" },
{ "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 9,000 Mbps" },
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "450 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "5600 (recommended)"
}
]
},
{
"columns": [
{ "key": "plan", "title": "Plan", "value": "12XL" },
{ "key": "pricing", "title": "Pricing", "value": "$2,790" },
{ "key": "cpu", "title": "CPU", "value": "48-core ARM (dedicated)" },
{ "key": "memory", "title": "Memory", "value": "192 GB" },
{ "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 13,500 Mbps" },
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "480 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "8600 (recommended)"
}
]
},
{
"columns": [
{ "key": "plan", "title": "Plan", "value": "16XL" },
{ "key": "pricing", "title": "Pricing", "value": "$3,720" },
{ "key": "cpu", "title": "CPU", "value": "64-core ARM (dedicated)" },
{ "key": "memory", "title": "Memory", "value": "256 GB" },
{ "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 19,000 Mbps" },
{
"key": "directConnections",
"title": "Connections: Direct",
"value": "500 (recommended)"
},
{
"key": "poolerConnections",
"title": "Connections: Pooler",
"value": "11,600 (recommended)"
}
]
},
@@ -29,6 +29,30 @@ const components = {
return <CodeBlock {...props} />
},
ImageGrid,
img: (props: any) => {
const classes = [
'next-image--dynamic-fill',
'from-brand-500 to-brand-500',
'rounded border bg-gradient-to-r via-blue-500',
]
return (
<div
className="
next-image--dynamic-fill
to-scale-400
from-scale-500 rounded-md
border bg-gradient-to-r
"
>
<Image
{...props}
className="next-image--dynamic-fill to-brand-1000 from-brand-900 rounded-md border bg-gradient-to-r"
layout="fill"
/>
</div>
)
},
}
// plugins for next-mdx-remote
+1 -1
View File
@@ -53,7 +53,7 @@ function Database() {
openGraph={{
title: meta_title,
description: meta_description,
url: `https://supabase.com/auth`,
url: `https://supabase.com/database`,
images: [
{
url: `https://supabase.com${basePath}/images/product/database/database-og.jpg`,
View File
Whitespace-only changes.
+40
View File
@@ -0,0 +1,40 @@
import { NextSeo } from 'next-seo'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
// NOTE: this is a placeholder for our future Next.js marketing page. For now it redirects to our Next.js guide.
function Nextjs() {
// base path for images
const router = useRouter()
const { basePath } = router
useEffect(() => {
router.push('/docs/guides/with-nextjs')
}, [])
const meta_title = 'Next.js Database with Auth, Realtime, Fiel Storage, and more.'
const meta_description =
'Supabase is the fastest way to build performant Next.js apps with Authentication & User Management, Realtime, File Storage on top of PostgreSQL Databases.'
return (
<>
<NextSeo
title={meta_title}
description={meta_description}
openGraph={{
title: meta_title,
description: meta_description,
url: `https://supabase.com/nextjs`,
images: [
{
url: `https://supabase.com${basePath}/images/product/database/database-og.jpg`,
},
],
}}
/>
</>
)
}
export default Nextjs
+3
View File
@@ -0,0 +1,3 @@
import Nextjs from './Nextjs'
export default Nextjs
+22
View File
@@ -1,5 +1,27 @@
Pricing Changelog
### 16 June 2022
----
Auth
- New item “MAUs (Monthly Active Users)”
Security & Compliance
- New item: “Security questionnaire help” for enterprise
- New item - “Metrics Endpoint” for enterprise and pro tier users
Database Compute Add ons
- New item: “4XL”
- New item: “8XL”
- New item: “12XL”
- New item: “16XL”
Database
- New item: “Point in time recovery” for enterprise
Clarifications
- General - Clearer language that we charge on a per project per month
- Storage - Uploads for Pro, changed from 3GB → 5GB in title card
- Storage - file upload size - clarified that it can be changed in dashboard
- All products - “Transfer” renamed to “Egress”
- Database compute add-ons - Number of connections more accurate
- Storage - Free tier egress per month clarified
### 31 March 2022
----
+45 -38
View File
@@ -30,6 +30,7 @@ export default function IndexPage() {
const tiers = [
{
name: 'Free',
costUnit: '/month per project',
href: 'https://app.supabase.com/new/new-project',
priceMonthly: 0,
warning: 'Limit of 2 free projects',
@@ -39,6 +40,7 @@ export default function IndexPage() {
'Up to 2GB bandwidth',
'Up to 50MB file uploads',
'Social OAuth providers',
'50,000 monthly active users',
'Up to 500K Edge Function invocations',
'1-day log retention',
'Community support',
@@ -49,16 +51,17 @@ export default function IndexPage() {
},
{
name: 'Pro',
costUnit: '/month per project',
href: 'https://app.supabase.com/new/new-project',
from: true,
priceMonthly: 25,
warning: 'per project',
description: 'For production applications with the option to scale.',
features: [
'8GB database & 100GB file storage',
'50GB bandwidth',
'3GB file uploads',
'5GB file uploads',
'Social OAuth providers',
'100,000 monthly active users',
'2M Edge Function invocations',
'Daily backups',
'7-day log retention',
@@ -181,21 +184,16 @@ export default function IndexPage() {
border
"
>
<div className="dark:bg-scale-300 h-48 bg-white px-8 pt-6">
<h3
className="
text-scale-1200
mb-2
inline-flex
rounded-full
text-sm
font-normal
tracking-wide
lg:text-xl"
id="tier-standard"
>
{tier.name}
</h3>
<div className="dark:bg-scale-300 h-[220px] bg-white px-8 pt-6">
<div className="mb-2 flex items-center gap-2">
<h3 className="text-scale-1200 text-xl font-medium">{tier.name}</h3>
{tier.warning && (
<div className="bg-scale-400 text-scale-1100 rounded-md bg-opacity-30 py-0.5 px-2 text-xs">
{tier.warning}
</div>
)}
</div>
<p className="text-scale-1100 my-4 h-[40px] text-sm">{tier.description}</p>
<div
className="
text-scale-1200 flex items-baseline
@@ -207,44 +205,47 @@ export default function IndexPage() {
"
>
{tier.priceMonthly !== undefined ? (
<div className="flex items-end gap-2">
{tier.from && (
<span className="text-base font-medium xl:text-xl">From</span>
)}
<div>
<span>${tier.priceMonthly}</span>
<span className="text-scale-900 ml-1 text-2xl font-medium">/mo</span>
</div>
{tier.warning && (
<div className="bg-brand-300 text-brand-1000 mt-2 rounded-md bg-opacity-30 px-2 py-1 text-xs">
{tier.warning}
<div className="flex flex-col gap-1">
<div className="flex items-end gap-2">
<div>
<span>
{typeof tier.priceMonthly !== 'string' && '$'}
{tier.priceMonthly}
</span>
<span className="text-scale-900 ml-1 text-xl font-normal">
{tier.costUnit}
</span>
</div>
</div>
{tier.from && (
<span className="text-scale-900 block text-sm">
+ any additional usage
</span>
)}
</div>
) : (
<span>Contact Us</span>
)}
</div>
<p className="text-scale-1100 mt-4 text-base">{tier.description}</p>
</div>
<div
className="
dark:border-scale-400 bg-scale-100 dark:bg-scale-300 flex
dark:border-scale-400 dark:bg-scale-300 flex h-full
h-full
flex-1
flex-col
justify-between
space-y-6
space-y-6
border-t
bg-white
px-8
py-6
"
>
{tier.preface && <p className="text-scale-1200 text-base">{tier.preface}</p>}
{tier.preface && <p className="text-scale-1100 text-sm">{tier.preface}</p>}
{/* <p className="text-scale-900 text-sm">Included with plan:</p> */}
<ul role="list" className="dark:divide-scale-400 divide-y">
{tier.features.map((feature) => (
@@ -645,16 +646,22 @@ export default function IndexPage() {
<div className="mt-16">
{/* @ts-ignore */}
<Accordion
type="bordered"
type="default"
openBehaviour="multiple"
size="medium"
className="text-scale-900 dark:text-white"
>
{pricingFaq.map((faq, i) => {
return (
<Accordion.Item key={i} header={faq.question} id={`faq--${i.toString()}`}>
<ReactMarkdown className="text-scale-1100">{faq.answer}</ReactMarkdown>
</Accordion.Item>
<div className="border-b pb-3">
<Accordion.Item
key={i}
header={<span className="text-scale-1200">{faq.question}</span>}
id={`faq--${i.toString()}`}
>
<ReactMarkdown className="text-scale-900">{faq.answer}</ReactMarkdown>
</Accordion.Item>
</div>
)
})}
</Accordion>
+19
View File
@@ -223,3 +223,22 @@ table {
.sbui-typography a {
@apply break-words;
}
/*
* sets the image in @Next/Image components
* to respect the height of the content
*
*/
.next-image--dynamic-fill {
width: 100%;
grid-column: 1 / -1;
}
.next-image--dynamic-fill > span {
position: relative !important;
}
.next-image--dynamic-fill img {
object-fit: contain;
width: 100% !important;
position: relative !important;
height: unset !important;
}
@@ -9,12 +9,14 @@ export default function IndexPage() {
<div className="w-full h-full bg-gray-300">
{!user ? (
<div className="w-full h-full flex justify-center items-center p-4">
<Auth
supabaseClient={supabase}
providers={['google', 'github']}
socialLayout="horizontal"
socialButtonSize="xlarge"
/>
<div>
<Auth
supabaseClient={supabase}
providers={['google', 'github']}
socialLayout="horizontal"
socialButtonSize="xlarge"
/>
</div>
</div>
) : (
<div
@@ -31,7 +31,9 @@ const PolicyEditor: FC<Props> = ({
const operation = policyFormFields?.command ?? ''
const definition = policyFormFields?.definition ?? ''
const check = policyFormFields?.check ?? ''
const selectedRoles = policyFormFields?.roles ?? []
// Filter out default public role (public if no roles selected)
const selectedRoles = (policyFormFields?.roles ?? []).filter((role: string) => role !== 'public')
return (
<div className="">
@@ -1,28 +0,0 @@
import { FC } from 'react'
import { Button, Typography } from '@supabase/ui'
interface Props {
organization: any
}
const AWSMarketplaceSubscription: FC<Props> = ({ organization }) => {
return (
<div className="flex flex-col space-y-4">
<Typography.Text>
This organization is subscribed via AWS Marketplace. The subscription expires on{' '}
{organization.aws_marketplace.Entitlements[0].ExpirationDate.slice(0, 10)}.
</Typography.Text>
<Button>
<a
href="https://aws.amazon.com/marketplace/saas/ordering?productId=dc498450-cecf-44c2-8c99-c3c13f16e70a&offerId=d1htt16t3ygx5brtwhooms0ei"
target="_blank"
>
Manage AWS Marketplace subscription
</a>
</Button>
</div>
)
}
export default AWSMarketplaceSubscription
@@ -4,7 +4,6 @@ import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { get } from 'lib/common/fetch'
import AWSMarketplaceSubscription from './AWSMarketplaceSubscription'
import ProjectsSummary from './ProjectsSummary'
import CreditBalance from './CreditBalance'
import PaymentMethods from './PaymentMethods'
@@ -94,33 +93,27 @@ const BillingSettings: FC<Props> = ({ organization, projects = [] }) => {
return (
<article className="container my-4 max-w-4xl space-y-8">
{organization.aws_marketplace ? (
<AWSMarketplaceSubscription organization={organization} />
) : (
<>
<div className="space-y-8">
<ProjectsSummary projects={projects} />
<CreditBalance balance={balance} isCredit={isCredit} isDebt={isDebt} />
<PaymentMethods
loading={isLoadingCustomer || isLoadingPaymentMethods}
defaultPaymentMethod={defaultPaymentMethod}
paymentMethods={paymentMethods || []}
onDefaultMethodUpdated={setCustomer}
onPaymentMethodsDeleted={() => getPaymentMethods()}
/>
<BillingAddress
loading={isLoadingCustomer}
address={customer?.address ?? {}}
onAddressUpdated={(address: any) => setCustomer({ ...customer, address })}
/>
<TaxID
loading={isLoadingTaxIds}
taxIds={taxIds || []}
onTaxIdsUpdated={(ids: any) => setTaxIds(ids)}
/>
</div>
</>
)}
<div className="space-y-8">
<ProjectsSummary projects={projects} />
<CreditBalance balance={balance} isCredit={isCredit} isDebt={isDebt} />
<PaymentMethods
loading={isLoadingCustomer || isLoadingPaymentMethods}
defaultPaymentMethod={defaultPaymentMethod}
paymentMethods={paymentMethods || []}
onDefaultMethodUpdated={setCustomer}
onPaymentMethodsDeleted={() => getPaymentMethods()}
/>
<BillingAddress
loading={isLoadingCustomer}
address={customer?.address ?? {}}
onAddressUpdated={(address: any) => setCustomer({ ...customer, address })}
/>
<TaxID
loading={isLoadingTaxIds}
taxIds={taxIds || []}
onTaxIdsUpdated={(ids: any) => setTaxIds(ids)}
/>
</div>
</article>
)
}
@@ -5,9 +5,6 @@ import {
ExtensionsUpgrade,
Notification,
NotificationName,
PostgresqlUpgradeData,
ProjectExceedingTierLimitData,
ProjectUpdateData,
ServerUpgrade,
ServiceUpgrade,
ViolatedLimit,
@@ -15,17 +12,16 @@ import {
export const formatNotificationText = (project: Project, notification: Notification) => {
const projectName = project.name
const notificationName = notification.notification_name
if (notificationName === NotificationName.ProjectExceedingTierLimit) {
const { violations } = notification.data as ProjectExceedingTierLimitData
if (notification.data.name === NotificationName.ProjectExceedingTierLimit) {
const { violations } = notification.data
const violationsText = violations
.map((violation: ViolatedLimit) => violation.dimension as string)
.reduce((a: string, b: string) => `${a}, ${b}`)
return `Your project "${projectName}" has exceeded its limits in the following areas: ${violationsText}.`
} else if (notificationName === NotificationName.PostgresqlUpgradeAvailable) {
const { upgrade_type, additional } = notification.data as PostgresqlUpgradeData
} else if (notification.data.name === NotificationName.PostgresqlUpgradeAvailable) {
const { upgrade_type, additional } = notification.data
if (upgrade_type === 'postgresql-server') {
const { version_to } = additional as ServerUpgrade
@@ -35,24 +31,26 @@ export const formatNotificationText = (project: Project, notification: Notificat
return `New version of "${name}" (${version_to}) is now available for project "${projectName}".`
}
return ''
} else if (notificationName === NotificationName.PostgresqlUpgradeCompleted) {
const { upgrade_type, additional } = notification.data as PostgresqlUpgradeData
} else if (notification.data.name === NotificationName.PostgresqlUpgradeCompleted) {
const { upgrade_type, additional } = notification.data
if (upgrade_type === 'postgresql-server') {
const { version_to } = additional as ServerUpgrade
return `Postgres (${version_to}) has been successfully updated to ${version_to} for project "${projectName}".`
} else if (upgrade_type === 'extensions') {
const { name, version_to } = additional as ExtensionsUpgrade
return `The extension "${name}" has been successfully updated to ${version_to} for project "${projectName}`
return `The extension "${name}" has been successfully updated to ${version_to} for project "${projectName}".`
}
} else if (notificationName === 'project.update-completed') {
const { upgrades } = notification.data as ProjectUpdateData
} else if (notification.data.name === NotificationName.ProjectUpdateCompleted) {
const { upgrades } = notification.data
const upgradesText = upgrades
.map((upgrade: ServiceUpgrade) => upgrade.name)
.reduce((a: string, b: string) => `${a}, ${b}`)
return `The following services have been successfully updated for project "${projectName}": ${upgradesText}`
return `The following services have been successfully updated for project "${projectName}": ${upgradesText}.`
} else if (notification.data.name === NotificationName.ProjectInformational) {
return notification.data.message
} else {
return `Unknown notification type: ${notification.notification_name} - Please reach out to support for more information`
return `Unknown notification type: ${notification.notification_name} - Please reach out to support for more information.`
}
}
@@ -65,6 +63,8 @@ export const formatNotificationCTAText = (availableActions: Action[]) => {
return 'Restart your project to get the latest updates.'
case ActionType.UpgradeProjectToPro:
return 'Upgrade your project to ensure continued availability.'
case ActionType.PgBouncerRestart:
return 'Restart your connection pooler to get the latest updates.'
default:
return ''
}
@@ -1,7 +1,7 @@
import { FC, Fragment, useState } from 'react'
import { useRouter } from 'next/router'
import { Button, IconBell, Popover } from '@supabase/ui'
import { Notification, NotificationStatus } from '@supabase/shared-types/out/notifications'
import { Notification, NotificationStatus, ActionType } from '@supabase/shared-types/out/notifications'
import { Project } from 'types'
import { useNotifications, useStore } from 'hooks'
@@ -44,12 +44,21 @@ const NotificationsPopover: FC<Props> = () => {
if (!projectToRestart || !targetNotification) return
const { id } = targetNotification
const { id: projectId, ref, region } = projectToRestart
const serviceNamesByActionName: Record<string, string> = {
[ActionType.PgBouncerRestart]: 'pgbouncer',
[ActionType.SchedulePostgresRestart]: 'postgresql'
}
const services: string[] = targetNotification.meta.actions_available
.map((action) => action.action_type)
.filter((actionName) => Object.keys(serviceNamesByActionName).indexOf(actionName) !== -1)
.map((actionName) => serviceNamesByActionName[actionName])
const { error } = await post(`${API_URL}/projects/${ref}/restart-services`, {
restartRequest: {
region,
source_notification_id: id,
services: ['postgresql'],
services: services,
},
})
@@ -61,7 +70,7 @@ const NotificationsPopover: FC<Props> = () => {
})
} else {
app.onProjectPostgrestStatusUpdated(projectId, 'OFFLINE')
ui.setNotification({ category: 'success', message: `Restarting project` })
ui.setNotification({ category: 'success', message: `Restarting services` })
router.push(`/project/${ref}`)
}
@@ -1,8 +1,9 @@
import { FC, ReactNode, useEffect } from 'react'
import { observer } from 'mobx-react-lite'
import { useStore } from 'hooks'
import { useStore, withAuth } from 'hooks'
import Error from 'components/ui/Error'
import { useSqlEditorStore, SqlEditorContext } from 'localStores/sqlEditor/SqlEditorStore'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
import SQLEditorMenu from './SQLEditorMenu'
@@ -12,26 +13,36 @@ interface Props {
}
const SQLEditorLayout: FC<Props> = ({ title, children }) => {
const { content } = useStore()
const { ui, content, meta } = useStore()
const { profile: user } = ui
const sqlEditorStore = useSqlEditorStore(ui.selectedProject?.ref, meta)
useEffect(() => {
content.load()
}, [])
if (sqlEditorStore) {
// this calls content.load() for us, as well as loading tabs
sqlEditorStore.loadRemotePersistentData(content, user?.id)
}
}, [sqlEditorStore])
return content.error ? (
<ProjectLayout>
<Error error={content.error} />
</ProjectLayout>
) : (
<ProjectLayout
isLoading={content.isLoading}
title={title || 'SQL'}
product="SQL Editor"
productMenu={<SQLEditorMenu />}
>
{children}
</ProjectLayout>
return (
<SqlEditorContext.Provider value={sqlEditorStore}>
{content.error ? (
<ProjectLayout>
<Error error={content.error} />
</ProjectLayout>
) : (
<ProjectLayout
isLoading={content.isLoading || sqlEditorStore === null}
title={title || 'SQL'}
product="SQL Editor"
productMenu={<SQLEditorMenu />}
>
{children}
</ProjectLayout>
)}
</SqlEditorContext.Provider>
)
}
export default observer(SQLEditorLayout)
export default withAuth(observer(SQLEditorLayout))
@@ -1,5 +1,4 @@
import { useState } from 'react'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import {
Button,
@@ -12,19 +11,16 @@ import {
IconLoader,
IconTrash,
IconChevronDown,
Loading,
Modal,
IconEdit2,
} from '@supabase/ui'
import { IS_PLATFORM } from 'lib/constants'
import { useStore } from 'hooks'
import { useProjectContentStore } from 'stores/projectContentStore'
import { useOptimisticSqlSnippetCreate, useStore } from 'hooks'
import QueryTab from 'localStores/sqlEditor/QueryTab'
import { useSqlStore, TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore'
import RenameQuery from 'components/to-be-cleaned/SqlEditor/RenameQuery'
import { createSqlSnippet } from 'components/to-be-cleaned/SqlEditor/SqlEditor.utils'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
@@ -46,11 +42,12 @@ const OpenQueryItem = observer(({ tabInfo }: { tabInfo: QueryTab }) => {
})
const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => {
const router = useRouter()
const { ref } = router.query
const {
ui: { profile: user },
content: contentStore,
} = useStore()
const sqlEditorStore: any = useSqlStore()
const contentStore: any = useProjectContentStore(ref)
const [tabId, setTabId] = useState('')
const [renameModalOpen, setRenameModalOpen] = useState(false)
@@ -109,9 +106,14 @@ const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => {
buttonLabel="Confirm"
visible={deleteModalOpen}
onSelectConfirm={async () => {
await contentStore.del(id)
await contentStore.load()
sqlEditorStore.closeTab(id)
await contentStore.del(id)
sqlEditorStore.loadTabs(
sqlEditorStore.tabsFromContentStore(contentStore, user?.id),
false
)
}}
onSelectCancel={() => setDeleteModalOpen(false)}
>
@@ -124,39 +126,11 @@ const DropdownMenu = observer(({ tabInfo }: { tabInfo: QueryTab }) => {
})
const SideBarContent = observer(() => {
const router = useRouter()
const { ref } = router.query
const { ui } = useStore()
const { profile: user } = ui
const sqlEditorStore: any = useSqlStore()
const contentStore: any = useProjectContentStore(ref)
const [filterString, setFilterString] = useState('')
const [loadingNewQuery, setLoadingNewQuery] = useState(false)
async function handleNewQuery() {
try {
setLoadingNewQuery(true)
// create new sql snippet, this also reloads the project_content store
// @ts-ignore
const snippet = await createSqlSnippet({ router })
// reload the local sqlEditorStore
await sqlEditorStore.loadRemotePersistentData(contentStore, user?.id)
// select tab with new snippet
if (snippet) sqlEditorStore.selectTab(snippet.id)
setLoadingNewQuery(false)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to create new query: ${error.message}`,
})
setLoadingNewQuery(false)
}
}
const handleNewQuery = useOptimisticSqlSnippetCreate()
const getStartedTabs = (sqlEditorStore?.tabs ?? []).filter((tab: any) => {
return tab.type === TAB_TYPES.WELCOME
@@ -191,7 +165,6 @@ const SideBarContent = observer(() => {
type="default"
style={{ justifyContent: 'start' }}
onClick={() => handleNewQuery()}
loading={loadingNewQuery}
>
New query
</Button>
@@ -237,37 +210,35 @@ const SideBarContent = observer(() => {
})}
</div>
)}
<Loading active={contentStore.isInitialized}>
<div className="space-y-6 px-3">
{favouriteTabs.length >= 1 && (
<div className="editor-product-menu">
<Menu.Group title="Favorites" />
<div className="space-y-1">
{favouriteTabs.map((tabInfo: any) => {
const { id } = tabInfo || {}
return <OpenQueryItem key={id} tabInfo={tabInfo} />
})}
</div>
<div className="space-y-6 px-3">
{favouriteTabs.length >= 1 && (
<div className="editor-product-menu">
<Menu.Group title="Favorites" />
<div className="space-y-1">
{favouriteTabs.map((tabInfo: any) => {
const { id } = tabInfo || {}
return <OpenQueryItem key={id} tabInfo={tabInfo} />
})}
</div>
)}
{queryTabs.length >= 1 && (
<div className="editor-product-menu">
<Menu.Group title="SQL snippets" />
<div className="space-y-1">
{queryTabs.map((tabInfo: any) => {
const { id } = tabInfo || {}
return <OpenQueryItem key={id} tabInfo={tabInfo} />
})}
</div>
</div>
)}
{queryTabs.length >= 1 && (
<div className="editor-product-menu">
<Menu.Group title="SQL snippets" />
<div className="space-y-1">
{queryTabs.map((tabInfo: any) => {
const { id } = tabInfo || {}
return <OpenQueryItem key={id} tabInfo={tabInfo} />
})}
</div>
)}
{filterString.length > 0 && favouriteTabs.length === 0 && queryTabs.length === 0 && (
<div className="px-4">
<p className="text-sm">No queries found</p>
</div>
)}
</div>
</Loading>
</div>
)}
{filterString.length > 0 && favouriteTabs.length === 0 && queryTabs.length === 0 && (
<div className="px-4">
<p className="text-sm">No queries found</p>
</div>
)}
</div>
</div>
)}
</Menu>
@@ -1,19 +1,13 @@
import React from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { useStore } from 'hooks'
import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore'
import { useProjectContentStore } from 'stores/projectContentStore'
import { Button, Form, Input, Modal } from '@supabase/ui'
const RenameQuery = observer(({ tabId, onComplete, visible, onCancel }) => {
const { ui } = useStore()
const { ui, content: contentStore } = useStore()
const router = useRouter()
const { ref } = router.query
const contentStore = useProjectContentStore(ref)
const sqlEditorStore = useSqlStore()
const model = prepareModel()
@@ -27,10 +21,6 @@ const RenameQuery = observer(({ tabId, onComplete, visible, onCancel }) => {
async function onRename(model) {
try {
/*
* old localStorage save
*/
sqlEditorStore.renameQuery(tabId, model)
/*
* new main db save
*/
@@ -38,7 +28,12 @@ const RenameQuery = observer(({ tabId, onComplete, visible, onCancel }) => {
name: model.name,
description: model.desc,
})
await contentStore.load()
/*
* old localStorage save
*/
sqlEditorStore.renameQuery(tabId, model)
if (onComplete) onComplete()
return Promise.resolve()
} catch (error) {
@@ -1,8 +1,8 @@
import { UserContent } from 'types'
import { SqlSnippets, UserContent } from 'types'
export const SQL_SNIPPET_SCHEMA_VERSION = '1.0'
export const NEW_SQL_SNIPPET_SKELETON: UserContent = {
export const NEW_SQL_SNIPPET_SKELETON: UserContent<SqlSnippets.Content> = {
name: 'New Query',
description: '',
type: 'sql',
@@ -1,53 +1,23 @@
import { UserContent } from 'types'
import { useProjectContentStore } from 'stores/projectContentStore'
import { SqlSnippets, UserContent } from 'types'
import { NEW_SQL_SNIPPET_SKELETON } from './SqlEditor.constants'
/* Creates a new SQL snippet with a basic skeleton */
export const createSqlSnippet = async ({
router,
sql,
export const createSqlSnippetSkeleton = ({
name,
sql,
owner_id,
}: {
router: any
sql: any
name: any
}) => {
const { ref } = router.query
const contentStore = useProjectContentStore(ref)
const skeleton: UserContent = {
name?: string
sql?: string
owner_id?: number
} = {}): UserContent<SqlSnippets.Content> => {
return {
...NEW_SQL_SNIPPET_SKELETON,
...(name && { name }),
...(owner_id && { owner_id }),
content: {
...NEW_SQL_SNIPPET_SKELETON.content,
content_id: '',
sql: sql,
sql: sql ?? '',
},
}
const { data: sqlSnippet, error } = await contentStore.create(skeleton)
if (error) throw error
// reload store data
await contentStore.load()
return sqlSnippet[0]
}
/*
* deleteSqlSnippet()
*
* Deletes a SQL snippet
*
*/
export const deleteSqlSnippet = async ({ router, id }: { router: any; id: any }) => {
const { ref } = router.query
const contentStore = useProjectContentStore(ref)
const { data: delReport, error } = await contentStore.del(id)
if (error) throw error
// reload store data
await contentStore.load()
return delReport
}
@@ -1,32 +1,34 @@
import React, { useEffect, useRef, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import DataGrid from '@supabase/react-data-grid'
import Split from 'react-split'
import Editor from '@monaco-editor/react'
import DataGrid from '@supabase/react-data-grid'
import { toJS } from 'mobx'
import { CSVLink } from 'react-csv'
import { debounce } from 'lodash'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import {
Button,
IconHeart,
Dropdown,
IconAlertCircle,
IconCheck,
IconChevronDown,
IconChevronUp,
IconHeart,
IconLoader,
IconRefreshCcw,
Typography,
Dropdown,
} from '@supabase/ui'
import Editor from '@monaco-editor/react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { copyToClipboard, timeout } from 'lib/helpers'
import { useKeyboardShortcuts, useStore, useWindowDimensions, usePrevious } from 'hooks'
import Telemetry from 'lib/telemetry'
import { IS_PLATFORM } from 'lib/constants'
import { copyToClipboard, timeout } from 'lib/helpers'
import { useSqlStore, UTILITY_TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore'
import { useProjectContentStore } from 'stores/projectContentStore'
import { SQL_SNIPPET_SCHEMA_VERSION } from './SqlEditor.constants'
import Telemetry from 'lib/telemetry'
import { useStore } from 'hooks'
import { debounce } from 'lodash'
import { useWindowDimensions, useKeyboardShortcuts } from 'hooks'
const TabSqlQuery = observer(() => {
const { content: contentStore } = useStore()
const sqlEditorStore = useSqlStore()
const { height: screenHeight } = useWindowDimensions()
const snapOffset = 50
@@ -49,8 +51,23 @@ const TabSqlQuery = observer(() => {
sqlEditorStore.activeTab.setSplitSizes(sizes)
}
async function updateSqlSnippet(value) {
if (sqlEditorStore.activeTab) {
await contentStore.updateSql(sqlEditorStore.activeTab.id, {
content: {
schema_version: SQL_SNIPPET_SCHEMA_VERSION,
content_id: sqlEditorStore.activeTab.id,
sql: value,
favorite: sqlEditorStore.activeTab.favorite,
},
})
} else {
console.warn('No active tab found while updating SQL snippet')
}
}
return (
<div className="flex flex-col h-full">
<div className="flex h-full flex-col">
<Split
style={{ height: '100%' }}
direction="vertical"
@@ -62,9 +79,12 @@ const TabSqlQuery = observer(() => {
collapsed={sqlEditorStore.activeTab.utilityTabHeight == 0 ? 1 : undefined}
onDragEnd={onDragEnd}
>
<MonacoEditor error={sqlEditorStore.activeTab.sqlQueryError} />
<MonacoEditor
error={sqlEditorStore.activeTab.sqlQueryError}
updateSqlSnippet={updateSqlSnippet}
/>
<div>
<UtilityPanel />
<UtilityPanel updateSqlSnippet={updateSqlSnippet} />
</div>
</Split>
</div>
@@ -72,12 +92,7 @@ const TabSqlQuery = observer(() => {
})
export default TabSqlQuery
const MonacoEditor = ({ error }) => {
const router = useRouter()
const { ref } = router.query
const contentStore = useProjectContentStore(ref)
const MonacoEditor = ({ error, updateSqlSnippet }) => {
const sqlEditorStore = useSqlStore()
const editorRef = useRef(null)
const monacoRef = useRef(null)
@@ -154,6 +169,9 @@ const MonacoEditor = ({ error }) => {
editor?.focus()
}
// changes are stored in debouncer before running persistData()
let debounceUpdateSqlSnippet = debounce((value) => updateSqlSnippet(value), 1500)
async function handleEditorChange(value) {
// update sqlEditorStore with new value immediately
// this is so any SQL run will be whatever is currently in monaco editor
@@ -163,29 +181,8 @@ const MonacoEditor = ({ error }) => {
debounceUpdateSqlSnippet(value)
}
// changes are stored in debouncer before running persistData()
let debounceUpdateSqlSnippet = debounce((value) => updateSqlSnippet(value), 1500)
async function updateSqlSnippet(value) {
//
// this will need to be refactored but for now
// we still use the old sqlEditorStore to handle db updates
if (sqlEditorStore.activeTab) {
await contentStore.updateSql(sqlEditorStore.activeTab.id, {
content: {
schema_version: SQL_SNIPPET_SCHEMA_VERSION,
content_id: sqlEditorStore.activeTab.id,
sql: value,
favorite: sqlEditorStore.activeTab.favorite,
},
})
} else {
console.warn('No active tab found while updating SQL snippet')
}
}
return (
<div className="flex-grow overflow-y-auto border-b dark:border-dark">
<div className="dark:border-dark flex-grow overflow-y-auto border-b">
<Editor
className="monaco-editor"
theme={'supabase'}
@@ -208,15 +205,15 @@ const MonacoEditor = ({ error }) => {
)
}
const UtilityPanel = observer(() => {
const UtilityPanel = observer(({ updateSqlSnippet }) => {
const sqlEditorStore = useSqlStore()
return (
<>
<div className="flex justify-between overflow-visible px-6 py-2">
<ResultsDropdown />
<div className="inline-flex justify-end">
<UtilityActions />
<div className="inline-flex items-center justify-end">
<UtilityActions updateSqlSnippet={updateSqlSnippet} />
</div>
</div>
<div className="p-0 pt-0 pb-0">
@@ -271,7 +268,101 @@ const ResultsDropdown = observer(() => {
)
})
const UtilityActions = observer(() => {
const SavingIndicator = observer(({ updateSqlSnippet }) => {
const { content } = useStore()
const sqlEditorStore = useSqlStore()
const previousState = usePrevious(content.savingState)
const [showSavedText, setShowSavedText] = useState(false)
useEffect(() => {
let cancel = false
if (
(previousState === 'CREATING' || previousState === 'UPDATING') &&
content.savingState === 'IDLE'
) {
setShowSavedText(true)
setTimeout(() => {
if (!cancel) setShowSavedText(false)
}, 3000)
}
return () => (cancel = true)
}, [content.savingState])
const retry = () => {
const [item] = content.list((item) => item.id === sqlEditorStore.selectedTabId)
if (content.savingState === 'CREATING_FAILED' && item) {
content.save(toJS(item))
}
if (content.savingState === 'UPDATING_FAILED' && item) {
updateSqlSnippet(sqlEditorStore.activeTab.query)
}
}
return (
<div className="mx-2 flex items-center gap-2">
{(content.savingState === 'CREATING_FAILED' || content.savingState === 'UPDATING_FAILED') && (
<Button
type="text"
size="tiny"
shadow={false}
icon={<IconRefreshCcw className="text-gray-1100" size="tiny" strokeWidth={2} />}
onClick={retry}
>
Retry
</Button>
)}
{(content.savingState === 'CREATING' || content.savingState === 'UPDATING') && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconLoader className="animate-spin" size={14} strokeWidth={2} />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border',
].join(' ')}
>
<span className="text-scale-1200 text-xs">Saving changes...</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
{(content.savingState === 'CREATING_FAILED' || content.savingState === 'UPDATING_FAILED') && (
<IconAlertCircle className="text-red-900" size={14} strokeWidth={2} />
)}
{showSavedText && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconCheck className="text-brand-800" size={14} strokeWidth={3} />
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
'border-scale-200 border ',
].join(' ')}
>
<span className="text-scale-1200 text-xs">All changes saved</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
)}
<span className="text-scale-1000 text-sm">
{content.savingState === 'CREATING_FAILED' && 'Failed to create'}
{content.savingState === 'UPDATING_FAILED' && 'Failed to save'}
</span>
</div>
)
})
const UtilityActions = observer(({ updateSqlSnippet }) => {
const sqlEditorStore = useSqlStore()
useKeyboardShortcuts(
@@ -291,6 +382,7 @@ const UtilityActions = observer(() => {
return (
<>
<SavingIndicator updateSqlSnippet={updateSqlSnippet} />
{IS_PLATFORM && <FavoriteButton />}
<SizeToggleButton />
<Button
@@ -352,14 +444,10 @@ const SizeToggleButton = observer(() => {
})
const FavoriteButton = observer(() => {
const router = useRouter()
const { ref } = router.query
const { ui } = useStore()
const { ui, content: contentStore } = useStore()
const { profile: user } = ui
const sqlEditorStore = useSqlStore()
const contentStore = useProjectContentStore(ref)
const [loading, setLoading] = useState(false)
@@ -397,8 +485,9 @@ const FavoriteButton = observer(() => {
/*
* reload sql data in store and re-select tab
*/
await sqlEditorStore.loadRemotePersistentData(contentStore, user.id)
sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false)
sqlEditorStore.selectTab(id)
setLoading(false)
} catch (error) {
ui.setNotification({
@@ -434,7 +523,7 @@ const FavoriteButton = observer(() => {
/*
* reload sql data in store and re-select tab
*/
await sqlEditorStore.loadRemotePersistentData(contentStore, user.id)
sqlEditorStore.loadTabs(sqlEditorStore.tabsFromContentStore(contentStore, user?.id), false)
sqlEditorStore.selectTab(id)
setLoading(false)
} catch (error) {
@@ -479,14 +568,14 @@ const UtilityTabResults = observer(() => {
if (sqlEditorStore.activeTab.isExecuting) {
return (
<div className="bg-table-header-light dark:bg-table-header-dark">
<p className="px-6 py-4 m-0 border-0 font-mono text-sm">Running...</p>
<p className="m-0 border-0 px-6 py-4 font-mono text-sm">Running...</p>
</div>
)
} else if (sqlEditorStore.activeTab.errorResult) {
return (
<div className="bg-table-header-light dark:bg-table-header-dark">
<Typography.Text>
<p className="px-6 py-4 m-0 border-0 font-mono">{sqlEditorStore.activeTab.errorResult}</p>
<p className="m-0 border-0 px-6 py-4 font-mono">{sqlEditorStore.activeTab.errorResult}</p>
</Typography.Text>
</div>
)
@@ -494,7 +583,7 @@ const UtilityTabResults = observer(() => {
return (
<div className="bg-table-header-light dark:bg-table-header-dark">
<Typography.Text type="secondary">
<p className="px-6 py-4 m-0 border-0 ">
<p className="m-0 border-0 px-6 py-4 ">
Click <Typography.Text code>RUN</Typography.Text> to execute your query.
</p>
</Typography.Text>
@@ -530,7 +619,7 @@ const Results = ({ results }) => {
return (
<div className="bg-table-header-light dark:bg-table-header-dark">
<Typography.Text type="danger">
<p className="px-6 py-4 m-0 font-mono border-0"> {`ERROR: ${results.error}`}</p>
<p className="m-0 border-0 px-6 py-4 font-mono"> {`ERROR: ${results.error}`}</p>
</Typography.Text>
</div>
)
@@ -538,7 +627,7 @@ const Results = ({ results }) => {
if (!results.length) {
return (
<div className="bg-table-header-light dark:bg-table-header-dark">
<p className="px-6 py-4 m-0 font-mono border-0 text-sm">Success. No rows returned</p>
<p className="m-0 border-0 px-6 py-4 font-mono text-sm">Success. No rows returned</p>
</div>
)
}
@@ -547,7 +636,7 @@ const Results = ({ results }) => {
return <span className="font-mono text-xs">{JSON.stringify(row[column])}</span>
}
const columnRender = (name) => {
return <div className="flex items-center justify-center font-mono h-full">{name}</div>
return <div className="flex h-full items-center justify-center font-mono">{name}</div>
}
const columns = Object.keys(results[0]).map((key) => ({
key,
@@ -1,72 +1,41 @@
import React, { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useRouter } from 'next/router'
import { IconChevronRight, IconLoader, Typography } from '@supabase/ui'
import { IconChevronRight, Typography } from '@supabase/ui'
import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore'
import { useProjectContentStore } from 'stores/projectContentStore'
import Telemetry from 'lib/telemetry'
import { useStore } from 'hooks'
import { useOptimisticSqlSnippetCreate } from 'hooks'
import { partition } from 'lodash'
import { createSqlSnippet } from './SqlEditor.utils'
import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.constants'
import CardButton from 'components/ui/CardButton'
const TabWelcome = observer(() => {
const router = useRouter()
const { ref } = router.query
const [sql, quickStart] = partition(SQL_TEMPLATES, { type: 'template' })
const { ui } = useStore()
const { profile: user } = ui
const sqlEditorStore = useSqlStore()
const contentStore = useProjectContentStore(ref)
const [SQL, QuickStart] = partition(SQL_TEMPLATES, { type: 'template' })
async function handleNewQuery(sql, title) {
try {
//do what you need here
// create new sql snippet
// this also reloads the project_content store
const snippet = await createSqlSnippet({ router, sql, name: title })
// reload the local sqlEditorStore
await sqlEditorStore.loadRemotePersistentData(contentStore, user?.id)
// select tab with new snippet
sqlEditorStore.selectTab(snippet.id)
} catch (error) {
ui.setNotification({
error,
category: 'error',
message: `Failed to create new query: ${error.message}`,
})
}
}
const handleNewQuery = useOptimisticSqlSnippetCreate()
return (
<div className="block p-6 h-full overflow-y-auto space-y-8">
<div className="block h-full space-y-8 overflow-y-auto p-6">
<div>
<div className="mb-4">
<h1 className="text-scale-1200 text-xl mb-3">Scripts</h1>
<p className="text-sm text-scale-1100">Quick scripts to run on your database.</p>
<p className="text-sm text-scale-1100">
<h1 className="text-scale-1200 mb-3 text-xl">Scripts</h1>
<p className="text-scale-1100 text-sm">Quick scripts to run on your database.</p>
<p className="text-scale-1100 text-sm">
Click on any script to fill the query box, modify the script, then click
<span className="text-code">Run</span>.
</p>
</div>
<div className="grid sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{SQL.map((x) => (
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{sql.map((x) => (
<SqlCard
key={x.title}
title={x.title}
description={x.description}
sql={x.sql}
onClick={(sql, title) => {
handleNewQuery(sql, title)
handleNewQuery({ sql, name: title })
Telemetry.sendEvent('scripts', 'script_clicked', x.title)
}}
/>
@@ -75,18 +44,18 @@ const TabWelcome = observer(() => {
</div>
<div className="mb-8">
<div className="mb-4">
<h1 className="text-scale-1200 text-xl mb-3">Quick start</h1>
<p className="text-sm text-scale-1100">
<h1 className="text-scale-1200 mb-3 text-xl">Quick start</h1>
<p className="text-scale-1100 text-sm">
While we're in beta, we want to offer a quick way to explore Supabase. While we build
importers, check out these simple starters.
</p>
<p className="text-sm text-scale-1100">
<p className="text-scale-1100 text-sm">
Click on any script to fill the query box, modify the script, then click
<span className="text-code">Run</span>.
</p>
</div>
<div className="grid sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4">
{QuickStart.map((x) => (
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{quickStart.map((x) => (
<SqlCard
key={x.title}
title={x.title}
@@ -117,7 +86,7 @@ const SqlCard = ({ title, description, sql, onClick }) => {
<CardButton
onClick={() => handleOnClick()}
title={title}
footer={<span className="text-sm text-scale-1100">{description}</span>}
footer={<span className="text-scale-1100 text-sm">{description}</span>}
>
{/* <a
className="rounded bg-panel-header-light dark:bg-panel-header-dark transition-colors
@@ -157,13 +126,13 @@ const FavoriteCard = observer(({ favorite }) => {
return (
<a
className="rounded bg-panel-header-light dark:bg-panel-header-dark transition-colors
border border-panel-border-light dark:border-panel-border-dark
hover:border-panel-border-hover-light dark:hover:border-panel-border-hover-dark
cursor-pointer"
className="bg-panel-header-light dark:bg-panel-header-dark border-panel-border-light dark:border-panel-border-dark
hover:border-panel-border-hover-light dark:hover:border-panel-border-hover-dark cursor-pointer
rounded border
transition-colors"
onClick={onClick}
>
<div className="p-6 py-3 border-b dark:border-dark flex items-center justify-between">
<div className="dark:border-dark flex items-center justify-between border-b p-6 py-3">
<Typography.Title level={6} className="m-0">
{name}
</Typography.Title>
@@ -171,7 +140,7 @@ const FavoriteCard = observer(({ favorite }) => {
<IconChevronRight />
</Typography.Text>
</div>
<p className="p-6 py-4 capitalize-first">
<p className="capitalize-first p-6 py-4">
<Typography.Text type="secondary">{desc}</Typography.Text>
</p>
</a>
+5 -2
View File
@@ -1,4 +1,5 @@
import { Modal, Button, Space } from '@supabase/ui'
import { MouseEventHandler } from 'react'
import { FC, useState, useEffect } from 'react'
interface Props {
@@ -34,7 +35,9 @@ const ConfirmationModal: FC<Props> = ({
const [loading, setLoading] = useState(false)
const onConfirm = () => {
const onConfirm: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
e.stopPropagation()
setLoading(true)
onSelectConfirm()
}
@@ -48,7 +51,7 @@ const ConfirmationModal: FC<Props> = ({
size={size}
onCancel={onSelectCancel}
customFooter={
<div className="flex items-center w-full space-x-3">
<div className="flex w-full items-center space-x-3">
<Button block type="secondary" onClick={onSelectCancel}>
Cancel
</Button>
+1
View File
@@ -1,5 +1,6 @@
export * from './misc/useStore'
export * from './misc/withAuth'
export * from './misc/useOptimisticSqlSnippetCreate'
export * from './misc/useNotifications'
export * from './deprecated'
export * from './queries'
@@ -0,0 +1,40 @@
import { createSqlSnippetSkeleton } from 'components/to-be-cleaned/SqlEditor/SqlEditor.utils'
import { useSqlStore } from 'localStores/sqlEditor/SqlEditorStore'
import { useCallback } from 'react'
import { useStore } from './useStore'
export function useOptimisticSqlSnippetCreate() {
const { ui, content: contentStore } = useStore()
const { profile: user } = ui
const sqlEditorStore: any = useSqlStore()
return useCallback(
async (args?: Parameters<typeof createSqlSnippetSkeleton>[0]) => {
// get currently selected tab id in case we need to roll back to it
const previouslySelectedTabId = sqlEditorStore.selectedTabId
const snippet = createSqlSnippetSkeleton({ owner_id: user?.id, ...args })
// save the snippet in memory in the content store with a client-generated id
const { data } = contentStore.createOptimistically(snippet)
// update the tabs in the sql editor storage to match the content store
const tabs = sqlEditorStore.tabsFromContentStore(contentStore, user?.id)
sqlEditorStore.loadTabs(tabs, false)
// select tab with new snippet
sqlEditorStore.selectTab(data.id)
// save the new query to the server in the background
const { error } = await contentStore.save(data)
if (error) {
ui.setNotification({
category: 'error',
message: `Failed to create new query: ${error.message}`,
})
}
},
[user, sqlEditorStore, contentStore]
)
}
+2 -7
View File
@@ -1,3 +1,4 @@
import { v4 as _uuidV4 } from 'uuid'
import { post } from 'lib/common/fetch'
import { PASSWORD_STRENGTH, DEFAULT_MINIMUM_PASSWORD_STRENGTH, API_URL } from 'lib/constants'
@@ -32,14 +33,8 @@ export const prettifyJSON = (minifiedJSON: string) => {
}
}
// https://stackoverflow.com/a/2117523
// a "good enough" unique ID that typescript is happy with, and doesn't have external dependencies
export const uuidv4 = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c == 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
return _uuidV4()
}
export const timeout = (ms: number) => {
+47 -20
View File
@@ -5,17 +5,23 @@ import { IS_PLATFORM } from 'lib/constants'
import Tab from './Tab'
import QueryTab from './QueryTab'
import Favorite from './Favorite'
// import { useStore } from 'hooks'
import { SchemasQuery, TableColumnsQuery, AllFunctionsQuery } from './queries'
import { isUndefined } from 'lodash'
let store = null
let projectRef = null
let metaProjectRef = null
export function useSqlEditorStore(ref, meta) {
if (store === null || ref !== projectRef) {
if ((ref && ref !== projectRef) || (meta?.projectRef && meta.projectRef !== metaProjectRef)) {
projectRef = ref
metaProjectRef = meta.projectRef
store = new SqlEditorStore(ref, meta)
}
if (!ref || !meta) {
store = null
}
return store
}
@@ -70,6 +76,10 @@ class SqlEditorStore {
}
get activeTab() {
if (this.tabs.length === 1) {
this.selectedTabId = this.tabs[0].id
}
const found = this.tabs.find((x) => x.id === this.selectedTabId)
return found
}
@@ -112,22 +122,16 @@ class SqlEditorStore {
else return tab.isExecuting
}
/*
* ! Temporary solution !
*
* New action for retriving data from main db user_content
* You need to pass the contentStore into this
*/
async loadRemotePersistentData(contentStore, user_id) {
await contentStore.load()
const sqlSnippets = contentStore.sqlSnippets((x) => x.owner_id === user_id)
const snippets = toJS(sqlSnippets)
tabsFromContentStore(contentStore, user_id) {
const snippets = contentStore
.sqlSnippets((x) => x.owner_id === user_id)
.map((snippet) => toJS(snippet))
// add the welcome tab
let tabs = IS_PLATFORM ? [new Tab('Welcome', TAB_TYPES.WELCOME)] : []
// add the tabs to array, but with structure the localStore expects
snippets.map((snippet) => {
snippets.forEach((snippet) => {
const data = {
desc: snippet.description,
id: snippet.id,
@@ -136,9 +140,24 @@ class SqlEditorStore {
type: 'SQL_QUERY',
favorite: snippet.content.favorite,
}
tabs.push(data)
})
return tabs
}
/*
* ! Temporary solution !
*
* New action for retriving data from main db user_content
* You need to pass the contentStore into this
*/
async loadRemotePersistentData(contentStore, user_id) {
await contentStore.load()
const tabs = this.tabsFromContentStore(contentStore, user_id)
/*
* Reshape snippet content to fit the SqlEditorStore shape
*/
@@ -197,7 +216,7 @@ class SqlEditorStore {
this.selectedTabId = this.tabs.length ? this.tabs[0].id : undefined
}
loadTabs(values) {
loadTabs(values, autoSelectTab = true) {
const tabs = values.map((x) => {
switch (x.type) {
case TAB_TYPES.WELCOME:
@@ -213,7 +232,10 @@ class SqlEditorStore {
})
this.tabs = tabs.filter((x) => x !== undefined)
this.selectedTabId = this.tabs.length ? this.tabs[0].id : undefined
if (autoSelectTab) {
this.selectedTabId = this.tabs.length ? this.tabs[0].id : undefined
}
}
loadFavorites(values) {
@@ -279,10 +301,15 @@ class SqlEditorStore {
}
closeTab(id) {
this.tabs = this.tabs.filter((x) => x.id !== id)
const tabs = this.tabs.filter((x) => x.id !== id)
// if user close selectedTab, select the last tab if available
if (this.tabs.length && this.selectedTabId === id)
this.selectedTabId = this.tabs[this.tabs.length - 1].id
if (tabs.length && this.selectedTabId === id) {
const nextId = tabs[tabs.length - 1].id
if (nextId) {
this.selectedTabId = nextId
}
}
}
createQueryTab(query, name) {
@@ -331,10 +358,10 @@ class SqlEditorStore {
}
renameQuery(id, model) {
const found = this.tabs.find((x) => x.id == id)
const found = this.tabs.find((x) => x.id === id)
found?.rename(model)
const favorite = this.favorites.find((x) => x.key == id)
const favorite = this.favorites.find((x) => x.key === id)
favorite?.rename(model)
}
+7 -7
View File
@@ -20,7 +20,7 @@
"@stripe/stripe-js": "^1.25.0",
"@supabase/postgres-meta": "^0.26.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.21",
"@supabase/shared-types": "^0.1.24",
"@supabase/supabase-js": "^1.35.3",
"@supabase/ui": "0.37.0-alpha.58",
"ajv": "^8.6.3",
@@ -8338,9 +8338,9 @@
}
},
"node_modules/@supabase/shared-types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.21.tgz",
"integrity": "sha512-iF6XMy2cYPm7p3hximwuDmQOFZuVHVKr/uKcrtQlFrQIYjmMMrF/7Qm20muq2+oH8WNxcj29AZJ6oBx6mYzEqQ=="
"version": "0.1.24",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.24.tgz",
"integrity": "sha512-zvGWWLLh5ZAeiSR4VfJGBY9KbRb1Jh8//gJVmZwiaRv0i6F4i9qTsFOmWjpFgLY5+grAqwSfyIfB+jG8rxcidQ=="
},
"node_modules/@supabase/storage-js": {
"version": "1.7.0",
@@ -35112,9 +35112,9 @@
}
},
"@supabase/shared-types": {
"version": "0.1.21",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.21.tgz",
"integrity": "sha512-iF6XMy2cYPm7p3hximwuDmQOFZuVHVKr/uKcrtQlFrQIYjmMMrF/7Qm20muq2+oH8WNxcj29AZJ6oBx6mYzEqQ=="
"version": "0.1.24",
"resolved": "https://registry.npmjs.org/@supabase/shared-types/-/shared-types-0.1.24.tgz",
"integrity": "sha512-zvGWWLLh5ZAeiSR4VfJGBY9KbRb1Jh8//gJVmZwiaRv0i6F4i9qTsFOmWjpFgLY5+grAqwSfyIfB+jG8rxcidQ=="
},
"@supabase/storage-js": {
"version": "1.7.0",
+3 -1
View File
@@ -24,7 +24,7 @@
"@stripe/stripe-js": "^1.25.0",
"@supabase/postgres-meta": "^0.26.1",
"@supabase/react-data-grid": "^7.1.0-beta.7",
"@supabase/shared-types": "^0.1.21",
"@supabase/shared-types": "^0.1.24",
"@supabase/supabase-js": "^1.35.3",
"@supabase/ui": "0.37.0-alpha.58",
"ajv": "^8.6.3",
@@ -74,6 +74,7 @@
"swr": "^1.2.1",
"uniforms-bootstrap4": "^3.6.2",
"uniforms-bridge-json-schema": "^3.6.2",
"uuid": "^8.3.2",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
@@ -104,6 +105,7 @@
"@types/react-window-infinite-loader": "^1.0.5",
"@types/semver": "^7.3.9",
"@types/sqlstring": "^2.3.0",
"@types/uuid": "^8.3.4",
"@types/zxcvbn": "^4.4.1",
"autoprefixer": "^10.4.2",
"babel-loader": "^8.2.3",
-107
View File
@@ -1,107 +0,0 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Button, Select } from '@supabase/ui'
import { observer } from 'mobx-react-lite'
import { useStore } from 'hooks'
import { API_URL } from 'lib/constants'
import { get, post } from 'lib/common/fetch'
const Associate = observer(() => {
const { ui, app } = useStore()
const sortedOrganizations = app.organizations.list()
const [awsMarketplace, setAwsMarketplace] = useState<any>()
const [error, setError] = useState<any>('')
const [loading, setLoading] = useState<any>(true)
const [organizationSlug, setOrganizationSlug] = useState<any>('')
const [saving, setSaving] = useState<any>(false)
const router = useRouter()
const handleChange = (e: any) => {
setOrganizationSlug(e.target.value)
}
const onSave = async () => {
if (organizationSlug === '') {
setError('Please select an organization')
return
}
setSaving(true)
const { error } = await post(
`${API_URL}/organizations/${organizationSlug}/associate`,
awsMarketplace
)
if (error) {
setError(error.message)
}
setSaving(false)
if (!error) {
router.push('/')
}
}
useEffect(() => {
const getAwsMarketplaceInfo = async () => {
try {
setLoading(true)
const xAmznMarketplaceToken = router.query['x-amzn-marketplace-token']
if (typeof xAmznMarketplaceToken !== 'string') {
throw new Error('Missing or multiple x-amzn-marketplace-token')
}
const response = await get(
`${API_URL}/organizations/aws-marketplace?x-amzn-marketplace-token=${encodeURIComponent(
xAmznMarketplaceToken
)}`
)
if (response.error) {
throw response.error
}
if (response.slug) {
setOrganizationSlug(response.slug)
}
setAwsMarketplace(response.awsMarketplace)
setLoading(false)
if (sortedOrganizations.length === 1) {
onSave()
}
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to get AWS Marketplace Information: ${error.message}`,
})
}
}
getAwsMarketplaceInfo()
}, [])
return (
<div className="flex justify-center">
{!loading && (
<div>
<Select
className="my-4"
error={error}
label="Select an organization to associate AWS Marketplace subscription with:"
onChange={handleChange}
value={organizationSlug}
>
<Select.Option key="" value="">
{' '}
</Select.Option>
{sortedOrganizations.map((organization: any) => (
<Select.Option key={organization.slug} value={organization.slug}>
{organization.name}
</Select.Option>
))}
</Select>
<Button type="primary" loading={saving} onClick={onSave}>
Save
</Button>
</div>
)}
</div>
)
})
export default Associate
+1 -5
View File
@@ -105,9 +105,7 @@ Home.getLayout = (page) => <IndexLayout>{page}</IndexLayout>
export default observer(Home)
// detect for redirect from 3rd party service like vercel, aws...
function isRedirectFromThirdPartyService(router: NextRouter) {
return router.query.next !== undefined || router.query['x-amzn-marketplace-token'] !== undefined
}
const isRedirectFromThirdPartyService = (router: NextRouter) => router.query.next !== undefined
const UnauthorizedLanding = () => {
const router = useRouter()
@@ -148,8 +146,6 @@ const IndexLayout = withAuth(
router.push(`/vercel/integrate?${params.toString()}`)
} else if (router.query?.next?.includes('new-project')) {
router.push('/new/project')
} else if (router.query['x-amzn-marketplace-token'] != undefined) {
router.push(`/account/associate?${params.toString()}`)
} else if (
typeof router.query?.next === 'string' &&
router.query?.next?.startsWith('project/_/')
+9 -47
View File
@@ -1,58 +1,16 @@
import React, { useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { useMonaco } from '@monaco-editor/react'
import {
useSqlEditorStore,
SqlEditorContext,
useSqlStore,
TAB_TYPES,
} from 'localStores/sqlEditor/SqlEditorStore'
import { useSqlStore, TAB_TYPES } from 'localStores/sqlEditor/SqlEditorStore'
import TabWelcome from 'components/to-be-cleaned/SqlEditor/TabWelcome'
import TabSqlQuery from 'components/to-be-cleaned/SqlEditor/TabSqlQuery'
import getPgsqlCompletionProvider from 'components/to-be-cleaned/SqlEditor/PgsqlCompletionProvider'
import getPgsqlSignatureHelpProvider from 'components/to-be-cleaned/SqlEditor/PgsqlSignatureHelpProvider'
import { useProjectContentStore } from 'stores/projectContentStore'
import { useStore, withAuth } from 'hooks'
import { useStore } from 'hooks'
import { SQLEditorLayout } from 'components/layouts'
import BaseLayout from 'components/layouts'
import { NextPageWithLayout } from 'types'
const PageConfig = () => {
const { meta, ui } = useStore()
const { profile: user } = ui
useEffect(() => {
if (ui.selectedProject !== undefined) {
loadPersistantData()
}
}, [ui.selectedProject])
/*
* Load persited data
*/
async function loadPersistantData() {
if (sqlEditorStore === undefined) return
await sqlEditorStore.loadRemotePersistentData(contentStore, (user as any)?.id)
}
if (ui.selectedProject === undefined) {
return <BaseLayout children={undefined} />
}
const contentStore: any = useProjectContentStore(ui.selectedProject.ref)
const sqlEditorStore: any = useSqlEditorStore(ui.selectedProject.ref, meta)
return (
<SqlEditorContext.Provider value={sqlEditorStore}>
<SQLEditorLayout title="SQL">
<SqlEditor />
</SQLEditorLayout>
</SqlEditorContext.Provider>
)
}
export default withAuth(observer(PageConfig))
const SqlEditor = observer(() => {
const SqlEditor: NextPageWithLayout = () => {
const { meta, ui } = useStore()
const { isDarkTheme } = ui
const sqlEditorStore: any = useSqlStore()
@@ -142,4 +100,8 @@ const SqlEditor = observer(() => {
}
return <>{renderTabContent()}</>
})
}
SqlEditor.getLayout = (page) => <SQLEditorLayout title="SQL">{page}</SQLEditorLayout>
export default observer(SqlEditor)
+93 -14
View File
@@ -1,6 +1,7 @@
import { makeAutoObservable } from 'mobx'
import { keyBy } from 'lodash'
import { uuidv4 } from 'lib/helpers'
import { get, post, patch, delete_ } from 'lib/common/fetch'
import { LogSqlSnippets, UserContent, UserContentMap } from 'types'
import { IRootStore } from '../RootStore'
@@ -14,15 +15,22 @@ export interface IProjectContentStore {
isLoading: boolean
isInitialized: boolean
isLoaded: boolean
savingState: 'IDLE' | 'CREATING' | 'CREATING_FAILED' | 'UPDATING' | 'UPDATING_FAILED'
error: any
recentLogSqlSnippets: LogSqlSnippets.Content[]
baseUrl: string
projectRef?: string
load: () => void
load: () => Promise<UserContentMap | undefined>
loadPersistentData: () => Promise<void>
create: (x: UserContent) => { data: UserContent; error: { error: { message: string } } }
save: (
content: UserContent
) => Promise<{ data: UserContent; error: null } | { data: null; error: { message: string } }>
create: (
content: UserContent
) => Promise<{ data: UserContent; error: null } | { data: null; error: { message: string } }>
createOptimistically: (content: UserContent) => { data: UserContent; error: null }
list: (filter?: any) => any[]
reports: (filter?: any) => any[]
sqlSnippets: (filter?: any) => any[]
@@ -30,6 +38,23 @@ export interface IProjectContentStore {
addRecentLogSqlSnippet: (snippet: Partial<LogSqlSnippets.Content>) => void
clearRecentLogSqlSnippets: () => void
setProjectRef: (ref?: string) => void
update: (
id: any,
updates: any,
type: UserContent['type']
) => Promise<
| {
data: UserContent
error: null
}
| {
data: null
error: unknown
}
>
del(id: any): Promise<{ data: boolean; error: unknown }>
delOptimistically(id: string): { data: boolean; error: null }
}
export default class ProjectContentStore implements IProjectContentStore {
@@ -51,6 +76,7 @@ export default class ProjectContentStore implements IProjectContentStore {
recentLogSqlSnippets: LogSqlSnippets.Content[] = []
state = this.STATES.INITIAL
savingState: 'IDLE' | 'CREATING' | 'CREATING_FAILED' | 'UPDATING' | 'UPDATING_FAILED'
error = null
constructor(rootStore: IRootStore, options: { projectRef: string }) {
@@ -61,6 +87,7 @@ export default class ProjectContentStore implements IProjectContentStore {
this.recentLogSqlKey = `${this.localStorageKey}-recent-log-sql`
this.loadPersistentData()
this.baseUrl = ``
this.savingState = 'IDLE'
makeAutoObservable(this)
}
@@ -84,7 +111,7 @@ export default class ProjectContentStore implements IProjectContentStore {
if (response.error) {
throw response.error
}
this.data = keyBy(response, 'id')
this.data = keyBy(response.data ?? response, 'id')
return response
}
@@ -191,37 +218,84 @@ export default class ProjectContentStore implements IProjectContentStore {
return snippets.sort((a, b) => a.name.localeCompare(b.name))
}
// @ts-ignore
async create(payload: any) {
/**
* Creates a new content item, but does not update local store
*/
async save(payload: UserContent) {
try {
this.savingState = 'CREATING'
const headers = {
'Content-Type': 'application/json',
}
const created = await post(this.baseUrl, payload, { headers })
const created = await post<UserContent>(this.baseUrl, payload, { headers })
if (created.error) throw created.error
this.data[created['id']] = created
return { data: created, error: null }
this.savingState = 'IDLE'
return { data: created as UserContent, error: null }
} catch (error) {
return { data: null, error }
this.savingState = 'CREATING_FAILED'
return { data: null, error: error as { message: string } }
}
}
async update(id: any, updates: any, type: UserContent['type']) {
/**
* Creates a new content item, and updates local store
*/
async create(payload: UserContent) {
const { data, error } = await this.save(payload)
if (error) {
return { data: null, error }
}
if (data && data['id']) {
this.data[data['id']] = data
}
return { data: data as UserContent, error: null }
}
/**
* Creates a new content item locally in store without saving to the server
*/
createOptimistically(payload: UserContent) {
const created = { ...payload, id: uuidv4() }
this.data[created.id] = created
return { data: created, error: null }
}
async update(id: any, updates: any, type?: UserContent['type']) {
try {
this.savingState = 'UPDATING'
const headers = {
'Content-Type': 'application/json',
}
let payload = {
...updates,
type,
id,
}
if (type) {
payload.type = type
}
const url = `${this.baseUrl}?id=${id}`
const updated = await patch(url, payload, { headers })
const updated = await patch<UserContent[]>(url, payload, { headers })
if (updated.error) throw updated.error
this.data[id] = updated
return { data: updated, error: null }
const localUpdate = { ...this.data[id], ...payload }
this.data[id] = localUpdate
this.savingState = 'IDLE'
return { data: localUpdate, error: null }
} catch (error) {
this.savingState = 'UPDATING_FAILED'
return { data: null, error }
}
}
@@ -249,6 +323,11 @@ export default class ProjectContentStore implements IProjectContentStore {
}
}
delOptimistically(id: string) {
delete this.data[id]
return { data: true, error: null }
}
setProjectRef(ref?: string) {
if (ref) {
this.projectRef = ref
+4
View File
@@ -61,6 +61,8 @@ export interface IMetaStore {
publications: IPublicationStore
types: IPostgresMetaInterface<any>
projectRef?: string
query: (value: string) => Promise<any | { error: ResponseError }>
validateQuery: (value: string) => Promise<any | { error: ResponseError }>
formatQuery: (value: string) => Promise<any | { error: ResponseError }>
@@ -118,6 +120,7 @@ export default class MetaStore implements IMetaStore {
publications: PublicationStore
types: TypesStore
projectRef?: string
connectionString?: string
baseUrl: string
excludedSchemas = [
@@ -136,6 +139,7 @@ export default class MetaStore implements IMetaStore {
constructor(rootStore: IRootStore, options: { projectRef: string; connectionString: string }) {
const { projectRef, connectionString } = options
this.rootStore = rootStore
this.projectRef = projectRef
this.baseUrl = `${API_URL}/pg-meta/${projectRef}`
const headers: any = {}
-9
View File
@@ -111,15 +111,6 @@ input.form-control,
filter: invert(0.8);
}
select {
// [Joshen] Color is hard-coded - I can't figure out how to use the UI library colors
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='16' height='16' fill='transparent' stroke='gray' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='4 11 9 16 14 11'></polyline></svg>");
}
select option {
@apply text-bg-primary-dark;
}
input.is-invalid {
@apply bg-red-100;
@apply border border-red-700;
+4 -4
View File
@@ -7,10 +7,10 @@ description: 'Get started with Supabase and Plasmic, an open-source framework fo
In this guide, we will show you how to build a crowd-sourced Pokemon Pokedex, by connecting **Supabase**, an open source Firebase backend alternative, with **Plasmic**, a visual builder for the web. While many users leverage Plasmic to quickly launch and iterate on landing pages, in this tutorial we’ll show just how powerful Plasmic can be as a general-purpose visual builder for React, which can be used to design and implement fully featured read-write applications.
You can play with the live demo here:
[https://plasmic-supabase-demo-git-master-plasmic.vercel.app/](https://plasmic-supabase-demo-git-master-plasmic.vercel.app/login-page)
[https://plasmic-supabase-demo.vercel.app/](https://plasmic-supabase-demo.vercel.app/)
You can also take a look at the Plasmic project here:
[https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap](https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap)
[https://studio.plasmic.app/projects/66RKaSPCwKxYjCfXWHCxn6](https://studio.plasmic.app/projects/66RKaSPCwKxYjCfXWHCxn6)
> You’ll need to enable 3rd-party cookies in your browser for the project to properly load.
@@ -79,8 +79,8 @@ If you pre-populated the database in Step 1. you should see the following homepa
Now let’s try to make some additions! The code base is currently configured to a read-only copy of the Plasmic project. Let’s make an editable copy first.
Open the default starter Plasmic project here:
[https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap](https://studio.plasmic.app/projects/i6YZmFxPJB69NEUgdf79ap)
Open the default starter Plasmic project here:
[https://studio.plasmic.app/projects/66RKaSPCwKxYjCfXWHCxn6](https://studio.plasmic.app/projects/66RKaSPCwKxYjCfXWHCxn6)
![clone-project-plasmic](/img/guides/integrations/plasmic/clone-project-plasmic-04.png)
+2 -2
View File
@@ -55,8 +55,8 @@ This project comes with TypeScript configured and has the following structure.
- A `.env` file: Contains the `DATABASE_URL` variable, which Prisma will use.
- A `script.ts` file: where we will run some queries using Prisma Client.
This starter also comes with the following packages installed:
- [`@prisma/client`](https://www.npmjs.com/package/prisma): An auto-generated and type-safe query builder that’s _tailored_ to your data.
- [`prisma`](https://www.npmjs.com/package/@prisma/client): Prisma’s command-line interface (CLI). It allows you to initialize new project assets, generate Prisma Client, and analyze existing database structures through introspection to automatically create your application models.
- [`@prisma/client`](https://www.npmjs.com/package/@prisma/client): An auto-generated and type-safe query builder that’s _tailored_ to your data.
- [`prisma`](https://www.npmjs.com/package/prisma): Prisma’s command-line interface (CLI). It allows you to initialize new project assets, generate Prisma Client, and analyze existing database structures through introspection to automatically create your application models.
> Note: Prisma works with both JavaScript and TypeScript. However, to get the best possible development experience, using TypeScript is highly recommended.
### Configuring the project to use PostgreSQL
+22 -121
View File
@@ -17,7 +17,8 @@
"mermaid": "^8.13.8",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-inlinesvg": "^2.0.1"
"react-inlinesvg": "^2.0.1",
"react-loadable": "^5.5.0"
},
"devDependencies": {
"@types/node": "^14.17.34",
@@ -8719,16 +8720,6 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.assignin": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
"integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI="
},
"node_modules/lodash.bind": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz",
"integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU="
},
"node_modules/lodash.curry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
@@ -8739,66 +8730,16 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
},
"node_modules/lodash.filter": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz",
"integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4="
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
},
"node_modules/lodash.flow": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
"integrity": "sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o="
},
"node_modules/lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM="
},
"node_modules/lodash.map": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz",
"integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM="
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/lodash.pick": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
"integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM="
},
"node_modules/lodash.reduce": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
"integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs="
},
"node_modules/lodash.reject": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz",
"integrity": "sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU="
},
"node_modules/lodash.some": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz",
"integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0="
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -10800,6 +10741,17 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-loadable": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-loadable/-/react-loadable-5.5.0.tgz",
"integrity": "sha512-C8Aui0ZpMd4KokxRdVAm2bQtI03k2RMRNzOB+IipV3yxFTSVICv7WoUr5L9ALB5BmKO1iHgZtWM8EvYG83otdg==",
"dependencies": {
"prop-types": "^15.5.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-loadable-ssr-addon-v5-slorber": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz",
@@ -15859,6 +15811,7 @@
},
"@kiwicopple/prism-react-renderer": {
"version": "git+ssh://git@github.com/kiwicopple/prism-react-renderer.git#4a09100a587bce2d94d7ac8ed3564a61c6e70781",
"integrity": "sha512-QluokmJkMoHOwahIBN34hTG5n03kFyJ0REDq4mkf0o05JUC61ikXQsPNXtynASqoZRW7A+bKVNT65kCtUfgZlQ==",
"from": "@kiwicopple/prism-react-renderer@https://github.com/kiwicopple/prism-react-renderer.git",
"requires": {
"prismjs": "^1.22.0"
@@ -20417,16 +20370,6 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.assignin": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assignin/-/lodash.assignin-4.2.0.tgz",
"integrity": "sha1-uo31+4QesKPoBEIysOJjqNxqKKI="
},
"lodash.bind": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lodash.bind/-/lodash.bind-4.2.1.tgz",
"integrity": "sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU="
},
"lodash.curry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.curry/-/lodash.curry-4.1.1.tgz",
@@ -20437,66 +20380,16 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
},
"lodash.filter": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.filter/-/lodash.filter-4.6.0.tgz",
"integrity": "sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4="
},
"lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
},
"lodash.flow": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz",
"integrity": "sha1-h79AKSuM+D5OjOGjrkIJ4gBxZ1o="
},
"lodash.foreach": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
"integrity": "sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM="
},
"lodash.map": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.map/-/lodash.map-4.6.0.tgz",
"integrity": "sha1-dx7Hg540c9nEzeKLGTlMNWL09tM="
},
"lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4="
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"lodash.pick": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
"integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM="
},
"lodash.reduce": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reduce/-/lodash.reduce-4.6.0.tgz",
"integrity": "sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs="
},
"lodash.reject": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.reject/-/lodash.reject-4.6.0.tgz",
"integrity": "sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU="
},
"lodash.some": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz",
"integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0="
},
"lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -21880,6 +21773,14 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-loadable": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-loadable/-/react-loadable-5.5.0.tgz",
"integrity": "sha512-C8Aui0ZpMd4KokxRdVAm2bQtI03k2RMRNzOB+IipV3yxFTSVICv7WoUr5L9ALB5BmKO1iHgZtWM8EvYG83otdg==",
"requires": {
"prop-types": "^15.5.0"
}
},
"react-loadable-ssr-addon-v5-slorber": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz",
+2 -1
View File
@@ -24,7 +24,8 @@
"mermaid": "^8.13.8",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-inlinesvg": "^2.0.1"
"react-inlinesvg": "^2.0.1",
"react-loadable": "^5.5.0"
},
"browserslist": {
"production": [
+2 -2
View File
@@ -63359,7 +63359,7 @@
"isExported": true
},
"comment": {
"text": "A unique identifier for the bucket you are creating.\n"
"text": "A unique identifier for the bucket you are updating.\n"
},
"type": {
"type": "intrinsic",
@@ -64901,7 +64901,7 @@
"isExported": true
},
"comment": {
"text": "A unique identifier for the bucket you are creating.\n"
"text": "A unique identifier for the bucket you are updating.\n"
},
"type": {
"type": "intrinsic",
+2 -2
View File
@@ -1267,7 +1267,7 @@
"isExported": true
},
"comment": {
"text": "A unique identifier for the bucket you are creating.\n"
"text": "A unique identifier for the bucket you are updating.\n"
},
"type": {
"type": "intrinsic",
@@ -2809,7 +2809,7 @@
"isExported": true
},
"comment": {
"text": "A unique identifier for the bucket you are creating.\n"
"text": "A unique identifier for the bucket you are updating.\n"
},
"type": {
"type": "intrinsic",
+1 -1
View File
@@ -1449,7 +1449,7 @@ pages:
- name: Upload file using `ArrayBuffer` from base64 file data
js: |
```js
import {decode} from 'base64-arraybuffer'
import { decode } from 'base64-arraybuffer'
const { data, error } = await supabase
.storage
+1
View File
@@ -1285,6 +1285,7 @@ h4.method-list-item-label {
h4.method-list-item-label .method-list-item-label-name {
font-family: var(--ifm-font-family-monospace);
font-size: 0.8rem;
margin-right: 4px;
padding: var(--ifm-code-padding-vertical) 0;
}
h4.method-list-item-label .method-list-item-label-badge {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 95 KiB