diff options
author | bobzel <zzzman@gmail.com> | 2023-07-28 11:36:20 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2023-07-28 11:36:20 -0400 |
commit | 4ee777d48da06ff8053d32e8bcb27ba344bdfe98 (patch) | |
tree | b726e3cdfe8ca99ededd4f303d8a45f7d8219a1f /src | |
parent | 7edcc150d013343cb7feca49ce43228b99e6c7e5 (diff) | |
parent | ef636fd670ba0f9786785e724ef4e88508ee2630 (diff) |
Merge branch 'master' into james-azure-image
Diffstat (limited to 'src')
19 files changed, 2508 insertions, 643 deletions
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/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/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/InkTranscription.tsx b/src/client/views/InkTranscription.tsx index c39fa79da..6c213f40f 100644 --- a/src/client/views/InkTranscription.tsx +++ b/src/client/views/InkTranscription.tsx @@ -105,7 +105,7 @@ export class InkTranscription extends React.Component { : null; } - r.addEventListener('exported', (e: any) => this.exportInk(e, this._textRef)); + r?.addEventListener('exported', (e: any) => this.exportInk(e, this._textRef)); return (this._textRef = r); }; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index dc85fdfae..cbaa763f5 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'; @@ -49,7 +49,6 @@ import { InkTranscription } from './InkTranscription'; import { LightboxView } from './LightboxView'; import { LinkMenu } from './linking/LinkMenu'; import './MainView.scss'; -import { NewLightboxView } from './newlightbox/NewLightboxView'; import { AudioBox } from './nodes/AudioBox'; import { DocumentLinksButton } from './nodes/DocumentLinksButton'; import { DocumentView, DocumentViewInternal, OpenWhere, OpenWhereMod } from './nodes/DocumentView'; diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 1098b56c2..fb23fc7f1 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -11,7 +11,7 @@ import { listSpec } from '../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; -import { emptyFunction, return18, returnEmptyDoclist, returnEmptyFilter, returnEmptyString, returnFalse, returnTrue, returnZero, simulateMouseClick, Utils } from '../../../Utils'; +import { emptyFunction, lightOrDark, return18, returnEmptyDoclist, returnEmptyFilter, returnEmptyString, returnFalse, returnTrue, returnZero, simulateMouseClick, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes'; import { DocumentManager } from '../../util/DocumentManager'; @@ -537,8 +537,6 @@ export class TreeView extends React.Component<TreeViewProps> { TraceMobx(); const expandKey = this.treeViewExpandedView; const sortings = (this.props.styleProvider?.(this.doc, this.props.treeView.props, StyleProp.TreeViewSortings) as { [key: string]: { color: string; icon: JSX.Element | string } }) ?? {}; - const color = this.props.styleProvider?.(this.doc, this.props.treeView.props, StyleProp.Color); - console.log('tree view', color, this.doc.title, Doc.IsSystem(this.doc)); if (['links', 'annotations', 'embeddings', this.fieldKey].includes(expandKey)) { const sorting = StrCast(this.doc.treeViewSortCriterion, TreeSort.None); const sortKeys = Object.keys(sortings); @@ -578,10 +576,7 @@ export class TreeView extends React.Component<TreeViewProps> { ); } return ( - <div - style={{ - color: color, - }}> + <div> {!docs?.length || this.props.AddToMap /* hack to identify pres box trees */ ? null : ( <div className={'treeView-sorting'}> <IconButton @@ -660,14 +655,14 @@ export class TreeView extends React.Component<TreeViewProps> { ); } else if (this.treeViewExpandedView === 'fields') { return ( - <ul key={this.doc[Id] + this.doc.title} style={{ cursor: 'inherit', color: color }}> + <ul key={this.doc[Id] + this.doc.title} style={{ cursor: 'inherit' }}> <div>{this.expandedField}</div> </ul> ); } return ( <ul - style={{ color: color }} + style={{}} onPointerDown={e => { e.preventDefault(); e.stopPropagation(); @@ -980,6 +975,8 @@ export class TreeView extends React.Component<TreeViewProps> { ref={this._tref} title="click to edit title. Double Click or Drag to Open" style={{ + backgroundColor: Doc.IsSystem(this.props.document) || this.props.document.isFolder ? StrCast(Doc.UserDoc().userVariantColor) : undefined, + color: Doc.IsSystem(this.props.document) || this.props.document.isFolder ? lightOrDark(StrCast(Doc.UserDoc().userVariantColor)) : undefined, fontWeight: Doc.IsSearchMatch(this.doc) !== undefined ? 'bold' : undefined, textDecoration: Doc.GetT(this.doc, 'title', 'string', true) ? 'underline' : undefined, outline: this.doc === Doc.ActiveDashboard ? 'dashed 1px #06123232' : undefined, 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/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..b877cc36a 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,19 +309,20 @@ 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"/>} + <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>}> - <button className="antimodeMenu-button annotate" onPointerDown={this.gptSummarize} style={{ cursor: 'grab' }}> - <FontAwesomeIcon icon="comment-dots" size="lg" /> - </button> - </Tooltip> + <IconButton + tooltip="Summarize with AI" // + onPointerDown={this.gptSummarize} + icon={<FontAwesomeIcon icon="comment-dots" size="lg" />} + color={StrCast(Doc.UserDoc().userColor)} + /> )} <GPTPopup key="gptpopup" @@ -338,66 +336,74 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { mode={this.GPTMode} /> {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( - <IconButton - tooltip={'Click to Record Annotation'} + <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'} + <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' + <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'} + <IconButton + tooltip="Click/Drag to create cropped image" // onPointerDown={this.cropDown} - icon={<FontAwesomeIcon icon="image"/>} + 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/topbar/TopBar.tsx b/src/client/views/topbar/TopBar.tsx index ec4252eb8..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'; diff --git a/src/fields/URLField.ts b/src/fields/URLField.ts index 8db4d6003..8ac20b1e5 100644 --- a/src/fields/URLField.ts +++ b/src/fields/URLField.ts @@ -25,7 +25,7 @@ export abstract class URLField extends ObjectField { constructor(url: URL | string) { super(); if (typeof url === 'string') { - url = (url.startsWith('http') || url.startsWith('https')) ? new URL(url) : new URL(url, window.location.origin); + url = url.startsWith('http') ? new URL(url) : new URL(url, window.location.origin); } this.url = url; } |