mirror of
https://github.com/mattermost/focalboard.git
synced 2025-02-16 19:47:23 +02:00
First commit!
This commit is contained in:
commit
b5b294a54c
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@ -0,0 +1 @@
|
||||
node_modules/
|
47
.eslintrc
Normal file
47
.eslintrc
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"extends": "airbnb",
|
||||
"env": {
|
||||
"node": true,
|
||||
"es6": true,
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"indent": [2, "tab", {"SwitchCase": 1}],
|
||||
"no-tabs": 0,
|
||||
"comma-dangle": 0,
|
||||
"max-len": ["warn", { "code": 140 }],
|
||||
"operator-linebreak": [2, "after"],
|
||||
"prefer-destructuring": ["warn", {
|
||||
"array": false,
|
||||
"object": true
|
||||
}, {
|
||||
"enforceForRenamedProperties": false
|
||||
}],
|
||||
"no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
|
||||
"func-names": [2, "as-needed"],
|
||||
|
||||
"import/prefer-default-export": 0,
|
||||
"import/extensions": [2, "ignorePackages"],
|
||||
"import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
|
||||
|
||||
"no-unexpected-multiline": 1,
|
||||
"no-lonely-if": 0,
|
||||
"no-nested-ternary": 0,
|
||||
"class-methods-use-this": 1,
|
||||
"lines-between-class-members": ["warn", { "exceptAfterSingleLine": true }],
|
||||
"no-restricted-syntax": ["error", "ForInStatement", "LabeledStatement", "WithStatement"],
|
||||
"no-console": 0,
|
||||
"no-alert": 2,
|
||||
"object-shorthand": ["error", "properties"],
|
||||
"no-bitwise": ["error", { "int32Hint": true }],
|
||||
"no-param-reassign": [2, { "props": false }],
|
||||
"quotes": [2, "double", { "allowTemplateLiterals": true }],
|
||||
"brace-style": ["warn", "1tbs", { "allowSingleLine": true }],
|
||||
"object-curly-newline": ["error", {
|
||||
"ObjectExpression": { "consistent": true },
|
||||
"ObjectPattern": { "multiline": true },
|
||||
"ImportDeclaration": "never",
|
||||
"ExportDeclaration": { "multiline": true, "minProperties": 3 }
|
||||
}]
|
||||
}
|
||||
}
|
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
# Created by https://www.gitignore.io/api/node
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (http://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directory
|
||||
# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git
|
||||
node_modules
|
||||
dist
|
||||
pack
|
||||
bin
|
||||
debug
|
||||
__debug_bin
|
||||
files
|
||||
octo.db
|
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"semi": false
|
||||
}
|
25
.vscode/launch.json
vendored
Normal file
25
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Go: Launch Server",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/server/main",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Attach by Process ID",
|
||||
"processId": "${command:PickProcess}",
|
||||
"request": "attach",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"type": "pwa-node"
|
||||
}
|
||||
]
|
||||
}
|
36
Makefile
Normal file
36
Makefile
Normal file
@ -0,0 +1,36 @@
|
||||
.PHONY: prebuild clean cleanall pack packdev builddev build watch go goUbuntu
|
||||
|
||||
all: build
|
||||
|
||||
GOMAIN = ./server/main
|
||||
GOBIN = ./bin/octoserver
|
||||
|
||||
pack:
|
||||
npm run pack
|
||||
|
||||
packdev:
|
||||
npm run packdev
|
||||
|
||||
go:
|
||||
go build -o $(GOBIN) $(GOMAIN)
|
||||
|
||||
goUbuntu:
|
||||
env GOOS=linux GOARCH=amd64 go build -o $(GOBIN) $(GOMAIN)
|
||||
|
||||
builddev: packdev go
|
||||
|
||||
build: pack go
|
||||
|
||||
watch:
|
||||
npm run watchdev
|
||||
|
||||
prebuild:
|
||||
npm install
|
||||
|
||||
clean:
|
||||
rm -rf bin
|
||||
rm -rf dist
|
||||
rm -rf pack
|
||||
|
||||
cleanall: clean
|
||||
rm -rf node_modules
|
9
config.json
Normal file
9
config.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"port": 8000,
|
||||
"dbtype": "sqlite3",
|
||||
"dbconfig": "./octo.db",
|
||||
"postgres_dbconfig": "dbname=octo sslmode=disable",
|
||||
"useSSL": false,
|
||||
"webpath": "./pack",
|
||||
"filespath": "./files"
|
||||
}
|
35
html-templates/index.ejs
Normal file
35
html-templates/index.ejs
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<link rel="stylesheet" href="/images.css">
|
||||
<link rel="stylesheet" href="/colors.css">
|
||||
|
||||
<script>
|
||||
window.location.href = "/boards"
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body class="container">
|
||||
<header id="header">
|
||||
<a href="/">OCTO</a>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<p>
|
||||
<a href="boards">All Boards</a>
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<footer id="footer">
|
||||
</footer>
|
||||
|
||||
<div id="overlay">
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
32
html-templates/page.ejs
Normal file
32
html-templates/page.ejs
Normal file
@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
|
||||
<link rel="stylesheet" href="/easymde.min.css">
|
||||
<link rel="stylesheet" href="/main.css">
|
||||
<link rel="stylesheet" href="/images.css">
|
||||
<link rel="stylesheet" href="/colors.css">
|
||||
</head>
|
||||
|
||||
<body class="container">
|
||||
<header id="header">
|
||||
<a href="/">OCTO</a>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
</main>
|
||||
|
||||
<footer id="footer">
|
||||
</footer>
|
||||
|
||||
<div id="overlay">
|
||||
</div>
|
||||
|
||||
<div id="modal">
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
5231
package-lock.json
generated
Normal file
5231
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "mattermost-octo-tasks",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"pack": "NODE_ENV=production webpack --config webpack.js",
|
||||
"packdev": "NODE_ENV=dev webpack --config webpack.dev.js",
|
||||
"watchdev": "NODE_ENV=dev webpack --watch --config webpack.dev.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^1.1.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-simplemde-editor": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/react": "^16.9.49",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"copy-webpack-plugin": "^6.0.3",
|
||||
"file-loader": "^6.1.0",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"terser-webpack-plugin": "^4.1.0",
|
||||
"ts-loader": "^8.0.3",
|
||||
"typescript": "^4.0.2",
|
||||
"webpack": "^4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-merge": "^5.1.2"
|
||||
}
|
||||
}
|
73
server/main/config.go
Normal file
73
server/main/config.go
Normal file
@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Configuration is the app configuration stored in a json file
|
||||
type Configuration struct {
|
||||
ServerRoot string `json:"serverRoot"`
|
||||
Port int `json:"port"`
|
||||
DBType string `json:"dbtype"`
|
||||
DBConfigString string `json:"dbconfig"`
|
||||
UseSSL bool `json:"useSSL"`
|
||||
WebPath string `json:"webpath"`
|
||||
FilesPath string `json:"filespath"`
|
||||
}
|
||||
|
||||
func readConfigFile() Configuration {
|
||||
fileName := "config.json"
|
||||
if !fileExists(fileName) {
|
||||
log.Println(`config.json not found, using default settings`)
|
||||
return Configuration{}
|
||||
}
|
||||
|
||||
file, _ := os.Open(fileName)
|
||||
defer file.Close()
|
||||
decoder := json.NewDecoder(file)
|
||||
configuration := Configuration{}
|
||||
err := decoder.Decode(&configuration)
|
||||
if err != nil {
|
||||
log.Fatal("Invalid config.json", err)
|
||||
}
|
||||
|
||||
// Apply defaults
|
||||
if len(configuration.ServerRoot) < 1 {
|
||||
configuration.ServerRoot = "http://localhost"
|
||||
}
|
||||
|
||||
if configuration.Port == 0 {
|
||||
configuration.Port = 8000
|
||||
}
|
||||
|
||||
if len(configuration.DBType) < 1 {
|
||||
configuration.DBType = "sqlite3"
|
||||
}
|
||||
|
||||
if len(configuration.DBConfigString) < 1 {
|
||||
configuration.DBConfigString = "./octo.db"
|
||||
}
|
||||
|
||||
if len(configuration.WebPath) < 1 {
|
||||
configuration.WebPath = "./pack"
|
||||
}
|
||||
|
||||
if len(configuration.FilesPath) < 1 {
|
||||
configuration.FilesPath = "./files"
|
||||
}
|
||||
|
||||
log.Println("readConfigFile")
|
||||
log.Printf("%+v", configuration)
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
func fileExists(filename string) bool {
|
||||
info, err := os.Stat(filename)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
56
server/main/listenerSession.go
Normal file
56
server/main/listenerSession.go
Normal file
@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// BlockIDClientPair is a tuple of BlockID and WebSocket connection
|
||||
type BlockIDClientPair struct {
|
||||
BlockID string
|
||||
Client *websocket.Conn
|
||||
}
|
||||
|
||||
// ListenerSession is a WebSocket session that is notified of changes to blocks
|
||||
type ListenerSession struct {
|
||||
mu sync.RWMutex
|
||||
blockIDClientPairs []BlockIDClientPair
|
||||
}
|
||||
|
||||
// AddListener adds a listener for a blockID's change
|
||||
func (s *ListenerSession) AddListener(client *websocket.Conn, blockID string) {
|
||||
var p = BlockIDClientPair{Client: client, BlockID: blockID}
|
||||
s.mu.Lock()
|
||||
s.blockIDClientPairs = append(s.blockIDClientPairs, p)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// RemoveListener removes a webSocket listener
|
||||
func (s *ListenerSession) RemoveListener(client *websocket.Conn) {
|
||||
s.mu.Lock()
|
||||
var newValue = []BlockIDClientPair{}
|
||||
for _, p := range s.blockIDClientPairs {
|
||||
if p.Client != client {
|
||||
newValue = append(newValue, p)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.blockIDClientPairs = newValue
|
||||
}
|
||||
|
||||
// GetListeners returns the listeners to a blockID's changes
|
||||
func (s *ListenerSession) GetListeners(blockID string) []*websocket.Conn {
|
||||
var results = []*websocket.Conn{}
|
||||
|
||||
s.mu.Lock()
|
||||
for _, p := range s.blockIDClientPairs {
|
||||
if p.BlockID == blockID {
|
||||
results = append(results, p.Client)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return results
|
||||
}
|
472
server/main/main.go
Normal file
472
server/main/main.go
Normal file
@ -0,0 +1,472 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io/ioutil"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"../utils"
|
||||
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var config Configuration
|
||||
|
||||
// WebsocketMsg is send on block changes
|
||||
type WebsocketMsg struct {
|
||||
Action string `json:"action"`
|
||||
BlockID string `json:"blockId"`
|
||||
}
|
||||
|
||||
// A single session for now
|
||||
var session = new(ListenerSession)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
|
||||
func serveWebFile(w http.ResponseWriter, r *http.Request, relativeFilePath string) {
|
||||
folderPath := config.WebPath
|
||||
filePath := filepath.Join(folderPath, relativeFilePath)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func handleStaticFile(r *mux.Router, requestPath string, filePath string, contentType string) {
|
||||
r.HandleFunc(requestPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("handleStaticFile: %s", requestPath)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
serveWebFile(w, r, filePath)
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// REST APIs
|
||||
|
||||
func handleGetBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
parentID := query.Get("parent_id")
|
||||
blockType := query.Get("type")
|
||||
|
||||
var blocks []string
|
||||
if len(blockType) > 0 {
|
||||
blocks = getBlocksWithParentAndType(parentID, blockType)
|
||||
} else {
|
||||
blocks = getBlocksWithParent(parentID)
|
||||
}
|
||||
log.Printf("GetBlocks parentID: %s, %d result(s)", parentID, len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, 200, response)
|
||||
}
|
||||
|
||||
func handlePostBlocks(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blockMaps []map[string]interface{}
|
||||
err = json.Unmarshal([]byte(requestBody), &blockMaps)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, ``)
|
||||
return
|
||||
}
|
||||
|
||||
var blockIDsToNotify = []string{}
|
||||
uniqueBlockIDs := make(map[string]bool)
|
||||
|
||||
for _, blockMap := range blockMaps {
|
||||
jsonBytes, err := json.Marshal(blockMap)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
block := blockFromMap(blockMap)
|
||||
|
||||
// Error checking
|
||||
if len(block.Type) < 1 {
|
||||
errorResponse(w, 500, fmt.Sprintf(`{"description": "missing type", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.CreateAt < 1 {
|
||||
errorResponse(w, 500, fmt.Sprintf(`{"description": "invalid createAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
if block.UpdateAt < 1 {
|
||||
errorResponse(w, 500, fmt.Sprintf(`{"description": "invalid updateAt", "id": "%s"}`, block.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if !uniqueBlockIDs[block.ID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ID)
|
||||
}
|
||||
if len(block.ParentID) > 0 && !uniqueBlockIDs[block.ParentID] {
|
||||
blockIDsToNotify = append(blockIDsToNotify, block.ParentID)
|
||||
}
|
||||
|
||||
insertBlock(block, string(jsonBytes))
|
||||
}
|
||||
|
||||
broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
|
||||
log.Printf("POST Blocks %d block(s)", len(blockMaps))
|
||||
jsonResponse(w, 200, "{}")
|
||||
}
|
||||
|
||||
func handleDeleteBlock(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
var blockIDsToNotify = []string{blockID}
|
||||
|
||||
parentID := getParentID(blockID)
|
||||
|
||||
if len(parentID) > 0 {
|
||||
blockIDsToNotify = append(blockIDsToNotify, parentID)
|
||||
}
|
||||
|
||||
deleteBlock(blockID)
|
||||
|
||||
broadcastBlockChangeToWebsocketClients(blockIDsToNotify)
|
||||
|
||||
log.Printf("DELETE Block %s", blockID)
|
||||
jsonResponse(w, 200, "{}")
|
||||
}
|
||||
|
||||
func handleGetSubTree(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
blockID := vars["blockID"]
|
||||
|
||||
blocks := getSubTree(blockID)
|
||||
|
||||
log.Printf("GetSubTree blockID: %s, %d result(s)", blockID, len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, 200, response)
|
||||
}
|
||||
|
||||
func handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
blocks := getAllBlocks()
|
||||
|
||||
log.Printf("EXPORT Blocks, %d result(s)", len(blocks))
|
||||
response := `[` + strings.Join(blocks[:], ",") + `]`
|
||||
jsonResponse(w, 200, response)
|
||||
}
|
||||
|
||||
func handleImport(w http.ResponseWriter, r *http.Request) {
|
||||
requestBody, err := ioutil.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Catch panics from parse errors, etc.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf(`ERROR: %v`, r)
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
var blockMaps []map[string]interface{}
|
||||
err = json.Unmarshal([]byte(requestBody), &blockMaps)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, ``)
|
||||
return
|
||||
}
|
||||
|
||||
for _, blockMap := range blockMaps {
|
||||
jsonBytes, err := json.Marshal(blockMap)
|
||||
if err != nil {
|
||||
errorResponse(w, 500, `{}`)
|
||||
return
|
||||
}
|
||||
|
||||
block := blockFromMap(blockMap)
|
||||
insertBlock(block, string(jsonBytes))
|
||||
}
|
||||
|
||||
log.Printf("IMPORT Blocks %d block(s)", len(blockMaps))
|
||||
jsonResponse(w, 200, "{}")
|
||||
}
|
||||
|
||||
// File upload
|
||||
|
||||
func handleServeFile(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
filename := vars["filename"]
|
||||
|
||||
contentType := "image/jpg"
|
||||
fileExtension := strings.ToLower(filepath.Ext(filename))
|
||||
if fileExtension == "png" {
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
|
||||
folderPath := config.FilesPath
|
||||
filePath := filepath.Join(folderPath, filename)
|
||||
http.ServeFile(w, r, filePath)
|
||||
}
|
||||
|
||||
func handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Println(`handleUploadFile`)
|
||||
file, handle, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
log.Printf(`handleUploadFile, filename: %s`, handle.Filename)
|
||||
|
||||
saveFile(w, file, handle)
|
||||
}
|
||||
|
||||
func saveFile(w http.ResponseWriter, file multipart.File, handle *multipart.FileHeader) {
|
||||
data, err := ioutil.ReadAll(file)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: File extension includes the dot
|
||||
fileExtension := strings.ToLower(filepath.Ext(handle.Filename))
|
||||
if fileExtension == ".jpeg" {
|
||||
fileExtension = ".jpg"
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf(`%s%s`, utils.CreateGUID(), fileExtension)
|
||||
|
||||
folderPath := config.FilesPath
|
||||
filePath := filepath.Join(folderPath, filename)
|
||||
os.MkdirAll(folderPath, os.ModePerm)
|
||||
err = ioutil.WriteFile(filePath, data, 0666)
|
||||
if err != nil {
|
||||
jsonResponse(w, http.StatusInternalServerError, `{}`)
|
||||
return
|
||||
}
|
||||
url := fmt.Sprintf(`%s/files/%s`, config.ServerRoot, filename)
|
||||
log.Printf(`saveFile, url: %s`, url)
|
||||
json := fmt.Sprintf(`{ "url": "%s" }`, url)
|
||||
jsonResponse(w, http.StatusOK, json)
|
||||
}
|
||||
|
||||
// Response helpers
|
||||
|
||||
func jsonResponse(w http.ResponseWriter, code int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
func errorResponse(w http.ResponseWriter, code int, message string) {
|
||||
log.Printf("%d ERROR", code)
|
||||
w.WriteHeader(code)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------------------
|
||||
// WebSocket OnChange listener
|
||||
|
||||
func handleWebSocketOnChange(w http.ResponseWriter, r *http.Request) {
|
||||
// Upgrade initial GET request to a websocket
|
||||
ws, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// TODO: Auth
|
||||
|
||||
query := r.URL.Query()
|
||||
blockID := query.Get("id")
|
||||
log.Printf("CONNECT WebSocket onChange, blockID: %s, client: %s", blockID, ws.RemoteAddr())
|
||||
|
||||
// Make sure we close the connection when the function returns
|
||||
defer func() {
|
||||
log.Printf("DISCONNECT WebSocket onChange, blockID: %s, client: %s", blockID, ws.RemoteAddr())
|
||||
|
||||
// Remove client from listeners
|
||||
session.RemoveListener(ws)
|
||||
|
||||
ws.Close()
|
||||
}()
|
||||
|
||||
// Register our new client
|
||||
session.AddListener(ws, blockID)
|
||||
|
||||
// TODO: Implement WebSocket message pump
|
||||
// Simple message handling loop
|
||||
for {
|
||||
_, _, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("ERROR WebSocket onChange, blockID: %s, client: %s, err: %v", blockID, ws.RemoteAddr(), err)
|
||||
session.RemoveListener(ws)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastBlockChangeToWebsocketClients(blockIDs []string) {
|
||||
for _, blockID := range blockIDs {
|
||||
listeners := session.GetListeners(blockID)
|
||||
log.Printf("%d listener(s) for blockID: %s", len(listeners), blockID)
|
||||
|
||||
if listeners != nil {
|
||||
var message = WebsocketMsg{
|
||||
Action: "UPDATE_BLOCK",
|
||||
BlockID: blockID,
|
||||
}
|
||||
for _, listener := range listeners {
|
||||
log.Printf("Broadcast change, blockID: %s, remoteAddr: %s", blockID, listener.RemoteAddr())
|
||||
err := listener.WriteJSON(message)
|
||||
if err != nil {
|
||||
log.Printf("broadcast error: %v", err)
|
||||
listener.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isProcessRunning(pid int) bool {
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = process.Signal(syscall.Signal(0))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func monitorPid(pid int) {
|
||||
log.Printf("Monitoring PID: %d", pid)
|
||||
go func() {
|
||||
for {
|
||||
if !isProcessRunning(pid) {
|
||||
log.Printf("Monitored process not found, exiting.")
|
||||
os.Exit(1)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func main() {
|
||||
// config.json file
|
||||
config = readConfigFile()
|
||||
|
||||
// Command line args
|
||||
pMonitorPid := flag.Int("monitorpid", -1, "a process ID")
|
||||
pPort := flag.Int("port", config.Port, "the port number")
|
||||
flag.Parse()
|
||||
|
||||
if pMonitorPid != nil && *pMonitorPid > 0 {
|
||||
monitorPid(*pMonitorPid)
|
||||
}
|
||||
|
||||
if pPort != nil && *pPort > 0 && *pPort != config.Port {
|
||||
// Override port
|
||||
log.Printf("Port from commandline: %d", *pPort)
|
||||
config.Port = *pPort
|
||||
}
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Static files
|
||||
handleStaticFile(r, "/", "index.html", "text/html; charset=utf-8")
|
||||
handleStaticFile(r, "/boards", "boards.html", "text/html; charset=utf-8")
|
||||
handleStaticFile(r, "/board", "board.html", "text/html; charset=utf-8")
|
||||
|
||||
handleStaticFile(r, "/boardsPage.js", "boardsPage.js", "text/javascript; charset=utf-8")
|
||||
handleStaticFile(r, "/boardPage.js", "boardPage.js", "text/javascript; charset=utf-8")
|
||||
|
||||
handleStaticFile(r, "/favicon.ico", "static/favicon.svg", "image/svg+xml; charset=utf-8")
|
||||
|
||||
handleStaticFile(r, "/easymde.min.css", "static/easymde.min.css", "text/css")
|
||||
handleStaticFile(r, "/main.css", "static/main.css", "text/css")
|
||||
handleStaticFile(r, "/colors.css", "static/colors.css", "text/css")
|
||||
handleStaticFile(r, "/images.css", "static/images.css", "text/css")
|
||||
|
||||
// APIs
|
||||
r.HandleFunc("/api/v1/blocks", handleGetBlocks).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks", handlePostBlocks).Methods("POST")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}", handleDeleteBlock).Methods("DELETE")
|
||||
r.HandleFunc("/api/v1/blocks/{blockID}/subtree", handleGetSubTree).Methods("GET")
|
||||
|
||||
r.HandleFunc("/api/v1/files", handleUploadFile).Methods("POST")
|
||||
|
||||
r.HandleFunc("/api/v1/blocks/export", handleExport).Methods("GET")
|
||||
r.HandleFunc("/api/v1/blocks/import", handleImport).Methods("POST")
|
||||
|
||||
// WebSocket
|
||||
r.HandleFunc("/ws/onchange", handleWebSocketOnChange)
|
||||
|
||||
// Files
|
||||
r.HandleFunc("/files/{filename}", handleServeFile).Methods("GET")
|
||||
|
||||
http.Handle("/", r)
|
||||
|
||||
connectDatabase(config.DBType, config.DBConfigString)
|
||||
|
||||
// Ctrl+C handling
|
||||
handler := make(chan os.Signal, 1)
|
||||
signal.Notify(handler, os.Interrupt)
|
||||
go func() {
|
||||
for sig := range handler {
|
||||
// sig is a ^C, handle it
|
||||
if sig == os.Interrupt {
|
||||
os.Exit(1)
|
||||
break
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Start the server, with SSL if the certs exist
|
||||
urlPort := fmt.Sprintf(`:%d`, config.Port)
|
||||
var isSSL = config.UseSSL && utils.FileExists("./cert/cert.pem") && utils.FileExists("./cert/key.pem")
|
||||
if isSSL {
|
||||
log.Println("https server started on ", urlPort)
|
||||
err := http.ListenAndServeTLS(urlPort, "./cert/cert.pem", "./cert/key.pem", nil)
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServeTLS: ", err)
|
||||
}
|
||||
} else {
|
||||
log.Println("http server started on ", urlPort)
|
||||
err := http.ListenAndServe(urlPort, nil)
|
||||
if err != nil {
|
||||
log.Fatal("ListenAndServe: ", err)
|
||||
}
|
||||
}
|
||||
}
|
278
server/main/octoDatabase.go
Normal file
278
server/main/octoDatabase.go
Normal file
@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func connectDatabase(dbType string, connectionString string) {
|
||||
log.Println("connectDatabase")
|
||||
var err error
|
||||
|
||||
db, err = sql.Open(dbType, connectionString)
|
||||
if err != nil {
|
||||
log.Fatal("connectDatabase: ", err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = db.Ping()
|
||||
if err != nil {
|
||||
log.Println(`Database Ping failed`)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
createTablesIfNotExists(dbType)
|
||||
}
|
||||
|
||||
// Block is the basic data unit
|
||||
type Block struct {
|
||||
ID string `json:"id"`
|
||||
ParentID string `json:"parentId"`
|
||||
Type string `json:"type"`
|
||||
CreateAt int64 `json:"createAt"`
|
||||
UpdateAt int64 `json:"updateAt"`
|
||||
DeleteAt int64 `json:"deleteAt"`
|
||||
}
|
||||
|
||||
func createTablesIfNotExists(dbType string) {
|
||||
// TODO: Add update_by with the user's ID
|
||||
// TODO: Consolidate insert_at and update_at, decide if the server of DB should set it
|
||||
var query string
|
||||
if dbType == "sqlite3" {
|
||||
query = `CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at DATETIME NOT NULL DEFAULT current_timestamp,
|
||||
parent_id VARCHAR(36),
|
||||
type TEXT,
|
||||
json TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);`
|
||||
} else {
|
||||
query = `CREATE TABLE IF NOT EXISTS blocks (
|
||||
id VARCHAR(36),
|
||||
insert_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
parent_id VARCHAR(36),
|
||||
type TEXT,
|
||||
json TEXT,
|
||||
create_at BIGINT,
|
||||
update_at BIGINT,
|
||||
delete_at BIGINT,
|
||||
PRIMARY KEY (id, insert_at)
|
||||
);`
|
||||
}
|
||||
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
log.Fatal("createTablesIfNotExists: ", err)
|
||||
panic(err)
|
||||
}
|
||||
log.Printf("createTablesIfNotExists(%s)", dbType)
|
||||
}
|
||||
|
||||
func blockFromMap(m map[string]interface{}) Block {
|
||||
var b Block
|
||||
b.ID = m["id"].(string)
|
||||
// Parent ID can be nil (for now)
|
||||
if m["parentId"] != nil {
|
||||
b.ParentID = m["parentId"].(string)
|
||||
}
|
||||
// Allow nil type for imports
|
||||
if m["type"] != nil {
|
||||
b.Type = m["type"].(string)
|
||||
}
|
||||
if m["createAt"] != nil {
|
||||
b.CreateAt = int64(m["createAt"].(float64))
|
||||
}
|
||||
if m["updateAt"] != nil {
|
||||
b.UpdateAt = int64(m["updateAt"].(float64))
|
||||
}
|
||||
if m["deleteAt"] != nil {
|
||||
b.DeleteAt = int64(m["deleteAt"].(float64))
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func getBlocksWithParentAndType(parentID string, blockType string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1 and type = $2`
|
||||
|
||||
rows, err := db.Query(query, parentID, blockType)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParentAndType ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func getBlocksWithParent(parentID string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0 and parent_id = $1`
|
||||
|
||||
rows, err := db.Query(query, parentID)
|
||||
if err != nil {
|
||||
log.Printf(`getBlocksWithParent ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func getSubTree(blockID string) []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND (id = $1
|
||||
OR parent_id = $1)`
|
||||
|
||||
rows, err := db.Query(query, blockID)
|
||||
if err != nil {
|
||||
log.Printf(`getSubTree ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func getAllBlocks() []string {
|
||||
query := `WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT COALESCE("json", '{}')
|
||||
FROM latest
|
||||
WHERE delete_at = 0`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
log.Printf(`getAllBlocks ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return blocksFromRows(rows)
|
||||
}
|
||||
|
||||
func blocksFromRows(rows *sql.Rows) []string {
|
||||
defer rows.Close()
|
||||
|
||||
var results []string
|
||||
|
||||
for rows.Next() {
|
||||
var json string
|
||||
err := rows.Scan(&json)
|
||||
if err != nil {
|
||||
// handle this error
|
||||
log.Printf(`blocksFromRows ERROR: %v`, err)
|
||||
panic(err)
|
||||
}
|
||||
|
||||
results = append(results, json)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func getParentID(blockID string) string {
|
||||
statement :=
|
||||
`WITH latest AS
|
||||
(
|
||||
SELECT * FROM
|
||||
(
|
||||
SELECT
|
||||
*,
|
||||
ROW_NUMBER() OVER (PARTITION BY id ORDER BY insert_at DESC) AS rn
|
||||
FROM blocks
|
||||
) a
|
||||
WHERE rn = 1
|
||||
)
|
||||
|
||||
SELECT parent_id
|
||||
FROM latest
|
||||
WHERE delete_at = 0
|
||||
AND id = $1`
|
||||
|
||||
row := db.QueryRow(statement, blockID)
|
||||
|
||||
var parentID string
|
||||
err := row.Scan(&parentID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parentID
|
||||
}
|
||||
|
||||
func insertBlock(block Block, json string) {
|
||||
statement := `INSERT INTO blocks(id, parent_id, type, json, create_at, update_at, delete_at) VALUES($1, $2, $3, $4, $5, $6, $7)`
|
||||
_, err := db.Exec(statement, block.ID, block.ParentID, block.Type, json, block.CreateAt, block.UpdateAt, block.DeleteAt)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBlock(blockID string) {
|
||||
now := time.Now().Unix()
|
||||
json := fmt.Sprintf(`{"id":"%s","updateAt":%d,"deleteAt":%d}`, blockID, now, now)
|
||||
statement := `INSERT INTO blocks(id, json, update_at, delete_at) VALUES($1, $2, $3, $4)`
|
||||
_, err := db.Exec(statement, blockID, json, now, now)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
30
server/utils/utils.go
Normal file
30
server/utils/utils.go
Normal file
@ -0,0 +1,30 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FileExists returns true if a file exists at the path
|
||||
func FileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// CreateGUID returns a random GUID
|
||||
func CreateGUID() string {
|
||||
b := make([]byte, 16)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
uuid := fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
|
||||
return uuid
|
||||
}
|
5
src/@custom_types/pgtools.d.ts
vendored
Normal file
5
src/@custom_types/pgtools.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/** Declaration file generated by dts-gen */
|
||||
export function createdb(opts: any, dbName: any, cb?: any): any;
|
||||
|
||||
export function dropdb(opts: any, dbName: any, cb?: any): any;
|
||||
|
87
src/client/archiver.ts
Normal file
87
src/client/archiver.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { Mutator } from "./mutator"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
interface Archive {
|
||||
version: number
|
||||
date: number
|
||||
blocks: IBlock[]
|
||||
}
|
||||
|
||||
class Archiver {
|
||||
static async exportBoardTree(boardTree: BoardTree) {
|
||||
const blocks = boardTree.allBlocks
|
||||
const archive: Archive = {
|
||||
version: 1,
|
||||
date: Date.now(),
|
||||
blocks
|
||||
}
|
||||
|
||||
this.exportArchive(archive)
|
||||
}
|
||||
|
||||
static async exportFullArchive(mutator: Mutator) {
|
||||
const blocks = await mutator.exportFullArchive()
|
||||
const archive: Archive = {
|
||||
version: 1,
|
||||
date: Date.now(),
|
||||
blocks
|
||||
}
|
||||
|
||||
this.exportArchive(archive)
|
||||
}
|
||||
|
||||
private static exportArchive(archive: Archive) {
|
||||
const content = JSON.stringify(archive)
|
||||
|
||||
const date = new Date()
|
||||
const filename = `archive-${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}.octo`
|
||||
const link = document.createElement("a")
|
||||
link.style.display = "none"
|
||||
// const file = new Blob([content], { type: "text/json" })
|
||||
// link.href = URL.createObjectURL(file)
|
||||
link.href = "data:text/json," + encodeURIComponent(content)
|
||||
link.download = filename
|
||||
document.body.appendChild(link) // FireFox support
|
||||
|
||||
link.click()
|
||||
|
||||
// TODO: Remove or reuse link
|
||||
}
|
||||
|
||||
static importFullArchive(mutator: Mutator, onImported?: () => void): void {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = ".octo"
|
||||
input.onchange = async () => {
|
||||
const file = input.files[0]
|
||||
const contents = await (new Response(file)).text()
|
||||
Utils.log(`Import ${contents.length} bytes.`)
|
||||
const archive: Archive = JSON.parse(contents)
|
||||
const { blocks } = archive
|
||||
const date = new Date(archive.date)
|
||||
Utils.log(`Import archive, version: ${archive.version}, date/time: ${date.toLocaleString()}, ${blocks.length} block(s).`)
|
||||
|
||||
// Basic error checking
|
||||
const filteredBlocks = blocks.filter(o => {
|
||||
if (!o.id) { return false }
|
||||
return true
|
||||
})
|
||||
|
||||
Utils.log(`Import ${filteredBlocks.length} filtered blocks.`)
|
||||
|
||||
await mutator.importFullArchive(filteredBlocks)
|
||||
Utils.log(`Import completed`)
|
||||
onImported?.()
|
||||
}
|
||||
|
||||
input.style.display = "none"
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
|
||||
// TODO: Remove or reuse input
|
||||
}
|
||||
}
|
||||
|
||||
export { Archiver }
|
71
src/client/block.ts
Normal file
71
src/client/block.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { IBlock, IProperty } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class Block implements IBlock {
|
||||
id: string = Utils.createGuid()
|
||||
parentId: string
|
||||
type: string
|
||||
title: string
|
||||
icon?: string
|
||||
url?: string
|
||||
order: number
|
||||
properties: IProperty[] = []
|
||||
createAt: number = Date.now()
|
||||
updateAt: number = 0
|
||||
deleteAt: number = 0
|
||||
|
||||
static duplicate(block: IBlock) {
|
||||
const now = Date.now()
|
||||
|
||||
const newBlock = new Block(block)
|
||||
newBlock.id = Utils.createGuid()
|
||||
newBlock.title = `Copy of ${block.title}`
|
||||
newBlock.createAt = now
|
||||
newBlock.updateAt = now
|
||||
newBlock.deleteAt = 0
|
||||
|
||||
return newBlock
|
||||
}
|
||||
|
||||
constructor(block: any = {}) {
|
||||
const now = Date.now()
|
||||
|
||||
this.id = block.id || Utils.createGuid()
|
||||
this.parentId = block.parentId
|
||||
this.type = block.type
|
||||
this.title = block.title
|
||||
this.icon = block.icon
|
||||
this.url = block.url
|
||||
this.order = block.order
|
||||
this.properties = block.properties ? block.properties.map((o: IProperty) => ({...o})) : [] // Deep clone
|
||||
this.createAt = block.createAt || now
|
||||
this.updateAt = block.updateAt || now
|
||||
this.deleteAt = block.deleteAt || 0
|
||||
}
|
||||
|
||||
static getPropertyValue(block: IBlock, id: string): string | undefined {
|
||||
if (!block.properties) { return undefined }
|
||||
const property = block.properties.find( o => o.id === id )
|
||||
if (!property) { return undefined }
|
||||
return property.value
|
||||
}
|
||||
|
||||
static setProperty(block: IBlock, id: string, value?: string) {
|
||||
if (!block.properties) { block.properties = [] }
|
||||
if (!value) {
|
||||
// Remove property
|
||||
block.properties = block.properties.filter( o => o.id !== id )
|
||||
return
|
||||
}
|
||||
|
||||
const property = block.properties.find( o => o.id === id )
|
||||
if (property) {
|
||||
property.value = value
|
||||
} else {
|
||||
const newProperty: IProperty = { id, value }
|
||||
block.properties.push(newProperty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Block }
|
13
src/client/blockIcons.ts
Normal file
13
src/client/blockIcons.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { randomEmojiList } from "./emojiList"
|
||||
|
||||
class BlockIcons {
|
||||
static readonly shared = new BlockIcons()
|
||||
|
||||
randomIcon(): string {
|
||||
const index = Math.floor(Math.random() * randomEmojiList.length)
|
||||
const icon = randomEmojiList[index]
|
||||
return icon
|
||||
}
|
||||
}
|
||||
|
||||
export { BlockIcons }
|
40
src/client/board.ts
Normal file
40
src/client/board.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Block } from "./block"
|
||||
|
||||
type PropertyType = "text" | "number" | "select" | "multiSelect" | "date" | "person" | "file" | "checkbox" | "url" | "email" | "phone" | "createdTime" | "createdBy" | "updatedTime" | "updatedBy"
|
||||
|
||||
interface IPropertyOption {
|
||||
value: string,
|
||||
color: string
|
||||
}
|
||||
|
||||
// A template for card properties attached to a board
|
||||
interface IPropertyTemplate {
|
||||
id: string
|
||||
name: string
|
||||
type: PropertyType
|
||||
options: IPropertyOption[]
|
||||
}
|
||||
|
||||
class Board extends Block {
|
||||
cardProperties: IPropertyTemplate[] = []
|
||||
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
this.type = "board"
|
||||
if (block.cardProperties) {
|
||||
// Deep clone of properties and their options
|
||||
this.cardProperties = block.cardProperties.map((o: IPropertyTemplate) => {
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
type: o.type,
|
||||
options: o.options ? o.options.map(option => ({...option})): []
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.cardProperties = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { Board, PropertyType, IPropertyOption, IPropertyTemplate }
|
235
src/client/boardPage.tsx
Normal file
235
src/client/boardPage.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView } from "./boardView"
|
||||
import { CardTree } from "./cardTree"
|
||||
import { BoardComponent } from "./components/boardComponent"
|
||||
import { CardDialog } from "./components/cardDialog"
|
||||
import { FilterComponent } from "./components/filterComponent"
|
||||
import { TableComponent } from "./components/tableComponent"
|
||||
import { FlashMessage } from "./flashMessage"
|
||||
import { Mutator } from "./mutator"
|
||||
import { OctoClient } from "./octoClient"
|
||||
import { OctoListener } from "./octoListener"
|
||||
import { IBlock, IPageController } from "./octoTypes"
|
||||
import { UndoManager } from "./undomanager"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class BoardPage implements IPageController {
|
||||
boardTitle: HTMLElement
|
||||
mainBoardHeader: HTMLElement
|
||||
mainBoardBody: HTMLElement
|
||||
groupByButton: HTMLElement
|
||||
groupByLabel: HTMLElement
|
||||
|
||||
boardId: string
|
||||
viewId: string
|
||||
|
||||
boardTree: BoardTree
|
||||
view: BoardView
|
||||
|
||||
updateTitleTimeout: number
|
||||
updatePropertyLabelTimeout: number
|
||||
|
||||
shownCardTree: CardTree
|
||||
|
||||
private filterAnchorElement?: HTMLElement
|
||||
private octo = new OctoClient()
|
||||
private boardListener = new OctoListener()
|
||||
private cardListener = new OctoListener()
|
||||
|
||||
constructor() {
|
||||
const queryString = new URLSearchParams(window.location.search)
|
||||
if (!queryString.has("id")) {
|
||||
// No id, redirect to home
|
||||
window.location.href = "/"
|
||||
return
|
||||
}
|
||||
|
||||
this.boardId = queryString.get("id")
|
||||
this.viewId = queryString.get("v")
|
||||
|
||||
console.log(`BoardPage. boardId: ${this.boardId}`)
|
||||
if (this.boardId) {
|
||||
this.boardTree = new BoardTree(this.octo, this.boardId)
|
||||
this.sync()
|
||||
|
||||
this.boardListener.open(this.boardId, (blockId: string) => {
|
||||
console.log(`octoListener.onChanged: ${blockId}`)
|
||||
this.sync()
|
||||
})
|
||||
} else {
|
||||
// Show error
|
||||
}
|
||||
|
||||
document.body.addEventListener("keydown", async (e) => {
|
||||
if (e.target !== document.body) { return }
|
||||
|
||||
if (e.keyCode === 90 && !e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Cmd+Z
|
||||
Utils.log(`Undo`)
|
||||
const description = UndoManager.shared.undoDescription
|
||||
await UndoManager.shared.undo()
|
||||
if (description) {
|
||||
FlashMessage.show(`Undo ${description}`)
|
||||
} else {
|
||||
FlashMessage.show(`Undo`)
|
||||
}
|
||||
} else if (e.keyCode === 90 && e.shiftKey && (e.ctrlKey || e.metaKey) && !e.altKey) { // Shift+Cmd+Z
|
||||
Utils.log(`Redo`)
|
||||
const description = UndoManager.shared.redoDescription
|
||||
await UndoManager.shared.redo()
|
||||
if (description) {
|
||||
FlashMessage.show(`Redo ${description}`)
|
||||
} else {
|
||||
FlashMessage.show(`Redo`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { octo, boardTree } = this
|
||||
const { board, activeView } = boardTree
|
||||
const mutator = new Mutator(octo)
|
||||
|
||||
const rootElement = Utils.getElementById("main")
|
||||
|
||||
if (board) {
|
||||
Utils.setFavicon(board.icon)
|
||||
} else {
|
||||
ReactDOM.render(
|
||||
<div>Loading...</div>,
|
||||
rootElement
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeView) {
|
||||
document.title = `OCTO - ${board.title} | ${activeView.title}`
|
||||
|
||||
switch (activeView.viewType) {
|
||||
case "board": {
|
||||
ReactDOM.render(
|
||||
<BoardComponent mutator={mutator} boardTree={this.boardTree} pageController={this} />,
|
||||
rootElement
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "table": {
|
||||
ReactDOM.render(
|
||||
<TableComponent mutator={mutator} boardTree={this.boardTree} pageController={this} />,
|
||||
rootElement
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
Utils.assertFailure(`render() Unhandled viewType: ${activeView.viewType}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (boardTree && boardTree.board && this.shownCardTree) {
|
||||
ReactDOM.render(
|
||||
<CardDialog mutator={mutator} boardTree={boardTree} cardTree={this.shownCardTree} onClose={() => { this.showCard(undefined) }}></CardDialog>,
|
||||
Utils.getElementById("overlay")
|
||||
)
|
||||
} else {
|
||||
ReactDOM.render(
|
||||
<div />,
|
||||
Utils.getElementById("overlay")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ReactDOM.render(
|
||||
<div>Loading...</div>,
|
||||
rootElement
|
||||
)
|
||||
}
|
||||
|
||||
if (this.filterAnchorElement) {
|
||||
const element = this.filterAnchorElement
|
||||
const bodyRect = document.body.getBoundingClientRect()
|
||||
const rect = element.getBoundingClientRect()
|
||||
// Show at bottom-left of element
|
||||
const maxX = bodyRect.right - 420 - 100
|
||||
const pageX = Math.min(maxX, rect.left - bodyRect.left)
|
||||
const pageY = rect.bottom - bodyRect.top
|
||||
|
||||
ReactDOM.render(
|
||||
<FilterComponent
|
||||
mutator={mutator}
|
||||
boardTree={boardTree}
|
||||
pageX={pageX}
|
||||
pageY={pageY}
|
||||
onClose={() => {this.showFilter(undefined)}}
|
||||
>
|
||||
</FilterComponent>,
|
||||
Utils.getElementById("modal")
|
||||
)
|
||||
} else {
|
||||
ReactDOM.render(<div />, Utils.getElementById("modal"))
|
||||
}
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const { boardTree } = this
|
||||
|
||||
await boardTree.sync()
|
||||
|
||||
// Default to first view
|
||||
if (!this.viewId) {
|
||||
this.viewId = boardTree.views[0].id
|
||||
}
|
||||
|
||||
boardTree.setActiveView(this.viewId)
|
||||
// TODO: Handle error (viewId not found)
|
||||
this.viewId = boardTree.activeView.id
|
||||
|
||||
console.log(`sync complete... title: ${boardTree.board.title}`)
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
// IPageController
|
||||
|
||||
async showCard(card: IBlock) {
|
||||
this.cardListener.close()
|
||||
|
||||
if (card) {
|
||||
const cardTree = new CardTree(this.octo, card.id)
|
||||
await cardTree.sync()
|
||||
this.shownCardTree = cardTree
|
||||
|
||||
this.cardListener = new OctoListener()
|
||||
this.cardListener.open(card.id, async () => {
|
||||
await cardTree.sync()
|
||||
this.render()
|
||||
})
|
||||
} else {
|
||||
this.shownCardTree = undefined
|
||||
}
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
showView(viewId: string) {
|
||||
this.viewId = viewId
|
||||
this.boardTree.setActiveView(this.viewId)
|
||||
const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + `?id=${encodeURIComponent(this.boardId)}&v=${encodeURIComponent(viewId)}`
|
||||
window.history.pushState({ path: newUrl }, "", newUrl)
|
||||
this.render()
|
||||
}
|
||||
|
||||
showFilter(ahchorElement?: HTMLElement) {
|
||||
this.filterAnchorElement = ahchorElement
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardPage }
|
||||
|
||||
const _ = new BoardPage()
|
||||
console.log("BoardPage")
|
252
src/client/boardTree.ts
Normal file
252
src/client/boardTree.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import { Board, IPropertyOption, IPropertyTemplate } from "./board"
|
||||
import { BoardView } from "./boardView"
|
||||
import { CardFilter } from "./cardFilter"
|
||||
import { OctoClient } from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
type Group = { option: IPropertyOption, cards: IBlock[] }
|
||||
|
||||
class BoardTree {
|
||||
board!: Board
|
||||
views: BoardView[] = []
|
||||
cards: IBlock[] = []
|
||||
emptyGroupCards: IBlock[] = []
|
||||
groups: Group[] = []
|
||||
|
||||
activeView?: BoardView
|
||||
groupByProperty?: IPropertyTemplate
|
||||
|
||||
private allCards: IBlock[] = []
|
||||
get allBlocks(): IBlock[] {
|
||||
return [this.board, ...this.views, ...this.allCards]
|
||||
}
|
||||
|
||||
constructor(
|
||||
private octo: OctoClient,
|
||||
private boardId: string) {
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const blocks = await this.octo.getSubtree(this.boardId)
|
||||
this.rebuild(blocks)
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
const boardBlock = blocks.find(block => block.type === "board")
|
||||
|
||||
if (boardBlock) {
|
||||
this.board = new Board(boardBlock)
|
||||
}
|
||||
|
||||
const viewBlocks = blocks.filter(block => block.type === "view")
|
||||
this.views = viewBlocks.map(o => new BoardView(o))
|
||||
|
||||
const cardBlocks = blocks.filter(block => block.type === "card")
|
||||
this.allCards = cardBlocks
|
||||
this.cards = []
|
||||
|
||||
this.ensureMinimumSchema()
|
||||
}
|
||||
|
||||
private async ensureMinimumSchema() {
|
||||
const { board } = this
|
||||
|
||||
let didChange = false
|
||||
|
||||
// At least one select property
|
||||
const selectProperties = board.cardProperties.find(o => o.type === "select")
|
||||
if (!selectProperties) {
|
||||
const property: IPropertyTemplate = {
|
||||
id: Utils.createGuid(),
|
||||
name: "Status",
|
||||
type: "select",
|
||||
options: []
|
||||
}
|
||||
board.cardProperties.push(property)
|
||||
didChange = true
|
||||
}
|
||||
|
||||
// At least one view
|
||||
if (this.views.length < 1) {
|
||||
const view = new BoardView()
|
||||
view.parentId = board.id
|
||||
view.groupById = board.cardProperties.find(o => o.type === "select")?.id
|
||||
this.views.push(view)
|
||||
didChange = true
|
||||
}
|
||||
|
||||
return didChange
|
||||
}
|
||||
|
||||
setActiveView(viewId: string) {
|
||||
this.activeView = this.views.find(o => o.id === viewId)
|
||||
if (!this.activeView) {
|
||||
Utils.logError(`Cannot find BoardView: ${viewId}`)
|
||||
this.activeView = this.views[0]
|
||||
}
|
||||
|
||||
// Fix missing group by (e.g. for new views)
|
||||
if (this.activeView.viewType === "board" && !this.activeView.groupById) {
|
||||
this.activeView.groupById = this.board.cardProperties.find(o => o.type === "select")?.id
|
||||
}
|
||||
this.applyFilterSortAndGroup()
|
||||
}
|
||||
|
||||
applyFilterSortAndGroup() {
|
||||
this.cards = this.filterCards(this.allCards)
|
||||
this.cards = this.sortCards(this.cards)
|
||||
|
||||
if (this.activeView.groupById) {
|
||||
this.setGroupByProperty(this.activeView.groupById)
|
||||
} else {
|
||||
Utils.assert(this.activeView.viewType !== "board")
|
||||
}
|
||||
}
|
||||
|
||||
private setGroupByProperty(propertyId: string) {
|
||||
const { board } = this
|
||||
|
||||
let property = board.cardProperties.find(o => o.id === propertyId)
|
||||
// TODO: Handle multi-select
|
||||
if (!property || property.type !== "select") {
|
||||
Utils.logError(`this.view.groupById card property not found: ${propertyId}`)
|
||||
property = board.cardProperties.find(o => o.type === "select")
|
||||
Utils.assertValue(property)
|
||||
}
|
||||
this.groupByProperty = property
|
||||
|
||||
this.groupCards()
|
||||
}
|
||||
|
||||
private groupCards() {
|
||||
this.groups = []
|
||||
|
||||
const groupByPropertyId = this.groupByProperty.id
|
||||
|
||||
this.emptyGroupCards = this.cards.filter(o => {
|
||||
const property = o.properties.find(p => p.id === groupByPropertyId)
|
||||
return !property || !property.value || !this.groupByProperty.options.find(option => option.value === property.value)
|
||||
})
|
||||
|
||||
const propertyOptions = this.groupByProperty.options || []
|
||||
for (const option of propertyOptions) {
|
||||
const cards = this.cards
|
||||
.filter(o => {
|
||||
const property = o.properties.find(p => p.id === groupByPropertyId)
|
||||
return property && property.value === option.value
|
||||
})
|
||||
|
||||
const group: Group = {
|
||||
option,
|
||||
cards
|
||||
}
|
||||
|
||||
this.groups.push(group)
|
||||
}
|
||||
}
|
||||
|
||||
private filterCards(cards: IBlock[]): IBlock[] {
|
||||
const { board } = this
|
||||
const filterGroup = this.activeView?.filter
|
||||
if (!filterGroup) { return cards.slice() }
|
||||
|
||||
return CardFilter.applyFilterGroup(filterGroup, board.cardProperties, cards)
|
||||
}
|
||||
|
||||
private sortCards(cards: IBlock[]): IBlock[] {
|
||||
if (!this.activeView) { Utils.assertFailure(); return cards }
|
||||
const { board } = this
|
||||
const { sortOptions } = this.activeView
|
||||
let sortedCards: IBlock[]
|
||||
|
||||
if (sortOptions.length < 1) {
|
||||
Utils.log(`Default sort`)
|
||||
sortedCards = cards.sort((a, b) => {
|
||||
const aValue = a.title || ""
|
||||
const bValue = b.title || ""
|
||||
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) { return -1 }
|
||||
if (bValue && !aValue) { return 1 }
|
||||
if (!aValue && !bValue) { return a.createAt - b.createAt }
|
||||
|
||||
return a.createAt - b.createAt
|
||||
})
|
||||
} else {
|
||||
sortOptions.forEach(sortOption => {
|
||||
if (sortOption.propertyId === "__name") {
|
||||
Utils.log(`Sort by name`)
|
||||
sortedCards = cards.sort((a, b) => {
|
||||
const aValue = a.title || ""
|
||||
const bValue = b.title || ""
|
||||
|
||||
// Always put empty values at the bottom, newest last
|
||||
if (aValue && !bValue) { return -1 }
|
||||
if (bValue && !aValue) { return 1 }
|
||||
if (!aValue && !bValue) { return a.createAt - b.createAt }
|
||||
|
||||
let result = aValue.localeCompare(bValue)
|
||||
if (sortOption.reversed) { result = -result }
|
||||
return result
|
||||
})
|
||||
} else {
|
||||
const sortPropertyId = sortOption.propertyId
|
||||
const template = board.cardProperties.find(o => o.id === sortPropertyId)
|
||||
Utils.log(`Sort by ${template.name}`)
|
||||
sortedCards = cards.sort((a, b) => {
|
||||
// Always put cards with no titles at the bottom
|
||||
if (a.title && !b.title) { return -1 }
|
||||
if (b.title && !a.title) { return 1 }
|
||||
if (!a.title && !b.title) { return a.createAt - b.createAt }
|
||||
|
||||
const aProperty = a.properties.find(o => o.id === sortPropertyId)
|
||||
const bProperty = b.properties.find(o => o.id === sortPropertyId)
|
||||
const aValue = aProperty ? aProperty.value : ""
|
||||
const bValue = bProperty ? bProperty.value : ""
|
||||
let result = 0
|
||||
if (template.type === "select") {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) { return -1 }
|
||||
if (bValue && !aValue) { return 1 }
|
||||
if (!aValue && !bValue) { return a.createAt - b.createAt }
|
||||
|
||||
// Sort by the option order (not alphabetically by value)
|
||||
const aOrder = template.options.findIndex(o => o.value === aValue)
|
||||
const bOrder = template.options.findIndex(o => o.value === bValue)
|
||||
|
||||
result = aOrder - bOrder
|
||||
} else if (template.type === "number" || template.type === "date") {
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) { return -1 }
|
||||
if (bValue && !aValue) { return 1 }
|
||||
if (!aValue && !bValue) { return a.createAt - b.createAt }
|
||||
|
||||
result = Number(aValue) - Number(bValue)
|
||||
} else if (template.type === "createdTime") {
|
||||
result = a.createAt - b.createAt
|
||||
} else if (template.type === "updatedTime") {
|
||||
result = a.updateAt - b.updateAt
|
||||
} else {
|
||||
// Text-based sort
|
||||
|
||||
// Always put empty values at the bottom
|
||||
if (aValue && !bValue) { return -1 }
|
||||
if (bValue && !aValue) { return 1 }
|
||||
if (!aValue && !bValue) { return a.createAt - b.createAt }
|
||||
|
||||
result = aValue.localeCompare(bValue)
|
||||
}
|
||||
|
||||
if (sortOption.reversed) { result = -result }
|
||||
return result
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return sortedCards
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardTree }
|
26
src/client/boardView.ts
Normal file
26
src/client/boardView.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Block } from "./block"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
|
||||
type IViewType = "board" | "table" | "calendar" | "list" | "gallery"
|
||||
type ISortOption = { propertyId: "__name" | string, reversed: boolean }
|
||||
|
||||
class BoardView extends Block {
|
||||
viewType: IViewType
|
||||
groupById?: string
|
||||
sortOptions: ISortOption[]
|
||||
visiblePropertyIds: string[]
|
||||
filter?: FilterGroup
|
||||
|
||||
constructor(block: any = {}) {
|
||||
super(block)
|
||||
|
||||
this.type = "view"
|
||||
this.viewType = block.viewType || "board"
|
||||
this.groupById = block.groupById
|
||||
this.sortOptions = block.sortOptions ? block.sortOptions.map((o: ISortOption) => ({...o})) : [] // Deep clone
|
||||
this.visiblePropertyIds = block.visiblePropertyIds ? block.visiblePropertyIds.slice() : []
|
||||
this.filter = new FilterGroup(block.filter)
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardView, IViewType, ISortOption }
|
106
src/client/boardsPage.ts
Normal file
106
src/client/boardsPage.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { Archiver } from "./archiver"
|
||||
import { Board } from "./board"
|
||||
import { Mutator } from "./mutator"
|
||||
import { OctoClient } from "./octoClient"
|
||||
import { UndoManager } from "./undomanager"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class BoardsPage {
|
||||
boardsPanel: HTMLElement
|
||||
|
||||
boardId: string
|
||||
boards: Board[]
|
||||
|
||||
octo = new OctoClient()
|
||||
|
||||
constructor() {
|
||||
// This is a placeholder page
|
||||
|
||||
const mainPanel = Utils.getElementById("main")
|
||||
|
||||
this.boardsPanel = mainPanel.appendChild(document.createElement("div"))
|
||||
|
||||
{
|
||||
const addButton = document.body.appendChild(document.createElement("div"))
|
||||
addButton.className = "octo-button"
|
||||
addButton.innerText = "+ Add Board"
|
||||
addButton.onclick = () => { this.addClicked() }
|
||||
}
|
||||
|
||||
document.body.appendChild(document.createElement("br"))
|
||||
|
||||
{
|
||||
const importButton = document.body.appendChild(document.createElement("div"))
|
||||
importButton.className = "octo-button"
|
||||
importButton.innerText = "Import archive"
|
||||
importButton.onclick = async () => {
|
||||
const octo = new OctoClient()
|
||||
const mutator = new Mutator(octo, UndoManager.shared)
|
||||
Archiver.importFullArchive(mutator, () => {
|
||||
this.updateView()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const exportButton = document.body.appendChild(document.createElement("div"))
|
||||
exportButton.className = "octo-button"
|
||||
exportButton.innerText = "Export archive"
|
||||
exportButton.onclick = () => {
|
||||
const octo = new OctoClient()
|
||||
const mutator = new Mutator(octo, UndoManager.shared)
|
||||
Archiver.exportFullArchive(mutator)
|
||||
}
|
||||
}
|
||||
|
||||
this.updateView()
|
||||
}
|
||||
|
||||
async getBoardData() {
|
||||
const boards = this.octo.getBlocks(null, "board")
|
||||
}
|
||||
|
||||
async updateView() {
|
||||
const { boardsPanel } = this
|
||||
|
||||
boardsPanel.innerText = ""
|
||||
|
||||
const boards = await this.octo.getBlocks(null, "board")
|
||||
for (const board of boards) {
|
||||
const p = boardsPanel.appendChild(document.createElement("p"))
|
||||
const a = p.appendChild(document.createElement("a"))
|
||||
a.style.padding = "5px 10px"
|
||||
a.style.fontSize = "20px"
|
||||
a.href = `./board?id=${encodeURIComponent(board.id)}`
|
||||
|
||||
if (board.icon) {
|
||||
const icon = a.appendChild(document.createElement("span"))
|
||||
icon.className = "octo-icon"
|
||||
icon.style.marginRight = "10px"
|
||||
icon.innerText = board.icon
|
||||
}
|
||||
|
||||
const title = a.appendChild(document.createElement("b"))
|
||||
const updatedDate = new Date(board.updateAt)
|
||||
title.innerText = board.title
|
||||
const details = a.appendChild(document.createElement("span"))
|
||||
details.style.fontSize = "15px"
|
||||
details.style.color = "#909090"
|
||||
details.style.marginLeft = "10px"
|
||||
details.innerText = ` ${Utils.displayDate(updatedDate)}`
|
||||
}
|
||||
|
||||
console.log(`updateView: ${boards.length} board(s).`)
|
||||
}
|
||||
|
||||
async addClicked() {
|
||||
const board = new Board()
|
||||
await this.octo.insertBlock(board)
|
||||
await this.updateView()
|
||||
}
|
||||
}
|
||||
|
||||
export = BoardsPage
|
||||
|
||||
const _ = new BoardsPage()
|
||||
console.log("boardsView")
|
115
src/client/cardFilter.ts
Normal file
115
src/client/cardFilter.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { IPropertyTemplate } from "./board"
|
||||
import { FilterClause } from "./filterClause"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
import { IBlock, IProperty } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class CardFilter {
|
||||
static applyFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[], cards: IBlock[]): IBlock[] {
|
||||
return cards.filter(card => this.isFilterGroupMet(filterGroup, templates, card))
|
||||
}
|
||||
|
||||
static isFilterGroupMet(filterGroup: FilterGroup, templates: IPropertyTemplate[], card: IBlock): boolean {
|
||||
const { filters } = filterGroup
|
||||
|
||||
if (filterGroup.filters.length < 1) {
|
||||
return true // No filters = always met
|
||||
}
|
||||
|
||||
if (filterGroup.operation === "or") {
|
||||
for (const filter of filters) {
|
||||
if (FilterGroup.isAnInstanceOf(filter)) {
|
||||
if (this.isFilterGroupMet(filter, templates, card)) { return true }
|
||||
} else {
|
||||
if (this.isClauseMet(filter, templates, card)) { return true }
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
Utils.assert(filterGroup.operation === "and")
|
||||
for (const filter of filters) {
|
||||
if (FilterGroup.isAnInstanceOf(filter)) {
|
||||
if (!this.isFilterGroupMet(filter, templates, card)) { return false }
|
||||
} else {
|
||||
if (!this.isClauseMet(filter, templates, card)) { return false }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static isClauseMet(filter: FilterClause, templates: IPropertyTemplate[], card: IBlock): boolean {
|
||||
const property = card.properties.find(o => o.id === filter.propertyId)
|
||||
const value = property?.value
|
||||
switch (filter.condition) {
|
||||
case "includes": {
|
||||
if (filter.values.length < 1) { break } // No values = ignore clause (always met)
|
||||
return (filter.values.find(cValue => cValue === value) !== undefined)
|
||||
}
|
||||
case "notIncludes": {
|
||||
if (filter.values.length < 1) { break } // No values = ignore clause (always met)
|
||||
return (filter.values.find(cValue => cValue === value) === undefined)
|
||||
}
|
||||
case "isEmpty": {
|
||||
return !value
|
||||
}
|
||||
case "isNotEmpty": {
|
||||
return !!value
|
||||
}
|
||||
}
|
||||
Utils.assertFailure(`Invalid filter condition ${filter.condition}`)
|
||||
return true
|
||||
}
|
||||
|
||||
static propertiesThatMeetFilterGroup(filterGroup: FilterGroup, templates: IPropertyTemplate[]): IProperty[] {
|
||||
// TODO: Handle filter groups
|
||||
const filters = filterGroup.filters.filter(o => !FilterGroup.isAnInstanceOf(o))
|
||||
if (filters.length < 1) { return [] }
|
||||
|
||||
if (filterGroup.operation === "or") {
|
||||
// Just need to meet the first clause
|
||||
const property = this.propertyThatMeetsFilterClause(filters[0] as FilterClause, templates)
|
||||
return [property]
|
||||
} else {
|
||||
return filters.map(filterClause => this.propertyThatMeetsFilterClause(filterClause as FilterClause, templates))
|
||||
}
|
||||
}
|
||||
|
||||
static propertyThatMeetsFilterClause(filterClause: FilterClause, templates: IPropertyTemplate[]): IProperty {
|
||||
const template = templates.find(o => o.id === filterClause.propertyId)
|
||||
switch (filterClause.condition) {
|
||||
case "includes": {
|
||||
if (filterClause.values.length < 1) { return { id: filterClause.propertyId } }
|
||||
return { id: filterClause.propertyId, value: filterClause.values[0] }
|
||||
}
|
||||
case "notIncludes": {
|
||||
if (filterClause.values.length < 1) { return { id: filterClause.propertyId } }
|
||||
if (template.type === "select") {
|
||||
const option = template.options.find(o => !filterClause.values.includes(o.value))
|
||||
return { id: filterClause.propertyId, value: option.value }
|
||||
} else {
|
||||
// TODO: Handle non-select types
|
||||
return { id: filterClause.propertyId }
|
||||
}
|
||||
}
|
||||
case "isEmpty": {
|
||||
return { id: filterClause.propertyId }
|
||||
}
|
||||
case "isNotEmpty": {
|
||||
if (template.type === "select") {
|
||||
if (template.options.length > 0) {
|
||||
const option = template.options[0]
|
||||
return { id: filterClause.propertyId, value: option.value }
|
||||
} else {
|
||||
return { id: filterClause.propertyId }
|
||||
}
|
||||
} else {
|
||||
// TODO: Handle non-select types
|
||||
return { id: filterClause.propertyId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { CardFilter }
|
35
src/client/cardTree.ts
Normal file
35
src/client/cardTree.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { OctoClient } from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
|
||||
class CardTree {
|
||||
card: IBlock
|
||||
comments: IBlock[]
|
||||
contents: IBlock[]
|
||||
isSynched: boolean
|
||||
|
||||
constructor(
|
||||
private octo: OctoClient,
|
||||
private cardId: string) {
|
||||
}
|
||||
|
||||
async sync() {
|
||||
const blocks = await this.octo.getSubtree(this.cardId)
|
||||
this.rebuild(blocks)
|
||||
}
|
||||
|
||||
private rebuild(blocks: IBlock[]) {
|
||||
this.card = blocks.find(o => o.id === this.cardId)
|
||||
|
||||
this.comments = blocks
|
||||
.filter(block => block.type === "comment")
|
||||
.sort((a, b) => a.createAt - b.createAt)
|
||||
|
||||
this.contents = blocks
|
||||
.filter(block => block.type === "text" || block.type === "image")
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
this.isSynched = true
|
||||
}
|
||||
}
|
||||
|
||||
export { CardTree }
|
89
src/client/components/boardCard.tsx
Normal file
89
src/client/components/boardCard.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import React from "react"
|
||||
import { Block } from "../block"
|
||||
import { IPropertyTemplate } from "../board"
|
||||
import { Menu } from "../menu"
|
||||
import { Mutator } from "../mutator"
|
||||
import { IBlock } from "../octoTypes"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Utils } from "../utils"
|
||||
|
||||
type BoardCardProps = {
|
||||
mutator: Mutator
|
||||
card: IBlock
|
||||
visiblePropertyTemplates: IPropertyTemplate[]
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
type BoardCardState = {
|
||||
isDragged?: boolean
|
||||
}
|
||||
|
||||
class BoardCard extends React.Component<BoardCardProps, BoardCardState> {
|
||||
|
||||
constructor(props: BoardCardProps) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { card } = this.props
|
||||
const optionsButtonRef = React.createRef<HTMLDivElement>()
|
||||
const visiblePropertyTemplates = this.props.visiblePropertyTemplates || []
|
||||
const element =
|
||||
<div
|
||||
className="octo-board-card"
|
||||
draggable={true}
|
||||
style={{ opacity: this.state.isDragged ? 0.5 : 1 }}
|
||||
onClick={this.props.onClick}
|
||||
onDragStart={(e) => { this.setState({ isDragged: true }); this.props.onDragStart(e) }}
|
||||
onDragEnd={(e) => { this.setState({ isDragged: false }); this.props.onDragEnd(e) }}
|
||||
|
||||
onMouseOver={() => { optionsButtonRef.current.style.display = null }}
|
||||
onMouseLeave={() => { optionsButtonRef.current.style.display = "none" }}
|
||||
>
|
||||
<div ref={optionsButtonRef} className="octo-hoverbutton square" style={{ display: "none" }} onClick={(e) => { this.showOptionsMenu(e) }}><div className="imageOptions" /></div>
|
||||
|
||||
<div className="octo-icontitle">
|
||||
{ card.icon ? <div className="octo-icon">{card.icon}</div> : undefined }
|
||||
<div key="__title">{card.title || "Untitled"}</div>
|
||||
</div>
|
||||
{visiblePropertyTemplates.map(template => {
|
||||
return OctoUtils.propertyValueReadonlyElement(card, template, "")
|
||||
})}
|
||||
</div>
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
private showOptionsMenu(e: React.MouseEvent) {
|
||||
const { mutator, card } = this.props
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "delete", name: "Delete" },
|
||||
{ id: "duplicate", name: "Duplicate" }
|
||||
]
|
||||
Menu.shared.onMenuClicked = (id) => {
|
||||
switch (id) {
|
||||
case "delete": {
|
||||
mutator.deleteBlock(card, "delete card")
|
||||
break
|
||||
}
|
||||
case "duplicate": {
|
||||
const newCard = Block.duplicate(card)
|
||||
mutator.insertBlock(newCard, "duplicate card")
|
||||
break
|
||||
}
|
||||
default: {
|
||||
Utils.assertFailure(`Unhandled menu id: ${id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardCard }
|
33
src/client/components/boardColumn.tsx
Normal file
33
src/client/components/boardColumn.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
|
||||
type Props = {
|
||||
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
isDragOver?: boolean
|
||||
}
|
||||
|
||||
class BoardColumn extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
render() {
|
||||
const element =
|
||||
<div
|
||||
className={this.state.isDragOver ? "octo-board-column dragover" : "octo-board-column"}
|
||||
onDragOver={(e) => { e.preventDefault(); this.setState({ isDragOver: true }) }}
|
||||
onDragEnter={(e) => { e.preventDefault(); this.setState({ isDragOver: true }) }}
|
||||
onDragLeave={(e) => { e.preventDefault(); this.setState({ isDragOver: false }) }}
|
||||
onDrop={(e) => { this.setState({ isDragOver: false }); this.props.onDrop(e) }}
|
||||
>
|
||||
{this.props.children}
|
||||
</div>
|
||||
|
||||
return element
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardColumn }
|
385
src/client/components/boardComponent.tsx
Normal file
385
src/client/components/boardComponent.tsx
Normal file
@ -0,0 +1,385 @@
|
||||
import React from "react"
|
||||
import { Archiver } from "../archiver"
|
||||
import { Block } from "../block"
|
||||
import { BlockIcons } from "../blockIcons"
|
||||
import { IPropertyOption } from "../board"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { ISortOption } from "../boardView"
|
||||
import { CardFilter } from "../cardFilter"
|
||||
import { Constants } from "../constants"
|
||||
import { Menu } from "../menu"
|
||||
import { Mutator } from "../mutator"
|
||||
import { IBlock, IPageController } from "../octoTypes"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Utils } from "../utils"
|
||||
import { BoardCard } from "./boardCard"
|
||||
import { BoardColumn } from "./boardColumn"
|
||||
import { Button } from "./button"
|
||||
import { Editable } from "./editable"
|
||||
|
||||
type Props = {
|
||||
mutator: Mutator,
|
||||
boardTree?: BoardTree
|
||||
pageController: IPageController
|
||||
}
|
||||
|
||||
type State = {
|
||||
isHoverOnCover: boolean
|
||||
}
|
||||
|
||||
class BoardComponent extends React.Component<Props, State> {
|
||||
private draggedCard: IBlock
|
||||
private draggedHeaderOption: IPropertyOption
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { isHoverOnCover: false }
|
||||
}
|
||||
|
||||
render() {
|
||||
const { mutator, boardTree, pageController } = this.props
|
||||
|
||||
if (!boardTree || !boardTree.board) {
|
||||
return (
|
||||
<div>Loading...</div>
|
||||
)
|
||||
}
|
||||
|
||||
const propertyValues = boardTree.groupByProperty?.options || []
|
||||
console.log(`${propertyValues.length} propertyValues`)
|
||||
|
||||
const groupByStyle = { color: "#000000" }
|
||||
const { board, activeView } = boardTree
|
||||
const visiblePropertyTemplates = board.cardProperties.filter(template => activeView.visiblePropertyIds.includes(template.id))
|
||||
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
|
||||
const hasSort = activeView.sortOptions.length > 0
|
||||
|
||||
return (
|
||||
<div className="octo-app">
|
||||
<div className="octo-frame">
|
||||
<div
|
||||
className="octo-hovercontrols"
|
||||
onMouseOver={() => { this.setState({ ...this.state, isHoverOnCover: true }) }}
|
||||
onMouseLeave={() => { this.setState({ ...this.state, isHoverOnCover: false }) }}
|
||||
>
|
||||
<Button
|
||||
style={{ display: (!board.icon && this.state.isHoverOnCover) ? null : "none" }}
|
||||
onClick={() => {
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(board, newIcon)
|
||||
}}
|
||||
>Add Icon</Button>
|
||||
</div>
|
||||
|
||||
<div className="octo-icontitle">
|
||||
{board.icon ?
|
||||
<div className="octo-button octo-icon" onClick={(e) => { this.iconClicked(e) }}>{board.icon}</div>
|
||||
: undefined}
|
||||
<Editable className="title" text={board.title} placeholderText="Untitled Board" onChanged={(text) => { mutator.changeTitle(board, text) }} />
|
||||
</div>
|
||||
|
||||
<div className="octo-board">
|
||||
<div className="octo-controls">
|
||||
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
||||
<div className="octo-button" style={{ color: "#000000", fontWeight: 600 }} onClick={(e) => { OctoUtils.showViewMenu(e, mutator, boardTree, pageController) }}><div className="imageDropdown"></div></div>
|
||||
<div className="octo-spacer"></div>
|
||||
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
||||
<div className="octo-button" id="groupByButton" onClick={(e) => { this.groupByClicked(e) }}>
|
||||
Group by <span style={groupByStyle} id="groupByLabel">{boardTree.groupByProperty?.name}</span>
|
||||
</div>
|
||||
<div className={ hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
||||
<div className={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { this.sortClicked(e) }}>Sort</div>
|
||||
<div className="octo-button">Search</div>
|
||||
<div className="octo-button" onClick={(e) => { this.optionsClicked(e) }}><div className="imageOptions" /></div>
|
||||
<div className="octo-button filled" onClick={() => { this.addCard(undefined) }}>New</div>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
|
||||
<div className="octo-board-header" id="mainBoardHeader">
|
||||
|
||||
{/* No value */}
|
||||
|
||||
<div className="octo-board-header-cell">
|
||||
<div className="octo-label" title={`Items with an empty ${boardTree.groupByProperty?.name} property will go here. This column cannot be removed.`}>{`No ${boardTree.groupByProperty?.name}`}</div>
|
||||
<Button text={`${boardTree.emptyGroupCards.length}`} />
|
||||
<div className="octo-spacer" />
|
||||
<Button><div className="imageOptions" /></Button>
|
||||
<Button onClick={() => { this.addCard(undefined) }}><div className="imageAdd" /></Button>
|
||||
</div>
|
||||
|
||||
{boardTree.groups.map(group =>
|
||||
<div
|
||||
key={group.option.value}
|
||||
className="octo-board-header-cell"
|
||||
|
||||
draggable={true}
|
||||
onDragStart={() => { this.draggedHeaderOption = group.option }}
|
||||
onDragEnd={() => { this.draggedHeaderOption = undefined }}
|
||||
|
||||
onDragOver={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.add("dragover") }}
|
||||
onDragEnter={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.add("dragover") }}
|
||||
onDragLeave={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.remove("dragover") }}
|
||||
onDrop={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.remove("dragover"); this.onDropToColumn(group.option) }}
|
||||
>
|
||||
<Editable
|
||||
className={`octo-label ${group.option.color}`}
|
||||
text={group.option.value}
|
||||
onChanged={(text) => { this.propertyNameChanged(group.option, text) }} />
|
||||
<Button text={`${group.cards.length}`} />
|
||||
<div className="octo-spacer" />
|
||||
<Button onClick={(e) => { this.valueOptionClicked(e, group.option) }}><div className="imageOptions" /></Button>
|
||||
<Button onClick={() => { this.addCard(group.option.value) }}><div className="imageAdd" /></Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="octo-board-header-cell">
|
||||
<Button text="+ Add a group" onClick={(e) => { this.addGroupClicked() }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
<div className="octo-board-body" id="mainBoardBody">
|
||||
|
||||
{/* No value column */}
|
||||
|
||||
<BoardColumn onDrop={(e) => { this.onDropToColumn(undefined) }}>
|
||||
{boardTree.emptyGroupCards.map(card =>
|
||||
<BoardCard
|
||||
mutator={mutator}
|
||||
card={card}
|
||||
visiblePropertyTemplates={visiblePropertyTemplates}
|
||||
key={card.id}
|
||||
onClick={() => { this.showCard(card) }}
|
||||
onDragStart={() => { this.draggedCard = card }}
|
||||
onDragEnd={() => { this.draggedCard = undefined }} />
|
||||
)}
|
||||
<Button text="+ New" onClick={() => { this.addCard(undefined) }} />
|
||||
</BoardColumn>
|
||||
|
||||
{/* Columns */}
|
||||
|
||||
{boardTree.groups.map(group =>
|
||||
<BoardColumn onDrop={(e) => { this.onDropToColumn(group.option) }} key={group.option.value}>
|
||||
{group.cards.map(card =>
|
||||
<BoardCard
|
||||
mutator={mutator}
|
||||
card={card}
|
||||
visiblePropertyTemplates={visiblePropertyTemplates}
|
||||
key={card.id}
|
||||
onClick={() => { this.showCard(card) }}
|
||||
onDragStart={() => { this.draggedCard = card }}
|
||||
onDragEnd={() => { this.draggedCard = undefined }} />
|
||||
)}
|
||||
<Button text="+ New" onClick={() => { this.addCard(group.option.value) }} />
|
||||
</BoardColumn>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private iconClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board } = boardTree
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "random", name: "Random" },
|
||||
{ id: "remove", name: "Remove Icon" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "remove":
|
||||
mutator.changeIcon(board, undefined, "remove icon")
|
||||
break
|
||||
case "random":
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(board, newIcon)
|
||||
break
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
async showCard(card?: IBlock) {
|
||||
console.log(`showCard: ${card?.title}`)
|
||||
|
||||
await this.props.pageController.showCard(card)
|
||||
}
|
||||
|
||||
async addCard(groupByValue?: string) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView, board } = boardTree
|
||||
|
||||
const properties = CardFilter.propertiesThatMeetFilterGroup(activeView.filter, board.cardProperties)
|
||||
const card = new Block({ type: "card", parentId: boardTree.board.id, properties })
|
||||
if (boardTree.groupByProperty) {
|
||||
Block.setProperty(card, boardTree.groupByProperty.id, groupByValue)
|
||||
}
|
||||
await mutator.insertBlock(card, "add card", async () => { await this.showCard(card) }, async () => { await this.showCard(undefined) })
|
||||
}
|
||||
|
||||
async propertyNameChanged(option: IPropertyOption, text: string) {
|
||||
const { mutator, boardTree } = this.props
|
||||
|
||||
await mutator.changePropertyOptionValue(boardTree, boardTree.groupByProperty, option, text)
|
||||
}
|
||||
|
||||
async valueOptionClicked(e: React.MouseEvent<HTMLElement>, option: IPropertyOption) {
|
||||
const { mutator, boardTree } = this.props
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "delete", name: "Delete" },
|
||||
{ id: "", name: "", type: "separator" },
|
||||
...Constants.menuColors
|
||||
]
|
||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "delete":
|
||||
console.log(`Delete property value: ${option.value}`)
|
||||
await mutator.deletePropertyOption(boardTree, boardTree.groupByProperty, option)
|
||||
break
|
||||
default:
|
||||
if (type === "color") {
|
||||
// id is the color
|
||||
await mutator.changePropertyOptionColor(boardTree.board, option, optionId)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private filterClicked(e: React.MouseEvent) {
|
||||
const { pageController } = this.props
|
||||
pageController.showFilter(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async sortClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView } = boardTree
|
||||
const { sortOptions } = activeView
|
||||
const sortOption = sortOptions.length > 0 ? sortOptions[0] : undefined
|
||||
|
||||
const propertyTemplates = boardTree.board.cardProperties
|
||||
Menu.shared.options = propertyTemplates.map((o) => { return { id: o.id, name: o.name } })
|
||||
Menu.shared.onMenuClicked = async (propertyId: string) => {
|
||||
let newSortOptions: ISortOption[] = []
|
||||
if (sortOption && sortOption.propertyId === propertyId) {
|
||||
// Already sorting by name, so reverse it
|
||||
newSortOptions = [
|
||||
{ propertyId, reversed: !sortOption.reversed }
|
||||
]
|
||||
} else {
|
||||
newSortOptions = [
|
||||
{ propertyId, reversed: false }
|
||||
]
|
||||
}
|
||||
|
||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async optionsClicked(e: React.MouseEvent) {
|
||||
const { boardTree } = this.props
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "exportBoardArchive", name: "Export board archive" },
|
||||
]
|
||||
|
||||
Menu.shared.onMenuClicked = async (id: string) => {
|
||||
switch (id) {
|
||||
case "exportBoardArchive": {
|
||||
Archiver.exportBoardTree(boardTree)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async propertiesClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView } = boardTree
|
||||
|
||||
const selectProperties = boardTree.board.cardProperties
|
||||
Menu.shared.options = selectProperties.map((o) => {
|
||||
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
||||
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
||||
})
|
||||
|
||||
Menu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
||||
const property = selectProperties.find(o => o.id === id)
|
||||
Utils.assertValue(property)
|
||||
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
||||
|
||||
let newVisiblePropertyIds = []
|
||||
if (activeView.visiblePropertyIds.includes(id)) {
|
||||
newVisiblePropertyIds = activeView.visiblePropertyIds.filter(o => o !== id)
|
||||
} else {
|
||||
newVisiblePropertyIds = [...activeView.visiblePropertyIds, id]
|
||||
}
|
||||
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async groupByClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
|
||||
const selectProperties = boardTree.board.cardProperties.filter(o => o.type === "select")
|
||||
Menu.shared.options = selectProperties.map((o) => { return { id: o.id, name: o.name } })
|
||||
Menu.shared.onMenuClicked = async (command: string) => {
|
||||
if (boardTree.activeView.groupById === command) { return }
|
||||
|
||||
await mutator.changeViewGroupById(boardTree.activeView, command)
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
async addGroupClicked() {
|
||||
console.log(`onAddGroupClicked`)
|
||||
|
||||
const { mutator, boardTree } = this.props
|
||||
|
||||
const option: IPropertyOption = {
|
||||
value: "New group",
|
||||
color: "#cccccc"
|
||||
}
|
||||
|
||||
await mutator.insertPropertyOption(boardTree, boardTree.groupByProperty, option, "add group")
|
||||
}
|
||||
|
||||
async onDropToColumn(option: IPropertyOption) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { draggedCard, draggedHeaderOption } = this
|
||||
const propertyValue = option ? option.value : undefined
|
||||
|
||||
Utils.assertValue(mutator)
|
||||
Utils.assertValue(boardTree)
|
||||
|
||||
if (draggedCard) {
|
||||
Utils.log(`ondrop. Card: ${draggedCard.title}, column: ${propertyValue}`)
|
||||
const oldValue = Block.getPropertyValue(draggedCard, boardTree.groupByProperty.id)
|
||||
if (propertyValue !== oldValue) {
|
||||
await mutator.changePropertyValue(draggedCard, boardTree.groupByProperty.id, propertyValue, "drag card")
|
||||
}
|
||||
} else if (draggedHeaderOption) {
|
||||
Utils.log(`ondrop. Header option: ${draggedHeaderOption.value}, column: ${propertyValue}`)
|
||||
Utils.assertValue(boardTree.groupByProperty)
|
||||
|
||||
// Move option to new index
|
||||
const { board } = boardTree
|
||||
const options = boardTree.groupByProperty.options
|
||||
const destIndex = option ? options.indexOf(option) : 0
|
||||
|
||||
await mutator.changePropertyOptionOrder(board, boardTree.groupByProperty, draggedHeaderOption, destIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { BoardComponent }
|
26
src/client/components/button.tsx
Normal file
26
src/client/components/button.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from "react"
|
||||
|
||||
type Props = {
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
style?: React.CSSProperties
|
||||
backgroundColor?: string
|
||||
text?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
class Button extends React.Component<Props> {
|
||||
render() {
|
||||
const style = {...this.props.style, backgroundColor: this.props.backgroundColor}
|
||||
return (
|
||||
<div
|
||||
onClick={this.props.onClick}
|
||||
className="octo-button"
|
||||
style={style}
|
||||
title={this.props.title}>
|
||||
{this.props.children}
|
||||
{this.props.text}
|
||||
</div>)
|
||||
}
|
||||
}
|
||||
|
||||
export { Button }
|
441
src/client/components/cardDialog.tsx
Normal file
441
src/client/components/cardDialog.tsx
Normal file
@ -0,0 +1,441 @@
|
||||
import React from "react"
|
||||
import { Block } from "../block"
|
||||
import { BlockIcons } from "../blockIcons"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { CardTree } from "../cardTree"
|
||||
import { Menu, MenuOption } from "../menu"
|
||||
import { Mutator } from "../mutator"
|
||||
import { IBlock } from "../octoTypes"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { PropertyMenu } from "../propertyMenu"
|
||||
import { Utils } from "../utils"
|
||||
import { Button } from "./button"
|
||||
import { Editable } from "./editable"
|
||||
import { MarkdownEditor } from "./markdownEditor"
|
||||
|
||||
type Props = {
|
||||
boardTree: BoardTree
|
||||
cardTree: CardTree
|
||||
mutator: Mutator
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
isHoverOnCover: boolean
|
||||
}
|
||||
|
||||
class CardDialog extends React.Component<Props, State> {
|
||||
private titleRef = React.createRef<Editable>()
|
||||
private keydownHandler: any
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { isHoverOnCover: false }
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.titleRef.current.focus()
|
||||
this.keydownHandler = (e: KeyboardEvent) => {
|
||||
if (e.target !== document.body) { return }
|
||||
|
||||
if (e.keyCode === 27) {
|
||||
this.close()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", this.keydownHandler)
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.keydownHandler)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { boardTree, cardTree, mutator } = this.props
|
||||
const { board } = boardTree
|
||||
const { card, comments } = cardTree
|
||||
|
||||
const newCommentPlaceholderText = "Add a comment..."
|
||||
|
||||
const backgroundRef = React.createRef<HTMLDivElement>()
|
||||
const newCommentRef = React.createRef<Editable>()
|
||||
const sendCommentButtonRef = React.createRef<HTMLDivElement>()
|
||||
let contentElements
|
||||
if (cardTree.contents.length > 0) {
|
||||
contentElements =
|
||||
<div className="octo-content">
|
||||
{cardTree.contents.map(block => {
|
||||
if (block.type === "text") {
|
||||
const cardText = block.title
|
||||
return <div key={block.id} className="octo-block octo-hover-container">
|
||||
<div className="octo-block-margin">
|
||||
<div className="octo-button octo-hovercontrol square octo-hover-item" onClick={(e) => { this.showContentBlockMenu(e, block) }}>
|
||||
<div className="imageOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownEditor text={cardText} placeholderText="Edit text..." onChanged={(text) => {
|
||||
Utils.log(`change text ${block.id}, ${text}`)
|
||||
mutator.changeTitle(block, text, "edit card text")
|
||||
}} />
|
||||
</div>
|
||||
} else if (block.type === "image") {
|
||||
const url = block.url
|
||||
return <div key={block.id} className="octo-block octo-hover-container">
|
||||
<div className="octo-block-margin">
|
||||
<div className="octo-button octo-hovercontrol square octo-hover-item" onClick={(e) => { this.showContentBlockMenu(e, block) }}>
|
||||
<div className="imageOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<img src={url} alt={block.title}></img>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div></div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
} else {
|
||||
contentElements = <div className="octo-content">
|
||||
<div className="octo-block octo-hover-container">
|
||||
<div className="octo-block-margin"></div>
|
||||
<MarkdownEditor
|
||||
text=""
|
||||
placeholderText="Add a description..."
|
||||
onChanged={(text) => {
|
||||
const order = cardTree.contents.length * 1000
|
||||
const block = new Block({ type: "text", parentId: card.id, title: text, order })
|
||||
mutator.insertBlock(block, "add card text")
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const icon = card.icon
|
||||
|
||||
// TODO: Replace this placeholder
|
||||
const username = "John Smith"
|
||||
const userImageUrl = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="fill: rgb(192, 192, 192);"><rect width="100" height="100" /></svg>`
|
||||
|
||||
const element =
|
||||
<div
|
||||
ref={backgroundRef}
|
||||
className="dialog-back"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === backgroundRef.current) { this.close() }
|
||||
}}>
|
||||
<div className="dialog" >
|
||||
<div className="toolbar">
|
||||
<div className="octo-spacer"></div>
|
||||
<Button text="..." onClick={(e) => {
|
||||
Menu.shared.options = [
|
||||
{ id: "delete", name: "Delete" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "delete":
|
||||
console.log(`Delete card: ${card.title} (${card.id})`)
|
||||
await mutator.deleteBlock(card, "delete card")
|
||||
this.close()
|
||||
break
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}}></Button>
|
||||
</div>
|
||||
<div className="content">
|
||||
{icon ?
|
||||
<div className="octo-button octo-icon octo-card-icon" onClick={(e) => { this.iconClicked(e) }}>{icon}</div>
|
||||
: undefined
|
||||
}
|
||||
<div
|
||||
className="octo-hovercontrols"
|
||||
onMouseOver={() => { this.setState({ ...this.state, isHoverOnCover: true }) }}
|
||||
onMouseLeave={() => { this.setState({ ...this.state, isHoverOnCover: false }) }}
|
||||
>
|
||||
<Button
|
||||
style={{ display: (!icon && this.state.isHoverOnCover) ? null : "none" }}
|
||||
onClick={() => {
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(card, newIcon)
|
||||
}}
|
||||
>Add Icon</Button>
|
||||
</div>
|
||||
|
||||
<Editable ref={this.titleRef} className="title" text={card.title} placeholderText="Untitled" onChanged={(text) => { mutator.changeTitle(card, text) }} />
|
||||
|
||||
{/* Property list */}
|
||||
|
||||
<div className="octo-propertylist">
|
||||
{board.cardProperties.map(propertyTemplate => {
|
||||
return (
|
||||
<div key={propertyTemplate.id} className="octo-propertyrow">
|
||||
<div className="octo-button octo-propertyname" onClick={(e) => {
|
||||
const menu = PropertyMenu.shared
|
||||
menu.property = propertyTemplate
|
||||
menu.onNameChanged = (propertyName) => {
|
||||
Utils.log(`menu.onNameChanged`)
|
||||
mutator.renameProperty(board, propertyTemplate.id, propertyName)
|
||||
}
|
||||
|
||||
menu.onMenuClicked = async (command) => {
|
||||
switch (command) {
|
||||
case "type-text":
|
||||
await mutator.changePropertyType(board, propertyTemplate, "text")
|
||||
break
|
||||
case "type-number":
|
||||
await mutator.changePropertyType(board, propertyTemplate, "number")
|
||||
break
|
||||
case "type-createdTime":
|
||||
await mutator.changePropertyType(board, propertyTemplate, "createdTime")
|
||||
break
|
||||
case "type-updatedTime":
|
||||
await mutator.changePropertyType(board, propertyTemplate, "updatedTime")
|
||||
break
|
||||
case "type-select":
|
||||
await mutator.changePropertyType(board, propertyTemplate, "select")
|
||||
break
|
||||
case "delete":
|
||||
await mutator.deleteProperty(boardTree, propertyTemplate.id)
|
||||
break
|
||||
default:
|
||||
Utils.assertFailure(`Unhandled menu id: ${command}`)
|
||||
}
|
||||
}
|
||||
menu.showAtElement(e.target as HTMLElement)
|
||||
}}>{propertyTemplate.name}</div>
|
||||
{OctoUtils.propertyValueEditableElement(mutator, card, propertyTemplate)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div
|
||||
className="octo-button octo-propertyname"
|
||||
style={{ textAlign: "left", width: "150px", color: "rgba(55, 53, 37, 0.4)" }}
|
||||
onClick={async () => {
|
||||
// TODO: Show UI
|
||||
await mutator.insertPropertyTemplate(boardTree)
|
||||
}}>+ Add a property</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
|
||||
<hr />
|
||||
<div className="commentlist">
|
||||
{comments.map(comment => {
|
||||
const optionsButtonRef = React.createRef<HTMLDivElement>()
|
||||
const showCommentMenu = (e: React.MouseEvent, activeComment: IBlock) => {
|
||||
Menu.shared.options = [
|
||||
{ id: "delete", name: "Delete" }
|
||||
]
|
||||
Menu.shared.onMenuClicked = (id) => {
|
||||
switch (id) {
|
||||
case "delete": {
|
||||
mutator.deleteBlock(activeComment)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
return <div key={comment.id} className="comment" onMouseOver={() => { optionsButtonRef.current.style.display = null }} onMouseLeave={() => { optionsButtonRef.current.style.display = "none" }}>
|
||||
<div className="comment-header">
|
||||
<img className="comment-avatar" src={userImageUrl} />
|
||||
<div className="comment-username">{username}</div>
|
||||
<div className="comment-date">{(new Date(comment.createAt)).toLocaleTimeString()}</div>
|
||||
<div ref={optionsButtonRef} className="octo-hoverbutton square" style={{ display: "none" }} onClick={(e) => { showCommentMenu(e, comment) }}>...</div>
|
||||
</div>
|
||||
<div className="comment-text">{comment.title}</div>
|
||||
</div>
|
||||
})}
|
||||
|
||||
{/* New comment */}
|
||||
|
||||
<div className="commentrow">
|
||||
<img className="comment-avatar" src={userImageUrl} />
|
||||
<Editable
|
||||
ref={newCommentRef}
|
||||
className="newcomment"
|
||||
placeholderText={newCommentPlaceholderText}
|
||||
onChanged={(text) => { return }}
|
||||
onFocus={() => {
|
||||
sendCommentButtonRef.current.style.display = null
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!newCommentRef.current.text) {
|
||||
sendCommentButtonRef.current.style.display = "none"
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) {
|
||||
sendCommentButtonRef.current.click()
|
||||
}
|
||||
}}
|
||||
></Editable>
|
||||
|
||||
<div ref={sendCommentButtonRef} className="octo-button filled" style={{ display: "none" }}
|
||||
onClick={(e) => {
|
||||
const text = newCommentRef.current.text
|
||||
console.log(`Send comment: ${newCommentRef.current.text}`)
|
||||
this.sendComment(text)
|
||||
newCommentRef.current.text = undefined
|
||||
newCommentRef.current.blur()
|
||||
}}
|
||||
>Send</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
{/* Content blocks */}
|
||||
|
||||
<div className="content fullwidth">
|
||||
{contentElements}
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<div className="octo-hoverpanel octo-hover-container">
|
||||
<div
|
||||
className="octo-button octo-hovercontrol octo-hover-item"
|
||||
onClick={(e) => {
|
||||
Menu.shared.options = [
|
||||
{ id: "text", name: "Text" },
|
||||
{ id: "image", name: "Image" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "text":
|
||||
const order = cardTree.contents.length * 1000
|
||||
const block = new Block({ type: "text", parentId: card.id, order })
|
||||
await mutator.insertBlock(block, "add text")
|
||||
break
|
||||
case "image":
|
||||
Utils.selectLocalFile(
|
||||
(file) => {
|
||||
mutator.createImageBlock(card.id, file, cardTree.contents.length * 1000)
|
||||
},
|
||||
".jpg,.jpeg,.png")
|
||||
break
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}}
|
||||
>Add content</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div >
|
||||
</div >
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
async sendComment(text: string) {
|
||||
const { mutator, cardTree } = this.props
|
||||
const { card } = cardTree
|
||||
|
||||
Utils.assertValue(card)
|
||||
|
||||
const block = new Block({ type: "comment", parentId: card.id, title: text })
|
||||
await mutator.insertBlock(block, "add comment")
|
||||
}
|
||||
|
||||
private showContentBlockMenu(e: React.MouseEvent, block: IBlock) {
|
||||
const { mutator, cardTree } = this.props
|
||||
const { card } = cardTree
|
||||
const index = cardTree.contents.indexOf(block)
|
||||
|
||||
const options: MenuOption[] = []
|
||||
if (index > 0) {
|
||||
options.push({ id: "moveUp", name: "Move up" })
|
||||
}
|
||||
if (index < cardTree.contents.length - 1) {
|
||||
options.push({ id: "moveDown", name: "Move down" })
|
||||
}
|
||||
|
||||
options.push(
|
||||
{ id: "insertAbove", name: "Insert above", type: "submenu" },
|
||||
{ id: "delete", name: "Delete" }
|
||||
)
|
||||
|
||||
Menu.shared.options = options
|
||||
Menu.shared.subMenuOptions.set("insertAbove", [
|
||||
{ id: "text", name: "Text" },
|
||||
{ id: "image", name: "Image" },
|
||||
])
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "moveUp": {
|
||||
if (index < 1) { Utils.logError(`Unexpected index ${index}`); return }
|
||||
const previousBlock = cardTree.contents[index - 1]
|
||||
const newOrder = OctoUtils.getOrderBefore(previousBlock, cardTree.contents)
|
||||
Utils.log(`moveUp ${newOrder}`)
|
||||
mutator.changeOrder(block, newOrder, "move up")
|
||||
break
|
||||
}
|
||||
case "moveDown": {
|
||||
if (index >= cardTree.contents.length - 1) { Utils.logError(`Unexpected index ${index}`); return }
|
||||
const nextBlock = cardTree.contents[index + 1]
|
||||
const newOrder = OctoUtils.getOrderAfter(nextBlock, cardTree.contents)
|
||||
Utils.log(`moveDown ${newOrder}`)
|
||||
mutator.changeOrder(block, newOrder, "move down")
|
||||
break
|
||||
}
|
||||
case "insertAbove-text": {
|
||||
const newBlock = new Block({ type: "text", parentId: card.id })
|
||||
// TODO: Handle need to reorder all blocks
|
||||
newBlock.order = OctoUtils.getOrderBefore(block, cardTree.contents)
|
||||
Utils.log(`insert block ${block.id}, order: ${block.order}`)
|
||||
mutator.insertBlock(newBlock, "insert card text")
|
||||
break
|
||||
}
|
||||
case "insertAbove-image": {
|
||||
Utils.selectLocalFile(
|
||||
(file) => {
|
||||
mutator.createImageBlock(card.id, file, OctoUtils.getOrderBefore(block, cardTree.contents))
|
||||
},
|
||||
".jpg,.jpeg,.png")
|
||||
|
||||
break
|
||||
}
|
||||
case "delete": {
|
||||
mutator.deleteBlock(block)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private iconClicked(e: React.MouseEvent) {
|
||||
const { mutator, cardTree } = this.props
|
||||
const { card } = cardTree
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "random", name: "Random" },
|
||||
{ id: "remove", name: "Remove Icon" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "remove":
|
||||
mutator.changeIcon(card, undefined, "remove icon")
|
||||
break
|
||||
case "random":
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(card, newIcon)
|
||||
break
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
close() {
|
||||
Menu.shared.hide()
|
||||
PropertyMenu.shared.hide()
|
||||
|
||||
this.props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
export { CardDialog }
|
126
src/client/components/editable.tsx
Normal file
126
src/client/components/editable.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import React from "react"
|
||||
import { Utils } from "../utils"
|
||||
|
||||
type Props = {
|
||||
onChanged: (text: string) => void
|
||||
text?: string
|
||||
placeholderText?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
isMarkdown: boolean
|
||||
isMultiline: boolean
|
||||
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
}
|
||||
|
||||
class Editable extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
text: "",
|
||||
isMarkdown: false,
|
||||
isMultiline: false
|
||||
}
|
||||
|
||||
private _text: string = ""
|
||||
get text(): string { return this._text }
|
||||
set text(value: string) {
|
||||
const { isMarkdown } = this.props
|
||||
|
||||
if (!value) {
|
||||
this.elementRef.current.innerText = ""
|
||||
} else {
|
||||
this.elementRef.current.innerHTML = isMarkdown ? Utils.htmlFromMarkdown(value) : Utils.htmlEncode(value)
|
||||
}
|
||||
|
||||
this._text = value || ""
|
||||
}
|
||||
|
||||
private elementRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this._text = props.text || ""
|
||||
}
|
||||
|
||||
componentDidUpdate(prevPros: Props, prevState: State) {
|
||||
this._text = this.props.text || ""
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.elementRef.current.focus()
|
||||
// Put cursor at end
|
||||
document.execCommand("selectAll", false, null)
|
||||
document.getSelection().collapseToEnd()
|
||||
}
|
||||
|
||||
blur() {
|
||||
this.elementRef.current.blur()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { text, className, style, placeholderText, isMarkdown, isMultiline, onFocus, onBlur, onKeyDown, onChanged } = this.props
|
||||
|
||||
const initialStyle = { ...this.props.style }
|
||||
|
||||
let html: string
|
||||
if (text) {
|
||||
html = isMarkdown ? Utils.htmlFromMarkdown(text) : Utils.htmlEncode(text)
|
||||
} else {
|
||||
html = ""
|
||||
}
|
||||
|
||||
const element =
|
||||
<div
|
||||
ref={this.elementRef}
|
||||
className={"octo-editable " + className}
|
||||
contentEditable={true}
|
||||
suppressContentEditableWarning={true}
|
||||
style={initialStyle}
|
||||
placeholder={placeholderText}
|
||||
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
|
||||
onFocus={() => {
|
||||
this.elementRef.current.innerText = this.text
|
||||
this.elementRef.current.style.color = style?.color || null
|
||||
this.elementRef.current.classList.add("active")
|
||||
|
||||
if (onFocus) { onFocus() }
|
||||
}}
|
||||
|
||||
onBlur={async () => {
|
||||
const newText = this.elementRef.current.innerText
|
||||
const oldText = this.props.text || ""
|
||||
if (newText !== oldText && onChanged) {
|
||||
onChanged(newText)
|
||||
}
|
||||
|
||||
this.text = newText
|
||||
|
||||
this.elementRef.current.classList.remove("active")
|
||||
|
||||
if (onBlur) { onBlur() }
|
||||
}}
|
||||
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 27 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // ESC
|
||||
e.stopPropagation()
|
||||
this.elementRef.current.blur()
|
||||
} else if (!isMultiline && e.keyCode === 13 && !(e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { // Return
|
||||
e.stopPropagation()
|
||||
this.elementRef.current.blur()
|
||||
}
|
||||
|
||||
if (onKeyDown) { onKeyDown(e) }
|
||||
}}
|
||||
></div>
|
||||
|
||||
return element
|
||||
}
|
||||
}
|
||||
|
||||
export { Editable }
|
175
src/client/components/filterComponent.tsx
Normal file
175
src/client/components/filterComponent.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
import React from "react"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { FilterClause, FilterCondition } from "../filterClause"
|
||||
import { FilterGroup } from "../filterGroup"
|
||||
import { Menu } from "../menu"
|
||||
import { Mutator } from "../mutator"
|
||||
import { Utils } from "../utils"
|
||||
|
||||
type Props = {
|
||||
mutator: Mutator
|
||||
boardTree: BoardTree
|
||||
pageX: number
|
||||
pageY: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
class FilterComponent extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { boardTree } = this.props
|
||||
const { board, activeView } = boardTree
|
||||
|
||||
const backgroundRef = React.createRef<HTMLDivElement>()
|
||||
// TODO: Handle FilterGroups (compound filter statements)
|
||||
const filters: FilterClause[] = activeView.filter?.filters.filter(o => !FilterGroup.isAnInstanceOf(o)) as FilterClause[] || []
|
||||
|
||||
return (
|
||||
<div
|
||||
className="octo-modal-back"
|
||||
ref={backgroundRef}
|
||||
onClick={(e) => { if (e.target === backgroundRef.current) { this.props.onClose() } }}
|
||||
>
|
||||
<div
|
||||
className="octo-modal octo-filter-dialog"
|
||||
style={{ position: "absolute", left: this.props.pageX, top: this.props.pageY }}
|
||||
>
|
||||
{filters.map(filter => {
|
||||
const template = board.cardProperties.find(o => o.id === filter.propertyId)
|
||||
const propertyName = template ? template.name : "(unknown)" // TODO: Handle error
|
||||
const key = `${filter.propertyId}-${filter.condition}-${filter.values.join(",")}`
|
||||
Utils.log(`FilterClause key: ${key}`)
|
||||
return <div className="octo-filterclause" key={key}>
|
||||
<div className="octo-button" onClick={(e) => this.propertyClicked(e, filter)}>{propertyName}</div>
|
||||
<div className="octo-button" onClick={(e) => this.conditionClicked(e, filter)}>{FilterClause.filterConditionDisplayString(filter.condition)}</div>
|
||||
{
|
||||
filter.condition === "includes" || filter.condition === "notIncludes"
|
||||
? <div className="octo-button" onClick={(e) => this.valuesClicked(e, filter)}>
|
||||
{
|
||||
filter.values.length > 0
|
||||
? filter.values.join(", ")
|
||||
: <div>(empty)</div>
|
||||
}
|
||||
</div>
|
||||
: undefined
|
||||
}
|
||||
<div className="octo-spacer" />
|
||||
<div className="octo-button" onClick={() => this.deleteClicked(filter)}>Delete</div>
|
||||
</div>
|
||||
})}
|
||||
|
||||
<br />
|
||||
|
||||
<div className="octo-button" onClick={() => this.addFilterClicked()}>+ Add Filter</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private propertyClicked(e: React.MouseEvent, filter: FilterClause) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board, activeView: view } = boardTree
|
||||
|
||||
const filterIndex = view.filter.filters.indexOf(filter)
|
||||
Utils.assert(filterIndex >= 0, `Can't find filter`)
|
||||
|
||||
Menu.shared.options = board.cardProperties
|
||||
.filter(o => o.type === "select")
|
||||
.map(o => ({ id: o.id, name: o.name }))
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
const filterGroup = new FilterGroup(view.filter)
|
||||
const newFilter = filterGroup.filters[filterIndex] as FilterClause
|
||||
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
|
||||
if (newFilter.propertyId !== optionId) {
|
||||
newFilter.propertyId = optionId
|
||||
newFilter.values = []
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private conditionClicked(e: React.MouseEvent, filter: FilterClause) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView: view } = boardTree
|
||||
|
||||
const filterIndex = view.filter.filters.indexOf(filter)
|
||||
Utils.assert(filterIndex >= 0, `Can't find filter`)
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "includes", name: "includes" },
|
||||
{ id: "notIncludes", name: "doesn't include" },
|
||||
{ id: "isEmpty", name: "is empty" },
|
||||
{ id: "isNotEmpty", name: "is not empty" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
const filterGroup = new FilterGroup(view.filter)
|
||||
const newFilter = filterGroup.filters[filterIndex] as FilterClause
|
||||
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
|
||||
if (newFilter.condition !== optionId) {
|
||||
newFilter.condition = optionId as FilterCondition
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private valuesClicked(e: React.MouseEvent, filter: FilterClause) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board, activeView: view } = boardTree
|
||||
|
||||
const template = board.cardProperties.find(o => o.id === filter.propertyId)
|
||||
if (!template) { return }
|
||||
|
||||
// BUGBUG: This index will be wrong if someone else changes the filter array after a change. Solution is to make Menu a React component.
|
||||
const filterIndex = view.filter.filters.indexOf(filter)
|
||||
Utils.assert(filterIndex >= 0, `Can't find filter`)
|
||||
|
||||
Menu.shared.options = template.options.map(o => ({ id: o.value, name: o.value, type: "switch", isOn: filter.values.includes(o.value) }))
|
||||
Menu.shared.onMenuToggled = async (optionId: string, isOn: boolean) => {
|
||||
// const index = view.filter.filters.indexOf(filter)
|
||||
const filterGroup = new FilterGroup(view.filter)
|
||||
const newFilter = filterGroup.filters[filterIndex] as FilterClause
|
||||
Utils.assert(newFilter, `No filter at index ${filterIndex}`)
|
||||
if (isOn) {
|
||||
newFilter.values.push(optionId)
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
} else {
|
||||
newFilter.values = newFilter.values.filter(o => o !== optionId)
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private deleteClicked(filter: FilterClause) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView: view } = boardTree
|
||||
|
||||
const filterGroup = new FilterGroup(view.filter)
|
||||
filterGroup.filters = filterGroup.filters.filter(o => FilterGroup.isAnInstanceOf(o) || !o.isEqual(filter))
|
||||
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
}
|
||||
|
||||
private addFilterClicked() {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board, activeView: view } = boardTree
|
||||
|
||||
const filters = view.filter?.filters.filter(o => !FilterGroup.isAnInstanceOf(o)) as FilterClause[] || []
|
||||
const filterGroup = new FilterGroup(view.filter)
|
||||
const filter = new FilterClause()
|
||||
// Pick the first select property that isn't already filtered on
|
||||
const selectProperty = board.cardProperties
|
||||
.filter(o => !filters.find(f => f.propertyId === o.id))
|
||||
.find(o => o.type === "select")
|
||||
if (selectProperty) {
|
||||
filter.propertyId = selectProperty.id
|
||||
}
|
||||
filterGroup.filters.push(filter)
|
||||
|
||||
mutator.changeViewFilter(view, filterGroup)
|
||||
}
|
||||
}
|
||||
|
||||
export { FilterComponent }
|
178
src/client/components/markdownEditor.tsx
Normal file
178
src/client/components/markdownEditor.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import EasyMDE from "easymde"
|
||||
import React from "react"
|
||||
import SimpleMDE from "react-simplemde-editor"
|
||||
import { Utils } from "../utils"
|
||||
|
||||
type Props = {
|
||||
onChanged: (text: string) => void
|
||||
text?: string
|
||||
placeholderText?: string
|
||||
uniqueId?: string
|
||||
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
isEditing: boolean
|
||||
}
|
||||
|
||||
class MarkdownEditor extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
text: ""
|
||||
}
|
||||
|
||||
get text(): string { return this.elementRef.current.state.value }
|
||||
set text(value: string) {
|
||||
this.elementRef.current.setState({ value })
|
||||
}
|
||||
|
||||
private editorInstance: EasyMDE
|
||||
private frameRef = React.createRef<HTMLDivElement>()
|
||||
private elementRef = React.createRef<SimpleMDE>()
|
||||
private previewRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { isEditing: false }
|
||||
}
|
||||
|
||||
componentDidUpdate(prevPros: Props, prevState: State) {
|
||||
this.text = this.props.text || ""
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
const cm = this.editorInstance?.codemirror
|
||||
if (cm) {
|
||||
setTimeout(() => {
|
||||
cm.refresh()
|
||||
cm.focus()
|
||||
cm.getInputField()?.focus()
|
||||
cm.setCursor(cm.lineCount(), 0) // Put cursor at end
|
||||
}, 100)
|
||||
}
|
||||
|
||||
this.setState({ isEditing: true })
|
||||
}
|
||||
|
||||
hideEditor() {
|
||||
this.editorInstance?.codemirror?.getInputField()?.blur()
|
||||
this.setState({ isEditing: false })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { text, placeholderText, uniqueId, onFocus, onBlur, onChanged } = this.props
|
||||
const { isEditing } = this.state
|
||||
|
||||
let html: string
|
||||
if (text) {
|
||||
html = Utils.htmlFromMarkdown(text)
|
||||
} else {
|
||||
html = Utils.htmlFromMarkdown(placeholderText || "")
|
||||
}
|
||||
|
||||
const previewElement =
|
||||
<div
|
||||
ref={this.previewRef}
|
||||
className={text ? "octo-editor-preview" : "octo-editor-preview octo-placeholder"}
|
||||
style={{ display: isEditing ? "none" : null }}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
this.showEditor()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
const editorElement =
|
||||
<div className="octo-editor-activeEditor"
|
||||
// Use visibility instead of display here so the editor is pre-rendered, avoiding a flash on showEditor
|
||||
style={isEditing ? {} : { visibility: "hidden", position: "absolute", top: 0, left: 0 }}
|
||||
onKeyDown={(e) => {
|
||||
// HACKHACK: Need to handle here instad of in CodeMirror because that breaks auto-lists
|
||||
if (e.keyCode === 27 && !e.shiftKey && !(e.ctrlKey || e.metaKey) && !e.altKey) { // Esc
|
||||
this.editorInstance?.codemirror?.getInputField()?.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SimpleMDE
|
||||
id={uniqueId}
|
||||
ref={this.elementRef}
|
||||
getMdeInstance={(instance) => {
|
||||
this.editorInstance = instance
|
||||
|
||||
// BUGBUG: This breaks auto-lists
|
||||
// instance.codemirror.setOption("extraKeys", {
|
||||
// "Ctrl-Enter": (cm) => {
|
||||
// cm.getInputField().blur()
|
||||
// }
|
||||
// })
|
||||
}}
|
||||
value={text}
|
||||
// onChange={() => {
|
||||
// // We register a change onBlur, consider implementing "auto-save" later
|
||||
// }}
|
||||
events={{
|
||||
"blur": () => {
|
||||
const newText = this.elementRef.current.state.value
|
||||
const oldText = this.props.text || ""
|
||||
if (newText !== oldText && onChanged) {
|
||||
const newHtml = newText ? Utils.htmlFromMarkdown(newText) : Utils.htmlFromMarkdown(placeholderText || "")
|
||||
this.previewRef.current.innerHTML = newHtml
|
||||
onChanged(newText)
|
||||
}
|
||||
|
||||
this.text = newText
|
||||
|
||||
this.frameRef.current.classList.remove("active")
|
||||
|
||||
if (onBlur) { onBlur() }
|
||||
|
||||
this.hideEditor()
|
||||
},
|
||||
"focus": () => {
|
||||
this.frameRef.current.classList.add("active")
|
||||
|
||||
this.elementRef.current.setState({ value: this.text })
|
||||
|
||||
if (onFocus) { onFocus() }
|
||||
},
|
||||
}}
|
||||
options={{
|
||||
autoDownloadFontAwesome: true,
|
||||
toolbar: false,
|
||||
status: false,
|
||||
spellChecker: false,
|
||||
minHeight: "10px",
|
||||
shortcuts: {
|
||||
"toggleStrikethrough": "Cmd-.",
|
||||
"togglePreview": null,
|
||||
"drawImage": null,
|
||||
"drawLink": null,
|
||||
"toggleSideBySide": null,
|
||||
"toggleFullScreen": null
|
||||
}
|
||||
}}
|
||||
// BUGBUG: This breaks auto-lists
|
||||
// extraKeys={{
|
||||
// Esc: (cm) => {
|
||||
// cm.getInputField().blur()
|
||||
// }
|
||||
// }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
const element =
|
||||
<div
|
||||
ref={this.frameRef}
|
||||
className="octo-editor"
|
||||
>
|
||||
{previewElement}
|
||||
{editorElement}
|
||||
</div>
|
||||
|
||||
return element
|
||||
}
|
||||
}
|
||||
|
||||
export { MarkdownEditor }
|
63
src/client/components/switch.tsx
Normal file
63
src/client/components/switch.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React from "react"
|
||||
|
||||
type Props = {
|
||||
onChanged: (isOn: boolean) => void
|
||||
isOn: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
type State = {
|
||||
isOn: boolean
|
||||
}
|
||||
|
||||
// Switch is an on-off style switch / checkbox
|
||||
class Switch extends React.Component<Props, State> {
|
||||
static defaultProps = {
|
||||
isMarkdown: false,
|
||||
isMultiline: false
|
||||
}
|
||||
|
||||
elementRef = React.createRef<HTMLDivElement>()
|
||||
innerElementRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { isOn: props.isOn }
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.elementRef.current.focus()
|
||||
// Put cursor at end
|
||||
document.execCommand("selectAll", false, null)
|
||||
document.getSelection().collapseToEnd()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { style } = this.props
|
||||
const { isOn } = this.state
|
||||
|
||||
const className = isOn ? "octo-switch on" : "octo-switch"
|
||||
const element =
|
||||
<div
|
||||
ref={this.elementRef}
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={() => { this.onClicked() }}
|
||||
>
|
||||
<div ref={this.innerElementRef} className="octo-switch-inner"></div>
|
||||
</div>
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
private async onClicked() {
|
||||
const newIsOn = !this.state.isOn
|
||||
await this.setState({ isOn: newIsOn })
|
||||
|
||||
const { onChanged } = this.props
|
||||
|
||||
onChanged(newIsOn)
|
||||
}
|
||||
}
|
||||
|
||||
export { Switch }
|
406
src/client/components/tableComponent.tsx
Normal file
406
src/client/components/tableComponent.tsx
Normal file
@ -0,0 +1,406 @@
|
||||
import React from "react"
|
||||
import { Archiver } from "../archiver"
|
||||
import { Block } from "../block"
|
||||
import { BlockIcons } from "../blockIcons"
|
||||
import { IPropertyTemplate } from "../board"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { ISortOption } from "../boardView"
|
||||
import { CsvExporter } from "../csvExporter"
|
||||
import { Menu } from "../menu"
|
||||
import { Mutator } from "../mutator"
|
||||
import { IBlock, IPageController } from "../octoTypes"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Utils } from "../utils"
|
||||
import { Button } from "./button"
|
||||
import { Editable } from "./editable"
|
||||
import { TableRow } from "./tableRow"
|
||||
|
||||
type Props = {
|
||||
mutator: Mutator,
|
||||
boardTree?: BoardTree
|
||||
pageController: IPageController
|
||||
}
|
||||
|
||||
type State = {
|
||||
isHoverOnCover: boolean
|
||||
}
|
||||
|
||||
class TableComponent extends React.Component<Props, State> {
|
||||
private draggedHeaderTemplate: IPropertyTemplate
|
||||
private cardIdToRowMap = new Map<string, React.RefObject<TableRow>>()
|
||||
private cardIdToFocusOnRender: string
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { isHoverOnCover: false }
|
||||
}
|
||||
|
||||
render() {
|
||||
const { mutator, boardTree, pageController } = this.props
|
||||
|
||||
if (!boardTree || !boardTree.board) {
|
||||
return (
|
||||
<div>Loading...</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { board, cards, activeView } = boardTree
|
||||
|
||||
const hasFilter = activeView.filter && activeView.filter.filters?.length > 0
|
||||
const hasSort = activeView.sortOptions.length > 0
|
||||
|
||||
this.cardIdToRowMap.clear()
|
||||
|
||||
return (
|
||||
<div className="octo-app">
|
||||
<div className="octo-frame">
|
||||
<div
|
||||
className="octo-hovercontrols"
|
||||
onMouseOver={() => { this.setState({ ...this.state, isHoverOnCover: true }) }}
|
||||
onMouseLeave={() => { this.setState({ ...this.state, isHoverOnCover: false }) }}
|
||||
>
|
||||
<Button
|
||||
style={{ display: (!board.icon && this.state.isHoverOnCover) ? null : "none" }}
|
||||
onClick={() => {
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(board, newIcon)
|
||||
}}
|
||||
>Add Icon</Button>
|
||||
</div>
|
||||
|
||||
<div className="octo-icontitle">
|
||||
{board.icon ?
|
||||
<div className="octo-button octo-icon" onClick={(e) => { this.iconClicked(e) }}>{board.icon}</div>
|
||||
: undefined}
|
||||
<Editable className="title" text={board.title} placeholderText="Untitled Board" onChanged={(text) => { mutator.changeTitle(board, text) }} />
|
||||
</div>
|
||||
|
||||
<div className="octo-table">
|
||||
<div className="octo-controls">
|
||||
<Editable style={{ color: "#000000", fontWeight: 600 }} text={activeView.title} placeholderText="Untitled View" onChanged={(text) => { mutator.changeTitle(activeView, text) }} />
|
||||
<div className="octo-button" style={{ color: "#000000", fontWeight: 600 }} onClick={(e) => { OctoUtils.showViewMenu(e, mutator, boardTree, pageController) }}><div className="imageDropdown"></div></div>
|
||||
<div className="octo-spacer"></div>
|
||||
<div className="octo-button" onClick={(e) => { this.propertiesClicked(e) }}>Properties</div>
|
||||
<div className={ hasFilter ? "octo-button active" : "octo-button"} onClick={(e) => { this.filterClicked(e) }}>Filter</div>
|
||||
<div className={ hasSort ? "octo-button active" : "octo-button"} onClick={(e) => { this.sortClicked(e) }}>Sort</div>
|
||||
<div className="octo-button">Search</div>
|
||||
<div className="octo-button" onClick={(e) => this.optionsClicked(e)}><div className="imageOptions"></div></div>
|
||||
<div className="octo-button filled" onClick={() => { this.addCard(true) }}>New</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
<div className="octo-table-body">
|
||||
|
||||
{/* Headers */}
|
||||
|
||||
<div className="octo-table-header" id="mainBoardHeader">
|
||||
<div className="octo-table-cell" id="mainBoardHeader">
|
||||
<div
|
||||
className="octo-label"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={(e) => { this.headerClicked(e, "__name") }}
|
||||
>Name</div>
|
||||
</div>
|
||||
|
||||
{board.cardProperties
|
||||
.filter(template => activeView.visiblePropertyIds.includes(template.id))
|
||||
.map(template =>
|
||||
<div
|
||||
key={template.id}
|
||||
className="octo-table-cell"
|
||||
|
||||
draggable={true}
|
||||
onDragStart={() => { this.draggedHeaderTemplate = template }}
|
||||
onDragEnd={() => { this.draggedHeaderTemplate = undefined }}
|
||||
|
||||
onDragOver={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.add("dragover") }}
|
||||
onDragEnter={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.add("dragover") }}
|
||||
onDragLeave={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.remove("dragover") }}
|
||||
onDrop={(e) => { e.preventDefault(); (e.target as HTMLElement).classList.remove("dragover"); this.onDropToColumn(template) }}
|
||||
>
|
||||
<div
|
||||
className="octo-label"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={(e) => { this.headerClicked(e, template.id) }}
|
||||
>{template.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rows, one per card */}
|
||||
|
||||
{cards.map(card => {
|
||||
|
||||
const openButonRef = React.createRef<HTMLDivElement>()
|
||||
const tableRowRef = React.createRef<TableRow>()
|
||||
|
||||
let focusOnMount = false
|
||||
if (this.cardIdToFocusOnRender && this.cardIdToFocusOnRender === card.id) {
|
||||
this.cardIdToFocusOnRender = undefined
|
||||
focusOnMount = true
|
||||
}
|
||||
|
||||
const tableRow = <TableRow
|
||||
key={card.id}
|
||||
ref={tableRowRef}
|
||||
mutator={mutator}
|
||||
boardTree={boardTree}
|
||||
card={card}
|
||||
focusOnMount={focusOnMount}
|
||||
showCard={(c) => { this.showCard(c) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.keyCode === 13) {
|
||||
// Enter: Insert new card if on last row
|
||||
if (cards.length > 0 && cards[cards.length - 1] === card) {
|
||||
this.addCard(false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
></TableRow>
|
||||
|
||||
this.cardIdToRowMap.set(card.id, tableRowRef)
|
||||
|
||||
return tableRow
|
||||
})}
|
||||
|
||||
{/* Add New row */}
|
||||
|
||||
<div className="octo-table-footer">
|
||||
<div className="octo-table-cell" onClick={() => { this.addCard() }}>
|
||||
+ New
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
private iconClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board } = boardTree
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "random", name: "Random" },
|
||||
{ id: "remove", name: "Remove Icon" },
|
||||
]
|
||||
Menu.shared.onMenuClicked = (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "remove":
|
||||
mutator.changeIcon(board, undefined, "remove icon")
|
||||
break
|
||||
case "random":
|
||||
const newIcon = BlockIcons.shared.randomIcon()
|
||||
mutator.changeIcon(board, newIcon)
|
||||
break
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async propertiesClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView } = boardTree
|
||||
|
||||
const selectProperties = boardTree.board.cardProperties
|
||||
Menu.shared.options = selectProperties.map((o) => {
|
||||
const isVisible = activeView.visiblePropertyIds.includes(o.id)
|
||||
return { id: o.id, name: o.name, type: "switch", isOn: isVisible }
|
||||
})
|
||||
|
||||
Menu.shared.onMenuToggled = async (id: string, isOn: boolean) => {
|
||||
const property = selectProperties.find(o => o.id === id)
|
||||
Utils.assertValue(property)
|
||||
Utils.log(`Toggle property ${property.name} ${isOn}`)
|
||||
|
||||
let newVisiblePropertyIds = []
|
||||
if (activeView.visiblePropertyIds.includes(id)) {
|
||||
newVisiblePropertyIds = activeView.visiblePropertyIds.filter(o => o !== id)
|
||||
} else {
|
||||
newVisiblePropertyIds = [...activeView.visiblePropertyIds, id]
|
||||
}
|
||||
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private filterClicked(e: React.MouseEvent) {
|
||||
const { pageController } = this.props
|
||||
pageController.showFilter(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async sortClicked(e: React.MouseEvent) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { activeView } = boardTree
|
||||
const { sortOptions } = activeView
|
||||
const sortOption = sortOptions.length > 0 ? sortOptions[0] : undefined
|
||||
|
||||
const propertyTemplates = boardTree.board.cardProperties
|
||||
Menu.shared.options = propertyTemplates.map((o) => { return { id: o.id, name: o.name } })
|
||||
Menu.shared.onMenuClicked = async (propertyId: string) => {
|
||||
let newSortOptions: ISortOption[] = []
|
||||
if (sortOption && sortOption.propertyId === propertyId) {
|
||||
// Already sorting by name, so reverse it
|
||||
newSortOptions = [
|
||||
{ propertyId, reversed: !sortOption.reversed }
|
||||
]
|
||||
} else {
|
||||
newSortOptions = [
|
||||
{ propertyId, reversed: false }
|
||||
]
|
||||
}
|
||||
|
||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async optionsClicked(e: React.MouseEvent) {
|
||||
const { boardTree } = this.props
|
||||
|
||||
Menu.shared.options = [
|
||||
{ id: "exportCsv", name: "Export to CSV" },
|
||||
{ id: "exportBoardArchive", name: "Export board archive" },
|
||||
]
|
||||
|
||||
Menu.shared.onMenuClicked = async (id: string) => {
|
||||
switch (id) {
|
||||
case "exportCsv": {
|
||||
CsvExporter.exportTableCsv(boardTree)
|
||||
break
|
||||
}
|
||||
case "exportBoardArchive": {
|
||||
Archiver.exportBoardTree(boardTree)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
private async headerClicked(e: React.MouseEvent<HTMLDivElement>, templateId: string) {
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board } = boardTree
|
||||
const { activeView } = boardTree
|
||||
|
||||
const options = [
|
||||
{ id: "sortAscending", name: "Sort ascending" },
|
||||
{ id: "sortDescending", name: "Sort descending" },
|
||||
{ id: "insertLeft", name: "Insert left" },
|
||||
{ id: "insertRight", name: "Insert right" }
|
||||
]
|
||||
|
||||
if (templateId !== "__name") {
|
||||
options.push({ id: "hide", name: "Hide" })
|
||||
options.push({ id: "duplicate", name: "Duplicate" })
|
||||
options.push({ id: "delete", name: "Delete" })
|
||||
}
|
||||
|
||||
Menu.shared.options = options
|
||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "sortAscending": {
|
||||
const newSortOptions = [
|
||||
{ propertyId: templateId, reversed: false }
|
||||
]
|
||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
break
|
||||
}
|
||||
case "sortDescending": {
|
||||
const newSortOptions = [
|
||||
{ propertyId: templateId, reversed: true }
|
||||
]
|
||||
await mutator.changeViewSortOptions(activeView, newSortOptions)
|
||||
break
|
||||
}
|
||||
case "insertLeft": {
|
||||
if (templateId !== "__name") {
|
||||
const index = board.cardProperties.findIndex(o => o.id === templateId)
|
||||
await mutator.insertPropertyTemplate(boardTree, index)
|
||||
} else {
|
||||
// TODO: Handle name column
|
||||
}
|
||||
break
|
||||
}
|
||||
case "insertRight": {
|
||||
if (templateId !== "__name") {
|
||||
const index = board.cardProperties.findIndex(o => o.id === templateId) + 1
|
||||
await mutator.insertPropertyTemplate(boardTree, index)
|
||||
} else {
|
||||
// TODO: Handle name column
|
||||
}
|
||||
break
|
||||
}
|
||||
case "duplicate": {
|
||||
await mutator.duplicatePropertyTemplate(boardTree, templateId)
|
||||
break
|
||||
}
|
||||
case "hide": {
|
||||
const newVisiblePropertyIds = activeView.visiblePropertyIds.filter(o => o !== templateId)
|
||||
await mutator.changeViewVisibleProperties(activeView, newVisiblePropertyIds)
|
||||
break
|
||||
}
|
||||
case "delete": {
|
||||
await mutator.deleteProperty(boardTree, templateId)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
Utils.assertFailure(`Unexpected menu option: ${optionId}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
async showCard(card: IBlock) {
|
||||
console.log(`showCard: ${card.title}`)
|
||||
|
||||
await this.props.pageController.showCard(card)
|
||||
}
|
||||
|
||||
focusOnCardTitle(cardId: string) {
|
||||
const tableRowRef = this.cardIdToRowMap.get(cardId)
|
||||
Utils.log(`focusOnCardTitle, ${tableRowRef?.current ?? "undefined"}`)
|
||||
tableRowRef?.current.focusOnTitle()
|
||||
}
|
||||
|
||||
async addCard(show: boolean = false) {
|
||||
const { mutator, boardTree } = this.props
|
||||
|
||||
const card = new Block({ type: "card", parentId: boardTree.board.id })
|
||||
await mutator.insertBlock(
|
||||
card,
|
||||
"add card",
|
||||
async () => {
|
||||
if (show) {
|
||||
this.showCard(card)
|
||||
} else {
|
||||
// Focus on this card's title inline on next render
|
||||
this.cardIdToFocusOnRender = card.id
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async onDropToColumn(template: IPropertyTemplate) {
|
||||
const { draggedHeaderTemplate } = this
|
||||
if (!draggedHeaderTemplate) { return }
|
||||
|
||||
const { mutator, boardTree } = this.props
|
||||
const { board } = boardTree
|
||||
|
||||
Utils.assertValue(mutator)
|
||||
Utils.assertValue(boardTree)
|
||||
|
||||
Utils.log(`ondrop. Source column: ${draggedHeaderTemplate.name}, dest column: ${template.name}`)
|
||||
|
||||
// Move template to new index
|
||||
const destIndex = template ? board.cardProperties.indexOf(template) : 0
|
||||
await mutator.changePropertyTemplateOrder(board, draggedHeaderTemplate, destIndex)
|
||||
}
|
||||
}
|
||||
|
||||
export { TableComponent }
|
73
src/client/components/tableRow.tsx
Normal file
73
src/client/components/tableRow.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from "react"
|
||||
import { BoardTree } from "../boardTree"
|
||||
import { Mutator } from "../mutator"
|
||||
import { IBlock } from "../octoTypes"
|
||||
import { OctoUtils } from "../octoUtils"
|
||||
import { Editable } from "./editable"
|
||||
|
||||
type Props = {
|
||||
mutator: Mutator
|
||||
boardTree: BoardTree
|
||||
card: IBlock
|
||||
focusOnMount: boolean
|
||||
showCard: (card: IBlock) => void
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
}
|
||||
|
||||
type State = {
|
||||
}
|
||||
|
||||
class TableRow extends React.Component<Props, State> {
|
||||
private titleRef = React.createRef<Editable>()
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.focusOnMount) {
|
||||
this.titleRef.current.focus()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { mutator, boardTree, card, showCard, onKeyDown } = this.props
|
||||
const { board, activeView } = boardTree
|
||||
|
||||
const openButonRef = React.createRef<HTMLDivElement>()
|
||||
|
||||
const element = <div className="octo-table-row" key={card.id}>
|
||||
|
||||
{/* Name / title */}
|
||||
|
||||
<div className="octo-table-cell" id="mainBoardHeader" onMouseOver={() => { openButonRef.current.style.display = null }} onMouseLeave={() => { openButonRef.current.style.display = "none" }}>
|
||||
<div className="octo-icontitle">
|
||||
<div className="octo-icon">{card.icon}</div>
|
||||
<Editable
|
||||
ref={this.titleRef}
|
||||
text={card.title}
|
||||
placeholderText="Untitled"
|
||||
onChanged={(text) => { mutator.changeTitle(card, text) }}
|
||||
onKeyDown={(e) => { onKeyDown(e) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div ref={openButonRef} className="octo-hoverbutton" style={{ display: "none" }} onClick={() => { showCard(card) }}>Open</div>
|
||||
</div>
|
||||
|
||||
{/* Columns, one per property */}
|
||||
|
||||
{board.cardProperties
|
||||
.filter(template => activeView.visiblePropertyIds.includes(template.id))
|
||||
.map(template => {
|
||||
return <div className="octo-table-cell" key={template.id}>
|
||||
{OctoUtils.propertyValueEditableElement(mutator, card, template)}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
focusOnTitle() {
|
||||
this.titleRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
export { TableRow }
|
18
src/client/constants.ts
Normal file
18
src/client/constants.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { MenuOption } from "./menu"
|
||||
|
||||
class Constants {
|
||||
static menuColors: MenuOption[] = [
|
||||
{ id: "propColorDefault", name: "Default", type: "color" },
|
||||
{ id: "propColorGray", name: "Gray", type: "color" },
|
||||
{ id: "propColorBrown", name: "Brown", type: "color" },
|
||||
{ id: "propColorOrange", name: "Orange", type: "color" },
|
||||
{ id: "propColorYellow", name: "Yellow", type: "color" },
|
||||
{ id: "propColorGreen", name: "Green", type: "color" },
|
||||
{ id: "propColorBlue", name: "Blue", type: "color" },
|
||||
{ id: "propColorPurple", name: "Purple", type: "color" },
|
||||
{ id: "propColorPink", name: "Pink", type: "color" },
|
||||
{ id: "propColorRed", name: "Red", type: "color" }
|
||||
]
|
||||
}
|
||||
|
||||
export { Constants }
|
69
src/client/csvExporter.ts
Normal file
69
src/client/csvExporter.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView } from "./boardView"
|
||||
import { OctoUtils } from "./octoUtils"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class CsvExporter {
|
||||
|
||||
static exportTableCsv(boardTree: BoardTree, view?: BoardView) {
|
||||
const { activeView } = boardTree
|
||||
const viewToExport = view ?? activeView
|
||||
|
||||
const rows = CsvExporter.generateTableArray(boardTree, view)
|
||||
|
||||
let csvContent = "data:text/csv;charset=utf-8,"
|
||||
|
||||
rows.forEach((row) => {
|
||||
const encodedRow = row.join(",")
|
||||
csvContent += encodedRow + "\r\n"
|
||||
})
|
||||
|
||||
const filename = `${Utils.sanitizeFilename(viewToExport.title)}.csv`
|
||||
const encodedUri = encodeURI(csvContent)
|
||||
const link = document.createElement("a")
|
||||
link.style.display = "none"
|
||||
link.setAttribute("href", encodedUri)
|
||||
link.setAttribute("download", filename)
|
||||
document.body.appendChild(link) // FireFox support
|
||||
|
||||
link.click()
|
||||
// TODO: Remove or reuse link
|
||||
}
|
||||
|
||||
private static generateTableArray(boardTree: BoardTree, view?: BoardView): string[][] {
|
||||
const { board, cards, activeView } = boardTree
|
||||
const viewToExport = view ?? activeView
|
||||
|
||||
const rows: string[][] = []
|
||||
const visibleProperties = board.cardProperties.filter(template => viewToExport.visiblePropertyIds.includes(template.id))
|
||||
|
||||
{
|
||||
// Header row
|
||||
const row: string[] = []
|
||||
visibleProperties.forEach(template => {
|
||||
row.push(template.name)
|
||||
})
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
cards.forEach(card => {
|
||||
const row: string[] = []
|
||||
visibleProperties.forEach(template => {
|
||||
const property = card.properties.find(o => o.id === template.id)
|
||||
const displayValue = OctoUtils.propertyDisplayValue(card, property, template) || ""
|
||||
if (template.type === "number") {
|
||||
const numericValue = property?.value ? Number(property?.value).toString() : undefined
|
||||
row.push(numericValue)
|
||||
} else {
|
||||
// Export as string
|
||||
row.push(`"${displayValue}"`)
|
||||
}
|
||||
})
|
||||
rows.push(row)
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
export { CsvExporter }
|
6
src/client/emojiList.ts
Normal file
6
src/client/emojiList.ts
Normal file
File diff suppressed because one or more lines are too long
36
src/client/filterClause.ts
Normal file
36
src/client/filterClause.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Utils } from "./utils"
|
||||
|
||||
type FilterCondition = "includes" | "notIncludes" | "isEmpty" | "isNotEmpty"
|
||||
|
||||
class FilterClause {
|
||||
propertyId: string
|
||||
condition: FilterCondition
|
||||
values: string[]
|
||||
|
||||
static filterConditionDisplayString(filterCondition: FilterCondition) {
|
||||
switch (filterCondition) {
|
||||
case "includes": return "includes"
|
||||
case "notIncludes": return "doesn't include"
|
||||
case "isEmpty": return "is empty"
|
||||
case "isNotEmpty": return "is not empty"
|
||||
}
|
||||
Utils.assertFailure()
|
||||
return "(unknown)"
|
||||
}
|
||||
|
||||
constructor(o: any = {}) {
|
||||
this.propertyId = o.propertyId || ""
|
||||
this.condition = o.condition || "includes"
|
||||
this.values = o.values?.slice() || []
|
||||
}
|
||||
|
||||
isEqual(o: FilterClause) {
|
||||
return (
|
||||
this.propertyId === o.propertyId &&
|
||||
this.condition === o.condition &&
|
||||
Utils.arraysEqual(this.values, o.values)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { FilterClause, FilterCondition }
|
28
src/client/filterGroup.ts
Normal file
28
src/client/filterGroup.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { FilterClause } from "./filterClause"
|
||||
|
||||
type FilterGroupOperation = "and" | "or"
|
||||
|
||||
// A FilterGroup has 2 forms: (A or B or C) OR (A and B and C)
|
||||
class FilterGroup {
|
||||
operation: FilterGroupOperation = "and"
|
||||
filters: (FilterClause | FilterGroup)[] = []
|
||||
|
||||
static isAnInstanceOf(object: any): object is FilterGroup {
|
||||
return "innerOperation" in object && "filters" in object
|
||||
}
|
||||
|
||||
constructor(o: any = {}) {
|
||||
this.operation = o.operation || "and"
|
||||
this.filters = o.filters
|
||||
? o.filters.map((p: any) => {
|
||||
if (FilterGroup.isAnInstanceOf(p)) {
|
||||
return new FilterGroup(p)
|
||||
} else {
|
||||
return new FilterClause(p)
|
||||
}
|
||||
})
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
export { FilterGroup, FilterGroupOperation }
|
23
src/client/flashMessage.ts
Normal file
23
src/client/flashMessage.ts
Normal file
@ -0,0 +1,23 @@
|
||||
class FlashMessage {
|
||||
//
|
||||
// Show a temporary status message
|
||||
//
|
||||
static show(text: string, delay: number = 800) {
|
||||
const flashPanel = document.createElement("div")
|
||||
flashPanel.innerText = text
|
||||
flashPanel.classList.add("flashPanel")
|
||||
flashPanel.classList.add("flashIn")
|
||||
|
||||
document.body.appendChild(flashPanel)
|
||||
|
||||
setTimeout(() => {
|
||||
flashPanel.classList.remove("flashIn")
|
||||
flashPanel.classList.add("flashOut")
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(flashPanel)
|
||||
}, 300)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
export { FlashMessage }
|
184
src/client/menu.ts
Normal file
184
src/client/menu.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { Utils } from "./utils"
|
||||
|
||||
type MenuOption = {
|
||||
id: string,
|
||||
name: string,
|
||||
isOn?: boolean,
|
||||
type?: "separator" | "color" | "submenu" | "switch" | undefined
|
||||
}
|
||||
|
||||
// Menu is a pop-over context menu system
|
||||
class Menu {
|
||||
static shared = new Menu()
|
||||
|
||||
options: MenuOption[] = []
|
||||
readonly subMenuOptions: Map<string, MenuOption[]> = new Map()
|
||||
onMenuClicked?: (optionId: string, type?: string) => void
|
||||
onMenuToggled?: (optionId: string, isOn: boolean) => void
|
||||
|
||||
private menu?: HTMLElement
|
||||
private subMenu?: Menu
|
||||
private onBodyClick: any
|
||||
private onBodyKeyDown: any
|
||||
|
||||
get view() { return this.menu }
|
||||
|
||||
get isVisible() {
|
||||
return (this.menu !== undefined)
|
||||
}
|
||||
|
||||
createMenuElement() {
|
||||
const menu = Utils.htmlToElement(`<div class="menu noselect"></div>`)
|
||||
const menuElement = menu.appendChild(Utils.htmlToElement(`<div class="menu-options"></div>`))
|
||||
this.appendMenuOptions(menuElement)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
appendMenuOptions(menuElement: HTMLElement) {
|
||||
for (const option of this.options) {
|
||||
if (option.type === "separator") {
|
||||
const optionElement = menuElement.appendChild(Utils.htmlToElement(`<div class="menu-separator"></div>`))
|
||||
}
|
||||
else {
|
||||
const optionElement = menuElement.appendChild(Utils.htmlToElement(`<div class="menu-option"></div>`))
|
||||
optionElement.id = option.id
|
||||
const nameElement = optionElement.appendChild(Utils.htmlToElement(`<div class="menu-name"></div>`))
|
||||
nameElement.innerText = option.name
|
||||
if (option.type === "submenu") {
|
||||
optionElement.appendChild(Utils.htmlToElement(`<div class="imageSubmenuTriangle" style="float: right;"></div>`))
|
||||
optionElement.onmouseenter = (e) => {
|
||||
// Calculate offset taking window scroll into account
|
||||
const bodyRect = document.body.getBoundingClientRect()
|
||||
const rect = optionElement.getBoundingClientRect()
|
||||
this.showSubMenu(rect.right - bodyRect.left, rect.top - bodyRect.top, option.id)
|
||||
}
|
||||
} else {
|
||||
optionElement.onmouseenter = () => {
|
||||
this.hideSubMenu()
|
||||
}
|
||||
optionElement.onclick = (e) => {
|
||||
if (this.onMenuClicked) {
|
||||
this.onMenuClicked(option.id, option.type)
|
||||
}
|
||||
this.hide()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (option.type === "color") {
|
||||
const colorbox = optionElement.insertBefore(Utils.htmlToElement(`<div class="menu-colorbox"></div>`), optionElement.firstChild)
|
||||
colorbox.classList.add(option.id) // id is the css class name for the color
|
||||
} else if (option.type === "switch") {
|
||||
const className = option.isOn ? "octo-switch on" : "octo-switch"
|
||||
const switchElement = optionElement.appendChild(Utils.htmlToElement(`<div class="${className}"></div>`))
|
||||
switchElement.appendChild(Utils.htmlToElement(`<div class="octo-switch-inner"></div>`))
|
||||
switchElement.onclick = (e) => {
|
||||
const isOn = switchElement.classList.contains("on")
|
||||
if (isOn) {
|
||||
switchElement.classList.remove("on")
|
||||
} else {
|
||||
switchElement.classList.add("on")
|
||||
}
|
||||
|
||||
if (this.onMenuToggled) {
|
||||
this.onMenuToggled(option.id, !isOn)
|
||||
}
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
optionElement.onclick = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showAtElement(element: HTMLElement) {
|
||||
const bodyRect = document.body.getBoundingClientRect()
|
||||
const rect = element.getBoundingClientRect()
|
||||
// Show at bottom-left of element
|
||||
this.showAt(rect.left - bodyRect.left, rect.bottom - bodyRect.top)
|
||||
}
|
||||
|
||||
showAt(pageX: number, pageY: number) {
|
||||
if (this.menu) { this.hide() }
|
||||
this.menu = this.createMenuElement()
|
||||
this.menu.style.left = `${pageX}px`
|
||||
this.menu.style.top = `${pageY}px`
|
||||
|
||||
document.body.appendChild(this.menu)
|
||||
|
||||
this.onBodyClick = (e: MouseEvent) => {
|
||||
console.log(`onBodyClick`)
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.onBodyKeyDown = (e: KeyboardEvent) => {
|
||||
console.log(`onBodyKeyDown, target: ${e.target}`)
|
||||
// Ignore keydown events on other elements
|
||||
if (e.target !== document.body) { return }
|
||||
if (e.keyCode === 27) {
|
||||
// ESC
|
||||
this.hide()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.addEventListener("click", this.onBodyClick)
|
||||
document.body.addEventListener("keydown", this.onBodyKeyDown)
|
||||
}, 20)
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (!this.menu) { return }
|
||||
|
||||
this.hideSubMenu()
|
||||
|
||||
document.body.removeChild(this.menu)
|
||||
this.menu = undefined
|
||||
|
||||
document.body.removeEventListener("click", this.onBodyClick)
|
||||
this.onBodyClick = undefined
|
||||
|
||||
document.body.removeEventListener("keydown", this.onBodyKeyDown)
|
||||
this.onBodyKeyDown = undefined
|
||||
}
|
||||
|
||||
hideSubMenu() {
|
||||
if (this.subMenu) {
|
||||
this.subMenu.hide()
|
||||
this.subMenu = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private showSubMenu(pageX: number, pageY: number, id: string) {
|
||||
console.log(`showSubMenu: ${id}`)
|
||||
const options: MenuOption[] = this.subMenuOptions.get(id) || []
|
||||
|
||||
if (this.subMenu) {
|
||||
if (this.subMenu.options === options) {
|
||||
// Already showing the sub menu
|
||||
return
|
||||
}
|
||||
|
||||
this.subMenu.hide()
|
||||
}
|
||||
|
||||
this.subMenu = new Menu()
|
||||
|
||||
this.subMenu.onMenuClicked = (optionId: string, type?: string) => {
|
||||
const subMenuId = `${id}-${optionId}`
|
||||
if (this.onMenuClicked) {
|
||||
this.onMenuClicked(subMenuId, type)
|
||||
}
|
||||
this.hide()
|
||||
}
|
||||
|
||||
this.subMenu.options = options
|
||||
this.subMenu.showAt(pageX, pageY)
|
||||
}
|
||||
}
|
||||
|
||||
export { Menu, MenuOption }
|
549
src/client/mutator.ts
Normal file
549
src/client/mutator.ts
Normal file
@ -0,0 +1,549 @@
|
||||
import { Block } from "./block"
|
||||
import { Board, IPropertyOption, IPropertyTemplate, PropertyType } from "./board"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView, ISortOption } from "./boardView"
|
||||
import { FilterGroup } from "./filterGroup"
|
||||
import { OctoClient } from "./octoClient"
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { UndoManager } from "./undomanager"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
//
|
||||
// The Mutator is used to make all changes to server state
|
||||
// It also ensures that the Undo-manager is called for each action
|
||||
//
|
||||
class Mutator {
|
||||
constructor(private octo: OctoClient, private undoManager = UndoManager.shared) {
|
||||
}
|
||||
|
||||
async insertBlock(block: IBlock, description: string = "add", afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.insertBlock(block)
|
||||
await afterRedo?.()
|
||||
},
|
||||
async () => {
|
||||
await beforeUndo?.()
|
||||
await octo.deleteBlock(block.id)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async insertBlocks(blocks: IBlock[], description: string = "add", afterRedo?: () => Promise<void>, beforeUndo?: () => Promise<void>) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.insertBlocks(blocks)
|
||||
await afterRedo?.()
|
||||
},
|
||||
async () => {
|
||||
await beforeUndo?.()
|
||||
for (const block of blocks) {
|
||||
await octo.deleteBlock(block.id)
|
||||
}
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async deleteBlock(block: IBlock, description?: string) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
if (!description) {
|
||||
description = `delete ${block.type}`
|
||||
}
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.deleteBlock(block.id)
|
||||
},
|
||||
async () => {
|
||||
await octo.insertBlock(block)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async changeTitle(block: IBlock, title: string, description: string = "change title") {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = block.title
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
block.title = title
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
async () => {
|
||||
block.title = oldValue
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async changeIcon(block: IBlock, icon: string, description: string = "change icon") {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = block.icon
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
block.icon = icon
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
async () => {
|
||||
block.icon = oldValue
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async changeOrder(block: IBlock, order: number, description: string = "change order") {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = block.order
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
block.order = order
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
async () => {
|
||||
block.order = oldValue
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
// Property Templates
|
||||
|
||||
async insertPropertyTemplate(boardTree: BoardTree, index: number = -1, template?: IPropertyTemplate) {
|
||||
const { octo, undoManager } = this
|
||||
const { board, activeView } = boardTree
|
||||
|
||||
if (index < 0) { index = board.cardProperties.length }
|
||||
|
||||
if (!template) {
|
||||
template = {
|
||||
id: Utils.createGuid(),
|
||||
name: "New Property",
|
||||
type: "text",
|
||||
options: []
|
||||
}
|
||||
}
|
||||
|
||||
const oldBlocks: IBlock[] = [new Board(board)]
|
||||
|
||||
const changedBlocks: IBlock[] = [board]
|
||||
board.cardProperties.splice(index, 0, template)
|
||||
|
||||
let description = "add property"
|
||||
|
||||
if (activeView.viewType === "table") {
|
||||
oldBlocks.push(new BoardView(activeView))
|
||||
activeView.visiblePropertyIds.push(template.id)
|
||||
changedBlocks.push(activeView)
|
||||
|
||||
description = "add column"
|
||||
}
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.updateBlocks(changedBlocks)
|
||||
},
|
||||
async () => {
|
||||
await octo.updateBlocks(oldBlocks)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async duplicatePropertyTemplate(boardTree: BoardTree, propertyId: string) {
|
||||
const { octo, undoManager } = this
|
||||
const { board, activeView } = boardTree
|
||||
|
||||
const oldBlocks: IBlock[] = [new Board(board)]
|
||||
|
||||
const changedBlocks: IBlock[] = [board]
|
||||
const index = board.cardProperties.findIndex(o => o.id === propertyId)
|
||||
if (index === -1) { Utils.assertFailure(`Cannot find template with id: ${propertyId}`); return }
|
||||
const srcTemplate = board.cardProperties[index]
|
||||
const newTemplate: IPropertyTemplate = {
|
||||
id: Utils.createGuid(),
|
||||
name: `Copy of ${srcTemplate.name}`,
|
||||
type: srcTemplate.type,
|
||||
options: srcTemplate.options.slice()
|
||||
}
|
||||
board.cardProperties.splice(index + 1, 0, newTemplate)
|
||||
|
||||
let description = "duplicate property"
|
||||
if (activeView.viewType === "table") {
|
||||
oldBlocks.push(new BoardView(activeView))
|
||||
activeView.visiblePropertyIds.push(newTemplate.id)
|
||||
changedBlocks.push(activeView)
|
||||
|
||||
description = "duplicate column"
|
||||
}
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.updateBlocks(changedBlocks)
|
||||
},
|
||||
async () => {
|
||||
await octo.updateBlocks(oldBlocks)
|
||||
},
|
||||
description
|
||||
)
|
||||
|
||||
return changedBlocks
|
||||
}
|
||||
|
||||
async changePropertyTemplateOrder(board: Board, template: IPropertyTemplate, destIndex: number) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const templates = board.cardProperties
|
||||
const oldValue = templates
|
||||
const newValue = templates.slice()
|
||||
|
||||
const srcIndex = templates.indexOf(template)
|
||||
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
|
||||
newValue.splice(destIndex, 0, newValue.splice(srcIndex, 1)[0])
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
board.cardProperties = newValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
board.cardProperties = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
"reorder properties"
|
||||
)
|
||||
}
|
||||
|
||||
async deleteProperty(boardTree: BoardTree, propertyId: string) {
|
||||
const { octo, undoManager } = this
|
||||
const { board, views, cards } = boardTree
|
||||
|
||||
const oldBlocks: IBlock[] = [new Board(board)]
|
||||
|
||||
const changedBlocks: IBlock[] = [board]
|
||||
board.cardProperties = board.cardProperties.filter(o => o.id !== propertyId)
|
||||
|
||||
views.forEach(view => {
|
||||
if (view.visiblePropertyIds.includes(propertyId)) {
|
||||
oldBlocks.push(new BoardView(view))
|
||||
view.visiblePropertyIds = view.visiblePropertyIds.filter(o => o !== propertyId)
|
||||
changedBlocks.push(view)
|
||||
}
|
||||
})
|
||||
cards.forEach(card => {
|
||||
if (card.properties.findIndex(o => o.id === propertyId) !== -1) {
|
||||
oldBlocks.push(new Block(card))
|
||||
card.properties = card.properties.filter(o => o.id !== propertyId)
|
||||
changedBlocks.push(card)
|
||||
}
|
||||
})
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.updateBlocks(changedBlocks)
|
||||
},
|
||||
async () => {
|
||||
await octo.updateBlocks(oldBlocks)
|
||||
},
|
||||
"delete property"
|
||||
)
|
||||
}
|
||||
|
||||
async renameProperty(board: Board, propertyId: string, name: string) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldBlocks: IBlock[] = [new Board(board)]
|
||||
const changedBlocks: IBlock[] = [board]
|
||||
|
||||
const template = board.cardProperties.find(o => o.id === propertyId)
|
||||
if (!template) { Utils.assertFailure(`Can't find property template with Id: ${propertyId}`); return }
|
||||
Utils.log(`renameProperty from ${template.name} to ${name}`)
|
||||
template.name = name
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.updateBlocks(changedBlocks)
|
||||
},
|
||||
async () => {
|
||||
await octo.updateBlocks(oldBlocks)
|
||||
},
|
||||
"rename property"
|
||||
)
|
||||
}
|
||||
|
||||
// Properties
|
||||
|
||||
async insertPropertyOption(boardTree: BoardTree, template: IPropertyTemplate, option: IPropertyOption, description: string = "add option") {
|
||||
const { octo, undoManager } = this
|
||||
const { board } = boardTree
|
||||
|
||||
const oldValue = template.options
|
||||
const newValue = template.options.slice()
|
||||
newValue.push(option)
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
template.options = newValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
// TODO: Also remove property on cards
|
||||
template.options = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async deletePropertyOption(boardTree: BoardTree, template: IPropertyTemplate, option: IPropertyOption) {
|
||||
const { octo, undoManager } = this
|
||||
const { board } = boardTree
|
||||
|
||||
const oldValue = template.options.slice()
|
||||
const newValue = template.options.filter(o => o !== option)
|
||||
|
||||
// TODO: Also remove property on cards
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
template.options = newValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
template.options = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
"delete option"
|
||||
)
|
||||
}
|
||||
|
||||
async changePropertyOptionOrder(board: Board, template: IPropertyTemplate, option: IPropertyOption, destIndex: number) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = template.options
|
||||
const newValue = template.options.slice()
|
||||
|
||||
const srcIndex = newValue.indexOf(option)
|
||||
Utils.log(`srcIndex: ${srcIndex}, destIndex: ${destIndex}`)
|
||||
newValue.splice(destIndex, 0, newValue.splice(srcIndex, 1)[0])
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
template.options = newValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
template.options = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
"reorder options"
|
||||
)
|
||||
}
|
||||
|
||||
async changePropertyOptionValue(boardTree: BoardTree, propertyTemplate: IPropertyTemplate, option: IPropertyOption, value: string) {
|
||||
const { octo, undoManager } = this
|
||||
const { board, cards } = boardTree
|
||||
|
||||
const oldValue = option.value
|
||||
const oldBlocks: IBlock[] = [new Board(board)]
|
||||
|
||||
const changedBlocks: IBlock[] = [board]
|
||||
option.value = value
|
||||
|
||||
// Change the value on all cards that have this property too
|
||||
for (const card of cards) {
|
||||
card.properties.forEach(property => {
|
||||
if (property.id === propertyTemplate.id && property.value === oldValue) {
|
||||
oldBlocks.push(new Block(card))
|
||||
property.value = value
|
||||
changedBlocks.push(card)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.updateBlocks(changedBlocks)
|
||||
},
|
||||
async () => {
|
||||
await octo.updateBlocks(oldBlocks)
|
||||
},
|
||||
"rename option"
|
||||
)
|
||||
|
||||
return changedBlocks
|
||||
}
|
||||
|
||||
async changePropertyOptionColor(board: Board, option: IPropertyOption, color: string) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = option.color
|
||||
|
||||
undoManager.perform(
|
||||
async () => {
|
||||
option.color = color
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
option.color = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
"change option color"
|
||||
)
|
||||
}
|
||||
|
||||
async changePropertyValue(block: IBlock, propertyId: string, value?: string, description: string = "change property") {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = Block.getPropertyValue(block, propertyId)
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
Block.setProperty(block, propertyId, value)
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
async () => {
|
||||
Block.setProperty(block, propertyId, oldValue)
|
||||
await octo.updateBlock(block)
|
||||
},
|
||||
description
|
||||
)
|
||||
}
|
||||
|
||||
async changePropertyType(board: Board, propertyTemplate: IPropertyTemplate, type: PropertyType) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = propertyTemplate.type
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
propertyTemplate.type = type
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
async () => {
|
||||
propertyTemplate.type = oldValue
|
||||
await octo.updateBlock(board)
|
||||
},
|
||||
"change property type"
|
||||
)
|
||||
}
|
||||
|
||||
// Views
|
||||
|
||||
async changeViewSortOptions(view: BoardView, sortOptions: ISortOption[]) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = view.sortOptions
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
view.sortOptions = sortOptions
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
async () => {
|
||||
view.sortOptions = oldValue
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
"sort"
|
||||
)
|
||||
}
|
||||
|
||||
async changeViewFilter(view: BoardView, filter?: FilterGroup) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = view.filter
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
view.filter = filter
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
async () => {
|
||||
view.filter = oldValue
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
"filter"
|
||||
)
|
||||
}
|
||||
|
||||
async changeViewVisibleProperties(view: BoardView, visiblePropertyIds: string[]) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = view.visiblePropertyIds
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
view.visiblePropertyIds = visiblePropertyIds
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
async () => {
|
||||
view.visiblePropertyIds = oldValue
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
"hide / show property"
|
||||
)
|
||||
}
|
||||
|
||||
async changeViewGroupById(view: BoardView, groupById: string) {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const oldValue = view.groupById
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
view.groupById = groupById
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
async () => {
|
||||
view.groupById = oldValue
|
||||
await octo.updateBlock(view)
|
||||
},
|
||||
"group by"
|
||||
)
|
||||
}
|
||||
|
||||
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
|
||||
async exportFullArchive() {
|
||||
return this.octo.exportFullArchive()
|
||||
}
|
||||
|
||||
// Not a mutator, but convenient to put here since Mutator wraps OctoClient
|
||||
async importFullArchive(blocks: IBlock[]) {
|
||||
return this.octo.importFullArchive(blocks)
|
||||
}
|
||||
|
||||
async createImageBlock(parentId: string, file: File, order = 1000): Promise<IBlock | undefined> {
|
||||
const { octo, undoManager } = this
|
||||
|
||||
const url = await octo.uploadFile(file)
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const block = new Block({ type: "image", parentId, url, order })
|
||||
|
||||
await undoManager.perform(
|
||||
async () => {
|
||||
await octo.insertBlock(block)
|
||||
},
|
||||
async () => {
|
||||
await octo.deleteBlock(block.id)
|
||||
},
|
||||
"group by"
|
||||
)
|
||||
|
||||
return block
|
||||
}
|
||||
}
|
||||
|
||||
export { Mutator }
|
151
src/client/octoClient.ts
Normal file
151
src/client/octoClient.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { IBlock } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
//
|
||||
// OctoClient is the client interface to the server APIs
|
||||
//
|
||||
class OctoClient {
|
||||
serverUrl: string
|
||||
|
||||
constructor(serverUrl?: string) {
|
||||
this.serverUrl = serverUrl || window.location.origin
|
||||
console.log(`OctoClient serverUrl: ${this.serverUrl}`)
|
||||
}
|
||||
|
||||
async getSubtree(rootId?: string): Promise<IBlock[]> {
|
||||
const path = `/api/v1/blocks/${rootId}/subtree`
|
||||
const response = await fetch(this.serverUrl + path)
|
||||
const blocks = await response.json() as IBlock[]
|
||||
this.fixBlocks(blocks)
|
||||
return blocks
|
||||
}
|
||||
|
||||
async exportFullArchive(): Promise<IBlock[]> {
|
||||
const path = `/api/v1/blocks/export`
|
||||
const response = await fetch(this.serverUrl + path)
|
||||
const blocks = await response.json() as IBlock[]
|
||||
this.fixBlocks(blocks)
|
||||
return blocks
|
||||
}
|
||||
|
||||
async importFullArchive(blocks: IBlock[]): Promise<Response> {
|
||||
Utils.log(`importFullArchive: ${blocks.length} blocks(s)`)
|
||||
blocks.forEach(block => {
|
||||
Utils.log(`\t ${block.type}, ${block.id}`)
|
||||
})
|
||||
const body = JSON.stringify(blocks)
|
||||
return await fetch(this.serverUrl + "/api/v1/blocks/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
async getBlocks(parentId?: string, type?: string): Promise<IBlock[]> {
|
||||
let path: string
|
||||
if (parentId && type) {
|
||||
path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}&type=${encodeURIComponent(type)}`
|
||||
} else if (parentId) {
|
||||
path = `/api/v1/blocks?parent_id=${encodeURIComponent(parentId)}`
|
||||
} else if (type) {
|
||||
path = `/api/v1/blocks?type=${encodeURIComponent(type)}`
|
||||
} else {
|
||||
path = `/api/v1/blocks`
|
||||
}
|
||||
|
||||
const response = await fetch(this.serverUrl + path)
|
||||
const blocks = await response.json() as IBlock[]
|
||||
this.fixBlocks(blocks)
|
||||
return blocks
|
||||
}
|
||||
|
||||
fixBlocks(blocks: IBlock[]) {
|
||||
for (const block of blocks) {
|
||||
if (!block.properties) { block.properties = [] }
|
||||
|
||||
block.properties = block.properties.filter(property => property && property.id)
|
||||
}
|
||||
}
|
||||
|
||||
async updateBlock(block: IBlock): Promise<Response> {
|
||||
block.updateAt = Date.now()
|
||||
return await this.insertBlocks([block])
|
||||
}
|
||||
|
||||
async updateBlocks(blocks: IBlock[]): Promise<Response> {
|
||||
const now = Date.now()
|
||||
blocks.forEach(block => {
|
||||
block.updateAt = now
|
||||
})
|
||||
return await this.insertBlocks(blocks)
|
||||
}
|
||||
|
||||
async deleteBlock(blockId: string): Promise<Response> {
|
||||
console.log(`deleteBlock: ${blockId}`)
|
||||
return await fetch(this.serverUrl + `/api/v1/blocks/${encodeURIComponent(blockId)}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async insertBlock(block: IBlock): Promise<Response> {
|
||||
return this.insertBlocks([block])
|
||||
}
|
||||
|
||||
async insertBlocks(blocks: IBlock[]): Promise<Response> {
|
||||
Utils.log(`insertBlocks: ${blocks.length} blocks(s)`)
|
||||
blocks.forEach(block => {
|
||||
Utils.log(`\t ${block.type}, ${block.id}`)
|
||||
})
|
||||
const body = JSON.stringify(blocks)
|
||||
return await fetch(this.serverUrl + "/api/v1/blocks", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body
|
||||
})
|
||||
}
|
||||
|
||||
// Returns URL of uploaded file, or undefined on failure
|
||||
async uploadFile(file: File): Promise<string | undefined> {
|
||||
// IMPORTANT: We need to post the image as a form. The browser will convert this to a application/x-www-form-urlencoded POST
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
|
||||
try {
|
||||
const response = await fetch(this.serverUrl + "/api/v1/files", {
|
||||
method: "POST",
|
||||
// TIPTIP: Leave out Content-Type here, it will be automatically set by the browser
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
if (response.status === 200) {
|
||||
try {
|
||||
const text = await response.text()
|
||||
Utils.log(`uploadFile response: ${text}`)
|
||||
const json = JSON.parse(text)
|
||||
// const json = await response.json()
|
||||
return json.url
|
||||
} catch (e) {
|
||||
Utils.logError(`uploadFile json ERROR: ${e}`)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Utils.logError(`uploadFile ERROR: ${e}`)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export { OctoClient }
|
81
src/client/octoListener.ts
Normal file
81
src/client/octoListener.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Utils } from "./utils"
|
||||
|
||||
//
|
||||
// OctoListener calls a handler when a block or any of its children changes
|
||||
//
|
||||
class OctoListener {
|
||||
get isOpen(): boolean { return this.ws !== undefined }
|
||||
|
||||
readonly serverUrl: string
|
||||
private ws: WebSocket
|
||||
|
||||
notificationDelay: number = 200
|
||||
|
||||
constructor(serverUrl?: string) {
|
||||
this.serverUrl = serverUrl || window.location.origin
|
||||
console.log(`OctoListener serverUrl: ${this.serverUrl}`)
|
||||
}
|
||||
|
||||
open(blockId: string, onChange: (blockId: string) => void) {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
|
||||
if (this.ws) {
|
||||
this.close()
|
||||
}
|
||||
|
||||
const url = new URL(this.serverUrl)
|
||||
const wsServerUrl = `ws://${url.host}${url.pathname}ws/onchange?id=${encodeURIComponent(blockId)}`
|
||||
console.log(`OctoListener initWebSocket wsServerUrl: ${wsServerUrl}`)
|
||||
const ws = new WebSocket(wsServerUrl)
|
||||
this.ws = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
Utils.log("OctoListener webSocket opened.")
|
||||
ws.send("{}")
|
||||
}
|
||||
|
||||
ws.onerror = (e) => {
|
||||
Utils.logError(`OctoListener websocket onerror. data: ${e}`)
|
||||
}
|
||||
|
||||
ws.onclose = (e) => {
|
||||
Utils.log(`OctoListener websocket onclose, code: ${e.code}, reason: ${e.reason}`)
|
||||
if (this.isOpen) {
|
||||
// Unexpected close, re-open
|
||||
Utils.logError(`Unexpected close, re-opening...`)
|
||||
this.open(blockId, onChange)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
Utils.log(`OctoListener websocket onmessage. data: ${e.data}`)
|
||||
try {
|
||||
const message = JSON.parse(e.data)
|
||||
switch (message.action) {
|
||||
case "UPDATE_BLOCK":
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = undefined
|
||||
onChange(message.blockId)
|
||||
}, this.notificationDelay)
|
||||
break
|
||||
default:
|
||||
Utils.logError(`Unexpected action: ${message.action}`)
|
||||
}
|
||||
} catch (e) {
|
||||
Utils.log(`message is not an object`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this.ws) { return }
|
||||
|
||||
this.ws.close()
|
||||
this.ws = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export { OctoListener }
|
31
src/client/octoTypes.ts
Normal file
31
src/client/octoTypes.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// A property on a bock
|
||||
interface IProperty {
|
||||
id: string
|
||||
value?: string
|
||||
}
|
||||
|
||||
// A block is the fundamental data type
|
||||
interface IBlock {
|
||||
id: string
|
||||
parentId: string
|
||||
|
||||
type: string
|
||||
title?: string
|
||||
url?: string
|
||||
icon?: string
|
||||
order: number
|
||||
properties: IProperty[]
|
||||
|
||||
createAt: number
|
||||
updateAt: number
|
||||
deleteAt: number
|
||||
}
|
||||
|
||||
// These are methods exposed by the top-level page to components
|
||||
interface IPageController {
|
||||
showCard(card: IBlock): Promise<void>
|
||||
showView(viewId: string): void
|
||||
showFilter(anchorElement?: HTMLElement): void
|
||||
}
|
||||
|
||||
export { IProperty, IBlock, IPageController }
|
193
src/client/octoUtils.tsx
Normal file
193
src/client/octoUtils.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import React from "react"
|
||||
import { IPropertyTemplate } from "./board"
|
||||
import { BoardTree } from "./boardTree"
|
||||
import { BoardView } from "./boardView"
|
||||
import { Editable } from "./components/editable"
|
||||
import { Menu, MenuOption } from "./menu"
|
||||
import { Mutator } from "./mutator"
|
||||
import { IBlock, IPageController, IProperty } from "./octoTypes"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class OctoUtils {
|
||||
static async showViewMenu(e: React.MouseEvent, mutator: Mutator, boardTree: BoardTree, pageController: IPageController) {
|
||||
const { board } = boardTree
|
||||
|
||||
const options: MenuOption[] = boardTree.views.map(view => ({ id: view.id, name: view.title || "Untitled View" }))
|
||||
options.push({ id: "", name: "", type: "separator" })
|
||||
if (boardTree.views.length > 1) {
|
||||
options.push({ id: "__deleteView", name: "Delete View" })
|
||||
}
|
||||
options.push({ id: "__addview", name: "Add View", type: "submenu" })
|
||||
|
||||
const addViewMenuOptions = [
|
||||
{ id: "board", name: "Board" },
|
||||
{ id: "table", name: "Table" }
|
||||
]
|
||||
Menu.shared.subMenuOptions.set("__addview", addViewMenuOptions)
|
||||
|
||||
Menu.shared.options = options
|
||||
Menu.shared.onMenuClicked = async (optionId: string, type?: string) => {
|
||||
switch (optionId) {
|
||||
case "__deleteView": {
|
||||
Utils.log(`deleteView`)
|
||||
const view = boardTree.activeView
|
||||
const nextView = boardTree.views.find(o => o !== view)
|
||||
await mutator.deleteBlock(view, "delete view")
|
||||
pageController.showView(nextView.id)
|
||||
break
|
||||
}
|
||||
case "__addview-board": {
|
||||
Utils.log(`addview-board`)
|
||||
const view = new BoardView()
|
||||
view.title = "Board View"
|
||||
view.viewType = "board"
|
||||
view.parentId = board.id
|
||||
|
||||
const oldViewId = boardTree.activeView.id
|
||||
|
||||
await mutator.insertBlock(
|
||||
view,
|
||||
"add view",
|
||||
async () => { pageController.showView(view.id) },
|
||||
async () => { pageController.showView(oldViewId) })
|
||||
break
|
||||
}
|
||||
case "__addview-table": {
|
||||
Utils.log(`addview-table`)
|
||||
const view = new BoardView()
|
||||
view.title = "Table View"
|
||||
view.viewType = "table"
|
||||
view.parentId = board.id
|
||||
view.visiblePropertyIds = board.cardProperties.map(o => o.id)
|
||||
|
||||
const oldViewId = boardTree.activeView.id
|
||||
|
||||
await mutator.insertBlock(
|
||||
view,
|
||||
"add view",
|
||||
async () => { pageController.showView(view.id) },
|
||||
async () => { pageController.showView(oldViewId) })
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const view = boardTree.views.find(o => o.id === optionId)
|
||||
pageController.showView(view.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Menu.shared.showAtElement(e.target as HTMLElement)
|
||||
}
|
||||
|
||||
static propertyDisplayValue(block: IBlock, property: IProperty, propertyTemplate: IPropertyTemplate) {
|
||||
let displayValue: string
|
||||
switch (propertyTemplate.type) {
|
||||
case "createdTime":
|
||||
displayValue = Utils.displayDateTime(new Date(block.createAt))
|
||||
break
|
||||
case "updatedTime":
|
||||
displayValue = Utils.displayDateTime(new Date(block.updateAt))
|
||||
break
|
||||
default:
|
||||
displayValue = property ? property.value : undefined
|
||||
}
|
||||
|
||||
return displayValue
|
||||
}
|
||||
|
||||
static propertyValueReadonlyElement(card: IBlock, propertyTemplate: IPropertyTemplate, emptyDisplayValue: string = "Empty"): JSX.Element {
|
||||
return this.propertyValueElement(undefined, card, propertyTemplate, emptyDisplayValue)
|
||||
}
|
||||
|
||||
static propertyValueEditableElement(mutator: Mutator, card: IBlock, propertyTemplate: IPropertyTemplate, emptyDisplayValue?: string): JSX.Element {
|
||||
return this.propertyValueElement(mutator, card, propertyTemplate, emptyDisplayValue)
|
||||
}
|
||||
|
||||
private static propertyValueElement(mutator: Mutator | undefined, card: IBlock, propertyTemplate: IPropertyTemplate, emptyDisplayValue: string = "Empty"): JSX.Element {
|
||||
const property = card.properties.find(o => o.id === propertyTemplate.id)
|
||||
const displayValue = OctoUtils.propertyDisplayValue(card, property, propertyTemplate)
|
||||
const finalDisplayValue = displayValue || emptyDisplayValue
|
||||
|
||||
let propertyColorCssClassName: string
|
||||
if (property && propertyTemplate.type === "select") {
|
||||
const cardPropertyValue = propertyTemplate.options.find(o => o.value === property.value)
|
||||
if (cardPropertyValue) {
|
||||
propertyColorCssClassName = cardPropertyValue.color
|
||||
}
|
||||
}
|
||||
|
||||
let element: JSX.Element
|
||||
|
||||
if (propertyTemplate.type === "select") {
|
||||
let className = "octo-button octo-propertyvalue"
|
||||
if (!displayValue) { className += " empty" }
|
||||
|
||||
const showMenu = (clickedElement: HTMLElement) => {
|
||||
if (propertyTemplate.options.length < 1) { return }
|
||||
|
||||
const menu = Menu.shared
|
||||
menu.options = [{ id: "", name: "<Empty>" }]
|
||||
menu.options.push(...propertyTemplate.options.map(o => ({ id: o.value, name: o.value })))
|
||||
menu.onMenuClicked = (optionId) => {
|
||||
mutator.changePropertyValue(card, propertyTemplate.id, optionId)
|
||||
}
|
||||
menu.showAtElement(clickedElement)
|
||||
}
|
||||
|
||||
element = <div
|
||||
key={propertyTemplate.id}
|
||||
className={`${className} ${propertyColorCssClassName}`}
|
||||
tabIndex={0}
|
||||
onClick={mutator ? (e) => { showMenu(e.target as HTMLElement) } : undefined}
|
||||
onKeyDown={mutator ? (e) => {
|
||||
if (e.keyCode === 13) {
|
||||
showMenu(e.target as HTMLElement)
|
||||
}
|
||||
} : undefined}
|
||||
onFocus={mutator ? () => { Menu.shared.hide() } : undefined }
|
||||
>
|
||||
{finalDisplayValue}
|
||||
</div>
|
||||
} else if (propertyTemplate.type === "text" || propertyTemplate.type === "number") {
|
||||
if (mutator) {
|
||||
element = <Editable
|
||||
key={propertyTemplate.id}
|
||||
className="octo-propertyvalue"
|
||||
placeholderText="Empty"
|
||||
text={displayValue}
|
||||
onChanged={(text) => {
|
||||
mutator.changePropertyValue(card, propertyTemplate.id, text)
|
||||
}}
|
||||
></Editable>
|
||||
} else {
|
||||
element = <div key={propertyTemplate.id} className="octo-propertyvalue">{displayValue}</div>
|
||||
}
|
||||
} else {
|
||||
element = <div
|
||||
key={propertyTemplate.id}
|
||||
className="octo-propertyvalue"
|
||||
>{finalDisplayValue}</div>
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
|
||||
static getOrderBefore(block: IBlock, blocks: IBlock[]): number {
|
||||
const index = blocks.indexOf(block)
|
||||
if (index === 0) {
|
||||
return block.order / 2
|
||||
}
|
||||
const previousBlock = blocks[index-1]
|
||||
return (block.order + previousBlock.order) / 2
|
||||
}
|
||||
|
||||
static getOrderAfter(block: IBlock, blocks: IBlock[]): number {
|
||||
const index = blocks.indexOf(block)
|
||||
if (index === blocks.length - 1) {
|
||||
return block.order + 1000
|
||||
}
|
||||
const nextBlock = blocks[index+1]
|
||||
return (block.order + nextBlock.order) / 2
|
||||
}
|
||||
}
|
||||
|
||||
export { OctoUtils }
|
92
src/client/propertyMenu.ts
Normal file
92
src/client/propertyMenu.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { IPropertyTemplate, PropertyType } from "./board"
|
||||
import { Menu } from "./menu"
|
||||
import { Utils } from "./utils"
|
||||
|
||||
class PropertyMenu extends Menu {
|
||||
static shared = new PropertyMenu()
|
||||
|
||||
property: IPropertyTemplate
|
||||
onNameChanged?: (name: string) => void
|
||||
|
||||
private nameTextbox: HTMLElement
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
const typeMenuOptions = [
|
||||
{ id: "text", name: "Text" },
|
||||
{ id: "number", name: "Number" },
|
||||
{ id: "select", name: "Select" },
|
||||
{ id: "createdTime", name: "Created Time" },
|
||||
{ id: "updatedTime", name: "Updated Time" }
|
||||
]
|
||||
this.subMenuOptions.set("type", typeMenuOptions)
|
||||
}
|
||||
|
||||
createMenuElement() {
|
||||
const menu = Utils.htmlToElement(`<div class="menu noselect" style="min-width: 200px;"></div>`)
|
||||
|
||||
const ul = menu.appendChild(Utils.htmlToElement(`<ul class="menu-options"></ul>`))
|
||||
|
||||
const nameTextbox = ul.appendChild(Utils.htmlToElement(`<li class="menu-textbox"></li>`))
|
||||
this.nameTextbox = nameTextbox
|
||||
let propertyValue = this.property ? this.property.name : ""
|
||||
nameTextbox.innerText = propertyValue
|
||||
nameTextbox.contentEditable = "true"
|
||||
nameTextbox.onclick = (e) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
nameTextbox.onblur = () => {
|
||||
if (nameTextbox.innerText !== propertyValue) {
|
||||
propertyValue = nameTextbox.innerText
|
||||
if (this.onNameChanged) {
|
||||
this.onNameChanged(nameTextbox.innerText)
|
||||
}
|
||||
}
|
||||
}
|
||||
nameTextbox.onmouseenter = () => {
|
||||
this.hideSubMenu()
|
||||
}
|
||||
nameTextbox.onkeydown = (e) => { if (e.keyCode === 13 || e.keyCode === 27) { nameTextbox.blur(); e.stopPropagation() } }
|
||||
|
||||
ul.appendChild(Utils.htmlToElement(`<li class="menu-separator"></li>`))
|
||||
|
||||
this.appendMenuOptions(ul)
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
showAt(left: number, top: number) {
|
||||
this.options = [
|
||||
{ id: "type", name: this.typeDisplayName(this.property.type), type: "submenu" },
|
||||
{ id: "delete", name: "Delete" }
|
||||
]
|
||||
|
||||
super.showAt(left, top)
|
||||
setTimeout(() => {
|
||||
this.nameTextbox.focus()
|
||||
document.execCommand("selectAll", false, null)
|
||||
}, 20)
|
||||
}
|
||||
|
||||
private typeDisplayName(type: PropertyType): string {
|
||||
switch (type) {
|
||||
case "text": return "Text"
|
||||
case "number": return "Number"
|
||||
case "select": return "Select"
|
||||
case "multiSelect": return "Multi Select"
|
||||
case "person": return "Person"
|
||||
case "file": return "File or Media"
|
||||
case "checkbox": return "Checkbox"
|
||||
case "url": return "URL"
|
||||
case "email": return "Email"
|
||||
case "phone": return "Phone"
|
||||
case "createdTime": return "Created Time"
|
||||
case "createdBy": return "Created By"
|
||||
case "updatedTime": return "Updated Time"
|
||||
case "updatedBy": return "Updated By"
|
||||
}
|
||||
Utils.assertFailure(`typeDisplayName, unhandled type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export { PropertyMenu }
|
167
src/client/undomanager.ts
Normal file
167
src/client/undomanager.ts
Normal file
@ -0,0 +1,167 @@
|
||||
interface UndoCommand {
|
||||
checkpoint: number
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
description?: string
|
||||
}
|
||||
|
||||
//
|
||||
// General-purpose undo manager
|
||||
//
|
||||
class UndoManager {
|
||||
static shared = new UndoManager()
|
||||
|
||||
onStateDidChange?: () => void
|
||||
|
||||
private commands: UndoCommand[] = []
|
||||
private index = -1
|
||||
private limit = 0
|
||||
private isExecuting = false
|
||||
|
||||
get currentCheckpoint() {
|
||||
if (this.index < 0) {
|
||||
return 0
|
||||
}
|
||||
return this.commands[this.index].checkpoint
|
||||
}
|
||||
|
||||
get undoDescription(): string | undefined {
|
||||
const command = this.commands[this.index]
|
||||
if (!command) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return command.description
|
||||
}
|
||||
|
||||
get redoDescription(): string | undefined {
|
||||
const command = this.commands[this.index + 1]
|
||||
if (!command) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return command.description
|
||||
}
|
||||
|
||||
private async execute(command: UndoCommand, action: "undo" | "redo") {
|
||||
if (!command || typeof command[action] !== "function") {
|
||||
return this
|
||||
}
|
||||
this.isExecuting = true
|
||||
|
||||
await command[action]()
|
||||
|
||||
this.isExecuting = false
|
||||
return this
|
||||
}
|
||||
|
||||
async perform(
|
||||
redo: () => void,
|
||||
undo: () => void,
|
||||
description?: string,
|
||||
isDiscardable = false
|
||||
): Promise<UndoManager> {
|
||||
await redo()
|
||||
return this.registerUndo({ undo, redo }, description, isDiscardable)
|
||||
}
|
||||
|
||||
registerUndo(
|
||||
command: { undo: () => void; redo: () => void },
|
||||
description?: string,
|
||||
isDiscardable = false
|
||||
): UndoManager {
|
||||
if (this.isExecuting) {
|
||||
return this
|
||||
}
|
||||
// If we are here after having called undo, invalidate items higher on the stack
|
||||
this.commands.splice(this.index + 1, this.commands.length - this.index)
|
||||
|
||||
let checkpoint: number
|
||||
if (isDiscardable) {
|
||||
checkpoint =
|
||||
this.commands.length > 1
|
||||
? this.commands[this.commands.length - 1].checkpoint
|
||||
: 0
|
||||
} else {
|
||||
checkpoint = Date.now()
|
||||
}
|
||||
|
||||
const internalCommand = {
|
||||
checkpoint,
|
||||
undo: command.undo,
|
||||
redo: command.redo,
|
||||
description,
|
||||
}
|
||||
this.commands.push(internalCommand)
|
||||
|
||||
// If limit is set, remove items from the start
|
||||
if (this.limit && this.commands.length > this.limit) {
|
||||
this.commands = this.commands.splice(
|
||||
0,
|
||||
this.commands.length - this.limit
|
||||
)
|
||||
}
|
||||
|
||||
// Set the current index to the end
|
||||
this.index = this.commands.length - 1
|
||||
|
||||
if (this.onStateDidChange) {
|
||||
this.onStateDidChange()
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
async undo() {
|
||||
const command = this.commands[this.index]
|
||||
if (!command) {
|
||||
return this
|
||||
}
|
||||
|
||||
await this.execute(command, "undo")
|
||||
this.index -= 1
|
||||
|
||||
if (this.onStateDidChange) {
|
||||
this.onStateDidChange()
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
async redo() {
|
||||
const command = this.commands[this.index + 1]
|
||||
if (!command) {
|
||||
return this
|
||||
}
|
||||
|
||||
await this.execute(command, "redo")
|
||||
this.index += 1
|
||||
|
||||
if (this.onStateDidChange) {
|
||||
this.onStateDidChange()
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
clear() {
|
||||
const prevSize = this.commands.length
|
||||
|
||||
this.commands = []
|
||||
this.index = -1
|
||||
|
||||
if (this.onStateDidChange && prevSize > 0) {
|
||||
this.onStateDidChange()
|
||||
}
|
||||
}
|
||||
|
||||
get canUndo() {
|
||||
return this.index !== -1
|
||||
}
|
||||
|
||||
get canRedo() {
|
||||
return this.index < this.commands.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
export { UndoManager }
|
169
src/client/utils.ts
Normal file
169
src/client/utils.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import marked from "marked"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
msCrypto: Crypto
|
||||
}
|
||||
}
|
||||
|
||||
class Utils {
|
||||
static createGuid() {
|
||||
const crypto = window.crypto || window.msCrypto
|
||||
function randomDigit() {
|
||||
if (crypto && crypto.getRandomValues) {
|
||||
const rands = new Uint8Array(1)
|
||||
crypto.getRandomValues(rands)
|
||||
return (rands[0] % 16).toString(16)
|
||||
}
|
||||
|
||||
return (Math.floor((Math.random() * 16))).toString(16)
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx".replace(/x/g, randomDigit)
|
||||
}
|
||||
|
||||
static htmlToElement(html: string): HTMLElement {
|
||||
const template = document.createElement("template")
|
||||
html = html.trim()
|
||||
template.innerHTML = html
|
||||
return template.content.firstChild as HTMLElement
|
||||
}
|
||||
|
||||
static getElementById(elementId: string): HTMLElement {
|
||||
const element = document.getElementById(elementId)
|
||||
Utils.assert(element, `getElementById "${elementId}$`)
|
||||
return element!
|
||||
}
|
||||
|
||||
static htmlEncode(text: string) {
|
||||
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """)
|
||||
}
|
||||
|
||||
static htmlDecode(text: string) {
|
||||
return String(text).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"")
|
||||
}
|
||||
|
||||
// Markdown
|
||||
|
||||
static htmlFromMarkdown(text: string): string {
|
||||
// HACKHACK: Somehow, marked doesn't encode angle brackets
|
||||
const html = marked(text.replace(/</g, "<"))
|
||||
return html
|
||||
}
|
||||
// Date and Time
|
||||
|
||||
static displayDate(date: Date): string {
|
||||
const dateTimeFormat = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "2-digit" })
|
||||
const text = dateTimeFormat.format(date)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
static displayDateTime(date: Date): string {
|
||||
const dateTimeFormat = new Intl.DateTimeFormat(
|
||||
"en",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
const text = dateTimeFormat.format(date)
|
||||
return text
|
||||
}
|
||||
|
||||
// Errors
|
||||
|
||||
static assertValue(valueObject: any) {
|
||||
const name = Object.keys(valueObject)[0]
|
||||
const value = valueObject[name]
|
||||
if (!value) {
|
||||
Utils.logError(`ASSERT VALUE [${name}]`)
|
||||
}
|
||||
}
|
||||
|
||||
static assert(condition: any, tag: string = "") {
|
||||
/// #!if ENV !== "production"
|
||||
if (!condition) {
|
||||
Utils.logError(`ASSERT [${tag ?? new Error().stack}]`)
|
||||
}
|
||||
/// #!endif
|
||||
}
|
||||
|
||||
static assertFailure(tag: string = "") {
|
||||
/// #!if ENV !== "production"
|
||||
Utils.assert(false, tag)
|
||||
/// #!endif
|
||||
}
|
||||
|
||||
static log(message: string) {
|
||||
/// #!if ENV !== "production"
|
||||
const timestamp = (Date.now() / 1000).toFixed(2)
|
||||
console.log(`[${timestamp}] ${message}`)
|
||||
/// #!endif
|
||||
}
|
||||
|
||||
static logError(message: any) {
|
||||
/// #!if ENV !== "production"
|
||||
const timestamp = (Date.now() / 1000).toFixed(2)
|
||||
console.error(`[${timestamp}] ${message}`)
|
||||
/// #!endif
|
||||
}
|
||||
|
||||
// favicon
|
||||
|
||||
static setFavicon(icon?: string) {
|
||||
const href = icon ?
|
||||
`data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">${icon}</text></svg>`
|
||||
: ""
|
||||
const link = (document.querySelector("link[rel*='icon']") || document.createElement('link')) as HTMLLinkElement
|
||||
link.type = "image/x-icon"
|
||||
link.rel = "shortcut icon"
|
||||
link.href = href
|
||||
document.getElementsByTagName("head")[0].appendChild(link)
|
||||
}
|
||||
|
||||
// File names
|
||||
|
||||
static sanitizeFilename(filename: string) {
|
||||
// TODO: Use an industrial-strength sanitizer
|
||||
let sanitizedFilename = filename
|
||||
const illegalCharacters = ["\\", "/", "?", ":", "<", ">", "*", "|", "\"", "."]
|
||||
illegalCharacters.forEach(character => { sanitizedFilename = sanitizedFilename.replace(character, "") })
|
||||
return sanitizedFilename
|
||||
}
|
||||
|
||||
// File picker
|
||||
|
||||
static selectLocalFile(onSelect?: (file: File) => void, accept = ".jpg,.jpeg,.png"): void {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = accept
|
||||
input.onchange = async () => {
|
||||
const file = input.files![0]
|
||||
onSelect?.(file)
|
||||
}
|
||||
|
||||
input.style.display = "none"
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
|
||||
// TODO: Remove or reuse input
|
||||
}
|
||||
|
||||
// Arrays
|
||||
|
||||
static arraysEqual(a: any[], b: any[]) {
|
||||
if (a === b) { return true }
|
||||
if (a === null || b === null) { return false }
|
||||
if (a === undefined || b === undefined) { return false }
|
||||
if (a.length !== b.length) { return false }
|
||||
|
||||
for (let i = 0; i < a.length; ++i) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export { Utils }
|
49
src/static/colors.css
Normal file
49
src/static/colors.css
Normal file
@ -0,0 +1,49 @@
|
||||
.propColor0,
|
||||
.propColorDefault {
|
||||
background-color: rgba(206, 205, 202, 0.5);
|
||||
}
|
||||
|
||||
.propColor1, .propColor1:hover,
|
||||
.propColorGray, .propColorGray:hover {
|
||||
background-color: rgba(155, 154, 151, 0.4);
|
||||
}
|
||||
|
||||
.propColor2, .propColor2:hover,
|
||||
.propColorBrown, .propColorBrown:hover {
|
||||
background-color: rgba(140, 46, 0, 0.2);
|
||||
}
|
||||
|
||||
.propColor3, .propColor3:hover,
|
||||
.propColorOrange, .propColorOrange:hover {
|
||||
background-color: rgba(245, 93, 0, 0.2);
|
||||
}
|
||||
|
||||
.propColor4, .propColor4:hover,
|
||||
.propColorYellow, .propColorYellow:hover {
|
||||
background-color: rgba(233, 168, 0, 0.2);
|
||||
}
|
||||
|
||||
.propColor5, .propColor5:hover,
|
||||
.propColorGreen, .propColorGreen:hover {
|
||||
background-color: rgba(0, 135, 107, 0.2);
|
||||
}
|
||||
|
||||
.propColor6, .propColor6:hover,
|
||||
.propColorBlue, .propColorBlue:hover {
|
||||
background-color: rgba(0, 120, 233, 0.2);
|
||||
}
|
||||
|
||||
.propColor7, .propColor7:hover,
|
||||
.propColorPurple, .propColorPurple:hover {
|
||||
background-color: rgba(103, 36, 222, 0.2);
|
||||
}
|
||||
|
||||
.propColor8, .propColor8:hover,
|
||||
.propColorPink, .propColorPink:hover {
|
||||
background-color: rgba(221, 0, 129, 0.2);
|
||||
}
|
||||
|
||||
.propColor9, .propColor9:hover,
|
||||
.propColorRed, .propColorRed:hover {
|
||||
background-color: rgba(255, 0, 26, 0.2);
|
||||
}
|
1
src/static/favicon.svg
Normal file
1
src/static/favicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🐙</text></svg>
|
After Width: | Height: | Size: 109 B |
27
src/static/images.css
Normal file
27
src/static/images.css
Normal file
@ -0,0 +1,27 @@
|
||||
.imageDropdown {
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polyline points="30,35 50,55 70,35" style="fill:none;stroke:black;stroke-width:4;" /></svg>');
|
||||
background-size: 100% 100%;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.imageAdd {
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="stroke:black;stroke-width:4;" stroke-opacity="50%"><polyline points="30,50 70,50" /><polyline points="50,30 50,70" /></svg>');
|
||||
background-size: 100% 100%;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.imageOptions {
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="fill:black;" fill-opacity="50%"><circle cx="30" cy="50" r="5" /><circle cx="50" cy="50" r="5" /><circle cx="70" cy="50" r="5" /></svg>');
|
||||
background-size: 100% 100%;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.imageSubmenuTriangle {
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><polygon points="50,35 75,50 50,65" style="fill:black;stroke:none;" fill-opacity="70%" /></svg>');
|
||||
background-size: 100% 100%;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
819
src/static/main.css
Normal file
819
src/static/main.css
Normal file
@ -0,0 +1,819 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
outline: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
color: rgb(55, 53, 47);
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, "Apple Color Emoji", Arial, sans-serif, "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
/* -webkit-font-smoothing: auto; */
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
--cursor-color: rgb(55, 53, 47);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background-color: rgba(192, 192, 255, 0.2);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
border: none;
|
||||
color: rgba(55, 53, 47, 0.09);
|
||||
background-color: rgba(55, 53, 47, 0.09);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
header {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
background-color: #eeeeee;
|
||||
border-bottom: solid 1px #909090;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #505050;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.title, h1 {
|
||||
font-size: 40px;
|
||||
line-height: 40px;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* OCTO */
|
||||
|
||||
.octo-frame {
|
||||
padding: 10px 50px 50px 50px;
|
||||
min-width: 1000px;
|
||||
}
|
||||
|
||||
.octo-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.octo-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
border-bottom: solid 1px #cccccc;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 10px;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
.octo-controls > div {
|
||||
margin-right: 5px;
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.octo-board-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
min-height: 30px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0px 10px;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
.octo-board-header-cell {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
||||
width: 260px;
|
||||
margin-right: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.octo-board-header-cell > div {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.octo-board-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.octo-board-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
width: 260px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.dragover {
|
||||
background-color: rgba(128, 192, 255, 0.4);
|
||||
}
|
||||
|
||||
.octo-board-column > .octo-button {
|
||||
color: #909090;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.octo-board-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
border-radius: 3px;
|
||||
margin-bottom: 10px;
|
||||
padding: 7px 10px;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
|
||||
cursor: pointer;
|
||||
color: rgb(55, 53, 47);
|
||||
|
||||
transition: background 100ms ease-out 0s;
|
||||
}
|
||||
|
||||
.octo-board-card > div {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.octo-board-card > .octo-propertyvalue {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.octo-board-card:hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.octo-label {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
border-radius: 3px;
|
||||
line-height: 20px;
|
||||
color: rgb(55, 53, 47);
|
||||
white-space: nowrap
|
||||
}
|
||||
|
||||
.octo-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.octo-clickable,
|
||||
.octo-button {
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
padding: 0 5px;
|
||||
min-width: 20px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
|
||||
transition: background 100ms ease-out 0s;
|
||||
}
|
||||
|
||||
.octo-clickable,
|
||||
.octo-button:hover {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.filled {
|
||||
color: #ffffff;
|
||||
background-color: #50aadd;
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.filled:hover {
|
||||
background-color: #507090;
|
||||
}
|
||||
|
||||
/*-- Modal --*/
|
||||
|
||||
.octo-modal-back {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(90, 90, 90, 0.1);
|
||||
}
|
||||
|
||||
.octo-modal {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
min-width: 180px;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/*-- Filter modal dialog --*/
|
||||
|
||||
.octo-filter-dialog {
|
||||
min-width: 420px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.octo-filterclause {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.octo-filterclause > div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/*-- Menu --*/
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 15;
|
||||
min-width: 180px;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.menu-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.menu-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
font-weight: 400;
|
||||
padding: 2px 10px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.menu-option .menu-name {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.menu-option:hover {
|
||||
background: rgba(90, 90, 90, 0.1);
|
||||
}
|
||||
|
||||
.menu-separator {
|
||||
border-bottom: solid 1px #cccccc;
|
||||
}
|
||||
|
||||
.menu-colorbox {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px inset;
|
||||
}
|
||||
|
||||
.menu-textbox {
|
||||
font-weight: 400;
|
||||
padding: 2px 10px;
|
||||
cursor: text;
|
||||
touch-action: none;
|
||||
border: solid 1px #909090;
|
||||
border-radius: 3px;
|
||||
margin: 5px 5px;
|
||||
}
|
||||
|
||||
/*-- Dialog --*/
|
||||
|
||||
.dialog-back {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
|
||||
background-color: rgba(90, 90, 90, 0.5);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: #ffffff;
|
||||
border-radius: 3px;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
margin: 72px auto;
|
||||
padding: 0;
|
||||
max-width: 975px;
|
||||
height: calc(100% - 144px);
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dialog > .toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 30px;
|
||||
margin: 10px
|
||||
}
|
||||
|
||||
.dialog > .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 126px 10px 126px;
|
||||
}
|
||||
|
||||
.dialog > .content.fullwidth {
|
||||
padding: 10px 0 10px 0;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
|
||||
.octo-hovercontrols {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
min-height: 30px;
|
||||
width: 100%;
|
||||
color:rgba(55, 53, 47, 0.4);
|
||||
}
|
||||
|
||||
.octo-icon {
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols;
|
||||
line-height: 1em;
|
||||
align-self: baseline;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
|
||||
.octo-icontitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.octo-frame > .octo-icontitle > .octo-icon {
|
||||
font-size: 36px;
|
||||
line-height: 36px;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.octo-board-card > .octo-icontitle {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.octo-board-card > .octo-icontitle > .octo-icon {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.octo-table-cell > .octo-icontitle > .octo-icon {
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.octo-card-icon {
|
||||
height: 78px;
|
||||
min-width: 78px;
|
||||
font-size: 78px;
|
||||
line-height: 1.1;
|
||||
margin-left: 0;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/*-- Comments --*/
|
||||
|
||||
.dialog .commentlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.commentlist .commentrow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.commentlist .comment {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100%;
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
}
|
||||
|
||||
.comment-username {
|
||||
font-weight: bold;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
color: #cccccc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
color: rgb(55, 53, 47);
|
||||
width: 100%;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.commentlist .newcomment {
|
||||
color: rgba(55, 53, 47, 0.8);
|
||||
flex-grow: 1;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
/*-- Property list --*/
|
||||
|
||||
.octo-propertylist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.octo-propertyrow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.octo-propertyname {
|
||||
text-align: left;
|
||||
width: 150px;
|
||||
margin-right: 5px;
|
||||
color: #909090;
|
||||
}
|
||||
|
||||
.octo-propertyvalue {
|
||||
color: rgb(55, 53, 47);
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.octo-propertyvalue.empty {
|
||||
color: #cccccc;
|
||||
}
|
||||
|
||||
/*-- Editable --*/
|
||||
|
||||
.octo-editable {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.octo-editable.active {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.octo-placeholder {
|
||||
color: rgba(55, 53, 47, 0.4);
|
||||
}
|
||||
|
||||
[contentEditable=true]:empty:before{
|
||||
content: attr(placeholder);
|
||||
display: block;
|
||||
color: rgba(55, 53, 47, 0.4);
|
||||
}
|
||||
|
||||
.octo-table-cell:focus-within {
|
||||
background-color: rgba(46, 170, 220, 0.15);
|
||||
border: 1px solid rgba(46, 170, 220, 0.6);
|
||||
}
|
||||
|
||||
.octo-table-cell .octo-editable {
|
||||
padding: 0 5px;
|
||||
display: relative;
|
||||
left: -5px;
|
||||
}
|
||||
|
||||
.octo-table-cell .octo-editable.octo-editable.active {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.octo-propertyvalue.octo-editable.active,
|
||||
.octo-table-cell .octo-editable.active {
|
||||
border-radius: 3px;
|
||||
box-shadow: rgba(15, 15, 15, 0.05) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 3px 6px, rgba(15, 15, 15, 0.2) 0px 9px 24px;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
|
||||
.octo-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.octo-table-header,
|
||||
.octo-table-row,
|
||||
.octo-table-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
border-bottom: solid 1px rgba(55, 53, 47, 0.09);
|
||||
}
|
||||
|
||||
.octo-table-cell {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
color: rgba(55, 53, 47);
|
||||
border-right: solid 1px rgba(55, 53, 47, 0.09);
|
||||
box-sizing: border-box;
|
||||
padding: 5px 8px 6px 8px;
|
||||
|
||||
width: 240px;
|
||||
min-height: 32px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 21px;
|
||||
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.octo-table-cell .octo-propertyvalue {
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.octo-table-header .octo-table-cell,
|
||||
.octo-table-header .octo-table-cell .octo-label {
|
||||
color: rgba(55, 53, 47, 0.6);
|
||||
}
|
||||
|
||||
.octo-table-footer .octo-table-cell {
|
||||
color: rgba(55, 53, 47, 0.6);
|
||||
cursor: pointer;
|
||||
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.octo-table-footer .octo-table-cell:hover {
|
||||
background-color: rgba(55, 53, 47, 0.08);
|
||||
}
|
||||
|
||||
.octo-table-cell .octo-button,
|
||||
.octo-table-cell .octo-editable,
|
||||
.octo-table-cell .octo-propertyvalue
|
||||
{
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.octo-button.octo-hovercontrol {
|
||||
background: rgb(239, 239, 238);
|
||||
}
|
||||
|
||||
.octo-button.square {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.octo-button.active {
|
||||
color: rgb(45, 170, 220);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.octo-hoverbutton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 4px;
|
||||
cursor: pointer;
|
||||
background: rgb(239, 239, 238);
|
||||
box-shadow: rgba(15, 15, 15, 0.1) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 2px 4px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.octo-hoverbutton.square {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Switch */
|
||||
|
||||
.octo-switch {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
|
||||
box-sizing: content-box;
|
||||
height: 14px;
|
||||
width: 26px;
|
||||
border-radius: 44px;
|
||||
padding: 2px;
|
||||
background-color: rgba(135, 131, 120, 0.3);
|
||||
transition: background 200ms ease 0s, box-shadow 200ms ease 0s;
|
||||
}
|
||||
|
||||
.octo-switch-inner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 44px;
|
||||
background: white;
|
||||
transition: transform 200ms ease-out 0s, background 200ms ease-out 0s;
|
||||
transform: translateX(0px) translateY(0px);
|
||||
}
|
||||
|
||||
.octo-switch.on {
|
||||
background-color: rgba(46, 170, 220);
|
||||
}
|
||||
|
||||
.octo-switch.on .octo-switch-inner {
|
||||
transform: translateX(12px) translateY(0px);
|
||||
}
|
||||
|
||||
/* Flash Panel */
|
||||
|
||||
.flashPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
position: absolute;
|
||||
bottom: 120px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
padding: 10px 20px;
|
||||
min-width: 150px;
|
||||
max-width: 420px;
|
||||
min-height: 50px;
|
||||
color: #ffffff;
|
||||
background: rgba(100, 100, 100, 0.8);
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
border-radius: 20px;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.flashIn {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.flashOut {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: visibility 0s linear 200ms, opacity ease-in 200ms;
|
||||
}
|
||||
|
||||
/* Markdown Editor */
|
||||
|
||||
.octo-editor {
|
||||
cursor: text;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* CodeMirror / SimpleMDE / EasyMDE overrides */
|
||||
|
||||
.CodeMirror {
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
pre.CodeMirror-line {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.CodeMirror, .CodeMirror-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
line-height: 1.3;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.CodeMirror-cursor {
|
||||
border-left: 2px solid var(--cursor-color);
|
||||
}
|
||||
|
||||
.CodeMirror-selected {
|
||||
background: rgba(147, 204, 231, 0.8) !important;
|
||||
}
|
||||
|
||||
.octo-editor .CodeMirror-scroll {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* hide the scrollbars */
|
||||
.octo-editor .CodeMirror-vscrollbar, .octo-editor .CodeMirror-hscrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.octo-editor-preview {
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* .dialog .octo-editor-preview {
|
||||
min-height: 100px;
|
||||
} */
|
||||
|
||||
.octo-editor-activeEditor {
|
||||
overflow: hidden;
|
||||
border: solid 1px #aaccff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Hover panel */
|
||||
|
||||
.octo-hoverpanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
min-height: 30px;
|
||||
width: 100%;
|
||||
color:rgba(55, 53, 47, 0.4);
|
||||
}
|
||||
|
||||
.octo-block img {
|
||||
max-width: 500px;
|
||||
max-height: 500px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
/* This needs to be declared after other co-classes */
|
||||
.octo-hover-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.octo-hover-item {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.octo-hover-container:hover .octo-hover-item {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.octo-content {
|
||||
width: 100%;
|
||||
margin-right: 50px;
|
||||
}
|
||||
|
||||
.octo-block {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
|
||||
width: 100%;
|
||||
padding-right: 126px;
|
||||
}
|
||||
|
||||
.octo-block * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.octo-block-margin {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
padding-right: 10px;
|
||||
|
||||
width: 126px;
|
||||
}
|
30
tsconfig.json
Normal file
30
tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"target": "es2019",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"noImplicitAny": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"incremental": false,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"*": [
|
||||
"node_modules/*",
|
||||
"src/@custom_types/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
],
|
||||
"exclude": [
|
||||
]
|
||||
}
|
33
tslint.json
Normal file
33
tslint.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"extends": "tslint:recommended",
|
||||
"rules": {
|
||||
"new-parens": true,
|
||||
"no-arg": true,
|
||||
"no-bitwise": true,
|
||||
"no-conditional-assignment": true,
|
||||
"no-consecutive-blank-lines": true,
|
||||
"indent": [true, "tabs"],
|
||||
"member-access": [true, "no-public"],
|
||||
"semicolon": [true, "never"],
|
||||
"variable-name": [
|
||||
true,
|
||||
"ban-keywords",
|
||||
"check-format",
|
||||
"allow-pascal-case",
|
||||
"allow-leading-underscore"
|
||||
],
|
||||
"max-line-length": [false, { "limit": 150, "ignore-pattern": "\";?$" }],
|
||||
"trailing-comma": false,
|
||||
"object-literal-sort-keys": false,
|
||||
"member-ordering": false,
|
||||
"interface-name": false,
|
||||
"arrow-parens": false,
|
||||
"no-console": false,
|
||||
"align": false
|
||||
},
|
||||
"jsRules": {
|
||||
"max-line-length": {
|
||||
"options": [150]
|
||||
}
|
||||
}
|
||||
}
|
85
webpack.common.js
Normal file
85
webpack.common.js
Normal file
@ -0,0 +1,85 @@
|
||||
const webpack = require("webpack");
|
||||
const path = require("path");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
var HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
|
||||
const outpath = path.resolve(__dirname, "pack");
|
||||
|
||||
function makeCommonConfig() {
|
||||
const commonConfig = {
|
||||
target: "web",
|
||||
mode: "development",
|
||||
entry: "./src/index.js",
|
||||
node: {
|
||||
__dirname: false,
|
||||
__filename: false
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: "ts-loader",
|
||||
exclude: [/node_modules/],
|
||||
},
|
||||
{
|
||||
test: /\.html$/,
|
||||
loader: "file-loader",
|
||||
},
|
||||
{
|
||||
test: /\.(tsx?|js|jsx|html)$/,
|
||||
use: [
|
||||
],
|
||||
exclude: [/node_modules/],
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
modules: [
|
||||
'node_modules',
|
||||
path.resolve(__dirname),
|
||||
],
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
},
|
||||
plugins: [
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{ from: path.resolve(__dirname, "src/static"), to: "static" },
|
||||
{ from: path.resolve(__dirname, "node_modules/easymde/dist/easymde.min.css"), to: "static" },
|
||||
],
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
title: "OCTO",
|
||||
chunks: [],
|
||||
template: "html-templates/index.ejs",
|
||||
filename: 'index.html'
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
title: "OCTO - Boards",
|
||||
chunks: ["boardsPage"],
|
||||
template: "html-templates/page.ejs",
|
||||
filename: 'boards.html'
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
inject: true,
|
||||
title: "OCTO",
|
||||
chunks: ["boardPage"],
|
||||
template: "html-templates/page.ejs",
|
||||
filename: 'board.html'
|
||||
}),
|
||||
],
|
||||
entry: {
|
||||
boardsPage: "./src/client/boardsPage.ts",
|
||||
boardPage: "./src/client/boardPage.tsx"
|
||||
},
|
||||
output: {
|
||||
filename: "[name].js",
|
||||
path: outpath
|
||||
}
|
||||
};
|
||||
|
||||
return commonConfig;
|
||||
}
|
||||
|
||||
module.exports = makeCommonConfig;
|
17
webpack.dev.js
Normal file
17
webpack.dev.js
Normal file
@ -0,0 +1,17 @@
|
||||
const merge = require("webpack-merge");
|
||||
const makeCommonConfig = require("./webpack.common.js");
|
||||
|
||||
const commonConfig = makeCommonConfig();
|
||||
|
||||
const config = merge.merge(commonConfig, {
|
||||
mode: "development",
|
||||
devtool: "inline-source-map",
|
||||
optimization: {
|
||||
minimize: false
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = [
|
||||
merge.merge(config, {
|
||||
}),
|
||||
];
|
18
webpack.js
Normal file
18
webpack.js
Normal file
@ -0,0 +1,18 @@
|
||||
const merge = require("webpack-merge");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
const makeCommonConfig = require("./webpack.common.js");
|
||||
|
||||
const commonConfig = makeCommonConfig();
|
||||
|
||||
const config = merge.merge(commonConfig, {
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin({})]
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = [
|
||||
merge.merge(config, {
|
||||
}),
|
||||
];
|
Loading…
x
Reference in New Issue
Block a user