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..d742370 --- /dev/null +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.module.css @@ -0,0 +1,60 @@ +.container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); + gap: var(--ds-size-8); + align-items: start; + max-width: 100%; + 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); + + @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)); + } +} + +.signature { + padding: var(--ds-size-5); + background-color: #fff; + border-radius: var(--ds-border-radius-md); + overflow: auto; + 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..773d383 --- /dev/null +++ b/apps/www/app/_components/email-signature-generator/email-signatur-generator.tsx @@ -0,0 +1,233 @@ +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'; + +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 {} + } + + 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], + ); + + 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'); + } + }; + + useEffect(() => { + if (copyState !== 'copied') return; + const timer = window.setTimeout(() => setCopyState('idle'), 2500); + return () => window.clearTimeout(timer); + }, [copyState]); + + if (!mounted) { + return