diff options
36 files changed, 2917 insertions, 1008 deletions
diff --git a/package-lock.json b/package-lock.json index b40f6fe72..dcfb6a11b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -123,11 +123,11 @@ "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", - "@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - }, + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==" + }, "requires": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" @@ -5691,6 +5691,11 @@ "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==" }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, "available-typed-arrays": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", @@ -6351,9 +6356,9 @@ } }, "browndash-components": { - "version": "0.0.88", - "resolved": "https://registry.npmjs.org/browndash-components/-/browndash-components-0.0.88.tgz", - "integrity": "sha512-Np/Wn/MUMYawUVaxCI/EzaB5hk356hEge2PaEZsChrXCw56ptEDl7/FAHAeVv2/Giv39LqFLUFJPrP9lDC8VzA==", + "version": "0.0.90", + "resolved": "https://registry.npmjs.org/browndash-components/-/browndash-components-0.0.90.tgz", + "integrity": "sha512-56dsp2yoP5axTnXSPtSpWsQUkcj3hAekYBHLk7Kw6OV7LHVuu5PZruSmGE9xDrdhAFz2UdUyxKeAGB3PdbQoew==", "requires": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", @@ -13475,6 +13480,14 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, + "file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "requires": { + "tslib": "^2.4.0" + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -23951,6 +23964,16 @@ "prop-types": "^15.8.1" } }, + "react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "requires": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + } + }, "react-event-listener": { "version": "0.6.6", "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.6.6.tgz", diff --git a/package.json b/package.json index a087e3061..8da591254 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "body-parser": "^1.19.2", "bootstrap": "^4.6.1", "brotli": "^1.3.3", - "browndash-components": "^0.0.88", + "browndash-components": "^0.0.90", "browser-assert": "^1.2.1", "bson": "^4.6.1", "canvas": "^2.9.3", @@ -289,6 +289,7 @@ "react-compound-slider": "^2.5.0", "react-datepicker": "^3.8.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-grid-layout": "^1.3.4", "react-icons": "^4.3.1", "react-jsx-parser": "^1.29.0", diff --git a/src/Utils.ts b/src/Utils.ts index e03632c8b..599c6456a 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -16,18 +16,18 @@ export namespace Utils { return Date.now() - downTime < Utils.CLICK_TIME && Math.abs(x - downX) < Utils.DRAG_THRESHOLD && Math.abs(y - downY) < Utils.DRAG_THRESHOLD; } - export function cleanDocumentType(type: DocumentType) { - switch(type) { + export function cleanDocumentType(type: DocumentType) { + switch (type) { case DocumentType.IMG: - return "Image" + return 'Image'; case DocumentType.AUDIO: - return "Audio" + return 'Audio'; case DocumentType.COL: - return "Collection" + return 'Collection'; case DocumentType.RTF: - return "Text" - default: - return type.charAt(0).toUpperCase() + type.slice(1) + return 'Text'; + default: + return type.charAt(0).toUpperCase() + type.slice(1); } } @@ -140,7 +140,7 @@ export namespace Utils { } export function colorString(color: ColorState) { - return color.hex.startsWith('#') ? color.hex + (color.rgb.a ? decimalToHexString(Math.round(color.rgb.a * 255)) : 'ff') : color.hex; + return color.hex.startsWith('#') && color.hex.length < 8 ? color.hex + (color.rgb.a ? decimalToHexString(Math.round(color.rgb.a * 255)) : 'ff') : color.hex; } export function fromRGBAstr(rgba: string) { diff --git a/src/client/Network.ts b/src/client/Network.ts index d606b9854..39bf69e32 100644 --- a/src/client/Network.ts +++ b/src/client/Network.ts @@ -5,7 +5,7 @@ import { Upload } from '../server/SharedMediaTypes'; /** * Networking is repsonsible for connecting the client to the server. Networking * mainly provides methods that the client can use to begin the process of - * interacting with the server, such as fetching or uploading files. + * interacting with the server, such as fetching or uploading files. */ export namespace Networking { export async function FetchFromServer(relativeRoute: string) { @@ -25,9 +25,9 @@ export namespace Networking { /** * FileGuidPair attaches a guid to a file that is being uploaded, * allowing the client to track the upload progress. - * + * * When files are dragged to the canvas, the overWriteDoc's ID is - * used as the guid. Otherwise, a new guid is generated. + * used as the guid. Otherwise, a new guid is generated. */ export interface FileGuidPair { file: File; @@ -38,9 +38,10 @@ export namespace Networking { * with the mapping of guid to files as parameters. * * @param fileguidpairs the files and corresponding guids to be uploaded to the server + * @param browndash whether the endpoint should be invoked on the browndash server * @returns the response as a json from the server */ - export async function UploadFilesToServer<T extends Upload.FileInformation = Upload.FileInformation>(fileguidpairs: FileGuidPair | FileGuidPair[]): Promise<Upload.FileResponse<T>[]> { + export async function UploadFilesToServer<T extends Upload.FileInformation = Upload.FileInformation>(fileguidpairs: FileGuidPair | FileGuidPair[], browndash?: boolean): Promise<Upload.FileResponse<T>[]> { const formData = new FormData(); if (Array.isArray(fileguidpairs)) { if (!fileguidpairs.length) { @@ -57,17 +58,19 @@ export namespace Networking { ]) ); } - // If the fileguidpair has a guid to use (From the overwriteDoc) use that guid. Otherwise, generate a new guid. + // If the fileguidpair has a guid to use (From the overwriteDoc) use that guid. Otherwise, generate a new guid. fileguidpairs.forEach(fileguidpair => formData.append(fileguidpair.guid ?? Utils.GenerateGuid(), fileguidpair.file)); } else { - // Handle the case where fileguidpairs is a single file. + // Handle the case where fileguidpairs is a single file. formData.append(fileguidpairs.guid ?? Utils.GenerateGuid(), fileguidpairs.file); } const parameters = { method: 'POST', body: formData, }; - const response = await fetch('/uploadFormData', parameters); + + const endpoint = browndash ? '[insert endpoint allowing local => browndash]' : '/uploadFormData'; + const response = await fetch(endpoint, parameters); return response.json(); } diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/client/theme.ts diff --git a/src/client/util/GroupManager.scss b/src/client/util/GroupManager.scss index 253ed5d2a..673af16ee 100644 --- a/src/client/util/GroupManager.scss +++ b/src/client/util/GroupManager.scss @@ -7,33 +7,48 @@ .group-create { display: flex; flex-direction: column; - height: 90%; + overflow: visible; + height: 100%; + width: 98.5%; + padding: 5px; justify-content: space-between; margin-left: 5px; - input { - border-radius: 5px; - padding: 8px; - min-width: 100%; - border: 1px solid hsl(0, 0%, 80%); - outline: none; - height: 30; - - &:focus { - border: 2.5px solid #2684FF; - } - } - p { font-size: 20px; text-align: left; - color: black; } + } - button { - align-self: flex-end; + .group-input { + + input { + padding: 8px; + width: 100%; + outline: none; + border: none; + min-width: 100%; + border: 2px solid; + border-radius: 4px; + border-color: inherit; + background: white; + color: black; + height: 30; } } + + .select-users { + margin-right: 3; + max-height: 30; + width: 100%; + display: inline-flex; + flex-direction: row; + border: 2px solid; + border-radius: 4px; + border-color: inherit; + background: white; + color: black; + } } @@ -55,11 +70,14 @@ .group-interface { display: flex; flex-direction: column; + overflow: hidden; + padding: 10px; .overlay { transform: translate(-10px, -10px); width: 400px; height: 320px; + overflow: hidden; } .delete-button { @@ -81,7 +99,6 @@ font-size: 20px; text-align: left; margin-right: 15px; - color: black; } } @@ -94,18 +111,24 @@ margin-left: 5; width: 50px; cursor: pointer; + display: flex; + flex-direction: row; + } + + .style-divider { + width: 100%; + height: 1px; } .group-body { justify-content: space-between; - height: 220; - background-color: #e8e8e8; + height: 225; padding-right: 1em; justify-content: space-around; text-align: left; - overflow-y: auto; + overflow-y: hidden; width: 100%; .group-row { @@ -117,7 +140,6 @@ .group-name { max-width: 65%; margin: 0 10; - color: black; } .group-info { diff --git a/src/client/util/GroupManager.tsx b/src/client/util/GroupManager.tsx index da947aba6..5802d5ee0 100644 --- a/src/client/util/GroupManager.tsx +++ b/src/client/util/GroupManager.tsx @@ -15,6 +15,7 @@ import { SharingManager, User } from './SharingManager'; import { listSpec } from '../../fields/Schema'; import { DateField } from '../../fields/DateField'; import { Id } from '../../fields/FieldSymbols'; +import { Button, IconButton, Size } from 'browndash-components'; /** * Interface for options for the react-select component @@ -280,47 +281,59 @@ export class GroupManager extends React.Component<{}> { */ private get groupCreationModal() { const contents = ( - <div className="group-create"> + <div className="group-create" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> <div className="group-heading" style={{ marginBottom: 0 }}> <p> <b>New Group</b> </p> - <div - className={'close-button'} - onClick={action(() => { - this.createGroupModalOpen = false; - TaskCompletionBox.taskCompleted = false; - })}> - <FontAwesomeIcon icon={'times'} color={'black'} size={'lg'} /> + <div className="close-button"> + <Button + icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} + onClick={action(() => { + this.createGroupModalOpen = false; + TaskCompletionBox.taskCompleted = false; + })} + color={StrCast(Doc.UserDoc().userColor)} + /> </div> </div> - <input className="group-input" ref={this.inputRef} onKeyDown={this.handleKeyDown} autoFocus type="text" placeholder="Group name" onChange={action(() => (this.buttonColour = this.inputRef.current?.value ? 'black' : '#979797'))} /> - <Select - isMulti - options={this.options} - onChange={this.handleChange} - placeholder={'Select users'} - value={this.selectedUsers} - closeMenuOnSelect={false} - styles={{ - dropdownIndicator: (base, state) => ({ - ...base, - transition: '0.5s all ease', - transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : undefined, - }), - multiValue: base => ({ - ...base, - maxWidth: '50%', - - '&:hover': { - maxWidth: 'unset', - }, - }), - }} - /> - <button ref={this.createGroupButtonRef} onClick={this.createGroup} style={{ background: this.buttonColour }} disabled={this.buttonColour === '#979797'}> - Create - </button> + <div className="group-input" style={{border: StrCast(Doc.UserDoc().userColor)}} > + <input ref={this.inputRef} onKeyDown={this.handleKeyDown} autoFocus type="text" placeholder="Group name" onChange={action(() => (this.buttonColour = this.inputRef.current?.value ? 'black' : '#979797'))} /> + </div> + <div style={{border: StrCast(Doc.UserDoc().userColor)}}> + <Select + className="select-users" + isMulti + options={this.options} + onChange={this.handleChange} + placeholder={'Select users'} + value={this.selectedUsers} + closeMenuOnSelect={false} + styles={{ + control: () => ({ + display: 'inline-flex', + width: '100%', + }), + indicatorSeparator: () => ({ + display: 'inline-flex', + visibility: 'hidden', + }), + indicatorsContainer: () => ({ + display: 'inline-flex', + textDecorationColor: 'black', + }), + valueContainer: () => ({ + display: 'inline-flex', + fontStyle: StrCast(Doc.UserDoc().userColor), + color: StrCast(Doc.UserDoc().userColor), + width: '100%', + }), + }} + /> + </div> + <div className={"create-button"}> + <Button text={"Create"} type={"tertiary"} color={StrCast(Doc.UserDoc().userColor)} onClick={this.createGroup} /> + </div> </div> ); @@ -353,37 +366,34 @@ export class GroupManager extends React.Component<{}> { const groups = this.groupSort === 'ascending' ? this.allGroups.sort(sortGroups) : this.groupSort === 'descending' ? this.allGroups.sort(sortGroups).reverse() : this.allGroups; return ( - <div className="group-interface"> + <div className="group-interface" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> {this.groupCreationModal} {this.currentGroup ? <GroupMemberView group={this.currentGroup} onCloseButtonClick={action(() => (this.currentGroup = undefined))} /> : null} <div className="group-heading"> <p> <b>Manage Groups</b> </p> - <button onClick={action(() => (this.createGroupModalOpen = true))}> - <FontAwesomeIcon icon={'plus'} size={'sm'} /> Create Group - </button> - <div className={'close-button'} onClick={this.close}> - <FontAwesomeIcon icon={'times'} color={'black'} size={'lg'} /> + <Button icon={<FontAwesomeIcon icon={'plus'}/>} iconPlacement={'left'} text={"Create Group"} type={"tertiary"} color={StrCast(Doc.UserDoc().userColor)} onClick={action(() => (this.createGroupModalOpen = true))} /> + <div className={'close-button'} > + <Button + icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} + onClick={this.close} + color={StrCast(Doc.UserDoc().userColor)} + /> </div> </div> <div className="main-container"> <div className="sort-groups" onClick={action(() => (this.groupSort = this.groupSort === 'ascending' ? 'descending' : this.groupSort === 'descending' ? 'none' : 'ascending'))}> - Name{' '} - {this.groupSort === 'ascending' ? ( - <FontAwesomeIcon icon={'caret-up'} size={'xs'} /> - ) : this.groupSort === 'descending' ? ( - <FontAwesomeIcon icon={'caret-down'} size={'xs'} /> - ) : ( - <FontAwesomeIcon icon={'caret-right'} size={'xs'} /> - )} + Name + <IconButton icon={<FontAwesomeIcon icon={this.groupSort === 'ascending' ? 'caret-up' : this.groupSort === 'descending' ? 'caret-down' : 'caret-right'}/>} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} /> </div> - <div className="group-body"> + <div className={'style-divider'} style={{background: StrCast(Doc.UserDoc().userColor)}}/> + <div className="group-body" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> {groups.map(group => ( <div className="group-row" key={StrCast(group.title || group.groupName)}> <div className="group-name">{StrCast(group.title || group.groupName)}</div> <div className="group-info" onClick={action(() => (this.currentGroup = group))}> - <FontAwesomeIcon icon={'info-circle'} color={'#e8e8e8'} size={'sm'} style={{ backgroundColor: '#1e89d7', borderRadius: '100%', border: '1px solid #1e89d7' }} /> + <IconButton icon={<FontAwesomeIcon icon={'info-circle'} />} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} onClick={action(() => (this.currentGroup = group))} /> </div> </div> ))} diff --git a/src/client/util/GroupMemberView.scss b/src/client/util/GroupMemberView.scss index 2eb164988..d50569b26 100644 --- a/src/client/util/GroupMemberView.scss +++ b/src/client/util/GroupMemberView.scss @@ -1,6 +1,9 @@ .editing-interface { width: 100%; height: 100%; + padding: 10px; + float: right; + overflow: visible; hr { margin-top: 20; @@ -10,7 +13,6 @@ outline: none; border-radius: 5px; border: 0px; - color: #fcfbf7; text-transform: none; letter-spacing: 2px; font-size: 75%; @@ -37,8 +39,6 @@ text-align: center; border: none; outline: none; - color: black; - margin-top: -5; height: 20; text-overflow: ellipsis; background: none; @@ -61,21 +61,36 @@ margin-bottom: 25; .add-member-dropdown { - width: 65%; - margin: 0 5; + margin-right: 3; + max-height: 30; + width: 250px; + display: inline-flex; + flex-direction: row; + border: 2px solid; + border-radius: 4px; + border-color: inherit; + background: white; + color: black; + } - input { - height: 30; - } + .delete-button { + align-self: center; + background: inherit; } } } + .style-divider { + width: 98%; + height: 1px; + margin-top: 20px; + margin-bottom: 20px; + } + .editing-contents { overflow-y: auto; height: 62%; width: 100%; - color: black; margin-top: -15px; .editing-row { diff --git a/src/client/util/GroupMemberView.tsx b/src/client/util/GroupMemberView.tsx index b7f89794d..f2050dc61 100644 --- a/src/client/util/GroupMemberView.tsx +++ b/src/client/util/GroupMemberView.tsx @@ -8,6 +8,7 @@ import { StrCast } from "../../fields/Types"; import { MainViewModal } from "../views/MainViewModal"; import { GroupManager, UserOptions } from "./GroupManager"; import "./GroupMemberView.scss"; +import { Button, IconButton, Size } from "browndash-components"; interface GroupMemberViewProps { group: Doc; @@ -28,7 +29,7 @@ export class GroupMemberView extends React.Component<GroupMemberViewProps> { const hasEditAccess = GroupManager.Instance.hasEditAccess(this.props.group); return (!this.props.group ? null : - <div className="editing-interface"> + <div className="editing-interface" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> <div className="editing-header"> <input className="group-title" @@ -38,28 +39,48 @@ export class GroupMemberView extends React.Component<GroupMemberViewProps> { disabled={!hasEditAccess} > </input> - <div className={"memberView-closeButton"} onClick={action(this.props.onCloseButtonClick)}> - <FontAwesomeIcon icon={"times"} color={"black"} size={"lg"} /> + <div className={"memberView-closeButton"} > + <Button + icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} + onClick={action(this.props.onCloseButtonClick)} + color={StrCast(Doc.UserDoc().userColor)} + /> </div> {GroupManager.Instance.hasEditAccess(this.props.group) ? <div className="group-buttons"> - <div className="add-member-dropdown"> - <Select + <div style={{border: StrCast(Doc.UserDoc().userColor)}} > + <Select + className="add-member-dropdown" isSearchable={true} options={options} onChange={selectedOption => GroupManager.Instance.addMemberToGroup(this.props.group, (selectedOption as UserOptions).value)} placeholder={"Add members"} value={null} styles={{ - dropdownIndicator: (base, state) => ({ - ...base, - transition: '0.5s all ease', - transform: state.selectProps.menuIsOpen ? 'rotate(180deg)' : undefined - }) + control: () => ({ + display: 'inline-flex', + width: '100%', + }), + indicatorSeparator: () => ({ + display: 'inline-flex', + visibility: 'hidden', + }), + indicatorsContainer: () => ({ + display: 'inline-flex', + textDecorationColor: 'black', + }), + valueContainer: () => ({ + display: 'inline-flex', + fontStyle: StrCast(Doc.UserDoc().userColor), + color: StrCast(Doc.UserDoc().userColor), + width: '100%', + }), }} /> </div> - <button onClick={() => GroupManager.Instance.deleteGroup(this.props.group)}>Delete group</button> + <div className={"delete-button"}> + <Button text={"Delete Group"} type={"tertiary"} color={StrCast(Doc.UserDoc().userColor)} onClick={() => GroupManager.Instance.deleteGroup(this.props.group)} /> + </div> </div> : null} <div @@ -69,7 +90,7 @@ export class GroupMemberView extends React.Component<GroupMemberViewProps> { Emails {this.memberSort === "ascending" ? "↑" : this.memberSort === "descending" ? "↓" : ""} {/* → */} </div> </div> - <hr /> + <div className={'style-divider'} style={{background: StrCast(Doc.UserDoc().userColor)}}/> <div className="editing-contents" style={{ height: hasEditAccess ? "62%" : "85%" }} > @@ -83,7 +104,7 @@ export class GroupMemberView extends React.Component<GroupMemberViewProps> { </div> {hasEditAccess ? <div className={"remove-button"} onClick={() => GroupManager.Instance.removeMemberFromGroup(this.props.group, member)}> - <FontAwesomeIcon icon={"trash-alt"} size={"sm"} /> + <IconButton icon={<FontAwesomeIcon icon={'trash-alt'} />} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} onClick={() => GroupManager.Instance.removeMemberFromGroup(this.props.group, member)} /> </div> : null} </div> diff --git a/src/client/util/InteractionUtils.tsx b/src/client/util/InteractionUtils.tsx index 043f0f1f3..d0f459291 100644 --- a/src/client/util/InteractionUtils.tsx +++ b/src/client/util/InteractionUtils.tsx @@ -178,7 +178,7 @@ export namespace InteractionUtils { filter: mask ? `url(#mask${defGuid})` : undefined, opacity: 1.0, // opacity: strokeWidth !== width ? 0.5 : undefined, - pointerEvents: pevents as any, + pointerEvents: (pevents as any) === 'all' ? 'visiblepainted' : (pevents as any), stroke: color ?? 'rgb(0, 0, 0)', strokeWidth: strokeWidth, strokeLinecap: lineCap as any, diff --git a/src/client/util/ReportManager.scss b/src/client/util/ReportManager.scss deleted file mode 100644 index 5a2f2fcad..000000000 --- a/src/client/util/ReportManager.scss +++ /dev/null @@ -1,88 +0,0 @@ -@import '../views/global/globalCssVariables'; - -.issue-list-wrapper { - position: relative; - min-width: 250px; - background-color: $light-blue; - overflow-y: scroll; -} - -.issue-list { - display: flex; - align-items: center; - justify-content: space-between; - padding: 5px; - margin: 5px; - border-radius: 5px; - border: 1px solid grey; - background-color: lightgoldenrodyellow; -} - -// issue should pop up when the user hover over the issue -.issue-list:hover { - box-shadow: 2px; - cursor: pointer; - border: 3px solid #252b33; -} - -.issue-content { - background-color: white; - padding: 10px; - flex: 1 1 auto; - overflow-y: scroll; -} - -.issue-title { - font-size: 20px; - font-weight: 600; - color: black; -} - -.issue-body { - padding: 0 10px; - width: 100%; - text-align: left; -} - -.issue-body > * { - margin-top: 5px; -} - -.issue-body img, -.issue-body video { - display: block; - max-width: 100%; -} - -.report-issue-fab { - position: fixed; - bottom: 20px; - right: 20px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; -} - -.loading-center { - margin: auto 0; -} - -.settings-content label { - margin-top: 10px; -} - -.report-disclaimer { - font-size: 8px; - color: grey; - padding-right: 50px; - font-style: italic; - text-align: left; -} - -.flex-select { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; -} diff --git a/src/client/util/ReportManager.tsx b/src/client/util/ReportManager.tsx deleted file mode 100644 index 89c17e42f..000000000 --- a/src/client/util/ReportManager.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, runInAction } from 'mobx'; -import { observer } from 'mobx-react'; -import * as React from 'react'; -import { ColorState, SketchPicker } from 'react-color'; -import { Doc } from '../../fields/Doc'; -import { Id } from '../../fields/FieldSymbols'; -import { BoolCast, Cast, StrCast } from '../../fields/Types'; -import { addStyleSheet, addStyleSheetRule, Utils } from '../../Utils'; -import { GoogleAuthenticationManager } from '../apis/GoogleAuthenticationManager'; -import { DocServer } from '../DocServer'; -import { Networking } from '../Network'; -import { MainViewModal } from '../views/MainViewModal'; -import { FontIconBox } from '../views/nodes/FontIconBox/FontIconBox'; -import { DragManager } from './DragManager'; -import { GroupManager } from './GroupManager'; -import './SettingsManager.scss'; -import './ReportManager.scss'; -import { undoBatch } from './UndoManager'; -import { Octokit } from "@octokit/core"; -import { CheckBox } from '../views/search/CheckBox'; -import ReactLoading from 'react-loading'; -import ReactMarkdown from 'react-markdown'; -import rehypeRaw from 'rehype-raw'; -import remarkGfm from 'remark-gfm'; -const higflyout = require('@hig/flyout'); -export const { anchorPoints } = higflyout; -export const Flyout = higflyout.default; - -@observer -export class ReportManager extends React.Component<{}> { - public static Instance: ReportManager; - @observable private isOpen = false; - - private octokit: Octokit; - - @observable public issues: any[] = []; - @action setIssues = action((issues: any[]) => { this.issues = issues; }); - - // undefined is the default - null is if the user is making an issue - @observable public selectedIssue: any = undefined; - @action setSelectedIssue = action((issue: any) => { this.selectedIssue = issue; }); - - // only get the open issues - @observable public shownIssues = this.issues.filter(issue => issue.state === 'open'); - - public updateIssueSearch = action((query: string = '') => { - if (query === '') { - this.shownIssues = this.issues.filter(issue => issue.state === 'open'); - return; - } - this.shownIssues = this.issues.filter(issue => issue.title.toLowerCase().includes(query.toLowerCase())); - }); - - constructor(props: {}) { - super(props); - ReportManager.Instance = this; - - this.octokit = new Octokit({ - auth: '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' - }); - } - - public close = action(() => (this.isOpen = false)); - public open = action(() => { - if (this.issues.length === 0) { - // load in the issues if not already loaded - this.getAllIssues() - .then(issues => { - this.setIssues(issues); - this.updateIssueSearch(); - }) - .catch(err => console.log(err)); - } - (this.isOpen = true) - }); - - @observable private bugTitle = ''; - @action setBugTitle = action((title: string) => { this.bugTitle = title; }); - @observable private bugDescription = ''; - @action setBugDescription = action((description: string) => { this.bugDescription = description; }); - @observable private bugType = ''; - @action setBugType = action((type: string) => { this.bugType = type; }); - @observable private bugPriority = ''; - @action setBugPriority = action((priortiy: string) => { this.bugPriority = priortiy; }); - - // private toGithub = false; - // will always be set to true - no alterntive option yet - private toGithub = true; - - private formatTitle = (title: string, userEmail: string) => `${title} - ${userEmail.replace('@brown.edu', '')}`; - - public async getAllIssues() : Promise<any[]> { - const res = await this.octokit.request('GET /repos/{owner}/{repo}/issues', { - owner: 'brown-dash', - repo: 'Dash-Web', - }); - - // 200 status means success - if (res.status === 200) { - return res.data; - } else { - throw new Error('Error getting issues'); - } - } - - // turns an upload link into a servable link - // ex: - // C: /Users/dash/Documents/GitHub/Dash-Web/src/server/public/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2.png - // -> http://localhost:1050/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2_l.png - private fileLinktoServerLink = (fileLink: string) => { - const serverUrl = 'https://browndash.com/'; - - const regex = 'public' - const publicIndex = fileLink.indexOf(regex) + regex.length; - - const finalUrl = `${serverUrl}${fileLink.substring(publicIndex + 1).replace('.', '_l.')}`; - return finalUrl; - } - - public async reportIssue() { - if (this.bugTitle === '' || this.bugDescription === '' - || this.bugType === '' || this.bugPriority === '') { - alert('Please fill out all required fields to report an issue.'); - return; - } - - if (this.toGithub) { - - const formattedLinks = (this.fileLinks ?? []).map(this.fileLinktoServerLink) - - const req = await this.octokit.request('POST /repos/{owner}/{repo}/issues', { - owner: 'brown-dash', - repo: 'Dash-Web', - title: this.formatTitle(this.bugTitle, Doc.CurrentUserEmail), - body: `${this.bugDescription} \n\nfiles:\n${formattedLinks.join('\n')}`, - labels: [ - 'from-dash-app', - this.bugType, - this.bugPriority - ] - }); - - // 201 status means success - if (req.status !== 201) { - alert('Error creating issue on github.'); - // on error, don't close the modal - return; - } - } - else { - // if not going to github issues, not sure what to do yet... - } - - // if we're down here, then we're good to go. reset the fields. - this.setBugTitle(''); - this.setBugDescription(''); - // this.toGithub = false; - this.setFileLinks([]); - this.setBugType(''); - this.setBugPriority(''); - this.close(); - } - - @observable public fileLinks: any = []; - @action setFileLinks = action((links: any) => { this.fileLinks = links; }); - - private getServerPath = (link: any) => { return link.result.accessPaths.agnostic.server } - - private uploadFiles = (input: any) => { - // keep null while uploading - this.setFileLinks(null); - // upload the files to the server - if (input.files && input.files.length !== 0) { - const fileArray: File[] = Array.from(input.files); - (Networking.UploadFilesToServer(fileArray.map(file =>({file})))).then(links => { - console.log('finshed uploading', links.map(this.getServerPath)); - this.setFileLinks((links ?? []).map(this.getServerPath)); - }) - } - - } - - - private renderIssue = (issue: any) => { - - const isReportingIssue = issue === null; - - return isReportingIssue ? - // report issue - (<div className="settings-content"> - <h3 style={{ 'textDecoration': 'underline'}}>Report an Issue</h3> - <label>Please leave a title for the bug.</label><br /> - <input type="text" placeholder='title' onChange={(e) => this.setBugTitle(e.target.value)} required/> - <br /> - <label>Please leave a description for the bug and how it can be recreated.</label> - <textarea placeholder='description' onChange={(e) => this.setBugDescription(e.target.value)} required/> - <br /> - {/* {<label>Send to github issues? </label> - <input type="checkbox" onChange={(e) => this.toGithub = e.target.checked} /> - <br /> } */} - - <label>Please label the issue</label> - <div className='flex-select'> - <select name="bugType" onChange={e => this.bugType = e.target.value}> - <option value="" disabled selected>Type</option> - <option value="bug">Bug</option> - <option value="cosmetic">Poor Design or Cosmetic</option> - <option value="documentation">Poor Documentation</option> - </select> - - <select name="bigPriority" onChange={e => this.bugPriority = e.target.value}> - <option value="" disabled selected>Priority</option> - <option value="priority-low">Low</option> - <option value="priority-medium">Medium</option> - <option value="priority-high">High</option> - </select> - </div> - - - <div> - <label>Upload media that shows the bug (optional)</label> - <input type="file" name="file" multiple accept='audio/*, video/*, image/*' onChange={e => this.uploadFiles(e.target)}/> - </div> - <br /> - - <button onClick={() => this.reportIssue()} disabled={this.fileLinks === null} style={{ backgroundColor: this.fileLinks === null ? 'grey' : '' }}>{this.fileLinks === null ? 'Uploading...' : 'Submit'}</button> - </div>) - : - // view issue - ( - <div className='issue-container'> - <h5 style={{'textAlign': "left"}}><a href={issue.html_url} target="_blank">Issue #{issue.number}</a></h5> - <div className='issue-title'> - {issue.title} - </div> - <ReactMarkdown children={issue.body} className='issue-body' linkTarget={"_blank"} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> - </div> - ); - } - - private showReportIssueScreen = () => { - this.setSelectedIssue(null); - } - - private closeReportIssueScreen = () => { - this.setSelectedIssue(undefined); - } - - private get reportInterface() { - - const isReportingIssue = this.selectedIssue === null; - - return ( - <div className="settings-interface"> - <div className='issue-list-wrapper'> - <h3>Current Issues</h3> - <input type="text" placeholder='search issues' onChange={(e => this.updateIssueSearch(e.target.value))}></input><br /> - {this.issues.length === 0 ? <ReactLoading className='loading-center'/> : this.shownIssues.map(issue => <div className='issue-list' key={issue.number} onClick={() => this.setSelectedIssue(issue)}>{issue.title}</div>)} - - {/* <div className="settings-user"> - <button onClick={() => this.getAllIssues().then(issues => this.issues = issues)}>Poll Issues</button> - </div> */} - </div> - - <div className="close-button" onClick={this.close}> - <FontAwesomeIcon icon={'times'} color="black" size={'lg'} /> - </div> - - <div className="issue-content" style={{'paddingTop' : this.selectedIssue === undefined ? '50px' : 'inherit'}}> - {this.selectedIssue === undefined ? "no issue selected" : this.renderIssue(this.selectedIssue)} - </div> - - <div className='report-issue-fab'> - <span className='report-disclaimer' hidden={!isReportingIssue}>Note: issue reporting is not anonymous.</span> - <button - onClick={() => isReportingIssue ? this.closeReportIssueScreen() : this.showReportIssueScreen()} - >{isReportingIssue ? 'Cancel' : 'Report New Issue'}</button> - </div> - - - </div> - ); - } - - render() { - return ( - <MainViewModal - contents={this.reportInterface} - isDisplayed={this.isOpen} - interactive={true} - closeOnExternalClick={this.close} - dialogueBoxStyle={{ width: 'auto', height: '500px', background: Cast(Doc.UserDoc().userColor, 'string', null) }} - /> - ); - } -} diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx index b6df5f26a..b8e327968 100644 --- a/src/client/util/SettingsManager.tsx +++ b/src/client/util/SettingsManager.tsx @@ -17,8 +17,8 @@ import { GroupManager } from './GroupManager'; import './SettingsManager.scss'; import { undoBatch } from './UndoManager'; import { Button, ColorPicker, Dropdown, DropdownType, EditableText, Group, NumberDropdown, Size, Toggle, ToggleType, Type } from 'browndash-components'; -import { BsGoogle } from 'react-icons/bs' -import { FaFillDrip, FaPalette } from 'react-icons/fa' +import { BsGoogle } from 'react-icons/bs'; +import { FaFillDrip, FaPalette } from 'react-icons/fa'; const higflyout = require('@hig/flyout'); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -26,8 +26,10 @@ export const Flyout = higflyout.default; export enum ColorScheme { Dark = 'Dark', Light = 'Light', + CoolBlue = 'Cool Blue', + Cupcake = 'Cupcake', System = 'Match System', - Custom = 'Custom' + Custom = 'Custom', } export enum freeformScrollMode { @@ -78,15 +80,15 @@ export class SettingsManager extends React.Component<{}> { }; @computed get userColor() { - return StrCast(Doc.UserDoc().userColor) + return StrCast(Doc.UserDoc().userColor); } @computed get userVariantColor() { - return StrCast(Doc.UserDoc().userVariantColor) + return StrCast(Doc.UserDoc().userVariantColor); } @computed get userBackgroundColor() { - return StrCast(Doc.UserDoc().userBackgroundColor) + return StrCast(Doc.UserDoc().userBackgroundColor); } @undoBatch selectUserMode = action((mode: string) => (Doc.noviceMode = mode === 'Novice')); @@ -117,14 +119,24 @@ export class SettingsManager extends React.Component<{}> { Doc.UserDoc().userTheme = scheme; switch (scheme) { case ColorScheme.Light: - this.switchUserColor("#323232") - this.switchUserBackgroundColor("#DFDFDF") - this.switchUserVariantColor("#BDDDF5") + this.switchUserColor('#323232'); + this.switchUserBackgroundColor('#DFDFDF'); + this.switchUserVariantColor('#BDDDF5'); break; case ColorScheme.Dark: - this.switchUserColor("#DFDFDF") - this.switchUserBackgroundColor("#323232") - this.switchUserVariantColor("#4476F7") + this.switchUserColor('#DFDFDF'); + this.switchUserBackgroundColor('#323232'); + this.switchUserVariantColor('#4476F7'); + break; + case ColorScheme.CoolBlue: + this.switchUserColor('#ADEAFF'); + this.switchUserBackgroundColor('#060A15'); + this.switchUserVariantColor('#3C51FF'); + break; + case ColorScheme.Cupcake: + this.switchUserColor('#3BC7FF'); + this.switchUserBackgroundColor('#fffdf7'); + this.switchUserVariantColor('#FFD7F3'); break; case ColorScheme.Custom: break; @@ -138,34 +150,32 @@ export class SettingsManager extends React.Component<{}> { }); @computed get colorsContent() { - - - const colorSchemes = [ColorScheme.Light, ColorScheme.Dark, ColorScheme.Custom, ColorScheme.System]; - const schemeMap = ['Light', 'Dark', 'Custom', 'Match System']; + const colorSchemes = [ColorScheme.Light, ColorScheme.Dark, ColorScheme.Cupcake, ColorScheme.CoolBlue, ColorScheme.Custom, ColorScheme.System]; + const schemeMap = ['Light', 'Dark', 'Cupcake', 'Cool Blue', 'Custom', 'Match System']; const userTheme = StrCast(Doc.UserDoc().userTheme); return ( - <div style={{width: '100%'}}> - <Dropdown - formLabel='Theme' + <div style={{ width: '100%' }}> + <Dropdown + formLabel="Theme" size={Size.SMALL} type={Type.TERT} selectedVal={userTheme} - setSelectedVal={(scheme) => this.changeColorScheme(scheme as string)} - items={colorSchemes.map((scheme, i) => ( - { - text: schemeMap[i], - val: scheme - } - ))} + setSelectedVal={scheme => this.changeColorScheme(scheme as string)} + items={colorSchemes.map((scheme, i) => ({ + text: schemeMap[i], + val: scheme, + }))} dropdownType={DropdownType.SELECT} color={this.userColor} fillWidth /> - {userTheme === ColorScheme.Custom && <Group formLabel='Custom Theme'> - <ColorPicker tooltip={'User Color'} color={this.userColor} type={Type.SEC} icon={<FaFillDrip/>} selectedColor={this.userColor} setSelectedColor={this.switchUserColor}/> - <ColorPicker tooltip={'User Background Color'} color={this.userColor} type={Type.SEC} icon={<FaPalette/>} selectedColor={this.userBackgroundColor} setSelectedColor={this.switchUserBackgroundColor}/> - <ColorPicker tooltip={'User Variant Color'} color={this.userColor} type={Type.SEC} icon={<FaPalette/>} selectedColor={this.userVariantColor} setSelectedColor={this.switchUserVariantColor}/> - </Group>} + {userTheme === ColorScheme.Custom && ( + <Group formLabel="Custom Theme"> + <ColorPicker tooltip={'User Color'} color={this.userColor} type={Type.SEC} icon={<FaFillDrip />} selectedColor={this.userColor} setSelectedColor={this.switchUserColor} /> + <ColorPicker tooltip={'User Background Color'} color={this.userColor} type={Type.SEC} icon={<FaPalette />} selectedColor={this.userBackgroundColor} setSelectedColor={this.switchUserBackgroundColor} /> + <ColorPicker tooltip={'User Variant Color'} color={this.userColor} type={Type.SEC} icon={<FaPalette />} selectedColor={this.userVariantColor} setSelectedColor={this.switchUserVariantColor} /> + </Group> + )} </div> ); } @@ -173,64 +183,59 @@ export class SettingsManager extends React.Component<{}> { @computed get formatsContent() { return ( <div className="prefs-content"> - <Toggle - formLabel={'Show document header'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => (Doc.UserDoc().layout_showTitle = Doc.UserDoc().layout_showTitle ? undefined : 'author_date')} - toggleStatus={Doc.UserDoc().layout_showTitle !== undefined} size={Size.XSMALL} + <Toggle + formLabel={'Show document header'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => (Doc.UserDoc().layout_showTitle = Doc.UserDoc().layout_showTitle ? undefined : 'author_date')} + toggleStatus={Doc.UserDoc().layout_showTitle !== undefined} + size={Size.XSMALL} color={this.userColor} - /> - <Toggle - formLabel={'Show Full Toolbar'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => (Doc.UserDoc()['documentLinksButton-fullMenu'] = !Doc.UserDoc()['documentLinksButton-fullMenu'])} - toggleStatus={BoolCast(Doc.UserDoc()['documentLinksButton-fullMenu'])} + <Toggle + formLabel={'Show Full Toolbar'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => (Doc.UserDoc()['documentLinksButton-fullMenu'] = !Doc.UserDoc()['documentLinksButton-fullMenu'])} + toggleStatus={BoolCast(Doc.UserDoc()['documentLinksButton-fullMenu'])} size={Size.XSMALL} color={this.userColor} - /> - <Toggle - formLabel={'Show Button Labels'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => FontIconBox.SetShowLabels(!FontIconBox.GetShowLabels())} - toggleStatus={FontIconBox.GetShowLabels()} + <Toggle + formLabel={'Show Button Labels'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => FontIconBox.SetShowLabels(!FontIconBox.GetShowLabels())} + toggleStatus={FontIconBox.GetShowLabels()} size={Size.XSMALL} color={this.userColor} - /> - <Toggle - formLabel={'Recognize Ink Gestures'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => FontIconBox.SetRecognizeGestures(!FontIconBox.GetRecognizeGestures())} - toggleStatus={FontIconBox.GetRecognizeGestures()} + <Toggle + formLabel={'Recognize Ink Gestures'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => FontIconBox.SetRecognizeGestures(!FontIconBox.GetRecognizeGestures())} + toggleStatus={FontIconBox.GetRecognizeGestures()} size={Size.XSMALL} color={this.userColor} - /> - <Toggle - formLabel={'Hide Labels In Ink Shapes'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => (Doc.UserDoc().activeInkHideTextLabels = !Doc.UserDoc().activeInkHideTextLabels)} - toggleStatus={BoolCast(Doc.UserDoc().activeInkHideTextLabels)} + <Toggle + formLabel={'Hide Labels In Ink Shapes'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => (Doc.UserDoc().activeInkHideTextLabels = !Doc.UserDoc().activeInkHideTextLabels)} + toggleStatus={BoolCast(Doc.UserDoc().activeInkHideTextLabels)} size={Size.XSMALL} color={this.userColor} - /> - <Toggle - formLabel={'Open Ink Docs in Lightbox'} - formLabelPlacement={'right'} - toggleType={ToggleType.SWITCH} - onClick={e => (Doc.UserDoc().openInkInLightbox = !Doc.UserDoc().openInkInLightbox)} - toggleStatus={BoolCast(Doc.UserDoc().openInkInLightbox)} + <Toggle + formLabel={'Open Ink Docs in Lightbox'} + formLabelPlacement={'right'} + toggleType={ToggleType.SWITCH} + onClick={e => (Doc.UserDoc().openInkInLightbox = !Doc.UserDoc().openInkInLightbox)} + toggleStatus={BoolCast(Doc.UserDoc().openInkInLightbox)} size={Size.XSMALL} color={this.userColor} - /> </div> ); @@ -262,29 +267,23 @@ export class SettingsManager extends React.Component<{}> { <div className="tab-column-content"> {/* <NumberInput/> */} <Group formLabel={'Default Font'}> - <NumberDropdown - color={this.userColor} - numberDropdownType={'input'} - min={0} max={50} step={2} - type={Type.TERT} - number={0} - unit={"px"} - setNumber={() => {}} - /> - <Dropdown - items={fontFamilies.map((val) => { + <NumberDropdown color={this.userColor} numberDropdownType={'input'} min={0} max={50} step={2} type={Type.TERT} number={0} unit={'px'} setNumber={() => {}} /> + <Dropdown + items={fontFamilies.map(val => { return { text: val, val: val, style: { - fontFamily: val - } - } - })} + fontFamily: val, + }, + }; + })} dropdownType={DropdownType.SELECT} type={Type.TERT} selectedVal={StrCast(Doc.UserDoc().fontFamily)} - setSelectedVal={(val) => {this.changeFontFamily(val as string)}} + setSelectedVal={val => { + this.changeFontFamily(val as string); + }} color={this.userColor} fillWidth /> @@ -313,33 +312,12 @@ export class SettingsManager extends React.Component<{}> { @computed get passwordContent() { return ( <div className="password-content"> - <EditableText placeholder="Current password" - type={Type.SEC} - color={this.userColor} - val={""} - setVal={val => this.changeVal(val as string, 'curr')} - fillWidth - password - /> - <EditableText placeholder="New password" - type={Type.SEC} - color={this.userColor} - val={""} - setVal={val => this.changeVal(val as string, 'new')} - fillWidth - password - /> - <EditableText placeholder="Confirm new password" - type={Type.SEC} - color={this.userColor} - val={""} - setVal={val => this.changeVal(val as string, 'conf')} - fillWidth - password - /> + <EditableText placeholder="Current password" type={Type.SEC} color={this.userColor} val={''} setVal={val => this.changeVal(val as string, 'curr')} fillWidth password /> + <EditableText placeholder="New password" type={Type.SEC} color={this.userColor} val={''} setVal={val => this.changeVal(val as string, 'new')} fillWidth password /> + <EditableText placeholder="Confirm new password" type={Type.SEC} color={this.userColor} val={''} setVal={val => this.changeVal(val as string, 'conf')} fillWidth password /> {!this.passwordResultText ? null : <div className={`${this.passwordResultText.startsWith('Error') ? 'error' : 'success'}-text`}>{this.passwordResultText}</div>} - <Button type={Type.SEC} text={'Forgot Password'} color={this.userColor}/> - <Button type={Type.TERT} text={'Submit'} onClick={this.changePassword} color={this.userColor}/> + <Button type={Type.SEC} text={'Forgot Password'} color={this.userColor} /> + <Button type={Type.TERT} text={'Submit'} onClick={this.changePassword} color={this.userColor} /> </div> ); } @@ -347,7 +325,7 @@ export class SettingsManager extends React.Component<{}> { @computed get accountOthersContent() { return ( <div className="account-others-content"> - <Button type={Type.TERT} text={'Connect to Google'} iconPlacement='left' icon={<BsGoogle/>} onClick={() => this.googleAuthorize()}/> + <Button type={Type.TERT} text={'Connect to Google'} iconPlacement="left" icon={<BsGoogle />} onClick={() => this.googleAuthorize()} /> </div> ); } @@ -377,59 +355,56 @@ export class SettingsManager extends React.Component<{}> { <div className="tab-column"> <div className="tab-column-title">Modes</div> <div className="tab-column-content"> - <Dropdown - formLabel={"Mode"} + <Dropdown + formLabel={'Mode'} items={[ { text: 'Novice', description: 'Novice mode is a user-friendly setting designed to cater to those who are new to Dash', - val: "Novice" + val: 'Novice', }, { text: 'Developer', - description: 'Developer mode is an advanced setting that grants you greater control and access to the underlying mechanics and tools of a software or system. Developer mode is still under development as there are experimental features.', - val: "Developer" + description: + 'Developer mode is an advanced setting that grants you greater control and access to the underlying mechanics and tools of a software or system. Developer mode is still under development as there are experimental features.', + val: 'Developer', }, - ]} + ]} selectedVal={Doc.noviceMode ? 'Novice' : 'Developer'} - setSelectedVal={(val) => {this.selectUserMode(val as string)}} + setSelectedVal={val => { + this.selectUserMode(val as string); + }} dropdownType={DropdownType.SELECT} type={Type.TERT} - placement='bottom-start' + placement="bottom-start" color={this.userColor} fillWidth /> - <Toggle - formLabel={'Playground Mode'} - toggleType={ToggleType.SWITCH} - toggleStatus={this.playgroundMode} - onClick={this.playgroundModeToggle} - color={this.userColor} - /> + <Toggle formLabel={'Playground Mode'} toggleType={ToggleType.SWITCH} toggleStatus={this.playgroundMode} onClick={this.playgroundModeToggle} color={this.userColor} /> </div> <div className="tab-column-title" style={{ marginTop: 20, marginBottom: 10 }}> Freeform Navigation </div> <div className="tab-column-content"> - <Dropdown - formLabel={"Scroll Mode"} + <Dropdown + formLabel={'Scroll Mode'} items={[ { text: 'Scroll to Pan', description: 'Scrolling pans canvas, shift + scrolling zooms', - val: freeformScrollMode.Pan + val: freeformScrollMode.Pan, }, { text: 'Scroll to Zoom', description: 'Scrolling zooms canvas', - val: freeformScrollMode.Zoom + val: freeformScrollMode.Zoom, }, - ]} + ]} selectedVal={StrCast(Doc.UserDoc().freeformScrollMode)} - setSelectedVal={(val) => this.setFreeformScrollMode(val as string)} + setSelectedVal={val => this.setFreeformScrollMode(val as string)} dropdownType={DropdownType.SELECT} type={Type.TERT} - placement='bottom-start' + placement="bottom-start" color={this.userColor} /> </div> @@ -437,18 +412,8 @@ export class SettingsManager extends React.Component<{}> { <div className="tab-column"> <div className="tab-column-title">Permissions</div> <div className="tab-column-content"> - <Button - text={"Manage Groups"} - type={Type.TERT} - onClick={() => GroupManager.Instance?.open()} - color={this.userColor} - /> - <Toggle - toggleType={ToggleType.SWITCH} - formLabel={"Default access private"} - color={this.userColor} - toggleStatus={BoolCast(Doc.defaultAclPrivate)} - onClick={action(() => (Doc.defaultAclPrivate = !Doc.defaultAclPrivate))}/> + <Button text={'Manage Groups'} type={Type.TERT} onClick={() => GroupManager.Instance?.open()} color={this.userColor} /> + <Toggle toggleType={ToggleType.SWITCH} formLabel={'Default access private'} color={this.userColor} toggleStatus={BoolCast(Doc.defaultAclPrivate)} onClick={action(() => (Doc.defaultAclPrivate = !Doc.defaultAclPrivate))} /> </div> </div> </div> @@ -470,45 +435,35 @@ export class SettingsManager extends React.Component<{}> { <div className="settings-panel" style={{ background: this.userColor }}> <div className="settings-tabs"> {tabs.map(tab => { - const isActive = this.activeTab === tab.title + const isActive = this.activeTab === tab.title; return ( - <div key={tab.title} + <div + key={tab.title} style={{ background: isActive ? this.userBackgroundColor : this.userColor, color: isActive ? this.userColor : this.userBackgroundColor, }} - className={'tab-control ' + (isActive ? 'active' : 'inactive')} - onClick={action(() => (this.activeTab = tab.title)) - }> + className={'tab-control ' + (isActive ? 'active' : 'inactive')} + onClick={action(() => (this.activeTab = tab.title))}> {tab.title} </div> - ) + ); })} </div> <div className="settings-user"> - <div className="settings-username" - style={{color: this.userBackgroundColor}} - >{Doc.CurrentUserEmail}</div> - <Button - text={Doc.GuestDashboard ? 'Exit' : 'Log Out'} - type={Type.TERT} - color={this.userVariantColor} - onClick={() => window.location.assign(Utils.prepend('/logout'))} - /> + <div className="settings-username" style={{ color: this.userBackgroundColor }}> + {Doc.CurrentUserEmail} + </div> + <Button text={Doc.GuestDashboard ? 'Exit' : 'Log Out'} type={Type.TERT} color={this.userVariantColor} onClick={() => window.location.assign(Utils.prepend('/logout'))} /> </div> </div> - <div className="close-button"> - <Button - icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} - onClick={this.close} - color={this.userColor} - /> + <Button icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} onClick={this.close} color={this.userColor} /> </div> - <div className="settings-content" style={{color: this.userColor, background: this.userBackgroundColor}}> + <div className="settings-content" style={{ color: this.userColor, background: this.userBackgroundColor }}> {tabs.map(tab => ( <div key={tab.title} className={'tab-section ' + (this.activeTab === tab.title ? 'active' : 'inactive')}> {tab.ele} diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss index b11e694ff..c9f86f0e1 100644 --- a/src/client/util/SharingManager.scss +++ b/src/client/util/SharingManager.scss @@ -2,10 +2,6 @@ width: 600px; // height: 360px; - .overlay { - transform: translate(-20px, -20px); - } - .select { text-align: justify; text-align-last: end @@ -33,12 +29,7 @@ } .share-copy-link { - display: inline; border-radius: 4px; - border: solid gray 1px; - font-size: x-small; - background: #E8E8E8; - color: black; margin-top: -15px; margin-bottom: 15px; width: fit-content; @@ -50,13 +41,22 @@ display: flex; margin-bottom: 20px; align-items: center; - height: 36; + max-height: 28; .user-search { + max-height: 30; width: 90%; + display: inline-flex; + flex-direction: row; + border: 2px solid; + border-radius: 4px; + border-color: inherit; + background: white; + color: black; input { - height: 30; + height: 20px; + width: 100px; } } @@ -68,14 +68,12 @@ text-align-last: end; font-size: 13px; min-width: 90px; - height: 36; + height: 28; margin-left: 2px; } .share-button { - height: 36; margin-left: 3%; - background-color: black; } } @@ -131,6 +129,7 @@ display: flex; top:0; flex-direction: column; + border-radius: 4px; .user-sort { text-align: left; @@ -147,11 +146,12 @@ .groups-list, .users-list { font-style: italic; - background: #e8e8e8; - border: 2px solid gray; + border: 2px solid; padding-left: 10px; padding-right: 10px; width: 97%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; overflow-y: scroll; overflow-x: hidden; text-align: left; @@ -160,7 +160,6 @@ align-items: center; text-align: center; justify-content: center; - color: black; height: 250px; margin: 0 2; @@ -175,7 +174,6 @@ outline: none; border-radius: 5px; border: 0px; - color: #fcfbf7; text-transform: none; letter-spacing: 2px; font-size: 75%; @@ -194,11 +192,10 @@ font-size: 20px; text-align: left; margin: 0 0 20px 0; - color: black; } .hr-substitute { - border: solid black 0.5px; + border: solid 0.5px; margin-top: 20px; } @@ -223,11 +220,12 @@ height: 25px; padding-left: 2; width: 97%; + border-radius: 4px; margin-top: 10px; margin-left: -8px; font-size: 14; margin-bottom: -4; - border: 2px solid gray; + border: 2px solid; border-bottom: none; align-items: center; display: flex; @@ -237,11 +235,12 @@ height: 25px; padding-left: 2; width: 97%; + border-radius: 4px; margin-top: 10px; - margin-left: -.5px; + margin-left: -8px; font-size: 14; margin-bottom: -4; - border: 2px solid gray; + border: 2px solid; border-bottom: none; align-items: center; display: flex; @@ -264,7 +263,7 @@ font-weight: normal; padding: 3px; - border-bottom: 0.5px solid grey; + border-bottom: 0.5px solid; .group-info { cursor: pointer; @@ -368,35 +367,35 @@ margin-top: 20px; } - .link-container { - display: flex; - flex-direction: row; - margin-bottom: 10px; - margin-left: auto; - margin-right: auto; - - .link-box, - .copy { - padding: 10px; - border-radius: 10px; - padding: 10px; - border: solid black 1px; - } - - .link-box { - background: white; - color: blue; - text-decoration: underline; - } - - .copy { - margin-left: 20px; - cursor: alias; - border-radius: 50%; - width: 42px; - height: 42px; - transition: 1.5s all ease; - padding-top: 12px; - } - } + // .link-container { + // display: flex; + // flex-direction: row; + // margin-bottom: 10px; + // margin-left: auto; + // margin-right: auto; + + // .link-box, + // .copy { + // padding: 10px; + // border-radius: 10px; + // padding: 10px; + // border: solid black 1px; + // } + + // .link-box { + // background: white; + // color: blue; + // text-decoration: underline; + // } + + // .copy { + // margin-left: 20px; + // cursor: alias; + // border-radius: 50%; + // width: 42px; + // height: 42px; + // transition: 1.5s all ease; + // padding-top: 12px; + // } + // } }
\ No newline at end of file diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx index 33c9992d0..795df9c08 100644 --- a/src/client/util/SharingManager.tsx +++ b/src/client/util/SharingManager.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Colors } from 'browndash-components'; +import { Button, IconButton, Size } from 'browndash-components'; import { concat, intersection } from 'lodash'; import { action, computed, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; @@ -80,7 +80,7 @@ export class SharingManager extends React.Component<{}> { @observable private showUserOptions: boolean = false; // whether to show individuals as options when sharing (in the react-select component) @observable private showGroupOptions: boolean = false; // // whether to show groups as options when sharing (in the react-select component) private populating: boolean = false; // whether the list of users is populating or not - @observable private overrideNested: boolean = false; // whether child docs in a collection/dashboard should be changed to be less private - initially selected so default is override + @observable private upgradeNested: boolean = false; // whether child docs in a collection/dashboard should be changed to be less private - initially selected so default is upgrade all @observable private layoutDocAcls: boolean = false; // whether the layout doc or data doc's acls are to be used @observable private myDocAcls: boolean = false; // whether the My Docs checkbox is selected or not @observable private _buttonDown = false; @@ -97,7 +97,7 @@ export class SharingManager extends React.Component<{}> { DictationOverlay.Instance.hasActiveModal = true; this.isOpen = this.targetDoc !== undefined; this.permissions = SharingPermissions.Augment; - this.overrideNested = true; + this.upgradeNested = true; }); }; @@ -162,7 +162,7 @@ export class SharingManager extends React.Component<{}> { const acl = `acl-${normalizeEmail(user.email)}`; const docs = SelectionManager.Views().length < 2 ? [target] : SelectionManager.Views().map(docView => docView.rootDoc); docs.map(doc => (this.layoutDocAcls || doc.dockingConfig ? doc : Doc.GetProto(doc))).forEach(doc => { - distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.overrideNested ? true : undefined); + distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.upgradeNested ? true : undefined); if (permission !== SharingPermissions.None) { Doc.AddDocToList(sharingDoc, doc.dockingConfig ? dashStorage : storage, doc); } else GetEffectiveAcl(doc, user.email) === AclPrivate && Doc.RemoveDocFromList(sharingDoc, ((doc.createdFrom as Doc) || doc).dockingConfig ? dashStorage : storage, (doc.createdFrom as Doc) || doc); @@ -180,7 +180,7 @@ export class SharingManager extends React.Component<{}> { const docs = SelectionManager.Views().length < 2 ? [target] : SelectionManager.Views().map(docView => docView.rootDoc); docs.map(doc => (this.layoutDocAcls || doc.dockingConfig ? doc : Doc.GetProto(doc))).forEach(doc => { - distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.overrideNested ? true : undefined); + distributeAcls(acl, permission as SharingPermissions, doc, undefined, this.upgradeNested ? true : undefined); if (group instanceof Doc) { Doc.AddDocToList(group, 'docsShared', doc); @@ -525,13 +525,11 @@ export class SharingManager extends React.Component<{}> { const permissions = uniform ? StrCast(targetDoc?.[groupKey]) : '-multiple-'; return !permissions ? null : ( - <div key={groupKey} className={'container'}> + <div key={groupKey} className={'container'} style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> <div className={'padding'}>{StrCast(group.title)}</div> {group instanceof Doc ? ( - <div className="group-info" onClick={action(() => (GroupManager.Instance.currentGroup = group))}> - <FontAwesomeIcon icon={'info-circle'} color={'#e8e8e8'} size={'sm'} style={{ backgroundColor: '#1e89d7', borderRadius: '100%', border: '1px solid #1e89d7' }} /> - </div> + <IconButton icon={<FontAwesomeIcon icon={'info-circle'} />} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} onClick={action(() => (GroupManager.Instance.currentGroup = group))} /> ) : null} <div className={'edit-actions'}> {admin || this.myDocAcls ? ( @@ -557,28 +555,26 @@ export class SharingManager extends React.Component<{}> { color: StrCast(Doc.UserDoc().userColor) }} > - <p className="share-title"> - <div className="share-info" onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/properties/sharing-and-permissions/', '_blank')}> - <FontAwesomeIcon icon={'question-circle'} size={'sm'} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/properties/sharing-and-permissions/', '_blank')} /> + <p className="share-title" style={{color: StrCast(Doc.UserDoc().userColor)}}> + <div className="share-info" onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/collaboration/', '_blank')}> + <FontAwesomeIcon icon={'question-circle'} size={'sm'} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/features/collaboration/', '_blank')} /> </div> - <b>Share </b> + <b >Share </b> {this.focusOn(docs.length < 2 ? StrCast(targetDoc?.title, 'this document') : '-multiple-')} </p> - <button - className="share-copy-link" - style={{ background: this._buttonDown ? Colors.LIGHT_BLUE : undefined }} - onPointerDown={action(e => (this._buttonDown = true))} - onPointerUp={action(e => (this._buttonDown = false))} - onClick={this.copyURL}> - <FontAwesomeIcon title="Copy Public URL" icon="copy" size="sm" /> - Copy Public URL - </button> - <div className="close-button" onClick={this.close}> - <FontAwesomeIcon icon="times" color="black" size="lg" /> + <div className="share-copy-link"> + <Button type={"tertiary"} color={StrCast(Doc.UserDoc().userColor)} icon={<FontAwesomeIcon icon={"copy"} size="sm" />} iconPlacement={"left"} text={"Copy Guest URL"} /> + </div> + <div className="close-button"> + <Button + icon={<FontAwesomeIcon icon={'times'} size={'lg'} />} + onClick={this.close} + color={StrCast(Doc.UserDoc().userColor)} + /> </div> {admin ? ( <div className="share-container"> - <div className="share-setup"> + <div className="share-setup" style={{border: StrCast(Doc.UserDoc().userColor)}}> <Select className="user-search" placeholder="Enter user or group name..." @@ -590,9 +586,24 @@ export class SharingManager extends React.Component<{}> { onChange={this.handleUsersChange} value={this.selectedUsers} styles={{ + control: () => ({ + display: 'inline-flex', + width: '100%', + }), indicatorSeparator: () => ({ + display: 'inline-flex', visibility: 'hidden', }), + indicatorsContainer: () => ({ + display: 'inline-flex', + textDecorationColor: 'black', + }), + valueContainer: () => ({ + display: 'inline-flex', + fontStyle: StrCast(Doc.UserDoc().userColor), + color: StrCast(Doc.UserDoc().userColor), + width: '100%', + }), }} /> <div className="permissions-select"> @@ -600,9 +611,9 @@ export class SharingManager extends React.Component<{}> { {this.sharingOptions(true)} </select> </div> - <button ref={this.shareDocumentButtonRef} className="share-button" onClick={this.share}> - Share - </button> + <div className='share-button'> + <Button text={"SHARE"} type={"tertiary"} color={StrCast(Doc.UserDoc().userColor)} onClick={this.share} /> + </div> </div> <div className="sort-checkboxes"> <input type="checkbox" onChange={action(() => (this.showUserOptions = !this.showUserOptions))} /> <label style={{ marginRight: 10 }}>Individuals</label> @@ -612,7 +623,7 @@ export class SharingManager extends React.Component<{}> { <div className="acl-container"> {Doc.noviceMode ? null : ( <div className="layoutDoc-acls"> - <input type="checkbox" onChange={action(() => (this.overrideNested = !this.overrideNested))} checked={this.overrideNested} /> <label>Override Nested </label> + <input type="checkbox" onChange={action(() => (this.upgradeNested = !this.upgradeNested))} checked={this.upgradeNested} /> <label>Upgrade Nested </label> <input type="checkbox" onChange={action(() => (this.layoutDocAcls = !this.layoutDocAcls))} checked={this.layoutDocAcls} /> <label>Layout</label> </div> )} @@ -627,25 +638,22 @@ export class SharingManager extends React.Component<{}> { </div> </div> )} - <div className="main-container"> + <div className="main-container" style={{color: StrCast(Doc.UserDoc().userColor), border: StrCast(Doc.UserDoc().userColor)}}> <div className={'individual-container'}> <div className="user-sort" onClick={action(() => (this.individualSort = this.individualSort === 'ascending' ? 'descending' : this.individualSort === 'descending' ? 'none' : 'ascending'))}> <div className="title-individual"> - Individuals - <FontAwesomeIcon icon={this.individualSort === 'ascending' ? 'caret-up' : this.individualSort === 'descending' ? 'caret-down' : 'caret-right'} size="xs" /> + Individuals + <IconButton icon={<FontAwesomeIcon icon={this.individualSort === 'ascending' ? 'caret-up' : this.individualSort === 'descending' ? 'caret-down' : 'caret-right'}/>} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} /> </div> </div> <div className="users-list">{userListContents}</div> </div> <div className={'group-container'}> <div className="user-sort" onClick={action(() => (this.groupSort = this.groupSort === 'ascending' ? 'descending' : this.groupSort === 'descending' ? 'none' : 'ascending'))}> - <div className="title-group"> - Groups - <div className="group-info" onClick={action(() => GroupManager.Instance?.open())}> - <FontAwesomeIcon icon={'info-circle'} color={'#e8e8e8'} size={'sm'} style={{ backgroundColor: '#1e89d7', borderRadius: '100%', border: '1px solid #1e89d7' }} /> - </div> - - <FontAwesomeIcon icon={this.groupSort === 'ascending' ? 'caret-up' : this.groupSort === 'descending' ? 'caret-down' : 'caret-right'} size="xs" /> + <div className="title-group" > + Groups + <IconButton icon={<FontAwesomeIcon icon={'info-circle'} />} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} onClick={action(() => (GroupManager.Instance.open()))} /> + <IconButton icon={<FontAwesomeIcon icon={this.groupSort === 'ascending' ? 'caret-up' : this.groupSort === 'descending' ? 'caret-down' : 'caret-right'}/>} size={Size.XSMALL} color={StrCast(Doc.UserDoc().userColor)} /> </div> </div> <div className={'groups-list'}>{groupListContents}</div> diff --git a/src/client/util/reportManager/ReportManager.scss b/src/client/util/reportManager/ReportManager.scss new file mode 100644 index 000000000..cd6a1d934 --- /dev/null +++ b/src/client/util/reportManager/ReportManager.scss @@ -0,0 +1,364 @@ +@import '../../views/global/globalCssVariables'; + +// header + +.report-header { + display: flex; + justify-content: space-between; + align-items: center; + + .header-btns { + display: flex; + align-items: center; + gap: 0.5rem; + } + + h2 { + margin: 0; + padding: 0; + font-size: 24px; + } +} + +.report-header-vertical { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + + h2 { + margin: 0; + padding: 0; + padding-bottom: 8px; + font-size: 24px; + } +} + +// Report + +.report-issue { + width: 450px; + min-width: 300px; + padding: 16px; + padding-top: 32px; + display: flex; + flex-direction: column; + gap: 16px; + // background-color: #ffffff; + text-align: left; + position: relative; + + .report-label { + font-size: 14px; + font-weight: 400; + } + + .report-section { + display: flex; + flex-direction: column; + } + + .report-textarea { + border: none; + outline: none; + width: 100%; + height: 80px; + padding: 8px; + resize: vertical; + background: transparent; + transition: border 0.3s ease; + } + + .report-selects { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; + background-color: transparent; + + .report-select { + padding: 8px; + background-color: transparent; + + .report-opt { + padding: 8px; + } + } + } +} + +.report-input { + border: none; + outline: none; + border-bottom: 1px solid; + padding: 8px; + padding-left: 0; + transition: all 0.2s ease; + background: transparent; + + &:hover { + // border-bottom-color: $text-gray; + } + &:focus { + // border-bottom-color: #4476f7; + } +} + +// View issues + +.view-issues { + width: 75vw; + min-width: 500px; + display: flex; + gap: 16px; + height: 100%; + overflow-x: auto; + + video::-webkit-media-controls { + display: flex !important; + } + + .left { + flex: 1; + height: 100%; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + text-align: left; + position: relative; + + .issues { + padding-top: 24px; + position: relative; + flex-grow: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 16px; + } + } + + .right { + position: relative; + flex: 1; + padding: 16px; + min-width: 300px; + height: 100%; + overflow-y: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } +} + +// Issue + +.issue-card { + cursor: pointer; + padding: 16px; + border: 1px solid; + transition: all 0.1s ease; + display: flex; + flex-direction: column; + gap: 8px; + border-radius: 8px; + transition: all 0.2s ease; + + .issue-top { + display: flex; + align-items: center; + gap: 16px; + padding-bottom: 8px; + } + + .issue-label { + cursor: pointer; + font-size: 14px; + font-weight: 400; + padding: 0; + margin: 0; + } + + .issue-title { + font-size: 16px; + font-weight: 500; + padding: 0; + margin: 0; + } +} + +// Dropzone + +.dropzone { + padding: 2rem; + border-radius: 0.5rem; + border: 2px dashed; + + .dropzone-instructions { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + + p { + text-align: center; + } + } +} + +.file-list { + box-sizing: border-box; + margin: 0; + padding: 0; + font-size: 14px; + width: 100%; + overflow-x: auto; + list-style-type: none; + display: flex; + align-items: center; + gap: 16px; + + .file-name { + padding: 8px 12px; + display: flex; + align-items: center; + gap: 16px; + white-space: nowrap; + } +} + +// Detailed issue view + +.issue-view { + height: 100%; + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + text-align: left; + position: relative; + overflow: auto; + + .issue-label { + .issue-link { + cursor: pointer; + color: #4476f7; + } + } + + .issue-title { + font-size: 24px; + margin: 0; + padding: 0; + } + + .issue-date { + font-size: 14px; + } + + .issue-content { + font-size: 14px; + } +} + +// tags flex lists + +.issues-filters { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + + .issues-filter { + display: flex; + gap: 8px; + align-items: center; + white-space: nowrap; + overflow-x: auto; + } +} + +.issue-tags { + display: flex; + gap: 8px; + align-items: center; + white-space: nowrap; + overflow-x: auto; +} + +// Media previews + +.report-media-wrapper { + position: relative; + cursor: pointer; + + .close-btn { + position: absolute; + top: 2px; + right: 2px; + opacity: 0; + } + + .report-media-content { + position: relative; + display: inline block; + + video::-webkit-media-controls { + display: flex !important; + } + } + + .report-media-content::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* Adjust the opacity as desired */ + opacity: 0; + transition: opacity 0.3s ease; /* Transition for smooth effect */ + pointer-events: none; + + video::-webkit-media-controls { + pointer-events: all; + } + } + + &:hover { + .report-media-content::after { + opacity: 1; + } + + .close-btn { + opacity: 1; + } + } +} + +.report-audio-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +@media (max-width: 1100px) { + .report-header { + flex-direction: column; + align-items: stretch; + gap: 2rem; + } +} + +// Tag styling + +.report-tag { + box-sizing: border-box; + padding: 4px 10px; + font-size: 10px; + border-radius: 32px; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} diff --git a/src/client/util/reportManager/ReportManager.tsx b/src/client/util/reportManager/ReportManager.tsx new file mode 100644 index 000000000..e684bd637 --- /dev/null +++ b/src/client/util/reportManager/ReportManager.tsx @@ -0,0 +1,405 @@ +import * as React from 'react'; +import v4 = require('uuid/v4'); +import '.././SettingsManager.scss'; +import './ReportManager.scss'; +import Dropzone from 'react-dropzone'; +import ReactLoading from 'react-loading'; +import { action, observable } from 'mobx'; +import { BsX, BsArrowsAngleExpand, BsArrowsAngleContract } from 'react-icons/bs'; +import { CgClose } from 'react-icons/cg'; +import { AiOutlineUpload } from 'react-icons/ai'; +import { HiOutlineArrowLeft } from 'react-icons/hi'; +import { Issue } from './reportManagerSchema'; +import { observer } from 'mobx-react'; +import { Doc } from '../../../fields/Doc'; +import { MainViewModal } from '../../views/MainViewModal'; +import { Octokit } from '@octokit/core'; +import { Button, Dropdown, DropdownType, IconButton, Type } from 'browndash-components'; +import { BugType, FileData, Priority, ReportForm, ViewState, bugDropdownItems, darkColors, emptyReportForm, formatTitle, getAllIssues, isDarkMode, lightColors, passesTagFilter, priorityDropdownItems, uploadFilesToServer } from './reportManagerUtils'; +import { Filter, FormInput, FormTextArea, IssueCard, IssueView, Tag } from './ReportManagerComponents'; +import { StrCast } from '../../../fields/Types'; +import { MdRefresh } from 'react-icons/md'; +const higflyout = require('@hig/flyout'); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +/** + * Class for reporting and viewing Github issues within the app. + */ +@observer +export class ReportManager extends React.Component<{}> { + public static Instance: ReportManager; + @observable private isOpen = false; + + @observable private query = ''; + @action private setQuery = (q: string) => { + this.query = q; + }; + + private octokit: Octokit; + + @observable viewState: ViewState = ViewState.VIEW; + @action private setViewState = (state: ViewState) => { + this.viewState = state; + }; + @observable submitting: boolean = false; + @action private setSubmitting = (submitting: boolean) => { + this.submitting = submitting; + }; + + @observable fetchingIssues: boolean = false; + @action private setFetchingIssues = (fetching: boolean) => { + this.fetchingIssues = fetching; + }; + + @observable + public shownIssues: Issue[] = []; + @action setShownIssues = action((issues: Issue[]) => { + this.shownIssues = issues; + }); + + @observable + public priorityFilter: Priority | null = null; + @action setPriorityFilter = action((priority: Priority | null) => { + this.priorityFilter = priority; + }); + + @observable + public bugFilter: BugType | null = null; + @action setBugFilter = action((bug: BugType | null) => { + this.bugFilter = bug; + }); + + @observable selectedIssue: Issue | undefined = undefined; + @action setSelectedIssue = action((issue: Issue | undefined) => { + this.selectedIssue = issue; + }); + + @observable rightExpanded: boolean = false; + @action setRightExpanded = action((expanded: boolean) => { + this.rightExpanded = expanded; + }); + + // Form state + @observable private formData: ReportForm = emptyReportForm; + @action setFormData = action((newData: ReportForm) => { + this.formData = newData; + }); + + public close = action(() => (this.isOpen = false)); + public open = action(async () => { + this.isOpen = true; + if (this.shownIssues.length === 0) { + this.updateIssues(); + } + }); + + @action updateIssues = action(async () => { + this.setFetchingIssues(true); + try { + const issues = (await getAllIssues(this.octokit)) as Issue[]; + this.setShownIssues(issues.filter(issue => issue.state === 'open' && !issue.pull_request)); + } catch (err) { + console.log(err); + } + this.setFetchingIssues(false); + }); + + constructor(props: {}) { + super(props); + ReportManager.Instance = this; + + // initializing Github connection + this.octokit = new Octokit({ + auth: process.env.GITHUB_ACCESS_TOKEN, + }); + } + + /** + * Sends a request to Github to report a new issue with the form data. + * @returns nothing + */ + public async reportIssue(): Promise<void> { + if (this.formData.title === '' || this.formData.description === '') { + alert('Please fill out all required fields to report an issue.'); + return; + } + this.setSubmitting(true); + let formattedLinks: string[] = []; + if (this.formData.mediaFiles.length > 0) { + const links = await uploadFilesToServer(this.formData.mediaFiles); + if (links) { + formattedLinks = links; + } + } + + const req = await this.octokit.request('POST /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + title: formatTitle(this.formData.title, Doc.CurrentUserEmail), + body: `${this.formData.description} ${formattedLinks.length > 0 ? `\n\nFiles:\n${formattedLinks.join('\n')}` : ''}`, + labels: ['from-dash-app', this.formData.type, this.formData.priority], + }); + + // 201 status means success + if (req.status !== 201) { + alert('Error creating issue on github.'); + } else { + await this.updateIssues(); + alert('Successfully submitted issue.'); + } + this.setFormData(emptyReportForm); + this.setSubmitting(false); + } + + /** + * Handles file upload. + * + * @param files uploaded files + */ + private onDrop = (files: File[]) => { + this.setFormData({ ...this.formData, mediaFiles: [...this.formData.mediaFiles, ...files.map(file => ({ _id: v4(), file }))] }); + }; + + /** + * Gets a JSX element to render a media preview + * @param fileData file data + * @returns JSX element of a piece of media (image, video, audio) + */ + private getMediaPreview = (fileData: FileData): JSX.Element => { + const file = fileData.file; + const mimeType = file.type; + const preview = URL.createObjectURL(file); + + if (mimeType.startsWith('image/')) { + return ( + <div key={fileData._id} className="report-media-wrapper"> + <div className="report-media-content"> + <img height={100} alt={`Preview of ${file.name}`} src={preview} style={{ display: 'block' }} /> + </div> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setFormData({ ...this.formData, mediaFiles: this.formData.mediaFiles.filter(f => f._id !== fileData._id) })} /> + </div> + </div> + ); + } else if (mimeType.startsWith('video/')) { + return ( + <div key={fileData._id} className="report-media-wrapper"> + <div className="report-media-content"> + <video className="report-default-video" controls style={{ height: '100px', width: 'auto', display: 'block' }}> + <source src={preview} type="video/mp4" /> + Your browser does not support the video tag. + </video> + </div> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setFormData({ ...this.formData, mediaFiles: this.formData.mediaFiles.filter(f => f._id !== fileData._id) })} /> + </div> + </div> + ); + } else if (mimeType.startsWith('audio/')) { + return ( + <div key={fileData._id} className="report-audio-wrapper"> + <audio src={preview} controls /> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setFormData({ ...this.formData, mediaFiles: this.formData.mediaFiles.filter(f => f._id !== fileData._id) })} /> + </div> + </div> + ); + } + return <></>; + }; + + /** + * @returns the component that dispays all issues + */ + private viewIssuesComponent = () => { + const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)); + const colors = darkMode ? darkColors : lightColors; + + return ( + <div className="view-issues" style={{ backgroundColor: StrCast(Doc.UserDoc().userBackgroundColor), color: colors.text }}> + <div className="left" style={{ display: this.rightExpanded ? 'none' : 'flex' }}> + <div className="report-header"> + <h2 style={{ color: colors.text }}>Open Issues</h2> + <div className="header-btns"> + <IconButton color={StrCast(Doc.UserDoc().userColor)} tooltip="refresh" icon={<MdRefresh size="16px" />} onClick={this.updateIssues} /> + <Button + type={Type.TERT} + color={StrCast(Doc.UserDoc().userVariantColor)} + text="Report Issue" + onClick={() => { + this.setViewState(ViewState.CREATE); + }} + /> + </div> + </div> + <FormInput value={this.query} placeholder="Filter by query..." onChange={this.setQuery} /> + <div className="issues-filters"> + <Filter items={Object.values(Priority)} activeValue={this.priorityFilter} setActiveValue={p => this.setPriorityFilter(p)} /> + <Filter items={Object.values(BugType)} activeValue={this.bugFilter} setActiveValue={b => this.setBugFilter(b)} /> + </div> + <div className="issues"> + {this.fetchingIssues ? ( + <div style={{ flexGrow: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userColor)} width={50} height={50} /> + </div> + ) : ( + this.shownIssues + .filter(issue => issue.title.toLowerCase().includes(this.query)) + .filter(issue => passesTagFilter(issue, this.priorityFilter, this.bugFilter)) + .map(issue => ( + <IssueCard + key={issue.number} + issue={issue} + onSelect={() => { + this.setSelectedIssue(issue); + }} + /> + )) + )} + </div> + </div> + <div className="right">{this.selectedIssue ? <IssueView key={this.selectedIssue.number} issue={this.selectedIssue} /> : <div>No issue selected</div>} </div> + <div style={{ position: 'absolute', top: '8px', right: '8px', display: 'flex', gap: '16px' }}> + <IconButton + color={StrCast(Doc.UserDoc().userColor)} + tooltip={this.rightExpanded ? 'Minimize right side' : 'Expand right side'} + icon={this.rightExpanded ? <BsArrowsAngleContract size="16px" /> : <BsArrowsAngleExpand size="16px" />} + onClick={e => { + e.stopPropagation(); + this.setRightExpanded(!this.rightExpanded); + }} + /> + <IconButton color={StrCast(Doc.UserDoc().userColor)} tooltip="close" icon={<CgClose size="16px" />} onClick={this.close} /> + </div> + </div> + ); + }; + + /** + * @returns the form component for submitting issues + */ + private reportIssueComponent = () => { + const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)); + const colors = darkMode ? darkColors : lightColors; + + return ( + <div className="report-issue" style={{ color: colors.text }}> + <div className="report-header-vertical"> + <Button + type={Type.PRIM} + color={StrCast(Doc.UserDoc().userColor)} + text="back to view" + icon={<HiOutlineArrowLeft />} + iconPlacement="left" + onClick={() => { + this.setViewState(ViewState.VIEW); + }} + /> + <h2>Report an Issue</h2> + </div> + <div className="report-section"> + <label className="report-label">Please provide a title for the bug</label> + <FormInput value={this.formData.title} placeholder="Title..." onChange={val => this.setFormData({ ...this.formData, title: val })} /> + </div> + <div className="report-section"> + <label className="report-label">Please leave a description for the bug and how it can be recreated</label> + <FormTextArea value={this.formData.description} placeholder="Description..." onChange={val => this.setFormData({ ...this.formData, description: val })} /> + </div> + <div className="report-selects"> + <Dropdown + color={StrCast(Doc.UserDoc().userColor)} + formLabel={'Type'} + items={bugDropdownItems} + selectedVal={this.formData.type} + setSelectedVal={val => { + if (typeof val === 'string') this.setFormData({ ...this.formData, type: val as BugType }); + }} + dropdownType={DropdownType.SELECT} + type={Type.TERT} + fillWidth + /> + <Dropdown + color={StrCast(Doc.UserDoc().userColor)} + formLabel={'Priority'} + items={priorityDropdownItems} + selectedVal={this.formData.priority} + setSelectedVal={val => { + if (typeof val === 'string') this.setFormData({ ...this.formData, priority: val as Priority }); + }} + dropdownType={DropdownType.SELECT} + type={Type.TERT} + fillWidth + /> + </div> + <Dropzone + onDrop={this.onDrop} + accept={{ + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'video/*': ['.mp4', '.mpeg', '.webm', '.mov'], + 'audio/mpeg': ['.mp3'], + 'audio/wav': ['.wav'], + 'audio/ogg': ['.ogg'], + }}> + {({ getRootProps, getInputProps }) => ( + <div {...getRootProps({ className: 'dropzone' })} style={{ borderColor: isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border }}> + <input {...getInputProps()} /> + <div className="dropzone-instructions"> + <AiOutlineUpload size={25} /> + <p>Drop or select media that shows the bug (optional)</p> + </div> + </div> + )} + </Dropzone> + {this.formData.mediaFiles.length > 0 && <ul className="file-list">{this.formData.mediaFiles.map(file => this.getMediaPreview(file))}</ul>} + {this.submitting ? ( + <Button + text="Submit" + type={Type.TERT} + color={StrCast(Doc.UserDoc().userVariantColor)} + icon={<ReactLoading type="spin" color={'#ffffff'} width={20} height={20} />} + iconPlacement="right" + onClick={() => { + this.reportIssue(); + }} + /> + ) : ( + <Button + text="Submit" + type={Type.TERT} + color={StrCast(Doc.UserDoc().userVariantColor)} + onClick={() => { + this.reportIssue(); + }} + /> + )} + <div style={{ position: 'absolute', top: '4px', right: '4px' }}> + <IconButton color={StrCast(Doc.UserDoc().userColor)} tooltip="close" icon={<CgClose size={'16px'} />} onClick={this.close} /> + </div> + </div> + ); + }; + + /** + * @returns the component rendered to the modal + */ + private reportComponent = () => { + if (this.viewState === ViewState.VIEW) { + return this.viewIssuesComponent(); + } else { + return this.reportIssueComponent(); + } + }; + + render() { + return ( + <MainViewModal + contents={this.reportComponent()} + isDisplayed={this.isOpen} + interactive={true} + closeOnExternalClick={this.close} + dialogueBoxStyle={{ width: 'auto', minWidth: '300px', height: '85vh', maxHeight: '90vh', background: StrCast(Doc.UserDoc().userBackgroundColor), borderRadius: '8px' }} + /> + ); + } +} diff --git a/src/client/util/reportManager/ReportManagerComponents.tsx b/src/client/util/reportManager/ReportManagerComponents.tsx new file mode 100644 index 000000000..e870c073d --- /dev/null +++ b/src/client/util/reportManager/ReportManagerComponents.tsx @@ -0,0 +1,381 @@ +import * as React from 'react'; +import { Issue } from './reportManagerSchema'; +import { darkColors, dashBlue, getLabelColors, isDarkMode, lightColors } from './reportManagerUtils'; +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +import { StrCast } from '../../../fields/Types'; +import { Doc } from '../../../fields/Doc'; + +/** + * Mini helper components for the report component. + */ + +interface FilterProps<T> { + items: T[]; + activeValue: T | null; + setActiveValue: (val: T | null) => void; +} + +// filter ui for issues (horizontal list of tags) +export const Filter = <T extends string>({ items, activeValue, setActiveValue }: FilterProps<T>) => { + // establishing theme + const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)); + const colors = darkMode ? darkColors : lightColors; + const isTagDarkMode = isDarkMode(StrCast(Doc.UserDoc().userColor)); + const activeTagTextColor = isTagDarkMode ? darkColors.text : lightColors.text; + + return ( + <div className="issues-filter"> + <Tag + text={'All'} + onClick={() => { + setActiveValue(null); + }} + fontSize="12px" + backgroundColor={activeValue === null ? StrCast(Doc.UserDoc().userColor) : 'transparent'} + color={activeValue === null ? activeTagTextColor : colors.textGrey} + borderColor={activeValue === null ? StrCast(Doc.UserDoc().userColor) : colors.border} + border + /> + {items.map(item => { + return ( + <Tag + key={item} + text={item} + onClick={() => { + setActiveValue(item); + }} + fontSize="12px" + backgroundColor={activeValue === item ? StrCast(Doc.UserDoc().userColor) : 'transparent'} + color={activeValue === item ? activeTagTextColor : colors.textGrey} + border + borderColor={activeValue === item ? StrCast(Doc.UserDoc().userColor) : colors.border} + /> + ); + })} + </div> + ); +}; + +interface IssueCardProps { + issue: Issue; + onSelect: () => void; +} + +// Component for the issue cards list on the left +export const IssueCard = ({ issue, onSelect }: IssueCardProps) => { + const [textColor, setTextColor] = React.useState(''); + const [bgColor, setBgColor] = React.useState('transparent'); + const [borderColor, setBorderColor] = React.useState('transparent'); + + const resetColors = () => { + const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)); + const colors = darkMode ? darkColors : lightColors; + setTextColor(colors.text); + setBorderColor(colors.border); + setBgColor('transparent'); + }; + + const handlePointerOver = () => { + const darkMode = isDarkMode(StrCast(Doc.UserDoc().userColor)); + setTextColor(darkMode ? darkColors.text : lightColors.text); + setBorderColor(StrCast(Doc.UserDoc().userColor)); + setBgColor(StrCast(Doc.UserDoc().userColor)); + }; + + React.useEffect(() => { + resetColors(); + }, []); + + return ( + <div className="issue-card" onClick={onSelect} style={{ color: textColor, backgroundColor: bgColor, borderColor: borderColor }} onPointerOver={handlePointerOver} onPointerOut={resetColors}> + <div className="issue-top"> + <label className="issue-label">#{issue.number}</label> + <div className="issue-tags"> + {issue.labels.map(label => { + const labelString = typeof label === 'string' ? label : label.name ?? ''; + const colors = getLabelColors(labelString); + return <Tag key={labelString} text={labelString} backgroundColor={colors[0]} color={colors[1]} />; + })} + </div> + </div> + <h3 className="issue-title">{issue.title}</h3> + </div> + ); +}; + +interface IssueViewProps { + issue: Issue; +} + +// Detailed issue view that displays on the right +export const IssueView = ({ issue }: IssueViewProps) => { + const [issueBody, setIssueBody] = React.useState(''); + + // Parses the issue body into a formatted markdown (main functionality is replacing urls with tags) + const parseBody = async (body: string) => { + const imgTagRegex = /<img\b[^>]*\/?>/; + const videoTagRegex = /<video\b[^>]*\/?>/; + const audioTagRegex = /<audio\b[^>]*\/?>/; + + const fileRegex = /https:\/\/browndash\.com\/files/; + const localRegex = /http:\/\/localhost:1050\/files/; + const parts = body.split('\n'); + + const modifiedParts = await Promise.all( + parts.map(async part => { + if (imgTagRegex.test(part) || videoTagRegex.test(part) || audioTagRegex.test(part)) { + return `\n${await parseFileTag(part)}\n`; + } else if (fileRegex.test(part)) { + const tag = await parseDashFiles(part); + return tag; + } else if (localRegex.test(part)) { + const tag = await parseLocalFiles(part); + return tag; + } else { + return part; + } + }) + ); + + setIssueBody(modifiedParts.join('\n')); + }; + + // Extracts the src from an image tag and either returns the raw url if not accessible or a new image tag + const parseFileTag = async (tag: string): Promise<string> => { + const regex = /src="([^"]+)"/; + let url = ''; + const match = tag.match(regex); + if (!match) return tag; + url = match[1]; + if (!url) return tag; + + const mimeType = url.split('.').pop(); + if (!mimeType) return tag; + + switch (mimeType) { + // image + case '.jpg': + case '.png': + case '.jpeg': + case '.gif': + return await getDisplayedFile(url, 'image'); + // video + case '.mp4': + case '.mpeg': + case '.webm': + case '.mov': + return await getDisplayedFile(url, 'video'); + //audio + case '.mp3': + case '.wav': + case '.ogg': + return await getDisplayedFile(url, 'audio'); + } + return tag; + }; + + // Returns the corresponding HTML tag for a src url + const parseDashFiles = async (url: string) => { + const dashImgRegex = /https:\/\/browndash\.com\/files[/\\]images/; + const dashVideoRegex = /https:\/\/browndash\.com\/files[/\\]videos/; + const dashAudioRegex = /https:\/\/browndash\.com\/files[/\\]audio/; + + if (dashImgRegex.test(url)) { + return await getDisplayedFile(url, 'image'); + } else if (dashVideoRegex.test(url)) { + return await getDisplayedFile(url, 'video'); + } else if (dashAudioRegex.test(url)) { + return await getDisplayedFile(url, 'audio'); + } else { + return url; + } + }; + + // Returns the corresponding HTML tag for a src url + const parseLocalFiles = async (url: string) => { + const imgRegex = /http:\/\/localhost:1050\/files[/\\]images/; + const dashVideoRegex = /http:\/\/localhost:1050\.com\/files[/\\]videos/; + const dashAudioRegex = /http:\/\/localhost:1050\.com\/files[/\\]audio/; + + if (imgRegex.test(url)) { + return await getDisplayedFile(url, 'image'); + } else if (dashVideoRegex.test(url)) { + return await getDisplayedFile(url, 'video'); + } else if (dashAudioRegex.test(url)) { + return await getDisplayedFile(url, 'audio'); + } else { + return url; + } + }; + + const getDisplayedFile = async (url: string, fileType: 'image' | 'video' | 'audio'): Promise<string> => { + switch (fileType) { + case 'image': + const imgValid = await isImgValid(url); + if (!imgValid) return `\n${url} (This image could not be loaded)\n`; + return `\n${url}\n<img width="100%" alt="Issue asset" src=${url} />\n`; + case 'video': + const videoValid = await isVideoValid(url); + if (!videoValid) return `\n${url} (This video could not be loaded)\n`; + return `\n${url}\n<video class="report-default-video" width="100%" controls alt="Issue asset" src=${url} />\n`; + case 'audio': + const audioValid = await isAudioValid(url); + if (!audioValid) return `\n${url} (This audio could not be loaded)\n`; + return `\n${url}\n<audio src=${url} controls />\n`; + } + }; + + // Loads an image and returns a promise that resolves as whether the image is valid or not + const isImgValid = (src: string): Promise<boolean> => { + const imgElement = document.createElement('img'); + const validPromise: Promise<boolean> = new Promise(resolve => { + imgElement.addEventListener('load', () => resolve(true)); + imgElement.addEventListener('error', () => resolve(false)); + }); + imgElement.src = src; + return validPromise; + }; + + // Loads a video and returns a promise that resolves as whether the video is valid or not + const isVideoValid = (src: string): Promise<boolean> => { + const videoElement = document.createElement('video'); + const validPromise: Promise<boolean> = new Promise(resolve => { + videoElement.addEventListener('loadeddata', () => resolve(true)); + videoElement.addEventListener('error', () => resolve(false)); + }); + videoElement.src = src; + return validPromise; + }; + + // Loads audio and returns a promise that resolves as whether the audio is valid or not + const isAudioValid = (src: string): Promise<boolean> => { + const audioElement = document.createElement('audio'); + const validPromise: Promise<boolean> = new Promise(resolve => { + audioElement.addEventListener('loadeddata', () => resolve(true)); + audioElement.addEventListener('error', () => resolve(false)); + }); + audioElement.src = src; + return validPromise; + }; + + // Called on mount to parse the body + React.useEffect(() => { + setIssueBody('Loading...'); + parseBody((issue.body as string) ?? ''); + }, [issue]); + + return ( + <div className="issue-view"> + <span className="issue-label"> + Issue{' '} + <a className="issue-link" href={issue.html_url} target="_blank"> + #{issue.number} + </a> + </span> + <h2 className="issue-title">{issue.title}</h2> + <div className="issue-date"> + Opened on {new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {issue.user?.login && `by ${issue.user?.login}`} + </div> + {issue.labels.length > 0 && ( + <div> + <div className="issue-tags"> + {issue.labels.map(label => { + const labelString = typeof label === 'string' ? label : label.name ?? ''; + const colors = getLabelColors(labelString); + return <Tag key={labelString} text={labelString} backgroundColor={colors[0]} color={colors[1]} fontSize="12px" />; + })} + </div> + </div> + )} + <ReactMarkdown children={issueBody} className="issue-content" linkTarget={'_blank'} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> + </div> + ); +}; + +interface TagProps { + text: string; + fontSize?: string; + color?: string; + backgroundColor?: string; + borderColor?: string; + border?: boolean; + onClick?: () => void; +} + +// Small tag for labels of the issue +export const Tag = ({ text, color, backgroundColor, fontSize, border, borderColor, onClick }: TagProps) => { + return ( + <div + onClick={onClick ?? (() => {})} + className="report-tag" + style={{ color: color ?? '#ffffff', backgroundColor: backgroundColor ?? '#347bff', cursor: onClick ? 'pointer' : 'auto', fontSize: fontSize ?? '10px', border: border ? '1px solid' : 'none', borderColor: borderColor ?? '#94a3b8' }}> + {text} + </div> + ); +}; + +interface FormInputProps { + value: string; + placeholder: string; + onChange: (val: string) => void; +} +export const FormInput = ({ value, placeholder, onChange }: FormInputProps) => { + const [inputBorderColor, setInputBorderColor] = React.useState(''); + + return ( + <input + className="report-input" + style={{ borderBottom: `1px solid ${inputBorderColor}` }} + value={value} + type="text" + placeholder={placeholder} + onChange={e => onChange(e.target.value)} + required + onPointerOver={() => { + if (inputBorderColor === dashBlue) return; + setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.textGrey : lightColors.textGrey); + }} + onPointerOut={() => { + if (inputBorderColor === dashBlue) return; + setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border); + }} + onFocus={() => { + setInputBorderColor(dashBlue); + }} + onBlur={() => { + setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border); + }} + /> + ); +}; + +export const FormTextArea = ({ value, placeholder, onChange }: FormInputProps) => { + const [textAreaBorderColor, setTextAreaBorderColor] = React.useState(''); + + return ( + <textarea + className="report-textarea" + value={value} + placeholder={placeholder} + onChange={e => onChange(e.target.value)} + required + style={{ border: `1px solid ${textAreaBorderColor}` }} + onPointerOver={() => { + if (textAreaBorderColor === dashBlue) return; + setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.textGrey : lightColors.textGrey); + }} + onPointerOut={() => { + if (textAreaBorderColor === dashBlue) return; + setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border); + }} + onFocus={() => { + setTextAreaBorderColor(dashBlue); + }} + onBlur={() => { + setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border); + }} + /> + ); +}; diff --git a/src/client/util/reportManager/reportManagerSchema.ts b/src/client/util/reportManager/reportManagerSchema.ts new file mode 100644 index 000000000..9a1c7c3e9 --- /dev/null +++ b/src/client/util/reportManager/reportManagerSchema.ts @@ -0,0 +1,877 @@ +/** + * Issue interface schema from Github. + */ +export interface Issue { + active_lock_reason?: null | string; + assignee: null | PurpleSimpleUser; + assignees?: AssigneeElement[] | null; + /** + * How the author is associated with the repository. + */ + author_association: AuthorAssociation; + /** + * Contents of the issue + */ + body?: null | string; + body_html?: string; + body_text?: string; + closed_at: Date | null; + closed_by?: null | FluffySimpleUser; + comments: number; + comments_url: string; + created_at: Date; + draft?: boolean; + events_url: string; + html_url: string; + id: number; + /** + * Labels to associate with this issue; pass one or more label names to replace the set of + * labels on this issue; send an empty array to clear all labels from the issue; note that + * the labels are silently dropped for users without push access to the repository + */ + labels: Array<LabelObject | string>; + labels_url: string; + locked: boolean; + milestone: null | Milestone; + node_id: string; + /** + * Number uniquely identifying the issue within its repository + */ + number: number; + performed_via_github_app?: null | GitHubApp; + pull_request?: PullRequest; + reactions?: ReactionRollup; + /** + * A repository on GitHub. + */ + repository?: Repository; + repository_url: string; + /** + * State of the issue; either 'open' or 'closed' + */ + state: string; + /** + * The reason for the current state + */ + state_reason?: StateReason | null; + timeline_url?: string; + /** + * Title of the issue + */ + title: string; + updated_at: Date; + /** + * URL for the issue + */ + url: string; + user: null | TentacledSimpleUser; + [property: string]: any; +} + +/** + * A GitHub user. + */ +export interface PurpleSimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +/** + * A GitHub user. + */ +export interface AssigneeElement { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +/** + * How the author is associated with the repository. + */ +export enum AuthorAssociation { + Collaborator = 'COLLABORATOR', + Contributor = 'CONTRIBUTOR', + FirstTimeContributor = 'FIRST_TIME_CONTRIBUTOR', + FirstTimer = 'FIRST_TIMER', + Mannequin = 'MANNEQUIN', + Member = 'MEMBER', + None = 'NONE', + Owner = 'OWNER', +} + +/** + * A GitHub user. + */ +export interface FluffySimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +export interface LabelObject { + color?: null | string; + default?: boolean; + description?: null | string; + id?: number; + name?: string; + node_id?: string; + url?: string; + [property: string]: any; +} + +/** + * A collection of related issues and pull requests. + */ +export interface Milestone { + closed_at: Date | null; + closed_issues: number; + created_at: Date; + creator: null | MilestoneSimpleUser; + description: null | string; + due_on: Date | null; + html_url: string; + id: number; + labels_url: string; + node_id: string; + /** + * The number of the milestone. + */ + number: number; + open_issues: number; + /** + * The state of the milestone. + */ + state: State; + /** + * The title of the milestone. + */ + title: string; + updated_at: Date; + url: string; + [property: string]: any; +} + +/** + * A GitHub user. + */ +export interface MilestoneSimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +/** + * The state of the milestone. + */ +export enum State { + Closed = 'closed', + Open = 'open', +} + +/** + * GitHub apps are a new way to extend GitHub. They can be installed directly on + * organizations and user accounts and granted access to specific repositories. They come + * with granular permissions and built-in webhooks. GitHub apps are first class actors + * within GitHub. + */ +export interface GitHubApp { + client_id?: string; + client_secret?: string; + created_at: Date; + description: null | string; + /** + * The list of events for the GitHub app + */ + events: string[]; + external_url: string; + html_url: string; + /** + * Unique identifier of the GitHub app + */ + id: number; + /** + * The number of installations associated with the GitHub app + */ + installations_count?: number; + /** + * The name of the GitHub app + */ + name: string; + node_id: string; + owner: null | GitHubAppSimpleUser; + pem?: string; + /** + * The set of permissions for the GitHub app + */ + permissions: GitHubAppPermissions; + /** + * The slug name of the GitHub app + */ + slug?: string; + updated_at: Date; + webhook_secret?: null | string; + [property: string]: any; +} + +/** + * A GitHub user. + */ +export interface GitHubAppSimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +/** + * The set of permissions for the GitHub app + */ +export interface GitHubAppPermissions { + checks?: string; + contents?: string; + deployments?: string; + issues?: string; + metadata?: string; +} + +export interface PullRequest { + diff_url: null | string; + html_url: null | string; + merged_at?: Date | null; + patch_url: null | string; + url: null | string; + [property: string]: any; +} + +export interface ReactionRollup { + '+1': number; + '-1': number; + confused: number; + eyes: number; + heart: number; + hooray: number; + laugh: number; + rocket: number; + total_count: number; + url: string; + [property: string]: any; +} + +/** + * A repository on GitHub. + */ +export interface Repository { + /** + * Whether to allow Auto-merge to be used on pull requests. + */ + allow_auto_merge?: boolean; + /** + * Whether to allow forking this repo + */ + allow_forking?: boolean; + /** + * Whether to allow merge commits for pull requests. + */ + allow_merge_commit?: boolean; + /** + * Whether to allow rebase merges for pull requests. + */ + allow_rebase_merge?: boolean; + /** + * Whether to allow squash merges for pull requests. + */ + allow_squash_merge?: boolean; + /** + * Whether or not a pull request head branch that is behind its base branch can always be + * updated even if it is not required to be up to date before merging. + */ + allow_update_branch?: boolean; + /** + * Whether anonymous git access is enabled for this repository + */ + anonymous_access_enabled?: boolean; + archive_url: string; + /** + * Whether the repository is archived. + */ + archived: boolean; + assignees_url: string; + blobs_url: string; + branches_url: string; + clone_url: string; + collaborators_url: string; + comments_url: string; + commits_url: string; + compare_url: string; + contents_url: string; + contributors_url: string; + created_at: Date | null; + /** + * The default branch of the repository. + */ + default_branch: string; + /** + * Whether to delete head branches when pull requests are merged + */ + delete_branch_on_merge?: boolean; + deployments_url: string; + description: null | string; + /** + * Returns whether or not this repository disabled. + */ + disabled: boolean; + downloads_url: string; + events_url: string; + fork: boolean; + forks: number; + forks_count: number; + forks_url: string; + full_name: string; + git_commits_url: string; + git_refs_url: string; + git_tags_url: string; + git_url: string; + /** + * Whether discussions are enabled. + */ + has_discussions?: boolean; + /** + * Whether downloads are enabled. + */ + has_downloads: boolean; + /** + * Whether issues are enabled. + */ + has_issues: boolean; + has_pages: boolean; + /** + * Whether projects are enabled. + */ + has_projects: boolean; + /** + * Whether the wiki is enabled. + */ + has_wiki: boolean; + homepage: null | string; + hooks_url: string; + html_url: string; + /** + * Unique identifier of the repository + */ + id: number; + /** + * Whether this repository acts as a template that can be used to generate new repositories. + */ + is_template?: boolean; + issue_comment_url: string; + issue_events_url: string; + issues_url: string; + keys_url: string; + labels_url: string; + language: null | string; + languages_url: string; + license: null | LicenseSimple; + master_branch?: string; + /** + * The default value for a merge commit message. + * + * - `PR_TITLE` - default to the pull request's title. + * - `PR_BODY` - default to the pull request's body. + * - `BLANK` - default to a blank commit message. + */ + merge_commit_message?: MergeCommitMessage; + /** + * The default value for a merge commit title. + * + * - `PR_TITLE` - default to the pull request's title. + * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull + * request #123 from branch-name). + */ + merge_commit_title?: MergeCommitTitle; + merges_url: string; + milestones_url: string; + mirror_url: null | string; + /** + * The name of the repository. + */ + name: string; + network_count?: number; + node_id: string; + notifications_url: string; + open_issues: number; + open_issues_count: number; + organization?: null | RepositorySimpleUser; + /** + * A GitHub user. + */ + owner: OwnerObject; + permissions?: RepositoryPermissions; + /** + * Whether the repository is private or public. + */ + private: boolean; + pulls_url: string; + pushed_at: Date | null; + releases_url: string; + /** + * The size of the repository. Size is calculated hourly. When a repository is initially + * created, the size is 0. + */ + size: number; + /** + * The default value for a squash merge commit message: + * + * - `PR_BODY` - default to the pull request's body. + * - `COMMIT_MESSAGES` - default to the branch's commit messages. + * - `BLANK` - default to a blank commit message. + */ + squash_merge_commit_message?: SquashMergeCommitMessage; + /** + * The default value for a squash merge commit title: + * + * - `PR_TITLE` - default to the pull request's title. + * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull + * request's title (when more than one commit). + */ + squash_merge_commit_title?: SquashMergeCommitTitle; + ssh_url: string; + stargazers_count: number; + stargazers_url: string; + starred_at?: string; + statuses_url: string; + subscribers_count?: number; + subscribers_url: string; + subscription_url: string; + svn_url: string; + tags_url: string; + teams_url: string; + temp_clone_token?: string; + template_repository?: null | TemplateRepository; + topics?: string[]; + trees_url: string; + updated_at: Date | null; + url: string; + /** + * Whether a squash merge commit can use the pull request title as default. **This property + * has been deprecated. Please use `squash_merge_commit_title` instead. + */ + use_squash_pr_title_as_default?: boolean; + /** + * The repository visibility: public, private, or internal. + */ + visibility?: string; + watchers: number; + watchers_count: number; + /** + * Whether to require contributors to sign off on web-based commits + */ + web_commit_signoff_required?: boolean; + [property: string]: any; +} + +/** + * License Simple + */ +export interface LicenseSimple { + html_url?: string; + key: string; + name: string; + node_id: string; + spdx_id: null | string; + url: null | string; + [property: string]: any; +} + +/** + * The default value for a merge commit message. + * + * - `PR_TITLE` - default to the pull request's title. + * - `PR_BODY` - default to the pull request's body. + * - `BLANK` - default to a blank commit message. + */ +export enum MergeCommitMessage { + Blank = 'BLANK', + PRBody = 'PR_BODY', + PRTitle = 'PR_TITLE', +} + +/** + * The default value for a merge commit title. + * + * - `PR_TITLE` - default to the pull request's title. + * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull + * request #123 from branch-name). + */ +export enum MergeCommitTitle { + MergeMessage = 'MERGE_MESSAGE', + PRTitle = 'PR_TITLE', +} + +/** + * A GitHub user. + */ +export interface RepositorySimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +/** + * A GitHub user. + */ +export interface OwnerObject { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} + +export interface RepositoryPermissions { + admin: boolean; + maintain?: boolean; + pull: boolean; + push: boolean; + triage?: boolean; + [property: string]: any; +} + +/** + * The default value for a squash merge commit message: + * + * - `PR_BODY` - default to the pull request's body. + * - `COMMIT_MESSAGES` - default to the branch's commit messages. + * - `BLANK` - default to a blank commit message. + */ +export enum SquashMergeCommitMessage { + Blank = 'BLANK', + CommitMessages = 'COMMIT_MESSAGES', + PRBody = 'PR_BODY', +} + +/** + * The default value for a squash merge commit title: + * + * - `PR_TITLE` - default to the pull request's title. + * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull + * request's title (when more than one commit). + */ +export enum SquashMergeCommitTitle { + CommitOrPRTitle = 'COMMIT_OR_PR_TITLE', + PRTitle = 'PR_TITLE', +} + +export interface TemplateRepository { + allow_auto_merge?: boolean; + allow_merge_commit?: boolean; + allow_rebase_merge?: boolean; + allow_squash_merge?: boolean; + allow_update_branch?: boolean; + archive_url?: string; + archived?: boolean; + assignees_url?: string; + blobs_url?: string; + branches_url?: string; + clone_url?: string; + collaborators_url?: string; + comments_url?: string; + commits_url?: string; + compare_url?: string; + contents_url?: string; + contributors_url?: string; + created_at?: string; + default_branch?: string; + delete_branch_on_merge?: boolean; + deployments_url?: string; + description?: string; + disabled?: boolean; + downloads_url?: string; + events_url?: string; + fork?: boolean; + forks_count?: number; + forks_url?: string; + full_name?: string; + git_commits_url?: string; + git_refs_url?: string; + git_tags_url?: string; + git_url?: string; + has_downloads?: boolean; + has_issues?: boolean; + has_pages?: boolean; + has_projects?: boolean; + has_wiki?: boolean; + homepage?: string; + hooks_url?: string; + html_url?: string; + id?: number; + is_template?: boolean; + issue_comment_url?: string; + issue_events_url?: string; + issues_url?: string; + keys_url?: string; + labels_url?: string; + language?: string; + languages_url?: string; + /** + * The default value for a merge commit message. + * + * - `PR_TITLE` - default to the pull request's title. + * - `PR_BODY` - default to the pull request's body. + * - `BLANK` - default to a blank commit message. + */ + merge_commit_message?: MergeCommitMessage; + /** + * The default value for a merge commit title. + * + * - `PR_TITLE` - default to the pull request's title. + * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull + * request #123 from branch-name). + */ + merge_commit_title?: MergeCommitTitle; + merges_url?: string; + milestones_url?: string; + mirror_url?: string; + name?: string; + network_count?: number; + node_id?: string; + notifications_url?: string; + open_issues_count?: number; + owner?: Owner; + permissions?: TemplateRepositoryPermissions; + private?: boolean; + pulls_url?: string; + pushed_at?: string; + releases_url?: string; + size?: number; + /** + * The default value for a squash merge commit message: + * + * - `PR_BODY` - default to the pull request's body. + * - `COMMIT_MESSAGES` - default to the branch's commit messages. + * - `BLANK` - default to a blank commit message. + */ + squash_merge_commit_message?: SquashMergeCommitMessage; + /** + * The default value for a squash merge commit title: + * + * - `PR_TITLE` - default to the pull request's title. + * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull + * request's title (when more than one commit). + */ + squash_merge_commit_title?: SquashMergeCommitTitle; + ssh_url?: string; + stargazers_count?: number; + stargazers_url?: string; + statuses_url?: string; + subscribers_count?: number; + subscribers_url?: string; + subscription_url?: string; + svn_url?: string; + tags_url?: string; + teams_url?: string; + temp_clone_token?: string; + topics?: string[]; + trees_url?: string; + updated_at?: string; + url?: string; + use_squash_pr_title_as_default?: boolean; + visibility?: string; + watchers_count?: number; + [property: string]: any; +} + +export interface Owner { + avatar_url?: string; + events_url?: string; + followers_url?: string; + following_url?: string; + gists_url?: string; + gravatar_id?: string; + html_url?: string; + id?: number; + login?: string; + node_id?: string; + organizations_url?: string; + received_events_url?: string; + repos_url?: string; + site_admin?: boolean; + starred_url?: string; + subscriptions_url?: string; + type?: string; + url?: string; + [property: string]: any; +} + +export interface TemplateRepositoryPermissions { + admin?: boolean; + maintain?: boolean; + pull?: boolean; + push?: boolean; + triage?: boolean; + [property: string]: any; +} + +export enum StateReason { + Completed = 'completed', + NotPlanned = 'not_planned', + Reopened = 'reopened', +} + +/** + * A GitHub user. + */ +export interface TentacledSimpleUser { + avatar_url: string; + email?: null | string; + events_url: string; + followers_url: string; + following_url: string; + gists_url: string; + gravatar_id: null | string; + html_url: string; + id: number; + login: string; + name?: null | string; + node_id: string; + organizations_url: string; + received_events_url: string; + repos_url: string; + site_admin: boolean; + starred_at?: string; + starred_url: string; + subscriptions_url: string; + type: string; + url: string; + [property: string]: any; +} diff --git a/src/client/util/reportManager/reportManagerUtils.ts b/src/client/util/reportManager/reportManagerUtils.ts new file mode 100644 index 000000000..b95417aa1 --- /dev/null +++ b/src/client/util/reportManager/reportManagerUtils.ts @@ -0,0 +1,254 @@ +// Final file url reference: "https://browndash.com/files/images/upload_cb31bc0fda59c96ca14193ec494f80cf_o.jpg" /> + +import { Octokit } from '@octokit/core'; +import { Networking } from '../../Network'; +import { Issue } from './reportManagerSchema'; + +// enums and interfaces + +export enum ViewState { + VIEW, + CREATE, +} + +export enum Priority { + HIGH = 'priority-high', + MEDIUM = 'priority-medium', + LOW = 'priority-low', +} + +export enum BugType { + BUG = 'bug', + COSMETIC = 'cosmetic', + DOCUMENTATION = 'documentation', + ENHANCEMENT = 'enhancement', +} + +export interface FileData { + _id: string; + file: File; +} + +export interface ReportForm { + title: string; + description: string; + type: BugType; + priority: Priority; + mediaFiles: FileData[]; +} + +export type ReportFormKey = keyof ReportForm; + +export const emptyReportForm = { + title: '', + description: '', + type: BugType.BUG, + priority: Priority.MEDIUM, + mediaFiles: [], +}; + +// interfacing with Github + +/** + * Fetches issues from Github. + * @returns array of all issues + */ +export const getAllIssues = async (octokit: Octokit): Promise<any[]> => { + const res = await octokit.request('GET /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + per_page: 80, + }); + + // 200 status means success + if (res.status === 200) { + return res.data; + } else { + throw new Error('Error getting issues'); + } +}; + +/** + * Formats issue title. + * + * @param title title of issue + * @param userEmail email of issue submitter + * @returns formatted title + */ +export const formatTitle = (title: string, userEmail: string): string => `${title} - ${userEmail.replace('@brown.edu', '')}`; + +// uploading + +// turns an upload link -> server link +// ex: +// C: /Users/dash/Documents/GitHub/Dash-Web/src/server/public/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2.png +// -> https://browndash.com/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2_l.png +export const fileLinktoServerLink = (fileLink: string): string => { + const serverUrl = window.location.href.includes('browndash') ? 'https://browndash.com/' : 'http://localhost:1050/'; + + const regex = 'public'; + const publicIndex = fileLink.indexOf(regex) + regex.length; + + const finalUrl = `${serverUrl}${fileLink.substring(publicIndex + 1).replace('.', '_l.')}`; + return finalUrl; +}; + +/** + * Gets the server file path. + * + * @param link response from file upload + * @returns server file path + */ +export const getServerPath = (link: any): string => { + return link.result.accessPaths.agnostic.server as string; +}; + +/** + * Uploads media files to the server. + * @returns the server paths or undefined on error + */ +export const uploadFilesToServer = async (mediaFiles: FileData[]): Promise<string[] | undefined> => { + try { + // need to always upload to browndash + const links = await Networking.UploadFilesToServer(mediaFiles.map(file => ({ file: file.file }))); + return (links ?? []).map(getServerPath).map(fileLinktoServerLink); + } catch (err) { + if (err instanceof Error) { + alert(err.message); + } else { + alert(err); + } + } +}; + +// helper functions + +/** + * Returns when the issue passes the current filters. + * + * @param issue issue to check + * @returns boolean indicating whether the issue passes the current filters + */ +export const passesTagFilter = (issue: Issue, priorityFilter: string | null, bugFilter: string | null) => { + let passesPriority = true; + let passesBug = true; + if (priorityFilter) { + passesPriority = issue.labels.some(label => { + if (typeof label === 'string') { + return label === priorityFilter; + } else { + return label.name === priorityFilter; + } + }); + } + if (bugFilter) { + passesBug = issue.labels.some(label => { + if (typeof label === 'string') { + return label === bugFilter; + } else { + return label.name === bugFilter; + } + }); + } + return passesPriority && passesBug; +}; + +// sets and lists + +export const prioritySet = new Set(Object.values(Priority)); +export const bugSet = new Set(Object.values(BugType)); + +export const priorityDropdownItems = [ + { + text: 'Low', + val: Priority.LOW, + }, + { + text: 'Medium', + val: Priority.MEDIUM, + }, + { + text: 'High', + val: Priority.HIGH, + }, +]; + +export const bugDropdownItems = [ + { + text: 'Bug', + val: BugType.BUG, + }, + { + text: 'Poor Design or Cosmetic', + val: BugType.COSMETIC, + }, + { + text: 'Documentation', + val: BugType.DOCUMENTATION, + }, + { + text: 'New feature or request', + val: BugType.ENHANCEMENT, + }, +]; + +// colors + +// [bgColor, color] +export const priorityColors: { [key: string]: string[] } = { + 'priority-low': ['#d4e0ff', '#000000'], + 'priority-medium': ['#6a91f6', '#ffffff'], + 'priority-high': ['#003cd5', '#ffffff'], +}; + +// [bgColor, color] +export const bugColors: { [key: string]: string[] } = { + bug: ['#fe6d6d', '#ffffff'], + cosmetic: ['#c650f4', '#ffffff'], + documentation: ['#36acf0', '#ffffff'], + enhancement: ['#36d4f0', '#ffffff'], +}; + +export const getLabelColors = (label: string): string[] => { + if (prioritySet.has(label as Priority)) { + return priorityColors[label]; + } else if (bugSet.has(label as BugType)) { + return bugColors[label]; + } + return ['#0f73f6', '#ffffff']; +}; + +const hexToRgb = (hex: string) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : { + r: 0, + g: 0, + b: 0, + }; +}; + +// function that returns whether text should be light on the given bg color +export const isDarkMode = (bgHex: string): boolean => { + const { r, g, b } = hexToRgb(bgHex); + return r * 0.299 + g * 0.587 + b * 0.114 <= 186; +}; + +export const lightColors = { + text: '#000000', + textGrey: '#5c5c5c', + border: '#b8b8b8', +}; + +export const darkColors = { + text: '#ffffff', + textGrey: '#d6d6d6', + border: '#717171', +}; + +export const dashBlue = '#4476f7'; diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx index 1fffb3dbc..8412a9aae 100644 --- a/src/client/views/ContextMenu.tsx +++ b/src/client/views/ContextMenu.tsx @@ -192,11 +192,11 @@ export class ContextMenu extends React.Component { } return this.filteredItems.map((value, index) => Array.isArray(value) ? ( - <div className="contextMenu-group" + <div + className="contextMenu-group" style={{ - background: StrCast(Doc.UserDoc().userVariantColor) - }} - > + background: StrCast(Doc.UserDoc().userVariantColor), + }}> <div className="contextMenu-description">{value.join(' -> ')}</div> </div> ) : ( @@ -219,17 +219,18 @@ export class ContextMenu extends React.Component { this._height = Number(getComputedStyle(r).height.replace('px', '')); } })} - style={{ - left: this.pageX, ...(this._yRelativeToTop ? { top: this.pageY } : { bottom: this.pageY }), + style={{ + left: this.pageX, + ...(this._yRelativeToTop ? { top: this.pageY } : { bottom: this.pageY }), background: StrCast(Doc.UserDoc().userBackgroundColor), - color: StrCast(Doc.UserDoc().userColor) + color: StrCast(Doc.UserDoc().userColor), }}> {!this.itemsNeedSearch ? null : ( <span className={'search-icon'}> <span className="icon-background"> <FontAwesomeIcon icon="search" size="lg" /> </span> - <input className="contextMenu-item contextMenu-description search" type="text" placeholder="Filter Menu..." value={this._searchString} onKeyDown={this.onKeyDown} onChange={this.onChange} autoFocus /> + <input style={{ color: 'black' }} className="contextMenu-item contextMenu-description search" type="text" placeholder="Filter Menu..." value={this._searchString} onKeyDown={this.onKeyDown} onChange={this.onChange} autoFocus /> </span> )} {this.menuItems} diff --git a/src/client/views/DashboardView.tsx b/src/client/views/DashboardView.tsx index 1a5781df0..ae55c8ebf 100644 --- a/src/client/views/DashboardView.tsx +++ b/src/client/views/DashboardView.tsx @@ -199,7 +199,7 @@ export class DashboardView extends React.Component { <div className="dashboard-container" key={dashboard[Id]} - style={{ background: this.isUnviewedSharedDashboard(dashboard) && this.selectedDashboardGroup === DashboardGroup.SharedDashboards ? 'green' : shared ? 'blue' : '' }} + style={{ background: this.isUnviewedSharedDashboard(dashboard) && this.selectedDashboardGroup === DashboardGroup.SharedDashboards ? '#6CB982' : shared ? variant : '' }} onContextMenu={e => this.onContextMenu(dashboard, e)} onClick={e => this.clickDashboard(e, dashboard)}> <img diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 3522830e5..bab07ac96 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -831,7 +831,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P {shareSymbolIcon + ' ' + shareMode} - {!Doc.noviceMode ? ( + {/* {!Doc.noviceMode ? ( <div className="checkbox"> <div className="checkbox-box"> <input type="checkbox" checked={this.showLayoutAcl} onChange={action(() => (this.showLayoutAcl = !this.showLayoutAcl))} /> @@ -839,7 +839,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P <div className="checkbox-text"> Layout </div> </div> ) : null} - + */} </div> </div> ) : ( diff --git a/src/client/views/MainView.scss b/src/client/views/MainView.scss index 0c377730e..b3faff442 100644 --- a/src/client/views/MainView.scss +++ b/src/client/views/MainView.scss @@ -47,6 +47,10 @@ h1, align-items: center; justify-content: space-between; gap: 10px; + background: rgb(0, 0, 0); + border-radius: 8px; + padding-left: 5px; + padding-right: 5px; } .mainView-snapLines { @@ -190,10 +194,10 @@ h1, left: 0; position: absolute; z-index: 2; - background-color: linen;//$light-gray; + background-color: linen; //$light-gray; .editable-title { - background-color: linen;//$light-gray; + background-color: linen; //$light-gray; } } } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 5ab8a2f55..44eeb253c 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -21,7 +21,7 @@ import { DocumentManager } from '../util/DocumentManager'; import { GroupManager } from '../util/GroupManager'; import { HistoryUtil } from '../util/History'; import { Hypothesis } from '../util/HypothesisUtils'; -import { ReportManager } from '../util/ReportManager'; +import { ReportManager } from '../util/reportManager/ReportManager'; import { RTFMarkup } from '../util/RTFMarkup'; import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { SelectionManager } from '../util/SelectionManager'; @@ -66,6 +66,8 @@ import { PreviewCursor } from './PreviewCursor'; import { PropertiesView } from './PropertiesView'; import { DashboardStyleProvider, DefaultStyleProvider } from './StyleProvider'; import { TopBar } from './topbar/TopBar'; +import { ThemeProvider } from '@mui/material'; +import { theme } from '../theme'; const _global = (window /* browser */ || global) /* node */ as any; @observer @@ -749,8 +751,7 @@ export class MainView extends React.Component { @computed get leftMenuPanel() { return ( - <div key="menu" className="mainView-leftMenuPanel" style={{ background: StrCast(Doc.UserDoc().userBackgroundColor), - display: LightboxView.LightboxDoc ? 'none' : undefined }}> + <div key="menu" className="mainView-leftMenuPanel" style={{ background: StrCast(Doc.UserDoc().userBackgroundColor), display: LightboxView.LightboxDoc ? 'none' : undefined }}> <DocumentView Document={Doc.MyLeftSidebarMenu} DataDoc={undefined} @@ -805,14 +806,17 @@ export class MainView extends React.Component { {this._hideUI ? null : this.leftMenuPanel} <div key="inner" className={`mainView-innerContent${this.colorScheme}`}> {this.flyout} - <div className="mainView-libraryHandle" style={{ background: StrCast(Doc.UserDoc().userBackgroundColor), left: leftMenuFlyoutWidth - 10 /* ~half width of handle */, display: !this._leftMenuFlyoutWidth ? 'none' : undefined }} onPointerDown={this.onFlyoutPointerDown}> + <div + className="mainView-libraryHandle" + style={{ background: StrCast(Doc.UserDoc().userBackgroundColor), left: leftMenuFlyoutWidth - 10 /* ~half width of handle */, display: !this._leftMenuFlyoutWidth ? 'none' : undefined }} + onPointerDown={this.onFlyoutPointerDown}> <FontAwesomeIcon icon="chevron-left" color={StrCast(Doc.UserDoc().userColor)} style={{ opacity: '50%' }} size="sm" /> </div> <div className="mainView-innerContainer" style={{ width: `calc(100% - ${width}px)` }}> {this.dockingContent} {this._hideUI ? null : ( - <div className="mainView-propertiesDragger" key="props" onPointerDown={this.onPropertiesPointerDown} style={{ right: this.propertiesWidth() - 1, background : 'linen' }}> + <div className="mainView-propertiesDragger" key="props" onPointerDown={this.onPropertiesPointerDown} style={{ right: this.propertiesWidth() - 1, background: 'linen' }}> <FontAwesomeIcon icon={this.propertiesWidth() < 10 ? 'chevron-left' : 'chevron-right'} color={this.colorScheme === ColorScheme.Dark ? Colors.WHITE : Colors.BLACK} size="sm" /> </div> )} @@ -879,7 +883,7 @@ export class MainView extends React.Component { @computed get docButtons() { return !Doc.MyDockedBtns ? null : ( - <div className="mainView-docButtons" ref={this._docBtnRef}> + <div className="mainView-docButtons" style={{ background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor) }} ref={this._docBtnRef}> <CollectionLinearView Document={Doc.MyDockedBtns} DataDoc={undefined} diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx index 6105cc1b5..2e3668268 100644 --- a/src/client/views/PropertiesButtons.tsx +++ b/src/client/views/PropertiesButtons.tsx @@ -56,24 +56,25 @@ export class PropertiesButtons extends React.Component<{}, {}> { propertyToggleBtn = (label: (on?: any) => string, property: string, tooltip: (on?: any) => string, icon: (on?: any) => any, onClick?: (dv: Opt<DocumentView>, doc: Doc, property: string) => void, useUserDoc?: boolean) => { const targetDoc = useUserDoc ? Doc.UserDoc() : this.selectedDoc; const onPropToggle = (dv: Opt<DocumentView>, doc: Doc, prop: string) => ((dv?.layoutDoc || doc)[prop] = (dv?.layoutDoc || doc)[prop] ? false : true); - return !targetDoc ? null : <Toggle - toggleStatus={BoolCast(targetDoc[property])} - text={label(targetDoc?.[property])} - color={StrCast(Doc.UserDoc().userColor)} - icon={icon(targetDoc?.[property] as any)} - iconPlacement={'left'} - align={'flex-start'} - fillWidth={true} - toggleType={ToggleType.BUTTON} - onClick={undoable(() => { - if (SelectionManager.Views().length > 1) { - SelectionManager.Views().forEach(dv => (onClick ?? onPropToggle)(dv, dv.rootDoc, property)); - } else if (targetDoc) (onClick ?? onPropToggle)(undefined, targetDoc, property); - }, property)} - /> + return !targetDoc ? null : ( + <Toggle + toggleStatus={BoolCast(targetDoc[property])} + text={label(targetDoc?.[property])} + color={StrCast(Doc.UserDoc().userColor)} + icon={icon(targetDoc?.[property] as any)} + iconPlacement={'left'} + align={'flex-start'} + fillWidth={true} + toggleType={ToggleType.BUTTON} + onClick={undoable(() => { + if (SelectionManager.Views().length > 1) { + SelectionManager.Views().forEach(dv => (onClick ?? onPropToggle)(dv, dv.rootDoc, property)); + } else if (targetDoc) (onClick ?? onPropToggle)(undefined, targetDoc, property); + }, property)} + /> + ); }; - // this implments a container pattern by marking the targetDoc (collection) as a lightbox // that always fits its contents to its container and that hides all other documents when // a link is followed that targets a 'lightbox' destination @@ -110,7 +111,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { (dv, doc) => { const tdoc = dv?.rootDoc || doc; const newtitle = !tdoc._layout_showTitle ? 'title' : tdoc._layout_showTitle === 'title' ? 'title:hover' : ''; - tdoc._layout_showTitle = newtitle; + tdoc._layout_showTitle = newtitle ? newtitle : undefined; } ); } @@ -148,7 +149,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { @computed get clustersButton() { return this.propertyToggleBtn( - on => (on ?'DISABLE CLUSTERS' : 'HIGHLIGHT CLUSTERS'), + on => (on ? 'DISABLE CLUSTERS' : 'HIGHLIGHT CLUSTERS'), '_freeform_useClusters', on => `${on ? 'Hide' : 'Show'} clusters`, on => <FaBraille /> @@ -163,12 +164,13 @@ export class PropertiesButtons extends React.Component<{}, {}> { ); } - @computed get forceActiveButton() { //select text + @computed get forceActiveButton() { + //select text return this.propertyToggleBtn( - on => on ? 'INACTIVE INTERACTION' : 'ACTIVE INTERACTION', + on => (on ? 'INACTIVE INTERACTION' : 'ACTIVE INTERACTION'), '_forceActive', on => `${on ? 'Select to activate' : 'Contents always active'} `, - on => <MdTouchApp/> // 'eye' + on => <MdTouchApp /> // 'eye' ); } @@ -218,7 +220,8 @@ export class PropertiesButtons extends React.Component<{}, {}> { } @computed get captionButton() { - return this.propertyToggleBtn( //DEVELOPER + return this.propertyToggleBtn( + //DEVELOPER on => (on ? 'HIDE CAPTION' : 'SHOW CAPTION'), //'Caption', '_layout_showCaption', on => `${on ? 'Hide' : 'Show'} caption footer`, @@ -227,17 +230,19 @@ export class PropertiesButtons extends React.Component<{}, {}> { ); } - @computed get chromeButton() { // developer -- removing UI decoration + @computed get chromeButton() { + // developer -- removing UI decoration return this.propertyToggleBtn( - on => on ? 'ENABLE UI CONTROLS' : 'DISABLE UI CONTROLS', + on => (on ? 'ENABLE UI CONTROLS' : 'DISABLE UI CONTROLS'), '_chromeHidden', on => `${on ? 'Show' : 'Hide'} editing UI`, - on => on? <TbEditCircle/> : <TbEditCircleOff/> , // 'edit', + on => (on ? <TbEditCircle /> : <TbEditCircleOff />), // 'edit', (dv, doc) => ((dv?.rootDoc || doc)._chromeHidden = !(dv?.rootDoc || doc)._chromeHidden) ); } - @computed get layout_autoHeightButton() { // store previous dimensions to store old values + @computed get layout_autoHeightButton() { + // store previous dimensions to store old values return this.propertyToggleBtn( on => 'Auto\xA0Size', '_layout_autoHeight', @@ -251,7 +256,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { on => (on ? 'HIDE GRID' : 'DISPLAY GRID'), '_freeform_backgroundGrid', on => `Display background grid in collection`, - on => (on ? <MdGridOff /> :<MdGridOn /> ) //'border-all' + on => (on ? <MdGridOff /> : <MdGridOn />) //'border-all' ); } @@ -288,13 +293,14 @@ export class PropertiesButtons extends React.Component<{}, {}> { // } // ); // } - @computed get snapButton() { // THESE ARE NOT COMING + @computed get snapButton() { + // THESE ARE NOT COMING return this.propertyToggleBtn( on => (on ? 'HIDE SNAP LINES' : 'SHOW SNAP LINES'), 'freeform_snapLines', on => `Display snapping lines when objects are dragged`, on => <TfiBarChart />, //'th', - undefined, + undefined ); } @@ -346,11 +352,11 @@ export class PropertiesButtons extends React.Component<{}, {}> { const followLoc = this.selectedDoc._followLinkLocation; const linkedToLightboxView = () => LinkManager.Links(this.selectedDoc).some(link => LinkManager.getOppositeAnchor(link, this.selectedDoc)?._isLightbox); - if (followLoc === OpenWhere.lightbox && !linkedToLightboxView()) return 'linkInPlace' - else if (linkButton && followLoc === OpenWhere.addRight) return 'linkOnRight' - else if (linkButton && this.selectedDoc._followLinkLocation === OpenWhere.lightbox && linkedToLightboxView()) return 'enterPortal' - else if (ScriptCast(this.selectedDoc.onClick)?.script.originalScript.includes('toggleDetail')) return 'toggleDetail' - else return 'nothing' + if (followLoc === OpenWhere.lightbox && !linkedToLightboxView()) return 'linkInPlace'; + else if (linkButton && followLoc === OpenWhere.addRight) return 'linkOnRight'; + else if (linkButton && this.selectedDoc._followLinkLocation === OpenWhere.lightbox && linkedToLightboxView()) return 'enterPortal'; + else if (ScriptCast(this.selectedDoc.onClick)?.script.originalScript.includes('toggleDetail')) return 'toggleDetail'; + else return 'nothing'; } @computed @@ -362,22 +368,20 @@ export class PropertiesButtons extends React.Component<{}, {}> { ['linkInPlace', 'Open Link in Lightbox'], ['linkOnRight', 'Open Link on Right'], ]; - + const items: IListItemProps[] = buttonList.map(value => { - return ( - { - text: value[1], - val: value[1], - } - ); + return { + text: value[1], + val: value[1], + }; }); - console.log("click val: ", this.onClickVal) + console.log('click val: ', this.onClickVal); return !this.selectedDoc ? null : ( <Dropdown - tooltip={'Choose onClick behavior'} + tooltip={'Choose onClick behavior'} items={items} selectedVal={this.onClickVal} - setSelectedVal={(val) => this.handleOptionChange(val as string)} + setSelectedVal={val => this.handleOptionChange(val as string)} title={'Choose onClick behaviour'} color={StrCast(Doc.UserDoc().userColor)} dropdownType={DropdownType.SELECT} diff --git a/src/client/views/PropertiesView.scss b/src/client/views/PropertiesView.scss index 060b506e3..b79486167 100644 --- a/src/client/views/PropertiesView.scss +++ b/src/client/views/PropertiesView.scss @@ -42,16 +42,6 @@ padding: 0; margin-top: -5; } - - .propertiesView-acls-checkbox { - margin-top: -15px; - margin-bottom: -10px; - - .propertiesView-acls-checkbox-text { - display: inline; - font-size: 9px; - } - } } .change-buttons { @@ -68,14 +58,19 @@ } } + .propertiesView-acls-checkbox { + float: right; + margin-left: 50px; + } + .propertiesView-shareDropDown{ margin-right: 10px; min-width: 65px; & .propertiesView-shareDropDownNone{ - height: 16px; padding: 0px; padding-left: 3px; + padding-right: 3px; background: grey; color: rgb(71, 71, 71); border-radius: 6px; @@ -83,18 +78,18 @@ } & .propertiesView-shareDropDownEdit, .propertiesView-shareDropDownAdmin{ - height: 16px; padding: 0px; padding-left: 3px; + padding-right: 3px; background: rgb(254, 254, 199); color: rgb(75, 75, 5); border-radius: 6px; border: 1px solid rgb(75, 75, 5); } & .propertiesView-shareDropDownAugment{ - height: 16px; padding: 0px; padding-left: 3px; + padding-right: 3px; background: rgb(208, 255, 208); color:rgb(19, 80, 19); border-radius: 6px; @@ -102,18 +97,18 @@ } & .propertiesView-shareDropDownView{ - height: 16px; padding: 0px; padding-left: 3px; + padding-right: 3px; background: rgb(213, 213, 255); color: rgb(25, 25, 101); border-radius: 6px; border: 1px solid rgb(25, 25, 101); } & .propertiesView-shareDropDownNot-Shared{ - height: 16px; padding: 0px; padding-left: 3px; + padding-right: 3px; background: rgb(255, 207, 207); color: rgb(138, 47, 47); border-radius: 6px; @@ -177,19 +172,7 @@ } .expansion-button { - margin-left: -15px; - margin-right: 20px; - - .expansion-button-icon { - width: 11px; - height: 11px; - color: black; - - &:hover { - color: rgb(131, 131, 131); - cursor: pointer; - } - } + margin-right: 10px; } .propertiesView-sharingTable { @@ -238,6 +221,11 @@ } } + .propertiesView-permissions-select { + background: inherit; + border: none; + } + .propertiesView-field { display: flex; font-size: 7px; diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index a6a99b3cc..27b9c3c7a 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -3,7 +3,7 @@ import { IconLookup } from '@fortawesome/fontawesome-svg-core'; import { faAnchor, faArrowRight, faWindowMaximize } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Checkbox, Tooltip } from '@material-ui/core'; -import { Button, Colors, EditableText, NumberInput, Size, Slider, Type } from 'browndash-components'; +import { Button, Colors, EditableText, IconButton, NumberInput, Size, Slider, Type } from 'browndash-components'; import { concat } from 'lodash'; import { Lambda, action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; @@ -367,17 +367,18 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { */ @computed get expansionIcon() { return ( - <Tooltip title={<div className="dash-tooltip">Show more permissions</div>}> - <div - className="expansion-button" - onPointerDown={() => { + <div className="expansion-button" > + <IconButton + icon={<FontAwesomeIcon icon={'ellipsis-h'} />} + size={Size.XSMALL} + color={StrCast(Doc.UserDoc().userColor)} + onClick={action(() => { if (this.selectedDocumentView || this.selectedDoc) { SharingManager.Instance.open(this.selectedDocumentView?.props.Document === this.selectedDoc ? this.selectedDocumentView : undefined, this.selectedDoc); } - }}> - <FontAwesomeIcon className="expansion-button-icon" icon="ellipsis-h" color="black" size="sm" /> - </div> - </Tooltip> + })} + /> + </div> ); } @@ -417,7 +418,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { <div> <div className={'propertiesView-shareDropDown'}> <div className={`propertiesView-shareDropDown${permission}`}> - <div className="propertiesView-shareDropDown">{admin && permission !== 'Owner' ? this.getPermissionsSelect(name, permission, showGuestOptions) : concat(shareImage, ' ', permission)}</div> + <div >{admin && permission !== 'Owner' ? this.getPermissionsSelect(name, permission, showGuestOptions) : concat(shareImage, ' ', permission)}</div> </div> </div> </div> @@ -449,6 +450,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { const target = docs[0]; const showAdmin = GetEffectiveAcl(target) == AclAdmin; + console.log(GetEffectiveAcl(target), Doc.GetProto(target)[`acl-${normalizeEmail(Doc.CurrentUserEmail)}`]) const individualTableEntries = []; const usersAdded: string[] = []; // all shared users being added - organized by denormalized email @@ -512,28 +514,29 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { } }); - // public permission - const publicPermission = StrCast((this.layoutDocAcls ? target : Doc.GetProto(target))['acl-Guest']); + // guest permission + const guestPermission = StrCast((this.layoutDocAcls ? target : Doc.GetProto(target))['acl-Guest']); return ( <div> - <br /> - <div className="propertiesView-sharingTable">{<div> {individualTableEntries}</div>}</div> + <div> + <br></br> Individuals with Access to this Document + </div> + <div className="propertiesView-sharingTable" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> + {<div> {individualTableEntries}</div>} + </div> {groupTableEntries.length > 0 ? ( <div> <div> - {' '} - <br></br> Groups with Access to this Document{' '} + <br></br> Groups with Access to this Document + </div> + <div className="propertiesView-sharingTable" style={{background: StrCast(Doc.UserDoc().userBackgroundColor), color: StrCast(Doc.UserDoc().userColor)}}> + {<div> {groupTableEntries}</div>} </div> - <div className="propertiesView-sharingTable">{<div> {groupTableEntries}</div>}</div> </div> ) : null} - Guest - <div>{this.colorACLDropDown('Guest', true, publicPermission!, true)}</div> - <div> - {' '} - <br></br> Individual Users with Access to this Document{' '} - </div> + <br></br> Guest + <div>{this.colorACLDropDown('Guest', showAdmin, guestPermission!, true)}</div> </div> ); } @@ -1145,26 +1148,17 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { return <PropertiesSection title="Sharing & Permissions" content={<> - <div className="propertiesView-buttonContainer"> - {!Doc.noviceMode ? ( - <div className="propertiesView-buttonContainer"> - <div className="propertiesView-acls-checkbox"> - <div className="propertiesView-acls-checkbox-text"> Show / Contol Layout Permissions </div> - <Checkbox color="primary" onChange={action(() => (this.layoutDocAcls = !this.layoutDocAcls))} checked={this.layoutDocAcls} /> - </div> - {/* <Tooltip title={<><div className="dash-tooltip">{"Re-distribute sharing settings"}</div></>}> - <button onPointerDown={() => SharingManager.Instance.distributeOverCollection(this.selectedDoc!)}> - <FontAwesomeIcon icon="redo-alt" color="white" size="1x" /> - </button> - </Tooltip> */} - </div> - ) : null} + {/* <div className="propertiesView-buttonContainer"> */} + <div className="propertiesView-acls-checkbox"> + Layout Permissions + <Checkbox color="primary" onChange={action(() => (this.layoutDocAcls = !this.layoutDocAcls))} checked={this.layoutDocAcls} /> + </div> {/* <Tooltip title={<><div className="dash-tooltip">{"Re-distribute sharing settings"}</div></>}> <button onPointerDown={() => SharingManager.Instance.distributeOverCollection(this.selectedDoc!)}> <FontAwesomeIcon icon="redo-alt" size="1x" /> </button> </Tooltip> */} - </div> + {/* </div> */} {this.sharingTable} </>} isOpen={this.openSharing} diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index f1d98d22a..fb8ec93b2 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -235,6 +235,9 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo bActive, textX, textY, + // pt1, + // pt2, + // this code adds space between links pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13], }; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.scss index cb5cef29c..4ada1731f 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.scss @@ -1,11 +1,13 @@ -.collectionfreeformlinksview-svgCanvas{ +// TODO: change z-index to -1 when a modal is active? + +.collectionfreeformlinksview-svgCanvas { position: absolute; top: 0; left: 0; - width: 100%; + width: 100%; height: 100%; pointer-events: none; - } - .collectionfreeformlinksview-container { +} +.collectionfreeformlinksview-container { pointer-events: none; - }
\ No newline at end of file +} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 38922cb24..66352678c 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1236,7 +1236,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps const renderDoc = this.renderDoc({ borderRadius: this.borderRounding, outline: highlighting && !this.borderRounding && !highlighting.highlightStroke ? `${highlighting.highlightColor} ${highlighting.highlightStyle} ${highlighting.highlightIndex}px` : 'solid 0px', - border: highlighting && this.borderRounding && highlighting.highlightStyle === 'dashed' ? `${highlighting.highlightStyle} ${highlighting.highlightColor} ${highlighting.highlightIndex}px` : undefined, + border: highlighting && this.borderRounding && highlighting.highlightStyle === 'dashed' ? `${highlighting.highlightStyle} ${highlighting.highlightColor} ${highlighting.highlightIndex}px` : undefined, boxShadow, clipPath: borderPath?.clipPath, }); diff --git a/src/client/views/nodes/LoadingBox.scss b/src/client/views/nodes/LoadingBox.scss index 4c3b8dabe..d4a7e18f2 100644 --- a/src/client/views/nodes/LoadingBox.scss +++ b/src/client/views/nodes/LoadingBox.scss @@ -12,6 +12,10 @@ text-overflow: ellipsis; max-width: 80%; text-align: center; + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; } } diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 07b2afd91..17a3d0a03 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -253,21 +253,18 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { }; @computed get highlighter() { - return <Group> - <IconButton - icon={<FontAwesomeIcon icon="highlighter" style={{ transition: 'transform 0.1s', transform: 'rotate(-45deg)' }} />} - tooltip={'Click to Highlight'} - onClick={this.highlightClicked} - colorPicker={this.highlightColor} - color={StrCast(Doc.UserDoc().userColor)} - /> - <ColorPicker - colorPickerType={'github'} - selectedColor={this.highlightColor} - setSelectedColor={color => this.changeHighlightColor(color)} - size={Size.XSMALL} - /> - </Group> + return ( + <Group> + <IconButton + icon={<FontAwesomeIcon icon="highlighter" style={{ transition: 'transform 0.1s', transform: 'rotate(-45deg)' }} />} + tooltip={'Click to Highlight'} + onClick={this.highlightClicked} + colorPicker={this.highlightColor} + color={StrCast(Doc.UserDoc().userColor)} + /> + <ColorPicker colorPickerType={'github'} selectedColor={this.highlightColor} setSelectedColor={color => this.changeHighlightColor(color)} size={Size.XSMALL} /> + </Group> + ); } @action changeHighlightColor = (color: string) => { @@ -312,12 +309,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { this.Status === 'marquee' ? ( <> {this.highlighter} - <IconButton - tooltip={'Drag to Place Annotation'} - onPointerDown={this.pointerDown} - icon={<FontAwesomeIcon icon="comment-alt"/>} - color={StrCast(Doc.UserDoc().userColor)} - /> + <IconButton tooltip={'Drag to Place Annotation'} onPointerDown={this.pointerDown} icon={<FontAwesomeIcon icon="comment-alt" />} color={StrCast(Doc.UserDoc().userColor)} /> {/* GPT Summarize icon only shows up when text is highlighted, not on marquee selection*/} {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && this.canSummarize() && ( <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}> @@ -338,66 +330,30 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { mode={this.GPTMode} /> {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( - <IconButton - tooltip={'Click to Record Annotation'} - onPointerDown={this.audioDown} - icon={<FontAwesomeIcon icon="microphone" />} - color={StrCast(Doc.UserDoc().userColor)} - /> - )} - {this.canEdit() && ( - <IconButton - tooltip={'AI edit suggestions'} - onPointerDown={this.gptEdit} - icon={<FontAwesomeIcon icon="pencil-alt" />} - color={StrCast(Doc.UserDoc().userColor)} - /> + <IconButton tooltip={'Click to Record Annotation'} onPointerDown={this.audioDown} icon={<FontAwesomeIcon icon="microphone" />} color={StrCast(Doc.UserDoc().userColor)} /> )} - <Popup - tooltip='Find document to link to selected text' - type={Type.PRIM} - icon={<FontAwesomeIcon icon={'search'} />} - popup={<LinkPopup key="popup" linkCreateAnchor={this.onMakeAnchor} />} - color={StrCast(Doc.UserDoc().userColor)} - /> + {this.canEdit() && <IconButton tooltip={'AI edit suggestions'} onPointerDown={this.gptEdit} icon={<FontAwesomeIcon icon="pencil-alt" />} color={StrCast(Doc.UserDoc().userColor)} />} + <Popup tooltip="Find document to link to selected text" type={Type.PRIM} icon={<FontAwesomeIcon icon={'search'} />} popup={<LinkPopup key="popup" linkCreateAnchor={this.onMakeAnchor} />} color={StrCast(Doc.UserDoc().userColor)} /> {AnchorMenu.Instance.StartCropDrag === unimplementedFunction ? null : ( - <IconButton - tooltip={'Click/Drag to create cropped image'} - onPointerDown={this.cropDown} - icon={<FontAwesomeIcon icon="image"/>} - color={StrCast(Doc.UserDoc().userColor)} - /> + <IconButton tooltip={'Click/Drag to create cropped image'} onPointerDown={this.cropDown} icon={<FontAwesomeIcon icon="image" />} color={StrCast(Doc.UserDoc().userColor)} /> )} </> ) : ( <> - {this.Delete !== returnFalse && <IconButton - tooltip={'Remove Link Anchor'} - onPointerDown={this.Delete} - icon={<FontAwesomeIcon icon="trash-alt" />} - color={StrCast(Doc.UserDoc().userColor)} - />} - {this.PinToPres !== returnFalse && <IconButton - tooltip={'Pin to Presentation'} - onPointerDown={this.PinToPres} - icon={<FontAwesomeIcon icon="map-pin" />} - color={StrCast(Doc.UserDoc().userColor)} - />} - {this.ShowTargetTrail !== returnFalse && <IconButton - tooltip={'Show Linked Trail'} - onPointerDown={this.ShowTargetTrail} - icon={<FontAwesomeIcon icon="taxi" />} - color={StrCast(Doc.UserDoc().userColor)} - />} - {this.IsTargetToggler !== returnFalse && <Toggle - tooltip={'Make target visibility toggle on click'} - type={Type.PRIM} - toggleType={ToggleType.BUTTON} - toggleStatus={this.IsTargetToggler()} - onClick={this.MakeTargetToggle} - icon={<FontAwesomeIcon icon="thumbtack" />} - color={StrCast(Doc.UserDoc().userColor)} - />} + {this.Delete !== returnFalse && <IconButton tooltip={'Remove Link Anchor'} onPointerDown={this.Delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={StrCast(Doc.UserDoc().userColor)} />} + {this.PinToPres !== returnFalse && <IconButton tooltip={'Pin to Presentation'} onPointerDown={this.PinToPres} icon={<FontAwesomeIcon icon="map-pin" />} color={StrCast(Doc.UserDoc().userColor)} />} + {this.ShowTargetTrail !== returnFalse && <IconButton tooltip={'Show Linked Trail'} onPointerDown={this.ShowTargetTrail} icon={<FontAwesomeIcon icon="taxi" />} color={StrCast(Doc.UserDoc().userColor)} />} + {this.IsTargetToggler !== returnFalse && ( + <Toggle + tooltip={'Make target visibility toggle on click'} + type={Type.PRIM} + toggleType={ToggleType.BUTTON} + toggleStatus={this.IsTargetToggler()} + onClick={this.MakeTargetToggle} + icon={<FontAwesomeIcon icon="thumbtack" />} + color={StrCast(Doc.UserDoc().userColor)} + /> + )} </> ); diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 0fd93868a..1319a236d 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -26,6 +26,7 @@ import { Annotation } from './Annotation'; import './PDFViewer.scss'; import React = require('react'); import { GPTPopup } from './GPTPopup/GPTPopup'; +import { InkingStroke } from '../InkingStroke'; const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer'); const pdfjsLib = require('pdfjs-dist'); const _global = (window /* browser */ || global) /* node */ as any; @@ -519,7 +520,8 @@ export class PDFViewer extends React.Component<IViewerProps> { childStyleProvider = (doc: Doc | undefined, props: Opt<DocumentViewProps>, property: string): any => { if (doc instanceof Doc && property === StyleProp.PointerEvents) { if (this.inlineTextAnnotations.includes(doc) || this.props.isContentActive() === false) return 'none'; - return 'all'; + const isInk = doc && StrCast(Doc.Layout(doc).layout).includes(InkingStroke.name) && !props?.LayoutTemplateString; + return isInk ? 'visiblePainted' : 'all'; } return this.props.styleProvider?.(doc, props, property); }; @@ -537,7 +539,7 @@ export class PDFViewer extends React.Component<IViewerProps> { NativeWidth={returnZero} NativeHeight={returnZero} setContentView={emptyFunction} // override setContentView to do nothing - pointerEvents={SnappingManager.GetIsDragging() && this.props.isContentActive() ? returnAll : returnNone} // freeform view doesn't get events unless something is being dragged onto it. + pointerEvents={this.props.isContentActive() && (SnappingManager.GetIsDragging() || Doc.ActiveTool !== InkTool.None) ? returnAll : returnNone} // freeform view doesn't get events unless something is being dragged onto it. childPointerEvents={this.props.isContentActive() !== false ? 'all' : 'none'} // but freeform children need to get events to allow text editing, etc renderDepth={this.props.renderDepth + 1} isAnnotationOverlay={true} diff --git a/src/client/views/topbar/TopBar.tsx b/src/client/views/topbar/TopBar.tsx index 79f41fe9d..cb8eda9de 100644 --- a/src/client/views/topbar/TopBar.tsx +++ b/src/client/views/topbar/TopBar.tsx @@ -10,7 +10,7 @@ import { StrCast } from '../../../fields/Types'; import { GetEffectiveAcl } from '../../../fields/util'; import { DocumentManager } from '../../util/DocumentManager'; import { PingManager } from '../../util/PingManager'; -import { ReportManager } from '../../util/ReportManager'; +import { ReportManager } from '../../util/reportManager/ReportManager'; import { ServerStats } from '../../util/ServerStats'; import { SettingsManager } from '../../util/SettingsManager'; import { SharingManager } from '../../util/SharingManager'; @@ -134,14 +134,16 @@ export class TopBar extends React.Component { @computed get topbarRight() { return ( <div className="topbar-right"> - <Button - text={GetEffectiveAcl(Doc.ActiveDashboard) === AclAdmin ? 'Share' : 'View Original'} - type={Type.TERT} - color={this.variantColor} - onClick={() => { - SharingManager.Instance.open(undefined, Doc.ActiveDashboard); - }} - /> + {Doc.ActiveDashboard ? + <Button + text={GetEffectiveAcl(Doc.ActiveDashboard) === AclAdmin ? 'Share' : 'View Original'} + type={Type.TERT} + color={this.variantColor} + onClick={() => { + SharingManager.Instance.open(undefined, Doc.ActiveDashboard); + }} + /> + : null } <IconButton tooltip={"Issue Reporter ⌘I"} size={Size.SMALL} color={this.color} onClick={ReportManager.Instance.open} icon={<FaBug />} /> <IconButton tooltip={"Documentation ⌘D"} size={Size.SMALL} color={this.color} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/', '_blank')} icon={<FontAwesomeIcon icon="question-circle" />} /> <IconButton tooltip={"Settings ⌘⇧S"} size={Size.SMALL} color={this.color} onClick={SettingsManager.Instance.open} icon={<FontAwesomeIcon icon="cog" />} /> diff --git a/src/server/server_Initialization.ts b/src/server/server_Initialization.ts index c38ee8ac9..c1934451c 100644 --- a/src/server/server_Initialization.ts +++ b/src/server/server_Initialization.ts @@ -149,23 +149,15 @@ function registerAuthenticationRoutes(server: express.Express) { function registerCorsProxy(server: express.Express) { server.use('/corsProxy', async (req, res) => { - const referer = req.headers.referer ? decodeURIComponent(req.headers.referer) : ''; - let requrlraw = decodeURIComponent(req.url.substring(1)); - const qsplit = requrlraw.split('?q='); - const newqsplit = requrlraw.split('&q='); + //const referer = req.headers.referer ? decodeURIComponent(req.headers.referer) : ''; + let requrl = decodeURIComponent(req.url.substring(1)); + const qsplit = requrl.split('?q='); + const newqsplit = requrl.split('&q='); if (qsplit.length > 1 && newqsplit.length > 1) { const lastq = newqsplit[newqsplit.length - 1]; - requrlraw = qsplit[0] + '?q=' + lastq.split('&')[0] + '&' + qsplit[1].split('&')[1]; - } - const requrl = requrlraw.startsWith('/') ? referer + requrlraw : requrlraw; - // cors weirdness here... - // if the referer is a cors page and the cors() route (I think) redirected to /corsProxy/<path> and the requested url path was relative, - // then we redirect again to the cors referer and just add the relative path. - if (!requrl.startsWith('http') && req.originalUrl.startsWith('/corsProxy') && referer?.includes('corsProxy')) { - res.redirect(referer + (referer.endsWith('/') ? '' : '/') + requrl); - } else { - proxyServe(req, requrl, res); + requrl = qsplit[0] + '?q=' + lastq.split('&')[0] + '&' + qsplit[1].split('&')[1]; } + proxyServe(req, requrl, res); }); } @@ -184,7 +176,7 @@ function proxyServe(req: any, requrl: string, response: any) { const htmlText = htmlInputText .toString('utf8') .replace('<head>', '<head> <style>[id ^= "google"] { display: none; } </style>') - .replace(/href="https?([^"]*)"/g, httpsToCors) + // .replace(/href="https?([^"]*)"/g, httpsToCors) .replace(/data-srcset="[^"]*"/g, '') .replace(/srcset="[^"]*"/g, '') .replace(/target="_blank"/g, ''); |