-
-
Notifications
You must be signed in to change notification settings - Fork 180
feat(ui-react): add user-facing announcement modal #6265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
luizhf42
wants to merge
2
commits into
master
Choose a base branch
from
feat/ui-react/announcement-modal
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
121 changes: 121 additions & 0 deletions
121
ui-react/apps/console/src/components/announcements/AnnouncementModal.css
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| /* Colors are manually synced with tailwind.preset.js dark theme tokens. | ||
| Update these if the theme palette changes. */ | ||
|
|
||
| .announcement-modal-content .ProseMirror { | ||
| outline: none; | ||
| color: #e1e4ea; | ||
| font-size: 0.875rem; | ||
| line-height: 1.625; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror h1 { | ||
| font-size: 1.5rem; | ||
| font-weight: 700; | ||
| margin-top: 1.5rem; | ||
| margin-bottom: 0.5rem; | ||
| line-height: 1.3; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror h1:first-child { | ||
| margin-top: 0; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror h2 { | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| margin-top: 1.25rem; | ||
| margin-bottom: 0.5rem; | ||
| line-height: 1.3; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror h3 { | ||
| font-size: 1.1rem; | ||
| font-weight: 600; | ||
| margin-top: 1rem; | ||
| margin-bottom: 0.5rem; | ||
| line-height: 1.3; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror p { | ||
| margin-bottom: 0.75rem; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror p:last-child { | ||
| margin-bottom: 0; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror ul, | ||
| .announcement-modal-content .ProseMirror ol { | ||
| padding-left: 1.5rem; | ||
| margin-bottom: 0.75rem; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror ul { | ||
| list-style: disc; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror ol { | ||
| list-style: decimal; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror li { | ||
| margin-bottom: 0.25rem; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror blockquote { | ||
| border-left: 3px solid #383d47; | ||
| padding-left: 1rem; | ||
| margin-left: 0; | ||
| margin-bottom: 0.75rem; | ||
| color: #8b8f99; | ||
| font-style: italic; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror code { | ||
| background: #1e2127; | ||
| border: 1px solid #2c2f36; | ||
| border-radius: 0.25rem; | ||
| padding: 0.125rem 0.375rem; | ||
| font-family: "IBM Plex Mono", monospace; | ||
| font-size: 0.8125rem; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror pre { | ||
| background: #1e2127; | ||
| border: 1px solid #2c2f36; | ||
| border-radius: 0.5rem; | ||
| padding: 0.75rem 1rem; | ||
| margin-bottom: 0.75rem; | ||
| overflow-x: auto; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror pre code { | ||
| background: none; | ||
| border: none; | ||
| padding: 0; | ||
| font-size: 0.8125rem; | ||
| line-height: 1.5; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror a { | ||
| color: #667acc; | ||
| text-decoration: underline; | ||
| text-underline-offset: 2px; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror a:hover { | ||
| opacity: 0.8; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror img { | ||
| max-width: 100%; | ||
| height: auto; | ||
| border-radius: 0.5rem; | ||
| margin: 0.75rem 0; | ||
| } | ||
|
|
||
| .announcement-modal-content .ProseMirror hr { | ||
| border: none; | ||
| border-top: 1px solid #2c2f36; | ||
| margin: 1.25rem 0; | ||
| } |
104 changes: 104 additions & 0 deletions
104
ui-react/apps/console/src/components/announcements/AnnouncementModal.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| import { useId } from "react"; | ||
| import { useEditor, EditorContent } from "@tiptap/react"; | ||
| import StarterKit from "@tiptap/starter-kit"; | ||
| import Link from "@tiptap/extension-link"; | ||
| import Image from "@tiptap/extension-image"; | ||
| import { Markdown } from "@tiptap/markdown"; | ||
| import { MegaphoneIcon, XMarkIcon } from "@heroicons/react/24/outline"; | ||
| import BaseDialog from "@/components/common/BaseDialog"; | ||
| import { formatDateShort } from "@/utils/date"; | ||
| import { isAllowedUrl } from "@/utils/url"; | ||
| import type { Announcement } from "@/client"; | ||
| import "./AnnouncementModal.css"; | ||
|
|
||
| interface AnnouncementContentProps { | ||
| content: string; | ||
| } | ||
|
|
||
| function AnnouncementContent({ content }: AnnouncementContentProps) { | ||
| const editor = useEditor({ | ||
| extensions: [ | ||
| StarterKit, | ||
| Link.configure({ | ||
| openOnClick: true, | ||
| validate: (url) => isAllowedUrl(url), | ||
| HTMLAttributes: { | ||
| rel: "noopener noreferrer", | ||
| target: "_blank", | ||
| }, | ||
| }), | ||
| Image.configure({ allowBase64: false }), | ||
| Markdown, | ||
| ], | ||
| content, | ||
| contentType: "markdown", | ||
| editable: false, | ||
| }); | ||
|
|
||
| if (!editor) return <div className="min-h-[80px]" />; | ||
|
|
||
| return ( | ||
| <div className="announcement-modal-content"> | ||
| <EditorContent editor={editor} /> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| interface AnnouncementModalProps { | ||
| open: boolean; | ||
| onClose: () => void; | ||
| announcement: Announcement; | ||
| } | ||
|
|
||
| export default function AnnouncementModal({ | ||
| open, | ||
| onClose, | ||
| announcement, | ||
| }: AnnouncementModalProps) { | ||
| const titleId = useId(); | ||
|
|
||
| return ( | ||
| <BaseDialog open={open} onClose={onClose} size="md" aria-labelledby={titleId}> | ||
| <div className="flex items-start justify-between gap-4 p-6 border-b border-border"> | ||
| <div className="flex items-center gap-3"> | ||
| <div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center shrink-0"> | ||
| <MegaphoneIcon className="w-5 h-5 text-primary" strokeWidth={1.5} /> | ||
| </div> | ||
| <div> | ||
| <h2 | ||
| id={titleId} | ||
| className="text-base font-semibold text-text-primary leading-snug" | ||
| > | ||
| {announcement.title} | ||
| </h2> | ||
| <p className="text-xs text-text-muted font-mono mt-0.5"> | ||
| {formatDateShort(announcement.date)} | ||
| </p> | ||
| </div> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-border/40 transition-colors shrink-0 -mt-0.5 -mr-1" | ||
| aria-label="Close announcement" | ||
| > | ||
| <XMarkIcon className="w-4 h-4" /> | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="p-6 overflow-y-auto max-h-[60vh]"> | ||
| <AnnouncementContent key={announcement.uuid} content={announcement.content} /> | ||
| </div> | ||
|
|
||
| <div className="flex justify-end gap-2 p-5 border-t border-border"> | ||
| <button | ||
| type="button" | ||
| onClick={onClose} | ||
| className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" | ||
| > | ||
| Got it | ||
| </button> | ||
| </div> | ||
| </BaseDialog> | ||
| ); | ||
| } |
46 changes: 46 additions & 0 deletions
46
ui-react/apps/console/src/components/announcements/AnnouncementModalTrigger.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { useState } from "react"; | ||
| import { getConfig } from "@/env"; | ||
| import { useLatestAnnouncement } from "@/hooks/useLatestAnnouncement"; | ||
| import AnnouncementModal from "./AnnouncementModal"; | ||
| import type { Announcement } from "@/client"; | ||
|
|
||
| const STORAGE_KEY = "announcement"; | ||
|
|
||
| function computeHash(announcement: Announcement): string { | ||
| return btoa(JSON.stringify(announcement)); | ||
| } | ||
|
|
||
| function getStoredHash(): string { | ||
| return localStorage.getItem(STORAGE_KEY) ?? ""; | ||
| } | ||
|
|
||
| function markSeen(announcement: Announcement): void { | ||
| localStorage.setItem(STORAGE_KEY, computeHash(announcement)); | ||
| } | ||
|
|
||
| export default function AnnouncementModalTrigger() { | ||
| if (!getConfig().announcements) return null; | ||
|
|
||
| return <AnnouncementModalInner />; | ||
| } | ||
|
|
||
| function AnnouncementModalInner() { | ||
| const { announcement } = useLatestAnnouncement(); | ||
| const [dismissed, setDismissed] = useState(false); | ||
|
|
||
| const show = | ||
| !!announcement && | ||
| !dismissed && | ||
| computeHash(announcement) !== getStoredHash(); | ||
|
|
||
| const handleClose = () => { | ||
| if (announcement) markSeen(announcement); | ||
| setDismissed(true); | ||
| }; | ||
|
|
||
| if (!show || !announcement) return null; | ||
|
|
||
| return ( | ||
| <AnnouncementModal open={show} onClose={handleClose} announcement={announcement} /> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Runtime crash on non-ASCII content (high)
btoa()only accepts Latin-1 strings (U+0000–U+00FF). When an admin saves an announcement with emoji, accented characters, or CJK text,btoa(JSON.stringify(announcement))throwsInvalidCharacterError. This affects both call sites — theshowcheck (line 34) andmarkSeen(line 37) — so the modal either crashes on render or cannot be dismissed.Compatibility note: The Vue UI uses the identical
btoa(JSON.stringify(...))pattern with the same"announcement"localStorage key (AnnouncementsModal.vue:57,UserWarning.vue:112). Any fix here must either be applied to both UIs simultaneously, or use an approach that stays compatible.The simplest compatible fix is the standard UTF-8 → Latin-1 encoding trick:
This produces the same output as
btoa(JSON.stringify(...))for Latin-1 content (preserving backward compatibility with existing localStorage entries) while correctly handling non-ASCII characters. The Vue UI would need the equivalent change.Fix this →