diff --git a/package-lock.json b/package-lock.json index 0aafd97..41b3ff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fluentui/react": "^8.29.2", + "@fluentui/react-icons-mdl2": "^1.2.1", "@fluidframework/azure-client": "^0.46.0", "@fluidframework/test-runtime-utils": "0.46.0", "@microsoft/mgt-element": "^2.2.1", @@ -2226,6 +2227,37 @@ "react": ">=16.8.0 <18.0.0" } }, + "node_modules/@fluentui/react-icon-provider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-icon-provider/-/react-icon-provider-1.2.1.tgz", + "integrity": "sha512-P6Agy31ZF/S+4xDNBJzLQejkncKvblvhSwl0er4sLgPqID2fcIT6y0MGS4kOqMib9sC+aejpIav2pHqd+EMChg==", + "dependencies": { + "@fluentui/set-version": "^8.1.4", + "@fluentui/style-utilities": "^8.3.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@types/react": ">=16.8.0 <18.0.0", + "@types/react-dom": ">=16.8.0 <18.0.0", + "react": ">=16.8.0 <18.0.0", + "react-dom": ">=16.8.0 <18.0.0" + } + }, + "node_modules/@fluentui/react-icons-mdl2": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons-mdl2/-/react-icons-mdl2-1.2.1.tgz", + "integrity": "sha512-vQRrgeDfmDxZJCeh/iW8yCAtpwfUrpHI0QU2NojXl5U0uv4KuOHp39UJvu8dzdp1zZ7arEYMoIgcq0eNqssnQA==", + "dependencies": { + "@fluentui/react-icon-provider": "^1.2.1", + "@fluentui/set-version": "^8.1.4", + "@fluentui/utilities": "^8.3.1", + "@microsoft/load-themed-styles": "^1.10.26", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <18.0.0" + } + }, "node_modules/@fluentui/react-window-provider": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.1.4.tgz", @@ -28073,6 +28105,28 @@ "tslib": "^2.1.0" } }, + "@fluentui/react-icon-provider": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-icon-provider/-/react-icon-provider-1.2.1.tgz", + "integrity": "sha512-P6Agy31ZF/S+4xDNBJzLQejkncKvblvhSwl0er4sLgPqID2fcIT6y0MGS4kOqMib9sC+aejpIav2pHqd+EMChg==", + "requires": { + "@fluentui/set-version": "^8.1.4", + "@fluentui/style-utilities": "^8.3.1", + "tslib": "^2.1.0" + } + }, + "@fluentui/react-icons-mdl2": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons-mdl2/-/react-icons-mdl2-1.2.1.tgz", + "integrity": "sha512-vQRrgeDfmDxZJCeh/iW8yCAtpwfUrpHI0QU2NojXl5U0uv4KuOHp39UJvu8dzdp1zZ7arEYMoIgcq0eNqssnQA==", + "requires": { + "@fluentui/react-icon-provider": "^1.2.1", + "@fluentui/set-version": "^8.1.4", + "@fluentui/utilities": "^8.3.1", + "@microsoft/load-themed-styles": "^1.10.26", + "tslib": "^2.1.0" + } + }, "@fluentui/react-window-provider": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@fluentui/react-window-provider/-/react-window-provider-2.1.4.tgz", diff --git a/package.json b/package.json index 8c36ac2..e919c95 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@fluentui/react": "^8.29.2", + "@fluentui/react-icons-mdl2": "^1.2.1", "fluid-framework": "^0.46.0", "@fluidframework/azure-client": "^0.46.0", "@fluidframework/test-runtime-utils": "0.46.0", diff --git a/src/BrainstormModel.ts b/src/BrainstormModel.ts index 38c30b2..bf0f785 100644 --- a/src/BrainstormModel.ts +++ b/src/BrainstormModel.ts @@ -1,6 +1,6 @@ import { FluidContainer, ISharedMap, SharedMap } from "fluid-framework"; import { AzureMember } from "@fluidframework/azure-client"; -import { NoteData, Position } from "./Types"; +import { LikedNote, NoteData, Position } from "./Types"; const c_NoteIdPrefix = "noteId_"; const c_PositionPrefix = "position_"; @@ -19,6 +19,7 @@ export type BrainstormModel = Readonly<{ GetNoteLikedUsers(noteId: string): AzureMember[]; DeleteNote(noteId: string): void; NoteIds: string[]; + LikedNotes: LikedNote[]; setChangeListener(listener: () => void): void; removeChangeListener(listener: () => void): void; }>; @@ -49,6 +50,12 @@ export function createBrainstormModel(fluid: FluidContainer): BrainstormModel { sharedMap.set(c_ColorPrefix + noteId, noteColor); }; + const numLikesCalculated = (noteId: string) => { + return Array.from(sharedMap + .keys()) + .filter((key: string) => key.includes(c_votePrefix + noteId)) + .filter((key: string) => sharedMap.get(key) !== undefined).length; + }; return { CreateNote(noteId: string, myAuthor: AzureMember): NoteData { @@ -57,10 +64,7 @@ export function createBrainstormModel(fluid: FluidContainer): BrainstormModel { text: sharedMap.get(c_TextPrefix + noteId), position: sharedMap.get(c_PositionPrefix + noteId)!, author: sharedMap.get(c_AuthorPrefix + noteId)!, - numLikesCalculated: Array.from(sharedMap - .keys()) - .filter((key: string) => key.includes(c_votePrefix + noteId)) - .filter((key: string) => sharedMap.get(key) !== undefined).length, + numLikesCalculated: numLikesCalculated(noteId), didILikeThisCalculated: Array.from(sharedMap .keys()) @@ -127,6 +131,38 @@ export function createBrainstormModel(fluid: FluidContainer): BrainstormModel { ); }, + get LikedNotes(): LikedNote[] { + return ( + Array.from(sharedMap + .keys()) + // Only look at keys which represent if a note exists or not + .filter((key: String) => key.includes(c_NoteIdPrefix)) + // Modify the note ids to not expose the prefix + .map((noteIdWithPrefix) => + noteIdWithPrefix.substring(c_NoteIdPrefix.length) + ) + // Remove notes which are incomplete or deleted + .filter((noteId) => + !IsDeletedNote(noteId) && numLikesCalculated(noteId) > 0 && + sharedMap.get(c_TextPrefix + noteId) + ) + .map((noteId) => { + const text = sharedMap.get(c_TextPrefix + noteId); + const color = sharedMap.get(c_ColorPrefix + noteId); + const author = sharedMap.get(c_AuthorPrefix + noteId); + return { + text, + color, + author, + numLikesCalculated: numLikesCalculated(noteId) + }; + }) + .sort((a: LikedNote, b: LikedNote) => { + return b.numLikesCalculated - a.numLikesCalculated; + }) + ); + }, + setChangeListener(listener: () => void): void { sharedMap.on("valueChanged", listener); }, diff --git a/src/Config.ts b/src/Config.ts index 28d0050..8ce0238 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -1,6 +1,6 @@ import { AzureConnectionConfig, InsecureTokenProvider } from "@fluidframework/azure-client"; import { SharedMap } from "fluid-framework"; -import { generateUser } from './utils'; +import { generateUser } from './Utils'; export const useAzureFrs = process.env.REACT_APP_FLUID_CLIENT === "frs"; export const user = generateUser(); diff --git a/src/Navbar.tsx b/src/Navbar.tsx index cf36cd5..5adf9c2 100644 --- a/src/Navbar.tsx +++ b/src/Navbar.tsx @@ -1,9 +1,9 @@ export function Navbar() { return (
-
-
Let's Brainstorm
-
+
+
Let's Brainstorm
+
); diff --git a/src/Types.ts b/src/Types.ts index b666ea1..66fd832 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -19,3 +19,10 @@ export type ColorId = | "Pink" | "Purple" | "Orange"; + +export type LikedNote = { + text: string, + color: string, + author: AzureMember, + numLikesCalculated: number +}; diff --git a/src/index.tsx b/src/index.tsx index 91299e0..2e28c09 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,17 +4,16 @@ */ import { initializeIcons, ThemeProvider } from "@fluentui/react"; -import { AzureClient, AzureResources } from '@fluidframework/azure-client'; import React from 'react'; import ReactDOM from 'react-dom'; import { BrainstormView } from './view/BrainstormView'; import "./view/index.css"; import "./view/App.css"; import { themeNameToTheme } from './view/Themes'; -import { connectionConfig, containerSchema } from "./Config"; import { Navbar } from './Navbar'; import { Providers } from '@microsoft/mgt-element'; import { Msal2Provider } from '@microsoft/mgt-msal2-provider'; +import { getFluidContainer } from "./Utils"; Providers.globalProvider = new Msal2Provider({ clientId: '26fa7fdf-ae13-4db0-84f8-8249376812dc' @@ -23,37 +22,7 @@ Providers.globalProvider = new Msal2Provider({ export async function start() { initializeIcons(); - async function createOrGetContainer() : - Promise<{ containerId: string, azureResources: AzureResources}> { - let containerId = ''; - // Check if there's a previous containerId (user may have simply logged out) - const prevContainerId = sessionStorage.getItem("containerId"); - if (location.hash.length === 0) { - if (prevContainerId) { - location.hash = prevContainerId; - containerId = prevContainerId; - } - } - else { - containerId = location.hash.substring(1); - } - - const client = new AzureClient(connectionConfig); - let azureResources: AzureResources; - if (containerId) { - azureResources = await client.getContainer(containerId, containerSchema); - } - else { - azureResources = await client.createContainer(containerSchema); - // Temporary until attach() is available (per Fluid engineering) - containerId = azureResources.fluidContainer.id; - location.hash = containerId; - } - sessionStorage.setItem("containerId", containerId); - return {containerId, azureResources}; - } - - let {azureResources} = await createOrGetContainer(); + let {azureResources} = await getFluidContainer(); if (!azureResources.fluidContainer.connected) { await new Promise((resolve) => { diff --git a/src/utils.ts b/src/utils.ts index 377e120..546cfdb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,36 @@ +import { AzureClient, AzureResources } from "@fluidframework/azure-client"; +import { connectionConfig, containerSchema } from "./Config"; + +export async function getFluidContainer() : + Promise<{ containerId: string, azureResources: AzureResources}> { + let containerId = ''; + // Check if there's a previous containerId (user may have simply logged out) + const prevContainerId = sessionStorage.getItem("containerId"); + if (location.hash.length === 0) { + if (prevContainerId) { + location.hash = prevContainerId; + containerId = prevContainerId; + } + } + else { + containerId = location.hash.substring(1); + } + + const client = new AzureClient(connectionConfig); + let azureResources: AzureResources; + if (containerId) { + azureResources = await client.getContainer(containerId, containerSchema); + } + else { + azureResources = await client.createContainer(containerSchema); + // Temporary until attach() is available (per Fluid engineering) + containerId = azureResources.fluidContainer.id; + location.hash = containerId; + } + sessionStorage.setItem("containerId", containerId); + return {containerId, azureResources}; +} + export function generateUser() { const randomUser = { id: uuidv4(), diff --git a/src/view/App.css b/src/view/App.css index 37d3168..4bc385e 100644 --- a/src/view/App.css +++ b/src/view/App.css @@ -28,24 +28,96 @@ header { height: 40px; } -.container { +.grid-container { display: grid; grid-template-columns: repeat(2, 1fr); - grid-column-gap: 0px; - grid-template-areas: "title login"; + grid-template-areas: "left right"; align-items: center; height: 100%; - color: white; - margin-left: 25px; - margin-right: 25px; +} + +.left { + grid-area: left; +} + +.right { + grid-area: right; +} + +.end { + justify-self: end; +} + +.start { + justify-self: start; } .title { - grid-area: title; + margin-left: 25px; font-size: 20px; + color: white; } .login { - grid-area: login; - justify-self: end; -} \ No newline at end of file + color: white; + margin-right: 25px; +} + +.white { + color: white; +} + +.mr-15 { + margin-right: 15px; +} + +.mb-5 { + margin-bottom: 5px; +} + +.selected-items { + background-color: #efefef; +} + +.selected-items h2 { + height: 15px; + background-color: #ccc; + margin-bottom: 0px; +} + +.selected-items h2 div { + margin-left: 10px; +} + +.items-list ul { + list-style-type: none; + padding: 0; + margin: 5px 0px 0px 10px; + font-size: 12px; + padding-bottom: 2px; +} + +.items-list li { + margin-bottom: 5px; +} + +.icon-wrapper { + position: relative; + text-align: center; + height: 20px; + margin-right: 5px; +} + +.circle-icon { + font-size: 18px; +} + +.circle-icon-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #fff; + padding-bottom: 2px; +} + diff --git a/src/view/BrainstormView.tsx b/src/view/BrainstormView.tsx index efae36f..6e58c81 100644 --- a/src/view/BrainstormView.tsx +++ b/src/view/BrainstormView.tsx @@ -5,6 +5,7 @@ import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import { BrainstormModel, createBrainstormModel } from "../BrainstormModel"; import { Header } from "./Header"; +import { ItemsList } from "./ItemsList"; import { NoteSpace } from "./NoteSpace"; export const BrainstormView = (props: { frsResources: AzureResources }) => { @@ -47,12 +48,17 @@ export const BrainstormView = (props: { frsResources: AzureResources }) => { author={authorInfo} members={members} /> - - - +
+ +
+
+ + + +
); }; diff --git a/src/view/Header.tsx b/src/view/Header.tsx index 5a5b7e9..9d82ab1 100644 --- a/src/view/Header.tsx +++ b/src/view/Header.tsx @@ -10,7 +10,7 @@ import { DefaultColor } from "./Color"; import { ColorPicker } from "./ColorPicker"; import { NoteData } from "../Types"; import { NOTE_SIZE } from "./Note.style"; -import { uuidv4 } from '../utils'; +import { uuidv4 } from '../Utils'; export interface HeaderProps { model: BrainstormModel; diff --git a/src/view/ItemsList.tsx b/src/view/ItemsList.tsx new file mode 100644 index 0000000..425da82 --- /dev/null +++ b/src/view/ItemsList.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from "react"; +import { BrainstormModel } from "../BrainstormModel"; +import { ColorId, LikedNote } from "../Types"; +import { CircleFillIcon } from '@fluentui/react-icons-mdl2'; +import { ColorOptions } from "./Color"; + +export type ItemsListProps = Readonly<{ + model: BrainstormModel; + }>; + +export function ItemsList(props: ItemsListProps) { + const { model } = props; + const [notes, setNotes] = useState([]); + // This runs when via model changes whether initiated by user or from external + useEffect(() => { + const syncLocalAndFluidState = () => { + const likedNotes: LikedNote[] = model.LikedNotes; + setNotes(likedNotes); + }; + + syncLocalAndFluidState(); + model.setChangeListener(syncLocalAndFluidState); + return () => model.removeChangeListener(syncLocalAndFluidState); + }, [model]); + + + return ( +
+ +
+

+
Selected Items
+
Votes
+

+ {!!notes.length && +
    + {notes.map((note, i) => { + const iconColor = ColorOptions[note.color as ColorId].base; + return ( +
  • +
    +
    {note.text}
    +
    +
    + + {note.numLikesCalculated} +
    +
    +
    +
  • + ) + })} +
+ } +
+
+ ); +} \ No newline at end of file diff --git a/src/view/Note.style.ts b/src/view/Note.style.ts index ccbd567..f54a80a 100644 --- a/src/view/Note.style.ts +++ b/src/view/Note.style.ts @@ -3,8 +3,8 @@ import { ColorOptions } from "./Color"; import { ColorId } from "../Types"; export const NOTE_SIZE = { - width: 300, - height: 100 + width: 250, + height: 75 } export const tooltipHostStyle: Partial = { @@ -38,7 +38,13 @@ export const likesButtonStyle: IButtonStyles = { root: { backgroundColor: "transparent" }, rootHovered: { backgroundColor: "transparent", fontSize: "18px" }, rootPressed: { backgroundColor: "transparent" }, - iconHovered: { fontSize: "18px" } + iconHovered: { fontSize: "18px" }, +}; + +export const likesButtonAuthorStyle: IButtonStyles = { + root: { backgroundColor: "transparent" }, + rootHovered: { backgroundColor: "transparent", fontSize: "14px" }, + rootPressed: { backgroundColor: "transparent" }, }; export function getRootStyleForColor(color: ColorId): IStyle { @@ -49,7 +55,7 @@ export function getRootStyleForColor(color: ColorId): IStyle { boxShadow: "rgb(0 0 0 / 13%) 0px 1.6px 3.6px 0px, rgb(0 0 0 / 11%) 0px 0.3px 0.9px 0px", width: NOTE_SIZE.width, - minHeight: NOTE_SIZE.height + minHeight: NOTE_SIZE.height, }; } diff --git a/src/view/Note.tsx b/src/view/Note.tsx index b0b0aad..67afc36 100644 --- a/src/view/Note.tsx +++ b/src/view/Note.tsx @@ -14,6 +14,7 @@ import { NoteBody } from "./NoteBody"; export type NoteProps = Readonly<{ id: string; + user: AzureMember; setPosition: (position: Position) => void; onLike: () => void; getLikedUsers: () => AzureMember[]; diff --git a/src/view/NoteHeader.tsx b/src/view/NoteHeader.tsx index f0f9e44..96d4672 100644 --- a/src/view/NoteHeader.tsx +++ b/src/view/NoteHeader.tsx @@ -17,12 +17,14 @@ import { colorButtonStyle, likesButtonStyle, tooltipHostStyle, + likesButtonAuthorStyle, } from "./Note.style"; import { ReactionListCallout } from "./ReactionListCallout"; import { NoteProps } from "./Note" const HeaderComponent = (props: NoteProps) => { const colorButtonRef = React.useRef(); + const { user } = props; const headerProps = { className: mergeStyles(getHeaderStyleForColor(props.color)), @@ -84,7 +86,7 @@ const HeaderComponent = (props: NoteProps) => { iconProps: { iconName: props.didILikeThisCalculated ? "LikeSolid" : "Like", }, - buttonStyles: likesButtonStyle, + buttonStyles: isAuthorNote() ? likesButtonAuthorStyle : likesButtonStyle, commandBarButtonAs: (props) => { return ( { title: "Delete Note", onClick: props.onDelete, buttonStyles: deleteButtonStyle, - }, + } ]; + function isAuthorNote() { + return user.userId && props.author.userId === user.userId; + } + const nonResizingGroup = (props: IResizeGroupProps) => (
diff --git a/src/view/NoteSpace.tsx b/src/view/NoteSpace.tsx index f691814..c09d841 100644 --- a/src/view/NoteSpace.tsx +++ b/src/view/NoteSpace.tsx @@ -92,6 +92,7 @@ export function NoteSpace(props: NoteSpaceProps) { id={note.id} key={note.id} text={note.text} + user={props.author} setPosition={setPosition} onLike={onLike} getLikedUsers={getLikedUsers}