mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat: Add image when publishing a project (#10310)
* refactor(hub): remove per-item Publish to Hub entry points Publishing to the Hub now happens exclusively through the folder-level deploy-to-hub flow (/folders). Remove the standalone entry points: - script detail page menu item (and the SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB const that gated it) - script list row dropdown item - raw app editor menu item, its zip-download drawer and publishToHub() - long-dead commented block in AppEditorHeader Also drop the now-orphaned URL helpers (scriptToHubUrl, flowToHubUrl, appToHubUrl, rawAppToHubUrl) from lib/hub.ts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(hub): upload a custom project logo from the deploy-to-hub drawer Add a Logo field to the bundle metadata form: a drag-and-drop dropzone (png/svg, 512KB client-side cap mirrored server-side by the Hub) that turns into a live replica of the Hub project card once an image is picked, so the logo can be judged in context before publishing. The logo is pushed after the draft's items/migrations via the new POST /projects/{slug}/logo proxy in hub_publish.rs (slug validated by construction, `logo: null` forwarded to clear). Leaving the field empty never touches the Hub's existing logo, so re-publishing a bundle keeps it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hub): logo removal, safer mime inference, explicit clear semantics Review follow-ups on the project logo upload: - Removing a published logo is now possible: hubLogo is three-state (undefined = leave the Hub's logo alone, null = clear on publish, object = upload). Rehydration reads has_logo so the drawer shows a "Remove on publish" affordance when the Hub already has one, with an undo banner before publishing. - hub_publish.rs uses a double-Option for the logo field: a missing `logo` key is now a 400 instead of being serialized as `logo: null`, which the Hub interprets as an explicit clear — POSTing `{}` can no longer silently delete a project's logo. - Client mime inference prefers the browser-reported file.type over the filename extension, so a PNG misnamed *.svg no longer produces a broken preview and a guaranteed server-side sniff rejection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Update frontend/src/lib/components/workspaceSettings/deployToHubSession.svelte.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update frontend/src/lib/components/workspaceSettings/DeployToHub.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix(hub): validate logo size/mime/base64 in the proxy, document the endpoint - Enforce the logo constraints in windmill-api itself instead of relying on the browser and remote Hub: a route-level DefaultBodyLimit sized for a max logo in base64 (+JSON envelope) overrides the global request limit, and the handler validates the mime allowlist, base64 alphabet and decoded length (512KB cap) before anything is forwarded. - Add /w/{workspace}/hub/projects/{slug}/logo to openapi.yaml (with the ProjectLogoBody schema) and regenerate the frontend client. - Drop a narrating comment on the hidden file input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
cubic-dev-ai[bot]
parent
f00fcb2d1b
commit
48618cff8c
@@ -23313,6 +23313,42 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/projects/{slug}/logo:
|
||||
post:
|
||||
summary: set or clear a hub project's custom logo
|
||||
description: |
|
||||
Requires the caller to be a workspace admin. Sets the project's custom
|
||||
logo (base64 png/svg, decoded size max 512KB) or clears it when `logo`
|
||||
is null; the `logo` field itself is required so an empty body cannot
|
||||
clear the logo by accident. Forwards the request to the configured Hub
|
||||
scoped to the `{workspace}:{folder}` source and returns the Hub's
|
||||
status code and raw response body.
|
||||
operationId: publishHubProjectLogo
|
||||
tags:
|
||||
- hubPublish
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: slug
|
||||
in: path
|
||||
required: true
|
||||
description: hub project slug
|
||||
schema:
|
||||
$ref: "#/components/schemas/HubProjectSlug"
|
||||
- $ref: "#/components/parameters/HubPublishFolder"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProjectLogoBody"
|
||||
responses:
|
||||
"200":
|
||||
description: raw Hub response body (status code is passed through from the Hub)
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/hub/projects/{slug}/submit:
|
||||
post:
|
||||
summary: submit a hub project draft for review
|
||||
@@ -32495,6 +32531,26 @@ components:
|
||||
recording:
|
||||
type: object
|
||||
|
||||
ProjectLogoBody:
|
||||
type: object
|
||||
properties:
|
||||
logo:
|
||||
description: the logo to set, or null to clear the project's current logo
|
||||
nullable: true
|
||||
type: object
|
||||
properties:
|
||||
b64:
|
||||
description: base64-encoded image bytes (decoded size max 512KB)
|
||||
type: string
|
||||
mime:
|
||||
type: string
|
||||
enum: [image/png, image/svg+xml]
|
||||
required:
|
||||
- b64
|
||||
- mime
|
||||
required:
|
||||
- logo
|
||||
|
||||
PublishResourceTypeBody:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::auth::Tokened;
|
||||
use crate::db::ApiAuthed;
|
||||
use crate::HTTP_CLIENT;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Json, Path, Query, RawPathParams},
|
||||
extract::{DefaultBodyLimit, FromRequestParts, Json, Path, Query, RawPathParams},
|
||||
http::{request::Parts, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
@@ -32,6 +32,10 @@ pub fn workspaced_service() -> Router {
|
||||
"/projects/{slug}/pipeline_recording",
|
||||
post(publish_pipeline_recording),
|
||||
)
|
||||
.route(
|
||||
"/projects/{slug}/logo",
|
||||
post(publish_project_logo).layer(DefaultBodyLimit::max(LOGO_BODY_LIMIT)),
|
||||
)
|
||||
.route("/resource_types", post(publish_resource_type))
|
||||
.route("/resources", post(publish_resources))
|
||||
.route("/triggers", post(publish_triggers))
|
||||
@@ -359,6 +363,81 @@ async fn publish_pipeline_recording(
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ProjectLogoInner {
|
||||
b64: String,
|
||||
mime: String,
|
||||
}
|
||||
|
||||
// Custom project logo (png/svg, base64). Double-Option so a missing `logo`
|
||||
// key is distinguishable from an explicit `logo: null` (which clears the
|
||||
// logo on the Hub) — otherwise POSTing `{}` would silently delete it.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ProjectLogoBody {
|
||||
#[serde(default, deserialize_with = "deserialize_explicit")]
|
||||
logo: Option<Option<ProjectLogoInner>>,
|
||||
}
|
||||
|
||||
fn deserialize_explicit<'de, D: Deserializer<'de>>(
|
||||
d: D,
|
||||
) -> Result<Option<Option<ProjectLogoInner>>, D::Error> {
|
||||
Option::<ProjectLogoInner>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
// Mirrors the Hub's own limits so an oversized/invalid payload is rejected
|
||||
// here instead of being deserialized, copied and forwarded first. The route
|
||||
// also carries a DefaultBodyLimit sized for a max logo in base64 + JSON
|
||||
// envelope, overriding the much larger global request limit.
|
||||
const MAX_LOGO_BYTES: usize = 512 * 1024;
|
||||
const LOGO_BODY_LIMIT: usize = MAX_LOGO_BYTES / 3 * 4 + 16 * 1024;
|
||||
const ALLOWED_LOGO_MIMES: [&str; 2] = ["image/png", "image/svg+xml"];
|
||||
|
||||
fn validate_logo(inner: &ProjectLogoInner) -> Result<(), Error> {
|
||||
if !ALLOWED_LOGO_MIMES.contains(&inner.mime.as_str()) {
|
||||
return Err(Error::BadRequest(
|
||||
"logo mime must be image/png or image/svg+xml".to_string(),
|
||||
));
|
||||
}
|
||||
let b = inner.b64.as_bytes();
|
||||
let padding = b.iter().rev().take_while(|&&c| c == b'=').count();
|
||||
let valid = !b.is_empty()
|
||||
&& b.len() % 4 == 0
|
||||
&& padding <= 2
|
||||
&& b[..b.len() - padding]
|
||||
.iter()
|
||||
.all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/');
|
||||
if !valid {
|
||||
return Err(Error::BadRequest("invalid base64".to_string()));
|
||||
}
|
||||
if b.len() / 4 * 3 - padding > MAX_LOGO_BYTES {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"logo too large (max {}KB)",
|
||||
MAX_LOGO_BYTES / 1024
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_project_logo(
|
||||
ctx: HubPublishCtx,
|
||||
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
|
||||
Json(body): Json<ProjectLogoBody>,
|
||||
) -> Result<impl IntoResponse, Error> {
|
||||
let Some(logo) = body.logo else {
|
||||
return Err(Error::BadRequest(
|
||||
"logo field is required: an object to set it, or null to clear it".to_string(),
|
||||
));
|
||||
};
|
||||
if let Some(inner) = &logo {
|
||||
validate_logo(inner)?;
|
||||
}
|
||||
ctx.post(
|
||||
&format!("/projects/{}/logo", slug),
|
||||
&serde_json::json!({ "logo": logo }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct PublishResourceTypeBody {
|
||||
name: String,
|
||||
|
||||
@@ -599,15 +599,6 @@
|
||||
window.open(computeSecretUrl(secretUrl), '_blank')
|
||||
}
|
||||
},
|
||||
// {
|
||||
// displayName: 'Publish to Hub',
|
||||
// icon: faGlobe,
|
||||
// action: () => {
|
||||
// const url = appToHubUrl(toStatic($app, $staticExporter, $summary, $hubBaseUrlStore))
|
||||
// window.open(url.toString(), '_blank')
|
||||
// }
|
||||
// },
|
||||
|
||||
{
|
||||
displayName: 'App inputs',
|
||||
icon: FormInput,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { hubBaseUrlStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -36,7 +36,6 @@
|
||||
Shield,
|
||||
Trash,
|
||||
History,
|
||||
Globe2,
|
||||
FileText
|
||||
} from 'lucide-svelte'
|
||||
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
|
||||
@@ -47,7 +46,6 @@
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
|
||||
import { scriptToHubUrl } from '$lib/hub'
|
||||
import { buildForkEditUrl, editInForkAllowed, editInForkLabel } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
|
||||
@@ -406,29 +404,6 @@
|
||||
copyToClipboard(script.path)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: Globe2,
|
||||
action: async () => {
|
||||
const scriptData = await ScriptService.getScriptByPath({
|
||||
workspace: $workspaceStore!,
|
||||
path: script.path
|
||||
})
|
||||
window.open(
|
||||
scriptToHubUrl(
|
||||
scriptData.content,
|
||||
scriptData.summary,
|
||||
scriptData.description ?? '',
|
||||
scriptData.kind,
|
||||
scriptData.language,
|
||||
scriptData.schema,
|
||||
scriptData.lock ?? '',
|
||||
$hubBaseUrlStore
|
||||
).toString(),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: script.archived ? 'Unarchive' : 'Archive',
|
||||
icon: Archive,
|
||||
|
||||
@@ -10,22 +10,17 @@
|
||||
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
|
||||
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
||||
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
hubBaseUrlStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import YAML from 'yaml'
|
||||
import {
|
||||
Bug,
|
||||
DiffIcon,
|
||||
Download,
|
||||
EllipsisVertical,
|
||||
FileJson,
|
||||
Globe,
|
||||
History,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
@@ -295,8 +290,6 @@
|
||||
|
||||
let saveDrawerOpen = $state(false)
|
||||
let historyBrowserDrawerOpen = $state(false)
|
||||
let publishToHubDrawerOpen = $state(false)
|
||||
let publishingToHub = $state(false)
|
||||
let deploymentMsg: string | undefined = $state(undefined)
|
||||
|
||||
// Top-bar responsive collapse — container width, not viewport.
|
||||
@@ -307,37 +300,6 @@
|
||||
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
||||
const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md')
|
||||
|
||||
async function publishToHub() {
|
||||
if (!app) return
|
||||
publishingToHub = true
|
||||
try {
|
||||
const { default: JSZip } = await import('jszip')
|
||||
const { js, css } = await getBundle()
|
||||
const zip = new JSZip()
|
||||
zip.file('app.yaml', YAML.stringify(app))
|
||||
zip.file('bundle.js', js)
|
||||
zip.file('bundle.css', css)
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
|
||||
// Download the zip
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(appPath || 'raw-app').replaceAll('/', '__')}.zip`
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100)
|
||||
|
||||
// Open hub page
|
||||
const hubUrl = rawAppToHubUrl(
|
||||
$hubBaseUrlStore,
|
||||
summary || appPath.split('/').pop()?.replace('_', ' ') || 'my raw app'
|
||||
)
|
||||
window.open(hubUrl.toString(), '_blank')
|
||||
} finally {
|
||||
publishingToHub = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeSaveDrawer() {
|
||||
saveDrawerOpen = false
|
||||
}
|
||||
@@ -615,13 +577,6 @@
|
||||
displayName: 'Edit in YAML',
|
||||
icon: FileJson,
|
||||
action: () => onOpenYamlEditor?.()
|
||||
},
|
||||
{
|
||||
displayName: 'Publish to Hub',
|
||||
icon: Globe,
|
||||
action: () => {
|
||||
publishToHubDrawerOpen = true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
@@ -744,42 +699,6 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<Drawer bind:open={publishToHubDrawerOpen} size="600px">
|
||||
<DrawerContent title="Publish to Hub" on:close={() => (publishToHubDrawerOpen = false)}>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
loading={publishingToHub}
|
||||
disabled={!app}
|
||||
on:click={publishToHub}
|
||||
variant="accent"
|
||||
startIcon={{ icon: Download }}
|
||||
>
|
||||
Download & open hub
|
||||
</Button>
|
||||
{/snippet}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-secondary text-sm">
|
||||
This will download a zip file containing your raw app bundle and open the Windmill Hub
|
||||
submission page.
|
||||
</p>
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold mb-2">The zip file will contain:</p>
|
||||
<ul class="list-disc list-inside text-secondary space-y-1">
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">app.yaml</code> - App configuration</li
|
||||
>
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.js</code> - JavaScript bundle</li
|
||||
>
|
||||
<li
|
||||
><code class="text-xs bg-surface-secondary px-1 rounded">bundle.css</code> - CSS styles</li
|
||||
>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<AppJobsDrawer
|
||||
bind:open={jobsDrawerOpen}
|
||||
on:clear={() => {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
Eye,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
LayoutDashboard,
|
||||
Loader2,
|
||||
@@ -99,6 +100,56 @@
|
||||
async function confirmPublish() {
|
||||
if (await deployHub.session?.confirmPublish()) publishDrawer?.closeDrawer()
|
||||
}
|
||||
// Client-side mirror of the Hub's logo constraints (it re-validates server-side).
|
||||
const MAX_LOGO_BYTES = 512 * 1024
|
||||
let logoDragOver = $state(false)
|
||||
let logoFileInput = $state<HTMLInputElement | undefined>()
|
||||
async function handleLogoFile(file: File | undefined) {
|
||||
const s = deployHub.session
|
||||
if (!s || !file) return
|
||||
// Browser-reported type wins over the extension: a PNG misnamed *.svg
|
||||
// must be treated as PNG or the Hub's content sniff rejects it later.
|
||||
const lower = file.name.toLowerCase()
|
||||
const mime =
|
||||
file.type
|
||||
? file.type === 'image/png'
|
||||
? 'image/png'
|
||||
: file.type === 'image/svg+xml'
|
||||
? 'image/svg+xml'
|
||||
: undefined
|
||||
: lower.endsWith('.png')
|
||||
? 'image/png'
|
||||
: lower.endsWith('.svg')
|
||||
? 'image/svg+xml'
|
||||
: undefined
|
||||
if (!mime) {
|
||||
sendUserToast('Logo must be a PNG or SVG file', true)
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
sendUserToast('Logo too large (max 512KB)', true)
|
||||
return
|
||||
}
|
||||
const buf = new Uint8Array(await file.arrayBuffer())
|
||||
let bin = ''
|
||||
for (let i = 0; i < buf.length; i += 0x8000) {
|
||||
bin += String.fromCharCode(...buf.subarray(i, i + 0x8000))
|
||||
}
|
||||
s.hubLogo = { b64: btoa(bin), mime, name: file.name }
|
||||
}
|
||||
async function onLogoPicked(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Reset so re-picking the same file re-fires `change`.
|
||||
input.value = ''
|
||||
await handleLogoFile(file)
|
||||
}
|
||||
async function onLogoDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
logoDragOver = false
|
||||
await handleLogoFile(e.dataTransfer?.files?.[0])
|
||||
}
|
||||
|
||||
async function copyIframeSnippet(url: string) {
|
||||
const snippet = `<iframe src="${url}" width="100%" height="600" frameborder="0"></iframe>`
|
||||
try {
|
||||
@@ -1114,6 +1165,121 @@
|
||||
inputProps={{ placeholder: 'Short one-liner shown on the Hub card' }}
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-semibold text-primary">{s.hubLogo ? 'Preview' : 'Logo'}</span>
|
||||
{#if s.hubLogo}
|
||||
<!-- Live replica of the Hub project card so the user can judge the
|
||||
logo in its real context before publishing. -->
|
||||
<span class="text-[11px] text-hint">
|
||||
This is how your project card will look on the Hub.
|
||||
</span>
|
||||
<div class="mt-1 flex flex-col items-center gap-2">
|
||||
<div class="w-64 overflow-hidden rounded-2xl border shadow-sm">
|
||||
<div
|
||||
class="flex h-32 items-center justify-center bg-surface-secondary/50 px-4"
|
||||
>
|
||||
<img
|
||||
src={`data:${s.hubLogo.mime};base64,${s.hubLogo.b64}`}
|
||||
alt="Project logo preview"
|
||||
class="max-h-16 max-w-36 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="border-t bg-surface p-3">
|
||||
<div class="truncate text-xs font-medium text-primary">
|
||||
{s.hubName.trim() || 'Project name'}
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 text-[11px] text-secondary">
|
||||
{s.hubSummary.trim() || s.hubName.trim() || 'Short one-liner shown on the Hub card'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: ImageIcon }}
|
||||
onclick={() => logoFileInput?.click()}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: X }}
|
||||
onclick={() => (s.hubLogo = undefined)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if s.hubLogo === null}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 rounded border border-orange-300 bg-orange-50 px-3 py-2 dark:border-orange-800 dark:bg-orange-950/30"
|
||||
>
|
||||
<span class="text-orange-700 dark:text-orange-300">
|
||||
The project's current logo will be removed when you publish.
|
||||
</span>
|
||||
<Button size="xs" variant="default" onclick={() => (s.hubLogo = undefined)}>
|
||||
Undo
|
||||
</Button>
|
||||
</div>
|
||||
{:else if s.hubHasRemoteLogo}
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 rounded border bg-surface-secondary/50 px-3 py-2"
|
||||
>
|
||||
<span class="text-secondary">
|
||||
This project already has a custom logo on the Hub.
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
startIcon={{ icon: X }}
|
||||
onclick={() => (s.hubLogo = null)}
|
||||
>
|
||||
Remove on publish
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class={`group flex w-full cursor-pointer flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed px-4 py-6 text-center transition-colors ${
|
||||
logoDragOver
|
||||
? 'border-blue-400 bg-blue-50 dark:bg-blue-950/30'
|
||||
: 'text-secondary hover:border-blue-300 hover:bg-surface-secondary/50'
|
||||
}`}
|
||||
onclick={() => logoFileInput?.click()}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault()
|
||||
logoDragOver = true
|
||||
}}
|
||||
ondragleave={() => (logoDragOver = false)}
|
||||
ondrop={onLogoDrop}
|
||||
>
|
||||
<ImageIcon
|
||||
size={20}
|
||||
class="text-tertiary transition-colors group-hover:text-secondary"
|
||||
/>
|
||||
<span class="font-medium text-primary">
|
||||
Drop an image or <span class="text-blue-500">browse</span>
|
||||
</span>
|
||||
<span class="text-[11px] text-hint">PNG or SVG, max 512KB</span>
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={logoFileInput}
|
||||
type="file"
|
||||
accept=".png,.svg,image/png,image/svg+xml"
|
||||
style="display: none"
|
||||
onchange={onLogoPicked}
|
||||
/>
|
||||
{#if s.hubLogo === undefined}
|
||||
<span class="text-[11px] text-hint">
|
||||
Optional. Shown on the Hub project card and page. Leaving it empty keeps the
|
||||
project's current logo.
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-xs">
|
||||
<span class="font-semibold text-primary">Readme</span>
|
||||
<textarea
|
||||
|
||||
@@ -234,6 +234,14 @@ export class DeployToHubSession {
|
||||
hubName = $state('')
|
||||
hubSummary = $state('')
|
||||
hubReadme = $state('')
|
||||
// Custom logo state for the next publish (png/svg, base64 without the
|
||||
// data: prefix). Three-state: undefined = untouched (publishing leaves the
|
||||
// Hub's current logo alone), null = clear the Hub's logo on publish,
|
||||
// object = upload this image.
|
||||
hubLogo = $state<{ b64: string; mime: string; name: string } | null | undefined>(undefined)
|
||||
// Whether the Hub currently has a custom logo for this project (from
|
||||
// rehydration) — drives the "Remove current logo" affordance.
|
||||
hubHasRemoteLogo = $state(false)
|
||||
effectiveSlug = $state('')
|
||||
hubItemIds = $state<Record<string, number>>({})
|
||||
|
||||
@@ -586,6 +594,7 @@ export class DeployToHubSession {
|
||||
this.hubName = p.name ?? ''
|
||||
this.hubSummary = p.summary ?? ''
|
||||
this.hubReadme = p.readme ?? ''
|
||||
this.hubHasRemoteLogo = p.has_logo === true
|
||||
this.phase =
|
||||
p.status === 'live' ? 'live' : p.status === 'under_review' ? 'under_review' : 'draft'
|
||||
const ids: Record<string, number> = {}
|
||||
@@ -1242,6 +1251,22 @@ export class DeployToHubSession {
|
||||
failures++
|
||||
}
|
||||
|
||||
// Push the logo only when touched this session: an object uploads it,
|
||||
// null clears the Hub's current logo, undefined leaves it alone
|
||||
// (re-publishing a bundle must not clear it).
|
||||
if (this.hubLogo !== undefined) {
|
||||
try {
|
||||
await this.#postHub(`/hub/projects/${encodeURIComponent(slug)}/logo`, {
|
||||
logo: this.hubLogo ? { b64: this.hubLogo.b64, mime: this.hubLogo.mime } : null
|
||||
})
|
||||
this.hubHasRemoteLogo = this.hubLogo !== null
|
||||
this.hubLogo = undefined
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Logo ${this.hubLogo ? 'upload' : 'removal'} failed: ${e?.message ?? e}`, true)
|
||||
failures++
|
||||
}
|
||||
}
|
||||
|
||||
await sleep(150)
|
||||
if (this.#disposed) return
|
||||
// An incomplete push must never become submittable: a failed transitive item
|
||||
|
||||
@@ -22,8 +22,6 @@ export const SIDEBAR_SHOW_SCHEDULES = true
|
||||
export const WORKSPACE_SHOW_SLACK_CMD = true
|
||||
export const WORKSPACE_SHOW_WEBHOOK_CLI_SYNC = true
|
||||
|
||||
export const SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB = true
|
||||
|
||||
export const SCRIPT_VIEW_SHOW_SCHEDULE = true
|
||||
export const SCRIPT_VIEW_SHOW_EXAMPLE_CURL = true
|
||||
|
||||
|
||||
+1
-45
@@ -1,6 +1,4 @@
|
||||
import type { Schema } from './common'
|
||||
import { AppService, FlowService, type Flow, type Script } from './gen'
|
||||
import { encodeState } from './utils'
|
||||
import { AppService, FlowService } from './gen'
|
||||
import hubPathsData from './hubPaths.json'
|
||||
import {
|
||||
replacePlaceholderForSignatureScriptTemplate,
|
||||
@@ -11,22 +9,6 @@ import {
|
||||
export const DEFAULT_HUB_BASE_URL = 'https://hub.windmill.dev'
|
||||
export const PRIVATE_HUB_MIN_VERSION = 10_000_000
|
||||
|
||||
export function scriptToHubUrl(
|
||||
content: string,
|
||||
summary: string,
|
||||
description: string,
|
||||
kind: Script['kind'],
|
||||
language: Script['language'],
|
||||
schema: Schema | any,
|
||||
lock: string | undefined,
|
||||
hubBaseUrl: string
|
||||
): URL {
|
||||
const url = new URL(hubBaseUrl + '/scripts/add')
|
||||
url.hash = encodeState({ content, summary, description, kind, language, schema, lock })
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export const HubScript = {
|
||||
SIGNATURE_TEMPLATE: SIGNATURE_TEMPLATE_SCRIPT_HUB_PATH
|
||||
} as const
|
||||
@@ -65,32 +47,6 @@ export async function loadHubApps() {
|
||||
}
|
||||
}
|
||||
|
||||
export function flowToHubUrl(flow: Flow, hubBaseUrl: string): URL {
|
||||
const url = new URL(hubBaseUrl + '/flows/add')
|
||||
const openFlow = {
|
||||
value: flow.value,
|
||||
summary: flow.summary,
|
||||
description: flow.description,
|
||||
schema: flow.schema
|
||||
}
|
||||
url.searchParams.append('flow', encodeState(openFlow))
|
||||
return url
|
||||
}
|
||||
|
||||
export function appToHubUrl(staticApp: any, hubBaseUrl: string): URL {
|
||||
const url = new URL(hubBaseUrl + '/apps/add')
|
||||
url.searchParams.append('app', encodeState(staticApp))
|
||||
return url
|
||||
}
|
||||
|
||||
export function rawAppToHubUrl(hubBaseUrl: string, summary?: string): URL {
|
||||
const url = new URL(hubBaseUrl + '/raw_apps/add')
|
||||
if (summary) {
|
||||
url.searchParams.append('summary', summary)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
type HubPaths = {
|
||||
gitSyncTest: string
|
||||
gitInitRepo: string
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
hubBaseUrlStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore
|
||||
@@ -63,7 +62,6 @@
|
||||
Eye,
|
||||
FolderOpen,
|
||||
GitFork,
|
||||
Globe2,
|
||||
History,
|
||||
Loader2,
|
||||
Pen,
|
||||
@@ -76,8 +74,6 @@
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
} from 'lucide-svelte'
|
||||
import { SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB } from '$lib/consts'
|
||||
import { scriptToHubUrl } from '$lib/hub'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
|
||||
@@ -550,30 +546,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (SCRIPT_VIEW_SHOW_PUBLISH_TO_HUB) {
|
||||
menuItems.push({
|
||||
label: 'Publish to Hub',
|
||||
Icon: Globe2,
|
||||
onclick: () => {
|
||||
if (!script) return
|
||||
|
||||
window.open(
|
||||
scriptToHubUrl(
|
||||
script.content,
|
||||
script.summary,
|
||||
script.description ?? '',
|
||||
script.kind,
|
||||
script.language,
|
||||
script.schema,
|
||||
script.lock ?? '',
|
||||
$hubBaseUrlStore
|
||||
).toString(),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (showEditButtons) {
|
||||
if (script.archived) {
|
||||
menuItems.push({
|
||||
|
||||
Reference in New Issue
Block a user