Compare commits
49 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f20a754f | ||
|
|
c132f495b4 | ||
|
|
bcce516c66 | ||
|
|
e9967ebdc6 | ||
|
|
0a6eaed89f | ||
|
|
1a9d532d02 | ||
| d9fa6fd477 | |||
|
|
8039dfaf42 | ||
|
|
0dcc1e1fd9 | ||
|
|
9d45d87ef3 | ||
|
|
2c16d7af60 | ||
|
|
b0496c8de1 | ||
|
|
3b8c6432b6 | ||
|
|
d186489f28 | ||
|
|
6a757c94b0 | ||
|
|
3669b7e6ec | ||
|
|
566cff5e94 | ||
|
|
83e1ce5ffe | ||
|
|
48c1227d9f | ||
|
|
49fd943110 | ||
|
|
f9f083367e | ||
|
|
eea0b1c0f6 | ||
|
|
90b73edc34 | ||
|
|
6782362af1 | ||
|
|
c352176417 | ||
|
|
d89d63803f | ||
|
|
27e493945a | ||
|
|
aee1c25f54 | ||
|
|
7de9feea58 | ||
|
|
a6c94ac7ca | ||
|
|
afcadc8ae1 | ||
|
|
c2555a1d35 | ||
|
|
e9ce21133a | ||
|
|
fd01e751e2 | ||
|
|
08f2344ef9 | ||
|
|
44f5d92815 | ||
|
|
d0f410323e | ||
|
|
dc2b6dac8b | ||
|
|
f98a6ab863 | ||
|
|
5c27f9ed1c | ||
|
|
d3bc5e3f7f | ||
|
|
8768ab1692 | ||
|
|
a3155c5882 | ||
|
|
60c6d4eaba | ||
|
|
51502bd694 | ||
|
|
39c26f77bc | ||
|
|
02c427da23 | ||
|
|
1e42be3a4b | ||
|
|
12a908cde8 |
|
|
@ -8,6 +8,10 @@ Create an empty database. The configured user needs to be able to create and alt
|
|||
Create a configuration file `$HOME/.config/notes.yaml` with the following content:
|
||||
|
||||
```yaml
|
||||
network:
|
||||
address: '[::]'
|
||||
port: 1371
|
||||
|
||||
websocket:
|
||||
domains:
|
||||
- notes.com
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type Config struct {
|
|||
Static string
|
||||
Upload string
|
||||
}
|
||||
NotificationBaseURL string
|
||||
}
|
||||
|
||||
Session struct {
|
||||
|
|
|
|||
13
db.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
// Standard
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Queryable can take both a Db and a transaction
|
||||
type Queryable interface {
|
||||
Exec(string, ...any) (sql.Result, error)
|
||||
Query(string, ...any) (*sql.Rows, error)
|
||||
QueryRow(string, ...any) *sql.Row
|
||||
}
|
||||
1
go.mod
|
|
@ -4,6 +4,7 @@ go 1.21.0
|
|||
|
||||
require (
|
||||
git.gibonuddevalla.se/go/webservice v0.2.2
|
||||
git.gibonuddevalla.se/go/wrappederror v0.3.3
|
||||
github.com/google/uuid v1.5.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jmoiron/sqlx v1.3.5
|
||||
|
|
|
|||
2
go.sum
|
|
@ -2,6 +2,8 @@ git.gibonuddevalla.se/go/dbschema v1.3.0 h1:HzFMR29tWfy/ibIjltTbIMI4inVktj/rh8bE
|
|||
git.gibonuddevalla.se/go/dbschema v1.3.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig=
|
||||
git.gibonuddevalla.se/go/webservice v0.2.2 h1:pmfeLa7c9pSPbuu6TuzcJ6yuVwdMLJ8SSPm1IkusThk=
|
||||
git.gibonuddevalla.se/go/webservice v0.2.2/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws=
|
||||
git.gibonuddevalla.se/go/wrappederror v0.3.3 h1:pdIy3/daSY3zMmUr9PXW6ffIt8iYonOv64mgJBpKz+0=
|
||||
git.gibonuddevalla.se/go/wrappederror v0.3.3/go.mod h1:j4w320Hk1wvhOPjUaK4GgLvmtnjUUM5yVu6JFO1OCSc=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
|
|
|
|||
343
main.go
|
|
@ -3,14 +3,17 @@ package main
|
|||
import (
|
||||
// External
|
||||
"git.gibonuddevalla.se/go/webservice"
|
||||
"git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Internal
|
||||
"git.gibonuddevalla.se/go/webservice/session"
|
||||
"notes/notification"
|
||||
|
||||
// Standard
|
||||
"crypto/md5"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -20,6 +23,7 @@ import (
|
|||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const LISTEN_HOST = "0.0.0.0"
|
||||
|
|
@ -31,12 +35,14 @@ var (
|
|||
flagCheckLocal bool
|
||||
flagConfig string
|
||||
|
||||
service *webservice.Service
|
||||
connectionManager ConnectionManager
|
||||
static http.Handler
|
||||
config Config
|
||||
logger *slog.Logger
|
||||
VERSION string
|
||||
service *webservice.Service
|
||||
connectionManager ConnectionManager
|
||||
notificationManager notification.Manager
|
||||
static http.Handler
|
||||
config Config
|
||||
logger *slog.Logger
|
||||
schedulers map[int]Schedule
|
||||
VERSION string
|
||||
|
||||
//go:embed version sql/*
|
||||
embeddedSQL embed.FS
|
||||
|
|
@ -45,7 +51,7 @@ var (
|
|||
staticFS embed.FS
|
||||
)
|
||||
|
||||
func sqlProvider(dbname string, version int) (sql []byte, found bool) {
|
||||
func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{
|
||||
var err error
|
||||
sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
|
||||
if err != nil {
|
||||
|
|
@ -53,7 +59,27 @@ func sqlProvider(dbname string, version int) (sql []byte, found bool) {
|
|||
}
|
||||
found = true
|
||||
return
|
||||
}
|
||||
} // }}}
|
||||
func logCallback(e WrappedError.Error) { // {{{
|
||||
now := time.Now()
|
||||
out := struct {
|
||||
Year int
|
||||
Month int
|
||||
Day int
|
||||
Time string
|
||||
Error error
|
||||
}{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e}
|
||||
|
||||
j, _ := json.Marshal(out)
|
||||
file, err := os.OpenFile("/tmp/notes.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
logger.Error("log", "error", err)
|
||||
return
|
||||
}
|
||||
file.Write(j)
|
||||
file.Write([]byte("\n"))
|
||||
file.Close()
|
||||
} // }}}
|
||||
|
||||
func init() { // {{{
|
||||
version, _ := embeddedSQL.ReadFile("version")
|
||||
|
|
@ -63,6 +89,8 @@ func init() { // {{{
|
|||
opt.Level = slog.LevelDebug
|
||||
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt))
|
||||
|
||||
schedulers = make(map[int]Schedule, 512)
|
||||
|
||||
configFilename := os.Getenv("HOME") + "/.config/notes.yaml"
|
||||
flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on")
|
||||
flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists")
|
||||
|
|
@ -72,6 +100,9 @@ func init() { // {{{
|
|||
flag.Parse()
|
||||
} // }}}
|
||||
func main() { // {{{
|
||||
WrappedError.Init()
|
||||
WrappedError.SetLogCallback(logCallback)
|
||||
|
||||
var err error
|
||||
|
||||
if flagVersion {
|
||||
|
|
@ -80,6 +111,12 @@ func main() { // {{{
|
|||
}
|
||||
logger.Info("application", "version", VERSION)
|
||||
|
||||
config, err = ConfigRead(flagConfig)
|
||||
if err != nil {
|
||||
logger.Error("application", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
service, err = webservice.New(flagConfig, VERSION, logger)
|
||||
if err != nil {
|
||||
logger.Error("application", "error", err)
|
||||
|
|
@ -98,9 +135,19 @@ func main() { // {{{
|
|||
service.Register("/node/delete", true, true, nodeDelete)
|
||||
service.Register("/node/download", true, true, nodeDownload)
|
||||
service.Register("/node/search", true, true, nodeSearch)
|
||||
service.Register("/node/checklist_group/add", true, true, nodeChecklistGroupAdd)
|
||||
service.Register("/node/checklist_group/item_add", true, true, nodeChecklistGroupItemAdd)
|
||||
service.Register("/node/checklist_group/label", true, true, nodeChecklistGroupLabel)
|
||||
service.Register("/node/checklist_group/delete", true, true, nodeChecklistGroupDelete)
|
||||
service.Register("/node/checklist_item/state", true, true, nodeChecklistItemState)
|
||||
service.Register("/node/checklist_item/label", true, true, nodeChecklistItemLabel)
|
||||
service.Register("/node/checklist_item/delete", true, true, nodeChecklistItemDelete)
|
||||
service.Register("/node/checklist_item/move", true, true, nodeChecklistItemMove)
|
||||
service.Register("/key/retrieve", true, true, keyRetrieve)
|
||||
service.Register("/key/create", true, true, keyCreate)
|
||||
service.Register("/key/counter", true, true, keyCounter)
|
||||
service.Register("/notification/ack", false, false, notificationAcknowledge)
|
||||
service.Register("/schedule/list", true, true, scheduleList)
|
||||
service.Register("/", false, false, service.StaticHandler)
|
||||
|
||||
if flagCreateUser {
|
||||
|
|
@ -108,6 +155,19 @@ func main() { // {{{
|
|||
os.Exit(0)
|
||||
}
|
||||
|
||||
go scheduleHandler()
|
||||
|
||||
if err = service.InitDatabaseConnection(); err != nil {
|
||||
logger.Error("application", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = InitNotificationManager()
|
||||
if err != nil {
|
||||
logger.Error("application", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = service.Start()
|
||||
if err != nil {
|
||||
logger.Error("webserver", "error", err)
|
||||
|
|
@ -115,39 +175,6 @@ func main() { // {{{
|
|||
}
|
||||
} // }}}
|
||||
|
||||
/*
|
||||
func userPassword(w http.ResponseWriter, r *http.Request) { // {{{
|
||||
var err error
|
||||
var ok bool
|
||||
var session Session
|
||||
|
||||
if session, _, err = ValidateSession(r, true); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := struct {
|
||||
CurrentPassword string
|
||||
NewPassword string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err = session.UpdatePassword(req.CurrentPassword, req.NewPassword)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
"CurrentPasswordOK": ok,
|
||||
})
|
||||
} // }}}
|
||||
*/
|
||||
|
||||
func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
logger.Info("webserver", "request", "/node/tree")
|
||||
var err error
|
||||
|
|
@ -223,13 +250,14 @@ func nodeUpdate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
|
|||
Content string
|
||||
CryptoKeyID int
|
||||
Markdown bool
|
||||
TimeOffset int
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = UpdateNode(sess.UserID, req.NodeID, req.Content, req.CryptoKeyID, req.Markdown)
|
||||
err = UpdateNode(sess.UserID, req.NodeID, req.TimeOffset, req.Content, req.CryptoKeyID, req.Markdown)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
|
|
@ -471,6 +499,190 @@ func nodeSearch(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
|
|||
"Nodes": nodes,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistGroupAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
NodeID int
|
||||
Label string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var group ChecklistGroup
|
||||
group, err = ChecklistGroupAdd(sess.UserID, req.NodeID, req.Label)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
group.Items = []ChecklistItem{}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
"Group": group,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistGroupItemAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistGroupID int
|
||||
Label string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var item ChecklistItem
|
||||
item, err = ChecklistGroupItemAdd(sess.UserID, req.ChecklistGroupID, req.Label)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
"Item": item,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistGroupLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistGroupID int
|
||||
Label string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var item ChecklistItem
|
||||
item, err = ChecklistGroupLabel(sess.UserID, req.ChecklistGroupID, req.Label)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
"Item": item,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistGroupDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistGroupID int
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ChecklistGroupDelete(sess.UserID, req.ChecklistGroupID)
|
||||
if err != nil {
|
||||
logger.Error("checklist", "error", err)
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistItemState(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistItemID int
|
||||
State bool
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ChecklistItemState(sess.UserID, req.ChecklistItemID, req.State)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistItemLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistItemID int
|
||||
Label string
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ChecklistItemLabel(sess.UserID, req.ChecklistItemID, req.Label)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistItemDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistItemID int
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ChecklistItemDelete(sess.UserID, req.ChecklistItemID)
|
||||
if err != nil {
|
||||
logger.Error("checklist", "error", err)
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func nodeChecklistItemMove(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
req := struct {
|
||||
ChecklistItemID int
|
||||
AfterItemID int
|
||||
}{}
|
||||
if err = parseRequest(r, &req); err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = ChecklistItemMove(sess.UserID, req.ChecklistItemID, req.AfterItemID)
|
||||
if err != nil {
|
||||
logger.Error("checklist", "error", err)
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
|
||||
func keyRetrieve(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
logger.Info("webserver", "request", "/key/retrieve")
|
||||
|
|
@ -528,4 +740,51 @@ func keyCounter(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{
|
|||
})
|
||||
} // }}}
|
||||
|
||||
func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
logger.Info("webserver", "request", "/notification/ack")
|
||||
var err error
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
|
||||
err = AcknowledgeNotification(r.URL.Query().Get("uuid"))
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
})
|
||||
} // }}}
|
||||
func scheduleList(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||
logger.Info("webserver", "request", "/schedule/list")
|
||||
var err error
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
|
||||
request := struct {
|
||||
NodeID int
|
||||
StartDate time.Time
|
||||
EndDate time.Time
|
||||
}{}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if len(body) > 0 {
|
||||
err = json.Unmarshal(body, &request)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var schedules []Schedule
|
||||
schedules, err = FutureSchedules(sess.UserID, request.NodeID, request.StartDate, request.EndDate)
|
||||
if err != nil {
|
||||
responseError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
responseData(w, map[string]interface{}{
|
||||
"OK": true,
|
||||
"ScheduleEvents": schedules,
|
||||
})
|
||||
} // }}}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
388
node.go
|
|
@ -3,11 +3,29 @@ package main
|
|||
import (
|
||||
// External
|
||||
"github.com/jmoiron/sqlx"
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"time"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type ChecklistItem struct {
|
||||
ID int
|
||||
GroupID int `db:"checklist_group_id"`
|
||||
Order int
|
||||
Label string
|
||||
Checked bool
|
||||
}
|
||||
|
||||
type ChecklistGroup struct {
|
||||
ID int
|
||||
NodeID int `db:"node_id"`
|
||||
Order int
|
||||
Label string
|
||||
Items []ChecklistItem
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
ID int
|
||||
UserID int `db:"user_id"`
|
||||
|
|
@ -22,6 +40,8 @@ type Node struct {
|
|||
Complete bool
|
||||
Level int
|
||||
|
||||
ChecklistGroups []ChecklistGroup
|
||||
|
||||
ContentEncrypted string `db:"content_encrypted" json:"-"`
|
||||
Markdown bool
|
||||
}
|
||||
|
|
@ -211,6 +231,8 @@ func RetrieveNode(userID, nodeID int) (node Node, err error) { // {{{
|
|||
} else {
|
||||
node.Content = row.Content
|
||||
}
|
||||
|
||||
node.retrieveChecklist()
|
||||
}
|
||||
|
||||
if row.Level == 1 {
|
||||
|
|
@ -302,9 +324,82 @@ func CreateNode(userID, parentID int, name string) (node Node, err error) { // {
|
|||
node.Crumbs, err = NodeCrumbs(node.ID)
|
||||
return
|
||||
} // }}}
|
||||
func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
|
||||
func UpdateNode(userID, nodeID, timeOffset int, content string, cryptoKeyID int, markdown bool) (err error) { // {{{
|
||||
if nodeID == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var timezone string
|
||||
row := service.Db.Conn.QueryRow(`SELECT timezone FROM _webservice.user WHERE id=$1`, userID)
|
||||
err = row.Scan(&timezone)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-000F")
|
||||
return
|
||||
}
|
||||
|
||||
var scannedSchedules, dbSchedules, add, remove []Schedule
|
||||
scannedSchedules = ScanForSchedules(timezone, content)
|
||||
for i := range scannedSchedules {
|
||||
scannedSchedules[i].Node.ID = nodeID
|
||||
scannedSchedules[i].UserID = userID
|
||||
}
|
||||
|
||||
var tsx *sql.Tx
|
||||
tsx, err = service.Db.Conn.Begin()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dbSchedules, err = RetrieveSchedules(userID, nodeID)
|
||||
if err != nil {
|
||||
tsx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
for _, scanned := range scannedSchedules {
|
||||
found := false
|
||||
for _, db := range dbSchedules {
|
||||
if scanned.IsEqual(db) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
add = append(add, scanned)
|
||||
}
|
||||
}
|
||||
|
||||
for _, db := range dbSchedules {
|
||||
found := false
|
||||
for _, scanned := range scannedSchedules {
|
||||
if db.IsEqual(scanned) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
remove = append(remove, db)
|
||||
}
|
||||
}
|
||||
|
||||
for _, event := range remove {
|
||||
err = event.Delete(tsx)
|
||||
if err != nil {
|
||||
tsx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, event := range add {
|
||||
err = event.Insert(tsx)
|
||||
if err != nil {
|
||||
tsx.Rollback()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if cryptoKeyID > 0 {
|
||||
_, err = service.Db.Conn.Exec(`
|
||||
_, err = tsx.Exec(`
|
||||
UPDATE node
|
||||
SET
|
||||
content = '',
|
||||
|
|
@ -325,11 +420,12 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
|
|||
markdown,
|
||||
)
|
||||
} else {
|
||||
_, err = service.Db.Conn.Exec(`
|
||||
_, err = tsx.Exec(`
|
||||
UPDATE node
|
||||
SET
|
||||
content = $1,
|
||||
content_encrypted = '',
|
||||
markdown = $5,
|
||||
crypto_key_id = CASE $2::int
|
||||
WHEN 0 THEN NULL
|
||||
ELSE $2
|
||||
|
|
@ -342,8 +438,15 @@ func UpdateNode(userID, nodeID int, content string, cryptoKeyID int, markdown bo
|
|||
cryptoKeyID,
|
||||
nodeID,
|
||||
userID,
|
||||
markdown,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
tsx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
err = tsx.Commit()
|
||||
|
||||
return
|
||||
} // }}}
|
||||
|
|
@ -420,4 +523,283 @@ func SearchNodes(userID int, search string) (nodes []Node, err error) { // {{{
|
|||
return
|
||||
} // }}}
|
||||
|
||||
func ChecklistGroupAdd(userID, nodeID int, label string) (item ChecklistGroup, err error) { // {{{
|
||||
var row *sqlx.Row
|
||||
row = service.Db.Conn.QueryRowx(
|
||||
`
|
||||
INSERT INTO checklist_group(node_id, "order", "label")
|
||||
(
|
||||
SELECT
|
||||
$1,
|
||||
MAX("order")+1 AS "order",
|
||||
$2 AS "label"
|
||||
FROM checklist_group g
|
||||
INNER JOIN node n ON g.node_id = n.id
|
||||
WHERE
|
||||
user_id = $3 AND
|
||||
node_id = $1
|
||||
GROUP BY
|
||||
node_id
|
||||
) UNION (
|
||||
SELECT
|
||||
node.id AS node_id,
|
||||
0 AS "order",
|
||||
$2 AS "label"
|
||||
FROM node
|
||||
WHERE
|
||||
user_id = $3 AND
|
||||
node.id = $1
|
||||
)
|
||||
ORDER BY "order" DESC
|
||||
LIMIT 1
|
||||
RETURNING
|
||||
*
|
||||
`,
|
||||
nodeID,
|
||||
label,
|
||||
userID,
|
||||
)
|
||||
err = row.StructScan(&item)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistGroupLabel(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
UPDATE checklist_group g
|
||||
SET label = $3
|
||||
FROM node n
|
||||
WHERE
|
||||
g.node_id = n.id AND
|
||||
n.user_id = $1 AND
|
||||
g.id = $2;
|
||||
`,
|
||||
userID,
|
||||
checklistGroupID,
|
||||
label,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistGroupItemAdd(userID, checklistGroupID int, label string) (item ChecklistItem, err error) { // {{{
|
||||
var row *sqlx.Row
|
||||
row = service.Db.Conn.QueryRowx(
|
||||
`
|
||||
INSERT INTO checklist_item(checklist_group_id, "order", "label")
|
||||
(
|
||||
SELECT
|
||||
checklist_group_id,
|
||||
MAX("order")+1 AS "order",
|
||||
$1 AS "label"
|
||||
FROM checklist_item
|
||||
WHERE
|
||||
checklist_group_id = $2
|
||||
GROUP BY
|
||||
checklist_group_id
|
||||
) UNION (
|
||||
SELECT $2 AS checklist_group_id, 0 AS "order", $1 AS "label"
|
||||
)
|
||||
ORDER BY "order" DESC
|
||||
LIMIT 1
|
||||
RETURNING
|
||||
*
|
||||
`,
|
||||
label,
|
||||
checklistGroupID,
|
||||
)
|
||||
err = row.StructScan(&item)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistGroupDelete(userID, checklistGroupID int) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
DELETE
|
||||
FROM checklist_group g
|
||||
USING
|
||||
node n
|
||||
WHERE
|
||||
g.id = $2 AND
|
||||
g.node_id = n.id AND
|
||||
n.user_id = $1
|
||||
`,
|
||||
userID,
|
||||
checklistGroupID,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func ChecklistItemState(userID, checklistItemID int, state bool) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
UPDATE checklist_item i
|
||||
SET checked = $3
|
||||
FROM checklist_group g, node n
|
||||
WHERE
|
||||
i.checklist_group_id = g.id AND
|
||||
g.node_id = n.id AND
|
||||
n.user_id = $1 AND
|
||||
i.id = $2;
|
||||
`,
|
||||
userID,
|
||||
checklistItemID,
|
||||
state,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistItemLabel(userID, checklistItemID int, label string) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
UPDATE checklist_item i
|
||||
SET label = $3
|
||||
FROM checklist_group g, node n
|
||||
WHERE
|
||||
i.checklist_group_id = g.id AND
|
||||
g.node_id = n.id AND
|
||||
n.user_id = $1 AND
|
||||
i.id = $2;
|
||||
`,
|
||||
userID,
|
||||
checklistItemID,
|
||||
label,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistItemDelete(userID, checklistItemID int) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
DELETE
|
||||
FROM checklist_item i
|
||||
USING
|
||||
checklist_group g,
|
||||
node n
|
||||
WHERE
|
||||
i.id = $2 AND
|
||||
i.checklist_group_id = g.id AND
|
||||
g.node_id = n.id AND
|
||||
n.user_id = $1
|
||||
`,
|
||||
userID,
|
||||
checklistItemID,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
func ChecklistItemMove(userID, checklistItemID, afterItemID int) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(
|
||||
`
|
||||
WITH
|
||||
"to" AS (
|
||||
SELECT
|
||||
i.checklist_group_id AS group_id,
|
||||
i."order"
|
||||
FROM checklist_item i
|
||||
INNER JOIN checklist_group g ON i.checklist_group_id = g.id
|
||||
INNER JOIN node n ON g.node_id = n.id
|
||||
WHERE
|
||||
n.user_id = $1 AND
|
||||
i.id = $3
|
||||
),
|
||||
|
||||
update_order AS (
|
||||
UPDATE checklist_item
|
||||
SET
|
||||
"order" =
|
||||
CASE
|
||||
WHEN checklist_item."order" <= "to"."order" THEN checklist_item."order" - 1
|
||||
WHEN checklist_item."order" > "to"."order" THEN checklist_item."order" + 1
|
||||
END
|
||||
FROM "to"
|
||||
WHERE
|
||||
checklist_item.id != $2 AND
|
||||
checklist_item.checklist_group_id = "to".group_id
|
||||
)
|
||||
|
||||
UPDATE checklist_item
|
||||
SET
|
||||
checklist_group_id = "to".group_id,
|
||||
"order" = "to"."order"
|
||||
FROM "to"
|
||||
WHERE
|
||||
checklist_item.id = $2
|
||||
`,
|
||||
userID,
|
||||
checklistItemID,
|
||||
afterItemID,
|
||||
)
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func (node *Node) retrieveChecklist() (err error) { // {{{
|
||||
var rows *sqlx.Rows
|
||||
rows, err = service.Db.Conn.Queryx(`
|
||||
SELECT
|
||||
g.id AS group_id,
|
||||
g.order AS group_order,
|
||||
g.label AS group_label,
|
||||
|
||||
COALESCE(i.id, 0) AS item_id,
|
||||
COALESCE(i.order, 0) AS item_order,
|
||||
COALESCE(i.label, '') AS item_label,
|
||||
COALESCE(i.checked, false) AS checked
|
||||
|
||||
FROM public.checklist_group g
|
||||
LEFT JOIN public.checklist_item i ON i.checklist_group_id = g.id
|
||||
WHERE
|
||||
g.node_id = $1
|
||||
ORDER BY
|
||||
g.order DESC,
|
||||
i.order DESC
|
||||
`, node.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
groups := make(map[int]*ChecklistGroup)
|
||||
var found bool
|
||||
var group *ChecklistGroup
|
||||
var item ChecklistItem
|
||||
for rows.Next() {
|
||||
row := struct {
|
||||
GroupID int `db:"group_id"`
|
||||
GroupOrder int `db:"group_order"`
|
||||
GroupLabel string `db:"group_label"`
|
||||
|
||||
ItemID int `db:"item_id"`
|
||||
ItemOrder int `db:"item_order"`
|
||||
ItemLabel string `db:"item_label"`
|
||||
Checked bool
|
||||
}{}
|
||||
err = rows.StructScan(&row)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if group, found = groups[row.GroupID]; !found {
|
||||
group = new(ChecklistGroup)
|
||||
group.ID = row.GroupID
|
||||
group.NodeID = node.ID
|
||||
group.Order = row.GroupOrder
|
||||
group.Label = row.GroupLabel
|
||||
group.Items = []ChecklistItem{}
|
||||
groups[group.ID] = group
|
||||
}
|
||||
|
||||
item = ChecklistItem{}
|
||||
item.ID = row.ItemID
|
||||
item.GroupID = row.GroupID
|
||||
item.Order = row.ItemOrder
|
||||
item.Label = row.ItemLabel
|
||||
item.Checked = row.Checked
|
||||
|
||||
if item.ID > 0 {
|
||||
group.Items = append(group.Items, item)
|
||||
}
|
||||
}
|
||||
|
||||
node.ChecklistGroups = []ChecklistGroup{}
|
||||
for _, group := range groups {
|
||||
node.ChecklistGroups = append(node.ChecklistGroups, *group)
|
||||
}
|
||||
|
||||
return
|
||||
} // }}}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
15
notification/factory.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
)
|
||||
|
||||
func ServiceFactory(t string, config []byte, prio int, ackURL string) (Service, error) {
|
||||
switch t {
|
||||
case "NTFY":
|
||||
return NewNTFY(config, prio, ackURL)
|
||||
}
|
||||
|
||||
return nil, werr.New("Unknown notification service, '%s'", t).WithCode("002-0000")
|
||||
}
|
||||
72
notification/ntfy.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type NTFY struct {
|
||||
URL string
|
||||
Prio int
|
||||
AcknowledgeURL string
|
||||
}
|
||||
|
||||
func NewNTFY(config []byte, prio int, ackURL string) (instance NTFY, err error) {
|
||||
err = json.Unmarshal(config, &instance)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0001").WithData(config)
|
||||
return
|
||||
}
|
||||
instance.Prio = prio
|
||||
instance.AcknowledgeURL = ackURL
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (ntfy NTFY) GetPrio() int {
|
||||
return ntfy.Prio
|
||||
}
|
||||
|
||||
func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
|
||||
var req *http.Request
|
||||
var res *http.Response
|
||||
req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg))
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0002").WithData(ntfy.URL)
|
||||
return
|
||||
}
|
||||
|
||||
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
|
||||
req.Header.Add("X-Actions", ackURL)
|
||||
req.Header.Add("X-Priority", "5")
|
||||
req.Header.Add("X-Tags", "calendar")
|
||||
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0003")
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != 200 {
|
||||
err = werr.New("Invalid NTFY response").WithCode("002-0004").WithData(body)
|
||||
return
|
||||
}
|
||||
|
||||
ntfyResp := struct {
|
||||
ID string
|
||||
}{}
|
||||
err = json.Unmarshal(body, &ntfyResp)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0005").WithData(body)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
60
notification/pkg.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package notification
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
_ "fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetPrio() int
|
||||
Send(string, []byte) error
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
services map[int][]Service
|
||||
}
|
||||
|
||||
func NewManager() (nm Manager) {
|
||||
nm.services = make(map[int][]Service, 32)
|
||||
return
|
||||
}
|
||||
|
||||
func (nm *Manager) AddService(userID int, service Service) {
|
||||
var services []Service
|
||||
var found bool
|
||||
if services, found = nm.services[userID]; !found {
|
||||
services = []Service{}
|
||||
}
|
||||
|
||||
services = append(services, service)
|
||||
slices.SortFunc(services, func(a, b Service) int {
|
||||
if a.GetPrio() < b.GetPrio() {
|
||||
return -1
|
||||
}
|
||||
if a.GetPrio() > b.GetPrio() {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
nm.services[userID] = services
|
||||
}
|
||||
|
||||
func (nm *Manager) Send(userID int, uuid string, msg []byte) (err error) {
|
||||
|
||||
services, found := nm.services[userID]
|
||||
if !found {
|
||||
return werr.New("No notification services defined for user ID %d", userID).WithCode("002-0008")
|
||||
}
|
||||
|
||||
for _, service := range services {
|
||||
if err = service.Send(uuid, msg); err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
75
notification_manager.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Internal
|
||||
"notes/notification"
|
||||
|
||||
// Standard
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
type DbNotificationService struct {
|
||||
ID int
|
||||
UserID int `json:"user_id"`
|
||||
Service string
|
||||
Configuration string
|
||||
Prio int
|
||||
}
|
||||
|
||||
func InitNotificationManager() (err error) {// {{{
|
||||
var dbServices []DbNotificationService
|
||||
var row *sql.Row
|
||||
|
||||
row = service.Db.Conn.QueryRow(`
|
||||
WITH services AS (
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
prio,
|
||||
service,
|
||||
configuration::varchar
|
||||
FROM notification n
|
||||
ORDER BY
|
||||
user_id ASC,
|
||||
prio ASC
|
||||
)
|
||||
SELECT COALESCE(jsonb_agg(s.*), '[]')
|
||||
FROM services s
|
||||
`,
|
||||
)
|
||||
var dbData []byte
|
||||
err = row.Scan(&dbData)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0006")
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(dbData, &dbServices)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0007")
|
||||
return
|
||||
}
|
||||
|
||||
notificationManager = notification.NewManager()
|
||||
var service notification.Service
|
||||
for _, dbService := range dbServices {
|
||||
service, err = notification.ServiceFactory(
|
||||
dbService.Service,
|
||||
[]byte(dbService.Configuration),
|
||||
dbService.Prio,
|
||||
config.Application.NotificationBaseURL,
|
||||
)
|
||||
notificationManager.AddService(dbService.UserID, service)
|
||||
}
|
||||
|
||||
return
|
||||
}// }}}
|
||||
|
||||
func AcknowledgeNotification(uuid string) (err error) {// {{{
|
||||
_, err = service.Db.Conn.Exec(`UPDATE schedule SET acknowledged=true WHERE schedule_uuid=$1`, uuid)
|
||||
return
|
||||
}// }}}
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"encoding/json"
|
||||
"io"
|
||||
|
|
@ -18,6 +21,8 @@ func responseError(w http.ResponseWriter, err error) {
|
|||
}
|
||||
resJSON, _ := json.Marshal(res)
|
||||
|
||||
|
||||
werr.Wrap(err).Log()
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(resJSON)
|
||||
}
|
||||
|
|
|
|||
289
schedule.go
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
fmt.Printf("")
|
||||
}
|
||||
|
||||
type Schedule struct {
|
||||
ID int
|
||||
UserID int `json:"user_id" db:"user_id"`
|
||||
Node Node
|
||||
ScheduleUUID string `db:"schedule_uuid"`
|
||||
Time time.Time
|
||||
RemindMinutes int `db:"remind_minutes"`
|
||||
Description string
|
||||
Acknowledged bool
|
||||
}
|
||||
|
||||
func scheduleHandler() { // {{{
|
||||
// Wait for the approximate minute.
|
||||
wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds()
|
||||
logger.Info("schedule", "wait", wait/1000)
|
||||
time.Sleep(time.Millisecond * time.Duration(wait))
|
||||
tick := time.NewTicker(time.Minute)
|
||||
for {
|
||||
schedules := ExpiredSchedules()
|
||||
for _, event := range schedules {
|
||||
notificationManager.Send(
|
||||
event.UserID,
|
||||
event.ScheduleUUID,
|
||||
[]byte(
|
||||
fmt.Sprintf(
|
||||
"%s\n%s",
|
||||
event.Time.Format("2006-01-02 15:04"),
|
||||
event.Description,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
<-tick.C
|
||||
}
|
||||
} // }}}
|
||||
|
||||
func ScanForSchedules(timezone string, content string) (schedules []Schedule) { // {{{
|
||||
schedules = []Schedule{}
|
||||
|
||||
rxp := regexp.MustCompile(`\{\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}(?::[0-9]{2})?)\s*\,\s*(?:(\d+)\s*(h|min)\s*,)?\s*([^\]]+?)\s*\}`)
|
||||
foundSchedules := rxp.FindAllStringSubmatch(content, -1)
|
||||
|
||||
for _, data := range foundSchedules {
|
||||
// Missing seconds
|
||||
if strings.Count(data[1], ":") == 1 {
|
||||
data[1] = data[1] + ":00"
|
||||
}
|
||||
|
||||
logger.Info("time", "parse", data[1])
|
||||
location, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-00010")
|
||||
return
|
||||
}
|
||||
|
||||
timestamp, err := time.ParseInLocation("2006-01-02 15:04:05", data[1], location)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reminder
|
||||
var remindMinutes int
|
||||
if data[2] != "" && data[3] != "" {
|
||||
value, _ := strconv.Atoi(data[2])
|
||||
unit := strings.ToLower(data[3])
|
||||
switch unit {
|
||||
case "min":
|
||||
remindMinutes = value
|
||||
case "h":
|
||||
remindMinutes = value * 60
|
||||
}
|
||||
}
|
||||
|
||||
schedule := Schedule{
|
||||
Time: timestamp,
|
||||
RemindMinutes: remindMinutes,
|
||||
Description: data[4],
|
||||
}
|
||||
schedules = append(schedules, schedule)
|
||||
}
|
||||
|
||||
return
|
||||
} // }}}
|
||||
func RetrieveSchedules(userID int, nodeID int) (schedules []Schedule, err error) { // {{{
|
||||
schedules = []Schedule{}
|
||||
|
||||
res := service.Db.Conn.QueryRow(`
|
||||
WITH schedule_events AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
json_build_object('id', s.node_id) AS node,
|
||||
s.schedule_uuid,
|
||||
(time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
|
||||
s.description,
|
||||
s.acknowledged
|
||||
FROM schedule s
|
||||
INNER JOIN _webservice.user u ON s.user_id = u.id
|
||||
WHERE
|
||||
user_id=$1 AND
|
||||
CASE
|
||||
WHEN $2 > 0 THEN node_id = $2
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
SELECT
|
||||
COALESCE(jsonb_agg(s.*), '[]'::jsonb)
|
||||
FROM schedule_events s
|
||||
`,
|
||||
userID,
|
||||
nodeID,
|
||||
)
|
||||
|
||||
var data []byte
|
||||
err = res.Scan(&data)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-000E")
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &schedules)
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func (a Schedule) IsEqual(b Schedule) bool { // {{{
|
||||
return a.UserID == b.UserID &&
|
||||
a.Node.ID == b.Node.ID &&
|
||||
a.Time.Equal(b.Time) &&
|
||||
a.RemindMinutes == b.RemindMinutes &&
|
||||
a.Description == b.Description
|
||||
} // }}}
|
||||
func (s *Schedule) Insert(queryable Queryable) error { // {{{
|
||||
res := queryable.QueryRow(`
|
||||
INSERT INTO schedule(user_id, node_id, time, remind_minutes, description)
|
||||
VALUES($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
s.UserID,
|
||||
s.Node.ID,
|
||||
s.Time.Format("2006-01-02 15:04:05"),
|
||||
s.RemindMinutes,
|
||||
s.Description,
|
||||
)
|
||||
|
||||
err := res.Scan(&s.ID)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-000D")
|
||||
}
|
||||
return err
|
||||
} // }}}
|
||||
func (s *Schedule) Delete(queryable Queryable) error { // {{{
|
||||
_, err := queryable.Exec(`
|
||||
DELETE FROM schedule
|
||||
WHERE
|
||||
user_id = $1 AND
|
||||
id = $2
|
||||
`,
|
||||
s.UserID,
|
||||
s.ID,
|
||||
)
|
||||
return err
|
||||
} // }}}
|
||||
|
||||
func ExpiredSchedules() (schedules []Schedule) { // {{{
|
||||
schedules = []Schedule{}
|
||||
|
||||
res, err := service.Db.Conn.Queryx(`
|
||||
SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
s.node_id,
|
||||
s.schedule_uuid,
|
||||
(s.time - MAKE_INTERVAL(mins => s.remind_minutes)) AT TIME ZONE u.timezone AS time,
|
||||
s.description
|
||||
FROM schedule s
|
||||
INNER JOIN _webservice.user u ON s.user_id = u.id
|
||||
WHERE
|
||||
(time - MAKE_INTERVAL(mins => remind_minutes)) AT TIME ZONE u.timezone < NOW() AND
|
||||
NOT acknowledged
|
||||
ORDER BY
|
||||
time ASC
|
||||
`)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0009")
|
||||
return
|
||||
}
|
||||
defer res.Close()
|
||||
|
||||
for res.Next() {
|
||||
s := Schedule{}
|
||||
if err = res.Scan(&s.ID, &s.UserID, &s.Node.ID, &s.ScheduleUUID, &s.Time, &s.Description); err != nil {
|
||||
werr.Wrap(err).WithCode("002-000a")
|
||||
continue
|
||||
}
|
||||
schedules = append(schedules, s)
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
func FutureSchedules(userID int, nodeID int, start time.Time, end time.Time) (schedules []Schedule, err error) { // {{{
|
||||
schedules = []Schedule{}
|
||||
|
||||
var foo string
|
||||
row := service.Db.Conn.QueryRow(`SELECT TO_CHAR($1::date AT TIME ZONE 'UTC', 'yyyy-mm-dd HH24:MI')`, start)
|
||||
err = row.Scan(&foo)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
logger.Info("FOO", "date", foo)
|
||||
|
||||
res := service.Db.Conn.QueryRow(`
|
||||
WITH schedule_events AS (
|
||||
SELECT
|
||||
s.id,
|
||||
s.user_id,
|
||||
jsonb_build_object(
|
||||
'id', n.id,
|
||||
'name', n.name,
|
||||
'updated', n.updated
|
||||
) AS node,
|
||||
s.schedule_uuid,
|
||||
time AT TIME ZONE u.timezone AS time,
|
||||
s.description,
|
||||
s.acknowledged,
|
||||
s.remind_minutes AS RemindMinutes
|
||||
FROM schedule s
|
||||
INNER JOIN _webservice.user u ON s.user_id = u.id
|
||||
INNER JOIN node n ON s.node_id = n.id
|
||||
WHERE
|
||||
s.user_id = $1 AND
|
||||
(
|
||||
CASE
|
||||
WHEN $2 > 0 THEN n.id = $2
|
||||
ELSE true
|
||||
END
|
||||
) AND (
|
||||
CASE WHEN TO_CHAR($3::date, 'yyyy-mm-dd HH24:MI') = '0001-01-01 00:00' THEN TRUE
|
||||
ELSE (s.time AT TIME ZONE u.timezone) >= $3
|
||||
END
|
||||
) AND (
|
||||
CASE WHEN TO_CHAR($4::date, 'yyyy-mm-dd HH24:MI') = '0001-01-01 00:00' THEN TRUE
|
||||
ELSE (s.time AT TIME ZONE u.timezone) <= $4
|
||||
END
|
||||
) AND
|
||||
time >= NOW() AND
|
||||
NOT acknowledged
|
||||
)
|
||||
SELECT
|
||||
COALESCE(jsonb_agg(s.*), '[]'::jsonb)
|
||||
FROM schedule_events s
|
||||
`,
|
||||
userID,
|
||||
nodeID,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
var j []byte
|
||||
err = res.Scan(&j)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-000B").WithData(userID)
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(j, &schedules)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithCode("002-0010").WithData(string(j)).Log()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
} // }}}
|
||||
18
sql/00014.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
CREATE TABLE checklist_group (
|
||||
id serial NOT NULL,
|
||||
node_id int4 NOT NULL,
|
||||
"order" int NOT NULL DEFAULT 0,
|
||||
label varchar NOT NULL,
|
||||
CONSTRAINT checklist_group_pk PRIMARY KEY (id),
|
||||
CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE checklist_item (
|
||||
id serial NOT NULL,
|
||||
checklist_group_id int4 NOT NULL,
|
||||
"order" int NOT NULL DEFAULT 0,
|
||||
label varchar NOT NULL,
|
||||
checked bool NOT NULL DEFAULT false,
|
||||
CONSTRAINT checklist_item_pk PRIMARY KEY (id),
|
||||
CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
14
sql/00015.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE public.schedule (
|
||||
id SERIAL NOT NULL,
|
||||
user_id INT4 NOT NULL,
|
||||
node_id INT4 NOT NULL,
|
||||
schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL,
|
||||
"time" TIMESTAMP NOT NULL,
|
||||
description VARCHAR DEFAULT '' NOT NULL,
|
||||
acknowledged BOOL DEFAULT false NOT NULL,
|
||||
|
||||
CONSTRAINT schedule_pk PRIMARY KEY (id),
|
||||
CONSTRAINT schedule_uuid UNIQUE (schedule_uuid),
|
||||
CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
1
sql/00016.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);
|
||||
11
sql/00017.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE public.notification (
|
||||
id SERIAl NOT NULL,
|
||||
user_id INT4 NOT NULL,
|
||||
service VARCHAR DEFAULT 'NTFY' NOT NULL,
|
||||
"configuration" JSONB DEFAULT '{}' NOT NULL,
|
||||
prio INT DEFAULT 0 NOT NULL,
|
||||
|
||||
CONSTRAINT notification_pk PRIMARY KEY (id),
|
||||
CONSTRAINT notification_unique UNIQUE (user_id,prio),
|
||||
CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES "_webservice"."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
2
sql/00018.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz;
|
||||
|
||||
1
sql/00019.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0;
|
||||
2
sql/00020.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE _webservice."user" ADD timezone varchar DEFAULT 'UTC' NOT NULL;
|
||||
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamp USING "time"::timestamp;
|
||||
1
sql/00021.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz;
|
||||
|
|
@ -13,9 +13,17 @@ html {
|
|||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
*,
|
||||
*:focus,
|
||||
*:hover {
|
||||
outline: none;
|
||||
}
|
||||
[onClick] {
|
||||
cursor: pointer;
|
||||
}
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0px;
|
||||
|
|
@ -26,12 +34,16 @@ body {
|
|||
height: 100%;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0px;
|
||||
font-size: 1.5em;
|
||||
font-size: 1.25em;
|
||||
color: #518048;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
h2 {
|
||||
margin-top: 32px;
|
||||
font-size: 1.25em;
|
||||
font-size: 1em;
|
||||
color: #518048;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1em;
|
||||
}
|
||||
button {
|
||||
font-size: 1em;
|
||||
|
|
@ -141,10 +153,15 @@ button {
|
|||
#properties .key label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
#properties .checks {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-gap: 4px 8px;
|
||||
}
|
||||
header {
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
grid-template-columns: min-content 1fr repeat(4, min-content);
|
||||
grid-template-columns: min-content 1fr repeat(6, min-content);
|
||||
align-items: center;
|
||||
padding: 8px 0px;
|
||||
color: #333c11;
|
||||
|
|
@ -169,12 +186,14 @@ header .name {
|
|||
font-size: 1.25em;
|
||||
}
|
||||
header .markdown,
|
||||
header .checklist,
|
||||
header .search,
|
||||
header .add,
|
||||
header .keys {
|
||||
padding-right: 16px;
|
||||
}
|
||||
header .markdown img,
|
||||
header .checklist img,
|
||||
header .search img,
|
||||
header .add img,
|
||||
header .keys img {
|
||||
|
|
@ -193,6 +212,7 @@ header .menu {
|
|||
padding: 16px;
|
||||
background-color: #333;
|
||||
color: #ddd;
|
||||
z-index: 100;
|
||||
}
|
||||
#tree .node {
|
||||
display: grid;
|
||||
|
|
@ -307,10 +327,15 @@ header .menu {
|
|||
padding-top: 16px;
|
||||
}
|
||||
#markdown {
|
||||
padding: 16px;
|
||||
color: #333;
|
||||
grid-area: content;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
#markdown table {
|
||||
border-collapse: collapse;
|
||||
|
|
@ -320,6 +345,155 @@ header .menu {
|
|||
border: 1px solid #ddd;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
#markdown code {
|
||||
background: #e6eeee;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
#markdown pre {
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#markdown pre > code {
|
||||
background: unset;
|
||||
padding: 0px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
#checklist {
|
||||
grid-area: checklist;
|
||||
color: #333;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
#checklist .header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 0 16px;
|
||||
}
|
||||
#checklist .header img {
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#checklist .header + .checklist-group.edit {
|
||||
margin-top: 16px;
|
||||
}
|
||||
#checklist .checklist-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 0 8px;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
#checklist .checklist-group .label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#checklist .checklist-group .label.ok {
|
||||
color: #54b356;
|
||||
}
|
||||
#checklist .checklist-group .label.error {
|
||||
color: #d13636;
|
||||
}
|
||||
#checklist .checklist-group.edit {
|
||||
margin-top: 32px;
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
#checklist .checklist-group:not(.edit) .reorder {
|
||||
display: none;
|
||||
}
|
||||
#checklist .checklist-group:not(.edit) img {
|
||||
display: none;
|
||||
}
|
||||
#checklist .checklist-group img {
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#checklist .checklist-item {
|
||||
transform: translate(0, 0);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content);
|
||||
grid-gap: 0 8px;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 2px solid #fff;
|
||||
}
|
||||
#checklist .checklist-item.checked {
|
||||
text-decoration: line-through;
|
||||
color: #aaa;
|
||||
}
|
||||
#checklist .checklist-item.drag-target {
|
||||
border-bottom: 2px solid #71c837;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: #fff;
|
||||
margin: 0 2px 0 0;
|
||||
font: inherit;
|
||||
color: currentColor;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
border: 0.15em solid currentColor;
|
||||
border-radius: 0.15em;
|
||||
transform: translateY(-0.075em);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
#checklist .checklist-item label.ok {
|
||||
color: #54b356;
|
||||
}
|
||||
#checklist .checklist-item label.error {
|
||||
color: #d13636;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"].ok {
|
||||
border: 0.15em solid #54b356;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"].ok::before {
|
||||
box-shadow: inset 1em 1em #54b356;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"].error {
|
||||
border: 0.15em solid #d13636;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"].error::before {
|
||||
box-shadow: inset 1em 1em #d13636;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 0.7em;
|
||||
height: 0.7em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em #666;
|
||||
}
|
||||
#checklist .checklist-item input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
#checklist .checklist-item.edit input[type="checkbox"] {
|
||||
margin-left: 8px;
|
||||
}
|
||||
#checklist .checklist-item:not(.edit) .reorder {
|
||||
display: none;
|
||||
}
|
||||
#checklist .checklist-item:not(.edit) img {
|
||||
display: none;
|
||||
}
|
||||
#checklist .checklist-item img {
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#checklist .checklist-item label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ============================================================= *
|
||||
* Textarea replicates the height of an element expanding height *
|
||||
* ============================================================= */
|
||||
|
|
@ -358,7 +532,7 @@ header .menu {
|
|||
grid-area: 1 / 1 / 2 / 2;
|
||||
}
|
||||
/* ============================================================= */
|
||||
#file-section {
|
||||
#schedule-section {
|
||||
grid-area: files;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
|
|
@ -368,6 +542,23 @@ header .menu {
|
|||
border-radius: 8px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
color: #000;
|
||||
}
|
||||
#schedule-section .header {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#file-section {
|
||||
grid-area: schedule;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 32px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#file-section .header {
|
||||
font-weight: bold;
|
||||
|
|
@ -466,9 +657,9 @@ header .menu {
|
|||
}
|
||||
.layout-tree {
|
||||
display: grid;
|
||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
|
||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
|
||||
/* blank */
|
||||
color: #fff;
|
||||
min-height: 100%;
|
||||
|
|
@ -501,14 +692,17 @@ header .menu {
|
|||
display: block;
|
||||
}
|
||||
.layout-crumbs {
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
|
||||
/* blank */
|
||||
}
|
||||
.layout-crumbs #tree {
|
||||
display: none;
|
||||
}
|
||||
.layout-crumbs #checklist {
|
||||
padding: 16px;
|
||||
}
|
||||
.layout-keys {
|
||||
display: grid;
|
||||
grid-template-areas: "header" "keys";
|
||||
|
|
@ -547,22 +741,25 @@ header .menu {
|
|||
}
|
||||
#app.node {
|
||||
display: grid;
|
||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
|
||||
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree checklist" "tree schedule" "tree files" "tree blank";
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
|
||||
/* blank */
|
||||
color: #fff;
|
||||
min-height: 100%;
|
||||
}
|
||||
#app.node.toggle-tree {
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
|
||||
/* blank */
|
||||
}
|
||||
#app.node.toggle-tree #tree {
|
||||
display: none;
|
||||
}
|
||||
#app.node.toggle-tree #checklist {
|
||||
padding: 16px;
|
||||
}
|
||||
#profile-settings {
|
||||
color: #333;
|
||||
padding: 16px;
|
||||
|
|
@ -576,16 +773,80 @@ header .menu {
|
|||
#profile-settings .passwords div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
#schedule-events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
grid-gap: 4px 12px;
|
||||
margin: 32px;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#schedule-events .header {
|
||||
font-weight: bold;
|
||||
}
|
||||
#input-text {
|
||||
border: 1px solid #000 !important;
|
||||
padding: 16px;
|
||||
width: 300px;
|
||||
}
|
||||
#input-text .label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#input-text input[type=text] {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
#input-text .buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px 64px;
|
||||
grid-gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
#fullcalendar {
|
||||
margin: 32px;
|
||||
color: #444;
|
||||
}
|
||||
.folder .tabs {
|
||||
border-left: 1px solid #888;
|
||||
display: flex;
|
||||
}
|
||||
.folder .tabs .tab {
|
||||
padding: 16px 32px;
|
||||
border-top: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
border-right: 1px solid #888;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
.folder .tabs .tab.selected {
|
||||
border-bottom: none;
|
||||
background: #fff;
|
||||
}
|
||||
.folder .tabs .hack {
|
||||
border-bottom: 1px solid #888;
|
||||
width: 100%;
|
||||
}
|
||||
.folder .content {
|
||||
padding-top: 1px;
|
||||
border-left: 1px solid #888;
|
||||
border-right: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
@media only screen and (max-width: 932px) {
|
||||
#app.node {
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
|
||||
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "checklist" "schedule" "files" "blank";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
|
||||
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* checklist */ min-content /* schedule */ min-content /* files */ 1fr;
|
||||
/* blank */
|
||||
}
|
||||
#app.node #tree {
|
||||
display: none;
|
||||
}
|
||||
#app.node #checklist {
|
||||
padding: 16px;
|
||||
}
|
||||
#app.node.toggle-tree {
|
||||
display: grid;
|
||||
grid-template-areas: "header" "tree";
|
||||
|
|
@ -618,7 +879,9 @@ header .menu {
|
|||
padding: 16px;
|
||||
justify-self: start;
|
||||
}
|
||||
#file-section {
|
||||
#file-section,
|
||||
#checklist,
|
||||
#markdown {
|
||||
width: calc(100% - 32px);
|
||||
padding: 16px;
|
||||
margin-left: 16px;
|
||||
|
|
|
|||
76
static/images/add-gray.svg
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="127.99999"
|
||||
height="127.99999"
|
||||
viewBox="0 0 33.866664 33.866666"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="add-gray.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="28.5"
|
||||
inkscape:cy="32"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1044"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="1096"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-57.706364,-79.853668)">
|
||||
<rect
|
||||
style="color:#000000;overflow:visible;fill:#8dd35f;fill-rule:evenodd;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000;fill-opacity:1"
|
||||
id="rect232"
|
||||
width="33.866669"
|
||||
height="7.4083333"
|
||||
x="57.706364"
|
||||
y="93.082832" />
|
||||
<rect
|
||||
style="color:#000000;overflow:visible;fill:#8dd35f;fill-rule:evenodd;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000;fill-opacity:1"
|
||||
id="rect396"
|
||||
width="33.866665"
|
||||
height="7.4083333"
|
||||
x="79.853668"
|
||||
y="-78.343864"
|
||||
transform="rotate(90)" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
71
static/images/checklist-off.svg
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="4.7624998mm"
|
||||
height="5.2916698mm"
|
||||
viewBox="0 0 4.7624996 5.2916701"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="checklist-off.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#0088ff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4"
|
||||
inkscape:cx="9"
|
||||
inkscape:cy="9.5"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-120.36363,-120.48512)">
|
||||
<title
|
||||
id="title1">clipboard-check</title>
|
||||
<title
|
||||
id="title1-7">clipboard-check</title>
|
||||
<title
|
||||
id="title1-5">clipboard-check-outline</title>
|
||||
<path
|
||||
d="m 124.59697,121.01429 h -1.10596 c -0.11113,-0.30692 -0.40217,-0.52917 -0.74613,-0.52917 -0.34395,0 -0.635,0.22225 -0.74612,0.52917 h -1.10596 a 0.52916667,0.52916667 0 0 0 -0.52917,0.52917 v 3.70416 a 0.52916667,0.52916667 0 0 0 0.52917,0.52917 h 3.70417 a 0.52916667,0.52916667 0 0 0 0.52916,-0.52917 v -3.70416 a 0.52916667,0.52916667 0 0 0 -0.52916,-0.52917 m -1.85209,0 a 0.26458333,0.26458333 0 0 1 0.26459,0.26458 0.26458333,0.26458333 0 0 1 -0.26459,0.26459 0.26458333,0.26458333 0 0 1 -0.26458,-0.26459 0.26458333,0.26458333 0 0 1 0.26458,-0.26458 m -1.32291,1.05833 h 2.64583 v -0.52916 h 0.52917 v 3.70416 h -3.70417 v -3.70416 h 0.52917 v 0.52916 m 0.13229,1.7198 0.39687,-0.39688 0.52917,0.52917 1.19063,-1.19063 0.39687,0.39688 -1.5875,1.5875 z"
|
||||
id="path1"
|
||||
style="stroke-width:0.264583;fill:#f9f9f9" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
73
static/images/checklist-on.svg
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="4.7624998mm"
|
||||
height="5.2916698mm"
|
||||
viewBox="0 0 4.7624996 5.2916701"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="checklist-on.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#0088ff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4"
|
||||
inkscape:cx="9"
|
||||
inkscape:cy="9.5"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-120.36363,-120.48512)">
|
||||
<title
|
||||
id="title1">clipboard-check</title>
|
||||
<title
|
||||
id="title1-7">clipboard-check</title>
|
||||
<title
|
||||
id="title1-5">clipboard-check-outline</title>
|
||||
<title
|
||||
id="title1-3">clipboard-check</title>
|
||||
<path
|
||||
d="m 122.21571,124.71846 -1.05833,-1.05834 0.37306,-0.37306 0.68527,0.68263 1.74361,-1.74361 0.37306,0.37571 m -1.5875,-1.5875 a 0.26458333,0.26458333 0 0 1 0.26458,0.26458 0.26458333,0.26458333 0 0 1 -0.26458,0.26459 0.26458333,0.26458333 0 0 1 -0.26458,-0.26459 0.26458333,0.26458333 0 0 1 0.26458,-0.26458 m 1.85208,0 H 123.491 c -0.11112,-0.30692 -0.40216,-0.52917 -0.74612,-0.52917 -0.34396,0 -0.635,0.22225 -0.74613,0.52917 h -1.10595 a 0.52916666,0.52916666 0 0 0 -0.52917,0.52917 v 3.70416 a 0.52916666,0.52916666 0 0 0 0.52917,0.52917 h 3.70416 a 0.52916666,0.52916666 0 0 0 0.52917,-0.52917 v -3.70416 a 0.52916666,0.52916666 0 0 0 -0.52917,-0.52917 z"
|
||||
id="path1"
|
||||
style="stroke-width:0.264583;fill:#f9f9f9" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
66
static/images/edit-list-gray.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="19.463146mm"
|
||||
height="14.45555mm"
|
||||
viewBox="0 0 19.463145 14.455551"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="edit.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="4"
|
||||
inkscape:cx="34.875"
|
||||
inkscape:cy="63"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1044"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="1096"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-95.481611,-106.78967)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m 95.481611,106.78967 v 2.06503 h 11.357839 v -2.06503 H 95.481611 m 0,4.13011 v 2.06507 h 11.357839 v -2.06507 H 95.481611 m 17.553019,0.10341 c -0.10342,0 -0.30986,0.10342 -0.41304,0.20644 l -1.03251,1.03256 2.16829,2.16829 1.03255,-1.03252 c 0.20645,-0.20644 0.20645,-0.61951 0,-0.82603 l -1.34229,-1.3423 c -0.10342,-0.10302 -0.20644,-0.20644 -0.413,-0.20644 m -1.96181,1.85855 -6.29844,6.19518 v 2.1683 h 2.16829 l 6.29844,-6.29844 -2.16829,-2.06504 m -15.591209,2.1683 v 2.06507 h 7.227699 v -2.06507 z"
|
||||
id="path1"
|
||||
style="stroke-width:1.03253;fill:#b1b1b1;fill-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
66
static/images/edit-list.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="19.463146mm"
|
||||
height="14.45555mm"
|
||||
viewBox="0 0 19.463145 14.455551"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="edit.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="-9.5"
|
||||
inkscape:cy="166"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1044"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="1096"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-95.481611,-106.78967)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m 95.481611,106.78967 v 2.06503 h 11.357839 v -2.06503 H 95.481611 m 0,4.13011 v 2.06507 h 11.357839 v -2.06507 H 95.481611 m 17.553019,0.10341 c -0.10342,0 -0.30986,0.10342 -0.41304,0.20644 l -1.03251,1.03256 2.16829,2.16829 1.03255,-1.03252 c 0.20645,-0.20644 0.20645,-0.61951 0,-0.82603 l -1.34229,-1.3423 c -0.10342,-0.10302 -0.20644,-0.20644 -0.413,-0.20644 m -1.96181,1.85855 -6.29844,6.19518 v 2.1683 h 2.16829 l 6.29844,-6.29844 -2.16829,-2.06504 m -15.591209,2.1683 v 2.06507 h 7.227699 v -2.06507 z"
|
||||
id="path1"
|
||||
style="stroke-width:1.03253" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
66
static/images/edit.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.156595mm"
|
||||
height="26.156595mm"
|
||||
viewBox="0 0 26.156594 26.156597"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="edit.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="6.5"
|
||||
inkscape:cy="41"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1044"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="1096"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-103.00032,-112.64716)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m 128.73192,118.52071 c 0.56666,-0.56666 0.56666,-1.51108 0,-2.04869 l -3.39986,-3.39986 c -0.53761,-0.56666 -1.48203,-0.56666 -2.04869,0 l -2.67339,2.6589 5.44854,5.44849 m -23.0582,12.17566 v 5.44855 h 5.44849 l 16.06958,-16.08408 -5.44854,-5.44855 z"
|
||||
id="path1"
|
||||
style="stroke-width:1.45294;fill:#ff9955;fill-opacity:1" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
49
static/images/markdown-hollow.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="128"
|
||||
width="208"
|
||||
viewBox="-31.2 -32 208 128"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="markdown-hollow.svg"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="true"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="5.6568542"
|
||||
inkscape:cx="109.68994"
|
||||
inkscape:cy="68.942911"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1280"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1" />
|
||||
<rect
|
||||
fill="none"
|
||||
stroke-width="10"
|
||||
stroke="#000000"
|
||||
ry="10"
|
||||
y="-27"
|
||||
x="-26.200001"
|
||||
height="118"
|
||||
width="198"
|
||||
id="rect1"
|
||||
style="stroke:#ffffff;stroke-opacity:1" />
|
||||
<path
|
||||
d="M -1.2000003,66 V -2 H 18.8 l 20,25 20,-25 h 20 v 68 h -20 V 27 l -20,25 -20,-25 V 66 Z M 123.8,66 93.8,33 h 20 V -2 h 20 v 35 h 20 z"
|
||||
id="path1"
|
||||
style="fill:#ffffff;fill-opacity:1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
66
static/images/trashcan.svg
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="23.835697mm"
|
||||
height="26.815161mm"
|
||||
viewBox="0 0 23.835696 26.815162"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="trashcan.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="45.5"
|
||||
inkscape:cy="51"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="1916"
|
||||
inkscape:window-height="1044"
|
||||
inkscape:window-x="1920"
|
||||
inkscape:window-y="1096"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="false"
|
||||
inkscape:deskcolor="#dddddd"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-81.215483,-137.6695)">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m 88.66414,137.6695 v 1.48977 h -7.448657 v 2.97942 h 1.489734 v 19.36654 a 2.9794621,2.9794621 0 0 0 2.979459,2.97943 h 14.897314 a 2.9794621,2.9794621 0 0 0 2.97946,-2.97943 v -19.36654 h 1.48973 v -2.97942 H 97.602526 V 137.6695 H 88.66414 m -2.979464,4.46919 h 14.897314 v 19.36654 H 85.684676 v -19.36654 m 2.979464,2.97948 v 13.40758 h 2.979464 V 145.11817 H 88.66414 m 5.958922,0 v 13.40758 h 2.979464 v -13.40758 z"
|
||||
id="path1"
|
||||
style="stroke-width:1.48973;fill:#d35f5f" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -20,12 +20,14 @@
|
|||
"node": "/js/{{ .VERSION }}/node.mjs",
|
||||
"key": "/js/{{ .VERSION }}/key.mjs",
|
||||
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
||||
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
||||
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
|
||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script>
|
||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import 'preact/debug'
|
||||
import 'preact/devtools'
|
||||
import { signal } from 'preact/signals'
|
||||
|
||||
import { h, Component, render, createRef } from 'preact'
|
||||
import htm from 'htm'
|
||||
import { Session } from 'session'
|
||||
import { Node, NodeUI } from 'node'
|
||||
import { Websocket } from 'ws'
|
||||
import { signal } from 'preact/signals'
|
||||
const html = htm.bind(h)
|
||||
|
||||
class App extends Component {
|
||||
|
|
@ -46,7 +48,10 @@ class App extends Component {
|
|||
|
||||
responseError({ comm, app, upload }) {//{{{
|
||||
if (comm !== undefined) {
|
||||
comm.text().then(body => alert(body))
|
||||
if (typeof comm.text === 'function')
|
||||
comm.text().then(body => alert(body))
|
||||
else
|
||||
alert(comm)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -108,7 +113,7 @@ class App extends Component {
|
|||
this.websocket.register('open', ()=>console.log('websocket connected'))
|
||||
this.websocket.register('close', ()=>console.log('websocket disconnected'))
|
||||
this.websocket.register('error', msg=>console.log(msg))
|
||||
this.websocket.register('message', this.websocketMessage)
|
||||
this.websocket.register('message', msg=>this.websocketMessage(msg))
|
||||
this.websocket.start()
|
||||
}//}}}
|
||||
websocketMessage(data) {//{{{
|
||||
|
|
@ -116,7 +121,7 @@ class App extends Component {
|
|||
|
||||
switch (msg.Op) {
|
||||
case 'css_reload':
|
||||
refreshCSS()
|
||||
this.websocket.refreshCSS()
|
||||
break;
|
||||
}
|
||||
}//}}}
|
||||
|
|
|
|||
472
static/js/checklist.mjs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
import { h, Component, createRef } from 'preact'
|
||||
import htm from 'htm'
|
||||
import { signal } from 'preact/signals'
|
||||
const html = htm.bind(h)
|
||||
|
||||
export class ChecklistGroup {
|
||||
static sort(a, b) {//{{{
|
||||
if (a.Order < b.Order) return -1
|
||||
if (a.Order > b.Order) return 1
|
||||
return 0
|
||||
}//}}}
|
||||
constructor(data) {//{{{
|
||||
Object.keys(data).forEach(key => {
|
||||
if (key == 'Items')
|
||||
this.items = data[key].map(itemData => {
|
||||
let item = new ChecklistItem(itemData)
|
||||
item.checklistGroup = this
|
||||
return item
|
||||
})
|
||||
else
|
||||
this[key] = data[key]
|
||||
})
|
||||
}//}}}
|
||||
addItem(label, okCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/item_add', {
|
||||
ChecklistGroupID: this.ID,
|
||||
Label: label,
|
||||
})
|
||||
.then(json => {
|
||||
let item = new ChecklistItem(json.Item)
|
||||
item.checklistGroup = this
|
||||
this.items.push(item)
|
||||
okCallback()
|
||||
})
|
||||
.catch(window._app.current.responseError)
|
||||
return
|
||||
}//}}}
|
||||
updateLabel(newLabel, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/label', {
|
||||
ChecklistGroupID: this.ID,
|
||||
Label: newLabel,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
delete(okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_group/delete', {
|
||||
ChecklistGroupID: this.ID,
|
||||
})
|
||||
.then(() => {
|
||||
okCallback()
|
||||
})
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class ChecklistItem {
|
||||
static sort(a, b) {//{{{
|
||||
if (a.Order < b.Order) return -1
|
||||
if (a.Order > b.Order) return 1
|
||||
return 0
|
||||
}//}}}
|
||||
constructor(data) {//{{{
|
||||
Object.keys(data).forEach(key => {
|
||||
this[key] = data[key]
|
||||
})
|
||||
}//}}}
|
||||
updateState(newState, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/state', {
|
||||
ChecklistItemID: this.ID,
|
||||
State: newState,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
updateLabel(newLabel, okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/label', {
|
||||
ChecklistItemID: this.ID,
|
||||
Label: newLabel,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
delete(okCallback, errCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/delete', {
|
||||
ChecklistItemID: this.ID,
|
||||
})
|
||||
.then(() => {
|
||||
this.checklistGroup.items = this.checklistGroup.items.filter(item => item.ID != this.ID)
|
||||
okCallback()
|
||||
})
|
||||
.catch(errCallback)
|
||||
}//}}}
|
||||
move(to, okCallback) {//{{{
|
||||
window._app.current.request('/node/checklist_item/move', {
|
||||
ChecklistItemID: this.ID,
|
||||
AfterItemID: to.ID,
|
||||
})
|
||||
.then(okCallback)
|
||||
.catch(_app.current.responseError)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
export class Checklist extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.edit = signal(false)
|
||||
this.dragItemSource = null
|
||||
this.dragItemTarget = null
|
||||
this.groupElements = {}
|
||||
this.state = {
|
||||
confirmDeletion: true,
|
||||
continueAddingItems: true,
|
||||
}
|
||||
window._checklist = this
|
||||
}//}}}
|
||||
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
|
||||
this.groupElements = {}
|
||||
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
|
||||
return
|
||||
|
||||
if (typeof groups.sort != 'function')
|
||||
groups = []
|
||||
|
||||
groups.sort(ChecklistGroup.sort)
|
||||
let groupElements = groups.map(group => {
|
||||
this.groupElements[group.ID] = createRef()
|
||||
return html`<${ChecklistGroupElement} ref=${this.groupElements[group.ID]} key="group-${group.ID}" ui=${this} group=${group} />`
|
||||
})
|
||||
|
||||
let edit = 'edit-list-gray.svg'
|
||||
let confirmDeletionEl = ''
|
||||
if (this.edit.value) {
|
||||
edit = 'edit-list.svg'
|
||||
confirmDeletionEl = html`
|
||||
<div>
|
||||
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
|
||||
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="checkbox" id="continue-adding-items" checked=${continueAddingItems} onchange=${() => this.setState({ continueAddingItems: !continueAddingItems })} />
|
||||
<label for="continue-adding-items">Continue adding items</label>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
let addGroup = () => {
|
||||
if (this.edit.value)
|
||||
return html`<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addGroup()} />`
|
||||
}
|
||||
|
||||
return html`
|
||||
<div id="checklist">
|
||||
<div class="header">
|
||||
<h1>Checklist</h1>
|
||||
<img src="/images/${_VERSION}/${edit}" onclick=${() => this.toggleEdit()} />
|
||||
<${addGroup} />
|
||||
</div>
|
||||
${confirmDeletionEl}
|
||||
${groupElements}
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
|
||||
toggleEdit() {//{{{
|
||||
this.edit.value = !this.edit.value
|
||||
}//}}}
|
||||
addGroup() {//{{{
|
||||
let label = prompt("Create a new group")
|
||||
if (label === null)
|
||||
return
|
||||
label = label.trim()
|
||||
if (label == '')
|
||||
return
|
||||
|
||||
window._app.current.request('/node/checklist_group/add', {
|
||||
NodeID: window._app.current.nodeUI.current.node.value.ID,
|
||||
Label: label,
|
||||
})
|
||||
.then(json => {
|
||||
let group = new ChecklistGroup(json.Group)
|
||||
this.props.groups.push(group)
|
||||
this.forceUpdate()
|
||||
})
|
||||
.catch(window._app.current.responseError)
|
||||
return
|
||||
}//}}}
|
||||
dragTarget(target) {//{{{
|
||||
if (this.dragItemTarget)
|
||||
this.dragItemTarget.setDragTarget(false)
|
||||
this.dragItemTarget = target
|
||||
target.setDragTarget(true)
|
||||
}//}}}
|
||||
dragReset() {//{{{
|
||||
if (this.dragItemTarget) {
|
||||
this.dragItemTarget.setDragTarget(false)
|
||||
this.dragItemTarget = null
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class InputElement extends Component {
|
||||
render({ placeholder, label }) {//{{{
|
||||
return html`
|
||||
<dialog id="input-text">
|
||||
<div class="container">
|
||||
<div class="label">${label}</div>
|
||||
<input id="input-text-el" type="text" placeholder=${placeholder} />
|
||||
<div class="buttons">
|
||||
<div></div>
|
||||
<button onclick=${()=>this.cancel()}>Cancel</button>
|
||||
<button onclick=${()=>this.ok()}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
const dlg = document.getElementById('input-text')
|
||||
const input = document.getElementById('input-text-el')
|
||||
dlg.showModal()
|
||||
dlg.addEventListener("keydown", evt => this.keyhandler(evt))
|
||||
input.addEventListener("keydown", evt => this.keyhandler(evt))
|
||||
input.focus()
|
||||
}//}}}
|
||||
ok() {//{{{
|
||||
const input = document.getElementById('input-text-el')
|
||||
this.props.callback(true, input.value)
|
||||
}//}}}
|
||||
cancel() {//{{{
|
||||
this.props.callback(false)
|
||||
}//}}}
|
||||
keyhandler(evt) {//{{{
|
||||
let handled = true
|
||||
switch (evt.key) {
|
||||
case 'Enter':
|
||||
this.ok()
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
this.cancel()
|
||||
break;
|
||||
default:
|
||||
handled = false
|
||||
}
|
||||
if (handled) {
|
||||
evt.stopPropagation()
|
||||
evt.preventDefault()
|
||||
}
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ChecklistGroupElement extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.label = createRef()
|
||||
this.addingItem = signal(false)
|
||||
}//}}}
|
||||
render({ ui, group }) {//{{{
|
||||
let items = ({ ui, group }) =>
|
||||
group.items
|
||||
.sort(ChecklistItem.sort)
|
||||
.map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`)
|
||||
|
||||
let label = () => html`<div class="label" style="cursor: pointer" ref=${this.label} onclick=${() => this.editLabel()}>${group.Label}</div>`
|
||||
let addItem = () => {
|
||||
if (this.addingItem.value)
|
||||
return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />`
|
||||
}
|
||||
|
||||
return html`
|
||||
<${addItem} />
|
||||
<div class="checklist-group-container">
|
||||
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
|
||||
<div class="reorder" style="cursor: grab">☰</div>
|
||||
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
||||
<${label} />
|
||||
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
|
||||
</div>
|
||||
<${items} ui=${ui} group=${group} />
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
addItem(ok, label) {//{{{
|
||||
if (!ok) {
|
||||
this.addingItem.value = false
|
||||
return
|
||||
}
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
this.addingItem.value = false
|
||||
return
|
||||
}
|
||||
|
||||
this.props.group.addItem(label, () => {
|
||||
this.forceUpdate()
|
||||
})
|
||||
|
||||
if (!this.props.ui.state.continueAddingItems)
|
||||
this.addingItem.value = false
|
||||
}//}}}
|
||||
editLabel() {//{{{
|
||||
let label = prompt('Edit label', this.props.group.Label)
|
||||
if (label === null)
|
||||
return
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
alert(`A label can't be empty.`)
|
||||
return
|
||||
}
|
||||
|
||||
this.label.current.classList.remove('error')
|
||||
this.props.group.updateLabel(label, () => {
|
||||
this.props.group.Label = label
|
||||
this.label.current.innerHTML = label
|
||||
this.label.current.classList.add('ok')
|
||||
this.forceUpdate()
|
||||
setTimeout(() => this.label.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.label.current.classList.add('error')
|
||||
})
|
||||
|
||||
}//}}}
|
||||
delete() {//{{{
|
||||
if (this.props.ui.state.confirmDeletion) {
|
||||
if (!confirm(`Delete '${this.props.group.Label}'?`))
|
||||
return
|
||||
}
|
||||
|
||||
this.props.group.delete(() => {
|
||||
this.props.ui.props.groups = this.props.ui.props.groups.filter(g => g.ID != this.props.group.ID)
|
||||
this.props.ui.forceUpdate()
|
||||
}, err => {
|
||||
console.log(err)
|
||||
console.log('error')
|
||||
})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ChecklistItemElement extends Component {
|
||||
constructor(props) {//{{{
|
||||
super(props)
|
||||
this.state = {
|
||||
checked: props.item.Checked,
|
||||
dragTarget: false,
|
||||
}
|
||||
this.checkbox = createRef()
|
||||
this.label = createRef()
|
||||
}//}}}
|
||||
render({ ui, item }, { checked, dragTarget }) {//{{{
|
||||
let checkbox = () => {
|
||||
if (ui.edit.value)
|
||||
return html`<label ref=${this.label} onclick=${() => this.editLabel()} style="cursor: pointer">${item.Label}</label>`
|
||||
else
|
||||
return html`
|
||||
<input type="checkbox" ref=${this.checkbox} key="checkbox-${item.ID}" id="checkbox-${item.ID}" checked=${checked} onchange=${evt => this.update(evt.target.checked)} />
|
||||
<label ref=${this.label} for="checkbox-${item.ID}">${item.Label}</label>
|
||||
`
|
||||
}
|
||||
return html`
|
||||
<div class="checklist-item ${checked ? 'checked' : ''} ${ui.edit.value ? 'edit' : ''} ${dragTarget ? 'drag-target' : ''}" draggable=true>
|
||||
<div class="reorder" style="user-select: none;">☰</div>
|
||||
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
|
||||
<${checkbox} />
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
|
||||
this.base.addEventListener('dragend', () => this.dragEnd())
|
||||
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
|
||||
}//}}}
|
||||
|
||||
update(checked) {//{{{
|
||||
this.setState({ checked })
|
||||
this.checkbox.current.classList.remove('error')
|
||||
this.props.item.updateState(checked, () => {
|
||||
this.checkbox.current.classList.add('ok')
|
||||
setTimeout(() => this.checkbox.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.checkbox.current.classList.add('error')
|
||||
})
|
||||
}//}}}
|
||||
editLabel() {//{{{
|
||||
let label = prompt('Edit label', this.props.item.Label)
|
||||
if (label === null)
|
||||
return
|
||||
|
||||
label = label.trim()
|
||||
if (label == '') {
|
||||
alert(`A label can't be empty.`)
|
||||
return
|
||||
}
|
||||
|
||||
this.label.current.classList.remove('error')
|
||||
this.props.item.updateLabel(label, () => {
|
||||
this.props.item.Label = label
|
||||
this.label.current.innerHTML = label
|
||||
this.label.current.classList.add('ok')
|
||||
setTimeout(() => this.label.current.classList.remove('ok'), 500)
|
||||
}, () => {
|
||||
this.label.current.classList.add('error')
|
||||
})
|
||||
|
||||
}//}}}
|
||||
delete() {//{{{
|
||||
if (this.props.ui.state.confirmDeletion) {
|
||||
if (!confirm(`Delete '${this.props.item.Label}'?`))
|
||||
return
|
||||
}
|
||||
|
||||
this.props.item.delete(() => {
|
||||
this.props.group.forceUpdate()
|
||||
}, err => {
|
||||
console.log(err)
|
||||
console.log('error')
|
||||
})
|
||||
}//}}}
|
||||
|
||||
setDragTarget(state) {//{{{
|
||||
this.setState({ dragTarget: state })
|
||||
}//}}}
|
||||
dragStart(evt) {//{{{
|
||||
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
|
||||
this.props.ui.dragReset()
|
||||
this.props.ui.dragItemSource = this
|
||||
|
||||
const img = new Image();
|
||||
evt.dataTransfer.setDragImage(img, 10, 10);
|
||||
}//}}}
|
||||
dragEnter(evt) {//{{{
|
||||
evt.preventDefault()
|
||||
this.props.ui.dragTarget(this)
|
||||
}//}}}
|
||||
dragEnd() {//{{{
|
||||
let groups = this.props.ui.props.groups
|
||||
let from = this.props.ui.dragItemSource.props.item
|
||||
let to = this.props.ui.dragItemTarget.props.item
|
||||
|
||||
this.props.ui.dragReset()
|
||||
|
||||
if (from.ID == to.ID)
|
||||
return
|
||||
|
||||
let fromGroup = groups.find(g => g.ID == from.GroupID)
|
||||
let toGroup = groups.find(g => g.ID == to.GroupID)
|
||||
|
||||
|
||||
from.Order = to.Order
|
||||
from.GroupID = toGroup.ID
|
||||
toGroup.items.forEach(i => {
|
||||
if (i.ID == from.ID)
|
||||
return
|
||||
if (i.Order <= to.Order)
|
||||
i.Order--
|
||||
})
|
||||
|
||||
if (fromGroup.ID != toGroup.ID) {
|
||||
fromGroup.items = fromGroup.items.filter(i => i.ID != from.ID)
|
||||
toGroup.items.push(from)
|
||||
}
|
||||
|
||||
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
|
||||
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
|
||||
|
||||
from.move(to, () => {})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
6
static/js/lib/fullcalendar.min.js
vendored
Normal file
|
|
@ -3,7 +3,7 @@ import htm from 'htm'
|
|||
import { signal } from 'preact/signals'
|
||||
import { Keys, Key } from 'key'
|
||||
import Crypto from 'crypto'
|
||||
//import { marked } from 'marked'
|
||||
import { Checklist, ChecklistGroup } from 'checklist'
|
||||
const html = htm.bind(h)
|
||||
|
||||
export class NodeUI extends Component {
|
||||
|
|
@ -12,8 +12,8 @@ export class NodeUI extends Component {
|
|||
this.menu = signal(false)
|
||||
this.node = signal(null)
|
||||
this.nodeContent = createRef()
|
||||
this.nodeProperties = createRef()
|
||||
this.keys = signal([])
|
||||
|
||||
this.page = signal('node')
|
||||
window.addEventListener('popstate', evt => {
|
||||
if (evt.state && evt.state.hasOwnProperty('nodeID'))
|
||||
|
|
@ -58,18 +58,22 @@ export class NodeUI extends Component {
|
|||
case 'node':
|
||||
if (node.ID == 0) {
|
||||
page = html`
|
||||
<div style="cursor: pointer; color: #000; text-align: center;" onclick=${() => this.page.value = 'schedule-events'}>Schedule events</div>
|
||||
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
|
||||
`
|
||||
} else {
|
||||
let padlock = ''
|
||||
if (node.CryptoKeyID > 0)
|
||||
padlock = html`<img src="/images/${window._VERSION}/padlock-black.svg" style="height: 24px;" />`
|
||||
|
||||
page = html`
|
||||
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
|
||||
<div class="node-name">
|
||||
${node.Name} ${padlock}
|
||||
</div>
|
||||
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} />
|
||||
<${NodeEvents} events=${node.ScheduleEvents.value} />
|
||||
<${Checklist} ui=${this} groups=${node.ChecklistGroups} />
|
||||
<${NodeFiles} node=${this.node.value} />
|
||||
`
|
||||
}
|
||||
|
|
@ -80,7 +84,7 @@ export class NodeUI extends Component {
|
|||
break
|
||||
|
||||
case 'node-properties':
|
||||
page = html`<${NodeProperties} nodeui=${this} />`
|
||||
page = html`<${NodeProperties} ref=${this.nodeProperties} nodeui=${this} />`
|
||||
break
|
||||
|
||||
case 'keys':
|
||||
|
|
@ -94,18 +98,26 @@ export class NodeUI extends Component {
|
|||
case 'search':
|
||||
page = html`<${Search} nodeui=${this} />`
|
||||
break
|
||||
|
||||
case 'schedule-events':
|
||||
page = html`<${ScheduleEventList} nodeui=${this} />`
|
||||
break
|
||||
}
|
||||
|
||||
let menu = ''
|
||||
if (this.menu.value)
|
||||
menu = html`<${Menu} nodeui=${this} />`
|
||||
let menu = () => (this.menu.value ? html`<${Menu} nodeui=${this} />` : null)
|
||||
let checklist = () =>
|
||||
html`
|
||||
<div class="checklist" onclick=${evt => { evt.stopPropagation(); this.toggleChecklist() }}>
|
||||
<img src="/images/${window._VERSION}/${this.showChecklist() ? 'checklist-on.svg' : 'checklist-off.svg'}" />
|
||||
</div>`
|
||||
|
||||
return html`
|
||||
${menu}
|
||||
<${menu} />
|
||||
<header class="${modified}" onclick=${() => this.saveNode()}>
|
||||
<div class="tree"><img src="/images/${window._VERSION}/tree.svg" onclick=${() => document.getElementById('app').classList.toggle('toggle-tree')} /></div>
|
||||
<div class="name">Notes</div>
|
||||
<div class="markdown" onclick=${evt => { evt.stopPropagation(); this.toggleMarkdown() }}><img src="/images/${window._VERSION}/markdown.svg" /></div>
|
||||
<div class="markdown" onclick=${evt => { evt.stopPropagation(); this.toggleMarkdown() }}><img src="/images/${window._VERSION}/${node.RenderMarkdown.value ? 'markdown.svg' : 'markdown-hollow.svg'}" /></div>
|
||||
<${checklist} />
|
||||
<div class="search" onclick=${evt => { evt.stopPropagation(); this.showPage('search') }}><img src="/images/${window._VERSION}/search.svg" /></div>
|
||||
<div class="add" onclick=${evt => this.createNode(evt)}><img src="/images/${window._VERSION}/add.svg" /></div>
|
||||
<div class="keys" onclick=${evt => { evt.stopPropagation(); this.showPage('keys') }}><img src="/images/${window._VERSION}/padlock.svg" /></div>
|
||||
|
|
@ -143,6 +155,10 @@ export class NodeUI extends Component {
|
|||
return
|
||||
|
||||
switch (evt.key.toUpperCase()) {
|
||||
case 'C':
|
||||
this.showPage('node')
|
||||
break
|
||||
|
||||
case 'E':
|
||||
this.showPage('keys')
|
||||
break
|
||||
|
|
@ -160,7 +176,10 @@ export class NodeUI extends Component {
|
|||
break
|
||||
|
||||
case 'S':
|
||||
this.saveNode()
|
||||
if (this.page.value == 'node')
|
||||
this.saveNode()
|
||||
else if (this.page.value == 'node-properties')
|
||||
this.nodeProperties.current.save()
|
||||
break
|
||||
|
||||
case 'U':
|
||||
|
|
@ -231,15 +250,12 @@ export class NodeUI extends Component {
|
|||
})
|
||||
}//}}}
|
||||
saveNode() {//{{{
|
||||
/*
|
||||
let nodeContent = this.nodeContent.current
|
||||
if (this.page.value != 'node' || nodeContent === null)
|
||||
return
|
||||
*/
|
||||
|
||||
let content = this.node.value.content()
|
||||
this.node.value.setContent(content)
|
||||
this.node.value.save(() => this.props.app.nodeModified.value = false)
|
||||
this.node.value.save(() => {
|
||||
this.props.app.nodeModified.value = false
|
||||
this.node.value.retrieve()
|
||||
})
|
||||
}//}}}
|
||||
renameNode() {//{{{
|
||||
let name = prompt("New name")
|
||||
|
|
@ -286,8 +302,14 @@ export class NodeUI extends Component {
|
|||
showPage(pg) {//{{{
|
||||
this.page.value = pg
|
||||
}//}}}
|
||||
showChecklist() {//{{{
|
||||
return (this.node.value.ChecklistGroups && this.node.value.ChecklistGroups.length > 0) | this.node.value.ShowChecklist.value
|
||||
}//}}}
|
||||
toggleChecklist() {//{{{
|
||||
this.node.value.ShowChecklist.value = !this.node.value.ShowChecklist.value
|
||||
}//}}}
|
||||
toggleMarkdown() {//{{{
|
||||
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
|
||||
this.node.value.RenderMarkdown.value = !this.node.value.RenderMarkdown.value
|
||||
}//}}}
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +333,7 @@ class NodeContent extends Component {
|
|||
|
||||
var element
|
||||
if (node.RenderMarkdown.value)
|
||||
element = html`<div id="markdown"></div>`
|
||||
element = html`<${MarkdownContent} key='markdown-content' content=${content} />`
|
||||
else
|
||||
element = html`
|
||||
<div class="grow-wrap">
|
||||
|
|
@ -322,18 +344,10 @@ class NodeContent extends Component {
|
|||
return element
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
const markdown = document.getElementById('markdown')
|
||||
if (markdown)
|
||||
markdown.innerHTML = marked.parse(this.props.node.content())
|
||||
|
||||
this.resize()
|
||||
window.addEventListener('resize', () => this.resize())
|
||||
}//}}}
|
||||
componentDidUpdate() {//{{{
|
||||
const markdown = document.getElementById('markdown')
|
||||
if (markdown)
|
||||
markdown.innerHTML = marked.parse(this.props.node.content())
|
||||
|
||||
this.resize()
|
||||
}//}}}
|
||||
contentChanged(evt) {//{{{
|
||||
|
|
@ -346,16 +360,6 @@ class NodeContent extends Component {
|
|||
let textarea = document.getElementById('node-content')
|
||||
if (textarea)
|
||||
textarea.parentNode.dataset.replicatedValue = textarea.value
|
||||
|
||||
let crumbsEl = document.getElementById('crumbs')
|
||||
let markdown = document.getElementById('markdown')
|
||||
if (markdown) {
|
||||
let margins = (crumbsEl.clientWidth - 900) / 2.0
|
||||
if (margins < 0)
|
||||
margins = 0
|
||||
markdown.style.marginLeft = `${margins}px`
|
||||
markdown.style.marginRight = `${margins}px`
|
||||
}
|
||||
}//}}}
|
||||
unlock() {//{{{
|
||||
let pass = prompt(`Password for "${this.props.model.description}"`)
|
||||
|
|
@ -371,6 +375,35 @@ class NodeContent extends Component {
|
|||
}//}}}
|
||||
}
|
||||
|
||||
class MarkdownContent extends Component {
|
||||
render({ content }) {//{{{
|
||||
return html`<div id="markdown"></div>`
|
||||
}//}}}
|
||||
componentDidMount() {//{{{
|
||||
const markdown = document.getElementById('markdown')
|
||||
if (markdown)
|
||||
markdown.innerHTML = marked.parse(this.props.content)
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class NodeEvents extends Component {
|
||||
render({ events }) {//{{{
|
||||
if (events.length == 0)
|
||||
return html``
|
||||
|
||||
const eventElements = events.map(evt => {
|
||||
const dt = evt.Time.split('T')
|
||||
return html`<div>${dt[0]} ${dt[1].slice(0, 5)}</div>`
|
||||
})
|
||||
return html`
|
||||
<div id="schedule-section">
|
||||
<div class="header">Schedule events</div>
|
||||
${eventElements}
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class NodeFiles extends Component {
|
||||
render({ node }) {//{{{
|
||||
if (node.Files === null || node.Files.length == 0)
|
||||
|
|
@ -417,16 +450,24 @@ export class Node {
|
|||
this.Name = ''
|
||||
this.RenderMarkdown = signal(false)
|
||||
this.Markdown = false
|
||||
this.ShowChecklist = signal(false)
|
||||
this._content = ''
|
||||
this.Children = []
|
||||
this.Crumbs = []
|
||||
this.Files = []
|
||||
this._decrypted = false
|
||||
this._expanded = false // start value for the TreeNode component,
|
||||
this.ChecklistGroups = {}
|
||||
this.ScheduleEvents = signal([])
|
||||
// it doesn't control it afterwards.
|
||||
// Used to expand the crumbs upon site loading.
|
||||
}//}}}
|
||||
retrieve(callback) {//{{{
|
||||
this.app.request('/schedule/list', { NodeID: this.ID })
|
||||
.then(res => {
|
||||
this.ScheduleEvents.value = res.ScheduleEvents
|
||||
})
|
||||
|
||||
this.app.request('/node/retrieve', { ID: this.ID })
|
||||
.then(res => {
|
||||
this.ParentID = res.Node.ParentID
|
||||
|
|
@ -439,6 +480,7 @@ export class Node {
|
|||
this.Files = res.Node.Files
|
||||
this.Markdown = res.Node.Markdown
|
||||
this.RenderMarkdown.value = this.Markdown
|
||||
this.initChecklist(res.Node.ChecklistGroups)
|
||||
callback(this)
|
||||
})
|
||||
.catch(this.app.responseError)
|
||||
|
|
@ -469,6 +511,7 @@ export class Node {
|
|||
Content: this._content,
|
||||
CryptoKeyID: this.CryptoKeyID,
|
||||
Markdown: this.Markdown,
|
||||
TimeOffset: -(new Date().getTimezoneOffset()),
|
||||
}
|
||||
this.app.request('/node/update', req)
|
||||
.then(callback)
|
||||
|
|
@ -598,6 +641,13 @@ export class Node {
|
|||
this._decrypted = false
|
||||
return this._content
|
||||
}//}}}
|
||||
initChecklist(checklistData) {//{{{
|
||||
if (checklistData === undefined || checklistData === null)
|
||||
return
|
||||
this.ChecklistGroups = checklistData.map(groupData => {
|
||||
return new ChecklistGroup(groupData)
|
||||
})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class Menu extends Component {
|
||||
|
|
@ -751,10 +801,13 @@ class NodeProperties extends Component {
|
|||
<div id="properties">
|
||||
<h1>Note properties</h1>
|
||||
|
||||
These properties are only for this note.
|
||||
<div style="margin-bottom: 16px">These properties are only for this note.</div>
|
||||
|
||||
<h2>Markdown</h2>
|
||||
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt=>nodeui.node.value.Markdown = evt.target.checked} /> <label for="render-markdown">Render this node with markdown.</label>
|
||||
<div class="checks">
|
||||
<input type="checkbox" id="render-markdown" checked=${nodeui.node.value.Markdown} onchange=${evt => nodeui.node.value.Markdown = evt.target.checked} />
|
||||
<label for="render-markdown">Markdown view</label>
|
||||
|
||||
</div>
|
||||
|
||||
<h2>Encryption</h2>
|
||||
<div class="key">
|
||||
|
|
@ -932,4 +985,133 @@ class ProfileSettings extends Component {
|
|||
}//}}}
|
||||
}
|
||||
|
||||
class ScheduleEventList extends Component {
|
||||
static CALENDAR = Symbol('CALENDAR')
|
||||
static LIST = Symbol('LIST')
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.tab = signal(ScheduleEventList.CALENDAR)
|
||||
}//}}}
|
||||
render() {//{{{
|
||||
var tab
|
||||
switch (this.tab.value) {
|
||||
case ScheduleEventList.CALENDAR:
|
||||
tab = html`<${ScheduleCalendarTab} />`
|
||||
break;
|
||||
case ScheduleEventList.LIST:
|
||||
tab = html`<${ScheduleEventListTab} />`
|
||||
break;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div style="margin: 32px">
|
||||
<div class="folder">
|
||||
<div class="tabs">
|
||||
<div onclick=${() => this.tab.value = ScheduleEventList.CALENDAR} class="tab ${this.tab.value == ScheduleEventList.CALENDAR ? 'selected' : ''}">Calendar</div>
|
||||
<div onclick=${() => this.tab.value = ScheduleEventList.LIST} class="tab ${this.tab.value == ScheduleEventList.LIST ? 'selected' : ''}">List</div>
|
||||
<div class="hack"></div>
|
||||
</div>
|
||||
<div class="content">
|
||||
${tab}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ScheduleEventListTab extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
this.events = signal(null)
|
||||
this.retrieveFutureEvents()
|
||||
}//}}}
|
||||
render() {//{{{
|
||||
if (this.events.value === null)
|
||||
return
|
||||
|
||||
let events = this.events.value.sort((a, b) => {
|
||||
if (a.Time < b.Time) return -1
|
||||
if (a.Time > b.Time) return 1
|
||||
return 0
|
||||
}).map(evt => {
|
||||
const dt = evt.Time.split('T')
|
||||
const remind = () => {
|
||||
if (evt.RemindMinutes > 0)
|
||||
return html`${evt.RemindMinutes} min`
|
||||
}
|
||||
const nodeLink = () => html`<a href="/?node=${evt.Node.ID}">${evt.Node.Name}</a>`
|
||||
|
||||
|
||||
return html`
|
||||
<div class="date">${dt[0]}</div>
|
||||
<div class="time">${dt[1].slice(0, 5)}</div>
|
||||
<div class="remind"><${remind} /></div>
|
||||
<div class="description">${evt.Description}</div>
|
||||
<div class="node"><${nodeLink} /></div>
|
||||
`
|
||||
})
|
||||
|
||||
return html`
|
||||
<div id="schedule-events">
|
||||
<div class="header">Date</div>
|
||||
<div class="header">Time</div>
|
||||
<div class="header">Reminder</div>
|
||||
<div class="header">Event</div>
|
||||
<div class="header">Node</div>
|
||||
${events}
|
||||
</div>
|
||||
`
|
||||
}//}}}
|
||||
retrieveFutureEvents() {//{{{
|
||||
_app.current.request('/schedule/list')
|
||||
.then(data => {
|
||||
this.events.value = data.ScheduleEvents
|
||||
})
|
||||
}//}}}
|
||||
}
|
||||
|
||||
class ScheduleCalendarTab extends Component {
|
||||
constructor() {//{{{
|
||||
super()
|
||||
}//}}}
|
||||
componentDidMount() {
|
||||
let calendarEl = document.getElementById('fullcalendar');
|
||||
this.calendar = new FullCalendar.Calendar(calendarEl, {
|
||||
initialView: 'dayGridMonth',
|
||||
events: this.events,
|
||||
eventTimeFormat: {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
},
|
||||
firstDay: 1,
|
||||
aspectRatio: 2.5,
|
||||
});
|
||||
this.calendar.render();
|
||||
}
|
||||
render() {
|
||||
return html`<div id="fullcalendar"></div>`
|
||||
}
|
||||
events(info, successCallback, failureCallback) {
|
||||
const req = {
|
||||
StartDate: info.startStr,
|
||||
EndDate: info.endStr,
|
||||
}
|
||||
_app.current.request('/schedule/list', req)
|
||||
.then(data => {
|
||||
const fullcalendarEvents = data.ScheduleEvents.map(sch => {
|
||||
return {
|
||||
title: sch.Description,
|
||||
start: sch.Time,
|
||||
url: `/?node=${sch.Node.ID}`,
|
||||
}
|
||||
})
|
||||
successCallback(fullcalendarEvents)
|
||||
})
|
||||
.catch(err=>failureCallback(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
|
|
@ -8,10 +8,18 @@ html {
|
|||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
*,*:focus,*:hover{
|
||||
outline:none;
|
||||
}
|
||||
|
||||
[onClick] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
|
|
@ -23,13 +31,18 @@ html, body {
|
|||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0px;
|
||||
font-size: 1.5em;
|
||||
font-size: 1.25em;
|
||||
color: @header_1;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 32px;
|
||||
font-size: 1.25em;
|
||||
font-size: 1.0em;
|
||||
color: @header_1;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.0em;
|
||||
}
|
||||
|
||||
button {
|
||||
|
|
@ -157,12 +170,18 @@ button {
|
|||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.checks {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-gap: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
grid-area: header;
|
||||
grid-template-columns: min-content 1fr repeat(4, min-content);
|
||||
grid-template-columns: min-content 1fr repeat(6, min-content);
|
||||
align-items: center;
|
||||
padding: 8px 0px;
|
||||
color: darken(@accent_1, 35%);
|
||||
|
|
@ -189,7 +208,7 @@ header {
|
|||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown, .search, .add, .keys {
|
||||
.markdown, .checklist, .search, .add, .keys {
|
||||
padding-right: 16px;
|
||||
|
||||
img {
|
||||
|
|
@ -212,6 +231,7 @@ header {
|
|||
padding: 16px;
|
||||
background-color: #333;
|
||||
color: #ddd;
|
||||
z-index: 100; // Over crumbs shadow
|
||||
|
||||
.node {
|
||||
display: grid;
|
||||
|
|
@ -352,10 +372,17 @@ header {
|
|||
}
|
||||
|
||||
#markdown {
|
||||
padding: 16px;
|
||||
color: #333;
|
||||
grid-area: content;
|
||||
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
|
|
@ -365,6 +392,191 @@ header {
|
|||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: #e6eeee;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
pre > code {
|
||||
background: unset;
|
||||
padding: 0px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
#checklist {
|
||||
grid-area: checklist;
|
||||
color: #333;
|
||||
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0px;
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 0 16px;
|
||||
|
||||
img {
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.header + .checklist-group.edit {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.checklist-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
align-items: center;
|
||||
grid-gap: 0 8px;
|
||||
|
||||
margin-top: 1em;
|
||||
margin-bottom: 8px;
|
||||
font-weight: bold;
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label.ok {
|
||||
color: #54b356;
|
||||
}
|
||||
|
||||
.label.error {
|
||||
color: #d13636;
|
||||
}
|
||||
|
||||
&.edit {
|
||||
margin-top: 32px;
|
||||
border-bottom: 1px solid #aaa;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
&:not(.edit) {
|
||||
.reorder { display: none; }
|
||||
img { display: none; }
|
||||
}
|
||||
|
||||
img {
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.checklist-item {
|
||||
transform: translate(0, 0);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, min-content);
|
||||
grid-gap: 0 8px;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
border-bottom: 2px solid #fff;
|
||||
|
||||
&.checked {
|
||||
text-decoration: line-through;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
&.drag-target {
|
||||
border-bottom: 2px solid #71c837;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: #fff;
|
||||
margin: 0 2px 0 0;
|
||||
font: inherit;
|
||||
color: currentColor;
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
border: 0.15em solid currentColor;
|
||||
border-radius: 0.15em;
|
||||
transform: translateY(-0.075em);
|
||||
|
||||
display: grid;
|
||||
place-content: center;
|
||||
}
|
||||
|
||||
label.ok {
|
||||
color: #54b356;
|
||||
}
|
||||
|
||||
label.error {
|
||||
color: #d13636;
|
||||
}
|
||||
|
||||
input[type="checkbox"].ok {
|
||||
border: 0.15em solid #54b356;
|
||||
}
|
||||
|
||||
input[type="checkbox"].ok::before {
|
||||
box-shadow: inset 1em 1em #54b356;
|
||||
}
|
||||
|
||||
input[type="checkbox"].error {
|
||||
border: 0.15em solid #d13636;
|
||||
}
|
||||
|
||||
input[type="checkbox"].error::before {
|
||||
box-shadow: inset 1em 1em #d13636;
|
||||
}
|
||||
|
||||
input[type="checkbox"]::before {
|
||||
content: "";
|
||||
width: 0.70em;
|
||||
height: 0.70em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em @checkbox_1;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
&.edit {
|
||||
input[type="checkbox"] {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.edit) {
|
||||
.reorder {
|
||||
display: none;
|
||||
}
|
||||
img {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================= *
|
||||
|
|
@ -410,7 +622,7 @@ header {
|
|||
}
|
||||
/* ============================================================= */
|
||||
|
||||
#file-section {
|
||||
#schedule-section {
|
||||
grid-area: files;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
|
|
@ -420,6 +632,25 @@ header {
|
|||
border-radius: 8px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
color: #000;
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
#file-section {
|
||||
grid-area: schedule;
|
||||
justify-self: center;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 900px;
|
||||
padding: 32px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
|
|
@ -543,6 +774,8 @@ header {
|
|||
"tree child-nodes"
|
||||
"tree name"
|
||||
"tree content"
|
||||
"tree checklist"
|
||||
"tree schedule"
|
||||
"tree files"
|
||||
"tree blank"
|
||||
;
|
||||
|
|
@ -553,6 +786,8 @@ header {
|
|||
min-content /* child-nodes */
|
||||
min-content /* name */
|
||||
min-content /* content */
|
||||
min-content /* checklist */
|
||||
min-content /* schedule */
|
||||
min-content /* files */
|
||||
1fr; /* blank */
|
||||
color: #fff;
|
||||
|
|
@ -585,6 +820,8 @@ header {
|
|||
"child-nodes"
|
||||
"name"
|
||||
"content"
|
||||
"checklist"
|
||||
"schedule"
|
||||
"files"
|
||||
"blank"
|
||||
;
|
||||
|
|
@ -595,9 +832,15 @@ header {
|
|||
min-content /* child-nodes */
|
||||
min-content /* name */
|
||||
min-content /* content */
|
||||
min-content /* checklist */
|
||||
min-content /* schedule */
|
||||
min-content /* files */
|
||||
1fr; /* blank */
|
||||
#tree { display: none }
|
||||
|
||||
#checklist {
|
||||
padding: 16px;
|
||||
}
|
||||
}// }}}
|
||||
.layout-keys {
|
||||
display: grid;
|
||||
|
|
@ -654,6 +897,82 @@ header {
|
|||
}
|
||||
}
|
||||
|
||||
#schedule-events {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
grid-gap: 4px 12px;
|
||||
margin: 32px;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
#input-text {
|
||||
border: 1px solid #000 !important;
|
||||
padding: 16px;
|
||||
width: 300px;
|
||||
|
||||
.label {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 64px 64px;
|
||||
grid-gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
#fullcalendar {
|
||||
margin: 32px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.folder {
|
||||
.tabs {
|
||||
border-left: 1px solid #888;
|
||||
display: flex;
|
||||
|
||||
.tab {
|
||||
padding: 16px 32px;
|
||||
border-top: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
border-right: 1px solid #888;
|
||||
color: #444;
|
||||
background: #eee;
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
border-bottom: none;
|
||||
background: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.hack {
|
||||
border-bottom: 1px solid #888;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1px;
|
||||
border-left: 1px solid #888;
|
||||
border-right: 1px solid #888;
|
||||
border-bottom: 1px solid #888;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 932px) {
|
||||
#app.node {
|
||||
.layout-crumbs();
|
||||
|
|
@ -669,7 +988,7 @@ header {
|
|||
justify-self: start;
|
||||
}
|
||||
|
||||
#file-section {
|
||||
#file-section, #checklist, #markdown {
|
||||
width: calc(100% - 32px);
|
||||
padding: 16px;
|
||||
margin-left: 16px;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
@accent_2: #ecbf00;
|
||||
@accent_3: #c84a37;
|
||||
|
||||
@header_1: #518048;
|
||||
@header_2: #518048;
|
||||
|
||||
@checkbox_1: #666;
|
||||
|
||||
/*
|
||||
@theme_gradient: linear-gradient(to right, #009fff, #ec2f4b);
|
||||
@theme_gradient: linear-gradient(to right, #f5af19, #f12711);
|
||||
|
|
|
|||
2
version
|
|
@ -1 +1 @@
|
|||
v16
|
||||
v29
|
||||
|
|
|
|||