diff options
author | Sophie Zhang <sophie_zhang@brown.edu> | 2023-07-20 01:11:44 -0400 |
---|---|---|
committer | Sophie Zhang <sophie_zhang@brown.edu> | 2023-07-20 01:11:44 -0400 |
commit | 0ca5a86e6f9ff85a32de109105b241d613aae326 (patch) | |
tree | 47504f51a7c195735a858cf0a8e0f76c3a81175e /src | |
parent | e11aa60b774d457cb016bb0f375ce092f0a733af (diff) | |
parent | ea217200f1c42e4d4b142abc9abd55ca49535c49 (diff) |
Merge branch 'sophie-report-manager' into sophie-ai-images
Diffstat (limited to 'src')
21 files changed, 2597 insertions, 625 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/documents/Documents.ts b/src/client/documents/Documents.ts index 5ef033e35..f3f645ca2 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1731,7 +1731,7 @@ export namespace DocUtils { return; } const full = { ...options, _width: 400, title: name }; - const pathname = Utils.prepend(result.accessPaths.agnostic.client); + const pathname = result.accessPaths.agnostic.client; const doc = await DocUtils.DocumentFromType(type, pathname, full, overwriteDoc); if (doc) { const proto = Doc.GetProto(doc); 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..f20c2baaa --- /dev/null +++ b/src/client/util/reportManager/ReportManager.tsx @@ -0,0 +1,409 @@ +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; + } + let formattedLinks: string[] = []; + this.setSubmitting(true); + if (this.formData.mediaFiles.length > 0) { + const links = await uploadFilesToServer(this.formData.mediaFiles); + console.log(links); + if (!links) { + return; + } + 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.'); + return; + } + + // Reset fields + this.setFormData(emptyReportForm); + this.setSubmitting(false); + await this.updateIssues(); + alert('Successfully submitted issue.'); + } + + /** + * 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..8f882c7f2 --- /dev/null +++ b/src/client/util/reportManager/ReportManagerComponents.tsx @@ -0,0 +1,360 @@ +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 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 { + 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; + } + }; + + 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..d8344220f --- /dev/null +++ b/src/client/util/reportManager/reportManagerUtils.ts @@ -0,0 +1,257 @@ +// 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 = 'https://browndash.com/'; + + 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 })), + true + ); + 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/MainView.tsx b/src/client/views/MainView.tsx index 59ee24b0e..7c1abc232 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'; diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx index 6105cc1b5..3a3b2fc38 100644 --- a/src/client/views/PropertiesButtons.tsx +++ b/src/client/views/PropertiesButtons.tsx @@ -56,24 +56,25 @@ export class PropertiesButtons extends React.Component<{}, {}> { propertyToggleBtn = (label: (on?: any) => string, property: string, tooltip: (on?: any) => string, icon: (on?: any) => any, onClick?: (dv: Opt<DocumentView>, doc: Doc, property: string) => void, useUserDoc?: boolean) => { const targetDoc = useUserDoc ? Doc.UserDoc() : this.selectedDoc; const onPropToggle = (dv: Opt<DocumentView>, doc: Doc, prop: string) => ((dv?.layoutDoc || doc)[prop] = (dv?.layoutDoc || doc)[prop] ? false : true); - return !targetDoc ? null : <Toggle - toggleStatus={BoolCast(targetDoc[property])} - text={label(targetDoc?.[property])} - color={StrCast(Doc.UserDoc().userColor)} - icon={icon(targetDoc?.[property] as any)} - iconPlacement={'left'} - align={'flex-start'} - fillWidth={true} - toggleType={ToggleType.BUTTON} - onClick={undoable(() => { - if (SelectionManager.Views().length > 1) { - SelectionManager.Views().forEach(dv => (onClick ?? onPropToggle)(dv, dv.rootDoc, property)); - } else if (targetDoc) (onClick ?? onPropToggle)(undefined, targetDoc, property); - }, property)} - /> + return !targetDoc ? null : ( + <Toggle + toggleStatus={BoolCast(targetDoc[property])} + text={label(targetDoc?.[property])} + color={StrCast(Doc.UserDoc().userColor)} + icon={icon(targetDoc?.[property] as any)} + iconPlacement={'left'} + align={'flex-start'} + fillWidth={true} + toggleType={ToggleType.BUTTON} + onClick={undoable(() => { + if (SelectionManager.Views().length > 1) { + SelectionManager.Views().forEach(dv => (onClick ?? onPropToggle)(dv, dv.rootDoc, property)); + } else if (targetDoc) (onClick ?? onPropToggle)(undefined, targetDoc, property); + }, property)} + /> + ); }; - // this implments a container pattern by marking the targetDoc (collection) as a lightbox // that always fits its contents to its container and that hides all other documents when // a link is followed that targets a 'lightbox' destination @@ -148,7 +149,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { @computed get clustersButton() { return this.propertyToggleBtn( - on => (on ?'DISABLE CLUSTERS' : 'HIGHLIGHT CLUSTERS'), + on => (on ? 'DISABLE CLUSTERS' : 'HIGHLIGHT CLUSTERS'), '_freeform_useClusters', on => `${on ? 'Hide' : 'Show'} clusters`, on => <FaBraille /> @@ -163,12 +164,13 @@ export class PropertiesButtons extends React.Component<{}, {}> { ); } - @computed get forceActiveButton() { //select text + @computed get forceActiveButton() { + //select text return this.propertyToggleBtn( - on => on ? 'INACTIVE INTERACTION' : 'ACTIVE INTERACTION', + on => (on ? 'INACTIVE INTERACTION' : 'ACTIVE INTERACTION'), '_forceActive', on => `${on ? 'Select to activate' : 'Contents always active'} `, - on => <MdTouchApp/> // 'eye' + on => <MdTouchApp /> // 'eye' ); } @@ -218,7 +220,8 @@ export class PropertiesButtons extends React.Component<{}, {}> { } @computed get captionButton() { - return this.propertyToggleBtn( //DEVELOPER + return this.propertyToggleBtn( + //DEVELOPER on => (on ? 'HIDE CAPTION' : 'SHOW CAPTION'), //'Caption', '_layout_showCaption', on => `${on ? 'Hide' : 'Show'} caption footer`, @@ -227,17 +230,19 @@ export class PropertiesButtons extends React.Component<{}, {}> { ); } - @computed get chromeButton() { // developer -- removing UI decoration + @computed get chromeButton() { + // developer -- removing UI decoration return this.propertyToggleBtn( - on => on ? 'ENABLE UI CONTROLS' : 'DISABLE UI CONTROLS', + on => (on ? 'ENABLE UI CONTROLS' : 'DISABLE UI CONTROLS'), '_chromeHidden', on => `${on ? 'Show' : 'Hide'} editing UI`, - on => on? <TbEditCircle/> : <TbEditCircleOff/> , // 'edit', + on => (on ? <TbEditCircle /> : <TbEditCircleOff />), // 'edit', (dv, doc) => ((dv?.rootDoc || doc)._chromeHidden = !(dv?.rootDoc || doc)._chromeHidden) ); } - @computed get layout_autoHeightButton() { // store previous dimensions to store old values + @computed get layout_autoHeightButton() { + // store previous dimensions to store old values return this.propertyToggleBtn( on => 'Auto\xA0Size', '_layout_autoHeight', @@ -251,7 +256,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { on => (on ? 'HIDE GRID' : 'DISPLAY GRID'), '_freeform_backgroundGrid', on => `Display background grid in collection`, - on => (on ? <MdGridOff /> :<MdGridOn /> ) //'border-all' + on => (on ? <MdGridOff /> : <MdGridOn />) //'border-all' ); } @@ -288,13 +293,14 @@ export class PropertiesButtons extends React.Component<{}, {}> { // } // ); // } - @computed get snapButton() { // THESE ARE NOT COMING + @computed get snapButton() { + // THESE ARE NOT COMING return this.propertyToggleBtn( on => (on ? 'HIDE SNAP LINES' : 'SHOW SNAP LINES'), 'freeform_snapLines', on => `Display snapping lines when objects are dragged`, on => <TfiBarChart />, //'th', - undefined, + undefined ); } @@ -346,11 +352,11 @@ export class PropertiesButtons extends React.Component<{}, {}> { const followLoc = this.selectedDoc._followLinkLocation; const linkedToLightboxView = () => LinkManager.Links(this.selectedDoc).some(link => LinkManager.getOppositeAnchor(link, this.selectedDoc)?._isLightbox); - if (followLoc === OpenWhere.lightbox && !linkedToLightboxView()) return 'linkInPlace' - else if (linkButton && followLoc === OpenWhere.addRight) return 'linkOnRight' - else if (linkButton && this.selectedDoc._followLinkLocation === OpenWhere.lightbox && linkedToLightboxView()) return 'enterPortal' - else if (ScriptCast(this.selectedDoc.onClick)?.script.originalScript.includes('toggleDetail')) return 'toggleDetail' - else return 'nothing' + if (followLoc === OpenWhere.lightbox && !linkedToLightboxView()) return 'linkInPlace'; + else if (linkButton && followLoc === OpenWhere.addRight) return 'linkOnRight'; + else if (linkButton && this.selectedDoc._followLinkLocation === OpenWhere.lightbox && linkedToLightboxView()) return 'enterPortal'; + else if (ScriptCast(this.selectedDoc.onClick)?.script.originalScript.includes('toggleDetail')) return 'toggleDetail'; + else return 'nothing'; } @computed @@ -362,22 +368,20 @@ export class PropertiesButtons extends React.Component<{}, {}> { ['linkInPlace', 'Open Link in Lightbox'], ['linkOnRight', 'Open Link on Right'], ]; - + const items: IListItemProps[] = buttonList.map(value => { - return ( - { - text: value[1], - val: value[1], - } - ); + return { + text: value[1], + val: value[1], + }; }); - console.log("click val: ", this.onClickVal) + console.log('click val: ', this.onClickVal); return !this.selectedDoc ? null : ( <Dropdown - tooltip={'Choose onClick behavior'} + tooltip={'Choose onClick behavior'} items={items} selectedVal={this.onClickVal} - setSelectedVal={(val) => this.handleOptionChange(val as string)} + setSelectedVal={val => this.handleOptionChange(val as string)} title={'Choose onClick behaviour'} color={StrCast(Doc.UserDoc().userColor)} dropdownType={DropdownType.SELECT} diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index bdc0e1599..db000d5de 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -238,11 +238,11 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo textX, textY, // fully connected - pt1: pt1, - pt2: pt2, - // gaps between - // pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], - // pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13], + // 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/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index b0716d78a..4ce359f3f 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -306,7 +306,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp choosePath(url: URL) { const lower = url.href.toLowerCase(); if (url.protocol === 'data') return url.href; - if (url.href.indexOf(window.location.origin) === -1) return Utils.CorsProxy(url.href); + if (url.href.indexOf(window.location.origin) === -1 && url.href.indexOf("dashblobstore") === -1) return Utils.CorsProxy(url.href); if (!/\.(png|jpg|jpeg|gif|webp)$/.test(lower)) return `/assets/unknown-file-icon-hi.png`; const ext = extname(url.href); 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/topbar/TopBar.tsx b/src/client/views/topbar/TopBar.tsx index 79f41fe9d..b82f20dbd 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 8ac20b1e5..8db4d6003 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') ? new URL(url) : new URL(url, window.location.origin); + url = (url.startsWith('http') || url.startsWith('https')) ? new URL(url) : new URL(url, window.location.origin); } this.url = url; } diff --git a/src/server/ApiManagers/AzureManager.ts b/src/server/ApiManagers/AzureManager.ts new file mode 100644 index 000000000..12bb98ad0 --- /dev/null +++ b/src/server/ApiManagers/AzureManager.ts @@ -0,0 +1,67 @@ +import { ContainerClient, BlobServiceClient } from "@azure/storage-blob"; +import * as fs from "fs"; +import { Readable, Stream } from "stream"; +const AZURE_STORAGE_CONNECTION_STRING = process.env.AZURE_STORAGE_CONNECTION_STRING; + +export class AzureManager { + private _containerClient: ContainerClient; + private _blobServiceClient: BlobServiceClient; + private static _instance: AzureManager | undefined; + + public static CONTAINER_NAME = "dashmedia"; + public static STORAGE_ACCOUNT_NAME = "dashblobstore"; + + constructor() { + if (!AZURE_STORAGE_CONNECTION_STRING) { + throw new Error("Azure Storage Connection String Not Found"); + } + this._blobServiceClient = BlobServiceClient.fromConnectionString(AZURE_STORAGE_CONNECTION_STRING); + this._containerClient = this.BlobServiceClient.getContainerClient(AzureManager.CONTAINER_NAME); + } + + public static get Instance() { + return this._instance = this._instance ?? new AzureManager(); + } + + public get BlobServiceClient() { + return this._blobServiceClient; + } + + public get ContainerClient() { + return this._containerClient; + } + + public static UploadBlob(filename: string, filepath: string, filetype: string) { + const blockBlobClient = this.Instance.ContainerClient.getBlockBlobClient(filename); + const blobOptions = { blobHTTPHeaders: { blobContentType: filetype }}; + const stream = fs.createReadStream(filepath); + return blockBlobClient.uploadStream(stream, undefined, undefined, blobOptions); + } + + public static UploadBlobStream(stream: Readable, filename: string, filetype: string) { + const blockBlobClient = this.Instance.ContainerClient.getBlockBlobClient(filename); + const blobOptions = { blobHTTPHeaders: { blobContentType: filetype }}; + return blockBlobClient.uploadStream(stream, undefined, undefined, blobOptions); + } + + public static DeleteBlob(filename: string) { + const blockBlobClient = this.Instance.ContainerClient.getBlockBlobClient(filename); + return blockBlobClient.deleteIfExists(); + } + + public static async GetBlobs() { + const foundBlobs = []; + for await (const blob of this.Instance.ContainerClient.listBlobsFlat()) { + console.log(`${blob.name}`); + + const blobItem = { + url : `https://${AzureManager.STORAGE_ACCOUNT_NAME}.blob.core.windows.net/${AzureManager.CONTAINER_NAME}/${blob.name}`, + name : blob.name + } + + foundBlobs.push(blobItem); + } + + return foundBlobs; + } +} diff --git a/src/server/DashUploadUtils.ts b/src/server/DashUploadUtils.ts index eaaac4e6d..bff60568b 100644 --- a/src/server/DashUploadUtils.ts +++ b/src/server/DashUploadUtils.ts @@ -6,7 +6,7 @@ import { createReadStream, createWriteStream, existsSync, readFileSync, rename, import * as path from 'path'; import { basename } from 'path'; import * as sharp from 'sharp'; -import { Stream } from 'stream'; +import { Readable, Stream } from 'stream'; import { filesDirectory, publicDirectory } from '.'; import { Opt } from '../fields/Doc'; import { ParsedPDF } from '../server/PdfTypes'; @@ -17,6 +17,8 @@ import { resolvedServerUrl } from './server_Initialization'; import { AcceptableMedia, Upload } from './SharedMediaTypes'; import request = require('request-promise'); import formidable = require('formidable'); +import { AzureManager } from './ApiManagers/AzureManager'; +import axios from 'axios'; const spawn = require('child_process').spawn; const { exec } = require('child_process'); const parse = require('pdf-parse'); @@ -42,6 +44,10 @@ function isLocal() { return /Dash-Web[0-9]*[\\\/]src[\\\/]server[\\\/]public[\\\/](.*)/; } +function usingAzure(){ + return process.env.USE_AZURE === 'true'; +} + export namespace DashUploadUtils { export interface Size { width: number; @@ -61,6 +67,9 @@ export namespace DashUploadUtils { const size = 'content-length'; const type = 'content-type'; + const BLOBSTORE_URL = process.env.BLOBSTORE_URL; + const RESIZE_FUNCTION_URL = process.env.RESIZE_FUNCTION_URL; + const { imageFormats, videoFormats, applicationFormats, audioFormats } = AcceptableMedia; //TODO:glr export async function concatVideos(filePaths: string[]): Promise<Upload.AccessPathInfo> { @@ -182,6 +191,7 @@ export namespace DashUploadUtils { } export async function upload(file: File, overwriteGuid?: string): Promise<Upload.FileResponse> { + const isAzureOn = usingAzure(); const { type, path, name } = file; const types = type?.split('/') ?? []; uploadProgress.set(overwriteGuid ?? name, 'uploading'); // If the client sent a guid it uses to track upload progress, use that guid. Otherwise, use the file's name. @@ -478,17 +488,48 @@ export namespace DashUploadUtils { }; } + /** + * UploadInspectedImage() takes an image with its metadata. If Azure is being used, this method will call the Azure function + * to execute the resizing. If Azure is not used, the function will begin to resize the image. + * + * @param metadata metadata object from InspectImage() + * @param filename the name of the file + * @param prefix the prefix to use, which will be set to '' if none is provided. + * @param cleanUp a boolean indicating if the files should be deleted after upload. True by default. + * @returns the accessPaths for the resized files. + */ export const UploadInspectedImage = async (metadata: Upload.InspectionResults, filename?: string, prefix = '', cleanUp = true): Promise<Upload.ImageInformation> => { const { requestable, source, ...remaining } = metadata; const resolved = filename || `${prefix}upload_${Utils.GenerateGuid()}.${remaining.contentType.split('/')[1].toLowerCase()}`; const { images } = Directory; const information: Upload.ImageInformation = { accessPaths: { - agnostic: getAccessPaths(images, resolved), + agnostic: usingAzure() ? { + client: BLOBSTORE_URL + `/${filename}`, + server: BLOBSTORE_URL + `/${filename}` + } : getAccessPaths(images, resolved) }, ...metadata, }; - const writtenFiles = await outputResizedImages(() => request(requestable), resolved, pathToDirectory(Directory.images)); + let writtenFiles: { [suffix: string] : string}; + + if (usingAzure()) { + if (!RESIZE_FUNCTION_URL) { + throw new Error("Resize function URL not provided."); + } + + try { + const response = await axios.post(RESIZE_FUNCTION_URL, { + url: requestable + }); + writtenFiles = response.data.writtenFiles; + } catch (err) { + console.error(err); + writtenFiles = {}; + } + } else { + writtenFiles = await outputResizedImages(() => request(requestable), resolved, pathToDirectory(Directory.images)); + } for (const suffix of Object.keys(writtenFiles)) { information.accessPaths[suffix] = getAccessPaths(images, writtenFiles[suffix]); } @@ -533,6 +574,15 @@ export namespace DashUploadUtils { force: true, }; + /** + * outputResizedImages takes in a readable stream and resizes the images according to the sizes defined at the top of this file. + * + * The new images will be saved to the server with the corresponding prefixes. + * @param streamProvider a Stream of the image to process, taken from the /parsed_files location + * @param outputFileName the basename (No suffix) of the outputted file. + * @param outputDirectory the directory to output to, usually Directory.Images + * @returns a map with suffixes as keys and resized filenames as values. + */ export async function outputResizedImages(streamProvider: () => Stream | Promise<Stream>, outputFileName: string, outputDirectory: string) { const writtenFiles: { [suffix: string]: string } = {}; for (const { resizer, suffix } of resizers(path.extname(outputFileName))) { @@ -549,6 +599,11 @@ export namespace DashUploadUtils { return writtenFiles; } + /** + * define the resizers to use + * @param ext the extension + * @returns an array of resizer functions from sharp + */ function resizers(ext: string): DashUploadUtils.ImageResizer[] { return [ { suffix: SizeSuffix.Original }, |