Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions apps/web/src/components/branding/branding-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,29 @@ import { Button, Card, Field, Input, Textarea } from "@msk-forms/ui";
import { useRouter } from "next/navigation";
import { useState } from "react";

import { CssPreview } from "@/components/branding/css-preview";
import type { Dictionary } from "@/i18n";

type BrandingDict = Dictionary["branding"];

const DEFAULT_ACCENT = "#5eb131";
const HEX = /^#[0-9a-fA-F]{6}$/;

/**
* Beginner-friendly starter rules. Each targets `.msk-form` or a CSS variable
* so it behaves identically in the preview and on the live page (the two
* documented, stable hooks). `key` maps to a localized label.
*/
const CSS_SNIPPETS: { key: keyof BrandingDict["snip"]; css: string }[] = [
{ key: "rounded", css: ".msk-form {\n --radius: 1.25rem;\n}" },
{
key: "tint",
css: ".msk-form {\n background: hsl(var(--primary) / 0.05);\n padding: 1.5rem;\n border-radius: 1rem;\n}",
},
{ key: "text", css: ".msk-form {\n font-size: 1.05rem;\n}" },
{ key: "heading", css: ".msk-form h1 {\n color: hsl(var(--primary));\n font-weight: 800;\n}" },
];

export function BrandingForm({
guildId,
initial,
Expand Down Expand Up @@ -58,6 +74,11 @@ export function BrandingForm({
}
}

function addSnippet(snippet: string) {
setCss((prev) => (prev.trim() ? `${prev.trimEnd()}\n\n${snippet}` : snippet));
setSaved(false);
}

const swatch = HEX.test(color.trim()) ? color.trim() : DEFAULT_ACCENT;

return (
Expand Down Expand Up @@ -101,17 +122,51 @@ export function BrandingForm({

{canCss ? (
<Field label={t.customCss} hint={t.customCssHint}>
<Textarea
value={css}
rows={6}
spellCheck={false}
placeholder=".msk-form { ... }"
className="font-mono text-xs"
onChange={(e) => {
setCss(e.target.value);
setSaved(false);
}}
/>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">{t.cssSnippets}</span>
<div className="flex flex-wrap gap-2">
{CSS_SNIPPETS.map((s) => (
<button
key={s.key}
type="button"
onClick={() => addSnippet(s.css)}
className="rounded-md border border-border bg-card px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-primary/40 hover:text-foreground"
>
+ {t.snip[s.key]}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{t.cssSnippetsHint}</span>
</div>
<Textarea
value={css}
rows={6}
spellCheck={false}
placeholder=".msk-form { ... }"
className="font-mono text-xs"
onChange={(e) => {
setCss(e.target.value);
setSaved(false);
}}
/>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">{t.cssPreviewTitle}</span>
<CssPreview
css={css}
accentColor={color}
labels={{
guild: t.sample.guild,
title: t.sample.title,
desc: t.sample.desc,
field: t.sample.field,
field2: t.sample.field2,
submit: t.sample.submit,
}}
/>
<span className="text-xs text-muted-foreground">{t.cssPreviewNote}</span>
</div>
</div>
</Field>
) : (
<Field label={t.customCss}>
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/branding/css-preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import { sanitizeCustomCss } from "@msk-forms/shared";
import { useTheme } from "next-themes";
import { useMemo } from "react";

import { hexToHslChannels } from "@/lib/branding";

const HEX = /^#[0-9a-fA-F]{6}$/;

export interface CssPreviewLabels {
guild: string;
title: string;
desc: string;
field: string;
field2: string;
submit: string;
}

/**
* Base stylesheet for the preview document. Mirrors the public form's token
* system (the same `--primary`, `--card`, `--radius` … names and light/dark
* values from globals.css) and the `.msk-form` wrapper, so custom CSS that
* targets `.msk-form` or overrides a CSS variable behaves here exactly as it
* will on the live page. Deliberately self-contained (no Tailwind) so the
* preview renders in an isolated iframe.
*/
const BASE_CSS = `
:root{--background:0 0% 96.5%;--foreground:0 0% 10%;--card:0 0% 100%;--card-foreground:0 0% 10%;--primary:101 62% 40%;--primary-foreground:0 0% 100%;--secondary:0 0% 93%;--muted-foreground:0 0% 42%;--border:0 0% 88%;--input:0 0% 88%;--ring:101 62% 40%;--radius:0.75rem;}
.dark{--background:0 0% 9%;--foreground:0 0% 97%;--card:0 0% 12%;--card-foreground:0 0% 97%;--primary:99 57% 44%;--primary-foreground:0 0% 100%;--secondary:0 0% 17%;--muted-foreground:0 0% 66%;--border:0 0% 20%;--input:0 0% 23%;--ring:99 57% 44%;}
*{box-sizing:border-box;}
html,body{margin:0;}
body{background:hsl(var(--background));color:hsl(var(--foreground));font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased;padding:1.25rem;}
.msk-form{max-width:42rem;margin:0 auto;display:flex;flex-direction:column;gap:1.25rem;}
.msk-form .brand{font-size:.875rem;font-weight:500;color:hsl(var(--primary));}
.msk-form h1{font-size:1.75rem;font-weight:700;margin:.25rem 0 0;color:hsl(var(--foreground));}
.msk-form .desc{color:hsl(var(--muted-foreground));margin:.25rem 0 0;}
.msk-form .card{border:1px solid hsl(var(--border));background:hsl(var(--card));border-radius:var(--radius);padding:1.5rem;box-shadow:0 1px 2px rgba(0,0,0,.06);display:flex;flex-direction:column;gap:1rem;}
.msk-form label{display:block;font-size:.875rem;font-weight:500;margin-bottom:.375rem;color:hsl(var(--foreground));}
.msk-form input,.msk-form textarea{width:100%;border:1px solid hsl(var(--input));background:hsl(var(--background));border-radius:calc(var(--radius) - .25rem);padding:.5rem .75rem;font:inherit;color:inherit;}
.msk-form .btn{align-self:flex-start;background:hsl(var(--primary));color:hsl(var(--primary-foreground));border:none;border-radius:calc(var(--radius) - .25rem);padding:.55rem 1.1rem;font-weight:500;font-size:.9rem;cursor:default;}
`;

/** Escape text for safe interpolation into the preview HTML. */
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

/**
* Live, isolated preview of a guild's public form with the current accent color
* and custom CSS applied. Rendered in a sandboxed iframe so the CSS can't leak
* into the dashboard or run scripts; the CSS is sanitized the same way it is on
* save/render, so what you see matches the live page.
*/
export function CssPreview({
css,
accentColor,
labels,
}: {
css: string;
accentColor: string;
labels: CssPreviewLabels;
}) {
const { resolvedTheme } = useTheme();
const dark = resolvedTheme === "dark";

const srcDoc = useMemo(() => {
const safe = sanitizeCustomCss(css);
const trimmed = accentColor.trim();
const channels = HEX.test(trimmed) ? hexToHslChannels(trimmed) : null;
const accentStyle = channels ? ` style="--primary:${channels};--ring:${channels}"` : "";
return `<!doctype html><html${dark ? ' class="dark"' : ""}><head><meta charset="utf-8"><meta name="color-scheme" content="light dark"><style>${BASE_CSS}</style><style>${safe}</style></head><body><main class="msk-form"${accentStyle}><header><span class="brand">${esc(labels.guild)}</span><h1>${esc(labels.title)}</h1><p class="desc">${esc(labels.desc)}</p></header><div class="card"><div><label>${esc(labels.field)}</label><input placeholder="" /></div><div><label>${esc(labels.field2)}</label><textarea rows="3"></textarea></div><button class="btn" type="button">${esc(labels.submit)}</button></div></main></body></html>`;
}, [css, accentColor, dark, labels]);

return (
<iframe
title="preview"
sandbox=""
srcDoc={srcDoc}
className="h-[440px] w-full rounded-md border border-border bg-background"
/>
);
}
28 changes: 28 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,10 @@ const en = {
logoUploading: "Uploading…", logoRemove: "Remove logo", logoFailed: "Could not update the logo.",
customCss: "Custom CSS",
customCssHint: "Applied to your public form and status pages. Style classes like .msk-form or override CSS variables. Scripts and @import are stripped.",
cssSnippets: "Quick styles", cssSnippetsHint: "Add a starter rule, then tweak the values.",
cssPreviewTitle: "Live preview", cssPreviewNote: "A sample of your public form. Your accent color and CSS apply here instantly. Nothing is saved until you press Save.",
snip: { rounded: "Rounded corners", tint: "Accent background", text: "Larger text", heading: "Colored heading" },
sample: { guild: "Your community", title: "Application form", desc: "Tell us a bit about yourself.", field: "Your name", field2: "Why do you want to join?", submit: "Submit" },
},
botConfig: {
title: "Bot settings",
Expand Down Expand Up @@ -869,6 +873,10 @@ const de: Dictionary = {
logoUploading: "Wird hochgeladen…", logoRemove: "Logo entfernen", logoFailed: "Logo konnte nicht aktualisiert werden.",
customCss: "Eigenes CSS",
customCssHint: "Wird auf deinen öffentlichen Formular- und Status-Seiten angewendet. Style Klassen wie .msk-form oder überschreibe CSS-Variablen. Skripte und @import werden entfernt.",
cssSnippets: "Schnellstile", cssSnippetsHint: "Füge eine Vorlage ein und passe die Werte an.",
cssPreviewTitle: "Live-Vorschau", cssPreviewNote: "Ein Muster deines öffentlichen Formulars. Akzentfarbe und CSS werden hier sofort angewendet. Gespeichert wird erst mit Klick auf Speichern.",
snip: { rounded: "Runde Ecken", tint: "Akzent-Hintergrund", text: "Größerer Text", heading: "Farbige Überschrift" },
sample: { guild: "Deine Community", title: "Bewerbungsformular", desc: "Erzähl uns etwas über dich.", field: "Dein Name", field2: "Warum möchtest du beitreten?", submit: "Absenden" },
},
botConfig: {
title: "Bot-Einstellungen",
Expand Down Expand Up @@ -1408,6 +1416,10 @@ const hu: Dictionary = {
logoUploading: "Feltöltés…", logoRemove: "Logó eltávolítása", logoFailed: "A logó frissítése sikertelen.",
customCss: "Egyéni CSS",
customCssHint: "A nyilvános űrlap- és állapotoldalakon érvényesül. Stílusozz osztályokat, pl. .msk-form, vagy írj felül CSS-változókat. A szkriptek és az @import eltávolításra kerülnek.",
cssSnippets: "Gyors stílusok", cssSnippetsHint: "Adj hozzá egy kezdő szabályt, majd finomítsd az értékeket.",
cssPreviewTitle: "Élő előnézet", cssPreviewNote: "Egy minta a nyilvános űrlapodról. A kiemelőszín és a CSS azonnal érvényesül itt. Semmi nem mentődik el a Mentés gombig.",
snip: { rounded: "Lekerekített sarkok", tint: "Kiemelő háttér", text: "Nagyobb szöveg", heading: "Színes címsor" },
sample: { guild: "A közösséged", title: "Jelentkezési űrlap", desc: "Mesélj egy kicsit magadról.", field: "A neved", field2: "Miért szeretnél csatlakozni?", submit: "Küldés" },
},
botConfig: {
title: "Bot beállítások",
Expand Down Expand Up @@ -1947,6 +1959,10 @@ const fr: Dictionary = {
logoUploading: "Chargement…", logoRemove: "Supprimer le logo", logoFailed: "Impossible de mettre à jour le logo.",
customCss: "CSS personnalisé",
customCssHint: "Appliqué sur vos pages de formulaire et de statut publiques. Stylisez des classes comme .msk-form ou remplacez des variables CSS. Les scripts et @import sont supprimés.",
cssSnippets: "Styles rapides", cssSnippetsHint: "Ajoutez une règle de départ, puis ajustez les valeurs.",
cssPreviewTitle: "Aperçu en direct", cssPreviewNote: "Un exemple de votre formulaire public. Votre couleur d'accent et votre CSS s'appliquent ici instantanément. Rien n'est enregistré avant de cliquer sur Enregistrer.",
snip: { rounded: "Coins arrondis", tint: "Fond d'accent", text: "Texte plus grand", heading: "Titre coloré" },
sample: { guild: "Votre communauté", title: "Formulaire de candidature", desc: "Parlez-nous un peu de vous.", field: "Votre nom", field2: "Pourquoi voulez-vous nous rejoindre ?", submit: "Envoyer" },
},
botConfig: {
title: "Paramètres du bot",
Expand Down Expand Up @@ -2487,6 +2503,10 @@ const es: Dictionary = {
logoUploading: "Subiendo…", logoRemove: "Eliminar logo", logoFailed: "No se pudo actualizar el logo.",
customCss: "CSS personalizado",
customCssHint: "Se aplica a tus páginas públicas de formulario y estado. Estiliza clases como .msk-form o sobrescribe variables CSS. Los scripts y @import se eliminan.",
cssSnippets: "Estilos rápidos", cssSnippetsHint: "Añade una regla inicial y luego ajusta los valores.",
cssPreviewTitle: "Vista previa en vivo", cssPreviewNote: "Una muestra de tu formulario público. Tu color de acento y tu CSS se aplican aquí al instante. No se guarda nada hasta que pulses Guardar.",
snip: { rounded: "Esquinas redondeadas", tint: "Fondo de acento", text: "Texto más grande", heading: "Título con color" },
sample: { guild: "Tu comunidad", title: "Formulario de solicitud", desc: "Cuéntanos un poco sobre ti.", field: "Tu nombre", field2: "¿Por qué quieres unirte?", submit: "Enviar" },
},
botConfig: {
title: "Configuración del bot",
Expand Down Expand Up @@ -3027,6 +3047,10 @@ const pt: Dictionary = {
logoUploading: "Enviando…", logoRemove: "Remover logo", logoFailed: "Não foi possível atualizar o logo.",
customCss: "CSS personalizado",
customCssHint: "Aplicado às suas páginas públicas de formulário e status. Estilize classes como .msk-form ou substitua variáveis CSS. Scripts e @import são removidos.",
cssSnippets: "Estilos rápidos", cssSnippetsHint: "Adicione uma regra inicial e depois ajuste os valores.",
cssPreviewTitle: "Pré-visualização ao vivo", cssPreviewNote: "Uma amostra do seu formulário público. Sua cor de destaque e seu CSS são aplicados aqui na hora. Nada é salvo até você clicar em Salvar.",
snip: { rounded: "Cantos arredondados", tint: "Fundo de destaque", text: "Texto maior", heading: "Título colorido" },
sample: { guild: "Sua comunidade", title: "Formulário de inscrição", desc: "Conte um pouco sobre você.", field: "Seu nome", field2: "Por que você quer entrar?", submit: "Enviar" },
},
botConfig: {
title: "Configurações do bot",
Expand Down Expand Up @@ -3567,6 +3591,10 @@ const pl: Dictionary = {
logoUploading: "Przesyłanie…", logoRemove: "Usuń logo", logoFailed: "Nie udało się zaktualizować logo.",
customCss: "Własne CSS",
customCssHint: "Stosowane na publicznych stronach formularzy i statusów. Stylizuj klasy jak .msk-form lub nadpisuj zmienne CSS. Skrypty i @import są usuwane.",
cssSnippets: "Szybkie style", cssSnippetsHint: "Dodaj gotową regułę, a potem dostosuj wartości.",
cssPreviewTitle: "Podgląd na żywo", cssPreviewNote: "Przykład twojego publicznego formularza. Kolor akcentu i CSS są tu stosowane od razu. Nic nie zostaje zapisane, dopóki nie klikniesz Zapisz.",
snip: { rounded: "Zaokrąglone rogi", tint: "Tło akcentu", text: "Większy tekst", heading: "Kolorowy nagłówek" },
sample: { guild: "Twoja społeczność", title: "Formularz zgłoszeniowy", desc: "Opowiedz nam trochę o sobie.", field: "Twoje imię", field2: "Dlaczego chcesz dołączyć?", submit: "Wyślij" },
},
botConfig: {
title: "Ustawienia bota",
Expand Down