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(
+ ,
+ 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 {