-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
@@ -652,7 +971,7 @@ exports[`SettingsDialog should render correctly 1`] = `
class="navigation-item__name"
data-clarity-mask="True"
>
- test-participant-name
+ test-auth-user-name
void;
+};
+
+/*
+ * Switch component supporting two states ("left" or "right").
+ * it is completely stateless, which means the logic to actually flip the switch resides in the parent component.
+ * the component itself only emits whenever the switch is interacted with.
+ */
+export const Switch = (props: SwitchProps) => (
+
+ );
diff --git a/src/components/UserPill/UserPill.scss b/src/components/UserPill/UserPill.scss
new file mode 100644
index 0000000000..1460f85152
--- /dev/null
+++ b/src/components/UserPill/UserPill.scss
@@ -0,0 +1,131 @@
+@use "sass:color";
+@import "constants/style";
+
+$padding--default: $spacing--xxs $spacing--md $spacing--xxs $spacing--xxs;
+$padding--mobile: 0 0 0 0;
+// if you want to include default states, use padding below and set compensate-padding to true
+// $padding--mobile: 1.5px 1.5px 1.5px 1.5px;
+
+$size-mobile: 56px;
+
+.user-pill {
+ all: unset;
+ user-select: none;
+
+ height: $icon--huge;
+ padding: $padding--default;
+ border-radius: $rounded--large;
+
+ background-color: $gray--000;
+
+ &:enabled {
+ cursor: pointer;
+ }
+
+ &:disabled {
+ background-color: $gray--300;
+
+ .user-pill__avatar-overlay {
+ background-color: color.change(#eeeff1, $alpha: 0.5);
+ }
+
+ .user-pill__name {
+ color: $gray--800;
+ }
+ }
+}
+
+.user-pill__container {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-around;
+ align-items: center;
+ gap: $spacing--sm;
+}
+
+.user-pill__avatar-container {
+ position: relative;
+
+ width: $icon--huge;
+ height: $icon--huge;
+ border-radius: 100%;
+ background-color: $blue--100;
+
+ user-select: none;
+}
+
+.user-pill__avatar-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ border-radius: 100%;
+
+ background-color: transparent;
+}
+
+.user-pill__avatar {
+ width: $icon--huge;
+ height: $icon--huge;
+}
+
+.user-pill__name {
+ font-size: $text--base;
+ font-weight: 700;
+
+ color: $navy--600;
+}
+
+// desktop only styles
+@media screen and (min-width: calc($breakpoint--smartphone + 1px)) {
+ .user-pill:enabled {
+ // this is moved here as a desktop only style because it can't be overwritten by the mobile media query below
+ @include default-states($padding: $padding--default);
+ }
+}
+
+// mobile only styles
+@media screen and (max-width: $breakpoint--smartphone) {
+ .user-pill {
+ height: $size-mobile;
+ padding: $padding--mobile;
+ }
+
+ .user-pill__avatar-container,
+ .user-pill__avatar {
+ width: $size-mobile;
+ height: $size-mobile;
+ }
+
+ .user-pill__name {
+ display: none;
+ }
+}
+
+[theme="dark"] {
+ .user-pill {
+ background-color: $navy--400;
+
+ &:disabled {
+ background-color: $navy--400;
+
+ .user-pill__avatar-overlay {
+ background-color: color.change($navy--700, $alpha: 0.5);
+ }
+
+ .user-pill__name {
+ color: $navy--700;
+ }
+ }
+ }
+
+ .user-pill__avatar-container {
+ background-color: $navy--700;
+ }
+
+ .user-pill__name {
+ color: $gray--000;
+ }
+}
diff --git a/src/components/UserPill/UserPill.tsx b/src/components/UserPill/UserPill.tsx
new file mode 100644
index 0000000000..f2585e722d
--- /dev/null
+++ b/src/components/UserPill/UserPill.tsx
@@ -0,0 +1,42 @@
+import {useAppSelector} from "store";
+import {useNavigate} from "react-router";
+import StanAvatar from "assets/stan/Stan_Avatar.png";
+import classNames from "classnames";
+import {Avatar} from "components/Avatar";
+import "./UserPill.scss";
+
+type UserPillProps = {
+ className?: string;
+ disabled?: boolean;
+ locationPrefix?: string;
+};
+
+export const UserPill = (props: UserPillProps) => {
+ const navigate = useNavigate();
+ const me = useAppSelector((state) => state.auth.user)!; // TODO omit this after #4270, since seed is optional then
+ const myName = useAppSelector((state) => state.auth.user?.name);
+ const avatar = useAppSelector((state) => state.auth.user?.avatar);
+
+ const openSettings = () => {
+ navigate(`${props.locationPrefix ?? ""}/settings/profile`);
+ };
+
+ const renderAvatar = () =>
+ avatar ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ );
+};
diff --git a/src/constants/colors.scss b/src/constants/colors.scss
index bc97345d66..1df7000fd6 100644
--- a/src/constants/colors.scss
+++ b/src/constants/colors.scss
@@ -10,6 +10,7 @@ $blue--400: #3379ff; // primary dark
$blue--300: #669aff;
$blue--200: #99bcff;
$blue--100: #ccddff;
+$blue--50: #dde8ff;
// pink
$pink--800: #66002a;
@@ -119,6 +120,7 @@ $primary-colors: (
300: $blue--300,
200: $blue--200,
100: $blue--100,
+ 50: $blue--50,
),
planning-pink: (
800: $pink--800,
diff --git a/src/constants/style.scss b/src/constants/style.scss
index 6609eaba90..30d89a2ea5 100644
--- a/src/constants/style.scss
+++ b/src/constants/style.scss
@@ -125,6 +125,8 @@ $desktop: "screen and (min-width : 1280px)";
$menu-mobile: "screen and (max-width: 1343px)";
$menu-desktop: "screen and (min-width: 1344px)";
+$breakpoint--smartphone: 768px;
+
// responsive: @container query
$container__note: "note (max-width: 300px)";
@@ -201,3 +203,52 @@ $column-stripes--dark: repeating-linear-gradient(
color: $gray--000;
background-color: $navy--200;
}
+
+// this mixin is used to decrease an element's padding,
+// in order to keep it the same size if the borders change.
+@mixin compensate-padding($padding, $decrement) {
+ $new-padding: ();
+ @each $value in $padding {
+ $new-padding: append($new-padding, $value - $decrement);
+ }
+ padding: $new-padding;
+}
+
+// add some default css for hover, focus, and active states.
+@mixin default-states($border-size: 1.5px, $padding, $compensate-padding: true) {
+ &:hover {
+ box-shadow: 0 3px 12px 0 color.change($navy--700, $alpha: 0.12);
+ }
+
+ &:focus,
+ &:focus-within {
+ border: $border-size solid $blue--500;
+ @if $compensate-padding == true {
+ @include compensate-padding($padding, $border-size);
+ }
+ }
+
+ &:active {
+ border: $border-size solid $navy--400;
+ @if $compensate-padding == true {
+ @include compensate-padding($padding, $border-size);
+ }
+ }
+
+ [theme="dark"] & {
+ &:focus,
+ &:focus-within {
+ border: $border-size solid $blue--400;
+ @if $compensate-padding == true {
+ @include compensate-padding($padding, $border-size);
+ }
+ }
+
+ &:active {
+ border: $border-size solid $gray--000;
+ @if $compensate-padding == true {
+ @include compensate-padding($padding, $border-size);
+ }
+ }
+ }
+}
diff --git a/src/i18n/de/translation.json b/src/i18n/de/translation.json
index 4d27b1bad0..a624d316e1 100644
--- a/src/i18n/de/translation.json
+++ b/src/i18n/de/translation.json
@@ -2,7 +2,7 @@
"InfoBar": {
"ReturnToPresentedNote": "Zurück zur präsentierten Karte"
},
- "NewBoard": {
+ "LegacyNewBoard": {
"boardName": "Sitzungsname",
"createNewBoard": "Erstelle eine Sitzung",
"extendedConfigurationButton": "Erweiterte Einstellungen",
@@ -13,6 +13,16 @@
"uploadFile": "Datei hochladen oder hierher ziehen",
"importNewBoard": "Jetzt importieren"
},
+ "Templates": {
+ "switchTitle": "Vorlagen",
+ "title": "Wähle eine Vorlage",
+ "savedTemplates": "Deine Vorlagen",
+ "recommendedTemplates": "Unsere Empfehlungen"
+ },
+ "Sessions": {
+ "switchTitle": "Sitzungen",
+ "title": "Wähle eine Sitzung"
+ },
"LoginBoard": {
"title": "Login bei scrumlr.io",
"anonymousLogin": "Nehme anonym an der Sitzung teil",
@@ -354,6 +364,9 @@
"NoteReactionsPopup": {
"allReactionsTab": "Alle"
},
+ "SearchBar": {
+ "placeholder": "Suchen"
+ },
"SettingsDialog": {
"BoardSettings": "Board Einstellungen",
"BoardSettingsDescription": "Name, Zugriffseinstellungen",
diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json
index 8222289bcc..93780ab662 100644
--- a/src/i18n/en/translation.json
+++ b/src/i18n/en/translation.json
@@ -2,7 +2,7 @@
"InfoBar": {
"ReturnToPresentedNote": "Return to presented note"
},
- "NewBoard": {
+ "LegacyNewBoard": {
"boardName": "Board name",
"createNewBoard": "Create new Board",
"extendedConfigurationButton": "Extended configuration",
@@ -13,6 +13,16 @@
"uploadFile": "Upload file or drag here",
"importNewBoard": "Import now"
},
+ "Templates": {
+ "switchTitle": "Templates",
+ "title": "Choose a template",
+ "savedTemplates": "Your Templates",
+ "recommendedTemplates": "Our Recommendations"
+ },
+ "Sessions": {
+ "switchTitle": "Sessions",
+ "title": "Choose a session"
+ },
"LoginBoard": {
"title": "Sign in to scrumlr.io",
"anonymousLogin": "Join board anonymously",
@@ -354,6 +364,9 @@
"NoteReactionsPopup": {
"allReactionsTab": "All"
},
+ "SearchBar": {
+ "placeholder": "Search"
+ },
"SettingsDialog": {
"BoardSettings": "Board Settings",
"BoardSettingsDescription": "Name, Access Policy",
diff --git a/src/routes/Boards/Boards.scss b/src/routes/Boards/Boards.scss
new file mode 100644
index 0000000000..5f666742a8
--- /dev/null
+++ b/src/routes/Boards/Boards.scss
@@ -0,0 +1,169 @@
+@use "sass:color";
+@import "constants/style";
+
+$side-padding--desktop: 14vw;
+
+.boards {
+ width: 100vw;
+ height: 100vh;
+ background: linear-gradient(180deg, color.change($blue--500, $alpha: 0.075), color.change($gray--000, $alpha: 0.2));
+}
+
+.boards__grid {
+ display: grid;
+ grid-template-columns: $side-padding--desktop 114px auto $spacing--base 1fr auto auto $side-padding--desktop;
+ grid-template-rows: $spacing--lg 56px 48px 64px 1fr $spacing--lg; // using the actual component heights seems to yield the best results
+ grid-template-areas:
+ ". . . . . . . ."
+ ". logo title title title title user ."
+ ". . title title title title . ."
+ ". . switch . search search . ."
+ ". . main main main main . ."
+ ". . . . . . . .";
+
+ height: 100vh;
+}
+
+.boards__scrumlr-logo-container {
+ grid-area: logo;
+}
+
+.boards__user-pill {
+ grid-area: user;
+ justify-self: flex-end;
+}
+
+.boards__title {
+ grid-area: title;
+ align-self: center;
+ justify-self: center;
+
+ font-size: $text--2xl;
+ font-weight: 700;
+ line-height: 48px; // TODO use constant
+ color: $navy--900;
+}
+
+.boards__switch {
+ grid-area: switch;
+}
+
+.boards__search-bar {
+ grid-area: search;
+ justify-self: flex-end;
+}
+
+.boards__search-button {
+ all: unset;
+ display: none;
+
+ grid-area: search;
+
+ cursor: pointer;
+}
+
+.boards__search-button-icon-container {
+ width: $icon--large;
+ height: $icon--large;
+ padding: $spacing--sm;
+ border-radius: 100%;
+
+ color: $navy--600;
+ background-color: $gray--000;
+
+ &--active {
+ color: $gray--000;
+ background-color: $navy--600;
+ }
+}
+
+.boards--search-button-logo {
+ width: $icon--large;
+ height: $icon--large;
+}
+
+.boards__mobile-search-bar {
+ display: none;
+}
+
+.boards__outlet {
+ grid-area: main;
+ display: flex;
+ justify-content: stretch;
+
+ margin-top: $spacing--xl;
+}
+
+@media (max-width: $breakpoint--desktop) {
+ .boards__grid {
+ grid-template-columns: $spacing--2xl 114px auto $spacing--base 1fr auto auto $spacing--2xl;
+ grid-template-areas:
+ ". . . . . . . ."
+ ". logo title title title title user ."
+ ". . title title title title . ."
+ ". switch switch . search search search ."
+ ". main main main main main main ."
+ ". . . . . . . .";
+ }
+}
+
+@media screen and (max-width: $breakpoint--smartphone) {
+ .boards {
+ }
+
+ .boards__grid {
+ // stretch main to full width
+ grid-template-columns: $spacing--md auto auto 1fr auto auto $spacing--md;
+ // adds a row where the mobile search bar resides,
+ grid-template-rows: $spacing--md 56px $spacing--sm 32px $spacing--md 57px $spacing--sm auto 1fr;
+ grid-template-areas:
+ ". . . . . . ."
+ ". logo . . . user ."
+ ". . . . . . ."
+ ". title title title title title ."
+ ". . . . . . ."
+ ". switch switch . search search ."
+ ". . . . . . ."
+ ". search2 search2 search2 search2 search2 ."
+ "main main main main main main main";
+ }
+
+ .boards__title {
+ font-size: $text--lg;
+ line-height: 32px; // TODO use constant
+ }
+
+ .boards__search-button {
+ display: inline-block;
+ margin-right: $spacing--sm;
+ }
+
+ .boards__search-bar {
+ display: none;
+ }
+
+ .boards__mobile-search-bar {
+ display: flex;
+ grid-area: search2;
+ }
+}
+
+[theme="dark"] {
+ .boards {
+ background: linear-gradient(161deg, $navy--600 16.78%, $navy--700 49.35%, $navy--800 81.65%);
+ }
+
+ .boards__title {
+ color: $gray--000;
+ }
+
+ .boards__search-button-icon-container {
+ color: $gray--000;
+ background-color: $navy--400;
+
+ &--active {
+ color: $navy--400;
+ background-color: $gray--000;
+ }
+ }
+}
diff --git a/src/routes/Boards/Boards.tsx b/src/routes/Boards/Boards.tsx
new file mode 100644
index 0000000000..999991f68e
--- /dev/null
+++ b/src/routes/Boards/Boards.tsx
@@ -0,0 +1,81 @@
+import {useTranslation} from "react-i18next";
+import {Outlet, useLocation, useNavigate} from "react-router";
+import {useEffect, useState} from "react";
+import {ScrumlrLogo} from "components/ScrumlrLogo";
+import {UserPill} from "components/UserPill/UserPill";
+import {SearchBar} from "components/SearchBar/SearchBar";
+import {Switch} from "components/Switch/Switch";
+import {ReactComponent as SearchIcon} from "assets/icons/search.svg";
+import classNames from "classnames";
+import "./Boards.scss";
+
+type BoardView = "templates" | "sessions";
+
+export const Boards = () => {
+ const {t} = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ const [boardView, setBoardView] = useState("templates");
+ const [showMobileSearchBar, setShowMobileSearchBar] = useState(false);
+ const [searchBarInput, setSearchBarInput] = useState("");
+
+ const toggleMobileSearchBar = () => {
+ setShowMobileSearchBar((open) => !open);
+ };
+
+ // navigate to view that is currently not visible
+ const switchView = () => {
+ if (boardView === "templates") {
+ navigate("sessions");
+ } else {
+ navigate("templates");
+ }
+ };
+
+ useEffect(() => {
+ const currentLocation: BoardView = location.pathname.endsWith("/templates") ? "templates" : "sessions";
+ setBoardView(currentLocation);
+ }, [location]);
+
+ return (
+
+
+ {/* logo - - - profile */}
+
+
+
+ {/* - - title - - */}
+
{boardView === "templates" ? t("Templates.title") : t("Sessions.title")}
+
+ {/* switch - - - search */}
+
+
+ {/* desktop search bar */}
+
+
+ {/* mobile search button + search bar (row below) */}
+
+ {showMobileSearchBar &&
}
+
+
+
+
+
+
+ );
+};
diff --git a/src/routes/NewBoard/NewBoard.scss b/src/routes/Boards/Legacy/LegacyNewBoard.scss
similarity index 100%
rename from src/routes/NewBoard/NewBoard.scss
rename to src/routes/Boards/Legacy/LegacyNewBoard.scss
diff --git a/src/routes/NewBoard/NewBoard.tsx b/src/routes/Boards/Legacy/LegacyNewBoard.tsx
similarity index 89%
rename from src/routes/NewBoard/NewBoard.tsx
rename to src/routes/Boards/Legacy/LegacyNewBoard.tsx
index 78510b613e..aca386fe7a 100644
--- a/src/routes/NewBoard/NewBoard.tsx
+++ b/src/routes/Boards/Legacy/LegacyNewBoard.tsx
@@ -1,22 +1,21 @@
import {API} from "api";
-import "routes/NewBoard/NewBoard.scss";
import React, {useRef, useState} from "react";
-import {AccessPolicySelection} from "components/AccessPolicySelection";
import {AccessPolicy, BoardImportData} from "store/features/board/types";
import {useTranslation} from "react-i18next";
import {useNavigate} from "react-router";
+import {useAppDispatch} from "store";
+import {importBoard} from "store/features";
+import {Toast} from "utils/Toast";
+import {AccessPolicySelection} from "components/AccessPolicySelection";
import {TextInputLabel} from "components/TextInputLabel";
import {TextInput} from "components/TextInput";
import {Button} from "components/Button";
import {ScrumlrLogo} from "components/ScrumlrLogo";
-
import {PassphraseModal} from "components/PassphraseDialog/PassphraseModal/PassphraseModal";
-import {importBoard} from "store/features";
-import {useAppDispatch} from "store";
-import {columnTemplates} from "./columnTemplates";
-import {Toast} from "../../utils/Toast";
+import {legacyColumnTemplates} from "./legacyColumnTemplates";
+import "./LegacyNewBoard.scss";
-export const NewBoard = () => {
+export const LegacyNewBoard = () => {
const {t} = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -115,7 +114,7 @@ export const NewBoard = () => {
type: accessPolicy,
...additionalAccessPolicyOptions,
},
- columnTemplates[columnTemplate].columns
+ legacyColumnTemplates[columnTemplate].columns
);
navigate(`/board/${boardId}`);
}
@@ -133,10 +132,10 @@ export const NewBoard = () => {
{!extendedConfiguration && (
-
{t("NewBoard.basicConfigurationTitle")}
+
{t("LegacyNewBoard.basicConfigurationTitle")}
- {Object.keys(columnTemplates).map((key) => (
+ {Object.keys(legacyColumnTemplates).map((key) => (
@@ -177,8 +176,8 @@ export const NewBoard = () => {
}}
>
-
{t("NewBoard.importBoard")}
-
{t("NewBoard.uploadFile")}
+
{t("LegacyNewBoard.importBoard")}
+
{t("LegacyNewBoard.uploadFile")}
@@ -188,9 +187,9 @@ export const NewBoard = () => {
{extendedConfiguration && (
-
{t("NewBoard.extendedConfigurationTitle")}
+
{t("LegacyNewBoard.extendedConfigurationTitle")}
-
+
setBoardName(e.target.value)} />
@@ -202,22 +201,21 @@ export const NewBoard = () => {
{!importFile ? (
) : (
)}
-
{!extendedConfiguration && (
)}
{extendedConfiguration && (
)}
diff --git a/src/routes/NewBoard/columnTemplates.ts b/src/routes/Boards/Legacy/legacyColumnTemplates.ts
similarity index 94%
rename from src/routes/NewBoard/columnTemplates.ts
rename to src/routes/Boards/Legacy/legacyColumnTemplates.ts
index f13ec6c5b3..8c555f6ab9 100644
--- a/src/routes/NewBoard/columnTemplates.ts
+++ b/src/routes/Boards/Legacy/legacyColumnTemplates.ts
@@ -1,6 +1,6 @@
import {Color} from "constants/colors";
-export const columnTemplates: {[id: string]: {name: string; description?: string; columns: {name: string; hidden: boolean; color: Color}[]}} = {
+export const legacyColumnTemplates: {[id: string]: {name: string; description?: string; columns: {name: string; hidden: boolean; color: Color}[]}} = {
leanCoffee: {
name: "Lean Coffee",
columns: [
diff --git a/src/routes/Boards/Sessions/Sessions.tsx b/src/routes/Boards/Sessions/Sessions.tsx
new file mode 100644
index 0000000000..ea98876512
--- /dev/null
+++ b/src/routes/Boards/Sessions/Sessions.tsx
@@ -0,0 +1,8 @@
+import {Outlet} from "react-router";
+
+export const Sessions = () => (
+ <>
+ {/* settings */}
+ Hello Sessions
+ >
+);
diff --git a/src/routes/Boards/Sessions/index.ts b/src/routes/Boards/Sessions/index.ts
new file mode 100644
index 0000000000..c88c501dea
--- /dev/null
+++ b/src/routes/Boards/Sessions/index.ts
@@ -0,0 +1 @@
+export * from "./Sessions";
diff --git a/src/routes/Boards/Templates/Templates.scss b/src/routes/Boards/Templates/Templates.scss
new file mode 100644
index 0000000000..377ddfd971
--- /dev/null
+++ b/src/routes/Boards/Templates/Templates.scss
@@ -0,0 +1,134 @@
+@import "constants/style";
+
+$stan-height-factor: 93%;
+
+.templates {
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ // use margin below instead to accommodate for stan, otherwise the --saved container would be missing the space
+ // gap: $spacing--md;
+
+ position: relative;
+}
+
+.templates__container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ gap: $spacing--lg;
+
+ padding: $spacing--xl $spacing--xl 0 $spacing--xl;
+ border-radius: $rounded--default;
+
+ background-color: $blue--50;
+
+ &--saved {
+ margin-left: $spacing--md; // instead of gap, see above
+ }
+}
+
+.templates__container-header {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ align-items: center;
+ gap: $spacing--md;
+}
+
+.templates__container-title {
+ word-break: break-word; // because the word "Recommendations" is too long, TODO find better solution for this
+ font-size: $text--lg;
+ font-weight: 700;
+ line-height: 32px; // TODO use constant
+
+ color: $navy--600;
+}
+
+.templates__container-arrow-button {
+ all: unset;
+}
+
+.templates__container-arrow {
+ display: none;
+
+ &--disabled {
+ color: $gray--800;
+ }
+}
+
+.templates__stan {
+ position: absolute;
+
+ width: auto;
+ // TODO find a solution so stan never gets cut off
+ height: 100%;
+
+ // align right edge of stan to the left edge of the container
+ transform: translateX(-100%);
+
+ &--dark {
+ display: initial;
+ }
+
+ &--light {
+ display: none;
+ }
+}
+
+@media screen and (max-width: $breakpoint--smartphone) {
+ // instead of the both containers sitting on one screen we want them to be full width each, and make the main container scrollable.
+ .templates {
+ overflow-x: scroll;
+ scroll-snap-type: x mandatory;
+ }
+
+ .templates__container {
+ flex: 0 0 100%; // no growing, no shrinking, and take up the whole width
+ scroll-snap-align: center;
+ }
+
+ .templates__container-header {
+ justify-content: center;
+ }
+
+ .templates__container-title {
+ font-size: $text--md;
+ line-height: 24px; // TODO use constant
+ }
+
+ .templates__container-arrow {
+ display: inline-block;
+ width: 36px;
+ height: 36px;
+ }
+}
+
+[theme="dark"] {
+ .templates__container {
+ background-color: $navy--700;
+ }
+
+ .templates__container-title {
+ color: $blue--50;
+ }
+
+ .templates__stan {
+ height: $stan-height-factor;
+ &--dark {
+ display: none;
+ }
+
+ &--light {
+ display: initial;
+ }
+ }
+
+ .templates__container-arrow {
+ color: $blue--50;
+
+ &--disabled {
+ color: $navy--300;
+ }
+ }
+}
diff --git a/src/routes/Boards/Templates/Templates.tsx b/src/routes/Boards/Templates/Templates.tsx
new file mode 100644
index 0000000000..04cdbfc957
--- /dev/null
+++ b/src/routes/Boards/Templates/Templates.tsx
@@ -0,0 +1,58 @@
+import classNames from "classnames";
+import {Outlet} from "react-router";
+import {useAppSelector} from "store";
+import {useTranslation} from "react-i18next";
+import {useRef} from "react";
+// using a png instead of svg for now. reason being problems with layering
+import StanDark from "assets/stan/Stan_Hanging_With_Coffee_Cropped_Dark.png";
+import StanLight from "assets/stan/Stan_Hanging_With_Coffee_Cropped_Light.png";
+import {ReactComponent as ArrowLeft} from "assets/icons/arrow-left.svg";
+import {ReactComponent as ArrowRight} from "assets/icons/arrow-right.svg";
+import "./Templates.scss";
+
+type Side = "left" | "right";
+
+export const Templates = () => {
+ const templatesRef = useRef(null);
+ const {t} = useTranslation();
+ const isAnonymous = useAppSelector((state) => state.auth.user?.isAnonymous) ?? true;
+
+ const scrollToSide = (side: Side) => {
+ const screenWidth = document.documentElement.clientWidth;
+ const offset = screenWidth * (side === "left" ? -1 : 1);
+ templatesRef.current?.scroll({left: offset, behavior: "smooth"});
+ };
+
+ const renderContainerHeader = (renderSide: Side, title: string) =>
+ isAnonymous ? (
+
+ ) : (
+
+ );
+
+ return (
+ <>
+ {/* settings */}
+
+
+
+
+
+
{renderContainerHeader("left", t("Templates.recommendedTemplates"))}
+ {!isAnonymous &&
{renderContainerHeader("right", t("Templates.savedTemplates"))}}
+
+ >
+ );
+};
diff --git a/src/routes/Boards/Templates/index.ts b/src/routes/Boards/Templates/index.ts
new file mode 100644
index 0000000000..02f3228a69
--- /dev/null
+++ b/src/routes/Boards/Templates/index.ts
@@ -0,0 +1 @@
+export * from "./Templates";
diff --git a/src/routes/Boards/index.ts b/src/routes/Boards/index.ts
new file mode 100644
index 0000000000..2563c8f19e
--- /dev/null
+++ b/src/routes/Boards/index.ts
@@ -0,0 +1 @@
+export * from "./Boards";
diff --git a/src/routes/NewBoard/index.ts b/src/routes/NewBoard/index.ts
deleted file mode 100644
index eff10f9c69..0000000000
--- a/src/routes/NewBoard/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from "./NewBoard";
diff --git a/src/routes/Router.tsx b/src/routes/Router.tsx
index 2b2bf2b7b1..8e41c2a231 100644
--- a/src/routes/Router.tsx
+++ b/src/routes/Router.tsx
@@ -1,6 +1,8 @@
-import {BrowserRouter, Route, Routes} from "react-router";
+import {BrowserRouter, Navigate, Route, Routes} from "react-router";
import {LoginBoard} from "routes/LoginBoard";
-import {NewBoard} from "routes/NewBoard";
+import {Boards} from "routes/Boards";
+import {Templates} from "routes/Boards/Templates";
+import {Sessions} from "routes/Boards/Sessions";
import {BoardGuard} from "routes/Board";
import {NotFound} from "routes/NotFound";
import {RequireAuthentication} from "routes/RequireAuthentication";
@@ -20,8 +22,12 @@ import {Homepage} from "./Homepage";
import {Legal} from "./Legal";
import {StackView} from "./StackView";
import RouteChangeObserver from "./RouteChangeObserver";
+import {LegacyNewBoard} from "./Boards/Legacy/LegacyNewBoard";
+
+const renderLegacyRoute = (legacy: boolean) => (legacy ? } /> : } />);
const Router = () => {
+ const legacyCreateBoard = !!useAppSelector((state) => state.view.legacyCreateBoard);
const feedbackEnabled = useAppSelector((state) => state.view.feedbackEnabled);
return (
@@ -32,14 +38,32 @@ const Router = () => {
} />
} />
} />
+ {renderLegacyRoute(legacyCreateBoard)}
-
+
}
- />
+ >
+ } />
+ }>
+ {/* TODO extract settings routes to avoid repetition */}
+ }>
+ } />
+ } />
+ } />
+
+
+ }>
+ }>
+ } />
+ } />
+ } />
+
+
+
} />
("auth/signIn");
export const userCheckCompleted = createAction("auth/userCheckCompleted");
+
+// allow changes to user auth without being part of a board
+export const editUserOptimistically = createAction("auth/editUserOptimistically");
diff --git a/src/store/features/auth/reducer.ts b/src/store/features/auth/reducer.ts
index 0ae1e6be98..2204001ba8 100644
--- a/src/store/features/auth/reducer.ts
+++ b/src/store/features/auth/reducer.ts
@@ -1,6 +1,6 @@
import {createReducer} from "@reduxjs/toolkit";
import {AuthState} from "./types";
-import {signIn, userCheckCompleted} from "./actions";
+import {editUserOptimistically, signIn, userCheckCompleted} from "./actions";
import {signOut} from "./thunks";
const initialState: AuthState = {user: undefined, initializationSucceeded: null};
@@ -18,4 +18,7 @@ export const authReducer = createReducer(initialState, (builder) =>
.addCase(userCheckCompleted, (state, action) => {
state.initializationSucceeded = action.payload;
})
+ .addCase(editUserOptimistically, (state, action) => {
+ state.user = action.payload;
+ })
);
diff --git a/src/store/features/participants/thunks.ts b/src/store/features/participants/thunks.ts
index 7fd6dcf7d5..c4b317ee56 100644
--- a/src/store/features/participants/thunks.ts
+++ b/src/store/features/participants/thunks.ts
@@ -3,11 +3,23 @@ import {API} from "api";
import {ApplicationState, retryable} from "store";
import {Toast} from "utils/Toast";
import i18n from "i18next";
-import {Auth} from "../auth";
+import {Auth, editUserOptimistically} from "store/features";
-export const editSelf = createAsyncThunk("participants/editSelf", async (payload) => {
- await API.editUser(payload);
- return payload;
+export const editSelf = createAsyncThunk<
+ Auth,
+ {
+ auth: Auth;
+ applyOptimistically?: boolean;
+ },
+ {state: ApplicationState}
+>("participants/editSelf", async (payload, {dispatch}) => {
+ if (payload.applyOptimistically) {
+ // instantly apply changes (required when not in a board, since no event is retrieved)
+ dispatch(editUserOptimistically(payload.auth));
+ }
+
+ await API.editUser(payload.auth);
+ return payload.auth;
});
export const changePermission = createAsyncThunk(
diff --git a/src/store/features/view/reducer.ts b/src/store/features/view/reducer.ts
index 197e7b2eff..444bbac9d2 100644
--- a/src/store/features/view/reducer.ts
+++ b/src/store/features/view/reducer.ts
@@ -34,6 +34,7 @@ const initialState: ViewState = {
hotkeyNotificationsEnabled: getFromStorage(HOTKEY_NOTIFICATIONS_ENABLE_STORAGE_KEY) !== "false",
showBoardReactions: getFromStorage(BOARD_REACTIONS_ENABLE_STORAGE_KEY) !== "false",
theme: (getFromStorage(THEME_STORAGE_KEY) as Theme) ?? "auto",
+ legacyCreateBoard: process.env.REACT_APP_LEGACY_CREATE_BOARD === "true",
snowfallEnabled: getFromStorage(SNOWFALL_STORAGE_KEY) !== "false",
snowfallNotificationEnabled: getFromStorage(SNOWFALL_NOTIFICATION_STORAGE_KEY) !== "false",
};
diff --git a/src/store/features/view/types.ts b/src/store/features/view/types.ts
index 48e336163a..9ef4e6deec 100644
--- a/src/store/features/view/types.ts
+++ b/src/store/features/view/types.ts
@@ -41,6 +41,8 @@ export interface View {
showBoardReactions: boolean;
+ readonly legacyCreateBoard?: boolean;
+
snowfallEnabled: boolean;
snowfallNotificationEnabled: boolean;