Initial check-in
This commit is contained in:
Коммит
d6f99cf21e
|
@ -0,0 +1,29 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
react-app-env.d.ts
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
|
||||
# work around for https://github.com/facebook/create-react-app/issues/6560
|
||||
src/react-app-env.d.ts
|
|
@ -0,0 +1,30 @@
|
|||
# Lets Brainstorm
|
||||
|
||||
```
|
||||
This example is using an experimental API surface so please be cautious. We will break it!
|
||||
```
|
||||
|
||||
Brainstorm is an example of using the Fluid Framework to build a collaborative line of business application. In this example each user can create their own sticky notes that is managed on a board.
|
||||
|
||||
This application was shown during a [Microsoft Build session](https://aka.ms/OD522).
|
||||
|
||||
## Getting Started
|
||||
|
||||
To run this follow the steps below:
|
||||
|
||||
1. Run `npm install` from the brainstorm folder root
|
||||
2. Run `npm run start` to start the client
|
||||
3. Run `npx tinylicious` to start the "Tinylicious" test service
|
||||
4. Navigate to `http://localhost:3000` in a browser tab
|
||||
|
||||
This package is based on the [Create React App](https://reactjs.org/docs/create-a-new-react-app.html), so much of the Create React App documentation applies.
|
||||
|
||||
## Using the Brainstorm App
|
||||
|
||||
1. Navigate to `http://localhost:3000`
|
||||
|
||||
You'll be taken to a url similar to 'http://localhost:3000/**#1621961220840**' the path `##1621961220840` is specifies one brainstorm document.
|
||||
|
||||
2. Create another chrome tab with `http://localhost:3000/**#1621961220840**`
|
||||
|
||||
Now you can create notes, write text, change colors and more!
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"name": "@fluid-example/brainstorm",
|
||||
"version": "0.44.0",
|
||||
"description": "A simple brainstorming app built using Create React App plus a Fluid data model",
|
||||
"homepage": "https://fluidframework.com",
|
||||
"repository": "microsoft/FluidExamples",
|
||||
"license": "MIT",
|
||||
"author": "Microsoft and contributors",
|
||||
"sideEffects": false,
|
||||
"main": "dist/index.js",
|
||||
"module": "lib/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "react-scripts build",
|
||||
"eject": "react-scripts eject",
|
||||
"start": "react-scripts start",
|
||||
"start:server": "tinylicious",
|
||||
"start:frs": "cross-env REACT_APP_FLUID_CLIENT='\"frs\"' npm run start",
|
||||
"test": "react-scripts test",
|
||||
"test:report": "echo No test for this example"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
"event",
|
||||
"fdescribe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react": "^8.1.1",
|
||||
"@fluid-experimental/fluid-framework": "^0.44.0",
|
||||
"@fluid-experimental/frs-client": "^0.44.0",
|
||||
"@fluidframework/test-runtime-utils": "0.44.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"react": "^16.10.2",
|
||||
"react-dnd": "^14.0.2",
|
||||
"react-dnd-html5-backend": "^14.0.0",
|
||||
"react-dom": "^16.10.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tinylicious": "^0.4.21640",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "22.2.3",
|
||||
"@types/node": "^12.19.0",
|
||||
"@types/react": "^16.9.15",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
"react-scripts": "4.0.2",
|
||||
"typescript": "~4.1.3"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-core/11.0.0/css/fabric.min.css" />
|
||||
<title>Lets Brainstorm</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="rootContent"></div>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
|
@ -0,0 +1,138 @@
|
|||
import { FluidContainer, ISharedMap, SharedMap } from "@fluid-experimental/fluid-framework";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import { NoteData, Position } from "./Types";
|
||||
|
||||
const c_NoteIdPrefix = "noteId_";
|
||||
const c_PositionPrefix = "position_";
|
||||
const c_AuthorPrefix = "author_";
|
||||
const c_votePrefix = "vote_";
|
||||
const c_TextPrefix = "text_";
|
||||
const c_ColorPrefix = "color_";
|
||||
|
||||
export type BrainstormModel = Readonly<{
|
||||
CreateNote(noteId: string, myAuthor: FrsMember): NoteData;
|
||||
MoveNote(noteId: string, newPos: Position): void;
|
||||
SetNote(noteId: string, newCardData: NoteData): void;
|
||||
SetNoteText(noteId: string, noteText: string): void;
|
||||
SetNoteColor(noteId: string, noteColor: string): void;
|
||||
LikeNote(noteId: string, author: FrsMember): void;
|
||||
GetNoteLikedUsers(noteId: string): FrsMember[];
|
||||
DeleteNote(noteId: string): void;
|
||||
NoteIds: string[];
|
||||
setChangeListener(listener: () => void): void;
|
||||
removeChangeListener(listener: () => void): void;
|
||||
}>;
|
||||
|
||||
export function createBrainstormModel(fluid: FluidContainer): BrainstormModel {
|
||||
const sharedMap: ISharedMap = fluid.initialObjects.map as SharedMap;
|
||||
|
||||
const IsCompleteNote = (noteId: string) => {
|
||||
if (
|
||||
!sharedMap.get(c_PositionPrefix + noteId) ||
|
||||
!sharedMap.get(c_AuthorPrefix + noteId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const IsDeletedNote = (noteId: string) => {
|
||||
return sharedMap.get(c_NoteIdPrefix + noteId) === 0;
|
||||
};
|
||||
|
||||
const SetNoteText = (noteId: string, noteText: string) => {
|
||||
sharedMap.set(c_TextPrefix + noteId, noteText);
|
||||
};
|
||||
|
||||
const SetNoteColor = (noteId: string, noteColor: string) => {
|
||||
sharedMap.set(c_ColorPrefix + noteId, noteColor);
|
||||
};
|
||||
|
||||
|
||||
return {
|
||||
CreateNote(noteId: string, myAuthor: FrsMember): NoteData {
|
||||
const newNote: NoteData = {
|
||||
id: noteId,
|
||||
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,
|
||||
didILikeThisCalculated:
|
||||
Array.from(sharedMap
|
||||
.keys())
|
||||
.filter((key: string) =>
|
||||
key.includes(c_votePrefix + noteId + "_" + myAuthor.userId)
|
||||
)
|
||||
.filter((key: string) => sharedMap.get(key) !== undefined).length > 0,
|
||||
color: sharedMap.get(c_ColorPrefix + noteId)!,
|
||||
};
|
||||
return newNote;
|
||||
},
|
||||
|
||||
GetNoteLikedUsers(noteId: string): FrsMember[] {
|
||||
return (
|
||||
Array.from(sharedMap
|
||||
.keys())
|
||||
// Filter keys that represent if a note was liked
|
||||
.filter((key: string) => key.startsWith(c_votePrefix + noteId))
|
||||
.filter((key: string) => sharedMap.get(key) !== undefined)
|
||||
// Return the user associated with the like
|
||||
.map((value: string) => sharedMap.get(value)!)
|
||||
);
|
||||
},
|
||||
|
||||
MoveNote(noteId: string, newPos: Position) {
|
||||
sharedMap.set(c_PositionPrefix + noteId, newPos);
|
||||
},
|
||||
|
||||
SetNote(noteId: string, newCardData: NoteData) {
|
||||
sharedMap.set(c_PositionPrefix + noteId, newCardData.position);
|
||||
sharedMap.set(c_AuthorPrefix + noteId, newCardData.author);
|
||||
SetNoteText(newCardData.id, newCardData.text!);
|
||||
sharedMap.set(c_NoteIdPrefix + noteId, 1);
|
||||
sharedMap.set(c_ColorPrefix + noteId, newCardData.color);
|
||||
},
|
||||
|
||||
SetNoteText,
|
||||
|
||||
SetNoteColor,
|
||||
|
||||
LikeNote(noteId: string, author: FrsMember) {
|
||||
const voteString = c_votePrefix + noteId + "_" + author.userId;
|
||||
sharedMap.get(voteString) === author
|
||||
? sharedMap.set(voteString, undefined)
|
||||
: sharedMap.set(voteString, author);
|
||||
},
|
||||
|
||||
DeleteNote(noteId: string) {
|
||||
sharedMap.set(c_NoteIdPrefix + noteId, 0);
|
||||
},
|
||||
|
||||
get NoteIds(): string[] {
|
||||
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) => IsCompleteNote(noteId) && !IsDeletedNote(noteId))
|
||||
);
|
||||
},
|
||||
|
||||
setChangeListener(listener: () => void): void {
|
||||
sharedMap.on("valueChanged", listener);
|
||||
},
|
||||
|
||||
removeChangeListener(listener: () => void): void {
|
||||
sharedMap.off("valueChanged", listener);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { FrsConnectionConfig, InsecureTokenProvider } from "@fluid-experimental/frs-client";
|
||||
import { SharedMap } from "@fluid-experimental/fluid-framework";
|
||||
import { generateUser } from "@fluidframework/server-services-client";
|
||||
|
||||
export const useFrs = process.env.REACT_APP_FLUID_CLIENT === "frs";
|
||||
|
||||
export const user = generateUser();
|
||||
|
||||
export const containerSchema = {
|
||||
name: "brainstorm",
|
||||
initialObjects: {
|
||||
map: SharedMap,
|
||||
},
|
||||
}
|
||||
|
||||
export const connectionConfig: FrsConnectionConfig = useFrs ? {
|
||||
tenantId: 'm365cda',
|
||||
tokenProvider: new InsecureTokenProvider('c979c09e55407ceb62840c7ddfcfb0c1', user),
|
||||
orderer: 'https://alfred.eus-1.canary.frs.azure.com',
|
||||
storage: 'https://historian.eus-1.canary.frs.azure.com',
|
||||
} : {
|
||||
tenantId: "local",
|
||||
tokenProvider: new InsecureTokenProvider("fooBar", user),
|
||||
orderer: "http://localhost:7070",
|
||||
storage: "http://localhost:7070",
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
|
||||
export type Position = Readonly<{ x: number; y: number }>;
|
||||
|
||||
export type NoteData = Readonly<{
|
||||
id: any;
|
||||
text?: string;
|
||||
author: FrsMember;
|
||||
position: Position;
|
||||
numLikesCalculated: number;
|
||||
didILikeThisCalculated: boolean;
|
||||
color: ColorId;
|
||||
}>;
|
||||
|
||||
export type ColorId =
|
||||
| "Blue"
|
||||
| "Green"
|
||||
| "Yellow"
|
||||
| "Pink"
|
||||
| "Purple"
|
||||
| "Orange";
|
|
@ -0,0 +1,56 @@
|
|||
/*!
|
||||
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
|
||||
* Licensed under the MIT License.
|
||||
*/
|
||||
|
||||
import { initializeIcons, ThemeProvider } from "@fluentui/react";
|
||||
import { FrsClient } from '@fluid-experimental/frs-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";
|
||||
|
||||
export async function start() {
|
||||
initializeIcons();
|
||||
|
||||
const getContainerId = (): { containerId: string; isNew: boolean } => {
|
||||
let isNew = false;
|
||||
if (location.hash.length === 0) {
|
||||
isNew = true;
|
||||
location.hash = Date.now().toString();
|
||||
}
|
||||
const containerId = location.hash.substring(1);
|
||||
return { containerId, isNew };
|
||||
};
|
||||
|
||||
const { containerId, isNew } = getContainerId();
|
||||
|
||||
const client = new FrsClient(connectionConfig);
|
||||
|
||||
const frsResources = isNew
|
||||
? await client.createContainer({ id: containerId }, containerSchema)
|
||||
: await client.getContainer({ id: containerId }, containerSchema);
|
||||
|
||||
|
||||
if (!frsResources.fluidContainer.connected) {
|
||||
await new Promise<void>((resolve) => {
|
||||
frsResources.fluidContainer.once("connected", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={themeNameToTheme("default")}>
|
||||
<BrainstormView frsResources={frsResources} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
}
|
||||
|
||||
start().catch((error) => console.error(error));
|
|
@ -0,0 +1,24 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
h1 {
|
||||
color: gray;
|
||||
}
|
||||
h2 {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.PropName {
|
||||
font-weight: bold;
|
||||
color: #6264a7;
|
||||
}
|
||||
.Logo {
|
||||
font-size: 45pt;
|
||||
color: #6264a7;
|
||||
}
|
||||
.Error {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#NoteSpace {
|
||||
height: 100vh
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { mergeStyles, Spinner } from "@fluentui/react";
|
||||
import { FrsResources } from "@fluid-experimental/frs-client";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from 'react-dnd'
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { BrainstormModel, createBrainstormModel } from "../BrainstormModel";
|
||||
import { Header } from "./Header";
|
||||
import { NoteSpace } from "./NoteSpace";
|
||||
|
||||
export const BrainstormView = (props: { frsResources: FrsResources }) => {
|
||||
const { frsResources: { fluidContainer, containerServices } } = props;
|
||||
const [model] = React.useState<BrainstormModel>(createBrainstormModel(fluidContainer));
|
||||
|
||||
const audience = containerServices.audience;
|
||||
const [members, setMembers] = React.useState(Array.from(audience.getMembers().values()));
|
||||
const authorInfo = audience.getMyself();
|
||||
const setMembersCallback = React.useCallback(() => setMembers(
|
||||
Array.from(
|
||||
audience.getMembers().values()
|
||||
)
|
||||
), [setMembers, audience]);
|
||||
// Setup a listener to update our users when new clients join the session
|
||||
React.useEffect(() => {
|
||||
fluidContainer.on("connected", setMembersCallback);
|
||||
audience.on("membersChanged", setMembersCallback);
|
||||
return () => {
|
||||
fluidContainer.off("connected", () => setMembersCallback);
|
||||
audience.off("membersChanged", () => setMembersCallback);
|
||||
};
|
||||
}, [fluidContainer, audience, setMembersCallback]);
|
||||
|
||||
const wrapperClass = mergeStyles({
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
});
|
||||
|
||||
if (authorInfo === undefined) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClass}>
|
||||
<Header
|
||||
model={model}
|
||||
author={authorInfo}
|
||||
members={members}
|
||||
/>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<NoteSpace
|
||||
model={model}
|
||||
author={authorInfo}
|
||||
/>
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
import { ColorId } from "../Types";
|
||||
|
||||
export type ColorValues = { base: string; dark: string; light: string };
|
||||
|
||||
export const ColorOrder: ColorId[] = [
|
||||
"Blue",
|
||||
"Green",
|
||||
"Yellow",
|
||||
"Pink",
|
||||
"Purple",
|
||||
"Orange",
|
||||
];
|
||||
|
||||
export const DefaultColor = ColorOrder[0];
|
||||
|
||||
export const ColorOptions: { [key in ColorId]: ColorValues } = {
|
||||
Blue: { base: "#0078D4", dark: "#99C9EE", light: "#CCE4F6" },
|
||||
Green: { base: "#005E50", dark: "#99BFB9", light: "#CCDFDC" },
|
||||
Yellow: { base: "#F2C811", dark: "#FAE9A0", light: "#FCF4CF" },
|
||||
Pink: { base: "#E3008C", dark: "#F499D1", light: "#F9CCE8" },
|
||||
Purple: { base: "#8764B8", dark: "#CFC1E3", light: "#E7E0F1" },
|
||||
Orange: { base: "#CA5010", dark: "#EAB99F", light: "#F4DCCF" },
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import { SwatchColorPicker, IColorCellProps } from "@fluentui/react";
|
||||
import { ColorOptions, ColorOrder } from "./Color";
|
||||
import { ColorId } from "../Types";
|
||||
|
||||
|
||||
export type ColorButtonProps = {
|
||||
parent?: any,
|
||||
selectedColor: ColorId;
|
||||
setColor: (color: ColorId) => void;
|
||||
};
|
||||
|
||||
export function ColorPicker(props: ColorButtonProps) {
|
||||
const { selectedColor, setColor } = props;
|
||||
const colorCells = ColorOrder.map((id) => colorOptionToCell(id));
|
||||
const onChange = (_event: React.FormEvent<HTMLElement>, colorId: string | undefined) => {
|
||||
props.parent.current.dismissMenu();
|
||||
setColor(colorId as ColorId);
|
||||
};
|
||||
return (
|
||||
<SwatchColorPicker
|
||||
columnCount={6}
|
||||
colorCells={colorCells}
|
||||
defaultSelectedId={selectedColor}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function colorOptionToCell(id: ColorId): IColorCellProps {
|
||||
return {
|
||||
id: id,
|
||||
color: ColorOptions[id].dark,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
import {
|
||||
Text,
|
||||
CommandBar,
|
||||
ICommandBarItemProps,
|
||||
Facepile,
|
||||
} from "@fluentui/react";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import React from "react";
|
||||
import { BrainstormModel } from "../BrainstormModel";
|
||||
import { DefaultColor } from "./Color";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import { NoteData } from "../Types";
|
||||
import { NOTE_SIZE } from "./Note.style";
|
||||
|
||||
function uuidv4() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
var r = (Math.random() * 16) | 0,
|
||||
v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
export interface HeaderProps {
|
||||
model: BrainstormModel;
|
||||
author: FrsMember;
|
||||
members: FrsMember[];
|
||||
}
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const colorButtonRef = React.useRef<any>();
|
||||
const [color, setColor] = React.useState(DefaultColor);
|
||||
const personas = React.useMemo(() => props.members.map(member => {return { personaName: member.userName}}), [props.members]);
|
||||
|
||||
const onAddNote = () => {
|
||||
const { scrollHeight, scrollWidth } = document.getElementById("NoteSpace")!;
|
||||
const id = uuidv4();
|
||||
const newCardData: NoteData = {
|
||||
id,
|
||||
position: {
|
||||
x: Math.floor(Math.random() * (scrollWidth - NOTE_SIZE.width)),
|
||||
y: Math.floor(Math.random() * (scrollHeight - NOTE_SIZE.height)),
|
||||
},
|
||||
author: props.author,
|
||||
numLikesCalculated: 0,
|
||||
didILikeThisCalculated: false,
|
||||
color
|
||||
};
|
||||
props.model.SetNote(id, newCardData);
|
||||
};
|
||||
|
||||
const items: ICommandBarItemProps[] = [
|
||||
{
|
||||
key: "title",
|
||||
onRender: () => (
|
||||
<Text
|
||||
variant="xLarge"
|
||||
styles={{
|
||||
root: { alignSelf: "center", marginBottom: 6, marginRight: 16 },
|
||||
}}
|
||||
>
|
||||
Let's Brainstorm
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "add",
|
||||
text: "Add note",
|
||||
onClick: onAddNote,
|
||||
iconProps: {
|
||||
iconName: "QuickNote",
|
||||
},
|
||||
},
|
||||
{
|
||||
componentRef: colorButtonRef,
|
||||
key: "color",
|
||||
text: "Default Color",
|
||||
iconProps: {
|
||||
iconName: "Color",
|
||||
},
|
||||
subMenuProps: {
|
||||
key: "color-picker",
|
||||
items: [{ key: "foo" }],
|
||||
onRenderMenuList: () => (
|
||||
<ColorPicker
|
||||
parent={colorButtonRef}
|
||||
selectedColor={color}
|
||||
setColor={setColor}
|
||||
/>
|
||||
),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const farItems: ICommandBarItemProps[] = [
|
||||
{
|
||||
key: "presence",
|
||||
onRender: () => <Facepile
|
||||
styles={{ root: { alignSelf: "center" } }}
|
||||
personas={personas}
|
||||
/>,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<CommandBar
|
||||
styles={{ root: { paddingLeft: 0 } }}
|
||||
items={items}
|
||||
farItems={farItems}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { IRawStyle, IStyle, ITooltipHostStyles, IButtonStyles } from '@fluentui/react';
|
||||
import { ColorOptions } from "./Color";
|
||||
import { ColorId } from "../Types";
|
||||
|
||||
export const NOTE_SIZE = {
|
||||
width: 300,
|
||||
height: 100
|
||||
}
|
||||
|
||||
export const tooltipHostStyle: Partial<ITooltipHostStyles> = {
|
||||
root: { display: "inline-block" },
|
||||
};
|
||||
|
||||
export const iconStyle: React.CSSProperties = {
|
||||
color: "black",
|
||||
fontSize: "10px",
|
||||
};
|
||||
|
||||
export const deleteButtonStyle: IButtonStyles = {
|
||||
root: { backgroundColor: "transparent" },
|
||||
rootHovered: { backgroundColor: "transparent" },
|
||||
rootPressed: { backgroundColor: "transparent" },
|
||||
icon: { fontSize: "13px" },
|
||||
iconHovered: { fontSize: "15px" }
|
||||
};
|
||||
|
||||
export const colorButtonStyle: IButtonStyles = {
|
||||
root: { backgroundColor: "transparent " },
|
||||
rootHovered: { backgroundColor: "transparent" },
|
||||
rootPressed: { backgroundColor: "transparent" },
|
||||
rootExpanded: { backgroundColor: "transparent" },
|
||||
rootExpandedHovered: { backgroundColor: "transparent" },
|
||||
iconHovered: { fontSize: "18px" },
|
||||
iconExpanded: { fontSize: "18px" }
|
||||
};
|
||||
|
||||
export const likesButtonStyle: IButtonStyles = {
|
||||
root: { backgroundColor: "transparent" },
|
||||
rootHovered: { backgroundColor: "transparent", fontSize: "18px" },
|
||||
rootPressed: { backgroundColor: "transparent" },
|
||||
iconHovered: { fontSize: "18px" }
|
||||
};
|
||||
|
||||
export function getRootStyleForColor(color: ColorId): IStyle {
|
||||
return {
|
||||
background: ColorOptions[color].light,
|
||||
position: "absolute",
|
||||
borderRadius: "2px",
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
export function getHeaderStyleForColor(color: ColorId): IRawStyle {
|
||||
if (color === undefined) {
|
||||
return { backgroundColor: ColorOptions["Blue"].dark };
|
||||
}
|
||||
return { backgroundColor: ColorOptions[color].dark };
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
mergeStyles,
|
||||
} from "@fluentui/react";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import React from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { DefaultColor } from "./Color";
|
||||
import {
|
||||
getRootStyleForColor
|
||||
} from "./Note.style";
|
||||
import { NoteData, Position } from "../Types";
|
||||
import { NoteHeader } from "./NoteHeader";
|
||||
import { NoteBody } from "./NoteBody";
|
||||
|
||||
export type NoteProps = Readonly<{
|
||||
id: string;
|
||||
setPosition: (position: Position) => void;
|
||||
onLike: () => void;
|
||||
getLikedUsers: () => FrsMember[];
|
||||
onDelete: () => void;
|
||||
onColorChange: (color: string) => void;
|
||||
setText: (text: string) => void;
|
||||
}> &
|
||||
Pick<
|
||||
NoteData,
|
||||
| "author"
|
||||
| "position"
|
||||
| "color"
|
||||
| "didILikeThisCalculated"
|
||||
| "numLikesCalculated"
|
||||
| "text"
|
||||
>;
|
||||
|
||||
export function Note(props: NoteProps) {
|
||||
const {
|
||||
id,
|
||||
position: { x: left, y: top },
|
||||
color = DefaultColor,
|
||||
setText,
|
||||
text
|
||||
} = props;
|
||||
|
||||
const [, drag] = useDrag(
|
||||
() => ({
|
||||
type: "note",
|
||||
item: { id, left, top },
|
||||
}),
|
||||
[id, left, top]
|
||||
);
|
||||
|
||||
const rootClass = mergeStyles(getRootStyleForColor(color));
|
||||
|
||||
return (
|
||||
<div className={rootClass} ref={drag} style={{ left, top }}>
|
||||
<NoteHeader {...props} />
|
||||
<NoteBody setText={setText} text={text} color={color} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
import { TextField } from "@fluentui/react";
|
||||
import { NoteData } from "../Types";
|
||||
import { ColorOptions, DefaultColor } from "./Color";
|
||||
|
||||
export type NoteBodyProps = Readonly<{
|
||||
setText(text: string): void;
|
||||
}> &
|
||||
Pick<NoteData, "text" | "color">;
|
||||
|
||||
export function NoteBody(props: NoteBodyProps) {
|
||||
const { setText, text, color = DefaultColor } = props;
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1 }}>
|
||||
<TextField
|
||||
styles={{ fieldGroup: { background: ColorOptions[color].light } }}
|
||||
borderless
|
||||
multiline
|
||||
resizable={false}
|
||||
autoAdjustHeight
|
||||
onChange={(event) => setText(event.currentTarget.value)}
|
||||
value={text}
|
||||
placeholder={"Enter Text Here"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import {
|
||||
CommandBar,
|
||||
CommandBarButton,
|
||||
DirectionalHint,
|
||||
ICommandBarItemProps,
|
||||
IResizeGroupProps,
|
||||
ITooltipProps,
|
||||
mergeStyles,
|
||||
PersonaCoin,
|
||||
TooltipHost,
|
||||
} from "@fluentui/react";
|
||||
import React from "react";
|
||||
import { ColorPicker } from "./ColorPicker";
|
||||
import {
|
||||
getHeaderStyleForColor,
|
||||
deleteButtonStyle,
|
||||
colorButtonStyle,
|
||||
likesButtonStyle,
|
||||
tooltipHostStyle,
|
||||
} from "./Note.style";
|
||||
import { ReactionListCallout } from "./ReactionListCallout";
|
||||
import { NoteProps } from "./Note"
|
||||
|
||||
const HeaderComponent = (props: NoteProps) => {
|
||||
const colorButtonRef = React.useRef();
|
||||
|
||||
const headerProps = {
|
||||
className: mergeStyles(getHeaderStyleForColor(props.color)),
|
||||
};
|
||||
|
||||
const likeBtnTooltipProps: ITooltipProps = {
|
||||
|
||||
onRenderContent: () => {
|
||||
const likedUserList = props.getLikedUsers();
|
||||
|
||||
if (likedUserList.length === 0) {
|
||||
// Don't render a tooltip if no users reacted.
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ReactionListCallout
|
||||
label={"Like Reactions"}
|
||||
reactionIconName={"Like"}
|
||||
usersToDisplay={likedUserList}
|
||||
/>
|
||||
);
|
||||
},
|
||||
calloutProps: {
|
||||
beakWidth: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const items: ICommandBarItemProps[] = [
|
||||
{
|
||||
key: "persona",
|
||||
onRender: () => {
|
||||
return (
|
||||
<TooltipHost
|
||||
styles={{ root: { alignSelf: "center", display: "block" } }}
|
||||
content={props.author.userName}
|
||||
>
|
||||
<PersonaCoin
|
||||
styles={{
|
||||
coin: {
|
||||
alignSelf: "center",
|
||||
margin: "0px 8px",
|
||||
userSelect: "none",
|
||||
},
|
||||
}}
|
||||
text={props.author.userName}
|
||||
coinSize={24}
|
||||
/>
|
||||
</TooltipHost>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const farItems: ICommandBarItemProps[] = [
|
||||
{
|
||||
key: "likes",
|
||||
onClick: props.onLike,
|
||||
text: props.numLikesCalculated.toString(),
|
||||
iconProps: {
|
||||
iconName: props.didILikeThisCalculated ? "LikeSolid" : "Like",
|
||||
},
|
||||
buttonStyles: likesButtonStyle,
|
||||
commandBarButtonAs: (props) => {
|
||||
return (
|
||||
<TooltipHost
|
||||
tooltipProps={likeBtnTooltipProps}
|
||||
directionalHint={DirectionalHint.topAutoEdge}
|
||||
styles={tooltipHostStyle}
|
||||
>
|
||||
<CommandBarButton {...(props as any)} />
|
||||
</TooltipHost>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// @ts-ignore
|
||||
componentRef: colorButtonRef,
|
||||
key: "color",
|
||||
iconProps: {
|
||||
iconName: "Color",
|
||||
},
|
||||
subMenuProps: {
|
||||
key: "color-picker",
|
||||
items: [{ key: "foo" }],
|
||||
onRenderMenuList: () => (
|
||||
<ColorPicker
|
||||
parent={colorButtonRef}
|
||||
selectedColor={props.color!}
|
||||
setColor={(color) => props.onColorChange(color)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
buttonStyles: colorButtonStyle,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
iconProps: { iconName: "Clear" },
|
||||
title: "Delete Note",
|
||||
onClick: props.onDelete,
|
||||
buttonStyles: deleteButtonStyle,
|
||||
},
|
||||
];
|
||||
|
||||
const nonResizingGroup = (props: IResizeGroupProps) => (
|
||||
<div>
|
||||
<div style={{ position: "relative" }}>
|
||||
{props.onRenderData(props.data)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...headerProps}>
|
||||
<CommandBar
|
||||
resizeGroupAs={nonResizingGroup}
|
||||
styles={{
|
||||
root: { padding: 0, height: 36, backgroundColor: "transparent" },
|
||||
}}
|
||||
items={items}
|
||||
farItems={farItems}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const NoteHeader = React.memo(HeaderComponent, (prevProps, nextProps) => {
|
||||
return prevProps.color === nextProps.color
|
||||
&& prevProps.numLikesCalculated === nextProps.numLikesCalculated
|
||||
&& prevProps.didILikeThisCalculated === nextProps.didILikeThisCalculated
|
||||
})
|
|
@ -0,0 +1,107 @@
|
|||
import { IStyle, mergeStyles, ThemeProvider } from "@fluentui/react";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import React from "react";
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { NoteData, Position } from "../Types";
|
||||
import { Note } from "./Note";
|
||||
import { BrainstormModel } from "../BrainstormModel";
|
||||
import { lightTheme } from "./Themes";
|
||||
|
||||
export type NoteSpaceProps = Readonly<{
|
||||
model: BrainstormModel;
|
||||
author: FrsMember;
|
||||
}>;
|
||||
|
||||
export function NoteSpace(props: NoteSpaceProps) {
|
||||
const { model } = props;
|
||||
const [notes, setNotes] = React.useState<readonly NoteData[]>([]);
|
||||
|
||||
// This runs when via model changes whether initiated by user or from external
|
||||
React.useEffect(() => {
|
||||
const syncLocalAndFluidState = () => {
|
||||
const noteDataArr = [];
|
||||
const ids: string[] = model.NoteIds;
|
||||
|
||||
// Recreate the list of cards to re-render them via setNotes
|
||||
for (let noteId of ids) {
|
||||
const newCardData: NoteData = model.CreateNote(noteId, props.author);
|
||||
noteDataArr.push(newCardData);
|
||||
}
|
||||
setNotes(noteDataArr);
|
||||
};
|
||||
|
||||
syncLocalAndFluidState();
|
||||
model.setChangeListener(syncLocalAndFluidState);
|
||||
return () => model.removeChangeListener(syncLocalAndFluidState);
|
||||
}, [model, props.author]);
|
||||
|
||||
const rootStyle: IStyle = {
|
||||
flexGrow: 1,
|
||||
position: "relative",
|
||||
margin: "10px",
|
||||
borderRadius: "2px",
|
||||
};
|
||||
|
||||
const spaceClass = mergeStyles(rootStyle);
|
||||
|
||||
const [, drop] = useDrop(() => ({
|
||||
accept: 'note',
|
||||
drop(item: any, monitor) {
|
||||
const delta = monitor.getDifferenceFromInitialOffset()!;
|
||||
const left = Math.round(item.left + delta.x);
|
||||
const top = Math.round(item.top + delta.y);
|
||||
model.MoveNote(item.id, {
|
||||
x: left > 0 ? left : 0,
|
||||
y: top > 0 ? top : 0
|
||||
})
|
||||
return undefined;
|
||||
},
|
||||
}), [model]);
|
||||
|
||||
return (
|
||||
<div id="NoteSpace" ref={drop} className={spaceClass}>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
{notes.map((note, i) => {
|
||||
const setPosition = (position: Position) => {
|
||||
model.MoveNote(note.id, position);
|
||||
};
|
||||
|
||||
const setText = (text: string) => {
|
||||
model.SetNoteText(note.id, text);
|
||||
};
|
||||
|
||||
const onLike = () => {
|
||||
model.LikeNote(note.id, props.author);
|
||||
};
|
||||
|
||||
const getLikedUsers = () => {
|
||||
return model.GetNoteLikedUsers(note.id);
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
model.DeleteNote(note.id);
|
||||
};
|
||||
|
||||
const onColorChange = (color: string) => {
|
||||
model.SetNoteColor(note.id, color);
|
||||
};
|
||||
|
||||
return (
|
||||
<Note
|
||||
{...note}
|
||||
id={note.id}
|
||||
key={note.id}
|
||||
text={note.text}
|
||||
setPosition={setPosition}
|
||||
onLike={onLike}
|
||||
getLikedUsers={getLikedUsers}
|
||||
onDelete={onDelete}
|
||||
onColorChange={onColorChange}
|
||||
setText={setText}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ThemeProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { IPersonaStyles, List, Persona, PersonaSize } from "@fluentui/react";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import React from "react";
|
||||
|
||||
export function PersonaList(props: { users: FrsMember[] }) {
|
||||
const personaStyles: Partial<IPersonaStyles> = {
|
||||
root: {
|
||||
marginTop: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const renderPersonaListItem = (item?: FrsMember) => {
|
||||
return (
|
||||
item && (
|
||||
<Persona
|
||||
text={item.userName}
|
||||
size={PersonaSize.size24}
|
||||
styles={personaStyles}
|
||||
></Persona>
|
||||
)
|
||||
);
|
||||
};
|
||||
return <List items={props.users} onRenderCell={renderPersonaListItem}></List>;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import { Icon, Label, Stack } from "@fluentui/react";
|
||||
import { FrsMember } from "@fluid-experimental/frs-client";
|
||||
import React from "react";
|
||||
import { PersonaList } from "./PersonaList";
|
||||
|
||||
export type ReactionListCalloutProps = {
|
||||
label: string;
|
||||
usersToDisplay: FrsMember[];
|
||||
reactionIconName?: string;
|
||||
};
|
||||
|
||||
export function ReactionListCallout(props: ReactionListCalloutProps) {
|
||||
return (
|
||||
<div>
|
||||
<Stack horizontal tokens={{ childrenGap: 10 }}>
|
||||
{props.reactionIconName && (
|
||||
<Icon
|
||||
iconName={props.reactionIconName}
|
||||
style={{ fontSize: 15, alignSelf: "center" }}
|
||||
></Icon>
|
||||
)}
|
||||
<Label>Like Reactions</Label>
|
||||
</Stack>
|
||||
<PersonaList
|
||||
users={props.usersToDisplay}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import { createTheme } from "@fluentui/react";
|
||||
|
||||
export type ThemeName = "default" | "dark" | "contrast";
|
||||
|
||||
export function normalizeThemeName(theme?: string): ThemeName {
|
||||
switch (theme) {
|
||||
case "dark":
|
||||
return "dark";
|
||||
case "contrast":
|
||||
return "contrast";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
export function themeNameToTheme(themeName: ThemeName) {
|
||||
switch (themeName) {
|
||||
case "default":
|
||||
return lightTheme;
|
||||
case "dark":
|
||||
return darkTheme;
|
||||
case "contrast":
|
||||
return darkTheme;
|
||||
}
|
||||
}
|
||||
|
||||
export const lightTheme = createTheme({
|
||||
palette: {
|
||||
themePrimary: "#6264a7",
|
||||
themeLighterAlt: "#f7f7fb",
|
||||
themeLighter: "#e1e1f1",
|
||||
themeLight: "#c8c9e4",
|
||||
themeTertiary: "#989ac9",
|
||||
themeSecondary: "#7173b0",
|
||||
themeDarkAlt: "#585a95",
|
||||
themeDark: "#4a4c7e",
|
||||
themeDarker: "#37385d",
|
||||
neutralLighterAlt: "#faf9f8",
|
||||
neutralLighter: "#f3f2f1",
|
||||
neutralLight: "#edebe9",
|
||||
neutralQuaternaryAlt: "#e1dfdd",
|
||||
neutralQuaternary: "#d0d0d0",
|
||||
neutralTertiaryAlt: "#c8c6c4",
|
||||
neutralTertiary: "#b9b8b7",
|
||||
neutralSecondary: "#a2a1a0",
|
||||
neutralPrimaryAlt: "#8b8a89",
|
||||
neutralPrimary: "#30302f",
|
||||
neutralDark: "#5e5d5c",
|
||||
black: "#474645",
|
||||
white: "#ffffff",
|
||||
},
|
||||
});
|
||||
|
||||
export const darkTheme = createTheme({
|
||||
isInverted: true,
|
||||
palette: {
|
||||
themePrimary: "#6264a7",
|
||||
themeLighterAlt: "#040407",
|
||||
themeLighter: "#10101b",
|
||||
themeLight: "#1d1e32",
|
||||
themeTertiary: "#3b3c63",
|
||||
themeSecondary: "#565892",
|
||||
themeDarkAlt: "#6e70af",
|
||||
themeDark: "#8183bb",
|
||||
themeDarker: "#9ea0cd",
|
||||
neutralLighterAlt: "#000000",
|
||||
neutralLighter: "#000000",
|
||||
neutralLight: "#000000",
|
||||
neutralQuaternaryAlt: "#000000",
|
||||
neutralQuaternary: "#000000",
|
||||
neutralTertiaryAlt: "#000000",
|
||||
neutralTertiary: "#c8c8c8",
|
||||
neutralSecondary: "#d0d0d0",
|
||||
neutralPrimaryAlt: "#dadada",
|
||||
neutralPrimary: "#ffffff",
|
||||
neutralDark: "#f4f4f4",
|
||||
black: "#f8f8f8",
|
||||
white: "#000000",
|
||||
},
|
||||
});
|
|
@ -0,0 +1,14 @@
|
|||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.rootContent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
export * from "./BrainstormView";
|
||||
export * from "./Color";
|
||||
export * from "./ColorPicker";
|
||||
export * from "./Header";
|
||||
export * from "./Note";
|
||||
export * from "./NoteBody";
|
||||
export * from "./NoteHeader";
|
||||
export * from "./NoteSpace";
|
||||
export * from "./PersonaList";
|
||||
export * from "./Themes";
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Загрузка…
Ссылка в новой задаче