Add Formbricks configuration to environment files and update package dependencies
Deploy to Firebase Hosting / deploy (push) Failing after 1m26s
Deploy to Firebase Hosting / deploy (push) Failing after 1m26s
- Introduced new environment variables for Formbricks in .env.backup and .env.example. - Updated package.json and yarn.lock to include React and related types for improved functionality. - Added new favicon images and manifest for better branding and user experience. - Implemented new Svelte components for enhanced content management, including Badge, Card, OrganisationsBlock, and OpnFormBlock. - Updated ContentRows component to support new block types.
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { createElement } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MasonryPhotoAlbum, type Photo } from "react-photo-album";
|
||||
import { marked } from "marked";
|
||||
import { fade } from "svelte/transition";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "../../lib/block-types";
|
||||
import "../../lib/iconify-offline";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { useTranslate } from "../../lib/translations";
|
||||
import { T } from "../../lib/translations";
|
||||
import "react-photo-album/masonry.css";
|
||||
|
||||
const t = useTranslate();
|
||||
let { block }: { block: ImageGalleryBlockData } = $props();
|
||||
@@ -28,7 +30,14 @@
|
||||
.filter((x): x is { img: ImageGalleryImage; url: string } => x.url != null),
|
||||
);
|
||||
|
||||
let currentIndex = $state(0);
|
||||
/** Fallback, solange keine echten Pixelmaße bekannt sind (CMS liefert keine width/height). */
|
||||
const DEFAULT_W = 1600;
|
||||
const DEFAULT_H = 900;
|
||||
|
||||
let galleryPhotos = $state<Photo[]>([]);
|
||||
|
||||
let albumHost: HTMLDivElement | undefined = $state();
|
||||
let reactRoot: Root | null = null;
|
||||
|
||||
function imageUrl(img: ImageGalleryImage): string | null {
|
||||
if (typeof img.src === "string") return img.src.startsWith("//") ? `https:${img.src}` : img.src;
|
||||
@@ -36,68 +45,98 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
function prev() {
|
||||
currentIndex = (currentIndex - 1 + withUrl.length) % withUrl.length;
|
||||
function metaToPhotos(
|
||||
meta: { img: ImageGalleryImage; url: string }[],
|
||||
dims: { w: number; h: number }[],
|
||||
): Photo[] {
|
||||
return meta.map(({ img, url }, i) => ({
|
||||
src: url,
|
||||
width: Math.max(1, dims[i]?.w ?? DEFAULT_W),
|
||||
height: Math.max(1, dims[i]?.h ?? DEFAULT_H),
|
||||
alt: img.description ?? "",
|
||||
title: img.description ?? undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function next() {
|
||||
currentIndex = (currentIndex + 1) % withUrl.length;
|
||||
function loadImageSize(url: string): Promise<{ w: number; h: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const im = new Image();
|
||||
im.onload = () =>
|
||||
resolve({
|
||||
w: im.naturalWidth || DEFAULT_W,
|
||||
h: im.naturalHeight || DEFAULT_H,
|
||||
});
|
||||
im.onerror = () => resolve({ w: DEFAULT_W, h: DEFAULT_H });
|
||||
im.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
let startX = $state(0);
|
||||
let isDragging = $state(false);
|
||||
let dragDelta = $state(0);
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (withUrl.length <= 1) return;
|
||||
startX = e.clientX;
|
||||
isDragging = true;
|
||||
dragDelta = 0;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!isDragging) return;
|
||||
dragDelta = e.clientX - startX;
|
||||
if (e.pointerType === "touch" && Math.abs(dragDelta) > 10) e.preventDefault();
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
$effect(() => {
|
||||
const meta = withUrl;
|
||||
if (meta.length < 2) {
|
||||
galleryPhotos = [];
|
||||
return;
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
|
||||
const fallback = meta.map(() => ({ w: DEFAULT_W, h: DEFAULT_H }));
|
||||
galleryPhotos = metaToPhotos(meta, fallback);
|
||||
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let cancelled = false;
|
||||
void Promise.all(meta.map(({ url }) => loadImageSize(url))).then((dims) => {
|
||||
if (cancelled) return;
|
||||
galleryPhotos = metaToPhotos(meta, dims);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
function renderAlbum() {
|
||||
if (!reactRoot || galleryPhotos.length < 2) return;
|
||||
reactRoot.render(
|
||||
createElement(MasonryPhotoAlbum, {
|
||||
photos: galleryPhotos,
|
||||
defaultContainerWidth: 1180,
|
||||
columns: (containerWidth: number) => {
|
||||
if (containerWidth < 480) return 2;
|
||||
if (containerWidth < 800) return 3;
|
||||
return 4;
|
||||
},
|
||||
spacing: 8,
|
||||
padding: 0,
|
||||
sizes: {
|
||||
size: "min(1180px, 100vw - 2rem)",
|
||||
sizes: [{ viewport: "(max-width: 1280px)", size: "calc(100vw - 2rem)" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if (withUrl.length <= 1) return;
|
||||
startX = e.touches[0].clientX;
|
||||
isDragging = true;
|
||||
dragDelta = 0;
|
||||
}
|
||||
onMount(() => {
|
||||
if (withUrl.length < 2) return;
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!isDragging || !e.touches[0]) return;
|
||||
const delta = e.touches[0].clientX - startX;
|
||||
dragDelta = delta;
|
||||
if (Math.abs(delta) > 10) e.preventDefault();
|
||||
}
|
||||
let cancelled = false;
|
||||
void tick().then(() => {
|
||||
if (cancelled || !albumHost) return;
|
||||
reactRoot = createRoot(albumHost);
|
||||
renderAlbum();
|
||||
});
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!isDragging) return;
|
||||
if (Math.abs(dragDelta) > SWIPE_THRESHOLD) {
|
||||
if (dragDelta < 0) next();
|
||||
else prev();
|
||||
}
|
||||
isDragging = false;
|
||||
dragDelta = 0;
|
||||
startX = 0;
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
reactRoot?.unmount();
|
||||
reactRoot = null;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
galleryPhotos;
|
||||
if (withUrl.length < 2 || !reactRoot) return;
|
||||
renderAlbum();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="image_gallery" data-block-slug={block._slug}>
|
||||
@@ -122,74 +161,9 @@
|
||||
{/if}
|
||||
</figure>
|
||||
{:else}
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-sm bg-stein-100 aspect-video touch-pan-y cursor-grab active:cursor-grabbing"
|
||||
role="region"
|
||||
aria-label={t(T.image_gallery_swipe_aria)}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchmove={onTouchMove}
|
||||
ontouchend={onTouchEnd}
|
||||
ontouchcancel={onTouchEnd}
|
||||
>
|
||||
{#key currentIndex}
|
||||
{@const current = withUrl[currentIndex]}
|
||||
<figure
|
||||
class="m-0 absolute inset-0"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
<img
|
||||
src={current.url}
|
||||
alt={current.img.description ?? ""}
|
||||
class="w-full h-full object-cover"
|
||||
loading={currentIndex === 0 ? "eager" : "lazy"}
|
||||
/>
|
||||
{#if current.img.description}
|
||||
<figcaption
|
||||
class="absolute bottom-0 left-0 right-0 py-2 px-3 text-xs text-white bg-black/50 rounded-b-sm transition-opacity lg:opacity-0 lg:group-hover:opacity-100"
|
||||
>
|
||||
{current.img.description}
|
||||
</figcaption>
|
||||
{/if}
|
||||
</figure>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label={t(T.image_gallery_prev_aria)}
|
||||
onclick={prev}
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-opacity focus:outline-none focus:ring-2 focus:ring-wald-500 lg:opacity-0 lg:group-hover:opacity-100"
|
||||
aria-label={t(T.image_gallery_next_aria)}
|
||||
onclick={next}
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" class="text-2xl" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex justify-center gap-1.5 mt-2" role="tablist" aria-label={t(T.image_gallery_position_aria)}>
|
||||
{#each withUrl as _, i}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={t(T.image_gallery_slide_aria, { index: i + 1 })}
|
||||
aria-selected={i === currentIndex}
|
||||
class="w-2 h-2 rounded-full transition-colors {i === currentIndex
|
||||
? "bg-wald-600"
|
||||
: "bg-stein-300 hover:bg-stein-400"}"
|
||||
onclick={() => (currentIndex = i)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
bind:this={albumHost}
|
||||
class="react-photo-album-host w-full min-h-48 [&_.react-photo-album]:max-w-none"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { OpnFormBlockData } from "../../lib/block-types";
|
||||
|
||||
let { block }: { block: OpnFormBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||
const formId = $derived((block.formId ?? "").trim());
|
||||
const baseUrl = $derived(
|
||||
(typeof block.baseUrl === "string" && block.baseUrl.trim()
|
||||
? block.baseUrl.trim()
|
||||
: "https://opnform.pm86.de"
|
||||
).replace(/\/$/, ""),
|
||||
);
|
||||
const iframeSrc = $derived(
|
||||
formId ? `${baseUrl}/forms/${formId}` : "",
|
||||
);
|
||||
const iframeTitle = $derived(
|
||||
block.iframeTitle?.trim() || "Kontaktformular",
|
||||
);
|
||||
|
||||
/**
|
||||
* Wie im OpnForm-Embed: initEmbed erst wenn iframe.min.js fertig geladen ist
|
||||
* (entspricht <script … onload="initEmbed('…', { autoResize: true })">).
|
||||
* Zusätzlich min-height, weil das Widget oft ~150px inline setzt, bevor autoResize greift.
|
||||
*/
|
||||
const iframeStyle =
|
||||
"border: none; width: 100%; min-height: min(88vh, 48rem) !important;";
|
||||
|
||||
onMount(() => {
|
||||
if (!formId) return;
|
||||
const scriptUrl = `${baseUrl}/widgets/iframe.min.js`;
|
||||
|
||||
function runInit() {
|
||||
const w = window as Window & {
|
||||
initEmbed?: (id: string, opts: { autoResize?: boolean }) => void;
|
||||
};
|
||||
w.initEmbed?.(formId, { autoResize: true });
|
||||
}
|
||||
|
||||
const existing = document.querySelector<HTMLScriptElement>(
|
||||
`script[data-opnform-embed-js="${baseUrl}"]`,
|
||||
);
|
||||
const w = window as Window & { initEmbed?: unknown };
|
||||
|
||||
if (existing) {
|
||||
if (typeof w.initEmbed === "function") {
|
||||
queueMicrotask(runInit);
|
||||
} else {
|
||||
existing.addEventListener("load", runInit, { once: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.async = true;
|
||||
s.src = scriptUrl;
|
||||
s.dataset.opnformEmbedJs = baseUrl;
|
||||
s.onload = () => runInit();
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="{layoutClasses} content-opnform w-full min-w-0 overflow-visible -mx-6"
|
||||
data-block-type="opnform"
|
||||
data-block-slug={block._slug}
|
||||
>
|
||||
{#if iframeSrc}
|
||||
<iframe
|
||||
style={iframeStyle}
|
||||
id={formId}
|
||||
title={iframeTitle}
|
||||
src={iframeSrc}
|
||||
></iframe>
|
||||
{:else}
|
||||
<p class="text-sm text-stein-500">OpnForm: formId fehlt.</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { marked } from "marked";
|
||||
import { getBlockLayoutClasses } from "../../lib/block-layout";
|
||||
import type { OrganisationsBlockData, OrganisationEntry } from "../../lib/block-types";
|
||||
import { absoluteUrlFromCmsImageField } from "../../lib/cms-media-url";
|
||||
import Card from "../Card.svelte";
|
||||
import Badge from "../Badge.svelte";
|
||||
|
||||
marked.setOptions({ gfm: true });
|
||||
|
||||
/** Nach `resolveContentImages`: `resolvedLogoSrc`; sonst gleiche URL-Auflösung wie Image-Blöcke. */
|
||||
function organisationLogoSrc(o: OrganisationEntry): string | undefined {
|
||||
const resolved = o.resolvedLogoSrc?.trim();
|
||||
if (resolved) return resolved;
|
||||
return absoluteUrlFromCmsImageField(o.logo) ?? undefined;
|
||||
}
|
||||
|
||||
function organisationLogoAlt(o: OrganisationEntry): string {
|
||||
if (o.logo && typeof o.logo === "object" && o.logo.description) {
|
||||
return o.logo.description;
|
||||
}
|
||||
return o.name ?? "";
|
||||
}
|
||||
|
||||
let { block }: { block: OrganisationsBlockData } = $props();
|
||||
|
||||
const layoutClasses = $derived(
|
||||
block.layout ? getBlockLayoutClasses(block.layout) : "col-span-12 mb-4",
|
||||
);
|
||||
const compact = $derived(block.presentation === "compact");
|
||||
const orgs = $derived(block.organisations ?? []);
|
||||
const ctaHtml = $derived(
|
||||
block.addOrganisationMarkdown
|
||||
? (marked.parse(block.addOrganisationMarkdown) as string)
|
||||
: ""
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={layoutClasses} data-block-type="organisations" data-block-slug={block._slug}>
|
||||
{#if block.headline}
|
||||
<h3 class={compact ? "mb-2 text-lg font-semibold text-stein-900" : "mb-4"}>{block.headline}</h3>
|
||||
{/if}
|
||||
<div
|
||||
class={compact
|
||||
? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3"
|
||||
: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}
|
||||
>
|
||||
{#each orgs as org, orgIndex}
|
||||
{#if typeof org === "object" && org !== null}
|
||||
{@const o = org as OrganisationEntry}
|
||||
{@const linkUrl =
|
||||
typeof o.link === "object" &&
|
||||
o.link !== null &&
|
||||
typeof o.link.url === "string" &&
|
||||
o.link.url.trim() !== ""
|
||||
? o.link.url.trim()
|
||||
: undefined}
|
||||
{@const logoSrc = organisationLogoSrc(o)}
|
||||
{#if compact}
|
||||
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
|
||||
<div
|
||||
class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
||||
>
|
||||
{#if logoSrc}
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-0.5"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
|
||||
<svg
|
||||
class="h-7 w-7 text-stein-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id={fishMaskId}>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M24 30v14"
|
||||
/>
|
||||
<path
|
||||
fill="#555555"
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
|
||||
/>
|
||||
<circle cx="24" cy="24" r="2" fill="#fff" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 flex flex-col gap-0.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
|
||||
{#if o.badge && typeof o.badge === "object" && o.badge.label}
|
||||
<Badge color={o.badge.color}>{o.badge.label}</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if o.description}
|
||||
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card href={linkUrl}>
|
||||
<div
|
||||
class="h-14 w-14 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0"
|
||||
>
|
||||
{#if logoSrc}
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={organisationLogoAlt(o)}
|
||||
class="h-full w-full object-contain p-1"
|
||||
/>
|
||||
{:else}
|
||||
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
|
||||
<svg
|
||||
class="h-10 w-10 text-stein-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<mask id={fishMaskId}>
|
||||
<g fill="none">
|
||||
<path
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M24 30v14"
|
||||
/>
|
||||
<path
|
||||
fill="#555555"
|
||||
stroke="#fff"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="4"
|
||||
d="M29 23c11 5 16 14 16 14s-12 0-21-8c-9 8-21 8-21 8s5-10 16-14c0-13 5-19 5-19s5 6 5 19"
|
||||
/>
|
||||
<circle cx="24" cy="24" r="2" fill="#fff" />
|
||||
</g>
|
||||
</mask>
|
||||
</defs>
|
||||
<path fill="currentColor" d="M0 0h48v48H0z" mask={`url(#${fishMaskId})`} />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{#if o.badge && typeof o.badge === "object" && o.badge.label}
|
||||
<div class="absolute top-3 right-3">
|
||||
<Badge color={o.badge.color}>{o.badge.label}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
<h5 class="text-base font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
|
||||
{#if o.description}
|
||||
<p class="text-sm text-stein-600">{o.description}</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if block.addOrganisationTitle || block.addOrganisationMarkdown}
|
||||
<Card
|
||||
class={compact
|
||||
? "border-dashed border-wald-300 bg-wald-50 p-3! gap-1.5!"
|
||||
: "border-dashed border-wald-300 bg-wald-50"}
|
||||
>
|
||||
{#if block.addOrganisationTitle}
|
||||
<h5
|
||||
class={compact
|
||||
? "text-sm font-semibold text-wald-800 leading-snug"
|
||||
: "text-base font-semibold text-wald-800 leading-snug"}
|
||||
>
|
||||
{block.addOrganisationTitle}
|
||||
</h5>
|
||||
{/if}
|
||||
{#if ctaHtml}
|
||||
<div class={compact ? "markdown text-xs text-wald-700" : "markdown text-sm text-wald-700"}>
|
||||
{@html ctaHtml}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user