mirror of https://github.com/Budibase/budibase.git
142 changed files with 3887 additions and 2784 deletions
@ -0,0 +1,61 @@ |
|||
#!/usr/bin/env node
|
|||
|
|||
const os = require("os") |
|||
const exec = require("child_process").exec |
|||
const fs = require("fs") |
|||
const platform = os.platform() |
|||
|
|||
const windows = platform === "win32" |
|||
const mac = platform === "darwin" |
|||
const linux = platform === "linux" |
|||
|
|||
function execute(command) { |
|||
return new Promise(resolve => { |
|||
exec(command, (err, stdout) => resolve(linux ? !!stdout : true)) |
|||
}) |
|||
} |
|||
|
|||
async function commandExistsUnix(command) { |
|||
const unixCmd = `command -v ${command} 2>/dev/null && { echo >&1 ${command}; exit 0; }` |
|||
return execute(command) |
|||
} |
|||
|
|||
async function commandExistsWindows(command) { |
|||
if (/[\x00-\x1f<>:"|?*]/.test(command)) { |
|||
return false |
|||
} |
|||
return execute(`where ${command}`) |
|||
} |
|||
|
|||
function commandExists(command) { |
|||
return windows ? commandExistsWindows(command) : commandExistsUnix(command) |
|||
} |
|||
|
|||
async function init() { |
|||
const docker = commandExists("docker") |
|||
const dockerCompose = commandExists("docker-compose") |
|||
if (docker && dockerCompose) { |
|||
console.log("Docker installed - continuing.") |
|||
return |
|||
} |
|||
if (mac) { |
|||
console.log( |
|||
"Please install docker by visiting: https://docs.docker.com/docker-for-mac/install/" |
|||
) |
|||
} else if (windows) { |
|||
console.log( |
|||
"Please install docker by visiting: https://docs.docker.com/docker-for-windows/install/" |
|||
) |
|||
} else if (linux) { |
|||
console.log("Beginning automated linux installation.") |
|||
await execute(`./hosting/scripts/linux/get-docker.sh`) |
|||
await execute(`./hosting/scripts/linux/get-docker-compose.sh`) |
|||
} else { |
|||
console.error( |
|||
"Platform unknown - please look online for information about installing docker for our OS." |
|||
) |
|||
} |
|||
console.log("Once installation complete please re-run the setup script.") |
|||
process.exit(-1) |
|||
} |
|||
init() |
|||
@ -0,0 +1,240 @@ |
|||
const sanitize = require("sanitize-s3-objectkey") |
|||
const AWS = require("aws-sdk") |
|||
const stream = require("stream") |
|||
const fetch = require("node-fetch") |
|||
const tar = require("tar-fs") |
|||
const zlib = require("zlib") |
|||
const { promisify } = require("util") |
|||
const { join } = require("path") |
|||
const fs = require("fs") |
|||
const env = require("../environment") |
|||
const { budibaseTempDir, ObjectStoreBuckets } = require("./utils") |
|||
const { v4 } = require("uuid") |
|||
|
|||
const streamPipeline = promisify(stream.pipeline) |
|||
// use this as a temporary store of buckets that are being created
|
|||
const STATE = { |
|||
bucketCreationPromises: {}, |
|||
} |
|||
|
|||
const CONTENT_TYPE_MAP = { |
|||
html: "text/html", |
|||
css: "text/css", |
|||
js: "application/javascript", |
|||
} |
|||
const STRING_CONTENT_TYPES = [ |
|||
CONTENT_TYPE_MAP.html, |
|||
CONTENT_TYPE_MAP.css, |
|||
CONTENT_TYPE_MAP.js, |
|||
] |
|||
|
|||
function publicPolicy(bucketName) { |
|||
return { |
|||
Version: "2012-10-17", |
|||
Statement: [ |
|||
{ |
|||
Effect: "Allow", |
|||
Principal: { |
|||
AWS: ["*"], |
|||
}, |
|||
Action: "s3:GetObject", |
|||
Resource: [`arn:aws:s3:::${bucketName}/*`], |
|||
}, |
|||
], |
|||
} |
|||
} |
|||
|
|||
const PUBLIC_BUCKETS = [ObjectStoreBuckets.APPS, ObjectStoreBuckets.GLOBAL] |
|||
|
|||
/** |
|||
* Gets a connection to the object store using the S3 SDK. |
|||
* @param {string} bucket the name of the bucket which blobs will be uploaded/retrieved from. |
|||
* @return {Object} an S3 object store object, check S3 Nodejs SDK for usage. |
|||
* @constructor |
|||
*/ |
|||
exports.ObjectStore = bucket => { |
|||
AWS.config.update({ |
|||
accessKeyId: env.MINIO_ACCESS_KEY, |
|||
secretAccessKey: env.MINIO_SECRET_KEY, |
|||
}) |
|||
const config = { |
|||
s3ForcePathStyle: true, |
|||
signatureVersion: "v4", |
|||
params: { |
|||
Bucket: bucket, |
|||
}, |
|||
} |
|||
if (env.MINIO_URL) { |
|||
config.endpoint = env.MINIO_URL |
|||
} |
|||
return new AWS.S3(config) |
|||
} |
|||
|
|||
/** |
|||
* Given an object store and a bucket name this will make sure the bucket exists, |
|||
* if it does not exist then it will create it. |
|||
*/ |
|||
exports.makeSureBucketExists = async (client, bucketName) => { |
|||
try { |
|||
await client |
|||
.headBucket({ |
|||
Bucket: bucketName, |
|||
}) |
|||
.promise() |
|||
} catch (err) { |
|||
const promises = STATE.bucketCreationPromises |
|||
if (promises[bucketName]) { |
|||
await promises[bucketName] |
|||
} else if (err.statusCode === 404) { |
|||
// bucket doesn't exist create it
|
|||
promises[bucketName] = client |
|||
.createBucket({ |
|||
Bucket: bucketName, |
|||
}) |
|||
.promise() |
|||
await promises[bucketName] |
|||
delete promises[bucketName] |
|||
// public buckets are quite hidden in the system, make sure
|
|||
// no bucket is set accidentally
|
|||
if (PUBLIC_BUCKETS.includes(bucketName)) { |
|||
await client |
|||
.putBucketPolicy({ |
|||
Bucket: bucketName, |
|||
Policy: JSON.stringify(publicPolicy(bucketName)), |
|||
}) |
|||
.promise() |
|||
} |
|||
} else { |
|||
throw err |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Uploads the contents of a file given the required parameters, useful when |
|||
* temp files in use (for example file uploaded as an attachment). |
|||
*/ |
|||
exports.upload = async ({ bucket, filename, path, type, metadata }) => { |
|||
const extension = [...filename.split(".")].pop() |
|||
const fileBytes = fs.readFileSync(path) |
|||
|
|||
const objectStore = exports.ObjectStore(bucket) |
|||
await exports.makeSureBucketExists(objectStore, bucket) |
|||
|
|||
const config = { |
|||
// windows file paths need to be converted to forward slashes for s3
|
|||
Key: sanitize(filename).replace(/\\/g, "/"), |
|||
Body: fileBytes, |
|||
ContentType: type || CONTENT_TYPE_MAP[extension.toLowerCase()], |
|||
} |
|||
if (metadata) { |
|||
config.Metadata = metadata |
|||
} |
|||
return objectStore.upload(config).promise() |
|||
} |
|||
|
|||
/** |
|||
* Similar to the upload function but can be used to send a file stream |
|||
* through to the object store. |
|||
*/ |
|||
exports.streamUpload = async (bucket, filename, stream) => { |
|||
const objectStore = exports.ObjectStore(bucket) |
|||
await exports.makeSureBucketExists(objectStore, bucket) |
|||
|
|||
const params = { |
|||
Bucket: bucket, |
|||
Key: sanitize(filename).replace(/\\/g, "/"), |
|||
Body: stream, |
|||
} |
|||
return objectStore.upload(params).promise() |
|||
} |
|||
|
|||
/** |
|||
* retrieves the contents of a file from the object store, if it is a known content type it |
|||
* will be converted, otherwise it will be returned as a buffer stream. |
|||
*/ |
|||
exports.retrieve = async (bucket, filepath) => { |
|||
const objectStore = exports.ObjectStore(bucket) |
|||
const params = { |
|||
Bucket: bucket, |
|||
Key: sanitize(filepath).replace(/\\/g, "/"), |
|||
} |
|||
const response = await objectStore.getObject(params).promise() |
|||
// currently these are all strings
|
|||
if (STRING_CONTENT_TYPES.includes(response.ContentType)) { |
|||
return response.Body.toString("utf8") |
|||
} else { |
|||
return response.Body |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Same as retrieval function but puts to a temporary file. |
|||
*/ |
|||
exports.retrieveToTmp = async (bucket, filepath) => { |
|||
const data = await exports.retrieve(bucket, filepath) |
|||
const outputPath = join(budibaseTempDir(), v4()) |
|||
fs.writeFileSync(outputPath, data) |
|||
return outputPath |
|||
} |
|||
|
|||
exports.deleteFolder = async (bucket, folder) => { |
|||
const client = exports.ObjectStore(bucket) |
|||
const listParams = { |
|||
Bucket: bucket, |
|||
Prefix: folder, |
|||
} |
|||
|
|||
let response = await client.listObjects(listParams).promise() |
|||
if (response.Contents.length === 0) { |
|||
return |
|||
} |
|||
const deleteParams = { |
|||
Bucket: bucket, |
|||
Delete: { |
|||
Objects: [], |
|||
}, |
|||
} |
|||
|
|||
response.Contents.forEach(content => { |
|||
deleteParams.Delete.Objects.push({ Key: content.Key }) |
|||
}) |
|||
|
|||
response = await client.deleteObjects(deleteParams).promise() |
|||
// can only empty 1000 items at once
|
|||
if (response.Deleted.length === 1000) { |
|||
return exports.deleteFolder(bucket, folder) |
|||
} |
|||
} |
|||
|
|||
exports.uploadDirectory = async (bucket, localPath, bucketPath) => { |
|||
let uploads = [] |
|||
const files = fs.readdirSync(localPath, { withFileTypes: true }) |
|||
for (let file of files) { |
|||
const path = join(bucketPath, file.name) |
|||
const local = join(localPath, file.name) |
|||
if (file.isDirectory()) { |
|||
uploads.push(exports.uploadDirectory(bucket, local, path)) |
|||
} else { |
|||
uploads.push( |
|||
exports.streamUpload(bucket, path, fs.createReadStream(local)) |
|||
) |
|||
} |
|||
} |
|||
await Promise.all(uploads) |
|||
} |
|||
|
|||
exports.downloadTarball = async (url, bucket, path) => { |
|||
const response = await fetch(url) |
|||
if (!response.ok) { |
|||
throw new Error(`unexpected response ${response.statusText}`) |
|||
} |
|||
|
|||
const tmpPath = join(budibaseTempDir(), path) |
|||
await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath)) |
|||
if (!env.isTest()) { |
|||
await exports.uploadDirectory(bucket, tmpPath, path) |
|||
} |
|||
// return the temporary path incase there is a use for it
|
|||
return tmpPath |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
const { join } = require("path") |
|||
const { tmpdir } = require("os") |
|||
|
|||
exports.ObjectStoreBuckets = { |
|||
BACKUPS: "backups", |
|||
APPS: "prod-budi-app-assets", |
|||
TEMPLATES: "templates", |
|||
GLOBAL: "global", |
|||
} |
|||
|
|||
exports.budibaseTempDir = function () { |
|||
return join(tmpdir(), ".budibase") |
|||
} |
|||
@ -1,62 +1,16 @@ |
|||
<script> |
|||
export let forAttr = "", |
|||
extraSmall = false, |
|||
small = false, |
|||
medium = false, |
|||
large = false, |
|||
extraLarge = false, |
|||
white = false, |
|||
grey = false, |
|||
black = false |
|||
import "@spectrum-css/fieldlabel/dist/index-vars.css" |
|||
|
|||
export let size = "M" |
|||
</script> |
|||
|
|||
<label |
|||
class="bb-label" |
|||
class:extraSmall |
|||
class:small |
|||
class:medium |
|||
class:large |
|||
class:extraLarge |
|||
class:white |
|||
class:grey |
|||
class:black |
|||
for={forAttr} |
|||
> |
|||
<label class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}> |
|||
<slot /> |
|||
</label> |
|||
|
|||
<style> |
|||
.bb-label { |
|||
font-family: var(--font-sans); |
|||
font-weight: 500; |
|||
text-rendering: var(--text-render); |
|||
color: var(--ink); |
|||
font-size: var(--font-size-s); |
|||
margin-bottom: var(--spacing-s); |
|||
display: block; |
|||
} |
|||
.extraSmall { |
|||
font-size: var(--font-size-xs); |
|||
} |
|||
.small { |
|||
font-size: var(--font-size-s); |
|||
} |
|||
.medium { |
|||
font-size: var(--font-size-m); |
|||
} |
|||
.large { |
|||
font-size: var(--font-size-l); |
|||
} |
|||
.extraLarge { |
|||
font-size: var(--font-size-xl); |
|||
} |
|||
.white { |
|||
color: white; |
|||
} |
|||
.grey { |
|||
color: var(--grey-6); |
|||
} |
|||
.black { |
|||
color: var(--ink); |
|||
label { |
|||
padding: 0; |
|||
white-space: nowrap; |
|||
} |
|||
</style> |
|||
|
|||
@ -0,0 +1,90 @@ |
|||
export const gradient = (node, config = {}) => { |
|||
const defaultConfig = { |
|||
points: 12, |
|||
saturation: 0.85, |
|||
lightness: 0.7, |
|||
softness: 0.9, |
|||
seed: null, |
|||
} |
|||
|
|||
// Applies a gradient background
|
|||
const createGradient = config => { |
|||
config = { |
|||
...defaultConfig, |
|||
...config, |
|||
} |
|||
const { saturation, lightness, softness, points } = config |
|||
const seed = config.seed || Math.random().toString(32).substring(2) |
|||
|
|||
// Hash function which returns a fixed hash between specified limits
|
|||
// for a given seed and a given version
|
|||
const rangeHash = (seed, min = 0, max = 100, version = 0) => { |
|||
const range = max - min |
|||
let hash = range + version |
|||
for (let i = 0; i < seed.length * 2 + version; i++) { |
|||
hash = (hash << 5) - hash + seed.charCodeAt(i % seed.length) |
|||
hash = ((hash & hash) % range) + version |
|||
} |
|||
return min + (hash % range) |
|||
} |
|||
|
|||
// Generates a random HSL colour using the options specified
|
|||
const randomHSL = (seed, version, alpha = 1) => { |
|||
const lowerSaturation = Math.min(100, saturation * 100) |
|||
const upperSaturation = Math.min(100, (saturation + 0.2) * 100) |
|||
const lowerLightness = Math.min(100, lightness * 100) |
|||
const upperLightness = Math.min(100, (lightness + 0.2) * 100) |
|||
const hue = rangeHash(seed, 0, 360, version) |
|||
const sat = `${rangeHash( |
|||
seed, |
|||
lowerSaturation, |
|||
upperSaturation, |
|||
version |
|||
)}%` |
|||
const light = `${rangeHash( |
|||
seed, |
|||
lowerLightness, |
|||
upperLightness, |
|||
version |
|||
)}%` |
|||
return `hsla(${hue},${sat},${light},${alpha})` |
|||
} |
|||
|
|||
// Generates a radial gradient stop point
|
|||
const randomGradientPoint = (seed, version) => { |
|||
const lowerTransparency = Math.min(100, softness * 100) |
|||
const upperTransparency = Math.min(100, (softness + 0.2) * 100) |
|||
const transparency = rangeHash( |
|||
seed, |
|||
lowerTransparency, |
|||
upperTransparency, |
|||
version |
|||
) |
|||
return ( |
|||
`radial-gradient(at ` + |
|||
`${rangeHash(seed, 0, 100, version)}% ` + |
|||
`${rangeHash(seed, 0, 100, version + 1)}%,` + |
|||
`${randomHSL(seed, version, saturation)} 0,` + |
|||
`transparent ${transparency}%)` |
|||
) |
|||
} |
|||
|
|||
let css = `opacity:0.9;background:${randomHSL(seed, 0, 0.7)};` |
|||
css += "background-image:" |
|||
for (let i = 0; i < points - 1; i++) { |
|||
css += `${randomGradientPoint(seed, i)},` |
|||
} |
|||
css += `${randomGradientPoint(seed, points)};` |
|||
node.style = css |
|||
} |
|||
|
|||
// Apply the initial gradient
|
|||
createGradient(config) |
|||
|
|||
return { |
|||
// Apply a new gradient
|
|||
update: config => { |
|||
createGradient(config) |
|||
}, |
|||
} |
|||
} |
|||
@ -1,72 +1,84 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
import { ActionButton, Heading } from "@budibase/bbui" |
|||
import { notifications } from "@budibase/bbui" |
|||
import Spinner from "components/common/Spinner.svelte" |
|||
import download from "downloadjs" |
|||
import { |
|||
Heading, |
|||
Icon, |
|||
Body, |
|||
Layout, |
|||
ActionMenu, |
|||
MenuItem, |
|||
Link, |
|||
} from "@budibase/bbui" |
|||
import { gradient } from "actions" |
|||
import { url } from "@roxi/routify" |
|||
|
|||
export let name, _id |
|||
|
|||
let appExportLoading = false |
|||
|
|||
async function exportApp() { |
|||
appExportLoading = true |
|||
try { |
|||
download( |
|||
`/api/backups/export?appId=${_id}&appname=${encodeURIComponent(name)}` |
|||
) |
|||
notifications.success("App Export Complete.") |
|||
} catch (err) { |
|||
console.error(err) |
|||
notifications.error("App Export Failed.") |
|||
} finally { |
|||
appExportLoading = false |
|||
} |
|||
} |
|||
export let app |
|||
export let exportApp |
|||
export let deleteApp |
|||
</script> |
|||
|
|||
<div class="apps-card"> |
|||
<Heading size="S">{name}</Heading> |
|||
<div class="card-footer" data-cy={`app-${name}`}> |
|||
<ActionButton on:click={() => $goto(`/builder/${_id}`)}> |
|||
Open |
|||
{name} |
|||
→ |
|||
</ActionButton> |
|||
{#if appExportLoading} |
|||
<Spinner size="10" /> |
|||
{:else} |
|||
<ActionButton icon="Download" quiet /> |
|||
{/if} |
|||
</div> |
|||
<div class="wrapper"> |
|||
<Layout noPadding gap="XS" alignContent="start"> |
|||
<div class="preview" use:gradient={{ seed: app.name }} /> |
|||
<div class="title"> |
|||
<Link href={$url(`../../app/${app._id}`)}> |
|||
<Heading size="XS"> |
|||
{app.name} |
|||
</Heading> |
|||
</Link> |
|||
<ActionMenu align="right"> |
|||
<Icon slot="control" name="More" hoverable /> |
|||
<MenuItem on:click={() => exportApp(app)} icon="Download"> |
|||
Export |
|||
</MenuItem> |
|||
<MenuItem on:click={() => deleteApp(app)} icon="Delete"> |
|||
Delete |
|||
</MenuItem> |
|||
</ActionMenu> |
|||
</div> |
|||
<div class="status"> |
|||
<Body noPadding size="S"> |
|||
Edited {Math.floor(1 + Math.random() * 10)} months ago |
|||
</Body> |
|||
{#if Math.random() > 0.5} |
|||
<Icon name="LockClosed" /> |
|||
{/if} |
|||
</div> |
|||
</Layout> |
|||
</div> |
|||
|
|||
<style> |
|||
.apps-card { |
|||
background-color: var(--background); |
|||
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-xl) |
|||
var(--spacing-xl); |
|||
max-width: 300px; |
|||
max-height: 150px; |
|||
border-radius: var(--border-radius-m); |
|||
border: var(--border-dark); |
|||
.wrapper { |
|||
overflow: hidden; |
|||
} |
|||
.preview { |
|||
height: 135px; |
|||
border-radius: var(--border-radius-s); |
|||
margin-bottom: var(--spacing-s); |
|||
} |
|||
|
|||
.card-footer { |
|||
.title, |
|||
.status { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
margin-top: var(--spacing-m); |
|||
align-items: center; |
|||
} |
|||
|
|||
i { |
|||
font-size: var(--font-size-l); |
|||
cursor: pointer; |
|||
transition: 0.2s all; |
|||
.title :global(a) { |
|||
text-decoration: none; |
|||
flex: 1 1 auto; |
|||
width: 0; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
margin-right: var(--spacing-m); |
|||
} |
|||
|
|||
i:hover { |
|||
color: var(--blue); |
|||
.title :global(h1) { |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
.title :global(h1:hover) { |
|||
color: var(--spectrum-global-color-blue-600); |
|||
cursor: pointer; |
|||
transition: color 130ms ease; |
|||
} |
|||
</style> |
|||
|
|||
@ -1,50 +0,0 @@ |
|||
<script> |
|||
import AppCard from "./AppCard.svelte" |
|||
import { Heading, Divider } from "@budibase/bbui" |
|||
import Spinner from "components/common/Spinner.svelte" |
|||
import { get } from "builderStore/api" |
|||
|
|||
let promise = getApps() |
|||
|
|||
async function getApps() { |
|||
const res = await get("/api/applications") |
|||
const json = await res.json() |
|||
|
|||
if (res.ok) { |
|||
return json |
|||
} else { |
|||
throw new Error(json) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Heading size="M">Your Apps</Heading> |
|||
<Divider size="M" /> |
|||
{#await promise} |
|||
<div class="spinner-container"> |
|||
<Spinner size="30" /> |
|||
</div> |
|||
{:then apps} |
|||
<div class="apps"> |
|||
{#each apps as app} |
|||
<AppCard {...app} /> |
|||
{/each} |
|||
</div> |
|||
{:catch err} |
|||
<h1 style="color:red">{err}</h1> |
|||
{/await} |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
margin-top: 10px; |
|||
} |
|||
.apps { |
|||
margin-top: var(--layout-m); |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|||
grid-gap: var(--layout-s); |
|||
justify-content: start; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,80 @@ |
|||
<script> |
|||
import { gradient } from "actions" |
|||
import { |
|||
Heading, |
|||
Button, |
|||
Icon, |
|||
ActionMenu, |
|||
MenuItem, |
|||
Link, |
|||
} from "@budibase/bbui" |
|||
import { url } from "@roxi/routify" |
|||
|
|||
export let app |
|||
export let openApp |
|||
export let exportApp |
|||
export let deleteApp |
|||
export let last |
|||
</script> |
|||
|
|||
<div class="title" class:last> |
|||
<div class="preview" use:gradient={{ seed: app.name }} /> |
|||
<Link href={$url(`../../app/${app._id}`)}> |
|||
<Heading size="XS"> |
|||
{app.name} |
|||
</Heading> |
|||
</Link> |
|||
</div> |
|||
<div class:last> |
|||
Edited {Math.round(Math.random() * 10 + 1)} months ago |
|||
</div> |
|||
<div class:last> |
|||
{#if Math.random() < 0.33} |
|||
<div class="status status--open" /> |
|||
Open |
|||
{:else if Math.random() < 0.33} |
|||
<div class="status status--locked-other" /> |
|||
Locked by Will Wheaton |
|||
{:else} |
|||
<div class="status status--locked-you" /> |
|||
Locked by you |
|||
{/if} |
|||
</div> |
|||
<div class:last> |
|||
<Button on:click={() => openApp(app)} size="S" secondary>Open</Button> |
|||
<ActionMenu align="right"> |
|||
<Icon hoverable slot="control" name="More" /> |
|||
<MenuItem on:click={() => exportApp(app)} icon="Download">Export</MenuItem> |
|||
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem> |
|||
</ActionMenu> |
|||
</div> |
|||
|
|||
<style> |
|||
.preview { |
|||
height: 40px; |
|||
width: 40px; |
|||
border-radius: var(--border-radius-s); |
|||
} |
|||
.title :global(a) { |
|||
text-decoration: none; |
|||
} |
|||
.title :global(h1:hover) { |
|||
color: var(--spectrum-global-color-blue-600); |
|||
cursor: pointer; |
|||
transition: color 130ms ease; |
|||
} |
|||
.status { |
|||
height: 10px; |
|||
width: 10px; |
|||
border-radius: 50%; |
|||
} |
|||
.status--locked-you { |
|||
background-color: var(--spectrum-global-color-orange-600); |
|||
} |
|||
.status--locked-other { |
|||
background-color: var(--spectrum-global-color-red-600); |
|||
} |
|||
.status--open { |
|||
background-color: var(--spectrum-global-color-green-600); |
|||
} |
|||
</style> |
|||
@ -1,15 +0,0 @@ |
|||
<script> |
|||
import { Button, Modal } from "@budibase/bbui" |
|||
import BuilderSettingsModal from "./BuilderSettingsModal.svelte" |
|||
|
|||
let modal |
|||
</script> |
|||
|
|||
<div> |
|||
<Button primary quiet icon="Settings" text on:click={modal.show}> |
|||
Settings |
|||
</Button> |
|||
</div> |
|||
<Modal bind:this={modal} width="30%"> |
|||
<BuilderSettingsModal /> |
|||
</Modal> |
|||
@ -1,83 +0,0 @@ |
|||
<script> |
|||
export let step, done, active |
|||
</script> |
|||
|
|||
<div class="container" class:active class:done> |
|||
<div class="circle" class:active class:done> |
|||
{#if done} |
|||
<svg |
|||
width="12" |
|||
height="10" |
|||
viewBox="0 0 12 10" |
|||
fill="none" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
> |
|||
<path |
|||
fill-rule="evenodd" |
|||
clip-rule="evenodd" |
|||
d="M10.1212 0.319527C10.327 0.115582 10.6047 0.000803464 10.8944 |
|||
4.20219e-06C11.1841 -0.00079506 11.4624 0.11245 11.6693 |
|||
0.315256C11.8762 0.518062 11.9949 0.794134 11.9998 1.08379C12.0048 |
|||
1.37344 11.8955 1.65339 11.6957 1.86313L5.82705 9.19893C5.72619 |
|||
9.30757 5.60445 9.39475 5.46913 9.45527C5.3338 9.51578 5.18766 9.54839 |
|||
5.03944 9.55113C4.89123 9.55388 4.74398 9.52671 4.60651 |
|||
9.47124C4.46903 9.41578 4.34416 9.33316 4.23934 9.22833L0.350925 |
|||
5.33845C0.242598 5.23751 0.155712 5.11578 0.0954499 4.98054C0.0351876 |
|||
4.84529 0.00278364 4.69929 0.00017159 4.55124C-0.00244046 4.4032 |
|||
0.024793 4.25615 0.0802466 4.11886C0.1357 3.98157 0.218238 3.85685 |
|||
0.322937 3.75215C0.427636 3.64746 0.55235 3.56492 0.68964 |
|||
3.50946C0.82693 3.45401 0.973983 3.42678 1.12203 3.42939C1.27007 3.432 |
|||
1.41607 3.46441 1.55132 3.52467C1.68657 3.58493 1.80829 3.67182 |
|||
1.90923 3.78014L4.98762 6.85706L10.0933 0.35187C10.1024 0.340482 |
|||
10.1122 0.329679 10.1227 0.319527H10.1212Z" |
|||
fill="var(--background)" |
|||
/> |
|||
</svg> |
|||
{:else}{step}{/if} |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.container::before { |
|||
content: ""; |
|||
position: absolute; |
|||
top: -30px; |
|||
width: 1px; |
|||
height: 30px; |
|||
background: var(--grey-5); |
|||
} |
|||
.container:first-child::before { |
|||
display: none; |
|||
} |
|||
.container { |
|||
position: relative; |
|||
height: 45px; |
|||
display: grid; |
|||
place-items: center; |
|||
} |
|||
.container.active { |
|||
box-shadow: inset 3px 0 0 0 var(--blue); |
|||
} |
|||
.circle.active { |
|||
background: var(--blue); |
|||
color: white; |
|||
border: none; |
|||
} |
|||
.circle.done { |
|||
background: var(--grey-5); |
|||
color: white; |
|||
border: none; |
|||
} |
|||
.circle { |
|||
color: var(--grey-5); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
display: grid; |
|||
place-items: center; |
|||
width: 30px; |
|||
height: 30px; |
|||
border-radius: 50%; |
|||
border: 1px solid var(--grey-5); |
|||
box-sizing: border-box; |
|||
} |
|||
</style> |
|||
@ -1,6 +0,0 @@ |
|||
<script> |
|||
import { Button } from "@budibase/bbui" |
|||
import { auth } from "stores/backend" |
|||
</script> |
|||
|
|||
<Button primary quiet text icon="LogOut" on:click={auth.logout}>Log Out</Button> |
|||
@ -1,121 +0,0 @@ |
|||
<script> |
|||
import { Label, Heading, Input, notifications } from "@budibase/bbui" |
|||
|
|||
const BYTES_IN_MB = 1000000 |
|||
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5 |
|||
|
|||
export let template |
|||
export let values |
|||
export let errors |
|||
export let touched |
|||
|
|||
let blurred = { appName: false } |
|||
let file |
|||
|
|||
function handleFile(evt) { |
|||
const fileArray = Array.from(evt.target.files) |
|||
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) { |
|||
notifications.error( |
|||
`Files cannot exceed ${ |
|||
FILE_SIZE_LIMIT / BYTES_IN_MB |
|||
}MB. Please try again with smaller files.` |
|||
) |
|||
return |
|||
} |
|||
file = evt.target.files[0] |
|||
template.file = file |
|||
} |
|||
</script> |
|||
|
|||
<div class="container"> |
|||
{#if template?.fromFile} |
|||
<Heading size="L">Import your Web App</Heading> |
|||
{:else} |
|||
<Heading size="L">Create your Web App</Heading> |
|||
{/if} |
|||
{#if template?.fromFile} |
|||
<div class="template"> |
|||
<Label extraSmall grey>Import File</Label> |
|||
<div class="dropzone"> |
|||
<input |
|||
id="file-upload" |
|||
accept=".txt" |
|||
type="file" |
|||
on:change={handleFile} |
|||
/> |
|||
<label for="file-upload" class:uploaded={file}> |
|||
{#if file}{file.name}{:else}Import{/if} |
|||
</label> |
|||
</div> |
|||
</div> |
|||
{:else if template} |
|||
<div class="template"> |
|||
<Label extraSmall grey>Selected Template</Label> |
|||
<Heading size="S">{template.name}</Heading> |
|||
</div> |
|||
{/if} |
|||
<Input |
|||
on:change={() => ($touched.applicationName = true)} |
|||
bind:value={$values.applicationName} |
|||
label="Web App Name" |
|||
placeholder="Enter name of your web application" |
|||
error={$touched.applicationName && $errors.applicationName} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: grid; |
|||
grid-gap: var(--spacing-xl); |
|||
margin-top: var(--spacing-xl); |
|||
} |
|||
|
|||
.template :global(label) { |
|||
/* Fix layout due to LH 0 on heading */ |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.dropzone { |
|||
text-align: center; |
|||
display: flex; |
|||
align-items: center; |
|||
flex-direction: column; |
|||
border-radius: 10px; |
|||
transition: all 0.3s; |
|||
} |
|||
|
|||
.uploaded { |
|||
color: var(--blue); |
|||
} |
|||
|
|||
input[type="file"] { |
|||
display: none; |
|||
} |
|||
|
|||
label { |
|||
font-family: var(--font-sans); |
|||
cursor: pointer; |
|||
font-weight: 500; |
|||
box-sizing: border-box; |
|||
overflow: hidden; |
|||
border-radius: var(--border-radius-s); |
|||
color: var(--ink); |
|||
padding: var(--spacing-m) var(--spacing-l); |
|||
transition: all 0.2s ease 0s; |
|||
display: inline-flex; |
|||
text-rendering: optimizeLegibility; |
|||
min-width: auto; |
|||
outline: none; |
|||
font-feature-settings: "case" 1, "rlig" 1, "calt" 0; |
|||
-webkit-box-align: center; |
|||
user-select: none; |
|||
flex-shrink: 0; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: 100%; |
|||
background-color: var(--grey-2); |
|||
font-size: var(--font-size-xs); |
|||
line-height: normal; |
|||
border: var(--border-transparent); |
|||
} |
|||
</style> |
|||
@ -1,29 +0,0 @@ |
|||
<script> |
|||
import { Select, Heading } from "@budibase/bbui" |
|||
|
|||
export let values |
|||
export let errors |
|||
export let touched |
|||
</script> |
|||
|
|||
<div class="container"> |
|||
<Heading size="L">What's your role for this app?</Heading> |
|||
<Select |
|||
bind:value={$values.roleId} |
|||
label="Role" |
|||
options={[ |
|||
{ label: "Admin", value: "ADMIN" }, |
|||
{ label: "Power User", value: "POWER_USER" }, |
|||
]} |
|||
getOptionLabel={option => option.label} |
|||
getOptionValue={option => option.value} |
|||
error={$errors.roleId} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: grid; |
|||
grid-gap: var(--spacing-xl); |
|||
} |
|||
</style> |
|||
@ -1,2 +0,0 @@ |
|||
export { default as Info } from "./Info.svelte" |
|||
export { default as User } from "./User.svelte" |
|||
@ -0,0 +1,20 @@ |
|||
import { writable } from 'svelte/store' |
|||
import api from "builderStore/api" |
|||
|
|||
export function fetchData (url) { |
|||
const store = writable({status: 'LOADING', data: {}, error: {}}) |
|||
|
|||
async function get() { |
|||
store.update(u => ({...u, status: 'SUCCESS'})) |
|||
try { |
|||
const response = await api.get(url) |
|||
store.set({data: await response.json(), status: 'SUCCESS'}) |
|||
} catch(e) { |
|||
store.set({data: {}, error: e, status: 'ERROR'}) |
|||
} |
|||
} |
|||
|
|||
get() |
|||
|
|||
return {subscribe: store.subscribe, refresh: get} |
|||
} |
|||
@ -1,29 +0,0 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
import { goto } from "@roxi/routify" |
|||
import { |
|||
SideNavigation as Navigation, |
|||
SideNavigationItem as Item, |
|||
} from "@budibase/bbui" |
|||
import { admin } from "stores/portal" |
|||
import LoginForm from "components/login/LoginForm.svelte" |
|||
import BuilderSettingsButton from "components/start/BuilderSettingsButton.svelte" |
|||
import LogoutButton from "components/start/LogoutButton.svelte" |
|||
import Logo from "/assets/budibase-logo.svg" |
|||
import api from "builderStore/api" |
|||
|
|||
let checklist |
|||
|
|||
onMount(async () => { |
|||
await admin.init() |
|||
if (!$admin?.checklist?.adminUser) { |
|||
$goto("./admin") |
|||
} else { |
|||
$goto("./portal") |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
{#if $admin.checklist} |
|||
<slot /> |
|||
{/if} |
|||
@ -1,69 +0,0 @@ |
|||
<script> |
|||
import { |
|||
Button, |
|||
Heading, |
|||
Label, |
|||
notifications, |
|||
Layout, |
|||
Input, |
|||
Body, |
|||
} from "@budibase/bbui" |
|||
import { goto } from "@roxi/routify" |
|||
import { onMount } from "svelte" |
|||
import api from "builderStore/api" |
|||
|
|||
let adminUser = {} |
|||
|
|||
async function save() { |
|||
try { |
|||
// Save the admin user |
|||
const response = await api.post(`/api/admin/users/init`, adminUser) |
|||
|
|||
const json = await response.json() |
|||
if (response.status !== 200) throw new Error(json.message) |
|||
notifications.success(`Admin user created.`) |
|||
$goto("../portal") |
|||
} catch (err) { |
|||
notifications.error(`Failed to create admin user.`) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<div class="container"> |
|||
<header> |
|||
<Heading size="M">Create an admin user</Heading> |
|||
<Body size="S">The admin user has access to everything in budibase.</Body> |
|||
</header> |
|||
<div class="config-form"> |
|||
<Layout gap="S"> |
|||
<Input label="email" bind:value={adminUser.email} /> |
|||
<Input |
|||
label="password" |
|||
type="password" |
|||
bind:value={adminUser.password} |
|||
/> |
|||
<Button cta on:click={save}>Create super admin user</Button> |
|||
</Layout> |
|||
</div> |
|||
</div> |
|||
</section> |
|||
|
|||
<style> |
|||
section { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
height: 100%; |
|||
} |
|||
|
|||
header { |
|||
text-align: center; |
|||
width: 80%; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
.config-form { |
|||
margin-bottom: 42px; |
|||
} |
|||
</style> |
|||
@ -1,116 +1,33 @@ |
|||
<script> |
|||
import { |
|||
SideNavigation as Navigation, |
|||
SideNavigationItem as Item, |
|||
} from "@budibase/bbui" |
|||
import { onMount } from "svelte" |
|||
import { goto } from "@roxi/routify" |
|||
import { auth } from "stores/backend" |
|||
import LoginForm from "components/login/LoginForm.svelte" |
|||
import BuilderSettingsButton from "components/start/BuilderSettingsButton.svelte" |
|||
import LogoutButton from "components/start/LogoutButton.svelte" |
|||
import Logo from "/assets/budibase-logo.svg" |
|||
import { admin } from "stores/portal" |
|||
|
|||
let modal |
|||
</script> |
|||
|
|||
{#if $auth} |
|||
{#if $auth.user} |
|||
<div class="root"> |
|||
<div class="ui-nav"> |
|||
<div class="home-logo"> |
|||
<img src={Logo} alt="Budibase icon" /> |
|||
</div> |
|||
<div class="nav-section"> |
|||
<div class="nav-top"> |
|||
<Navigation> |
|||
<Item href="/builder/" icon="Apps" selected>Apps</Item> |
|||
<Item external href="https://portal.budi.live/" icon="Servers"> |
|||
Hosting |
|||
</Item> |
|||
<Item external href="https://docs.budibase.com/" icon="Book"> |
|||
Documentation |
|||
</Item> |
|||
<Item |
|||
external |
|||
href="https://github.com/Budibase/budibase/discussions" |
|||
icon="PeopleGroup" |
|||
> |
|||
Community |
|||
</Item> |
|||
<Item |
|||
external |
|||
href="https://github.com/Budibase/budibase/issues/new/choose" |
|||
icon="Bug" |
|||
> |
|||
Raise an issue |
|||
</Item> |
|||
</Navigation> |
|||
</div> |
|||
<div class="nav-bottom"> |
|||
<BuilderSettingsButton /> |
|||
<LogoutButton /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="main"> |
|||
<slot /> |
|||
</div> |
|||
</div> |
|||
{:else} |
|||
<section class="login"> |
|||
<LoginForm /> |
|||
</section> |
|||
{/if} |
|||
{/if} |
|||
|
|||
<style> |
|||
.root { |
|||
display: grid; |
|||
grid-template-columns: 260px 1fr; |
|||
height: 100%; |
|||
width: 100%; |
|||
} |
|||
|
|||
.login { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
height: 100%; |
|||
width: 100%; |
|||
} |
|||
let loaded = false |
|||
$: hasAdminUser = !!$admin?.checklist?.adminUser |
|||
|
|||
.main { |
|||
grid-column: 2; |
|||
overflow: auto; |
|||
} |
|||
onMount(async () => { |
|||
await admin.init() |
|||
await auth.checkAuth() |
|||
loaded = true |
|||
}) |
|||
|
|||
.ui-nav { |
|||
grid-column: 1; |
|||
background-color: var(--background); |
|||
padding: 20px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
border-right: var(--border-light); |
|||
// Force creation of an admin user if one doesn't exist |
|||
$: { |
|||
if (loaded && !hasAdminUser) { |
|||
$goto("./admin") |
|||
} |
|||
} |
|||
|
|||
.home-logo { |
|||
cursor: pointer; |
|||
height: 40px; |
|||
margin-bottom: 20px; |
|||
} |
|||
|
|||
.home-logo img { |
|||
height: 40px; |
|||
} |
|||
|
|||
.nav-section { |
|||
margin: 20px 0 0 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: space-between; |
|||
height: 100%; |
|||
// Redirect to log in at any time if the user isn't authenticated |
|||
$: { |
|||
if (loaded && hasAdminUser && !$auth.user) { |
|||
$goto("./auth/login") |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
.nav-bottom :global(> *) { |
|||
margin-top: 5px; |
|||
} |
|||
</style> |
|||
{#if loaded} |
|||
<slot /> |
|||
{/if} |
|||
|
|||
@ -0,0 +1,78 @@ |
|||
<script> |
|||
import { |
|||
Button, |
|||
Heading, |
|||
notifications, |
|||
Layout, |
|||
Input, |
|||
Body, |
|||
} from "@budibase/bbui" |
|||
import { goto } from "@roxi/routify" |
|||
import api from "builderStore/api" |
|||
import { admin } from "stores/portal" |
|||
|
|||
let adminUser = {} |
|||
|
|||
async function save() { |
|||
try { |
|||
// Save the admin user |
|||
const response = await api.post(`/api/admin/users/init`, adminUser) |
|||
const json = await response.json() |
|||
if (response.status !== 200) { |
|||
throw new Error(json.message) |
|||
} |
|||
notifications.success(`Admin user created`) |
|||
await admin.init() |
|||
$goto("../portal") |
|||
} catch (err) { |
|||
notifications.error(`Failed to create admin user`) |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<section> |
|||
<div class="container"> |
|||
<Layout gap="XS"> |
|||
<img src="https://i.imgur.com/ZKyklgF.png" /> |
|||
</Layout> |
|||
<div class="center"> |
|||
<Layout gap="XS"> |
|||
<Heading size="M">Create an admin user</Heading> |
|||
<Body size="M" |
|||
>The admin user has access to everything in Budibase.</Body |
|||
> |
|||
</Layout> |
|||
</div> |
|||
<Layout gap="XS"> |
|||
<Input label="Email" bind:value={adminUser.email} /> |
|||
<Input label="Password" type="password" bind:value={adminUser.password} /> |
|||
</Layout> |
|||
<Layout gap="S"> |
|||
<Button cta on:click={save}>Create super admin user</Button> |
|||
</Layout> |
|||
</div> |
|||
</section> |
|||
|
|||
<style> |
|||
section { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
height: 100%; |
|||
} |
|||
.container { |
|||
margin: 0 auto; |
|||
width: 260px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
.center { |
|||
text-align: center; |
|||
} |
|||
img { |
|||
width: 40px; |
|||
margin: 0 auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,4 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
$goto("../portal") |
|||
</script> |
|||
@ -0,0 +1,4 @@ |
|||
<script> |
|||
import { goto } from "@roxi/routify" |
|||
$goto("./login") |
|||
</script> |
|||
@ -0,0 +1,5 @@ |
|||
<script> |
|||
import LoginForm from "components/login/LoginForm.svelte" |
|||
</script> |
|||
|
|||
<LoginForm /> |
|||
@ -0,0 +1,231 @@ |
|||
<script> |
|||
import { |
|||
Heading, |
|||
Layout, |
|||
Button, |
|||
ActionButton, |
|||
ActionGroup, |
|||
ButtonGroup, |
|||
Select, |
|||
Modal, |
|||
ModalContent, |
|||
Page, |
|||
notifications, |
|||
Body, |
|||
} from "@budibase/bbui" |
|||
import CreateAppModal from "components/start/CreateAppModal.svelte" |
|||
import api, { del } from "builderStore/api" |
|||
import analytics from "analytics" |
|||
import { onMount } from "svelte" |
|||
import { apps } from "stores/portal" |
|||
import download from "downloadjs" |
|||
import { goto } from "@roxi/routify" |
|||
import ConfirmDialog from "components/common/ConfirmDialog.svelte" |
|||
import AppCard from "components/start/AppCard.svelte" |
|||
import AppRow from "components/start/AppRow.svelte" |
|||
|
|||
let layout = "grid" |
|||
let template |
|||
let appToDelete |
|||
let creationModal |
|||
let deletionModal |
|||
let creatingApp = false |
|||
let loaded = false |
|||
|
|||
const checkKeys = async () => { |
|||
const response = await api.get(`/api/keys/`) |
|||
const keys = await response.json() |
|||
if (keys.userId) { |
|||
analytics.identify(keys.userId) |
|||
} |
|||
} |
|||
|
|||
const initiateAppCreation = () => { |
|||
creationModal.show() |
|||
creatingApp = true |
|||
} |
|||
|
|||
const initiateAppImport = () => { |
|||
template = { fromFile: true } |
|||
creationModal.show() |
|||
creatingApp = true |
|||
} |
|||
|
|||
const stopAppCreation = () => { |
|||
template = null |
|||
creatingApp = false |
|||
} |
|||
|
|||
const openApp = app => { |
|||
$goto(`../../app/${app._id}`) |
|||
} |
|||
|
|||
const exportApp = app => { |
|||
try { |
|||
download( |
|||
`/api/backups/export?appId=${app._id}&appname=${encodeURIComponent( |
|||
app.name |
|||
)}` |
|||
) |
|||
notifications.success("App export complete") |
|||
} catch (err) { |
|||
console.error(err) |
|||
notifications.error("App export failed") |
|||
} |
|||
} |
|||
|
|||
const deleteApp = app => { |
|||
appToDelete = app |
|||
deletionModal.show() |
|||
} |
|||
|
|||
const confirmDeleteApp = async () => { |
|||
if (!appToDelete) { |
|||
return |
|||
} |
|||
await del(`/api/applications/${appToDelete?._id}`) |
|||
await apps.load() |
|||
appToDelete = null |
|||
} |
|||
|
|||
onMount(async () => { |
|||
checkKeys() |
|||
await apps.load() |
|||
loaded = true |
|||
}) |
|||
</script> |
|||
|
|||
<Page wide> |
|||
{#if $apps.length} |
|||
<Layout noPadding> |
|||
<div class="title"> |
|||
<Heading>Apps</Heading> |
|||
<ButtonGroup> |
|||
<Button secondary on:click={initiateAppImport}>Import app</Button> |
|||
<Button cta on:click={initiateAppCreation}>Create new app</Button> |
|||
</ButtonGroup> |
|||
</div> |
|||
<div class="filter"> |
|||
<div class="select"> |
|||
<Select quiet placeholder="Filter by groups" /> |
|||
</div> |
|||
<ActionGroup> |
|||
<ActionButton |
|||
on:click={() => (layout = "grid")} |
|||
selected={layout === "grid"} |
|||
quiet |
|||
icon="ClassicGridView" |
|||
/> |
|||
<ActionButton |
|||
on:click={() => (layout = "table")} |
|||
selected={layout === "table"} |
|||
quiet |
|||
icon="ViewRow" |
|||
/> |
|||
</ActionGroup> |
|||
</div> |
|||
<div |
|||
class:appGrid={layout === "grid"} |
|||
class:appTable={layout === "table"} |
|||
> |
|||
{#each $apps as app, idx (app._id)} |
|||
<svelte:component |
|||
this={layout === "grid" ? AppCard : AppRow} |
|||
{app} |
|||
{openApp} |
|||
{exportApp} |
|||
{deleteApp} |
|||
last={idx === $apps.length - 1} |
|||
/> |
|||
{/each} |
|||
</div> |
|||
</Layout> |
|||
{/if} |
|||
{#if !$apps.length && !creatingApp && loaded} |
|||
<div class="empty-wrapper"> |
|||
<Modal inline> |
|||
<ModalContent |
|||
title="Create your first app" |
|||
confirmText="Create app" |
|||
showCancelButton={false} |
|||
showCloseIcon={false} |
|||
onConfirm={initiateAppCreation} |
|||
size="M" |
|||
> |
|||
<div slot="footer"> |
|||
<Button on:click={initiateAppImport} secondary>Import app</Button> |
|||
</div> |
|||
<Body size="S"> |
|||
The purpose of the Budibase builder is to help you build beautiful, |
|||
powerful applications quickly and easily. |
|||
</Body> |
|||
</ModalContent> |
|||
</Modal> |
|||
</div> |
|||
{/if} |
|||
</Page> |
|||
<Modal |
|||
bind:this={creationModal} |
|||
padding={false} |
|||
width="600px" |
|||
on:hide={stopAppCreation} |
|||
> |
|||
<CreateAppModal {template} /> |
|||
</Modal> |
|||
<ConfirmDialog |
|||
bind:this={deletionModal} |
|||
title="Confirm deletion" |
|||
okText="Delete app" |
|||
onOk={confirmDeleteApp} |
|||
> |
|||
Are you sure you want to delete the app <b>{appToDelete?.name}</b>? |
|||
</ConfirmDialog> |
|||
|
|||
<style> |
|||
.title, |
|||
.filter { |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
|
|||
.select { |
|||
width: 120px; |
|||
} |
|||
|
|||
.appGrid { |
|||
display: grid; |
|||
grid-gap: 50px; |
|||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
|||
} |
|||
.appTable { |
|||
display: grid; |
|||
grid-template-rows: auto; |
|||
grid-template-columns: 1fr 1fr 1fr auto; |
|||
align-items: center; |
|||
} |
|||
.appTable :global(> div) { |
|||
height: 70px; |
|||
display: grid; |
|||
align-items: center; |
|||
gap: var(--spacing-xl); |
|||
grid-template-columns: auto 1fr; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
padding: 0 var(--spacing-s); |
|||
} |
|||
.appTable :global(> div:not(.last)) { |
|||
border-bottom: var(--border-light); |
|||
} |
|||
|
|||
.empty-wrapper { |
|||
flex: 1 1 auto; |
|||
height: 100%; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
</style> |
|||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue