From 284d8b9c0d30325be928c516a7d01f5775b951b3 Mon Sep 17 00:00:00 2001 From: Barsnes Date: Wed, 26 Aug 2026 10:36:31 +0200 Subject: [PATCH 1/4] feat: add email signature --- .claude/launch.json | 5 +- .../email-signatur-generator.module.css | 72 +++++ .../email-signatur-generator.tsx | 254 ++++++++++++++++++ .../signature-config.ts | 87 ++++++ .../signature-template.ts | 90 +++++++ .../mdx-components/mdx-components.tsx | 2 + .../digdir/profilering/epost-signatur.mdx | 24 ++ apps/www/public/images/digdir-epost.png | Bin 0 -> 9212 bytes 8 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css create mode 100644 apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx create mode 100644 apps/www/app/_components/email-signature-generator/signature-config.ts create mode 100644 apps/www/app/_components/email-signature-generator/signature-template.ts create mode 100644 apps/www/app/content/digdir/profilering/epost-signatur.mdx create mode 100644 apps/www/public/images/digdir-epost.png diff --git a/.claude/launch.json b/.claude/launch.json index 6036c12..81f9f0c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -4,9 +4,8 @@ { "name": "www", "runtimeExecutable": "pnpm", - "runtimeArgs": ["--filter", "www", "dev", "--port", "4699"], - "port": 4699, - "autoPort": false + "runtimeArgs": ["--filter", "www", "dev"], + "port": 5173 } ] } diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css new file mode 100644 index 0000000..10cad69 --- /dev/null +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css @@ -0,0 +1,72 @@ +.container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: var(--ds-size-8); + align-items: start; + max-width: var(--long-content-width); + margin-block: var(--ds-size-8); + + @media (max-width: 900px) { + grid-template-columns: minmax(0, 1fr); + } +} + +.form { + display: flex; + flex-direction: column; + gap: var(--ds-size-6); +} + +.preview { + display: flex; + flex-direction: column; + gap: var(--ds-size-4); + padding: var(--ds-size-6); + border: 1px solid var(--ds-color-neutral-border-subtle); + border-radius: var(--ds-border-radius-lg); + background-color: var(--ds-color-neutral-background-tinted); + + /* + * Keep the preview – and the copy button with it – in view while the form is + * filled in. Picking all three languages makes the signature taller than the + * viewport, so the card is capped and the signature itself scrolls; without + * the cap the button would sit below the fold exactly when it is needed. + */ + @media (min-width: 901px) { + position: sticky; + top: calc(var(--header-height) + var(--ds-size-4)); + max-height: calc(100dvh - var(--header-height) - var(--ds-size-8)); + } +} + +/* + * The signature carries its own inline styling, so it must render on a white + * ground in both colour schemes – it is a picture of the finished e-mail, not + * part of the surrounding page. + */ +.signature { + padding: var(--ds-size-5); + background-color: #fff; + border-radius: var(--ds-border-radius-md); + overflow: auto; + /* Let the flex item shrink below its content height so the cap above bites. */ + min-height: 0; +} + +.actions { + display: flex; + flex-direction: column; + gap: var(--ds-size-2); + align-items: start; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx new file mode 100644 index 0000000..3c39c32 --- /dev/null +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx @@ -0,0 +1,254 @@ +import { + Button, + Checkbox, + Fieldset, + Heading, + Paragraph, + Radio, + Textfield, + ValidationMessage, +} from '@digdir/designsystemet-react'; +import { CheckmarkIcon, FilesIcon } from '@navikt/aksel-icons'; +import { useEffect, useId, useMemo, useState } from 'react'; +import classes from './email-signatur-generator.module.css'; +import { + getLanguages, + getOffice, + type LanguageCode, + LOGO_PATH, + languages, + type OfficeId, + offices, +} from './signature-config'; +import { + buildSignatureHtml, + buildSignatureText, + type SignatureData, +} from './signature-template'; + +type CopyState = 'idle' | 'copied' | 'error'; + +/** + * Copy both flavours in one go, so the receiving client can pick the rich one + * and fall back to plain text. `ClipboardItem` is the only API that carries + * `text/html`; older browsers get the `execCommand` route below. + */ +const copySignature = async (html: string, text: string) => { + if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/html': new Blob([html], { type: 'text/html' }), + 'text/plain': new Blob([text], { type: 'text/plain' }), + }), + ]); + return; + } catch { + // Having the API is no guarantee of being allowed to use it – Firefox and + // managed-browser policies both refuse `clipboard-write` for pages they + // are happy to run. Fall through rather than give up. + } + } + + // Fallback: select a detached copy of the markup and let the browser convert + // the selection to rich text itself. + const holder = document.createElement('div'); + holder.setAttribute('contenteditable', 'true'); + holder.innerHTML = html; + holder.style.position = 'fixed'; + holder.style.left = '-9999px'; + document.body.appendChild(holder); + + try { + const range = document.createRange(); + range.selectNodeContents(holder); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + if (!document.execCommand('copy')) { + throw new Error('execCommand("copy") was rejected'); + } + selection?.removeAllRanges(); + } finally { + holder.remove(); + } +}; + +export const EmailSignatureGenerator = () => { + const previewId = useId(); + + const [name, setName] = useState(''); + const [role, setRole] = useState(''); + const [phone, setPhone] = useState(''); + const [office, setOffice] = useState('leikanger'); + const [selectedLanguages, setSelectedLanguages] = useState([ + 'nb', + ]); + const [copyState, setCopyState] = useState('idle'); + const [mounted, setMounted] = useState(false); + + useEffect(() => setMounted(true), []); + + const noLanguage = selectedLanguages.length === 0; + + const input: SignatureData = useMemo( + () => ({ + name, + role, + phone, + office: getOffice(office), + languages: getLanguages(selectedLanguages), + }), + [name, role, phone, office, selectedLanguages], + ); + + // Root-relative for the preview; the copy swaps in an absolute URL, which is + // the only kind a mail client can resolve. + const previewHtml = useMemo( + () => buildSignatureHtml({ ...input, logoSrc: LOGO_PATH }), + [input], + ); + + const toggleLanguage = (code: LanguageCode, checked: boolean) => { + setSelectedLanguages((current) => + checked + ? [...current, code] + : current.filter((language) => language !== code), + ); + }; + + const onCopy = async () => { + const html = buildSignatureHtml({ + ...input, + logoSrc: new URL(LOGO_PATH, window.location.origin).href, + }); + + try { + await copySignature(html, buildSignatureText(input)); + setCopyState('copied'); + } catch { + setCopyState('error'); + } + }; + + // Let the "Kopiert!" confirmation fade back to the default label. + useEffect(() => { + if (copyState !== 'copied') return; + const timer = window.setTimeout(() => setCopyState('idle'), 2500); + return () => window.clearTimeout(timer); + }, [copyState]); + + // Render nothing until mounted. `ds-field` / `ds-fieldset` hand out ids from a + // module-level counter on the server but from `window.dsUseId` in the browser + // (see @digdir/designsystemet-web `useId`), so the two can never agree and + // every SSR-ed Field logs a hydration mismatch. Nothing is lost by skipping + // the server pass: this is a clipboard tool, and the prerendered markup would + // only ever be an empty form. + if (!mounted) { + return `; @@ -81,7 +84,7 @@ export const buildSignatureText = (input: SignatureData): string => or(input.name, 'Navn Navnesen'), or(input.role, 'stilling'), '', - `${language.phoneLabel}: ${or(input.phone, 'XXX XX XXX')}`, + `${language.phoneLabel}: ${or(formatPhone(input.phone), 'XXX XX XXX')}`, '', input.office.address, ].join('\n'), diff --git a/apps/www/app/content/digdir/kom-i-gang.mdx b/apps/www/app/content/digdir/kom-i-gang.mdx deleted file mode 100644 index 0e89a64..0000000 --- a/apps/www/app/content/digdir/kom-i-gang.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: Kom i gang -sidebar_title: Kom i gang -description: Slik tar du i bruk Digdir-profilen i prosjektet ditt. -category: Introduksjon -order: 1 -published: true ---- - -Velkommen til dokumentasjonen for **Digdir**-profilen. Denne siden er skrevet i -MDX og rendres gjennom det samme systemet som designsystemet.no. - -## Installasjon - -Installer pakkene du trenger: - -```bash -pnpm add @digdir/designsystemet-react @digdir/designsystemet-css -``` - -## Bruk en komponent - -Importer og bruk komponenter slik: - -```tsx -import { Button } from '@digdir/designsystemet-react'; - -export function Example() { - return ; -} -``` - - - Tips: alle sider får automatisk en innholdsfortegnelse basert på - overskriftene dine. - - -## Neste steg - -Se [fargene](/digdir/colors) og [komponentene](/digdir/components) for mer -informasjon. diff --git a/apps/www/app/content/digdir/komponenter.mdx b/apps/www/app/content/digdir/komponenter.mdx deleted file mode 100644 index 561f83f..0000000 --- a/apps/www/app/content/digdir/komponenter.mdx +++ /dev/null @@ -1,28 +0,0 @@ ---- -title: Komponenter -sidebar_title: Komponenter -description: Oversikt over komponenter i Digdir-profilen. -category: Styling -order: 2 -published: true ---- - -Komponentene kommer fra `@digdir/designsystemet-react` og styles av profilens -tema. - -## Varsler - -Bruk `Alert` for å fremheve viktig informasjon: - - - Husk å teste komponentene med tastatur og skjermleser. - - -## Merkelapper - -Du kan bruke `Badge` for å vise status eller antall. - -## Mer - -Dette er kun eksempelinnhold – legg til dine egne MDX-filer under -`app/content/digdir/` for å utvide dokumentasjonen. From 4383e4c2298b7d551814004b6c1b885738eff187 Mon Sep 17 00:00:00 2001 From: Barsnes Date: Wed, 26 Aug 2026 10:42:33 +0200 Subject: [PATCH 3/4] clean code --- .../email-signatur-generator.module.css | 12 ---------- .../email-signatur-generator.tsx | 22 +------------------ .../signature-config.ts | 7 ------ .../signature-template.ts | 4 ++-- 4 files changed, 3 insertions(+), 42 deletions(-) diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css index 10cad69..3368fb3 100644 --- a/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css @@ -26,12 +26,6 @@ border-radius: var(--ds-border-radius-lg); background-color: var(--ds-color-neutral-background-tinted); - /* - * Keep the preview – and the copy button with it – in view while the form is - * filled in. Picking all three languages makes the signature taller than the - * viewport, so the card is capped and the signature itself scrolls; without - * the cap the button would sit below the fold exactly when it is needed. - */ @media (min-width: 901px) { position: sticky; top: calc(var(--header-height) + var(--ds-size-4)); @@ -39,17 +33,11 @@ } } -/* - * The signature carries its own inline styling, so it must render on a white - * ground in both colour schemes – it is a picture of the finished e-mail, not - * part of the surrounding page. - */ .signature { padding: var(--ds-size-5); background-color: #fff; border-radius: var(--ds-border-radius-md); overflow: auto; - /* Let the flex item shrink below its content height so the cap above bites. */ min-height: 0; } diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx index 3c39c32..5fdf6d7 100644 --- a/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx @@ -28,11 +28,6 @@ import { type CopyState = 'idle' | 'copied' | 'error'; -/** - * Copy both flavours in one go, so the receiving client can pick the rich one - * and fall back to plain text. `ClipboardItem` is the only API that carries - * `text/html`; older browsers get the `execCommand` route below. - */ const copySignature = async (html: string, text: string) => { if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) { try { @@ -43,15 +38,9 @@ const copySignature = async (html: string, text: string) => { }), ]); return; - } catch { - // Having the API is no guarantee of being allowed to use it – Firefox and - // managed-browser policies both refuse `clipboard-write` for pages they - // are happy to run. Fall through rather than give up. - } + } catch {} } - // Fallback: select a detached copy of the markup and let the browser convert - // the selection to rich text itself. const holder = document.createElement('div'); holder.setAttribute('contenteditable', 'true'); holder.innerHTML = html; @@ -103,8 +92,6 @@ export const EmailSignatureGenerator = () => { [name, role, phone, office, selectedLanguages], ); - // Root-relative for the preview; the copy swaps in an absolute URL, which is - // the only kind a mail client can resolve. const previewHtml = useMemo( () => buildSignatureHtml({ ...input, logoSrc: LOGO_PATH }), [input], @@ -132,19 +119,12 @@ export const EmailSignatureGenerator = () => { } }; - // Let the "Kopiert!" confirmation fade back to the default label. useEffect(() => { if (copyState !== 'copied') return; const timer = window.setTimeout(() => setCopyState('idle'), 2500); return () => window.clearTimeout(timer); }, [copyState]); - // Render nothing until mounted. `ds-field` / `ds-fieldset` hand out ids from a - // module-level counter on the server but from `window.dsUseId` in the browser - // (see @digdir/designsystemet-web `useId`), so the two can never agree and - // every SSR-ed Field logs a hydration mismatch. Nothing is lost by skipping - // the server pass: this is a clipboard tool, and the prerendered markup would - // only ever be an empty form. if (!mounted) { return `; @@ -84,7 +84,7 @@ export const buildSignatureText = (input: SignatureData): string => or(input.name, 'Navn Navnesen'), or(input.role, 'stilling'), '', - `${language.phoneLabel}: ${or(formatPhone(input.phone), 'XXX XX XXX')}`, + `${language.phoneLabel}: ${or(formatPhone(input.phone), 'XX XX XX XX')}`, '', input.office.address, ].join('\n'), From 6fdb5c29906e5446007d268cc74d473a85294aa9 Mon Sep 17 00:00:00 2001 From: Barsnes Date: Wed, 26 Aug 2026 10:45:18 +0200 Subject: [PATCH 4/4] make full widt --- .../email-signatur-generator.module.css | 4 ++-- .../email-signature-generator/email-signatur-generator.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css index 3368fb3..d742370 100644 --- a/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css @@ -1,9 +1,9 @@ .container { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: var(--ds-size-8); align-items: start; - max-width: var(--long-content-width); + max-width: 100%; margin-block: var(--ds-size-8); @media (max-width: 900px) { diff --git a/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx index 5fdf6d7..773d383 100644 --- a/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx @@ -143,7 +143,6 @@ export const EmailSignatureGenerator = () => { /> setRole(event.target.value)}