diff --git a/packages/frontend/src/app/canvas/[canvasId]/admin/paste/page.tsx b/packages/frontend/src/app/canvas/[canvasId]/admin/paste/page.tsx index 7ea55638b..81fb80708 100644 --- a/packages/frontend/src/app/canvas/[canvasId]/admin/paste/page.tsx +++ b/packages/frontend/src/app/canvas/[canvasId]/admin/paste/page.tsx @@ -450,7 +450,6 @@ function AdminPasteTab() { actionPanel={actionPanel} canvasLabel="Admin paste" showInvite={false} - showNotices={false} showReticle={false} /> {actionPanel} diff --git a/packages/frontend/src/app/canvas/[canvasId]/moderation/ModerationDashboard.tsx b/packages/frontend/src/app/canvas/[canvasId]/moderation/ModerationDashboard.tsx index eda55dd27..1ac4b6e26 100644 --- a/packages/frontend/src/app/canvas/[canvasId]/moderation/ModerationDashboard.tsx +++ b/packages/frontend/src/app/canvas/[canvasId]/moderation/ModerationDashboard.tsx @@ -46,7 +46,6 @@ export default function ModerationDashboard() { searchOverlayPixels={searchOverlayPixels} searchOverlayVisible={isSearchOverlayVisible} showInvite={false} - showNotices={false} showReticle={false} /> {actionPanel} diff --git a/packages/frontend/src/components/ContentUnavailableView.tsx b/packages/frontend/src/components/ContentUnavailableView.tsx new file mode 100644 index 000000000..2edd1b0b4 --- /dev/null +++ b/packages/frontend/src/components/ContentUnavailableView.tsx @@ -0,0 +1,33 @@ +import { styled } from "@mui/material"; + +export const Root = styled("article")` + align-content: center; + margin-trim: block; + min-block-size: min(100vb, 16em); /* fallback */ + min-block-size: min(100dvb, 16em); + padding-block: 2rem; + text-align: center; + text-wrap: balance; + svg, + .lucide { + margin-inline: auto; + opacity: 55%; + font-size: 3.375rem; + height: 3.375rem; + width: auto; + } +`; + +export const Heading = styled("h2")` + color: unset; + font-size: 1.5em; + font-weight: 600; + margin-block-start: 0.5em; +`; + +export const Description = styled("p")` + color: ${(props) => props.theme.palette.text.secondary}; + margin-block-start: 0.5em; +`; + +export default { Root, Heading, Description }; diff --git a/packages/frontend/src/components/canvas/CanvasView.tsx b/packages/frontend/src/components/canvas/CanvasView.tsx index 31f2d0098..8d16ec1e8 100644 --- a/packages/frontend/src/components/canvas/CanvasView.tsx +++ b/packages/frontend/src/components/canvas/CanvasView.tsx @@ -39,7 +39,6 @@ import { CANVAS_WRAPPER_CLASS_NAME, clamp, normalizeFrameBounds } from "@/util"; import type { ActionPanel } from "../action-panel"; import { Button } from "../button"; import CanvasIcon from "../CanvasIcon"; -import Notices from "../notices/Notices"; import VisuallyHidden from "../VisuallyHidden"; import { addPoints, @@ -209,7 +208,6 @@ const FullscreenButton = styled(BaseFullscreenButton)` const FullscreenPanelButton = styled(BaseFullscreenButton)` border-radius: 0.5rem 0.5rem 0.5rem 1rem; inset-block-start: 4rem; - z-index: 3; `; const FullscreenPanelOverlay = styled("div")` @@ -221,7 +219,6 @@ const FullscreenPanelOverlay = styled("div")` pointer-events: auto; position: absolute; width: min(var(--action-panel-width), calc(100vw - 1rem)); - z-index: 2; > * { width: 100%; @@ -490,7 +487,6 @@ interface CanvasViewProps { searchOverlayPixels?: PixelHistoryOverlayPixel[] | null; searchOverlayVisible?: boolean; showInvite?: boolean; - showNotices?: boolean; showReticle?: boolean; } @@ -500,7 +496,6 @@ export default function CanvasView({ searchOverlayPixels = null, searchOverlayVisible = false, showInvite = true, - showNotices = true, showReticle = true, }: CanvasViewProps) { const imageRef = useRef(null); @@ -1239,7 +1234,6 @@ export default function CanvasView({ ref={containerRef} onPointerDown={handlePointerDown} > - {showNotices && } {canUseFullscreen && ( , +) { return ( - - + + theme.breakpoints.up("md")} { + align-items: baseline; display: flex; } - li { display: inline-flex; } - - /* - * Workaround for accessibility issue with VoiceOver. - * See https://gerardkcohen.me/writing/2017/voiceover-list-style-type.html - */ - li::before { - content: "\\200B"; /* zero-width space */ - } `; export const NavLink = styled(Link)` border-radius: 0.5rem; color: var(--discord-white); + font-weight: 450; + letter-spacing: 0.01em; padding-block: 0.5rem; padding-inline: 0.75rem; text-decoration: none; - transition-duration: var(--transition-duration-fast); - transition-property: background-color, opacity, outline-width; + transition-property: background-color, outline-width; + transition-timing-function: var(--transition-duration-fast); :hover { opacity: 55%; @@ -137,6 +131,7 @@ export default function Nav() { ))} + {links.map(({ href, label }) => (
  • {label} diff --git a/packages/frontend/src/components/notices/NoticeBanner.tsx b/packages/frontend/src/components/notices/NoticeBanner.tsx deleted file mode 100644 index a020e3bd5..000000000 --- a/packages/frontend/src/components/notices/NoticeBanner.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import type { Notice } from "@blurple-canvas-web/types"; -import { styled } from "@mui/material/styles"; -import { CircleAlert, Info, TriangleAlert } from "lucide-react"; -import Markdown from "markdown-to-jsx"; -import { resolveSpecialText } from "@/util/text"; - -type BannerComponent = typeof StyledBanner; - -const StyledBanner = styled("div")` - align-items: center; - background-color: var(--discord-legacy-dark-but-not-black); - border-radius: var(--card-border-radius); - border: 3px solid; - box-shadow: 0 0 10px rgba(0 0 0 / 50%); - cursor: default; - display: flex; - flex-direction: row; - gap: 1rem; - justify-content: center; - padding: 1rem; - width: fit-content; - - & > svg { - flex: 0 0 auto; - width: 1.25rem; - height: 1.25rem; - } -`; - -const StyledInfoBanner = styled(StyledBanner)` - border-color: oklch(from var(--discord-white) l c h / 50%); -`; - -const StyledWarningBanner = styled(StyledBanner)` - border-color: var(--discord-blurple); -`; - -const StyledErrorBanner = styled(StyledBanner)` - border-color: var(--discord-red); -`; - -const BannerBody = styled("div")` - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; -`; - -const ContentSpan = styled("span")` - > div { - display: flex; - flex-direction: column; - } -`; - -const DismissWrapper = styled("div")` - display: flex; - flex-direction: row; - gap: 1rem; -`; - -const DismissButton = styled("button")` - background: transparent; - border: none; - cursor: pointer; - flex: 0 0 auto; - opacity: 50%; - transition: opacity var(--transition-duration-fast) ease; - - :hover { - opacity: 80%; - } -`; - -interface BannerProps { - notice: Notice; - onDismiss?: () => void; - onDismissPermanently?: () => void; -} - -function Banner({ - notice, - BannerRoot, - icon, - onDismiss, - onDismissPermanently, -}: { - BannerRoot: BannerComponent; - icon: React.ReactNode; -} & BannerProps) { - const headerText = - notice.header ? `### ${resolveSpecialText(notice.header)}` : ""; - const contentText = notice.content ? resolveSpecialText(notice.content) : ""; - - return ( - { - e.stopPropagation(); - }} - > - {icon} - - {headerText && ( - - {headerText} - - )} - {contentText && ( - - {contentText} - - )} - - Dismiss - {!notice.persisted && ( - - Don't show again - - )} - - - - ); -} - -export default function NoticeBanner({ - notice, - onDismiss, - onDismissPermanently, -}: { - notice: Notice; - onDismiss?: () => void; - onDismissPermanently?: () => void; -}) { - switch (notice.type) { - case "info": - return ( - } - notice={notice} - onDismiss={onDismiss} - onDismissPermanently={onDismissPermanently} - /> - ); - case "warning": - return ( - } - notice={notice} - onDismiss={onDismiss} - onDismissPermanently={onDismissPermanently} - /> - ); - case "error": - return ( - } - notice={notice} - onDismiss={onDismiss} - onDismissPermanently={onDismissPermanently} - /> - ); - default: - return null; - } -} diff --git a/packages/frontend/src/components/notices/NoticeList.tsx b/packages/frontend/src/components/notices/NoticeList.tsx new file mode 100644 index 000000000..9c6e9b6f0 --- /dev/null +++ b/packages/frontend/src/components/notices/NoticeList.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { styled } from "@mui/material"; +import { MegaphoneOff } from "lucide-react"; +import { useCanvasContext } from "@/contexts"; +import { useNotices } from "@/hooks/queries/useNotice"; +import ContentUnavailableView from "../ContentUnavailableView"; +import NoticeListItem from "./NoticeListItem"; + +const UnorderedList = styled("ul")` + align-content: flex-start; + align-items: center; + display: grid; + font-size: 1rem; + gap: 0.75em; + grid-template-columns: auto 1fr; + line-height: 1.55; +`; + +export default function NoticeList( + props: React.ComponentPropsWithRef, +) { + const { data: notices } = useNotices(); + const { canvas } = useCanvasContext(); + + const filtered = + notices + ?.filter( + (notice) => notice.canvasId === null || notice.canvasId === canvas?.id, + ) + .sort( + (a, b) => + a.priority - b.priority || + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ) ?? []; + + if (filtered.length === 0) { + return ( + + + + No notices + + + No notices for {canvas.name} + + + ); + } + return ( + + {filtered.map((notice) => ( + + ))} + + ); +} diff --git a/packages/frontend/src/components/notices/NoticeListItem.tsx b/packages/frontend/src/components/notices/NoticeListItem.tsx new file mode 100644 index 000000000..10268d271 --- /dev/null +++ b/packages/frontend/src/components/notices/NoticeListItem.tsx @@ -0,0 +1,106 @@ +import type { Notice } from "@blurple-canvas-web/types"; +import { styled } from "@mui/material/styles"; +import { CircleAlert, Info, TriangleAlert } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { resolveSpecialText } from "@/util/text"; + +const icons = { + info: , + error: , + warning: , +} as const; + +const IconWrapper = styled("div")` + background-color: oklch(from var(--notice-tint) l c h / 15%); + border-radius: calc(infinity * 1px); + color: var(--notice-tint); + display: grid; + height: 2rem; + opacity: 94%; + place-items: center; + width: 2rem; +`; + +const BannerRoot = styled("li")` + --notice-tint: var(--discord-white;); + background-color: oklch(from var(--notice-tint) l c h / 6%); + border-radius: 0.75rem; + border: var(--card-border); + display: grid; + gap: inherit; + grid-column: 1 / -1; + grid-template-columns: subgrid; + padding: 0.75rem; + font-weight: 450; + letter-spacing: 0.01em; + + @supports (color: color-mix(in oklab, black, black)) { + color: color-mix(in oklab, currentColor 90%, var(--notice-tint)); + } + + &[data-severity="warning"] { + background-color: transparent; + } + &[data-severity="warning"] { + --notice-tint: var(--discord-blurple); + } + &[data-severity="error"] { + --notice-tint: var(--discord-red); + } + + /* + * Pseudoelement disrupts the grid flow. Notices not being declared as a list in WebKit not end of + * world. Sorta newsfeedy semantics anyway. + */ + &::before { + content: unset; + } +`; + +const BannerBody = styled("div")` + * + * { + margin-block-start: 0.5em; + } + h1, + h2, + h3, + h4, + h5, + h6 { + font-size: inherit; + font-weight: bolder; + } +`; + +interface NoticeListItemProps extends React.ComponentPropsWithRef< + typeof BannerRoot +> { + notice: Notice; +} + +export default function NoticeListItem({ + notice, + ...props +}: NoticeListItemProps) { + const headerText = + notice.header ? `### ${resolveSpecialText(notice.header)}` : ""; + const contentText = notice.content ? resolveSpecialText(notice.content) : ""; + + return ( + e.stopPropagation()} + {...props} + > + {icons[notice.type]} + + {headerText && ( +
    + {headerText} +
    + )} + {contentText && {contentText}} +
    +
    + ); +} diff --git a/packages/frontend/src/components/notices/Noticeboard.tsx b/packages/frontend/src/components/notices/Noticeboard.tsx new file mode 100644 index 000000000..f6436a56b --- /dev/null +++ b/packages/frontend/src/components/notices/Noticeboard.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { IconButton, styled } from "@mui/material"; +import { Megaphone } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import VisuallyHidden from "../VisuallyHidden"; +import NoticeList from "./NoticeList"; + +const anchorName = "--notices-button"; + +const StyledIconButton = styled(IconButton)` + anchor-name: ${anchorName}; +`; + +const Dialog = styled("dialog")` + background-color: var(--discord-legacy-dark-but-not-black); + border-radius: var(--card-border-radius); + border: var(--card-border); + box-shadow: 0 0 10px oklch(0 0 0 / 25%); + inline-size: min(100vi, 36rem); /* fallback */ + inline-size: min(100dvi, 36rem); + max-block-size: min(90vh, 60rem); /* fallback */ + max-block-size: min(90dvh, 60rem); + overflow: auto; + position-anchor: ${anchorName}; + position-area: block-end center; + position: absolute; + transform-origin: top center; + transition-duration: 100ms; + transition-property: opacity, transform; + transition-timing-function: linear, var(--ease-out-quart); + z-index: 1; /* Fighting with here 😔 */ + + > * { + padding: calc(var(--card-border-radius) - 0.75rem); + } + + opacity: 1; + transform: scale(100%); + @starting-style { + opacity: 0; + transform: scale(97%); + } +`; + +const Header = styled("header")` + align-items: center; + border-block-end: var(--card-border); + display: flex; + position: sticky; +`; + +const Heading = styled("h2")` + color: unset; + font-size: inherit; + font-weight: 500; + margin-inline: 0.75rem; +`; + +export default function Noticeboard() { + const [isMounted, setIsMounted] = useState(false); + useEffect(() => setIsMounted(true), []); + + const buttonId = useId(); + const buttonRef = useRef(null); + const dialogId = useId(); + const dialogRef = useRef(null); + + return ( + <> + + + Notices + + {isMounted && + createPortal( + +
    + Notices +
    + +
    , + document.body, + )} + + ); +} diff --git a/packages/frontend/src/components/notices/Notices.tsx b/packages/frontend/src/components/notices/Notices.tsx deleted file mode 100644 index 20e52080f..000000000 --- a/packages/frontend/src/components/notices/Notices.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { styled } from "@mui/material"; -import { useCallback, useMemo, useState } from "react"; -import useLocalStorage from "@/app/settings/useLocalStorage"; -import { useCanvasContext } from "@/contexts"; -import { useNotices } from "@/hooks/queries/useNotice"; -import NoticeBanner from "./NoticeBanner"; - -const NoticeWrapper = styled("div")` - align-items: center; - display: flex; - flex-direction: column; - gap: 0.5rem; - left: 50%; - margin-top: 2.5rem; - pointer-events: auto; - position: absolute; - top: 0; - transform: translateX(-50%); - width: 90%; - z-index: 2000; -`; - -export default function Notices() { - const { data: notices = [] } = useNotices(); - const { canvas } = useCanvasContext(); - - const [persistedDismissed = [], setPersistedDismissed] = - useLocalStorage("notices/dismissed"); - - const [transientDismissed, setTransientDismissed] = useState>( - new Set(), - ); - - const persistedSet = useMemo( - () => new Set(persistedDismissed), - [persistedDismissed], - ); - - const dismiss = useCallback( - (id: string, persist: boolean = false) => { - setTransientDismissed((s) => { - const next = new Set(s); - next.add(id); - return next; - }); - - const n = notices.find((x) => x.id === Number.parseInt(id, 10)); - if (persist && n && n.persisted === false) { - const nextArr = Array.from( - new Set([...(persistedDismissed ?? []), id]), - ); - setPersistedDismissed(nextArr); - } - }, - [notices, persistedDismissed, setPersistedDismissed], - ); - - const filteredNotices = notices - .filter( - (notice) => - (notice.canvasId === null || notice.canvasId === canvas?.id) && - !transientDismissed.has(notice.id.toString()) && - !persistedSet.has(notice.id.toString()), - ) - .sort((a, b) => { - if (a.priority !== b.priority) { - return a.priority - b.priority; - } else { - return ( - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ); - } - }); - - return ( - - {filteredNotices.map((notice) => ( - dismiss(notice.id.toString(), false)} - onDismissPermanently={() => dismiss(notice.id.toString(), true)} - /> - ))} - - ); -} diff --git a/packages/frontend/src/components/notices/index.ts b/packages/frontend/src/components/notices/index.ts new file mode 100644 index 000000000..3697b737c --- /dev/null +++ b/packages/frontend/src/components/notices/index.ts @@ -0,0 +1 @@ +export { default as Noticeboard } from "./Noticeboard"; diff --git a/packages/frontend/src/styles/core.css b/packages/frontend/src/styles/core.css index 8e4503ebd..b7f857462 100644 --- a/packages/frontend/src/styles/core.css +++ b/packages/frontend/src/styles/core.css @@ -87,6 +87,10 @@ text-decoration: none; } + dialog { + border: 0; + } + hr { box-sizing: content-box; height: 0; @@ -140,6 +144,13 @@ :is(ol, ul)[role="list"] { list-style-type: none; + /* + * Workaround for accessibility issue with VoiceOver. + * See https://gerardkcohen.me/writing/2017/voiceover-list-style-type.html + */ + li::before { + content: "\200B"; /* zero-width space */ + } } table {