mirror of
https://github.com/mattermost/focalboard.git
synced 2025-02-19 19:59:59 +02:00
[GH-438] Add basic web cliper browser extension (#1582)
This adds a rudimentary web clipper browser extension. It allows to save page titles and URLs into cards. URLs will be written into the first found card property of type 'url' (if any). Relates to: #438
This commit is contained in:
parent
2113cac6fd
commit
11c667b9bf
2
experiments/webext/.gitignore
vendored
Normal file
2
experiments/webext/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.parcel-cache
|
||||
web-ext-artifacts
|
3
experiments/webext/.parcelrc
Normal file
3
experiments/webext/.parcelrc
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@parcel/config-webextension"
|
||||
}
|
65
experiments/webext/README.md
Normal file
65
experiments/webext/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Focalboard Web Clipper Browser Extension ✂️
|
||||
|
||||
This is the Focalboard Web Clipper browser extension. It aims at supporting various use cases around converting web content from your browser directly into Focalboard cards.
|
||||
|
||||
⚠️ **Warning:** The extension is currently in an early and experimental state. Use it at your own risk only. Don't expect any eye candy.
|
||||
|
||||
## Status
|
||||
|
||||
The extension currently is in a proof-of-concept state with minimal functionality. The only supported use case at the time is building a read-later list. Things that work:
|
||||
|
||||
- Logging in to the Focalboard server from the extension settings
|
||||
- Selecting a board to capture cards into from the extension settings
|
||||
- Saving websites (title & URL) into cards from a page action (like e.g. Pocket does it)
|
||||
|
||||
Only Firefox was tested so far but polyfills have already been enabled so there's a good chance that it'll work in Chrome and maybe even Safari, too.
|
||||
|
||||
### Next Steps
|
||||
|
||||
We're really at the very beginning here so there's a lot to be done. Notable tasks include:
|
||||
|
||||
- Improve the React code by extracting components
|
||||
- Style the options and popup pages to mimic the look and feel of Focalboard
|
||||
- Replace the logo with something better (the current one was snatched from the Focalboard Windows app)
|
||||
- Link to the extension's options page from page action error messages
|
||||
- Clip parts of a website into image attachments on cards
|
||||
- Extract website content in reader mode into card descriptions
|
||||
- Optimise the logic for finding the first URL property (currently the whole board subtree has to be requested because there is no other API available)
|
||||
- Add some tests
|
||||
- Test the extension on Chrome / Safari and add infrastructure to facilitate this in future (e.g. `.web-ext-config.js`)
|
||||
- Add an onboarding (displayed after first install) and upboarding (displayed after update) page
|
||||
- Distribute the extension via the various browser add-on stores (ok, maybe too early 😜)
|
||||
|
||||
## Hacking
|
||||
|
||||
First, install dependencies with
|
||||
|
||||
```
|
||||
$ npm i
|
||||
```
|
||||
|
||||
You can then compile and bundle the code with
|
||||
|
||||
```
|
||||
$ npm run watchdev
|
||||
```
|
||||
|
||||
This will write output into `dist/dev/` and automatically recompile and bundle on any source change.
|
||||
|
||||
To run the extension in a separate Firefox instance, use
|
||||
|
||||
```
|
||||
$ npm run servedev
|
||||
```
|
||||
|
||||
Note that in the above commands you can substitue `dev` with `prod` to build and run the extension with production settings.
|
||||
|
||||
## Distribution
|
||||
|
||||
To build a distributable ZIP archive, run
|
||||
|
||||
```
|
||||
$ npm run build
|
||||
```
|
||||
|
||||
The archive will be placed into the `web-ext-artifacts` folder.
|
BIN
experiments/webext/icons/19.png
Normal file
BIN
experiments/webext/icons/19.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 KiB |
BIN
experiments/webext/icons/38.png
Normal file
BIN
experiments/webext/icons/38.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
BIN
experiments/webext/icons/48.png
Normal file
BIN
experiments/webext/icons/48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
experiments/webext/icons/96.png
Normal file
BIN
experiments/webext/icons/96.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.8 KiB |
34
experiments/webext/manifest.json
Normal file
34
experiments/webext/manifest.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Focalboard Web Clipper",
|
||||
"version": "0.1.0",
|
||||
"description": "Save websites directly into Focalboard",
|
||||
"icons": {
|
||||
"48": "icons/48.png",
|
||||
"96": "icons/96.png"
|
||||
},
|
||||
"page_action": {
|
||||
"browser_style": true,
|
||||
"default_icon": {
|
||||
"19": "icons/19.png",
|
||||
"38": "icons/38.png"
|
||||
},
|
||||
"default_title": "Save to Focalboard",
|
||||
"default_popup": "src/views/popup.html",
|
||||
"show_matches": ["<all_urls>"]
|
||||
},
|
||||
"options_ui": {
|
||||
"page": "src/views/options.html",
|
||||
"browser_style": true
|
||||
},
|
||||
"web_accessible_resources": [],
|
||||
"permissions": [
|
||||
"<all_urls>",
|
||||
"storage"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "focalboard-web-clipper@mattermost.com"
|
||||
}
|
||||
}
|
||||
}
|
23525
experiments/webext/package-lock.json
generated
Normal file
23525
experiments/webext/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
experiments/webext/package.json
Normal file
32
experiments/webext/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "focalboard-web-clipper",
|
||||
"version": "0.0.0",
|
||||
"targets": {
|
||||
"dev": {
|
||||
"sourceMap": {
|
||||
"inline": true,
|
||||
"inlineSources": true
|
||||
}
|
||||
},
|
||||
"prod": {}
|
||||
},
|
||||
"scripts": {
|
||||
"watchdev": "parcel watch manifest.json --target dev",
|
||||
"servedev": "web-ext run -s dist/dev/",
|
||||
"watchprod": "parcel watch manifest.json --target prod",
|
||||
"serveprod": "web-ext run -s dist/prod/",
|
||||
"build": "parcel build manifest.json --target prod && web-ext build -s dist/prod/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/config-webextension": "^2.0.0",
|
||||
"@parcel/transformer-sass": "^2.0.0",
|
||||
"@types/react": "^17.0.32",
|
||||
"@types/react-dom": "^17.0.10",
|
||||
"parcel": "^2.0.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"typescript": "^4.4.4",
|
||||
"web-ext": "^6.4.0",
|
||||
"webextension-polyfill-ts": "^0.26.0"
|
||||
}
|
||||
}
|
9
experiments/webext/src/utils/Board.ts
Normal file
9
experiments/webext/src/utils/Board.ts
Normal file
@ -0,0 +1,9 @@
|
||||
interface BoardFields {
|
||||
isTemplate: boolean
|
||||
}
|
||||
|
||||
export default interface Board {
|
||||
id: string
|
||||
title: string
|
||||
fields: BoardFields
|
||||
}
|
132
experiments/webext/src/utils/networking.ts
Normal file
132
experiments/webext/src/utils/networking.ts
Normal file
@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import Board from "../utils/Board"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
msCrypto: Crypto
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method: string, host: string, resource: string, body: any, token: string | null) {
|
||||
const response = await fetch(`${host}/api/v1/${resource}`, {
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Authorization': token ? `Bearer ${token}` : null
|
||||
} as HeadersInit,
|
||||
'body': body ? JSON.stringify(body) : null,
|
||||
'method': method
|
||||
})
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
if (json.error) {
|
||||
throw json.error
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
export async function logIn(host: string, username: string, password: string) {
|
||||
const json = await request('POST', host, 'login', { username: username, password: password, type: 'normal' }, null)
|
||||
return json.token
|
||||
}
|
||||
|
||||
export async function getBoards(host: string, token: string) {
|
||||
const json = await request('GET', host, 'workspaces/0/blocks?type=board', null, token) as Board[]
|
||||
return json.filter(board => !board.fields.isTemplate)
|
||||
}
|
||||
|
||||
export async function findUrlPropertyId(host: string, token: string, boardId: string) {
|
||||
const json = await request('GET', host, `workspaces/0/blocks/${boardId}/subtree`, null, token)
|
||||
for (let obj of json) {
|
||||
if (obj.type === 'board') {
|
||||
for (let property of obj.fields.cardProperties) {
|
||||
if (property.type === 'url') {
|
||||
return property.id
|
||||
}
|
||||
}
|
||||
break // Only one board in subtree, no need to continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createCard(host: string, token: string, boardId: string, urlPropertyId: string, title: string, url: string) {
|
||||
let properties = {} as any
|
||||
if (urlPropertyId) {
|
||||
properties[urlPropertyId] = url
|
||||
}
|
||||
const card = {
|
||||
id: createGuid(),
|
||||
schema: 1,
|
||||
workspaceId: '',
|
||||
parentId: boardId,
|
||||
rootId: boardId,
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
type: 'card',
|
||||
fields: {
|
||||
icon: null,
|
||||
properties: properties,
|
||||
contentOrder: [],
|
||||
isTemplate: false
|
||||
},
|
||||
title: title,
|
||||
createAt: Date.now(),
|
||||
updateAt: Date.now(),
|
||||
deleteAt: 0
|
||||
}
|
||||
await request('POST', host, 'workspaces/0/blocks', [card], token)
|
||||
}
|
||||
|
||||
function createGuid(): string {
|
||||
const data = randomArray(16)
|
||||
return '7' + base32encode(data, false)
|
||||
}
|
||||
|
||||
function randomArray(size: number): Uint8Array {
|
||||
const crypto = window.crypto || window.msCrypto
|
||||
const rands = new Uint8Array(size)
|
||||
if (crypto && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(rands)
|
||||
} else {
|
||||
for (let i = 0; i < size; i++) {
|
||||
rands[i] = Math.floor((Math.random() * 255))
|
||||
}
|
||||
}
|
||||
return rands
|
||||
}
|
||||
|
||||
const base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
|
||||
|
||||
function base32encode(data: Int8Array | Uint8Array | Uint8ClampedArray, pad: boolean): string {
|
||||
const dview = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
||||
let bits = 0
|
||||
let value = 0
|
||||
let output = ''
|
||||
|
||||
// adapted from https://github.com/LinusU/base32-encode
|
||||
for (let i = 0; i < dview.byteLength; i++) {
|
||||
value = (value << 8) | dview.getUint8(i)
|
||||
bits += 8
|
||||
|
||||
while (bits >= 5) {
|
||||
output += base32Alphabet[(value >>> (bits - 5)) & 31]
|
||||
bits -= 5
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += base32Alphabet[(value << (5 - bits)) & 31]
|
||||
}
|
||||
if (pad) {
|
||||
while ((output.length % 8) !== 0) {
|
||||
output += '='
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
29
experiments/webext/src/utils/settings.ts
Normal file
29
experiments/webext/src/utils/settings.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import browser from 'webextension-polyfill'
|
||||
|
||||
interface Settings {
|
||||
host: string | null
|
||||
username: string | null
|
||||
token: string | null
|
||||
boardId: string | null
|
||||
}
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
return browser.storage.sync.get(['host', 'username', 'token', 'boardId'])
|
||||
}
|
||||
|
||||
export function storeSettings(host: string, username: string, token: string | null, boardId: string | null) {
|
||||
console.log(`storing host ${host}`)
|
||||
return browser.storage.sync.set({ host: host, username: username, token: token, boardId: boardId })
|
||||
}
|
||||
|
||||
export function storeToken(value: string | null) {
|
||||
return browser.storage.sync.set({ token: value })
|
||||
}
|
||||
|
||||
export function storeBoardId(value: string | null) {
|
||||
return browser.storage.sync.set({ boardId: value })
|
||||
}
|
||||
|
40
experiments/webext/src/views/OptionsApp.scss
Normal file
40
experiments/webext/src/views/OptionsApp.scss
Normal file
@ -0,0 +1,40 @@
|
||||
/* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */
|
||||
/* See LICENSE.txt for license information. */
|
||||
|
||||
.OptionsApp {
|
||||
label {
|
||||
display: block;
|
||||
font-size: 90%;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
input[type=text], input[type=password], select {
|
||||
width: 20em;
|
||||
max-width: 100%;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
input[type=submit] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 1em 0 1em 0;
|
||||
|
||||
.in-progress {
|
||||
background-color: grey;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: lightgreen;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: lightpink;
|
||||
padding: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
111
experiments/webext/src/views/OptionsApp.tsx
Normal file
111
experiments/webext/src/views/OptionsApp.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, { ChangeEvent, MouseEvent, useEffect, useState } from "react"
|
||||
|
||||
import Board from "../utils/Board"
|
||||
import { getBoards, logIn } from "../utils/networking";
|
||||
import { loadSettings, storeSettings, storeToken, storeBoardId } from "../utils/settings";
|
||||
|
||||
import "./OptionsApp.scss"
|
||||
|
||||
export default function OptionsApp() {
|
||||
const [host, setHost] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [boards, setBoards] = useState([] as Board[])
|
||||
const [boardId, setBoardId] = useState(null as string | null)
|
||||
const [inProgress, setInProgress] = useState(false)
|
||||
const [error, setError] = useState(null as string | null)
|
||||
|
||||
useEffect(() => {
|
||||
async function initialiseBoards() {
|
||||
const settings = await loadSettings()
|
||||
if (settings.host) {
|
||||
setHost(settings.host)
|
||||
}
|
||||
if (settings.username) {
|
||||
setUsername(settings.username)
|
||||
}
|
||||
if (settings.token) {
|
||||
setToken(settings.token)
|
||||
}
|
||||
if (settings.boardId) {
|
||||
setBoardId(settings.boardId)
|
||||
}
|
||||
if (!settings.host || !settings.username || !settings.token) {
|
||||
setError('Unauthenticated')
|
||||
return
|
||||
}
|
||||
setInProgress(true)
|
||||
try {
|
||||
setBoards(await getBoards(settings.host, settings.token))
|
||||
} catch (error) {
|
||||
setError(`${error}`)
|
||||
} finally {
|
||||
setInProgress(false)
|
||||
}
|
||||
}
|
||||
initialiseBoards();
|
||||
}, [])
|
||||
|
||||
function onAuthenticateButtonClicked(event: MouseEvent) {
|
||||
authenticate(host, username, password)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
async function authenticate(host: string, username: string, password: string) {
|
||||
storeSettings(host, username, null, null)
|
||||
setBoards([])
|
||||
setBoardId(null)
|
||||
setInProgress(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = await logIn(host, username, password)
|
||||
storeToken(token)
|
||||
setToken(token)
|
||||
setBoards(await getBoards(host, token))
|
||||
const select = document.querySelector('select') as any
|
||||
select.value = null
|
||||
} catch (error) {
|
||||
setError(`${error}`)
|
||||
} finally {
|
||||
setInProgress(false)
|
||||
}
|
||||
}
|
||||
|
||||
function onBoardSelectionChanged(event: ChangeEvent) {
|
||||
const id = (event.target as HTMLSelectElement).value
|
||||
storeBoardId(id)
|
||||
setBoardId(id)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
return <div className="OptionsApp">
|
||||
<label>Focalboard host</label>
|
||||
<input type="text" value={host} onChange={e => setHost(e.target.value)}/>
|
||||
<label>Username</label>
|
||||
<input type="text" value={username} onChange={e => setUsername(e.target.value)}/>
|
||||
<label>Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}/>
|
||||
<input type="submit" value="Authenticate" onClick={onAuthenticateButtonClicked}/>
|
||||
<div className="status">
|
||||
{inProgress && <div className="in-progress">
|
||||
Connecting to Focalboard server...
|
||||
</div>}
|
||||
{!inProgress && !error && <div className="success">
|
||||
Token: <span>{token}</span>
|
||||
</div>}
|
||||
{!inProgress && error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
<br/>
|
||||
<br/>
|
||||
<label>Board</label>
|
||||
<select onChange={onBoardSelectionChanged}>
|
||||
{boards.map(board => <option value={board.id} selected={board.id === boardId}>{board.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
}
|
21
experiments/webext/src/views/PopupApp.scss
Normal file
21
experiments/webext/src/views/PopupApp.scss
Normal file
@ -0,0 +1,21 @@
|
||||
/* Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved. */
|
||||
/* See LICENSE.txt for license information. */
|
||||
|
||||
.PopupApp {
|
||||
.status {
|
||||
.in-progress {
|
||||
background-color: grey;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: lightgreen;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: lightpink;
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
}
|
55
experiments/webext/src/views/PopupApp.tsx
Normal file
55
experiments/webext/src/views/PopupApp.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
|
||||
import browser from 'webextension-polyfill'
|
||||
|
||||
import { createCard, findUrlPropertyId } from "../utils/networking";
|
||||
import { loadSettings } from "../utils/settings";
|
||||
|
||||
import "./PopupApp.scss"
|
||||
|
||||
export default function OptionsApp() {
|
||||
const [board, setBoard] = useState('')
|
||||
const [inProgress, setInProgress] = useState(false)
|
||||
const [error, setError] = useState(null as string | null)
|
||||
|
||||
useEffect(() => {
|
||||
async function createCardFromCurrentTab() {
|
||||
const settings = await loadSettings()
|
||||
if (!settings.host || !settings.token) {
|
||||
setError('Looks like you\'re unauthenticated. Please configure the extension\'s settings first.')
|
||||
return
|
||||
}
|
||||
if (!settings.boardId) {
|
||||
setError('Looks like you haven\'t selected a board to save to yet. Please configure the extension\'s settings first.')
|
||||
return
|
||||
}
|
||||
setInProgress(true)
|
||||
try {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true })
|
||||
const urlPropertyId = await findUrlPropertyId(settings.host as string, settings.token as string, settings.boardId as string)
|
||||
await createCard(settings.host as string, settings.token as string, settings.boardId as string, urlPropertyId, tabs[0].title, tabs[0].url)
|
||||
setBoard(`${settings.host}/${settings.boardId}`)
|
||||
} catch (error) {
|
||||
setError(`${error}`)
|
||||
} finally {
|
||||
setInProgress(false)
|
||||
}
|
||||
}
|
||||
createCardFromCurrentTab();
|
||||
}, [])
|
||||
|
||||
return <div className="PopupApp">
|
||||
<div className="status">
|
||||
{inProgress && <div className="in-progress">
|
||||
Saving to Focalboard...
|
||||
</div>}
|
||||
{!inProgress && !error && <div className="success">
|
||||
Saved to <a href={board} target="_blank">board</a>
|
||||
</div>}
|
||||
{!inProgress && error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
10
experiments/webext/src/views/options.html
Normal file
10
experiments/webext/src/views/options.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="options.tsx"></script>
|
||||
</body>
|
||||
</html>
|
10
experiments/webext/src/views/options.tsx
Normal file
10
experiments/webext/src/views/options.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
|
||||
import OptionsApp from "./OptionsApp"
|
||||
|
||||
const app = document.getElementById("app")
|
||||
ReactDOM.render(<OptionsApp/>, app)
|
10
experiments/webext/src/views/popup.html
Normal file
10
experiments/webext/src/views/popup.html
Normal file
@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="popup.tsx"></script>
|
||||
</body>
|
||||
</html>
|
10
experiments/webext/src/views/popup.tsx
Normal file
10
experiments/webext/src/views/popup.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
// Copyright (c) 2021-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
|
||||
import PopupApp from "./PopupApp"
|
||||
|
||||
const app = document.getElementById("app")
|
||||
ReactDOM.render(<PopupApp/>, app)
|
27
experiments/webext/tsconfig.json
Normal file
27
experiments/webext/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"incremental": false,
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node"
|
||||
},
|
||||
"include": [
|
||||
"."
|
||||
],
|
||||
"exclude": [
|
||||
".git",
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"pack"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user