1
0
mirror of https://github.com/mattermost/focalboard.git synced 2025-02-16 19:47:23 +02:00

First commit!

This commit is contained in:
Chen-I Lim 2020-10-08 09:21:27 -07:00
commit b5b294a54c
62 changed files with 12255 additions and 0 deletions

1
.eslintignore Normal file
View File

@ -0,0 +1 @@
node_modules/

47
.eslintrc Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
{
"tabWidth": 4,
"useTabs": true,
"semi": false
}

25
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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()
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

View 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
View 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 }

View 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
View 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
View 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
View 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 }

View 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
View 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
View 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 }

View 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
View 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
View 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
}
static htmlDecode(text: string) {
return String(text).replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"")
}
// Markdown
static htmlFromMarkdown(text: string): string {
// HACKHACK: Somehow, marked doesn't encode angle brackets
const html = marked(text.replace(/</g, "&lt;"))
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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, {
}),
];