mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
166 lines
7.5 KiB
Svelte
166 lines
7.5 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { ICONS, load, nodeById, PALETTE, save, STORE, withoutSecrets } from '../../deploy-canvas.js';
|
|
import Dropdown from './Dropdown.svelte';
|
|
import Edges from './Edges.svelte';
|
|
import Icon from './Icon.svelte';
|
|
import Node from './Node.svelte';
|
|
import PaletteItem from './PaletteItem.svelte';
|
|
import RunPanel from './RunPanel.svelte';
|
|
import { closeMenu, cv, dock, els, fit, hideResult, leave, pan, pickNext, PORT_Y, resetCanvas } from './state.svelte.js';
|
|
|
|
// The deploy canvas: header, step palette, and the canvas with the step
|
|
// cards. The state lives in state.svelte.js (init() runs before the mount).
|
|
let zoomLabel = $state();
|
|
// Theme: same as Coolify (html.dark).
|
|
let dark = $state(document.documentElement.classList.contains('dark'));
|
|
// Palette: full or icons only ('coolify-canvas:collapsed').
|
|
let folded = $state(load('collapsed', false));
|
|
// Small screens: a notice that the canvas is not optimized for mobile. CSS
|
|
// shows it below 768px; closed once, it stays closed.
|
|
let noticeOpen = $state(!load('mobile-notice-dismissed', false));
|
|
|
|
const stop = e => e.stopPropagation();
|
|
// A pill joined to the Deploy card: each address opens in a new tab.
|
|
const resultNode = $derived(cv.result && nodeById(cv.result.after, cv.flow));
|
|
const foldTitle = $derived(folded ? 'Show the step names' : 'Collapse the steps');
|
|
|
|
function toggleFolded() {
|
|
save('collapsed', !load('collapsed', false));
|
|
folded = load('collapsed', false);
|
|
}
|
|
function closeNotice() {
|
|
save('mobile-notice-dismissed', true);
|
|
noticeOpen = false;
|
|
}
|
|
// Opens the app's confirmation modal (deploy-canvas.blade.php), which
|
|
// dispatches deploy-canvas-reset on confirm.
|
|
function askReset() {
|
|
if (!cv.running) document.getElementById('deploy-canvas-reset-trigger')?.click();
|
|
}
|
|
|
|
// Save the flow (without secrets) and the view, 200 ms after a change.
|
|
let saveTimer = 0;
|
|
$effect(() => {
|
|
const flow = JSON.stringify(withoutSecrets(cv.flow));
|
|
const view = JSON.stringify(cv.view);
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(() => {
|
|
localStorage.setItem(`${STORE}:flow`, flow);
|
|
localStorage.setItem(`${STORE}:view`, view);
|
|
}, 200);
|
|
});
|
|
|
|
onMount(() => {
|
|
els.pz.apply();
|
|
if (els.fitOnOpen) requestAnimationFrame(fit);
|
|
const theme = new MutationObserver(() => { dark = document.documentElement.classList.contains('dark'); });
|
|
theme.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
|
const escape = e => { if (els.root?.isConnected && e.key === 'Escape') closeMenu(); };
|
|
const reset = () => { if (els.root?.isConnected) resetCanvas(); };
|
|
addEventListener('keydown', escape);
|
|
addEventListener('deploy-canvas-reset', reset);
|
|
return () => {
|
|
theme.disconnect();
|
|
removeEventListener('keydown', escape);
|
|
removeEventListener('deploy-canvas-reset', reset);
|
|
leave();
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="cv-root" class:cv-dark={dark} class:cv-folded={folded}>
|
|
<!-- Header: placed by dock (app top bar, floating panel, or a box above the canvas). -->
|
|
<div use:dock>
|
|
<div class="mr-auto flex min-w-0 items-center gap-2">
|
|
{#if cv.target}
|
|
<!-- Opened from an environment's New resource page: back to that environment. -->
|
|
<a class="cv-back button px-2!" href="/project/{cv.target.project}/environment/{cv.target.environment}" title="Back to the environment" aria-label="Back to the environment"><Icon name="back" cls="size-4" /></a>
|
|
{/if}
|
|
<b class="shrink-0 text-[14px] font-semibold text-black dark:text-fg">Deploy canvas</b>
|
|
<span class="shrink-0 rounded-full border border-coollabs/30 bg-coollabs/10 px-2 py-0.5 text-xs font-medium text-coollabs dark:border-warning/30 dark:bg-warning/15 dark:text-warning">Beta</span>
|
|
</div>
|
|
<div class="flex shrink-0 items-center gap-2">
|
|
<!-- Zoom controls, as on the environment canvas header. -->
|
|
<div class="flex items-center">
|
|
<button type="button" class="button px-2! rounded-r-none!" title="Zoom out" aria-label="Zoom out" onclick={() => els.pz.zoomTo(cv.view.z / 1.2)}><Icon name={ICONS.minus} cls="size-4" /></button>
|
|
<!-- Its text is the zoom level, written by panZoom. -->
|
|
<button bind:this={zoomLabel} type="button" class="button rounded-none! border-x-0! w-14 tabular-nums" title="Reset zoom to 100%" onclick={() => els.pz.zoomTo(1)}>100%</button>
|
|
<button type="button" class="button px-2! rounded-l-none!" title="Zoom in" aria-label="Zoom in" onclick={() => els.pz.zoomTo(cv.view.z * 1.2)}><Icon name={ICONS.plus} cls="size-4" /></button>
|
|
</div>
|
|
<button type="button" class="button px-2!" title="Fit to screen" aria-label="Fit to screen" onclick={fit}><Icon name={ICONS.fit} cls="size-4" />Fit</button>
|
|
<button type="button" class="button" title="Start again with an empty canvas" onclick={askReset}>Reset</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if noticeOpen}
|
|
<div class="cv-island cv-notice" role="note">
|
|
<Icon name={ICONS.info} />
|
|
<p>The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}<a href="/project/{cv.target.project}/environment/{cv.target.environment}/new">New resource page</a>.{/if}</p>
|
|
<button type="button" class="cv-x" title="Close" aria-label="Close this notice" onclick={closeNotice}><Icon name={ICONS.x} /></button>
|
|
</div>
|
|
{/if}
|
|
|
|
<aside class="cv-island cv-palette">
|
|
<div class="cv-palette-head">
|
|
<div><b>Steps</b><span>Drag one onto the canvas</span></div>
|
|
<button type="button" class="cv-x cv-fold" title={foldTitle} aria-label={foldTitle} aria-expanded={!folded} onclick={toggleFolded}><Icon name={ICONS.fold} /></button>
|
|
</div>
|
|
{#each PALETTE as [group, types]}
|
|
<h4>{group}</h4>
|
|
{#each types as type}
|
|
<PaletteItem {type} />
|
|
{/each}
|
|
{/each}
|
|
</aside>
|
|
|
|
<section class="cv-island cv-stage">
|
|
<div class="cv-viewport" use:pan={() => zoomLabel}>
|
|
<div class="cv-world">
|
|
<Edges />
|
|
{#each cv.flow.nodes as n (n.id)}
|
|
<Node {n} />
|
|
{/each}
|
|
{#if resultNode}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="cv-result"
|
|
style:left="{resultNode.x + (cv.sizes[resultNode.id]?.w || 296) + 72}px"
|
|
style:top="{resultNode.y + PORT_Y - 19}px"
|
|
onpointerdown={stop}
|
|
>
|
|
<span class="cv-port in"></span>
|
|
<i></i>
|
|
{#if cv.result.urls.length}
|
|
{#each cv.result.urls.slice(0, 3) as url}
|
|
<a href={url} target="_blank" rel="noopener noreferrer" title="Open {url}">{url.replace(/^https?:\/\//, '')}<Icon name={ICONS.out} /></a>
|
|
{/each}
|
|
{:else}
|
|
<a href={cv.result.href} target="_blank">Open in Coolify</a>
|
|
{/if}
|
|
<button class="cv-x" title="Hide" onclick={hideResult}><Icon name={ICONS.x} /></button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<RunPanel />
|
|
{#if cv.menu}
|
|
<div class="cv-menu" style:left="{cv.menu.left}px" style:top="{cv.menu.top}px">
|
|
<h5>Add the next step</h5>
|
|
{#each cv.menu.valid as type}
|
|
<PaletteItem {type} onpick={() => pickNext(type)} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if cv.toast}
|
|
<div class="cv-toast" class:bad={cv.toast.bad}>{cv.toast.text}</div>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if cv.dropdown}
|
|
{#key cv.dropdown?.trigger}
|
|
<Dropdown />
|
|
{/key}
|
|
{/if}
|
|
</div>
|