Skip to content

Commit 1e4b896

Browse files
Fix(webapp): Notification style updates (#3553)
### Style updates to the notifications - Tightened up the typography - Brighter background to make it stand out a bit more - A bit more padding to make it more readable - Show the close button on hover instead - Turned the notification into a separate component as it's shared on the admin page modal - Minor tweaks to the behavior of toggling the notification beween open/closed side menu states ### Before <img width="224" height="313" alt="before" src="https://github.com/user-attachments/assets/c9a9377c-4a3b-4477-921a-3c86385d3f0b" /> ### After (with image) <img width="239" height="284" alt="CleanShot 2026-05-11 at 17 22 01" src="https://github.com/user-attachments/assets/311b4dbc-4853-4e6c-9f83-8173b38bd466" /> ### After (no image) <img width="239" height="189" alt="after" src="https://github.com/user-attachments/assets/884e062b-3608-4cb3-a462-d50597257753" /> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 2301ed6 commit 1e4b896

3 files changed

Lines changed: 605 additions & 621 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { XMarkIcon } from "@heroicons/react/20/solid";
2+
import { useLayoutEffect, useRef, useState } from "react";
3+
import ReactMarkdown from "react-markdown";
4+
import { cn } from "~/utils/cn";
5+
6+
export function NotificationCard({
7+
title,
8+
description,
9+
image,
10+
actionUrl,
11+
onDismiss,
12+
onCardClick,
13+
onLinkClick,
14+
}: {
15+
title: string;
16+
description: string;
17+
image?: string;
18+
actionUrl?: string;
19+
onDismiss?: () => void;
20+
onCardClick?: () => void;
21+
onLinkClick?: () => void;
22+
}) {
23+
const [isExpanded, setIsExpanded] = useState(false);
24+
const [isOverflowing, setIsOverflowing] = useState(false);
25+
const descriptionRef = useRef<HTMLDivElement>(null);
26+
27+
useLayoutEffect(() => {
28+
const el = descriptionRef.current;
29+
if (!el) return;
30+
31+
const check = () => setIsOverflowing(el.scrollHeight - el.clientHeight > 1);
32+
check();
33+
34+
const observer = new ResizeObserver(check);
35+
observer.observe(el);
36+
return () => observer.disconnect();
37+
}, [description]);
38+
39+
const handleDismiss = (e: React.MouseEvent) => {
40+
e.preventDefault();
41+
e.stopPropagation();
42+
onDismiss?.();
43+
};
44+
45+
const handleToggleExpand = (e: React.MouseEvent) => {
46+
e.preventDefault();
47+
e.stopPropagation();
48+
setIsExpanded((v) => !v);
49+
};
50+
51+
const safeActionUrl = sanitizeUrl(actionUrl);
52+
const safeImage = sanitizeUrl(image);
53+
54+
return (
55+
<div className="group/card relative overflow-hidden rounded border border-charcoal-650 bg-charcoal-700/50 shadow-lg">
56+
{safeActionUrl && (
57+
<a
58+
href={safeActionUrl}
59+
target="_blank"
60+
rel="noopener noreferrer"
61+
aria-label={title}
62+
onClick={onCardClick}
63+
className="absolute inset-0 z-10"
64+
/>
65+
)}
66+
67+
<div className="flex items-start gap-1 px-2.5 pt-2">
68+
<p className="flex-1 text-[13px] font-medium leading-normal text-text-bright">{title}</p>
69+
<button
70+
type="button"
71+
onClick={handleDismiss}
72+
aria-label="Dismiss notification"
73+
title="Dismiss notification"
74+
className="relative z-20 -mr-1 shrink-0 rounded p-0.5 text-text-dimmed opacity-0 transition group-hover/card:opacity-100 hover:bg-charcoal-700 hover:text-text-bright focus-visible:opacity-100"
75+
>
76+
<XMarkIcon className="size-3.5" />
77+
</button>
78+
</div>
79+
80+
<div className="px-2.5 pb-2">
81+
<div ref={descriptionRef} className={cn(!isExpanded && "line-clamp-3")}>
82+
<ReactMarkdown components={getMarkdownComponents(onLinkClick)}>
83+
{description}
84+
</ReactMarkdown>
85+
</div>
86+
{(isOverflowing || isExpanded) && (
87+
<button
88+
type="button"
89+
onClick={handleToggleExpand}
90+
className="relative z-20 mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
91+
>
92+
{isExpanded ? "Show less" : "Show more"}
93+
</button>
94+
)}
95+
96+
{safeImage && <img src={safeImage} alt="" className="mt-1.5 rounded" />}
97+
</div>
98+
</div>
99+
);
100+
}
101+
102+
function getMarkdownComponents(onLinkClick?: () => void) {
103+
return {
104+
p: ({ children }: { children?: React.ReactNode }) => (
105+
<p className="my-0.5 text-xs leading-normal text-text-dimmed">{children}</p>
106+
),
107+
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
108+
<a
109+
href={href}
110+
target="_blank"
111+
rel="noopener noreferrer"
112+
className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300"
113+
onClick={(e) => {
114+
e.stopPropagation();
115+
onLinkClick?.();
116+
}}
117+
>
118+
{children}
119+
</a>
120+
),
121+
strong: ({ children }: { children?: React.ReactNode }) => (
122+
<strong className="font-semibold text-text-bright">{children}</strong>
123+
),
124+
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
125+
code: ({ children }: { children?: React.ReactNode }) => (
126+
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-[11px]">{children}</code>
127+
),
128+
};
129+
}
130+
131+
const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
132+
133+
/** Sanitize a URL to prevent XSS via javascript: or data: URIs. Returns "" if invalid. */
134+
function sanitizeUrl(url: string | undefined): string {
135+
if (!url) return "";
136+
try {
137+
const parsed = new URL(url);
138+
return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? parsed.href : "";
139+
} catch {
140+
return "";
141+
}
142+
}

0 commit comments

Comments
 (0)