diff --git a/app/lib/frontend/templates/layout.dart b/app/lib/frontend/templates/layout.dart index 61a5e6eab1..c2a68ec68f 100644 --- a/app/lib/frontend/templates/layout.dart +++ b/app/lib/frontend/templates/layout.dart @@ -62,7 +62,9 @@ String renderLayoutPage( if (type == PageType.standalone) 'page-standalone', if (type == PageType.landing) 'page-landing', ]; - final announcementBannerHtml = announcementBackend.getAnnouncementHtml(); + //final announcementBannerHtml = announcementBackend.getAnnouncementHtml(); + final announcementBannerHtml = + 'EXAMPLE BANNER -- EXAMPLE BANNER -- EXAMPLE BANNER -- EXAMPLE BANNER!!!!!!!!'; final session = requestContext.sessionData; final moderationSubject = () { if (pageData == null) { diff --git a/app/lib/frontend/templates/views/shared/layout.dart b/app/lib/frontend/templates/views/shared/layout.dart index de1cc2239c..9d8e8a5a2e 100644 --- a/app/lib/frontend/templates/views/shared/layout.dart +++ b/app/lib/frontend/templates/views/shared/layout.dart @@ -195,7 +195,14 @@ d.Node pageLayoutNode({ if (announcementBanner != null) d.div( classes: ['announcement-banner'], - child: announcementBanner, + attributes: { + 'data-widget': 'dismissible', + 'data-dismissible-by': '.dismisser' + }, + children: [ + announcementBanner, + d.div(classes: ['dismisser'], text: 'x'), + ], ), ], ), diff --git a/pkg/web_app/lib/src/widget/completion/widget.dart b/pkg/web_app/lib/src/widget/completion/widget.dart index e60e9892dd..0d460094b0 100644 --- a/pkg/web_app/lib/src/widget/completion/widget.dart +++ b/pkg/web_app/lib/src/widget/completion/widget.dart @@ -31,7 +31,7 @@ import '../../web_util.dart'; /// * `completion-dropdown` for the completion dropdown. /// * `completion-option` for each option in the dropdown, and, /// * `completion-option-select` is applied to selected options. -void create(Element element, Map options) { +void create(HTMLElement element, Map options) { if (!element.isA()) { throw UnsupportedError('Must be element'); } diff --git a/pkg/web_app/lib/src/widget/dismissible/widget.dart b/pkg/web_app/lib/src/widget/dismissible/widget.dart new file mode 100644 index 0000000000..9bbbd8c146 --- /dev/null +++ b/pkg/web_app/lib/src/widget/dismissible/widget.dart @@ -0,0 +1,105 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:collection/collection.dart'; +import 'package:web/web.dart'; + +import '../../web_util.dart'; + +/// Forget dismissed messages that are more than 2 years old +late final _deadline = DateTime.now().subtract(Duration(days: 365 * 2)); + +/// Don't save more than 50 entries +const _maxMissedMessages = 50; + +/// Create a dismissible widget on [element]. +/// +/// A `data-dismissible-by` is required, this must be a CSS selected for a +/// child element that when clicked will dismiss the message by removing +/// [element]. +/// +/// A hash is computed from `innerText` of [element], once dismissed this hash +/// will be stored in `localStorage`. And next time this widget is instantiated +/// with the same `innerText` it'll be removed immediately. +/// +/// Hashes of dismissed messages will be stored for up to 2 years. +/// No more than 50 dismissed messages are retained in `localStorage`. +void create(HTMLElement element, Map options) { + final by = options['by']; + if (by == null) throw UnsupportedError('data-dismissible-by required'); + + late final hash = _cheapNaiveHash(element.innerText); + if (_dismissed.any((e) => e.hash == hash)) { + element.remove(); + return; + } else { + element.style.display = 'revert'; + } + + final dismiss = (Event e) { + e.preventDefault(); + + element.remove(); + _dismissed.add(( + hash: hash, + date: DateTime.now(), + )); + _saveDismissed(); + }; + + var dismissible = false; + for (final dismisser in element.querySelectorAll(by).toList()) { + if (!dismisser.isA()) continue; + dismisser.addEventListener('click', dismiss.toJS); + dismissible = true; + } + if (!dismissible) { + throw UnsupportedError( + 'data-dismissible-by must point to a child element', + ); + } +} + +/// Create a cheap naive hash from [text]. +String _cheapNaiveHash(String text) { + final half = (text.length / 2).floor(); + return text.length.toRadixString(16).padLeft(2, '0') + + text.hashCode.toRadixString(16).padLeft(2, '0') + + text.substring(0, half).hashCode.toRadixString(16).padLeft(2, '0') + + text.substring(half).hashCode.toRadixString(16).padLeft(2, '0'); +} + +/// LocalStorage key where we store the hashes of messages that have been +/// dismissed. +/// +/// Data is stored on the format: `@;@;...` +const _dismissedMessageslocalStorageKey = 'dismissed-messages'; + +late final _dismissed = [ + ...?window.localStorage + .getItem(_dismissedMessageslocalStorageKey) + ?.split(';') + .where((e) => e.contains('@')) + .map((entry) { + final [hash, date, ...] = entry.split('@'); + return ( + hash: hash, + date: DateTime.tryParse(date) ?? DateTime.fromMicrosecondsSinceEpoch(0), + ); + }).where((entry) => entry.date.isAfter(_deadline)), +]; + +void _saveDismissed() { + window.localStorage.setItem( + _dismissedMessageslocalStorageKey, + _dismissed + .sortedBy((e) => e.date) // Sort by date + .reversed // Reverse ordering to prefer newest dates + .take(_maxMissedMessages) // Limit how many entries we save + .map((e) => e.hash + '@' + e.date.toIso8601String().split('T').first) + .join(';'), + ); +} diff --git a/pkg/web_app/lib/src/widget/widget.dart b/pkg/web_app/lib/src/widget/widget.dart index 46c877fd08..a01d558ca0 100644 --- a/pkg/web_app/lib/src/widget/widget.dart +++ b/pkg/web_app/lib/src/widget/widget.dart @@ -10,6 +10,7 @@ import 'package:web/web.dart'; import '../web_util.dart'; import 'completion/widget.dart' deferred as completion; +import 'dismissible/widget.dart' deferred as dismissible; /// Function to create an instance of the widget given an element and options. /// @@ -21,7 +22,7 @@ import 'completion/widget.dart' deferred as completion; /// `data-widget="completion"`. And option `src` is specified with: /// `data-completion-src="$value"`. typedef _WidgetFn = FutureOr Function( - Element element, + HTMLElement element, Map options, ); @@ -31,6 +32,8 @@ typedef _WidgetLoaderFn = FutureOr<_WidgetFn> Function(); /// Map from widget name to widget loader final _widgets = { 'completion': () => completion.loadLibrary().then((_) => completion.create), + 'dismissible': () => + dismissible.loadLibrary().then((_) => dismissible.create), }; Future<_WidgetFn> _noSuchWidget() async => diff --git a/pkg/web_css/lib/src/_base.scss b/pkg/web_css/lib/src/_base.scss index a34400f10c..7bb3d46fe3 100644 --- a/pkg/web_css/lib/src/_base.scss +++ b/pkg/web_css/lib/src/_base.scss @@ -19,7 +19,10 @@ body { overflow-wrap: anywhere; } -body, input, button, select { +body, +input, +button, +select { font-family: var(--pub-default-text-font_family); -webkit-font-smoothing: antialiased; // we don't use font ligatures, and Google Sans fonts would otherwise change text in surprising ways @@ -41,17 +44,39 @@ body, font-weight: 400; font-size: 16px; - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { font-family: var(--pub-default-text-font_family); font-weight: 400; } - h1 { font-size: 36px; } - h2 { font-size: 24px; } - h3 { font-size: 18px; } - h4 { font-size: 18px; } - h5 { font-size: 16px; } - h6 { font-size: 16px; } + h1 { + font-size: 36px; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 18px; + } + + h4 { + font-size: 18px; + } + + h5 { + font-size: 16px; + } + + h6 { + font-size: 16px; + } } img { @@ -134,6 +159,7 @@ main { .standalone-side-image-block { display: block; } + .standalone-wrapper.-has-side-image { .standalone-content { margin: 0px; @@ -167,7 +193,7 @@ pre { padding: 30px; overflow-x: auto; - > code { + >code { padding: 0px !important; border-radius: 0; display: inline-block; @@ -232,29 +258,37 @@ pre { .markdown-body { p { - margin-top: 8px; /* overrides github-markdown.css */ - margin-bottom: 12px; /* overrides github-markdown.css */ + margin-top: 8px; + /* overrides github-markdown.css */ + margin-bottom: 12px; + /* overrides github-markdown.css */ } table { - td, th { - padding: 12px 12px 12px 0; /* overrides github-markdown.css */ - border: none; /* overrides github-markdown.css */ + td, + th { + padding: 12px 12px 12px 0; + /* overrides github-markdown.css */ + border: none; + /* overrides github-markdown.css */ } tr { - border-top: none; /* overrides github-markdown.css */ + border-top: none; + /* overrides github-markdown.css */ &:nth-child(2n) { - background-color: inherit; /* overrides github-markdown.css */ + background-color: inherit; + /* overrides github-markdown.css */ } } th { font-family: var(--pub-default-text-font_family); font-size: 16px; - font-weight: 400; /* overrides github-markdown.css */ + font-weight: 400; + /* overrides github-markdown.css */ border-bottom: 1px solid #c8c8ca; text-align: left; } @@ -264,7 +298,8 @@ pre { } img { - background-color: inherit; /* overrides github-markdown.css */ + background-color: inherit; + /* overrides github-markdown.css */ } } } @@ -355,8 +390,13 @@ pre { } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } .hash-link { @@ -384,12 +424,37 @@ pre { } .announcement-banner { + display: none; + padding: 10px 0; background: var(--pub-home_announcement-background-color); font-size: 16px; text-align: center; + + .dismisser { + float: right; + padding: 5px 15px; + margin-top: -5px; + cursor: pointer; + user-select: none; + } + + z-index: 0; + animation-duration: 200ms; + animation-name: slide-down; + animation-timing-function: ease; +} + +@keyframes slide-down { + from { + translate: 0 -100%; + } + + to { + translate: 0 0; + } } a.-x-ago { diff --git a/pkg/web_css/lib/src/_site_header.scss b/pkg/web_css/lib/src/_site_header.scss index 0d7b2ad4a4..245066b409 100644 --- a/pkg/web_css/lib/src/_site_header.scss +++ b/pkg/web_css/lib/src/_site_header.scss @@ -21,6 +21,7 @@ } .site-header { + z-index: 100; // for animation of announcement background: var(--pub-site_header_banner-background-color); color: var(--pub-site_header_banner-text-color); display: flex; @@ -32,6 +33,7 @@ @media (max-width: $device-mobile-max-width) { &:focus-within { + .hamburger, .site-logo { opacity: 0.3; @@ -223,6 +225,7 @@ } .site-header-nav { + /* Navigation styles for mobile. */ @media (max-width: $device-mobile-max-width) { position: fixed; @@ -354,7 +357,7 @@ padding: 12px; min-width: 100px; - > h3 { + >h3 { border-bottom: 1px solid var(--pub-site_header_popup-border-color); } }