- commit
- 2a79ed9
- parent
- 45e47ce
- author
- Eric Bower
- date
- 2025-02-24 07:47:08 -0500 EST
refactor(tui): use vaxis While we really enjoyed the charm stack for our ssh apps, we are at a point where we want to reduce our overall dependencies for our SSH apps. With charm we have: - `crypto/ssh` - `gliberlabs/ssh` - `charmbracelet/ssh` - `charmbracelet/wish` There's a lot that can go wrong here and we have seen quite a bit of thrashing within these libraries that required us to make moderate changes when upgrading. We also enjoyed bubbletea/lipgloss but we are at the point where we would like to switch to `vaxis` since it is more inline with our design ethos. We are basically going to replace 5 go packages with 1 and we are starting with the TUI.
M
db/db.go
+17,
-10
1@@ -141,14 +141,6 @@ type Paginate[T any] struct {
2 Total int
3 }
4
5-type Analytics struct {
6- TotalUsers int
7- UsersLastMonth int
8- TotalPosts int
9- PostsLastMonth int
10- UsersWithPost int
11-}
12-
13 type VisitInterval struct {
14 Interval *time.Time `json:"interval"`
15 Visitors int `json:"visitors"`
16@@ -316,6 +308,21 @@ func (m *ErrMultiplePublicKeys) Error() string {
17 return "there are multiple users with this public key, you must provide the username when using SSH: `ssh <user>@<domain>`\n"
18 }
19
20+type UserStats struct {
21+ Prose UserServiceStats
22+ Pastes UserServiceStats
23+ Feeds UserServiceStats
24+ Pages UserServiceStats
25+}
26+
27+type UserServiceStats struct {
28+ Service string
29+ Num int
30+ FirstCreatedAt time.Time
31+ LastestCreatedAt time.Time
32+ LatestUpdatedAt time.Time
33+}
34+
35 var NameValidator = regexp.MustCompile("^[a-zA-Z0-9]{1,50}$")
36 var DenyList = []string{
37 "admin",
38@@ -348,8 +355,6 @@ type DB interface {
39 FindKeysForUser(user *User) ([]*PublicKey, error)
40 RemoveKeys(pubkeyIDs []string) error
41
42- FindSiteAnalytics(space string) (*Analytics, error)
43-
44 FindUsers() ([]*User, error)
45 FindUserForName(name string) (*User, error)
46 FindUserForNameAndKey(name string, pubkey string) (*User, error)
47@@ -408,5 +413,7 @@ type DB interface {
48 UpsertProject(userID, name, projectDir string) (*Project, error)
49 FindProjectByName(userID, name string) (*Project, error)
50
51+ FindUserStats(userID string) (*UserStats, error)
52+
53 Close() error
54 }
+50,
-45
1@@ -152,12 +152,6 @@ const (
2 sqlSelectTokensForUser = `SELECT id, user_id, name, created_at, expires_at FROM tokens WHERE user_id = $1`
3 sqlSelectTokenByNameForUser = `SELECT token FROM tokens WHERE user_id = $1 AND name = $2`
4
5- sqlSelectTotalUsers = `SELECT count(id) FROM app_users`
6- sqlSelectUsersAfterDate = `SELECT count(id) FROM app_users WHERE created_at >= $1`
7- sqlSelectTotalPosts = `SELECT count(id) FROM posts WHERE cur_space = $1`
8- sqlSelectTotalPostsAfterDate = `SELECT count(id) FROM posts WHERE created_at >= $1 AND cur_space = $2`
9- sqlSelectUsersWithPost = `SELECT count(app_users.id) FROM app_users WHERE EXISTS (SELECT 1 FROM posts WHERE user_id = app_users.id AND cur_space = $1);`
10-
11 sqlSelectFeatureForUser = `SELECT id, user_id, payment_history_id, name, data, created_at, expires_at FROM feature_flags WHERE user_id = $1 AND name = $2 ORDER BY expires_at DESC LIMIT 1`
12 sqlSelectSizeForUser = `SELECT COALESCE(sum(file_size), 0) FROM posts WHERE user_id = $1`
13
14@@ -533,45 +527,6 @@ func (me *PsqlDB) RemoveKeys(keyIDs []string) error {
15 return err
16 }
17
18-func (me *PsqlDB) FindSiteAnalytics(space string) (*db.Analytics, error) {
19- analytics := &db.Analytics{}
20- r := me.Db.QueryRow(sqlSelectTotalUsers)
21- err := r.Scan(&analytics.TotalUsers)
22- if err != nil {
23- return nil, err
24- }
25-
26- r = me.Db.QueryRow(sqlSelectTotalPosts, space)
27- err = r.Scan(&analytics.TotalPosts)
28- if err != nil {
29- return nil, err
30- }
31-
32- now := time.Now()
33- year, month, _ := now.Date()
34- begMonth := time.Date(year, month, 1, 0, 0, 0, 0, now.Location())
35-
36- r = me.Db.QueryRow(sqlSelectTotalPostsAfterDate, begMonth, space)
37- err = r.Scan(&analytics.PostsLastMonth)
38- if err != nil {
39- return nil, err
40- }
41-
42- r = me.Db.QueryRow(sqlSelectUsersAfterDate, begMonth)
43- err = r.Scan(&analytics.UsersLastMonth)
44- if err != nil {
45- return nil, err
46- }
47-
48- r = me.Db.QueryRow(sqlSelectUsersWithPost, space)
49- err = r.Scan(&analytics.UsersWithPost)
50- if err != nil {
51- return nil, err
52- }
53-
54- return analytics, nil
55-}
56-
57 func (me *PsqlDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post, error) {
58 // now := time.Now()
59 // expired := now.AddDate(0, 0, -3)
60@@ -1825,3 +1780,53 @@ func (me *PsqlDB) UpsertProject(userID, projectName, projectDir string) (*db.Pro
61 }
62 return me.FindProjectByName(userID, projectName)
63 }
64+
65+func (me *PsqlDB) findPagesStats(userID string) (*db.UserServiceStats, error) {
66+ stats := db.UserServiceStats{
67+ Service: "pgs",
68+ }
69+ err := me.Db.QueryRow(
70+ `SELECT count(id), min(created_at), max(created_at), max(updated_at) FROM projects WHERE user_id=$1`,
71+ userID,
72+ ).Scan(&stats.Num, &stats.FirstCreatedAt, &stats.LastestCreatedAt, &stats.LatestUpdatedAt)
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ return &stats, nil
78+}
79+
80+func (me *PsqlDB) FindUserStats(userID string) (*db.UserStats, error) {
81+ stats := db.UserStats{}
82+ rs, err := me.Db.Query(`SELECT cur_space, count(id), min(created_at), max(created_at), max(updated_at) FROM posts WHERE user_id=$1 GROUP BY cur_space`, userID)
83+ if err != nil {
84+ return nil, err
85+ }
86+
87+ for rs.Next() {
88+ stat := db.UserServiceStats{}
89+ err := rs.Scan(&stat.Service, &stat.Num, &stat.FirstCreatedAt, &stat.LastestCreatedAt, &stat.LatestUpdatedAt)
90+ if err != nil {
91+ return nil, err
92+ }
93+ switch stat.Service {
94+ case "prose":
95+ stats.Prose = stat
96+ case "pastes":
97+ stats.Pastes = stat
98+ case "feeds":
99+ stats.Feeds = stat
100+ }
101+ }
102+
103+ if rs.Err() != nil {
104+ return nil, rs.Err()
105+ }
106+
107+ pgs, err := me.findPagesStats(userID)
108+ if err != nil {
109+ return nil, err
110+ }
111+ stats.Pages = *pgs
112+ return &stats, err
113+}
+4,
-4
1@@ -57,10 +57,6 @@ func (me *StubDB) RemoveKeys(keyIDs []string) error {
2 return notImpl
3 }
4
5-func (me *StubDB) FindSiteAnalytics(space string) (*db.Analytics, error) {
6- return nil, notImpl
7-}
8-
9 func (me *StubDB) FindPostsBeforeDate(date *time.Time, space string) ([]*db.Post, error) {
10 return []*db.Post{}, notImpl
11 }
12@@ -268,3 +264,7 @@ func (me *StubDB) AddPicoPlusUser(username, email, paymentType, txId string) err
13 func (me *StubDB) FindTagsForUser(userID string, tag string) ([]string, error) {
14 return []string{}, notImpl
15 }
16+
17+func (me *StubDB) FindUserStats(userID string) (*db.UserStats, error) {
18+ return nil, notImpl
19+}
M
go.mod
+4,
-23
1@@ -4,32 +4,15 @@ go 1.24
2
3 toolchain go1.24.0
4
5-// replace github.com/picosh/tunkit => ../tunkit
6-
7 // replace github.com/picosh/send => ../send
8-
9-// replace github.com/picosh/go-rsync-receiver => ../go-rsync-receiver
10-
11-// replace github.com/picosh/pobj => ../pobj
12-
13-// replace github.com/picosh/pubsub => ../pubsub
14-
15-// replace github.com/picosh/utils => ../utils
16-
17-// replace git.sr.ht/~delthas/senpai => ../../senpai
18-
19-// replace git.sr.ht/~rockorager/vaxis => ../../vaxis
20-
21-// replace github.com/charmbracelet/wish => ../../wish
22+// replace git.sr.ht/~rockorager/vaxis => ../../../src/vaxis
23
24 require (
25 git.sr.ht/~delthas/senpai v0.3.1-0.20250311003540-18f699aaf9b0
26+ git.sr.ht/~rockorager/vaxis v0.12.1-0.20250309233058-d6d466f8f9b1
27 github.com/alecthomas/chroma/v2 v2.14.0
28 github.com/antoniomika/syncmap v1.0.0
29 github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de
30- github.com/charmbracelet/bubbles v0.20.0
31- github.com/charmbracelet/bubbletea v1.3.4
32- github.com/charmbracelet/glamour v0.8.0
33 github.com/charmbracelet/lipgloss v1.0.0
34 github.com/charmbracelet/promwish v0.7.0
35 github.com/charmbracelet/ssh v0.0.0-20250213143314-8712ec3ff3ef
36@@ -48,7 +31,6 @@ require (
37 github.com/microcosm-cc/bluemonday v1.0.27
38 github.com/minio/minio-go/v7 v7.0.87
39 github.com/mmcdole/gofeed v1.3.0
40- github.com/muesli/reflow v0.3.0
41 github.com/muesli/termenv v0.16.0
42 github.com/neurosnap/go-exif-remove v0.0.0-20221010134343-50d1e3c35577
43 github.com/picosh/pobj v0.0.0-20250304201248-a9c7179aa49b
44@@ -77,7 +59,6 @@ require (
45 codeberg.org/emersion/go-scfg v0.1.0 // indirect
46 dario.cat/mergo v1.0.0 // indirect
47 filippo.io/edwards25519 v1.1.0 // indirect
48- git.sr.ht/~rockorager/vaxis v0.12.1-0.20250303214113-6cc9a0be8874 // indirect
49 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
50 github.com/Masterminds/goutils v1.1.1 // indirect
51 github.com/Masterminds/semver/v3 v3.2.0 // indirect
52@@ -92,7 +73,6 @@ require (
53 github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
54 github.com/armon/go-metrics v0.4.1 // indirect
55 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
56- github.com/atotto/clipboard v0.1.4 // indirect
57 github.com/aws/aws-sdk-go-v2 v1.36.2 // indirect
58 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
59 github.com/aws/aws-sdk-go-v2/config v1.29.7 // indirect
60@@ -124,11 +104,13 @@ require (
61 github.com/caddyserver/zerossl v0.1.3 // indirect
62 github.com/cespare/xxhash v1.1.0 // indirect
63 github.com/cespare/xxhash/v2 v2.3.0 // indirect
64+ github.com/charmbracelet/bubbletea v1.3.4 // indirect
65 github.com/charmbracelet/keygen v0.5.1 // indirect
66 github.com/charmbracelet/log v0.4.0 // indirect
67 github.com/charmbracelet/x/ansi v0.8.0 // indirect
68 github.com/charmbracelet/x/conpty v0.1.0 // indirect
69 github.com/charmbracelet/x/errors v0.0.0-20250226164017-59292a315e58 // indirect
70+ github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b // indirect
71 github.com/charmbracelet/x/input v0.3.1 // indirect
72 github.com/charmbracelet/x/term v0.2.1 // indirect
73 github.com/charmbracelet/x/termios v0.1.1 // indirect
74@@ -309,7 +291,6 @@ require (
75 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
76 github.com/xujiajun/mmap-go v1.0.1 // indirect
77 github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
78- github.com/yuin/goldmark-emoji v1.0.4 // indirect
79 github.com/yusufpapurcu/wmi v1.2.4 // indirect
80 github.com/zeebo/blake3 v0.2.3 // indirect
81 go.etcd.io/bbolt v1.3.9 // indirect
M
go.sum
+2,
-14
1@@ -20,8 +20,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
2 filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
3 git.sr.ht/~delthas/senpai v0.3.1-0.20250311003540-18f699aaf9b0 h1:Knm2mHQwLsh1svD15lE27Cr6BMV2wH2t0OKUoSCNhuY=
4 git.sr.ht/~delthas/senpai v0.3.1-0.20250311003540-18f699aaf9b0/go.mod h1:RzVz1R7QRHGcRDnJTcr7AN/cD3rj9scdgvupkXTJLYk=
5-git.sr.ht/~rockorager/vaxis v0.12.1-0.20250303214113-6cc9a0be8874 h1:J582m7egrvWSJGay9GUAASHnM8zqzRbCaU8P64/B7/U=
6-git.sr.ht/~rockorager/vaxis v0.12.1-0.20250303214113-6cc9a0be8874/go.mod h1:RSNtZnMeIwpyQzgIEYo9EHJb8Wcl/RhFSxypLpD/ajg=
7+git.sr.ht/~rockorager/vaxis v0.12.1-0.20250309233058-d6d466f8f9b1 h1:o8opVUAysn+cQAnSadL1BVMhr2YcdjynRzPBz4fa9q0=
8+git.sr.ht/~rockorager/vaxis v0.12.1-0.20250309233058-d6d466f8f9b1/go.mod h1:RSNtZnMeIwpyQzgIEYo9EHJb8Wcl/RhFSxypLpD/ajg=
9 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
10 github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
11 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
12@@ -77,8 +77,6 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+
13 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
14 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
15 github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
16-github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
17-github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
18 github.com/aws/aws-sdk-go-v2 v1.36.2 h1:Ub6I4lq/71+tPb/atswvToaLGVMxKZvjYDVOWEExOcU=
19 github.com/aws/aws-sdk-go-v2 v1.36.2/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
20 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
21@@ -152,12 +150,8 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
22 github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
23 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
24 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
25-github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
26-github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
27 github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
28 github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
29-github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
30-github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
31 github.com/charmbracelet/keygen v0.5.1 h1:zBkkYPtmKDVTw+cwUyY6ZwGDhRxXkEp0Oxs9sqMLqxI=
32 github.com/charmbracelet/keygen v0.5.1/go.mod h1:zznJVmK/GWB6dAtjluqn2qsttiCBhA5MZSiwb80fcHw=
33 github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
34@@ -610,7 +604,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
35 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
36 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
37 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
38-github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
39 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
40 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
41 github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8=
42@@ -671,8 +664,6 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
43 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
44 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
45 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
46-github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
47-github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
48 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
49 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
50 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
51@@ -926,11 +917,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
52 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
53 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
54 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
55-github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
56 github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
57 github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
58-github.com/yuin/goldmark-emoji v1.0.4 h1:vCwMkPZSNefSUnOW2ZKRUjBSD5Ok3W78IXhGxxAEF90=
59-github.com/yuin/goldmark-emoji v1.0.4/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
60 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
61 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
62 github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
+3,
-5
1@@ -14,7 +14,6 @@ import (
2 "github.com/picosh/pico/db"
3 pgsdb "github.com/picosh/pico/pgs/db"
4 "github.com/picosh/pico/shared"
5- "github.com/picosh/pico/tui/common"
6 sst "github.com/picosh/pobj/storage"
7 "github.com/picosh/utils"
8 )
9@@ -54,9 +53,9 @@ func projectTable(projects []*db.Project, width int) *table.Table {
10 return t
11 }
12
13-func getHelpText(styles common.Styles, width int) string {
14+func getHelpText(width int) string {
15 helpStr := "Commands: [help, stats, ls, fzf, rm, link, unlink, prune, retain, depends, acl, cache]\n"
16- helpStr += styles.Note.Render("NOTICE:") + " *must* append with `--write` for the changes to persist.\n"
17+ helpStr += "NOTICE:" + " *must* append with `--write` for the changes to persist.\n"
18
19 projectName := "projA"
20 headers := []string{"Cmd", "Description"}
21@@ -128,7 +127,6 @@ type Cmd struct {
22 Store sst.ObjectStorage
23 Dbpool pgsdb.PgsDB
24 Write bool
25- Styles common.Styles
26 Width int
27 Height int
28 Cfg *PgsConfig
29@@ -203,7 +201,7 @@ func (c *Cmd) RmProjectAssets(projectName string) error {
30 }
31
32 func (c *Cmd) help() {
33- c.output(getHelpText(c.Styles, c.Width))
34+ c.output(getHelpText(c.Width))
35 }
36
37 func (c *Cmd) stats(cfgMaxSize uint64) error {
+0,
-3
1@@ -12,7 +12,6 @@ import (
2 "github.com/muesli/termenv"
3 "github.com/picosh/pico/db"
4 pgsdb "github.com/picosh/pico/pgs/db"
5- "github.com/picosh/pico/tui/common"
6 sendutils "github.com/picosh/send/utils"
7 "github.com/picosh/utils"
8 )
9@@ -95,7 +94,6 @@ func WishMiddleware(handler *UploadAssetHandler) wish.Middleware {
10
11 renderer := bm.MakeRenderer(sesh)
12 renderer.SetColorProfile(termenv.TrueColor)
13- styles := common.DefaultStyles(renderer)
14
15 opts := Cmd{
16 Session: sesh,
17@@ -104,7 +102,6 @@ func WishMiddleware(handler *UploadAssetHandler) wish.Middleware {
18 Log: log,
19 Dbpool: dbpool,
20 Write: false,
21- Styles: styles,
22 Width: width,
23 Height: height,
24 Cfg: handler.Cfg,
+0,
-24
1@@ -12,9 +12,6 @@ import (
2 "github.com/charmbracelet/wish"
3 "github.com/picosh/pico/db"
4 "github.com/picosh/pico/shared"
5- "github.com/picosh/pico/tui/common"
6- "github.com/picosh/pico/tui/notifications"
7- "github.com/picosh/pico/tui/plus"
8 "github.com/picosh/utils"
9
10 pipeLogger "github.com/picosh/utils/pipe/log"
11@@ -46,7 +43,6 @@ type Cmd struct {
12 Log *slog.Logger
13 Dbpool db.DB
14 Write bool
15- Styles common.Styles
16 }
17
18 func (c *Cmd) output(out string) {
19@@ -58,17 +54,6 @@ func (c *Cmd) help() {
20 c.output(helpStr)
21 }
22
23-func (c *Cmd) plus() {
24- view := plus.PlusView(c.User.Name, 80)
25- c.output(view)
26-}
27-
28-func (c *Cmd) notifications() error {
29- md := notifications.NotificationsView(c.Dbpool, c.User.ID, 80)
30- c.output(md)
31- return nil
32-}
33-
34 func (c *Cmd) logs(ctx context.Context) error {
35 conn := shared.NewPicoPipeClient()
36 stdoutPipe, err := pipeLogger.ReadLogs(ctx, c.Log, conn)
37@@ -184,15 +169,6 @@ func WishMiddleware(handler *CliHandler) wish.Middleware {
38 wish.Fatalln(sesh, err)
39 }
40 return
41- } else if cmd == "pico+" {
42- opts.plus()
43- return
44- } else if cmd == "notifications" {
45- err := opts.notifications()
46- if err != nil {
47- wish.Fatalln(sesh, err)
48- }
49- return
50 } else {
51 next(sesh)
52 return
+26,
-16
1@@ -8,11 +8,10 @@ import (
2 "syscall"
3 "time"
4
5+ "git.sr.ht/~rockorager/vaxis"
6 "github.com/charmbracelet/promwish"
7 "github.com/charmbracelet/ssh"
8 "github.com/charmbracelet/wish"
9- bm "github.com/charmbracelet/wish/bubbletea"
10- "github.com/muesli/termenv"
11 "github.com/picosh/pico/db/postgres"
12 "github.com/picosh/pico/shared"
13 "github.com/picosh/pico/tui"
14@@ -27,39 +26,50 @@ import (
15 "github.com/picosh/utils"
16 )
17
18-func createRouter(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler) proxy.Router {
19- return func(sh ssh.Handler, s ssh.Session) []wish.Middleware {
20+func createRouterVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler) proxy.Router {
21+ return func(sh ssh.Handler, sesh ssh.Session) []wish.Middleware {
22+ shrd := &tui.SharedModel{
23+ Session: sesh,
24+ Cfg: cfg,
25+ Dbpool: handler.DBPool,
26+ Logger: cfg.Logger,
27+ }
28 return []wish.Middleware{
29 pipe.Middleware(handler, ""),
30 list.Middleware(handler),
31 scp.Middleware(handler),
32 wishrsync.Middleware(handler),
33 auth.Middleware(handler),
34- wsh.PtyMdw(bm.MiddlewareWithColorProfile(tui.CmsMiddleware(cfg), termenv.TrueColor)),
35+ wsh.PtyMdw(createTui(shrd)),
36 WishMiddleware(cliHandler),
37- wsh.LogMiddleware(handler.GetLogger(s), handler.DBPool),
38+ wsh.LogMiddleware(handler.GetLogger(sesh), handler.DBPool),
39 }
40 }
41 }
42
43-func withProxy(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler, otherMiddleware ...wish.Middleware) ssh.Option {
44+func withProxyVaxis(cfg *shared.ConfigSite, handler *UploadHandler, cliHandler *CliHandler, otherMiddleware ...wish.Middleware) ssh.Option {
45 return func(server *ssh.Server) error {
46 err := sftp.SSHOption(handler)(server)
47 if err != nil {
48 return err
49 }
50
51- newSubsystemHandlers := map[string]ssh.SubsystemHandler{}
52+ return proxy.WithProxy(createRouterVaxis(cfg, handler, cliHandler), otherMiddleware...)(server)
53+ }
54+}
55
56- for name, subsystemHandler := range server.SubsystemHandlers {
57- newSubsystemHandlers[name] = func(s ssh.Session) {
58- wsh.LogMiddleware(handler.GetLogger(s), handler.DBPool)(ssh.Handler(subsystemHandler))(s)
59+func createTui(shrd *tui.SharedModel) wish.Middleware {
60+ return func(next ssh.Handler) ssh.Handler {
61+ return func(sesh ssh.Session) {
62+ vty, err := shared.NewVConsole(sesh)
63+ if err != nil {
64+ panic(err)
65 }
66+ opts := vaxis.Options{
67+ WithConsole: vty,
68+ }
69+ tui.NewTui(opts, shrd)
70 }
71-
72- server.SubsystemHandlers = newSubsystemHandlers
73-
74- return proxy.WithProxy(createRouter(cfg, handler, cliHandler), otherMiddleware...)(server)
75 }
76 }
77
78@@ -89,7 +99,7 @@ func StartSshServer() {
79 sshAuth.PubkeyAuthHandler(ctx, key)
80 return true
81 }),
82- withProxy(
83+ withProxyVaxis(
84 cfg,
85 handler,
86 cliHandler,
1@@ -123,10 +123,9 @@ func (v *VConsole) Close() error {
2 return err
3 }
4
5-func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error) {
6+func NewVConsole(sesh ssh.Session) (*VConsole, error) {
7 pty, win, ok := sesh.Pty()
8 if !ok {
9- slog.Error("PTY not found")
10 return nil, fmt.Errorf("PTY not found")
11 }
12
13@@ -176,6 +175,16 @@ func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error)
14 }
15 }()
16
17+ return vty, nil
18+}
19+
20+func NewSenpaiApp(sesh ssh.Session, username, pass string) (*senpai.App, error) {
21+ vty, err := NewVConsole(sesh)
22+ if err != nil {
23+ slog.Error("PTY not found")
24+ return nil, err
25+ }
26+
27 senpaiCfg := senpai.Defaults()
28 senpaiCfg.TLS = true
29 senpaiCfg.Addr = "irc.pico.sh:6697"
+398,
-0
1@@ -0,0 +1,398 @@
2+package tui
3+
4+import (
5+ "fmt"
6+ "math"
7+ "time"
8+
9+ "git.sr.ht/~rockorager/vaxis"
10+ "git.sr.ht/~rockorager/vaxis/vxfw"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
12+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
13+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
14+ "github.com/picosh/pico/db"
15+ "github.com/picosh/utils"
16+)
17+
18+type SitesLoaded struct{}
19+type SiteStatsLoaded struct{}
20+
21+type AnalyticsPage struct {
22+ shared *SharedModel
23+
24+ sites []*db.VisitUrl
25+ features []*db.FeatureFlag
26+ leftPane list.Dynamic
27+ err error
28+ stats map[string]*db.SummaryVisits
29+ selected string
30+ interval string
31+ focus string
32+ rightPane *Pager
33+}
34+
35+func NewAnalyticsPage(shrd *SharedModel) *AnalyticsPage {
36+ page := &AnalyticsPage{
37+ shared: shrd,
38+ stats: map[string]*db.SummaryVisits{},
39+ interval: "month",
40+ focus: "sites",
41+ }
42+
43+ page.leftPane = list.Dynamic{DrawCursor: true, Builder: page.getLeftWidget}
44+ page.rightPane = NewPager()
45+ return page
46+}
47+
48+func (m *AnalyticsPage) Footer() []Shortcut {
49+ ff := findAnalyticsFeature(m.features)
50+ toggle := "enable analytics"
51+ if ff != nil && ff.IsValid() {
52+ toggle = "disable analytics"
53+ }
54+ short := []Shortcut{
55+ {Shortcut: "j/k", Text: "choose"},
56+ {Shortcut: "tab", Text: "focus"},
57+ {Shortcut: "f", Text: "toggle filter (month/day)"},
58+ }
59+ if m.shared.PlusFeatureFlag != nil {
60+ short = append(short, Shortcut{Shortcut: "t", Text: toggle})
61+ }
62+ return short
63+}
64+
65+func (m *AnalyticsPage) getLeftWidget(i uint, cursor uint) vxfw.Widget {
66+ if int(i) >= len(m.sites) {
67+ return nil
68+ }
69+
70+ site := m.sites[i]
71+ txt := text.New(site.Url)
72+ return txt
73+}
74+
75+func (m *AnalyticsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
76+ switch msg := ev.(type) {
77+ case PageIn:
78+ go m.fetchSites()
79+ _ = m.fetchFeatures()
80+ m.focus = "page"
81+ return vxfw.FocusWidgetCmd(m), nil
82+ case SitesLoaded:
83+ m.focus = "sites"
84+ return vxfw.BatchCmd([]vxfw.Command{
85+ vxfw.FocusWidgetCmd(&m.leftPane),
86+ vxfw.RedrawCmd{},
87+ }), nil
88+ case SiteStatsLoaded:
89+ return vxfw.RedrawCmd{}, nil
90+ case vaxis.Key:
91+ if msg.Matches('f') {
92+ if m.interval == "day" {
93+ m.interval = "month"
94+ } else {
95+ m.interval = "day"
96+ }
97+ go m.fetchSiteStats(m.selected, m.interval)
98+ return vxfw.RedrawCmd{}, nil
99+ }
100+ if msg.Matches('t') {
101+ enabled, err := m.toggleAnalytics()
102+ if err != nil {
103+ fmt.Println(err)
104+ }
105+ var wdgt vxfw.Widget = m
106+ if enabled {
107+ m.focus = "sites"
108+ wdgt = &m.leftPane
109+ } else {
110+ m.focus = "page"
111+ }
112+ return vxfw.BatchCmd([]vxfw.Command{
113+ vxfw.FocusWidgetCmd(wdgt),
114+ vxfw.RedrawCmd{},
115+ }), nil
116+ }
117+ if msg.Matches(vaxis.KeyEnter) {
118+ m.selected = m.sites[m.leftPane.Cursor()].Url
119+ go m.fetchSiteStats(m.selected, m.interval)
120+ return vxfw.RedrawCmd{}, nil
121+ }
122+ if msg.Matches(vaxis.KeyTab) {
123+ var cmd vxfw.Widget
124+ if m.focus == "sites" && m.selected != "" {
125+ m.focus = "details"
126+ cmd = m.rightPane
127+ } else if m.focus == "details" {
128+ m.focus = "sites"
129+ cmd = &m.leftPane
130+ } else if m.focus == "page" {
131+ m.focus = "sites"
132+ cmd = &m.leftPane
133+ }
134+ return vxfw.BatchCmd([]vxfw.Command{
135+ vxfw.FocusWidgetCmd(cmd),
136+ vxfw.RedrawCmd{},
137+ }), nil
138+ }
139+ }
140+ return nil, nil
141+}
142+
143+func (m *AnalyticsPage) focusBorder(brd *Border) {
144+ focus := m.focus
145+ if focus == brd.Label {
146+ brd.Style = vaxis.Style{Foreground: oj}
147+ } else {
148+ brd.Style = vaxis.Style{Foreground: purp}
149+ }
150+}
151+
152+func (m *AnalyticsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
153+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
154+ ff := findAnalyticsFeature(m.features)
155+ if ff == nil || !ff.IsValid() {
156+ surf := m.banner(ctx)
157+ root.AddChild(0, 0, surf)
158+ return root, nil
159+ }
160+
161+ leftPaneW := float32(ctx.Max.Width) * 0.35
162+
163+ var wdgt vxfw.Widget = text.New("No sites found")
164+ if len(m.sites) > 0 {
165+ wdgt = &m.leftPane
166+ }
167+
168+ leftPane := NewBorder(wdgt)
169+ leftPane.Label = "sites"
170+ m.focusBorder(leftPane)
171+ leftSurf, _ := leftPane.Draw(vxfw.DrawContext{
172+ Characters: ctx.Characters,
173+ Max: vxfw.Size{
174+ Width: uint16(leftPaneW),
175+ Height: ctx.Max.Height,
176+ },
177+ })
178+
179+ root.AddChild(0, 0, leftSurf)
180+
181+ rightPaneW := float32(ctx.Max.Width) * 0.65
182+ if m.selected == "" {
183+ rightWdgt := text.New("Select a site on the left to view its stats")
184+ rightSurf, _ := rightWdgt.Draw(vxfw.DrawContext{
185+ Characters: ctx.Characters,
186+ Max: vxfw.Size{
187+ Width: uint16(rightPaneW),
188+ Height: ctx.Max.Height,
189+ },
190+ })
191+ root.AddChild(int(leftPaneW), 0, rightSurf)
192+ } else {
193+ rightSurf := vxfw.NewSurface(uint16(rightPaneW), math.MaxUint16, m)
194+
195+ ah := 0
196+ data, err := m.getSiteData()
197+ if err != nil {
198+ txt, _ := text.New("No data found").Draw(ctx)
199+ m.rightPane.Surface = txt
200+ rightPane := NewBorder(m.rightPane)
201+ rightPane.Label = "details"
202+ m.focusBorder(rightPane)
203+ pagerSurf, _ := rightPane.Draw(vxfw.DrawContext{
204+ Characters: ctx.Characters,
205+ Max: vxfw.Size{Width: uint16(rightPaneW), Height: ctx.Max.Height},
206+ })
207+ rightSurf.AddChild(0, 0, pagerSurf)
208+ root.AddChild(int(leftPaneW), 0, rightSurf)
209+ return root, nil
210+ }
211+
212+ rightCtx := vxfw.DrawContext{
213+ Characters: vaxis.Characters,
214+ Max: vxfw.Size{
215+ Width: uint16(rightPaneW) - 2,
216+ Height: ctx.Max.Height,
217+ },
218+ }
219+
220+ detailSurf, _ := m.detail(rightCtx).Draw(rightCtx)
221+ rightSurf.AddChild(0, ah, detailSurf)
222+ ah += int(detailSurf.Size.Height)
223+
224+ urlSurf, _ := m.urls(rightCtx, data.TopUrls, "urls").Draw(rightCtx)
225+ rightSurf.AddChild(0, ah, urlSurf)
226+ ah += int(urlSurf.Size.Height)
227+
228+ urlSurf, _ = m.urls(rightCtx, data.NotFoundUrls, "not found").Draw(rightCtx)
229+ rightSurf.AddChild(0, ah, urlSurf)
230+ ah += int(urlSurf.Size.Height)
231+
232+ urlSurf, _ = m.urls(rightCtx, data.TopReferers, "referers").Draw(rightCtx)
233+ rightSurf.AddChild(0, ah, urlSurf)
234+ ah += int(urlSurf.Size.Height)
235+
236+ surf, _ := m.visits(rightCtx, data.Intervals).Draw(rightCtx)
237+ rightSurf.AddChild(0, ah, surf)
238+
239+ m.rightPane.Surface = rightSurf
240+ rightPane := NewBorder(m.rightPane)
241+ rightPane.Label = "details"
242+ m.focusBorder(rightPane)
243+ pagerSurf, _ := rightPane.Draw(rightCtx)
244+
245+ root.AddChild(int(leftPaneW), 0, pagerSurf)
246+ }
247+
248+ return root, nil
249+}
250+
251+func (m *AnalyticsPage) getSiteData() (*db.SummaryVisits, error) {
252+ val, ok := m.stats[m.selected+":"+m.interval]
253+ if !ok {
254+ return nil, fmt.Errorf("site data not found")
255+ }
256+ return val, nil
257+}
258+
259+func (m *AnalyticsPage) detail(ctx vxfw.DrawContext) vxfw.Widget {
260+ datestr := "Date range: "
261+ now := time.Now()
262+ if m.interval == "day" {
263+ datestr += now.Format("2006 Jan")
264+ } else {
265+ datestr += now.Format("2006")
266+ }
267+ txt := richtext.New([]vaxis.Segment{
268+ {Text: datestr, Style: vaxis.Style{Foreground: green}},
269+ })
270+ rightPane := NewBorder(txt)
271+ rightPane.Width = ctx.Max.Width
272+ rightPane.Label = m.selected
273+ m.focusBorder(rightPane)
274+ return rightPane
275+}
276+
277+func (m *AnalyticsPage) urls(ctx vxfw.DrawContext, urls []*db.VisitUrl, label string) vxfw.Widget {
278+ segs := []vaxis.Segment{}
279+ for _, url := range urls {
280+ segs = append(segs, vaxis.Segment{Text: fmt.Sprintf("%s: %d\n", url.Url, url.Count)})
281+ }
282+ wdgt := richtext.New(segs)
283+ rightPane := NewBorder(wdgt)
284+ rightPane.Width = ctx.Max.Width
285+ rightPane.Label = label
286+ m.focusBorder(rightPane)
287+ return rightPane
288+}
289+
290+func (m *AnalyticsPage) visits(ctx vxfw.DrawContext, intervals []*db.VisitInterval) vxfw.Widget {
291+ segs := []vaxis.Segment{}
292+ for _, visit := range intervals {
293+ segs = append(
294+ segs,
295+ vaxis.Segment{
296+ Text: fmt.Sprintf("%s: %d\n", visit.Interval.Format(time.RFC3339), visit.Visitors),
297+ },
298+ )
299+ }
300+ wdgt := richtext.New(segs)
301+ rightPane := NewBorder(wdgt)
302+ rightPane.Width = ctx.Max.Width
303+ rightPane.Label = "visits"
304+ m.focusBorder(rightPane)
305+ return rightPane
306+}
307+
308+func (m *AnalyticsPage) fetchSites() {
309+ siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
310+ UserID: m.shared.User.ID,
311+ Origin: utils.StartOfMonth(),
312+ })
313+ if err != nil {
314+ m.err = err
315+ }
316+ m.sites = siteList
317+ m.shared.App.PostEvent(SitesLoaded{})
318+}
319+
320+func (m *AnalyticsPage) fetchSiteStats(site string, interval string) {
321+ opts := &db.SummaryOpts{
322+ Host: site,
323+
324+ UserID: m.shared.User.ID,
325+ Interval: interval,
326+ }
327+
328+ if interval == "day" {
329+ opts.Origin = utils.StartOfMonth()
330+ } else {
331+ opts.Origin = utils.StartOfYear()
332+ }
333+
334+ summary, err := m.shared.Dbpool.VisitSummary(opts)
335+ if err != nil {
336+ m.err = err
337+ return
338+ }
339+ m.stats[site+":"+interval] = summary
340+ m.shared.App.PostEvent(SiteStatsLoaded{})
341+}
342+
343+func (m *AnalyticsPage) fetchFeatures() error {
344+ features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
345+ m.features = features
346+ return err
347+}
348+
349+func (m *AnalyticsPage) banner(ctx vxfw.DrawContext) vxfw.Surface {
350+ style := vaxis.Style{Foreground: red}
351+ analytics := richtext.New([]vaxis.Segment{
352+ {
353+ Text: "Analytics is only available to pico+ users.\n\n",
354+ Style: style,
355+ },
356+ {
357+ Text: "Get usage statistics on your blog, blog posts, and pages sites. For example, see unique visitors, most popular URLs, and top referers.\n\n",
358+ },
359+ {
360+ Text: "We do not collect usage statistic unless analytics is enabled. Further, when analytics are disabled we do not purge usage statistics.\n\n",
361+ },
362+ {
363+ Text: "We will only store usage statistics for 1 year from when the event was created.",
364+ },
365+ })
366+ brd := NewBorder(analytics)
367+ brd.Label = "alert"
368+ surf, _ := brd.Draw(ctx)
369+ return surf
370+}
371+
372+func (m *AnalyticsPage) toggleAnalytics() (bool, error) {
373+ enabled := false
374+ if findAnalyticsFeature(m.features) == nil {
375+ now := time.Now()
376+ expiresAt := now.AddDate(100, 0, 0)
377+ _, err := m.shared.Dbpool.InsertFeature(m.shared.User.ID, "analytics", expiresAt)
378+ if err != nil {
379+ return false, err
380+ }
381+ enabled = true
382+ } else {
383+ err := m.shared.Dbpool.RemoveFeature(m.shared.User.ID, "analytics")
384+ if err != nil {
385+ return true, err
386+ }
387+ }
388+
389+ return enabled, m.fetchFeatures()
390+}
391+
392+func findAnalyticsFeature(features []*db.FeatureFlag) *db.FeatureFlag {
393+ for _, feature := range features {
394+ if feature.Name == "analytics" {
395+ return feature
396+ }
397+ }
398+ return nil
399+}
+0,
-387
1@@ -1,387 +0,0 @@
2-package analytics
3-
4-import (
5- "context"
6- "fmt"
7- "strings"
8-
9- input "github.com/charmbracelet/bubbles/textinput"
10- "github.com/charmbracelet/bubbles/viewport"
11- tea "github.com/charmbracelet/bubbletea"
12- "github.com/charmbracelet/glamour"
13- "github.com/charmbracelet/lipgloss"
14- "github.com/picosh/pico/db"
15- "github.com/picosh/pico/tui/common"
16- "github.com/picosh/pico/tui/pages"
17- "github.com/picosh/utils"
18-)
19-
20-type state int
21-
22-const (
23- stateLoading state = iota
24- stateReady
25-)
26-
27-type errMsg error
28-
29-type SiteStatsLoaded struct {
30- Summary *db.SummaryVisits
31-}
32-
33-type SiteListLoaded struct {
34- Sites []*db.VisitUrl
35-}
36-
37-type PathStatsLoaded struct {
38- Summary *db.SummaryVisits
39-}
40-
41-type HasAnalyticsFeature struct {
42- Has bool
43-}
44-
45-type Model struct {
46- shared *common.SharedModel
47- state state
48- logData []map[string]any
49- viewport viewport.Model
50- input input.Model
51- sub chan map[string]any
52- ctx context.Context
53- done context.CancelFunc
54- errMsg error
55- statsBySite *db.SummaryVisits
56- statsByPath *db.SummaryVisits
57- siteList []*db.VisitUrl
58- repl string
59- analyticsEnabled bool
60-}
61-
62-func headerHeight(shrd *common.SharedModel) int {
63- return shrd.HeaderHeight
64-}
65-
66-func headerWidth(w int) int {
67- return w - 2
68-}
69-
70-var helpMsg = `This view shows site usage analytics for prose, pages, and tuns.
71-
72-[Read our docs about site usage analytics](https://pico.sh/analytics)
73-
74-Shortcuts:
75-
76-- esc: leave page
77-- tab: toggle between viewport and input box
78-- u: scroll viewport up a page
79-- d: scroll viewport down a page
80-- j,k: scroll viewport
81-
82-Commands: [help, stats, site {domain}, post {slug}]
83-
84-**View usage stats for all sites for this month:**
85-
86-> stats
87-
88-**View usage stats for your site by month this year:**
89-
90-> site pico.sh
91-
92-**View usage stats for your site by day this month:**
93-
94-> site pico.sh day
95-
96-**View usage stats for your blog post by month this year:**
97-
98-> post my-post
99-
100-**View usage stats for blog posts by day this month:**
101-
102-> post my-post day
103-
104-`
105-
106-func NewModel(shrd *common.SharedModel) Model {
107- im := input.New()
108- im.Cursor.Style = shrd.Styles.Cursor
109- im.Placeholder = "type 'help' to learn how to use the repl"
110- im.PlaceholderStyle = shrd.Styles.InputPlaceholder
111- im.Prompt = shrd.Styles.FocusedPrompt.String()
112- im.CharLimit = 50
113- im.Focus()
114-
115- hh := headerHeight(shrd)
116- ww := headerWidth(shrd.Width)
117- inputHeight := lipgloss.Height(im.View())
118- viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
119- viewport.YPosition = hh
120-
121- ctx, cancel := context.WithCancel(shrd.Session.Context())
122-
123- return Model{
124- shared: shrd,
125- state: stateLoading,
126- viewport: viewport,
127- logData: []map[string]any{},
128- input: im,
129- sub: make(chan map[string]any),
130- ctx: ctx,
131- done: cancel,
132- }
133-}
134-
135-func (m Model) Init() tea.Cmd {
136- return m.hasAnalyticsFeature()
137-}
138-
139-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
140- var cmds []tea.Cmd
141- var cmd tea.Cmd
142- switch msg := msg.(type) {
143- case tea.WindowSizeMsg:
144- m.viewport.Width = headerWidth(msg.Width)
145- inputHeight := lipgloss.Height(m.input.View())
146- hh := headerHeight(m.shared)
147- m.viewport.Height = msg.Height - hh - inputHeight
148- m.viewport.SetContent(m.renderViewport())
149-
150- case errMsg:
151- m.errMsg = msg
152-
153- case pages.NavigateMsg:
154- // cancel activity logger
155- m.done()
156- // reset model
157- next := NewModel(m.shared)
158- return next, nil
159-
160- case tea.KeyMsg:
161- switch msg.String() {
162- case "esc":
163- return m, pages.Navigate(pages.MenuPage)
164- case "q":
165- if !m.input.Focused() {
166- return m, pages.Navigate(pages.MenuPage)
167- }
168- case "tab":
169- if m.input.Focused() {
170- m.input.Blur()
171- } else {
172- cmds = append(cmds, m.input.Focus())
173- }
174- case "enter":
175- replCmd := m.input.Value()
176- m.repl = replCmd
177- if replCmd == "stats" {
178- m.state = stateLoading
179- cmds = append(cmds, m.fetchSiteList())
180- } else if strings.HasPrefix(replCmd, "site") {
181- name, by := splitReplCmd(replCmd)
182- m.state = stateLoading
183- cmds = append(cmds, m.fetchSiteStats(name, by))
184- } else if strings.HasPrefix(replCmd, "post") {
185- slug, by := splitReplCmd(replCmd)
186- m.state = stateLoading
187- cmds = append(cmds, m.fetchPostStats(slug, by))
188- }
189-
190- m.viewport.SetContent(m.renderViewport())
191- m.input.SetValue("")
192- }
193-
194- case SiteStatsLoaded:
195- m.state = stateReady
196- m.statsBySite = msg.Summary
197- m.viewport.SetContent(m.renderViewport())
198-
199- case PathStatsLoaded:
200- m.state = stateReady
201- m.statsByPath = msg.Summary
202- m.viewport.SetContent(m.renderViewport())
203-
204- case SiteListLoaded:
205- m.state = stateReady
206- m.siteList = msg.Sites
207- m.viewport.SetContent(m.renderViewport())
208-
209- case HasAnalyticsFeature:
210- m.state = stateReady
211- m.analyticsEnabled = msg.Has
212- m.viewport.SetContent(m.renderViewport())
213- }
214-
215- m.input, cmd = m.input.Update(msg)
216- cmds = append(cmds, cmd)
217- if !m.input.Focused() {
218- m.viewport, cmd = m.viewport.Update(msg)
219- cmds = append(cmds, cmd)
220- }
221- return m, tea.Batch(cmds...)
222-}
223-
224-func (m Model) View() string {
225- if m.errMsg != nil {
226- return m.shared.Styles.Error.Render(m.errMsg.Error())
227- }
228- return m.viewport.View() + "\n" + m.input.View()
229-}
230-
231-func (m Model) renderViewport() string {
232- if m.state == stateLoading {
233- return "Loading ..."
234- }
235-
236- if m.shared.PlusFeatureFlag == nil || !m.shared.PlusFeatureFlag.IsValid() {
237- return m.renderMd(`**Analytics is only available for pico+ users.**
238-
239-[Read our docs about site usage analytics](https://pico.sh/analytics)`)
240- }
241-
242- if !m.analyticsEnabled {
243- return m.renderMd(`**Analytics must be enabled in the Settings page.**
244-
245-[Read our docs about site usage analytics](https://pico.sh/analytics)`)
246- }
247-
248- cmd := m.repl
249- if cmd == "help" {
250- return m.renderMd(helpMsg)
251- } else if cmd == "stats" {
252- return m.renderSiteList()
253- } else if strings.HasPrefix(cmd, "site") {
254- return m.renderSiteStats(m.statsBySite)
255- } else if strings.HasPrefix(cmd, "post") {
256- return m.renderSiteStats(m.statsByPath)
257- }
258-
259- return m.renderMd(helpMsg)
260-}
261-
262-func (m Model) renderMd(md string) string {
263- r, _ := glamour.NewTermRenderer(
264- // detect background color and pick either the default dark or light theme
265- glamour.WithAutoStyle(),
266- glamour.WithWordWrap(m.viewport.Width),
267- )
268- out, _ := r.Render(md)
269- return out
270-}
271-
272-func (m Model) renderSiteStats(summary *db.SummaryVisits) string {
273- name, by := splitReplCmd(m.repl)
274- str := m.shared.Styles.Label.SetString(fmt.Sprintf("%s by %s\n", name, by)).String()
275-
276- if !strings.HasPrefix(m.repl, "post") {
277- str += "\nTop URLs\n"
278- topUrlsTbl := common.VisitUrlsTbl(summary.TopUrls, m.shared.Styles.Renderer, m.viewport.Width)
279- str += topUrlsTbl.String()
280-
281- str += "\nTop Not Found URLs\n"
282- notFoundUrlsTbl := common.VisitUrlsTbl(summary.NotFoundUrls, m.shared.Styles.Renderer, m.viewport.Width)
283- str += notFoundUrlsTbl.String()
284- }
285-
286- str += "\nTop Referers\n"
287- topRefsTbl := common.VisitUrlsTbl(summary.TopReferers, m.shared.Styles.Renderer, m.viewport.Width)
288- str += topRefsTbl.String()
289-
290- if by == "day" {
291- str += "\nUnique Visitors by Day this Month\n"
292- } else {
293- str += "\nUnique Visitors by Month this Year\n"
294- }
295- uniqueTbl := common.UniqueVisitorsTbl(summary.Intervals, m.shared.Styles.Renderer, m.viewport.Width)
296- str += uniqueTbl.String()
297- return str
298-}
299-
300-func (m Model) fetchSiteStats(site string, interval string) tea.Cmd {
301- return func() tea.Msg {
302- opts := &db.SummaryOpts{
303- Host: site,
304-
305- UserID: m.shared.User.ID,
306- Interval: interval,
307- }
308-
309- if interval == "day" {
310- opts.Origin = utils.StartOfMonth()
311- } else {
312- opts.Origin = utils.StartOfYear()
313- }
314-
315- summary, err := m.shared.Dbpool.VisitSummary(opts)
316- if err != nil {
317- return errMsg(err)
318- }
319-
320- return SiteStatsLoaded{summary}
321- }
322-}
323-
324-func (m Model) fetchPostStats(raw string, interval string) tea.Cmd {
325- return func() tea.Msg {
326- slug := raw
327- if !strings.HasPrefix(slug, "/") {
328- slug = "/" + raw
329- }
330-
331- opts := &db.SummaryOpts{
332- Path: slug,
333-
334- UserID: m.shared.User.ID,
335- Interval: interval,
336- }
337-
338- if interval == "day" {
339- opts.Origin = utils.StartOfMonth()
340- } else {
341- opts.Origin = utils.StartOfYear()
342- }
343-
344- summary, err := m.shared.Dbpool.VisitSummary(opts)
345- if err != nil {
346- return errMsg(err)
347- }
348-
349- return PathStatsLoaded{summary}
350- }
351-}
352-
353-func (m Model) renderSiteList() string {
354- tbl := common.VisitUrlsTbl(m.siteList, m.shared.Styles.Renderer, m.viewport.Width)
355- str := "Sites: Total Unique Visitors\n"
356- str += tbl.String()
357- return str
358-}
359-
360-func (m Model) fetchSiteList() tea.Cmd {
361- return func() tea.Msg {
362- siteList, err := m.shared.Dbpool.FindVisitSiteList(&db.SummaryOpts{
363- UserID: m.shared.User.ID,
364- Origin: utils.StartOfMonth(),
365- })
366- if err != nil {
367- return errMsg(err)
368- }
369- return SiteListLoaded{siteList}
370- }
371-}
372-
373-func (m Model) hasAnalyticsFeature() tea.Cmd {
374- return func() tea.Msg {
375- feature := m.shared.Dbpool.HasFeatureForUser(m.shared.User.ID, "analytics")
376- return HasAnalyticsFeature{feature}
377- }
378-}
379-
380-func splitReplCmd(replCmd string) (string, string) {
381- replRaw := strings.SplitN(replCmd, " ", 3)
382- name := strings.TrimSpace(replRaw[1])
383- by := "month"
384- if len(replRaw) > 2 {
385- by = replRaw[2]
386- }
387- return name, by
388-}
+118,
-0
1@@ -0,0 +1,118 @@
2+package tui
3+
4+import (
5+ "fmt"
6+
7+ "git.sr.ht/~rockorager/vaxis"
8+ "git.sr.ht/~rockorager/vaxis/vxfw"
9+)
10+
11+var (
12+ horizontal = vaxis.Character{Grapheme: "─", Width: 1}
13+ vertical = vaxis.Character{Grapheme: "│", Width: 1}
14+ topLeft = vaxis.Character{Grapheme: "╭", Width: 1}
15+ topRight = vaxis.Character{Grapheme: "╮", Width: 1}
16+ bottomRight = vaxis.Character{Grapheme: "╯", Width: 1}
17+ bottomLeft = vaxis.Character{Grapheme: "╰", Width: 1}
18+)
19+
20+func border(label string, surf vxfw.Surface, style vaxis.Style) vxfw.Surface {
21+ finlabel := fmt.Sprintf(" %s ", label)
22+ w := surf.Size.Width
23+ h := surf.Size.Height
24+ surf.WriteCell(0, 0, vaxis.Cell{
25+ Character: topLeft,
26+ Style: style,
27+ })
28+ surf.WriteCell(0, h-1, vaxis.Cell{
29+ Character: bottomLeft,
30+ Style: style,
31+ })
32+ surf.WriteCell(w-1, 0, vaxis.Cell{
33+ Character: topRight,
34+ Style: style,
35+ })
36+ surf.WriteCell(w-1, h-1, vaxis.Cell{
37+ Character: bottomRight,
38+ Style: style,
39+ })
40+ idx := 0
41+ for j := 1; j < (int(w) - 1); j += 1 {
42+ i := uint16(j)
43+ // apply label
44+ char := horizontal
45+ if label != "" && j >= 2 && len(finlabel)+1 >= j {
46+ char = vaxis.Character{Grapheme: string(finlabel[idx]), Width: 1}
47+ idx += 1
48+ }
49+ surf.WriteCell(i, 0, vaxis.Cell{
50+ Character: char,
51+ Style: style,
52+ })
53+ surf.WriteCell(i, h-1, vaxis.Cell{
54+ Character: horizontal,
55+ Style: style,
56+ })
57+ }
58+ for j := 1; j < (int(h) - 1); j += 1 {
59+ i := uint16(j)
60+ surf.WriteCell(0, i, vaxis.Cell{
61+ Character: vertical,
62+ Style: style,
63+ })
64+ surf.WriteCell(w-1, i, vaxis.Cell{
65+ Character: vertical,
66+ Style: style,
67+ })
68+ }
69+
70+ return surf
71+}
72+
73+type Border struct {
74+ w vxfw.Widget
75+ Style vaxis.Style
76+ Label string
77+ Width uint16
78+ Height uint16
79+}
80+
81+func NewBorder(w vxfw.Widget) *Border {
82+ return &Border{
83+ w: w,
84+ Style: vaxis.Style{Foreground: purp},
85+ Label: "",
86+ }
87+}
88+
89+func (b *Border) HandleEvent(vaxis.Event, vxfw.EventPhase) (vxfw.Command, error) {
90+ return nil, nil
91+}
92+
93+func (b *Border) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
94+ surf, _ := b.w.Draw(vxfw.DrawContext{
95+ Characters: ctx.Characters,
96+ Max: vxfw.Size{
97+ Width: ctx.Max.Width - 2,
98+ Height: ctx.Max.Height - 3,
99+ },
100+ })
101+
102+ w := surf.Size.Width + 2
103+ if b.Width > 0 {
104+ w = b.Width - 2
105+ }
106+
107+ h := surf.Size.Height + 2
108+ if b.Height > 0 {
109+ h = b.Height - 2
110+ }
111+
112+ root := border(
113+ b.Label,
114+ vxfw.NewSurface(w, h, b),
115+ b.Style,
116+ )
117+ root.AddChild(1, 1, surf)
118+ return root, nil
119+}
+92,
-0
1@@ -0,0 +1,92 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
8+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
9+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
10+)
11+
12+type ChatPage struct {
13+ shared *SharedModel
14+ btn *button.Button
15+}
16+
17+func NewChatPage(shrd *SharedModel) *ChatPage {
18+ btn := button.New("chat!", func() (vxfw.Command, error) { return nil, nil })
19+ btn.Style = button.StyleSet{
20+ Default: vaxis.Style{Background: oj, Foreground: black},
21+ }
22+ return &ChatPage{shared: shrd, btn: btn}
23+}
24+
25+func (m *ChatPage) Footer() []Shortcut {
26+ short := []Shortcut{
27+ {Shortcut: "enter", Text: "chat"},
28+ }
29+ return short
30+}
31+
32+func (m *ChatPage) hasAccess() bool {
33+ if m.shared.PlusFeatureFlag != nil && m.shared.PlusFeatureFlag.IsValid() {
34+ return true
35+ }
36+
37+ if m.shared.BouncerFeatureFlag != nil && m.shared.BouncerFeatureFlag.IsValid() {
38+ return true
39+ }
40+
41+ return false
42+}
43+
44+func (m *ChatPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
45+ switch msg := ev.(type) {
46+ case PageIn:
47+ return vxfw.FocusWidgetCmd(m), nil
48+ case vaxis.Key:
49+ if msg.Matches(vaxis.KeyEnter) {
50+ _ = m.shared.App.Suspend()
51+ loadChat(m.shared)
52+ _ = m.shared.App.Resume()
53+ return vxfw.QuitCmd{}, nil
54+ }
55+ }
56+
57+ return nil, nil
58+}
59+
60+func (m *ChatPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
61+ segs := []vaxis.Segment{
62+ {Text: "We provide a managed IRC bouncer for pico+ users. When you click the button we will open our TUI chat with your user authenticated automatically.\n\n"},
63+ {Text: "If you haven't configured your pico+ account with our IRC bouncer, the guide is here:\n\n "},
64+ {Text: "https://pico.sh/bouncer", Style: vaxis.Style{Hyperlink: "https://pico.sh/bouncer"}},
65+ {Text: "\n\nIf you want to quickly chat with us on IRC without pico+, go to the web chat:\n\n "},
66+ {Text: "https://web.libera.chat/gamja?autojoin=#pico.sh", Style: vaxis.Style{Hyperlink: "https://web.libera.chat/gamja?autojoin=#pico.sh"}},
67+ }
68+ txt, _ := richtext.New(segs).Draw(ctx)
69+
70+ surfs := []vxfw.Surface{txt}
71+ if m.hasAccess() {
72+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
73+ Characters: ctx.Characters,
74+ Max: vxfw.Size{Width: 7, Height: 1},
75+ })
76+ surfs = append(surfs, btnSurf)
77+ } else {
78+ t := text.New("Our IRC Bouncer is only available to pico+ users.")
79+ t.Style = vaxis.Style{Foreground: red}
80+ ss, _ := t.Draw(ctx)
81+ surfs = append(surfs, ss)
82+ }
83+ stack := NewGroupStack(surfs)
84+ stack.Gap = 1
85+
86+ brd := NewBorder(stack)
87+ brd.Label = "irc chat"
88+ surf, _ := brd.Draw(ctx)
89+
90+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
91+ root.AddChild(0, 0, surf)
92+ return root, nil
93+}
+0,
-90
1@@ -1,90 +0,0 @@
2-package chat
3-
4-import (
5- tea "github.com/charmbracelet/bubbletea"
6- "github.com/picosh/pico/tui/common"
7- "github.com/picosh/pico/tui/pages"
8-)
9-
10-var maxWidth = 55
11-
12-type focus int
13-
14-const (
15- focusNone = iota
16- focusChat
17-)
18-
19-type Model struct {
20- shared *common.SharedModel
21- focus focus
22-}
23-
24-func NewModel(shrd *common.SharedModel) Model {
25- m := Model{
26- shared: shrd,
27- focus: focusChat,
28- }
29-
30- if m.shared.PlusFeatureFlag == nil && m.shared.BouncerFeatureFlag == nil {
31- m.focus = focusNone
32- }
33-
34- return m
35-}
36-
37-func (m Model) Init() tea.Cmd {
38- return nil
39-}
40-
41-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
42- switch msg := msg.(type) {
43- case tea.KeyMsg:
44- switch msg.String() {
45- case "q", "esc":
46- return m, pages.Navigate(pages.MenuPage)
47- case "tab":
48- if m.focus == focusNone && (m.shared.PlusFeatureFlag != nil || m.shared.BouncerFeatureFlag != nil) {
49- m.focus = focusChat
50- } else {
51- m.focus = focusNone
52- }
53- case "enter":
54- if m.focus == focusChat {
55- return m, m.gotoChat()
56- }
57- }
58- }
59- return m, nil
60-}
61-
62-func (m Model) View() string {
63- return m.analyticsView()
64-}
65-
66-func (m Model) analyticsView() string {
67- banner := `We provide a managed IRC bouncer for pico+ users. When you click the button we will open our TUI chat with your user authenticated automatically.
68-
69-If you haven't configured your pico+ account with our IRC bouncer, the guide is here:
70-
71- https://pico.sh/bouncer
72-
73-If you want to quickly chat with us on IRC without pico+, go to the web chat:
74-
75- https://web.libera.chat/gamja?autojoin=#pico.sh`
76-
77- str := ""
78- hasPlus := m.shared.PlusFeatureFlag != nil
79- if hasPlus || m.shared.BouncerFeatureFlag != nil {
80- hasFocus := m.focus == focusChat
81- str += banner + "\n\nLet's Chat! " + common.OKButtonView(m.shared.Styles, hasFocus, true)
82- } else {
83- str += banner + "\n\n" + m.shared.Styles.Error.SetString("Our IRC Bouncer is only available to pico+ users.\n\nHit Esc or q to return.").String()
84- }
85-
86- return m.shared.Styles.RoundedBorder.Width(maxWidth).SetString(str).String()
87-}
88-
89-func (m Model) gotoChat() tea.Cmd {
90- return LoadChat(m.shared)
91-}
+0,
-40
1@@ -1,40 +0,0 @@
2-package chat
3-
4-import (
5- "io"
6-
7- tea "github.com/charmbracelet/bubbletea"
8- "github.com/picosh/pico/shared"
9- "github.com/picosh/pico/tui/common"
10-)
11-
12-type SenpaiCmd struct {
13- shared *common.SharedModel
14-}
15-
16-func (m *SenpaiCmd) Run() error {
17- pass, err := m.shared.Dbpool.UpsertToken(m.shared.User.ID, "pico-chat")
18- if err != nil {
19- return err
20- }
21- app, err := shared.NewSenpaiApp(m.shared.Session, m.shared.User.Name, pass)
22- if err != nil {
23- return err
24- }
25- app.Run()
26- app.Close()
27- return nil
28-}
29-
30-func (m *SenpaiCmd) SetStdin(io.Reader) {}
31-func (m *SenpaiCmd) SetStdout(io.Writer) {}
32-func (m *SenpaiCmd) SetStderr(io.Writer) {}
33-
34-func LoadChat(shrd *common.SharedModel) tea.Cmd {
35- sp := &SenpaiCmd{
36- shared: shrd,
37- }
38- return tea.Exec(sp, func(err error) tea.Msg {
39- return tea.Quit()
40- })
41-}
+0,
-7
1@@ -1,7 +0,0 @@
2-package common
3-
4-type ErrMsg struct {
5- Err error
6-}
7-
8-func (e ErrMsg) Error() string { return e.Err.Error() }
+0,
-24
1@@ -1,24 +0,0 @@
2-package common
3-
4-import (
5- "log/slog"
6-
7- "github.com/charmbracelet/ssh"
8- "github.com/picosh/pico/db"
9- "github.com/picosh/pico/shared"
10-)
11-
12-type SharedModel struct {
13- Logger *slog.Logger
14- Session ssh.Session
15- Cfg *shared.ConfigSite
16- Dbpool db.DB
17- User *db.User
18- PlusFeatureFlag *db.FeatureFlag
19- BouncerFeatureFlag *db.FeatureFlag
20- Width int
21- Height int
22- HeaderHeight int
23- Styles Styles
24- Impersonated bool
25-}
+0,
-100
1@@ -1,100 +0,0 @@
2-package common
3-
4-import (
5- "github.com/charmbracelet/lipgloss"
6-)
7-
8-var (
9- Indigo = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
10- SubtleIndigo = lipgloss.AdaptiveColor{Light: "#7D79F6", Dark: "#514DC1"}
11- Cream = lipgloss.AdaptiveColor{Light: "#FFFDF5", Dark: "#FFFDF5"}
12- Fuschia = lipgloss.AdaptiveColor{Light: "#EE6FF8", Dark: "#EE6FF8"}
13- Green = lipgloss.AdaptiveColor{Light: "#ABE5D1", Dark: "#04B575"}
14- DarkRed = lipgloss.AdaptiveColor{Light: "#EBE5EC", Dark: "#2B2A2A"}
15- Red = lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}
16- FaintRed = lipgloss.AdaptiveColor{Light: "#FF6F91", Dark: "#C74665"}
17- Grey = lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"}
18- GreyLight = lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}
19-)
20-
21-type Styles struct {
22- Cursor,
23- Wrap,
24- Paragraph,
25- Code,
26- Subtle,
27- Error,
28- Prompt,
29- FocusedPrompt,
30- Note,
31- Delete,
32- Label,
33- ListKey,
34- InactivePagination,
35- SelectionMarker,
36- SelectedMenuItem,
37- Logo,
38- BlurredButtonStyle,
39- FocusedButtonStyle,
40- HelpSection,
41- HelpDivider,
42- App,
43- InputPlaceholder,
44- RoundedBorder lipgloss.Style
45- Renderer *lipgloss.Renderer
46-}
47-
48-func DefaultStyles(renderer *lipgloss.Renderer) Styles {
49- s := Styles{
50- Renderer: renderer,
51- }
52-
53- s.Cursor = renderer.NewStyle().Foreground(Fuschia)
54- s.Wrap = renderer.NewStyle().Width(58)
55- s.Paragraph = s.Wrap.Margin(1, 0, 0, 2)
56- s.Logo = renderer.NewStyle().
57- Foreground(Cream).
58- Background(Indigo).
59- Padding(0, 1)
60- s.Code = renderer.NewStyle().
61- Foreground(Red).
62- Background(DarkRed).
63- Padding(0, 1)
64- s.Subtle = renderer.NewStyle().
65- Foreground(Grey)
66- s.Error = renderer.NewStyle().Foreground(Red)
67- s.Prompt = renderer.NewStyle().MarginRight(1).SetString(">")
68- s.FocusedPrompt = s.Prompt.Foreground(Fuschia)
69- s.InputPlaceholder = renderer.NewStyle().Foreground(Grey)
70- s.Note = renderer.NewStyle().Foreground(Green)
71- s.Delete = s.Error
72- s.Label = renderer.NewStyle().Foreground(Fuschia)
73- s.ListKey = renderer.NewStyle().Foreground(Indigo)
74- s.InactivePagination = renderer.NewStyle().
75- Foreground(Grey)
76- s.SelectionMarker = renderer.NewStyle().
77- Foreground(Fuschia).
78- PaddingRight(1).
79- SetString("•")
80- s.SelectedMenuItem = renderer.NewStyle().Foreground(Fuschia)
81- s.BlurredButtonStyle = renderer.NewStyle().
82- Foreground(Cream).
83- Background(GreyLight).
84- Padding(0, 3)
85- s.FocusedButtonStyle = s.BlurredButtonStyle.
86- Background(Fuschia)
87- s.HelpDivider = renderer.NewStyle().
88- Foreground(Grey).
89- Padding(0, 1).
90- SetString("•")
91- s.HelpSection = renderer.NewStyle().
92- Foreground(Grey)
93- s.App = renderer.NewStyle().Margin(1, 0, 1, 2)
94- s.RoundedBorder = renderer.
95- NewStyle().
96- Padding(0, 1).
97- BorderForeground(Indigo).
98- Border(lipgloss.RoundedBorder(), true, true)
99-
100- return s
101-}
+0,
-60
1@@ -1,60 +0,0 @@
2-package common
3-
4-import (
5- "fmt"
6- "time"
7-
8- "github.com/charmbracelet/lipgloss"
9- "github.com/charmbracelet/lipgloss/table"
10- "github.com/picosh/pico/db"
11-)
12-
13-func UniqueVisitorsTbl(intervals []*db.VisitInterval, renderer *lipgloss.Renderer, maxWidth int) *table.Table {
14- headers := []string{"Date", "Unique Visitors"}
15- data := [][]string{}
16- sum := 0
17- for _, d := range intervals {
18- data = append(data, []string{
19- d.Interval.Format(time.DateOnly),
20- fmt.Sprintf("%d", d.Visitors),
21- })
22- sum += d.Visitors
23- }
24-
25- data = append(data, []string{
26- "Total",
27- fmt.Sprintf("%d", sum),
28- })
29-
30- t := table.New().
31- BorderStyle(renderer.NewStyle().BorderForeground(Indigo)).
32- Width(maxWidth).
33- Headers(headers...).
34- Rows(data...)
35- return t
36-}
37-
38-func VisitUrlsTbl(urls []*db.VisitUrl, renderer *lipgloss.Renderer, maxWidth int) *table.Table {
39- headers := []string{"URL", "Count"}
40- data := [][]string{}
41- sum := 0
42- for _, d := range urls {
43- data = append(data, []string{
44- d.Url,
45- fmt.Sprintf("%d", d.Count),
46- })
47- sum += d.Count
48- }
49-
50- data = append(data, []string{
51- "Total",
52- fmt.Sprintf("%d", sum),
53- })
54-
55- t := table.New().
56- BorderStyle(renderer.NewStyle().BorderForeground(Indigo)).
57- Width(maxWidth).
58- Headers(headers...).
59- Rows(data...)
60- return t
61-}
+0,
-5
1@@ -1,5 +0,0 @@
2-package common
3-
4-import "time"
5-
6-var DateFormat = time.DateOnly
+0,
-117
1@@ -1,117 +0,0 @@
2-package common
3-
4-import (
5- "fmt"
6- "strings"
7-
8- "github.com/charmbracelet/lipgloss"
9-)
10-
11-// State is a general UI state used to help style components.
12-type State int
13-
14-// UI states.
15-const (
16- StateNormal State = iota
17- StateSelected
18- StateActive
19- StateSpecial
20- StateDeleting
21-)
22-
23-var lineColors = map[State]lipgloss.TerminalColor{
24- StateNormal: lipgloss.AdaptiveColor{Light: "#BCBCBC", Dark: "#646464"},
25- StateSelected: lipgloss.Color("#F684FF"),
26- StateDeleting: lipgloss.AdaptiveColor{Light: "#FF8BA7", Dark: "#893D4E"},
27- StateSpecial: lipgloss.Color("#04B575"),
28-}
29-
30-// VerticalLine return a vertical line colored according to the given state.
31-func VerticalLine(renderer *lipgloss.Renderer, state State) string {
32- return renderer.NewStyle().
33- SetString("│").
34- Foreground(lineColors[state]).
35- String()
36-}
37-
38-// KeyValueView renders key-value pairs.
39-func KeyValueView(styles Styles, stuff ...string) string {
40- if len(stuff) == 0 {
41- return ""
42- }
43-
44- var (
45- s string
46- index int
47- )
48- for i := 0; i < len(stuff); i++ {
49- if i%2 == 0 {
50- // even: key
51- s += fmt.Sprintf("%s %s: ", VerticalLine(styles.Renderer, StateNormal), stuff[i])
52- continue
53- }
54- // odd: value
55- s += styles.Label.Render(stuff[i])
56- s += "\n"
57- index++
58- }
59-
60- return strings.TrimSpace(s)
61-}
62-
63-// OKButtonView returns a button reading "OK".
64-func OKButtonView(styles Styles, focused bool, defaultButton bool) string {
65- return styledButton(styles, "OK", defaultButton, focused)
66-}
67-
68-// CancelButtonView returns a button reading "Cancel.".
69-func CancelButtonView(styles Styles, focused bool, defaultButton bool) string {
70- return styledButton(styles, "Cancel", defaultButton, focused)
71-}
72-
73-func styledButton(styles Styles, str string, underlined, focused bool) string {
74- var st lipgloss.Style
75- if focused {
76- st = styles.FocusedButtonStyle
77- } else {
78- st = styles.BlurredButtonStyle
79- }
80- if underlined {
81- st = st.Underline(true)
82- }
83- return st.Render(str)
84-}
85-
86-// HelpView renders text intended to display at help text, often at the
87-// bottom of a view.
88-func HelpView(styles Styles, sections ...string) string {
89- var s string
90- if len(sections) == 0 {
91- return s
92- }
93-
94- for i := 0; i < len(sections); i++ {
95- s += styles.HelpSection.Render(sections[i])
96- if i < len(sections)-1 {
97- s += styles.HelpDivider.Render()
98- }
99- }
100-
101- return s
102-}
103-
104-func LogoView() string {
105- return `
106- . ."
107- i-~l^ 'I~??!
108- I??_??-<I^ .,!_??+<-?I
109- _-+ .,!+??->:;<??-<;' +-_
110- '-?i ':i_??_!". i?-'
111- _-+ '' +-_
112- I??I I??I
113- !??l. .l??i
114- ;_?_I' 'I_?_;
115- .I+??_>l:,,:l>_??+I.
116- ';i+--??--+i;'
117- ....`
118-}
+0,
-249
1@@ -1,249 +0,0 @@
2-package createaccount
3-
4-import (
5- "errors"
6- "fmt"
7- "strings"
8-
9- input "github.com/charmbracelet/bubbles/textinput"
10- tea "github.com/charmbracelet/bubbletea"
11- "github.com/picosh/pico/db"
12- "github.com/picosh/pico/tui/common"
13- "github.com/picosh/utils"
14-)
15-
16-type state int
17-
18-const (
19- ready state = iota
20- submitting
21-)
22-
23-// index specifies the UI element that's in focus.
24-type index int
25-
26-const (
27- textInput index = iota
28- okButton
29- cancelButton
30-)
31-
32-type CreateAccountMsg *db.User
33-
34-// NameTakenMsg is sent when the requested username has already been taken.
35-type NameTakenMsg struct{}
36-
37-// NameInvalidMsg is sent when the requested username has failed validation.
38-type NameInvalidMsg struct{}
39-
40-type errMsg struct{ err error }
41-
42-func (e errMsg) Error() string { return e.err.Error() }
43-
44-var deny = strings.Join(db.DenyList, ", ")
45-var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
46-
47-// Model holds the state of the username UI.
48-type Model struct {
49- shared *common.SharedModel
50-
51- state state
52- newName string
53- index index
54- errMsg string
55- input input.Model
56-}
57-
58-// NewModel returns a new username model in its initial state.
59-func NewModel(shared *common.SharedModel) Model {
60- im := input.New()
61- im.Cursor.Style = shared.Styles.Cursor
62- im.Placeholder = "enter username"
63- im.PlaceholderStyle = shared.Styles.InputPlaceholder
64- im.Prompt = shared.Styles.FocusedPrompt.String()
65- im.CharLimit = 50
66- im.Focus()
67-
68- return Model{
69- shared: shared,
70- state: ready,
71- newName: "",
72- index: textInput,
73- errMsg: "",
74- input: im,
75- }
76-}
77-
78-// updateFocus updates the focused states in the model based on the current
79-// focus index.
80-func (m *Model) updateFocus() {
81- if m.index == textInput && !m.input.Focused() {
82- m.input.Focus()
83- m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
84- } else if m.index != textInput && m.input.Focused() {
85- m.input.Blur()
86- m.input.Prompt = m.shared.Styles.Prompt.String()
87- }
88-}
89-
90-// Move the focus index one unit forward.
91-func (m *Model) indexForward() {
92- m.index++
93- if m.index > cancelButton {
94- m.index = textInput
95- }
96-
97- m.updateFocus()
98-}
99-
100-// Move the focus index one unit backwards.
101-func (m *Model) indexBackward() {
102- m.index--
103- if m.index < textInput {
104- m.index = cancelButton
105- }
106-
107- m.updateFocus()
108-}
109-
110-// Init is the Bubble Tea initialization function.
111-func (m Model) Init() tea.Cmd {
112- return input.Blink
113-}
114-
115-// Update is the Bubble Tea update loop.
116-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117- switch msg := msg.(type) {
118- case tea.KeyMsg:
119- // Ignore keys if we're submitting
120- if m.state == submitting {
121- return m, nil
122- }
123-
124- switch msg.String() {
125- case "tab":
126- m.indexForward()
127- case "shift+tab":
128- m.indexBackward()
129- case "l", "k", "right":
130- if m.index != textInput {
131- m.indexForward()
132- }
133- case "h", "j", "left":
134- if m.index != textInput {
135- m.indexBackward()
136- }
137- case "up", "down":
138- if m.index == textInput {
139- m.indexForward()
140- } else {
141- m.index = textInput
142- m.updateFocus()
143- }
144- case "enter":
145- switch m.index {
146- case textInput:
147- fallthrough
148- case okButton: // Submit the form
149- m.state = submitting
150- m.errMsg = ""
151- m.newName = strings.TrimSpace(m.input.Value())
152-
153- return m, m.createAccount()
154- case cancelButton: // Exit
155- return m, tea.Quit
156- }
157- }
158-
159- // Pass messages through to the input element if that's the element
160- // in focus
161- if m.index == textInput {
162- var cmd tea.Cmd
163- m.input, cmd = m.input.Update(msg)
164-
165- return m, cmd
166- }
167-
168- return m, nil
169-
170- case NameTakenMsg:
171- m.state = ready
172- m.errMsg = m.shared.Styles.Subtle.Render("Sorry, ") +
173- m.shared.Styles.Error.Render(m.newName) +
174- m.shared.Styles.Subtle.Render(" is taken.")
175-
176- return m, nil
177-
178- case NameInvalidMsg:
179- m.state = ready
180- head := m.shared.Styles.Error.Render("Invalid name.")
181- m.errMsg = m.shared.Styles.Wrap.Render(head)
182-
183- return m, nil
184-
185- case errMsg:
186- m.state = ready
187- head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
188- body := m.shared.Styles.Subtle.Render(msg.Error())
189- m.errMsg = m.shared.Styles.Wrap.Render(head + body)
190-
191- return m, nil
192-
193- default:
194- var cmd tea.Cmd
195- m.input, cmd = m.input.Update(msg) // Do we still need this?
196-
197- return m, cmd
198- }
199-}
200-
201-// View renders current view from the model.
202-func (m Model) View() string {
203- s := common.LogoView() + "\n\n"
204- pubkey := fmt.Sprintf("pubkey: %s", utils.KeyForSha256(m.shared.Session.PublicKey()))
205- s += "\nWelcome to pico.sh's management TUI. By creating an account you get access to our pico services. We have free and paid services. After you create an account, you can go to the Settings page to see which services you can access.\n\n"
206- s += m.shared.Styles.Label.SetString(pubkey).String()
207- s += "\n\n" + m.input.View() + "\n\n"
208-
209- if m.state == submitting {
210- s += m.spinnerView()
211- } else {
212- s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
213- s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
214- if m.errMsg != "" {
215- s += "\n\n" + m.errMsg
216- }
217- }
218- s += fmt.Sprintf("\n\n%s\n", m.shared.Styles.HelpSection.SetString(helpMsg))
219-
220- return s
221-}
222-
223-func (m Model) spinnerView() string {
224- return "Creating account..."
225-}
226-
227-func (m *Model) createAccount() tea.Cmd {
228- return func() tea.Msg {
229- if m.newName == "" {
230- return NameInvalidMsg{}
231- }
232-
233- key := utils.KeyForKeyText(m.shared.Session.PublicKey())
234-
235- user, err := m.shared.Dbpool.RegisterUser(m.newName, key, "")
236- if err != nil {
237- if errors.Is(err, db.ErrNameTaken) {
238- return NameTakenMsg{}
239- } else if errors.Is(err, db.ErrNameInvalid) {
240- return NameInvalidMsg{}
241- } else if errors.Is(err, db.ErrNameDenied) {
242- return NameInvalidMsg{}
243- } else {
244- return errMsg{err}
245- }
246- }
247-
248- return CreateAccountMsg(user)
249- }
250-}
+0,
-254
1@@ -1,254 +0,0 @@
2-package createkey
3-
4-import (
5- "errors"
6- "strings"
7-
8- input "github.com/charmbracelet/bubbles/textinput"
9- tea "github.com/charmbracelet/bubbletea"
10- "github.com/picosh/pico/db"
11- "github.com/picosh/pico/tui/common"
12- "github.com/picosh/pico/tui/pages"
13- "github.com/picosh/utils"
14- "golang.org/x/crypto/ssh"
15-)
16-
17-type state int
18-
19-const (
20- ready state = iota
21- submitting
22-)
23-
24-type index int
25-
26-const (
27- textInput index = iota
28- okButton
29- cancelButton
30-)
31-
32-type KeySetMsg string
33-
34-type KeyInvalidMsg struct{}
35-type KeyTakenMsg struct{}
36-
37-type errMsg struct {
38- err error
39-}
40-
41-func (e errMsg) Error() string { return e.err.Error() }
42-
43-type Model struct {
44- shared *common.SharedModel
45-
46- state state
47- newKey string
48- index index
49- errMsg string
50- input input.Model
51-}
52-
53-// updateFocus updates the focused states in the model based on the current
54-// focus index.
55-func (m *Model) updateFocus() {
56- if m.index == textInput && !m.input.Focused() {
57- m.input.Focus()
58- m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
59- } else if m.index != textInput && m.input.Focused() {
60- m.input.Blur()
61- m.input.Prompt = m.shared.Styles.Prompt.String()
62- }
63-}
64-
65-// Move the focus index one unit forward.
66-func (m *Model) indexForward() {
67- m.index++
68- if m.index > cancelButton {
69- m.index = textInput
70- }
71-
72- m.updateFocus()
73-}
74-
75-// Move the focus index one unit backwards.
76-func (m *Model) indexBackward() {
77- m.index--
78- if m.index < textInput {
79- m.index = cancelButton
80- }
81-
82- m.updateFocus()
83-}
84-
85-// NewModel returns a new username model in its initial state.
86-func NewModel(shared *common.SharedModel) Model {
87- im := input.New()
88- im.PlaceholderStyle = shared.Styles.InputPlaceholder
89- im.Cursor.Style = shared.Styles.Cursor
90- im.Placeholder = "ssh-ed25519 AAAA..."
91- im.Prompt = shared.Styles.FocusedPrompt.String()
92- im.CharLimit = 2049
93- im.Focus()
94-
95- return Model{
96- shared: shared,
97-
98- state: ready,
99- newKey: "",
100- index: textInput,
101- errMsg: "",
102- input: im,
103- }
104-}
105-
106-// Init is the Bubble Tea initialization function.
107-func (m Model) Init() tea.Cmd {
108- return input.Blink
109-}
110-
111-// Update is the Bubble Tea update loop.
112-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113- switch msg := msg.(type) {
114- case tea.KeyMsg:
115- switch msg.Type {
116- case tea.KeyEscape: // exit this mini-app
117- return m, pages.Navigate(pages.PubkeysPage)
118-
119- default:
120- // Ignore keys if we're submitting
121- if m.state == submitting {
122- return m, nil
123- }
124-
125- switch msg.String() {
126- case "tab":
127- m.indexForward()
128- case "shift+tab":
129- m.indexBackward()
130- case "l", "k", "right":
131- if m.index != textInput {
132- m.indexForward()
133- }
134- case "h", "j", "left":
135- if m.index != textInput {
136- m.indexBackward()
137- }
138- case "up", "down":
139- if m.index == textInput {
140- m.indexForward()
141- } else {
142- m.index = textInput
143- m.updateFocus()
144- }
145- case "enter":
146- switch m.index {
147- case textInput:
148- fallthrough
149- case okButton: // Submit the form
150- m.state = submitting
151- m.errMsg = ""
152- m.newKey = strings.TrimSpace(m.input.Value())
153-
154- return m, m.addPublicKey()
155- case cancelButton:
156- return m, pages.Navigate(pages.PubkeysPage)
157- }
158- }
159-
160- // Pass messages through to the input element if that's the element
161- // in focus
162- if m.index == textInput {
163- var cmd tea.Cmd
164- m.input, cmd = m.input.Update(msg)
165-
166- return m, cmd
167- }
168-
169- return m, nil
170- }
171-
172- case KeySetMsg:
173- return m, pages.Navigate(pages.PubkeysPage)
174-
175- case KeyInvalidMsg:
176- m.state = ready
177- head := m.shared.Styles.Error.Render("Invalid public key. ")
178- helpMsg := "Public keys must but in the correct format"
179- body := m.shared.Styles.Subtle.Render(helpMsg)
180- m.errMsg = m.shared.Styles.Wrap.Render(head + body)
181-
182- return m, nil
183-
184- case KeyTakenMsg:
185- m.state = ready
186- head := m.shared.Styles.Error.Render("Invalid public key. ")
187- helpMsg := "Public key is associated with another user"
188- body := m.shared.Styles.Subtle.Render(helpMsg)
189- m.errMsg = m.shared.Styles.Wrap.Render(head + body)
190-
191- return m, nil
192-
193- case errMsg:
194- m.state = ready
195- head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
196- body := m.shared.Styles.Subtle.Render(msg.Error())
197- m.errMsg = m.shared.Styles.Wrap.Render(head + body)
198-
199- return m, nil
200-
201- // leaving page so reset model
202- case pages.NavigateMsg:
203- next := NewModel(m.shared)
204- return next, next.Init()
205-
206- default:
207- var cmd tea.Cmd
208- m.input, cmd = m.input.Update(msg) // Do we still need this?
209-
210- return m, cmd
211- }
212-}
213-
214-// View renders current view from the model.
215-func (m Model) View() string {
216- s := "Enter a new public key\n\n"
217- s += m.input.View() + "\n\n"
218-
219- if m.state == submitting {
220- s += m.spinnerView()
221- } else {
222- s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
223- s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
224- if m.errMsg != "" {
225- s += "\n\n" + m.errMsg
226- }
227- }
228-
229- return s
230-}
231-
232-func (m Model) spinnerView() string {
233- return "Submitting..."
234-}
235-
236-func (m *Model) addPublicKey() tea.Cmd {
237- return func() tea.Msg {
238- pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(m.newKey))
239- if err != nil {
240- return KeyInvalidMsg{}
241- }
242-
243- key := utils.KeyForKeyText(pk)
244-
245- err = m.shared.Dbpool.InsertPublicKey(m.shared.User.ID, key, comment, nil)
246- if err != nil {
247- if errors.Is(err, db.ErrPublicKeyTaken) {
248- return KeyTakenMsg{}
249- }
250- return errMsg{err}
251- }
252-
253- return KeySetMsg(m.newKey)
254- }
255-}
+0,
-232
1@@ -1,232 +0,0 @@
2-package createtoken
3-
4-import (
5- "fmt"
6- "strings"
7-
8- input "github.com/charmbracelet/bubbles/textinput"
9- tea "github.com/charmbracelet/bubbletea"
10- "github.com/picosh/pico/tui/common"
11- "github.com/picosh/pico/tui/pages"
12-)
13-
14-type state int
15-
16-const (
17- ready state = iota
18- submitting
19- submitted
20-)
21-
22-type index int
23-
24-const (
25- textInput index = iota
26- okButton
27- cancelButton
28-)
29-
30-type TokenSetMsg struct {
31- token string
32-}
33-
34-type errMsg struct {
35- err error
36-}
37-
38-func (e errMsg) Error() string { return e.err.Error() }
39-
40-type Model struct {
41- shared *common.SharedModel
42-
43- state state
44- tokenName string
45- token string
46- index index
47- errMsg string
48- input input.Model
49-}
50-
51-// NewModel returns a new username model in its initial state.
52-func NewModel(shared *common.SharedModel) Model {
53- im := input.New()
54- im.Cursor.Style = shared.Styles.Cursor
55- im.Placeholder = "A name used for your reference"
56- im.PlaceholderStyle = shared.Styles.InputPlaceholder
57- im.Prompt = shared.Styles.FocusedPrompt.String()
58- im.CharLimit = 256
59- im.Focus()
60-
61- return Model{
62- shared: shared,
63-
64- state: ready,
65- tokenName: "",
66- token: "",
67- index: textInput,
68- errMsg: "",
69- input: im,
70- }
71-}
72-
73-// updateFocus updates the focused states in the model based on the current
74-// focus index.
75-func (m *Model) updateFocus() {
76- if m.index == textInput && !m.input.Focused() {
77- m.input.Focus()
78- m.input.Prompt = m.shared.Styles.FocusedPrompt.String()
79- } else if m.index != textInput && m.input.Focused() {
80- m.input.Blur()
81- m.input.Prompt = m.shared.Styles.Prompt.String()
82- }
83-}
84-
85-// Move the focus index one unit forward.
86-func (m *Model) indexForward() {
87- m.index += 1
88- if m.index > cancelButton {
89- m.index = textInput
90- }
91-
92- m.updateFocus()
93-}
94-
95-// Move the focus index one unit backwards.
96-func (m *Model) indexBackward() {
97- m.index -= 1
98- if m.index < textInput {
99- m.index = cancelButton
100- }
101-
102- m.updateFocus()
103-}
104-
105-// Init is the Bubble Tea initialization function.
106-func (m Model) Init() tea.Cmd {
107- return input.Blink
108-}
109-
110-// Update is the Bubble Tea update loop.
111-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112- switch msg := msg.(type) {
113- case tea.KeyMsg:
114- // Ignore keys if we're submitting
115- if m.state == submitting {
116- return m, nil
117- }
118-
119- switch msg.String() {
120- case "q", "esc":
121- return m, pages.Navigate(pages.TokensPage)
122- case "tab":
123- m.indexForward()
124- case "shift+tab":
125- m.indexBackward()
126- case "l", "k", "right":
127- if m.index != textInput {
128- m.indexForward()
129- }
130- case "h", "j", "left":
131- if m.index != textInput {
132- m.indexBackward()
133- }
134- case "up", "down":
135- if m.index == textInput {
136- m.indexForward()
137- } else {
138- m.index = textInput
139- m.updateFocus()
140- }
141- case "enter":
142- switch m.index {
143- case textInput:
144- fallthrough
145- case okButton: // Submit the form
146- // form already submitted so ok button exits
147- if m.state == submitted {
148- return m, pages.Navigate(pages.TokensPage)
149- }
150-
151- m.state = submitting
152- m.errMsg = ""
153- m.tokenName = strings.TrimSpace(m.input.Value())
154-
155- return m, m.addToken()
156- case cancelButton:
157- return m, pages.Navigate(pages.TokensPage)
158- }
159- }
160-
161- // Pass messages through to the input element if that's the element
162- // in focus
163- if m.index == textInput {
164- var cmd tea.Cmd
165- m.input, cmd = m.input.Update(msg)
166-
167- return m, cmd
168- }
169-
170- return m, nil
171-
172- case TokenSetMsg:
173- m.state = submitted
174- m.token = msg.token
175- return m, nil
176-
177- case errMsg:
178- m.state = ready
179- head := m.shared.Styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
180- body := m.shared.Styles.Subtle.Render(msg.Error())
181- m.errMsg = m.shared.Styles.Wrap.Render(head + body)
182-
183- return m, nil
184-
185- // leaving page so reset model
186- case pages.NavigateMsg:
187- next := NewModel(m.shared)
188- return next, next.Init()
189-
190- default:
191- var cmd tea.Cmd
192- m.input, cmd = m.input.Update(msg) // Do we still need this?
193-
194- return m, cmd
195- }
196-}
197-
198-// View renders current view from the model.
199-func (m Model) View() string {
200- s := "Enter a name for your token\n\n"
201- s += m.input.View() + "\n\n"
202-
203- if m.state == submitting {
204- s += spinnerView()
205- } else if m.state == submitted {
206- s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
207- s += "After you exit this screen you will *not* be able to see it again.\n\n"
208- s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
209- } else {
210- s += common.OKButtonView(m.shared.Styles, m.index == 1, true)
211- s += " " + common.CancelButtonView(m.shared.Styles, m.index == 2, false)
212- if m.errMsg != "" {
213- s += "\n\n" + m.errMsg
214- }
215- }
216-
217- return s
218-}
219-
220-func (m *Model) addToken() tea.Cmd {
221- return func() tea.Msg {
222- token, err := m.shared.Dbpool.InsertToken(m.shared.User.ID, m.tokenName)
223- if err != nil {
224- return errMsg{err}
225- }
226-
227- return TokenSetMsg{token}
228- }
229-}
230-
231-func spinnerView() string {
232- return "Submitting..."
233-}
+40,
-0
1@@ -0,0 +1,40 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+)
8+
9+type GroupStack struct {
10+ s []vxfw.Surface
11+ Gap int
12+ Direction string
13+}
14+
15+func NewGroupStack(widgets []vxfw.Surface) *GroupStack {
16+ return &GroupStack{
17+ s: widgets,
18+ Gap: 0,
19+ Direction: "vertical",
20+ }
21+}
22+
23+func (m *GroupStack) HandleEvent(vaxis.Event, vxfw.EventPhase) (vxfw.Command, error) {
24+ return nil, nil
25+}
26+
27+func (m *GroupStack) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
28+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
29+ ah := 0
30+ for _, surf := range m.s {
31+ if m.Direction == "vertical" {
32+ root.AddChild(0, ah, surf)
33+ ah += int(surf.Size.Height) + m.Gap
34+ } else {
35+ // horizontal
36+ root.AddChild(ah, 0, surf)
37+ ah += int(surf.Size.Width) + m.Gap
38+ }
39+ }
40+ return root, nil
41+}
+243,
-0
1@@ -0,0 +1,243 @@
2+package tui
3+
4+import (
5+ "fmt"
6+ "time"
7+
8+ "git.sr.ht/~rockorager/vaxis"
9+ "git.sr.ht/~rockorager/vaxis/vxfw"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
11+ "github.com/picosh/pico/db"
12+)
13+
14+type UsageInfo struct {
15+ stats *db.UserServiceStats
16+ Label string
17+}
18+
19+func NewUsageInfo(label string, stats *db.UserServiceStats) *UsageInfo {
20+ return &UsageInfo{Label: label, stats: stats}
21+}
22+
23+func (m *UsageInfo) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
24+ return nil, nil
25+}
26+
27+func (m *UsageInfo) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
28+ info := NewKv(m.getKv)
29+ brd := NewBorder(info)
30+ brd.Label = m.Label
31+ brd.Style = vaxis.Style{Foreground: purp}
32+ return brd.Draw(vxfw.DrawContext{
33+ Characters: ctx.Characters,
34+ Max: vxfw.Size{
35+ Width: 30,
36+ Height: 3 + 3,
37+ },
38+ })
39+}
40+
41+func (m *UsageInfo) getKv(idx uint16) (vxfw.Widget, vxfw.Widget) {
42+ if int(idx) >= 3 {
43+ return nil, nil
44+ }
45+ label := "posts"
46+ if m.Label == "pages" {
47+ label = "sites"
48+ }
49+ kv := [][]string{
50+ {label, fmt.Sprintf("%d", m.stats.Num)},
51+ {"oldest", m.stats.FirstCreatedAt.Format(time.DateOnly)},
52+ {"newest", m.stats.LastestCreatedAt.Format(time.DateOnly)},
53+ }
54+ return text.New(kv[idx][0]), text.New(kv[idx][1])
55+}
56+
57+type UserInfo struct {
58+ shared *SharedModel
59+}
60+
61+func NewUserInfo(shrd *SharedModel) *UserInfo {
62+ return &UserInfo{shrd}
63+}
64+
65+func (m *UserInfo) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
66+ return nil, nil
67+}
68+
69+func (m *UserInfo) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
70+ features := NewKv(m.getKv)
71+ brd := NewBorder(features)
72+ brd.Label = "info"
73+ brd.Style = vaxis.Style{Foreground: purp}
74+ h := 1
75+ if m.shared.PlusFeatureFlag != nil {
76+ h += 1
77+ }
78+ return brd.Draw(vxfw.DrawContext{
79+ Characters: ctx.Characters,
80+ Max: vxfw.Size{
81+ Width: 30,
82+ Height: uint16(h) + 3,
83+ },
84+ })
85+}
86+
87+func (m *UserInfo) getKv(idx uint16) (vxfw.Widget, vxfw.Widget) {
88+ if int(idx) >= 2 {
89+ return nil, nil
90+ }
91+
92+ createdAt := m.shared.User.CreatedAt.Format(time.DateOnly)
93+ if idx == 0 {
94+ return text.New("joined"), text.New(createdAt)
95+ }
96+
97+ if m.shared.PlusFeatureFlag != nil {
98+ expiresAt := m.shared.PlusFeatureFlag.ExpiresAt.Format(time.DateOnly)
99+ return text.New("pico+ expires"), text.New(expiresAt)
100+ }
101+
102+ return nil, nil
103+}
104+
105+type FeaturesList struct {
106+ shared *SharedModel
107+ features []*db.FeatureFlag
108+ err error
109+}
110+
111+func NewFeaturesList(shrd *SharedModel) *FeaturesList {
112+ return &FeaturesList{shared: shrd}
113+}
114+
115+func (m *FeaturesList) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
116+ switch ev.(type) {
117+ case vxfw.Init:
118+ m.err = m.fetchFeatures()
119+ return vxfw.RedrawCmd{}, nil
120+ }
121+ return nil, nil
122+}
123+
124+func (m *FeaturesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
125+ features := NewKv(m.getFeaturesKv)
126+ brd := NewBorder(features)
127+ brd.Label = "features"
128+ brd.Style = vaxis.Style{Foreground: purp}
129+ return brd.Draw(vxfw.DrawContext{
130+ Characters: ctx.Characters,
131+ Max: vxfw.Size{
132+ Width: 30,
133+ Height: uint16(len(m.features)) + 4,
134+ },
135+ })
136+}
137+
138+func (m *FeaturesList) fetchFeatures() error {
139+ features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
140+ m.features = features
141+ return err
142+}
143+
144+func (m *FeaturesList) getFeaturesKv(idx uint16) (vxfw.Widget, vxfw.Widget) {
145+ kv := [][]string{
146+ {"name", "expires at"},
147+ }
148+ for _, feature := range m.features {
149+ kv = append(kv, []string{feature.Name, feature.ExpiresAt.Format(time.DateOnly)})
150+ }
151+
152+ if int(idx) >= len(kv) {
153+ return nil, nil
154+ }
155+
156+ key := text.New(kv[idx][0])
157+ value := text.New(kv[idx][1])
158+
159+ if idx == 0 {
160+ style := vaxis.Style{UnderlineColor: purp, UnderlineStyle: vaxis.UnderlineDashed, Foreground: purp}
161+ key.Style = style
162+ value.Style = style
163+ }
164+
165+ return key, value
166+}
167+
168+type ServicesList struct {
169+ ff *db.FeatureFlag
170+}
171+
172+func NewServicesList(ff *db.FeatureFlag) *ServicesList {
173+ return &ServicesList{ff}
174+}
175+
176+func (m *ServicesList) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
177+ return nil, nil
178+}
179+
180+func (m *ServicesList) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
181+ services := NewKv(m.getServiceKv)
182+ brd := NewBorder(services)
183+ brd.Label = "services"
184+ brd.Style = vaxis.Style{Foreground: purp}
185+ servicesHeight := 8
186+ return brd.Draw(vxfw.DrawContext{
187+ Characters: ctx.Characters,
188+ Max: vxfw.Size{
189+ Width: 30,
190+ Height: uint16(servicesHeight) + 3,
191+ },
192+ })
193+}
194+
195+func (m *ServicesList) getServiceKv(idx uint16) (vxfw.Widget, vxfw.Widget) {
196+ hasPlus := m.ff != nil
197+ kv := [][]string{
198+ {"name", "status"},
199+ {"prose", "active"},
200+ {"pipe", "active"},
201+ {"pastes", "active"},
202+ {"rss-to-email", "active"},
203+ }
204+
205+ if hasPlus {
206+ kv = append(
207+ kv,
208+ []string{"pages", "active"},
209+ []string{"tuns", "active"},
210+ []string{"irc bouncer", "active"},
211+ )
212+ } else {
213+ kv = append(
214+ kv,
215+ []string{"pages", "free tier"},
216+ []string{"tuns", "pico+"},
217+ []string{"irc bouncer", "pico+"},
218+ )
219+ }
220+
221+ if int(idx) >= len(kv) {
222+ return nil, nil
223+ }
224+
225+ key := text.New(kv[idx][0])
226+ value := text.New(kv[idx][1])
227+ val := kv[idx][1]
228+
229+ if val == "active" {
230+ value.Style = vaxis.Style{Foreground: green}
231+ } else if val == "free tier" {
232+ value.Style = vaxis.Style{Foreground: oj}
233+ } else {
234+ value.Style = vaxis.Style{Foreground: red}
235+ }
236+
237+ if idx == 0 {
238+ style := vaxis.Style{UnderlineColor: purp, UnderlineStyle: vaxis.UnderlineDashed, Foreground: purp}
239+ key.Style = style
240+ value.Style = style
241+ }
242+
243+ return key, value
244+}
+76,
-0
1@@ -0,0 +1,76 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
8+ "git.sr.ht/~rockorager/vaxis/vxfw/textfield"
9+)
10+
11+type TextInput struct {
12+ input *textfield.TextField
13+ label string
14+ focus bool
15+}
16+
17+func (m *TextInput) GetValue() string {
18+ return m.input.Value
19+}
20+
21+func (m *TextInput) Reset() {
22+ m.input.Reset()
23+}
24+
25+func NewTextInput(label string) *TextInput {
26+ input := textfield.New()
27+ return &TextInput{
28+ label: label,
29+ input: input,
30+ }
31+}
32+
33+func (m *TextInput) FocusIn() (vxfw.Command, error) {
34+ m.focus = true
35+ return vxfw.FocusWidgetCmd(m.input), nil
36+}
37+
38+func (m *TextInput) FocusOut() (vxfw.Command, error) {
39+ m.focus = false
40+ return vxfw.RedrawCmd{}, nil
41+}
42+
43+func (m *TextInput) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
44+ switch msg := ev.(type) {
45+ case vaxis.Key:
46+ if msg.Matches(vaxis.KeyTab) {
47+ m.focus = !m.focus
48+ return vxfw.RedrawCmd{}, nil
49+ }
50+ }
51+ return nil, nil
52+}
53+
54+func (m *TextInput) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
55+ txt := text.New("> ")
56+ if m.focus {
57+ txt.Style = vaxis.Style{Foreground: oj}
58+ } else {
59+ txt.Style = vaxis.Style{Foreground: purp}
60+ }
61+ txtSurf, _ := txt.Draw(ctx)
62+ inputSurf, _ := m.input.Draw(ctx)
63+ stack := NewGroupStack([]vxfw.Surface{
64+ txtSurf,
65+ inputSurf,
66+ })
67+ stack.Direction = "horizontal"
68+ brd := NewBorder(stack)
69+ brd.Label = m.label
70+ if m.focus {
71+ brd.Style = vaxis.Style{Foreground: oj}
72+ } else {
73+ brd.Style = vaxis.Style{Foreground: purp}
74+ }
75+ surf, _ := brd.Draw(ctx)
76+ return surf, nil
77+}
+50,
-0
1@@ -0,0 +1,50 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+)
8+
9+type KVBuilder func(uint16) (vxfw.Widget, vxfw.Widget)
10+
11+type KvData struct {
12+ Builder KVBuilder
13+ KeyColWidth int
14+}
15+
16+func NewKv(builder KVBuilder) *KvData {
17+ return &KvData{
18+ Builder: builder,
19+ KeyColWidth: 15,
20+ }
21+}
22+
23+func (m *KvData) HandleEvent(vaxis.Event, vxfw.EventPhase) (vxfw.Command, error) {
24+ return nil, nil
25+}
26+
27+func (m *KvData) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
28+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
29+ lng := m.KeyColWidth
30+ left := vxfw.NewSurface(uint16(lng), ctx.Max.Height, m)
31+ right := vxfw.NewSurface(ctx.Max.Width-uint16(lng), ctx.Max.Height, m)
32+
33+ ah := 0
34+ var idx uint16 = 0
35+ for {
36+ key, value := m.Builder(idx)
37+ if key == nil {
38+ break
39+ }
40+ lft, _ := key.Draw(ctx)
41+ left.AddChild(0, ah, lft)
42+ rht, _ := value.Draw(ctx)
43+ right.AddChild(0, ah, rht)
44+ idx += 1
45+ ah += 1
46+ }
47+ root.AddChild(0, 0, left)
48+ root.AddChild(lng, 0, right)
49+
50+ return root, nil
51+}
+257,
-0
1@@ -0,0 +1,257 @@
2+package tui
3+
4+import (
5+ "bufio"
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "git.sr.ht/~rockorager/vaxis"
13+ "git.sr.ht/~rockorager/vaxis/vxfw"
14+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
15+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
16+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
17+ "github.com/picosh/pico/shared"
18+ "github.com/picosh/utils"
19+ pipeLogger "github.com/picosh/utils/pipe/log"
20+)
21+
22+type LogLineLoaded struct {
23+ Line map[string]any
24+}
25+
26+type LogsPage struct {
27+ shared *SharedModel
28+
29+ input *TextInput
30+ list *list.Dynamic
31+ focus string
32+ logs []*LogLine
33+ filtered []int
34+ ctx context.Context
35+ done context.CancelFunc
36+}
37+
38+func NewLogsPage(shrd *SharedModel) *LogsPage {
39+ ctx, cancel := context.WithCancel(shrd.Session.Context())
40+ page := &LogsPage{
41+ shared: shrd,
42+ input: NewTextInput("filter logs"),
43+ ctx: ctx,
44+ done: cancel,
45+ }
46+ page.list = &list.Dynamic{Builder: page.getWidget, DisableEventHandlers: true}
47+ return page
48+}
49+
50+func (m *LogsPage) Footer() []Shortcut {
51+ return []Shortcut{
52+ {Shortcut: "tab", Text: "focus"},
53+ }
54+}
55+
56+func (m *LogsPage) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
57+ switch msg := ev.(type) {
58+ case vaxis.Key:
59+ if msg.Matches(vaxis.KeyTab) {
60+ if m.focus == "list" {
61+ m.focus = "input"
62+ return m.input.FocusIn()
63+ }
64+ m.focus = "list"
65+ cmd, _ := m.input.FocusOut()
66+ return vxfw.BatchCmd([]vxfw.Command{cmd, vxfw.FocusWidgetCmd(m.list)}), nil
67+ }
68+
69+ if m.focus == "input" {
70+ m.filterLogs()
71+ return vxfw.RedrawCmd{}, nil
72+ }
73+ }
74+ return nil, nil
75+}
76+
77+func (m *LogsPage) filterLogs() {
78+ match := m.input.GetValue()
79+ m.filtered = []int{}
80+ for idx, ll := range m.logs {
81+ if match == "" {
82+ m.filtered = append(m.filtered, idx)
83+ continue
84+ }
85+
86+ lvlMatch := matched(ll.Level, match)
87+ msgMatch := matched(ll.Msg, match)
88+ serviceMatch := matched(ll.Service, match)
89+ errMatch := matched(ll.ErrMsg, match)
90+ urlMatch := matched(ll.Url, match)
91+ statusMatch := matched(fmt.Sprintf("%d", ll.Status), match)
92+ if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
93+ continue
94+ }
95+
96+ m.filtered = append(m.filtered, idx)
97+ }
98+
99+ if len(m.filtered) > 0 {
100+ m.list.SetCursor(uint(len(m.filtered) - 1))
101+ }
102+}
103+
104+func (m *LogsPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
105+ switch msg := ev.(type) {
106+ case PageIn:
107+ go func() {
108+ _ = m.connectToLogs()
109+ }()
110+ return m.input.FocusIn()
111+ case PageOut:
112+ m.done()
113+ case LogLineLoaded:
114+ m.logs = append(m.logs, NewLogLine(msg.Line))
115+ m.filterLogs()
116+ return vxfw.RedrawCmd{}, nil
117+ }
118+ return nil, nil
119+}
120+
121+func (m *LogsPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
122+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
123+
124+ if len(m.logs) == 0 {
125+ txt := text.New("This view shows all logs generated by our services tagged with your user. This view will show errors triggered by your pages sites, blogs, tuns, etc. Logs will show up here in realtime as they are generated. There is no log buffer.")
126+ txtSurf, _ := txt.Draw(ctx)
127+ root.AddChild(0, 0, txtSurf)
128+ } else {
129+ listSurf, _ := m.list.Draw(createDrawCtx(ctx, ctx.Max.Height-4))
130+ root.AddChild(0, 0, listSurf)
131+ }
132+
133+ inp, _ := m.input.Draw(createDrawCtx(ctx, 4))
134+ root.AddChild(0, int(ctx.Max.Height)-3, inp)
135+
136+ return root, nil
137+}
138+
139+func (m *LogsPage) getWidget(i uint, cursor uint) vxfw.Widget {
140+ if len(m.filtered) == 0 {
141+ return nil
142+ }
143+
144+ if int(i) >= len(m.filtered) {
145+ return nil
146+ }
147+
148+ idx := m.filtered[i]
149+ return logToWidget(m.logs[idx])
150+}
151+
152+func (m *LogsPage) connectToLogs() error {
153+ conn := shared.NewPicoPipeClient()
154+ drain, err := pipeLogger.ReadLogs(m.ctx, m.shared.Logger, conn)
155+ if err != nil {
156+ return err
157+ }
158+
159+ scanner := bufio.NewScanner(drain)
160+ scanner.Buffer(make([]byte, 32*1024), 32*1024)
161+ for scanner.Scan() {
162+ line := scanner.Text()
163+ parsedData := map[string]any{}
164+
165+ err := json.Unmarshal([]byte(line), &parsedData)
166+ if err != nil {
167+ m.shared.Logger.Error("json unmarshal", "err", err, "line", line)
168+ continue
169+ }
170+
171+ // user := utils.AnyToStr(parsedData, "user")
172+ // userId := utils.AnyToStr(parsedData, "userId")
173+ // if user == m.shared.User.Name || userId == m.shared.User.ID {
174+ m.shared.App.PostEvent(LogLineLoaded{parsedData})
175+ // }
176+ }
177+
178+ return nil
179+}
180+
181+func matched(str, match string) bool {
182+ prim := strings.ToLower(str)
183+ mtch := strings.ToLower(match)
184+ return strings.Contains(prim, mtch)
185+}
186+
187+type LogLine struct {
188+ Date string
189+ Service string
190+ Level string
191+ Msg string
192+ ErrMsg string
193+ Status int
194+ Url string
195+}
196+
197+func NewLogLine(data map[string]any) *LogLine {
198+ rawtime := utils.AnyToStr(data, "time")
199+ service := utils.AnyToStr(data, "service")
200+ level := utils.AnyToStr(data, "level")
201+ msg := utils.AnyToStr(data, "msg")
202+ errMsg := utils.AnyToStr(data, "err")
203+ status := utils.AnyToFloat(data, "status")
204+ url := utils.AnyToStr(data, "url")
205+ date, err := time.Parse(time.RFC3339Nano, rawtime)
206+ dateStr := rawtime
207+ if err == nil {
208+ dateStr = date.Format(time.RFC3339)
209+ }
210+
211+ return &LogLine{
212+ Date: dateStr,
213+ Service: service,
214+ Level: level,
215+ Msg: msg,
216+ ErrMsg: errMsg,
217+ Status: int(status),
218+ Url: url,
219+ }
220+}
221+
222+func logToWidget(ll *LogLine) vxfw.Widget {
223+ segs := []vaxis.Segment{
224+ {Text: ll.Date + " "},
225+ {Text: ll.Service + " "},
226+ }
227+
228+ if ll.Level == "ERROR" {
229+ segs = append(segs, vaxis.Segment{Text: ll.Level, Style: vaxis.Style{Background: red}})
230+ } else {
231+ segs = append(segs, vaxis.Segment{Text: ll.Level})
232+ }
233+
234+ segs = append(segs, vaxis.Segment{Text: " " + ll.Msg + " "})
235+ if ll.ErrMsg != "" {
236+ segs = append(segs, vaxis.Segment{Text: ll.ErrMsg + " ", Style: vaxis.Style{Foreground: red}})
237+ }
238+
239+ if ll.Status > 0 {
240+ if ll.Status >= 200 && ll.Status < 300 {
241+ segs = append(segs, vaxis.Segment{
242+ Text: fmt.Sprintf("%d ", ll.Status),
243+ Style: vaxis.Style{Foreground: green},
244+ })
245+ } else {
246+ segs = append(segs,
247+ vaxis.Segment{
248+ Text: fmt.Sprintf("%d ", ll.Status),
249+ Style: vaxis.Style{Foreground: red},
250+ })
251+ }
252+ }
253+
254+ segs = append(segs, vaxis.Segment{Text: ll.Url + " "})
255+
256+ txt := richtext.New(segs)
257+ return txt
258+}
+0,
-297
1@@ -1,297 +0,0 @@
2-package logs
3-
4-import (
5- "bufio"
6- "context"
7- "encoding/json"
8- "fmt"
9- "strings"
10- "time"
11-
12- input "github.com/charmbracelet/bubbles/textinput"
13- "github.com/charmbracelet/bubbles/viewport"
14- tea "github.com/charmbracelet/bubbletea"
15- "github.com/charmbracelet/lipgloss"
16- "github.com/picosh/pico/shared"
17- "github.com/picosh/pico/tui/common"
18- "github.com/picosh/pico/tui/pages"
19- "github.com/picosh/utils"
20- pipeLogger "github.com/picosh/utils/pipe/log"
21-)
22-
23-type state int
24-
25-const (
26- stateLoading state = iota
27- stateReady
28-)
29-
30-type logLineLoadedMsg map[string]any
31-type errMsg error
32-
33-type Model struct {
34- shared *common.SharedModel
35- state state
36- logData []map[string]any
37- viewport viewport.Model
38- input input.Model
39- sub chan map[string]any
40- ctx context.Context
41- done context.CancelFunc
42- errMsg error
43-}
44-
45-func headerHeight(shrd *common.SharedModel) int {
46- return shrd.HeaderHeight
47-}
48-
49-func headerWidth(w int) int {
50- return w - 2
51-}
52-
53-var defMsg = "This view shows all logs generated by our services tagged with your user. This view will show errors triggered by your pages sites, blogs, tuns, etc. Logs will show up here in realtime as they are generated. There is no log buffer."
54-
55-func NewModel(shrd *common.SharedModel) Model {
56- im := input.New()
57- im.Cursor.Style = shrd.Styles.Cursor
58- im.Placeholder = "filter logs"
59- im.PlaceholderStyle = shrd.Styles.InputPlaceholder
60- im.Prompt = shrd.Styles.FocusedPrompt.String()
61- im.CharLimit = 50
62- im.Focus()
63-
64- hh := headerHeight(shrd)
65- ww := headerWidth(shrd.Width)
66- inputHeight := lipgloss.Height(im.View())
67- viewport := viewport.New(ww, shrd.Height-hh-inputHeight)
68- viewport.YPosition = hh
69- viewport.SetContent(defMsg)
70-
71- ctx, cancel := context.WithCancel(shrd.Session.Context())
72-
73- return Model{
74- shared: shrd,
75- state: stateLoading,
76- viewport: viewport,
77- logData: []map[string]any{},
78- input: im,
79- sub: make(chan map[string]any),
80- ctx: ctx,
81- done: cancel,
82- }
83-}
84-
85-func (m Model) Init() tea.Cmd {
86- return tea.Batch(
87- m.connectLogs(m.sub),
88- m.waitForActivity(m.sub),
89- )
90-}
91-
92-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
93- var cmds []tea.Cmd
94- var cmd tea.Cmd
95- switch msg := msg.(type) {
96- case tea.WindowSizeMsg:
97- m.viewport.Width = headerWidth(msg.Width)
98- inputHeight := lipgloss.Height(m.input.View())
99- hh := headerHeight(m.shared)
100- m.viewport.Height = msg.Height - hh - inputHeight
101- m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
102-
103- case logLineLoadedMsg:
104- m.state = stateReady
105- m.logData = append(m.logData, msg)
106- lng := len(m.logData)
107- if lng > 1000 {
108- m.logData = m.logData[lng-1000:]
109- }
110- wasAt := false
111- if m.viewport.AtBottom() {
112- wasAt = true
113- }
114- m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
115- if wasAt {
116- m.viewport.GotoBottom()
117- }
118- cmds = append(cmds, m.waitForActivity(m.sub))
119-
120- case errMsg:
121- m.errMsg = msg
122-
123- case pages.NavigateMsg:
124- // cancel activity logger
125- m.done()
126- // reset model
127- next := NewModel(m.shared)
128- return next, nil
129-
130- case tea.KeyMsg:
131- switch msg.String() {
132- case "q", "esc":
133- return m, pages.Navigate(pages.MenuPage)
134- case "tab":
135- if m.input.Focused() {
136- m.input.Blur()
137- } else {
138- cmds = append(cmds, m.input.Focus())
139- }
140- default:
141- m.viewport.SetContent(logsToStr(m.shared.Styles, m.logData, m.input.Value()))
142- m.viewport.GotoBottom()
143- }
144- }
145- m.input, cmd = m.input.Update(msg)
146- cmds = append(cmds, cmd)
147- m.viewport, cmd = m.viewport.Update(msg)
148- cmds = append(cmds, cmd)
149- return m, tea.Batch(cmds...)
150-}
151-
152-func (m Model) View() string {
153- if m.errMsg != nil {
154- return m.shared.Styles.Error.Render(m.errMsg.Error())
155- }
156- if m.state == stateLoading {
157- return defMsg
158- }
159- return m.viewport.View() + "\n" + m.input.View()
160-}
161-
162-func (m Model) waitForActivity(sub chan map[string]any) tea.Cmd {
163- return func() tea.Msg {
164- select {
165- case result := <-sub:
166- return logLineLoadedMsg(result)
167- case <-m.ctx.Done():
168- return nil
169- }
170- }
171-}
172-
173-func (m Model) connectLogs(sub chan map[string]any) tea.Cmd {
174- return func() tea.Msg {
175- conn := shared.NewPicoPipeClient()
176- drain, err := pipeLogger.ReadLogs(m.ctx, m.shared.Logger, conn)
177- if err != nil {
178- return errMsg(err)
179- }
180-
181- scanner := bufio.NewScanner(drain)
182- scanner.Buffer(make([]byte, 32*1024), 32*1024)
183- for scanner.Scan() {
184- line := scanner.Text()
185- parsedData := map[string]any{}
186-
187- err := json.Unmarshal([]byte(line), &parsedData)
188- if err != nil {
189- m.shared.Logger.Error("json unmarshal", "err", err, "line", line)
190- continue
191- }
192-
193- user := utils.AnyToStr(parsedData, "user")
194- userId := utils.AnyToStr(parsedData, "userId")
195- if user == m.shared.User.Name || userId == m.shared.User.ID {
196- sub <- parsedData
197- }
198- }
199-
200- return nil
201- }
202-}
203-
204-func matched(str, match string) bool {
205- prim := strings.ToLower(str)
206- mtch := strings.ToLower(match)
207- return strings.Contains(prim, mtch)
208-}
209-
210-func logToStr(styles common.Styles, data map[string]any, match string) string {
211- rawtime := utils.AnyToStr(data, "time")
212- service := utils.AnyToStr(data, "service")
213- level := utils.AnyToStr(data, "level")
214- msg := utils.AnyToStr(data, "msg")
215- errMsg := utils.AnyToStr(data, "err")
216- status := utils.AnyToFloat(data, "status")
217- url := utils.AnyToStr(data, "url")
218-
219- if match != "" {
220- lvlMatch := matched(level, match)
221- msgMatch := matched(msg, match)
222- serviceMatch := matched(service, match)
223- errMatch := matched(errMsg, match)
224- urlMatch := matched(url, match)
225- statusMatch := matched(fmt.Sprintf("%d", int(status)), match)
226- if !lvlMatch && !msgMatch && !serviceMatch && !errMatch && !urlMatch && !statusMatch {
227- return ""
228- }
229- }
230-
231- date, err := time.Parse(time.RFC3339Nano, rawtime)
232- dateStr := rawtime
233- if err == nil {
234- dateStr = date.Format(time.RFC3339)
235- }
236-
237- if status == 0 {
238- return fmt.Sprintf(
239- "%s %s %s %s %s %s",
240- dateStr,
241- service,
242- levelView(styles, level),
243- msg,
244- styles.Error.Render(errMsg),
245- url,
246- )
247- }
248-
249- return fmt.Sprintf(
250- "%s %s %s %s %s %s %s",
251- dateStr,
252- service,
253- levelView(styles, level),
254- msg,
255- styles.Error.Render(errMsg),
256- statusView(styles, int(status)),
257- url,
258- )
259-}
260-
261-func statusView(styles common.Styles, status int) string {
262- statusStr := fmt.Sprintf("%d", status)
263- if status == 0 {
264- return statusStr
265- }
266- if status >= 200 && status < 300 {
267- return statusStr
268- }
269- return styles.Error.Render(statusStr)
270-}
271-
272-func levelView(styles common.Styles, level string) string {
273- if level == "ERROR" {
274- return styles.Error.Render(level)
275- }
276- return styles.Note.Render(level)
277-}
278-
279-func logsToStr(styles common.Styles, data []map[string]any, filter string) string {
280- acc := ""
281- for _, d := range data {
282- res := logToStr(styles, d, filter)
283- if res != "" {
284- acc += res
285- acc += "\n"
286- }
287- }
288-
289- if acc == "" {
290- if filter == "" {
291- return defMsg
292- } else {
293- return "No results found for filter provided."
294- }
295- }
296-
297- return acc
298-}
+0,
-48
1@@ -1,48 +0,0 @@
2-package tui
3-
4-import (
5- tea "github.com/charmbracelet/bubbletea"
6- "github.com/charmbracelet/ssh"
7- bm "github.com/charmbracelet/wish/bubbletea"
8- "github.com/muesli/termenv"
9- "github.com/picosh/pico/db/postgres"
10- "github.com/picosh/pico/shared"
11- "github.com/picosh/pico/tui/common"
12-)
13-
14-func CmsMiddleware(cfg *shared.ConfigSite) bm.Handler {
15- return func(sesh ssh.Session) (tea.Model, []tea.ProgramOption) {
16- logger := cfg.Logger
17-
18- _, _, active := sesh.Pty()
19- if !active {
20- logger.Info("no active terminal, skipping")
21- return nil, nil
22- }
23-
24- dbpool := postgres.NewDB(cfg.DbURL, cfg.Logger)
25- renderer := bm.MakeRenderer(sesh)
26- renderer.SetColorProfile(termenv.TrueColor)
27- styles := common.DefaultStyles(renderer)
28-
29- shrd := &common.SharedModel{
30- Session: sesh,
31- Cfg: cfg,
32- Dbpool: dbpool,
33- Styles: styles,
34- Width: 80,
35- Height: 24,
36- Logger: logger,
37- }
38-
39- m := NewUI(shrd)
40- err := m.setupUser()
41- if err != nil {
42- return nil, nil
43- }
44-
45- opts := bm.MakeOptions(sesh)
46- opts = append(opts, tea.WithAltScreen())
47- return m, opts
48- }
49-}
1@@ -0,0 +1,134 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
8+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
9+ "github.com/picosh/pico/db"
10+)
11+
12+var menuChoices = []string{
13+ "pubkeys",
14+ "tokens",
15+ "logs",
16+ "analytics",
17+ "pico+",
18+ "chat",
19+}
20+
21+type LoadedUsageStats struct{}
22+
23+type MenuPage struct {
24+ shared *SharedModel
25+
26+ list list.Dynamic
27+ features *FeaturesList
28+ stats *db.UserStats
29+}
30+
31+func getMenuWidget(i uint, cursor uint) vxfw.Widget {
32+ if int(i) >= len(menuChoices) {
33+ return nil
34+ }
35+ var style vaxis.Style
36+ if i == cursor {
37+ style.Attribute = vaxis.AttrReverse
38+ }
39+ content := menuChoices[i]
40+ return &text.Text{
41+ Content: content,
42+ Style: style,
43+ }
44+}
45+
46+func NewMenuPage(shrd *SharedModel) *MenuPage {
47+ m := &MenuPage{shared: shrd}
48+ m.list = list.Dynamic{Builder: getMenuWidget, DrawCursor: true}
49+ m.features = NewFeaturesList(shrd)
50+ return m
51+}
52+
53+func loadChat(shrd *SharedModel) {
54+ sp := &SenpaiCmd{
55+ Shared: shrd,
56+ }
57+ _ = sp.Run()
58+}
59+
60+func (m *MenuPage) fetchUsageStats() error {
61+ stats, err := m.shared.Dbpool.FindUserStats(m.shared.User.ID)
62+ if err != nil {
63+ return err
64+ }
65+ m.stats = stats
66+ return nil
67+}
68+
69+func (m *MenuPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
70+ switch msg := ev.(type) {
71+ case PageIn:
72+ _ = m.fetchUsageStats()
73+ cmd, _ := m.features.HandleEvent(vxfw.Init{}, phase)
74+ return vxfw.BatchCmd([]vxfw.Command{
75+ cmd,
76+ vxfw.FocusWidgetCmd(&m.list),
77+ }), nil
78+ case vaxis.Key:
79+ if msg.Matches(vaxis.KeyEnter) {
80+ choice := menuChoices[m.list.Cursor()]
81+ m.shared.App.PostEvent(Navigate{To: choice})
82+ }
83+ }
84+ return nil, nil
85+}
86+
87+func (m *MenuPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
88+ info, _ := NewUserInfo(m.shared).Draw(ctx)
89+
90+ brd := NewBorder(&m.list)
91+ brd.Label = "menu"
92+ brd.Style = vaxis.Style{Foreground: oj}
93+ menuSurf, _ := brd.Draw(vxfw.DrawContext{
94+ Characters: ctx.Characters,
95+ Max: vxfw.Size{
96+ Width: 30,
97+ Height: uint16(len(menuChoices)) + 3,
98+ },
99+ })
100+
101+ services, _ := NewServicesList(m.shared.PlusFeatureFlag).Draw(ctx)
102+ features, _ := m.features.Draw(ctx)
103+
104+ leftPane := NewGroupStack([]vxfw.Surface{
105+ menuSurf,
106+ info,
107+ services,
108+ })
109+ leftSurf, _ := leftPane.Draw(ctx)
110+
111+ right := []vxfw.Surface{}
112+ if len(m.features.features) > 0 {
113+ right = append(right, features)
114+ }
115+
116+ if m.stats != nil {
117+ pages, _ := NewUsageInfo("pages", &m.stats.Pages).Draw(ctx)
118+ prose, _ := NewUsageInfo("prose", &m.stats.Prose).Draw(ctx)
119+ pastes, _ := NewUsageInfo("pastes", &m.stats.Pastes).Draw(ctx)
120+ feeds, _ := NewUsageInfo("rss-to-email", &m.stats.Feeds).Draw(ctx)
121+ right = append(right,
122+ pages,
123+ prose,
124+ pastes,
125+ feeds,
126+ )
127+ }
128+ rightPane := NewGroupStack(right)
129+ rightSurf, _ := rightPane.Draw(ctx)
130+
131+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
132+ root.AddChild(0, 0, leftSurf)
133+ root.AddChild(30, 0, rightSurf)
134+ return root, nil
135+}
1@@ -1,179 +0,0 @@
2-package menu
3-
4-import (
5- tea "github.com/charmbracelet/bubbletea"
6- "github.com/muesli/reflow/indent"
7- "github.com/picosh/pico/tui/common"
8-)
9-
10-// menuChoice represents a chosen menu item.
11-type menuChoice int
12-
13-type MenuChoiceMsg struct {
14- MenuChoice menuChoice
15-}
16-
17-const (
18- KeysChoice menuChoice = iota
19- TokensChoice
20- SettingsChoice
21- LogsChoice
22- AnalyticsChoice
23- ChatChoice
24- NotificationsChoice
25- PlusChoice
26- ExitChoice
27- UnsetChoice // set when no choice has been made
28-)
29-
30-// menu text corresponding to menu choices. these are presented to the user.
31-var menuChoices = map[menuChoice]string{
32- KeysChoice: "Manage pubkeys",
33- TokensChoice: "Manage tokens",
34- SettingsChoice: "Settings",
35- LogsChoice: "Logs",
36- AnalyticsChoice: "Analytics",
37- ChatChoice: "Chat",
38- NotificationsChoice: "Notifications",
39- PlusChoice: "Pico+",
40- ExitChoice: "Exit",
41-}
42-
43-type Model struct {
44- shared *common.SharedModel
45- err error
46- menuIndex int
47- menuChoice menuChoice
48-}
49-
50-func NewModel(shared *common.SharedModel) Model {
51- return Model{
52- shared: shared,
53- menuChoice: UnsetChoice,
54- }
55-}
56-
57-func (m Model) Init() tea.Cmd {
58- return nil
59-}
60-
61-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
62- var (
63- cmds []tea.Cmd
64- )
65-
66- switch msg := msg.(type) {
67- case tea.KeyMsg:
68- switch msg.Type {
69- case tea.KeyCtrlC:
70- m.shared.Dbpool.Close()
71- return m, tea.Quit
72- }
73-
74- switch msg.String() {
75- case "q", "esc":
76- m.shared.Dbpool.Close()
77- return m, tea.Quit
78-
79- case "up", "k":
80- m.menuIndex--
81- if m.menuIndex < 0 {
82- m.menuIndex = len(menuChoices) - 1
83- }
84-
85- case "enter":
86- m.menuChoice = menuChoice(m.menuIndex)
87- cmds = append(cmds, MenuMsg(m.menuChoice))
88-
89- case "down", "j":
90- m.menuIndex++
91- if m.menuIndex >= len(menuChoices) {
92- m.menuIndex = 0
93- }
94- }
95- }
96-
97- return m, tea.Batch(cmds...)
98-}
99-
100-func MenuMsg(choice menuChoice) tea.Cmd {
101- return func() tea.Msg {
102- return MenuChoiceMsg{
103- MenuChoice: choice,
104- }
105- }
106-}
107-
108-func (m Model) bioView() string {
109- if m.shared.User == nil {
110- return "Loading user info..."
111- }
112-
113- var username string
114- if m.shared.User.Name != "" {
115- username = m.shared.User.Name
116- } else {
117- username = m.shared.Styles.Subtle.Render("(none set)")
118- }
119-
120- expires := ""
121- if m.shared.PlusFeatureFlag != nil {
122- expires = m.shared.PlusFeatureFlag.ExpiresAt.Format(common.DateFormat)
123- }
124-
125- vals := []string{
126- "Username", username,
127- "Joined", m.shared.User.CreatedAt.Format(common.DateFormat),
128- }
129-
130- if expires != "" {
131- vals = append(vals, "Pico+ Expires", expires)
132- }
133-
134- if m.shared.Impersonated {
135- vals = append(vals, "Impersonated", "YES")
136- }
137-
138- return common.KeyValueView(m.shared.Styles, vals...)
139-}
140-
141-func (m Model) menuView() string {
142- var s string
143- for i := 0; i < len(menuChoices); i++ {
144- e := " "
145- menuItem := menuChoices[menuChoice(i)]
146- if i == m.menuIndex {
147- e = m.shared.Styles.SelectionMarker.String() +
148- m.shared.Styles.SelectedMenuItem.Render(menuItem)
149- } else {
150- e += menuItem
151- }
152- if i < len(menuChoices)-1 {
153- e += "\n"
154- }
155- s += e
156- }
157-
158- return s
159-}
160-
161-func (m Model) errorView(err error) string {
162- head := m.shared.Styles.Error.Render("Error: ")
163- body := m.shared.Styles.Subtle.Render(err.Error())
164- msg := m.shared.Styles.Wrap.Render(head + body)
165- return "\n\n" + indent.String(msg, 2)
166-}
167-
168-func (m Model) footerView() string {
169- if m.err != nil {
170- return m.errorView(m.err)
171- }
172- return "\n\n" + common.HelpView(m.shared.Styles, "j/k, ↑/↓: choose", "enter: select")
173-}
174-
175-func (m Model) View() string {
176- s := m.bioView()
177- s += "\n\n" + m.menuView()
178- s += m.footerView()
179- return s
180-}
+0,
-118
1@@ -1,118 +0,0 @@
2-package notifications
3-
4-import (
5- "fmt"
6-
7- "github.com/charmbracelet/bubbles/viewport"
8- tea "github.com/charmbracelet/bubbletea"
9- "github.com/charmbracelet/glamour"
10- "github.com/picosh/pico/db"
11- "github.com/picosh/pico/tui/common"
12- "github.com/picosh/pico/tui/pages"
13-)
14-
15-func NotificationsView(dbpool db.DB, userID string, w int) string {
16- pass, err := dbpool.UpsertToken(userID, "pico-rss")
17- if err != nil {
18- return err.Error()
19- }
20- url := fmt.Sprintf("https://auth.pico.sh/rss/%s", pass)
21- md := fmt.Sprintf(`We provide a special RSS feed for all pico users where we can send
22-user-specific notifications. This is where we will send pico+
23-expiration notices, among other alerts. To be clear, this is
24-optional but **highly** recommended.
25-
26-> As of 2025/01/11 we automatically add this feed for pico+ users
27-> when they purchase a membership.
28-
29-Add this URL to your RSS feed reader:
30-
31-%s
32-
33-## Using our [rss-to-email](https://pico.sh/feeds) service
34-
35-Create a feeds file (using list file format, e.g. pico.txt):`, url)
36-
37- md += "\n```\n"
38- md += fmt.Sprintf(`=: email rss@myemail.com
39-=: digest_interval 1day
40-=> %s
41-`, url)
42- md += "\n```\n"
43- md += "Then copy the file to us:\n"
44- md += "```\nrsync pico.txt feeds.pico.sh:/\n```"
45-
46- r, _ := glamour.NewTermRenderer(
47- // detect background color and pick either the default dark or light theme
48- glamour.WithAutoStyle(),
49- glamour.WithWordWrap(w-20),
50- )
51- out, err := r.Render(md)
52- if err != nil {
53- return err.Error()
54- }
55- return out
56-}
57-
58-// Model holds the state of the username UI.
59-type Model struct {
60- shared *common.SharedModel
61-
62- Done bool // true when it's time to exit this view
63- Quit bool // true when the user wants to quit the whole program
64-
65- viewport viewport.Model
66-}
67-
68-func headerHeight(shrd *common.SharedModel) int {
69- return shrd.HeaderHeight
70-}
71-
72-func headerWidth(w int) int {
73- return w - 2
74-}
75-
76-func NewModel(shared *common.SharedModel) Model {
77- hh := headerHeight(shared)
78- ww := headerWidth(shared.Width)
79- viewport := viewport.New(ww, shared.Height-hh)
80- viewport.YPosition = hh
81- if shared.User != nil {
82- viewport.SetContent(NotificationsView(shared.Dbpool, shared.User.ID, ww))
83- }
84-
85- return Model{
86- shared: shared,
87-
88- Done: false,
89- Quit: false,
90- viewport: viewport,
91- }
92-}
93-
94-func (m Model) Init() tea.Cmd {
95- return nil
96-}
97-
98-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99- switch msg := msg.(type) {
100- case tea.WindowSizeMsg:
101- m.viewport.Width = headerWidth(msg.Width)
102- hh := headerHeight(m.shared)
103- m.viewport.Height = msg.Height - hh
104-
105- case tea.KeyMsg:
106- switch msg.String() {
107- case "q", "esc":
108- return m, pages.Navigate(pages.MenuPage)
109- }
110- }
111-
112- var cmd tea.Cmd
113- m.viewport, cmd = m.viewport.Update(msg)
114- return m, cmd
115-}
116-
117-func (m Model) View() string {
118- return m.viewport.View()
119-}
+42,
-0
1@@ -0,0 +1,42 @@
2+package tui
3+
4+import (
5+ "git.sr.ht/~rockorager/vaxis"
6+ "git.sr.ht/~rockorager/vaxis/vxfw"
7+)
8+
9+type Pager struct {
10+ Surface vxfw.Surface
11+ pos int
12+}
13+
14+func NewPager() *Pager {
15+ return &Pager{}
16+}
17+
18+func (m *Pager) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) {
19+ switch msg := ev.(type) {
20+ case vaxis.Key:
21+ if msg.Matches('j') {
22+ if m.pos == -1*int(m.Surface.Size.Height) {
23+ return nil, nil
24+ }
25+ m.pos -= 1
26+ return vxfw.RedrawCmd{}, nil
27+ }
28+ if msg.Matches('k') {
29+ if m.pos == 0 {
30+ return nil, nil
31+ }
32+ m.pos += 1
33+ return vxfw.RedrawCmd{}, nil
34+ }
35+ }
36+ return nil, nil
37+}
38+
39+func (m *Pager) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
40+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
41+ root.AddChild(0, m.pos, m.Surface)
42+ return root, nil
43+}
+0,
-59
1@@ -1,59 +0,0 @@
2-package pages
3-
4-import tea "github.com/charmbracelet/bubbletea"
5-
6-type Page int
7-
8-const (
9- MenuPage Page = iota
10- CreateAccountPage
11- CreatePubkeyPage
12- CreateTokenPage
13- PubkeysPage
14- TokensPage
15- NotificationsPage
16- PlusPage
17- SettingsPage
18- LogsPage
19- AnalyticsPage
20- ChatPage
21-)
22-
23-type NavigateMsg struct{ Page }
24-
25-func Navigate(page Page) tea.Cmd {
26- return func() tea.Msg {
27- return NavigateMsg{page}
28- }
29-}
30-
31-func ToTitle(page Page) string {
32- switch page {
33- case CreateAccountPage:
34- return "create account"
35- case CreatePubkeyPage:
36- return "add pubkey"
37- case CreateTokenPage:
38- return "new api token"
39- case MenuPage:
40- return "menu"
41- case NotificationsPage:
42- return "notifications"
43- case PlusPage:
44- return "pico+"
45- case TokensPage:
46- return "api tokens"
47- case PubkeysPage:
48- return "pubkeys"
49- case SettingsPage:
50- return "settings"
51- case LogsPage:
52- return "logs"
53- case AnalyticsPage:
54- return "analytics"
55- case ChatPage:
56- return "chat"
57- }
58-
59- return ""
60-}
+130,
-0
1@@ -0,0 +1,130 @@
2+package tui
3+
4+import (
5+ "fmt"
6+ "math"
7+ "net/url"
8+
9+ "git.sr.ht/~rockorager/vaxis"
10+ "git.sr.ht/~rockorager/vaxis/vxfw"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
12+)
13+
14+type PlusPage struct {
15+ shared *SharedModel
16+
17+ pager *Pager
18+}
19+
20+func NewPlusPage(shrd *SharedModel) *PlusPage {
21+ page := &PlusPage{
22+ shared: shrd,
23+ }
24+ page.pager = NewPager()
25+ return page
26+}
27+
28+func (m *PlusPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
29+ switch ev.(type) {
30+ case PageIn:
31+ return vxfw.FocusWidgetCmd(m.pager), nil
32+ }
33+ return nil, nil
34+}
35+
36+func (m *PlusPage) header(ctx vxfw.DrawContext) vxfw.Surface {
37+ intro := richtext.New([]vaxis.Segment{
38+ {
39+ Text: "$2/mo\n",
40+ Style: vaxis.Style{
41+ UnderlineStyle: vaxis.UnderlineCurly,
42+ UnderlineColor: oj,
43+ Foreground: oj,
44+ },
45+ },
46+ {
47+ Text: "(billed yearly)\n\n",
48+ },
49+
50+ {Text: "• tuns\n"},
51+ {Text: " • per-site analytics\n"},
52+ {Text: "• pgs\n"},
53+ {Text: " • per-site analytics\n"},
54+ {Text: "• prose\n"},
55+ {Text: " • blog analytics\n"},
56+ {Text: "• irc bouncer\n"},
57+ {Text: "• 10GB total storage\n\n"},
58+ })
59+ brd := NewBorder(intro)
60+ brd.Label = "pico+"
61+ surf, _ := brd.Draw(ctx)
62+ return surf
63+}
64+
65+func (m *PlusPage) contact(ctx vxfw.DrawContext) vxfw.Surface {
66+ intro := richtext.New([]vaxis.Segment{
67+ {Text: "Have any questions? Feel free to reach out:\n\n"},
68+ {Text: "• "}, {Text: "mailto:hello@pico.sh\n", Style: vaxis.Style{Hyperlink: "mailto:hello@pico.sh"}},
69+ {Text: "• "}, {Text: "https://pico.sh/irc\n", Style: vaxis.Style{Hyperlink: "https://pico.sh/irc"}},
70+ })
71+ brd := NewBorder(intro)
72+ brd.Label = "contact"
73+ surf, _ := brd.Draw(ctx)
74+ return surf
75+}
76+
77+func (m *PlusPage) payment(ctx vxfw.DrawContext) vxfw.Surface {
78+ paymentLink := "https://auth.pico.sh/checkout"
79+ link := fmt.Sprintf("%s/%s", paymentLink, url.QueryEscape(m.shared.User.Name))
80+ header := vaxis.Style{Foreground: oj, UnderlineColor: oj, UnderlineStyle: vaxis.UnderlineDashed}
81+ pay := richtext.New([]vaxis.Segment{
82+ {Text: "You can use this same flow to add additional years to your membership, including if you are already a pico+ user.\n\n", Style: vaxis.Style{Foreground: green}},
83+
84+ {Text: "There are a few ways to purchase a membership. We try our best to provide immediate access to pico+ regardless of payment method.\n"},
85+
86+ {Text: "\nOnline payment\n\n", Style: header},
87+
88+ {Text: link + "\n", Style: vaxis.Style{Hyperlink: link}},
89+
90+ {Text: "\nSnail mail\n\n", Style: header},
91+
92+ {Text: "Send cash (USD) or check to:\n\n"},
93+ {Text: "• pico.sh LLC\n"},
94+ {Text: "• 206 E Huron St\n"},
95+ {Text: "• Ann Arbor MI 48104\n"},
96+ })
97+ brd := NewBorder(pay)
98+ brd.Label = "payment"
99+ surf, _ := brd.Draw(ctx)
100+ return surf
101+}
102+
103+func (m *PlusPage) notes(ctx vxfw.DrawContext) vxfw.Surface {
104+ wdgt := richtext.New([]vaxis.Segment{
105+ {Text: "We do not maintain active subscriptions. "},
106+ {Text: "Every year you must pay again. We do not take monthly payments, you must pay for a year up-front. Pricing is subject to change."},
107+ })
108+ brd := NewBorder(wdgt)
109+ brd.Label = "notes"
110+ surf, _ := brd.Draw(ctx)
111+ return surf
112+}
113+
114+func (m *PlusPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
115+ hdr := NewGroupStack([]vxfw.Surface{
116+ m.header(ctx),
117+ m.contact(ctx),
118+ })
119+ hdr.Direction = "horizontal"
120+ hdr.Gap = 1
121+ hdrSurf, _ := hdr.Draw(createDrawCtx(ctx, 14))
122+ stack := NewGroupStack([]vxfw.Surface{
123+ hdrSurf,
124+ m.payment(ctx),
125+ m.notes(ctx),
126+ })
127+ stack.Gap = 1
128+ surf, _ := stack.Draw(createDrawCtx(ctx, math.MaxUint16))
129+ m.pager.Surface = surf
130+ return m.pager.Draw(ctx)
131+}
+0,
-125
1@@ -1,125 +0,0 @@
2-package plus
3-
4-import (
5- "fmt"
6- "net/url"
7-
8- "github.com/charmbracelet/bubbles/viewport"
9- tea "github.com/charmbracelet/bubbletea"
10- "github.com/charmbracelet/glamour"
11- "github.com/picosh/pico/tui/common"
12- "github.com/picosh/pico/tui/pages"
13-)
14-
15-func PlusView(username string, w int) string {
16- paymentLink := "https://auth.pico.sh/checkout"
17- url := fmt.Sprintf("%s/%s", paymentLink, url.QueryEscape(username))
18- md := fmt.Sprintf(`Signup for pico+
19-
20-## $2/month (billed annually)
21-
22-- tuns
23- - per-site analytics
24-- pages
25- - per-site analytics
26-- prose
27- - no inherent storage limits
28- - blog analytics
29-- irc bouncer
30-- 10GB total storage
31-
32-> You can use this same flow to add additional years to your membership,
33-> including if you are already a pico+ user.
34-
35-There are a few ways to purchase a membership. We try our best to
36-provide immediate access to pico+ regardless of payment
37-method.
38-
39-## Online Payment (credit card, paypal)
40-
41-%s
42-
43-## Snail Mail
44-
45-Send cash (USD) or check to:
46-- pico.sh LLC
47-- 206 E Huron St
48-- Ann Arbor MI 48104
49-
50-## Notes
51-
52-You can keep purchasing pico+ to add additional years to your subscription.
53-
54-Have any questions not covered here? [Email](mailto:hello@pico.sh)
55-us or join [IRC](https://pico.sh/irc), we will promptly respond.
56-
57-We do not maintain active subscriptions for pico+.
58-Every year you must pay again. We do not take monthly payments, you
59-must pay for a year up-front. Pricing is subject to change because
60-we plan on continuing to include more services as we build them.`, url)
61-
62- r, _ := glamour.NewTermRenderer(
63- // detect background color and pick either the default dark or light theme
64- glamour.WithAutoStyle(),
65- glamour.WithWordWrap(w-20),
66- )
67- out, _ := r.Render(md)
68- return out
69-}
70-
71-// Model holds the state of the username UI.
72-type Model struct {
73- shared *common.SharedModel
74- viewport viewport.Model
75-}
76-
77-func headerHeight(shrd *common.SharedModel) int {
78- return shrd.HeaderHeight
79-}
80-
81-func headerWidth(w int) int {
82- return w - 2
83-}
84-
85-// NewModel returns a new username model in its initial state.
86-func NewModel(shared *common.SharedModel) Model {
87- hh := headerHeight(shared)
88- ww := headerWidth(shared.Width)
89- viewport := viewport.New(ww, shared.Height-hh)
90- if shared.User != nil {
91- viewport.SetContent(PlusView(shared.User.Name, ww))
92- }
93-
94- return Model{
95- shared: shared,
96- viewport: viewport,
97- }
98-}
99-
100-func (m Model) Init() tea.Cmd {
101- return nil
102-}
103-
104-// Update is the Bubble Tea update loop.
105-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106- switch msg := msg.(type) {
107- case tea.WindowSizeMsg:
108- m.viewport.Width = headerWidth(msg.Width)
109- hh := headerHeight(m.shared)
110- m.viewport.Height = msg.Height - hh
111- case tea.KeyMsg:
112- switch msg.String() {
113- case "q", "esc":
114- return m, pages.Navigate(pages.MenuPage)
115- }
116- }
117-
118- var cmd tea.Cmd
119- m.viewport, cmd = m.viewport.Update(msg)
120- return m, cmd
121-}
122-
123-// View renders current view from the model.
124-func (m Model) View() string {
125- return m.viewport.View()
126-}
+268,
-0
1@@ -0,0 +1,268 @@
2+package tui
3+
4+import (
5+ "fmt"
6+ "time"
7+
8+ "git.sr.ht/~rockorager/vaxis"
9+ "git.sr.ht/~rockorager/vaxis/vxfw"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
12+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
13+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
14+ "github.com/picosh/pico/db"
15+ "github.com/picosh/utils"
16+ "golang.org/x/crypto/ssh"
17+)
18+
19+type PubkeysPage struct {
20+ shared *SharedModel
21+ list list.Dynamic
22+
23+ keys []*db.PublicKey
24+ err error
25+ confirm bool
26+}
27+
28+func NewPubkeysPage(shrd *SharedModel) *PubkeysPage {
29+ m := &PubkeysPage{
30+ shared: shrd,
31+ }
32+ m.list = list.Dynamic{DrawCursor: true, Builder: m.getWidget}
33+ return m
34+}
35+
36+type FetchPubkeys struct{}
37+
38+func (m *PubkeysPage) Footer() []Shortcut {
39+ return []Shortcut{
40+ {Shortcut: "j/k", Text: "choose"},
41+ {Shortcut: "x", Text: "delete"},
42+ {Shortcut: "c", Text: "create"},
43+ }
44+}
45+
46+func (m *PubkeysPage) fetchKeys() error {
47+ keys, err := m.shared.Dbpool.FindKeysForUser(m.shared.User)
48+ if err != nil {
49+ return err
50+
51+ }
52+ m.keys = keys
53+ return nil
54+}
55+
56+func (m *PubkeysPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
57+ switch msg := ev.(type) {
58+ case PageIn:
59+ m.err = m.fetchKeys()
60+ return vxfw.FocusWidgetCmd(&m.list), nil
61+ case vaxis.Key:
62+ if msg.Matches('c') {
63+ m.shared.App.PostEvent(Navigate{To: "add-pubkey"})
64+ }
65+ if msg.Matches('x') {
66+ if len(m.keys) < 2 {
67+ m.err = fmt.Errorf("cannot delete last key")
68+ } else {
69+ m.confirm = true
70+ }
71+ return vxfw.RedrawCmd{}, nil
72+ }
73+ if msg.Matches('y') {
74+ if m.confirm {
75+ m.confirm = false
76+ err := m.shared.Dbpool.RemoveKeys([]string{m.keys[m.list.Cursor()].ID})
77+ if err != nil {
78+ m.err = err
79+ return nil, nil
80+ }
81+ m.err = m.fetchKeys()
82+ return vxfw.RedrawCmd{}, nil
83+ }
84+ }
85+ if msg.Matches('n') {
86+ m.confirm = false
87+ return vxfw.RedrawCmd{}, nil
88+ }
89+ }
90+
91+ return nil, nil
92+}
93+
94+func (m *PubkeysPage) getWidget(i uint, cursor uint) vxfw.Widget {
95+ if int(i) >= len(m.keys) {
96+ return nil
97+ }
98+
99+ style := vaxis.Style{Foreground: grey}
100+ isSelected := i == cursor
101+ if isSelected {
102+ style = vaxis.Style{Foreground: fuschia}
103+ }
104+
105+ pubkey := m.keys[i]
106+ key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey.Key))
107+ if err != nil {
108+ m.shared.Logger.Error("parse pubkey", "err", err)
109+ return nil
110+ }
111+
112+ txt := richtext.New([]vaxis.Segment{
113+ {Text: "Name: ", Style: style},
114+ {Text: pubkey.Name + "\n"},
115+
116+ {Text: "Key: ", Style: style},
117+ {Text: ssh.FingerprintSHA256(key) + "\n"},
118+
119+ {Text: "Created: ", Style: style},
120+ {Text: pubkey.CreatedAt.Format(time.DateOnly)},
121+ })
122+
123+ return txt
124+}
125+
126+func (m *PubkeysPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
127+ w := ctx.Max.Width
128+ h := ctx.Max.Height
129+ root := vxfw.NewSurface(w, h, m)
130+
131+ header := richtext.New([]vaxis.Segment{
132+ {
133+ Text: fmt.Sprintf(
134+ "%d pubkeys\n",
135+ len(m.keys),
136+ ),
137+ },
138+ })
139+ headerSurf, _ := header.Draw(createDrawCtx(ctx, 2))
140+ root.AddChild(0, 0, headerSurf)
141+
142+ listSurf, _ := m.list.Draw(createDrawCtx(ctx, h-5))
143+ root.AddChild(0, 3, listSurf)
144+
145+ segs := []vaxis.Segment{}
146+ if m.confirm {
147+ segs = append(segs, vaxis.Segment{
148+ Text: "are you sure? y/n\n",
149+ Style: vaxis.Style{Foreground: red},
150+ })
151+ }
152+ if m.err != nil {
153+ segs = append(segs, vaxis.Segment{
154+ Text: m.err.Error() + "\n",
155+ Style: vaxis.Style{Foreground: red},
156+ })
157+ }
158+ segs = append(segs, vaxis.Segment{Text: "\n"})
159+
160+ footer := richtext.New(segs)
161+ footerSurf, _ := footer.Draw(createDrawCtx(ctx, 3))
162+ root.AddChild(0, int(h)-3, footerSurf)
163+
164+ return root, nil
165+}
166+
167+type AddKeyPage struct {
168+ shared *SharedModel
169+
170+ err error
171+ focus string
172+ input *TextInput
173+ btn *button.Button
174+}
175+
176+func NewAddPubkeyPage(shrd *SharedModel) *AddKeyPage {
177+ btn := button.New("ADD", func() (vxfw.Command, error) { return nil, nil })
178+ btn.Style = button.StyleSet{
179+ Default: vaxis.Style{Background: grey},
180+ Focus: vaxis.Style{Background: oj, Foreground: black},
181+ }
182+ return &AddKeyPage{
183+ shared: shrd,
184+
185+ input: NewTextInput("add pubkey"),
186+ btn: btn,
187+ }
188+}
189+
190+func (m *AddKeyPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
191+ switch msg := ev.(type) {
192+ case PageIn:
193+ m.focus = "input"
194+ m.input.Reset()
195+ return m.input.FocusIn()
196+ case vaxis.Key:
197+ if msg.Matches(vaxis.KeyTab) {
198+ if m.focus == "input" {
199+ m.focus = "button"
200+ cmd, _ := m.input.FocusOut()
201+ return vxfw.BatchCmd([]vxfw.Command{
202+ vxfw.FocusWidgetCmd(m.btn),
203+ cmd,
204+ }), nil
205+ }
206+ m.focus = "input"
207+ return m.input.FocusIn()
208+ }
209+ if msg.Matches(vaxis.KeyEnter) {
210+ if m.focus == "button" {
211+ err := m.addPubkey(m.input.GetValue())
212+ m.err = err
213+ if err == nil {
214+ m.input.Reset()
215+ m.shared.App.PostEvent(Navigate{To: "pubkeys"})
216+ return nil, nil
217+ }
218+ return vxfw.RedrawCmd{}, nil
219+ }
220+ }
221+ }
222+
223+ return nil, nil
224+}
225+
226+func (m *AddKeyPage) addPubkey(pubkey string) error {
227+ pk, comment, _, _, err := ssh.ParseAuthorizedKey([]byte(pubkey))
228+ if err != nil {
229+ return err
230+ }
231+
232+ key := utils.KeyForKeyText(pk)
233+
234+ return m.shared.Dbpool.InsertPublicKey(
235+ m.shared.User.ID, key, comment, nil,
236+ )
237+}
238+
239+func (m *AddKeyPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
240+ w := ctx.Max.Width
241+ h := ctx.Max.Height
242+ root := vxfw.NewSurface(w, h, m)
243+
244+ header := text.New("Enter a new public key")
245+ headerSurf, _ := header.Draw(createDrawCtx(ctx, 2))
246+ root.AddChild(0, 0, headerSurf)
247+
248+ inputSurf, _ := m.input.Draw(createDrawCtx(ctx, 4))
249+ root.AddChild(0, 3, inputSurf)
250+
251+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
252+ Characters: ctx.Characters,
253+ Max: vxfw.Size{Width: 5, Height: 1},
254+ })
255+ root.AddChild(0, 6, btnSurf)
256+
257+ if m.err != nil {
258+ e := richtext.New([]vaxis.Segment{
259+ {
260+ Text: m.err.Error(),
261+ Style: vaxis.Style{Foreground: red},
262+ },
263+ })
264+ errSurf, _ := e.Draw(createDrawCtx(ctx, 1))
265+ root.AddChild(0, 6, errSurf)
266+ }
267+
268+ return root, nil
269+}
+0,
-353
1@@ -1,353 +0,0 @@
2-package pubkeys
3-
4-import (
5- pager "github.com/charmbracelet/bubbles/paginator"
6- tea "github.com/charmbracelet/bubbletea"
7- "github.com/picosh/pico/db"
8- "github.com/picosh/pico/tui/common"
9- "github.com/picosh/pico/tui/pages"
10-)
11-
12-const keysPerPage = 4
13-
14-type state int
15-
16-const (
17- stateLoading state = iota
18- stateNormal
19- stateDeletingKey
20- stateDeletingActiveKey
21- stateDeletingAccount
22- stateQuitting
23-)
24-
25-type keyState int
26-
27-const (
28- keyNormal keyState = iota
29- keySelected
30- keyDeleting
31-)
32-
33-type (
34- keysLoadedMsg []*db.PublicKey
35- unlinkedKeyMsg int
36-)
37-
38-// Model is the Tea state model for this user interface.
39-type Model struct {
40- shared *common.SharedModel
41-
42- state state
43- err error
44- activeKeyIndex int // index of the key in the below slice which is currently in use
45- keys []*db.PublicKey // keys linked to user's account
46- index int // index of selected key in relation to the current page
47-
48- pager pager.Model
49-}
50-
51-// NewModel creates a new model with defaults.
52-func NewModel(shared *common.SharedModel) Model {
53- p := pager.New()
54- p.PerPage = keysPerPage
55- p.Type = pager.Dots
56- p.InactiveDot = shared.Styles.InactivePagination.Render("•")
57-
58- return Model{
59- shared: shared,
60-
61- pager: p,
62- state: stateLoading,
63- err: nil,
64- activeKeyIndex: -1,
65- keys: []*db.PublicKey{},
66- index: 0,
67- }
68-}
69-
70-// getSelectedIndex returns the index of the cursor in relation to the total
71-// number of items.
72-func (m *Model) getSelectedIndex() int {
73- return m.index + m.pager.Page*m.pager.PerPage
74-}
75-
76-// UpdatePaging runs an update against the underlying pagination model as well
77-// as performing some related tasks on this model.
78-func (m *Model) UpdatePaging(msg tea.Msg) {
79- // Handle paging
80- m.pager.SetTotalPages(len(m.keys))
81- m.pager, _ = m.pager.Update(msg)
82-
83- // If selected item is out of bounds, put it in bounds
84- numItems := m.pager.ItemsOnPage(len(m.keys))
85- m.index = min(m.index, numItems-1)
86-}
87-
88-// Init is the Tea initialization function.
89-func (m Model) Init() tea.Cmd {
90- return FetchKeys(m.shared)
91-}
92-
93-// Update is the tea update function which handles incoming messages.
94-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95- var (
96- cmds []tea.Cmd
97- )
98-
99- switch msg := msg.(type) {
100- case tea.KeyMsg:
101- switch msg.String() {
102- case "q", "esc":
103- return m, pages.Navigate(pages.MenuPage)
104- case "up", "k":
105- m.index -= 1
106- if m.index < 0 && m.pager.Page > 0 {
107- m.index = m.pager.PerPage - 1
108- m.pager.PrevPage()
109- }
110- m.index = max(0, m.index)
111- case "down", "j":
112- itemsOnPage := m.pager.ItemsOnPage(len(m.keys))
113- m.index += 1
114- if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
115- m.index = 0
116- m.pager.NextPage()
117- }
118- m.index = min(itemsOnPage-1, m.index)
119-
120- case "n":
121- return m, pages.Navigate(pages.CreatePubkeyPage)
122-
123- // Delete
124- case "x":
125- m.state = stateDeletingKey
126- m.UpdatePaging(msg)
127- return m, nil
128-
129- // Confirm Delete
130- case "y":
131- switch m.state {
132- case stateDeletingKey:
133- if len(m.keys) == 1 {
134- // The user is about to delete her account. Double confirm.
135- m.state = stateDeletingAccount
136- return m, nil
137- }
138- if m.getSelectedIndex() == m.activeKeyIndex {
139- // The user is going to delete her active key. Double confirm.
140- m.state = stateDeletingActiveKey
141- return m, nil
142- }
143- m.state = stateNormal
144- return m, m.unlinkKey()
145- case stateDeletingActiveKey:
146- m.state = stateQuitting
147- // Active key will be deleted. Remove the key and exit.
148- return m, m.unlinkKey()
149- case stateDeletingAccount:
150- // Account will be deleted. Remove the key and exit.
151- m.state = stateQuitting
152- return m, m.deleteAccount()
153- }
154- }
155-
156- case common.ErrMsg:
157- m.err = msg.Err
158- return m, nil
159-
160- case keysLoadedMsg:
161- m.state = stateNormal
162- m.index = 0
163- m.keys = msg
164- for i, key := range m.keys {
165- if m.shared.User.PublicKey != nil && key.Key == m.shared.User.PublicKey.Key {
166- m.activeKeyIndex = i
167- }
168- }
169-
170- case unlinkedKeyMsg:
171- if m.state == stateQuitting {
172- return m, tea.Quit
173- }
174- i := m.getSelectedIndex()
175-
176- // Remove key from array
177- m.keys = append(m.keys[:i], m.keys[i+1:]...)
178-
179- // Update pagination
180- m.pager.SetTotalPages(len(m.keys))
181- m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
182-
183- // Update cursor
184- m.index = min(m.index, m.pager.ItemsOnPage(len(m.keys)-1))
185- for i, key := range m.keys {
186- if key.Key == m.shared.User.PublicKey.Key {
187- m.activeKeyIndex = i
188- }
189- }
190-
191- return m, nil
192-
193- // leaving page so reset model
194- case pages.NavigateMsg:
195- next := NewModel(m.shared)
196- return next, next.Init()
197- }
198-
199- switch m.state {
200- case stateDeletingKey:
201- // If an item is being confirmed for delete, any key (other than the key
202- // used for confirmation above) cancels the deletion
203- k, ok := msg.(tea.KeyMsg)
204- if ok && k.String() != "y" {
205- m.state = stateNormal
206- }
207- }
208-
209- m.UpdatePaging(msg)
210- return m, tea.Batch(cmds...)
211-}
212-
213-// View renders the current UI into a string.
214-func (m Model) View() string {
215- if m.err != nil {
216- return m.err.Error()
217- }
218-
219- var s string
220-
221- switch m.state {
222- case stateLoading:
223- s = "Loading...\n\n"
224- case stateQuitting:
225- s = "Thanks for using pico.sh!\n"
226- default:
227- s = "Here are the pubkeys linked to your account. Add more pubkeys to be able to login with multiple SSH keypairs.\n\n"
228-
229- // Keys
230- s += m.keysView()
231- if m.pager.TotalPages > 1 {
232- s += m.pager.View()
233- }
234-
235- // Footer
236- switch m.state {
237- case stateDeletingKey:
238- s += m.promptView("Delete this key?")
239- case stateDeletingActiveKey:
240- s += m.promptView("This is the key currently in use. Are you, like, for-sure-for-sure?")
241- case stateDeletingAccount:
242- s += m.promptView("Sure? This will delete your account. Are you absolutely positive?")
243- default:
244- s += "\n\n" + m.helpView()
245- }
246- }
247-
248- return s
249-}
250-
251-func (m *Model) keysView() string {
252- var (
253- s string
254- state keyState
255- start, end = m.pager.GetSliceBounds(len(m.keys))
256- slice = m.keys[start:end]
257- )
258-
259- destructiveState :=
260- (m.state == stateDeletingKey ||
261- m.state == stateDeletingActiveKey ||
262- m.state == stateDeletingAccount)
263-
264- // Render key info
265- for i, key := range slice {
266- if destructiveState && m.index == i {
267- state = keyDeleting
268- } else if m.index == i {
269- state = keySelected
270- } else {
271- state = keyNormal
272- }
273- s += m.newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
274- }
275-
276- // If there aren't enough keys to fill the view, fill the missing parts
277- // with whitespace
278- if len(slice) < m.pager.PerPage {
279- for i := len(slice); i < m.pager.PerPage; i++ {
280- s += "\n\n\n"
281- }
282- }
283-
284- return s
285-}
286-
287-func (m *Model) helpView() string {
288- var items []string
289- if len(m.keys) > 1 {
290- items = append(items, "j/k, ↑/↓: choose")
291- }
292- if m.pager.TotalPages > 1 {
293- items = append(items, "h/l, ←/→: page")
294- }
295- items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
296- return common.HelpView(m.shared.Styles, items...)
297-}
298-
299-func (m *Model) promptView(prompt string) string {
300- st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
301- return st.Render(prompt) +
302- m.shared.Styles.Delete.Render("(y/N)")
303-}
304-
305-// FetchKeys loads the current set of keys via the charm client.
306-func FetchKeys(shrd *common.SharedModel) tea.Cmd {
307- return func() tea.Msg {
308- ak, err := shrd.Dbpool.FindKeysForUser(shrd.User)
309- if err != nil {
310- return common.ErrMsg{Err: err}
311- }
312- return keysLoadedMsg(ak)
313- }
314-}
315-
316-// unlinkKey deletes the selected key.
317-func (m *Model) unlinkKey() tea.Cmd {
318- return func() tea.Msg {
319- id := m.keys[m.getSelectedIndex()].ID
320- err := m.shared.Dbpool.RemoveKeys([]string{id})
321- if err != nil {
322- return common.ErrMsg{Err: err}
323- }
324- return unlinkedKeyMsg(m.index)
325- }
326-}
327-
328-func (m *Model) deleteAccount() tea.Cmd {
329- return func() tea.Msg {
330- id := m.keys[m.getSelectedIndex()].UserID
331- m.shared.Logger.Info("user requested account deletion", "user", m.shared.User.Name, "id", id)
332- err := m.shared.Dbpool.RemoveUsers([]string{id})
333- if err != nil {
334- return common.ErrMsg{Err: err}
335- }
336- return unlinkedKeyMsg(m.index)
337- }
338-}
339-
340-// Utils
341-
342-func min(a, b int) int {
343- if a < b {
344- return a
345- }
346- return b
347-}
348-
349-func max(a, b int) int {
350- if a > b {
351- return a
352- }
353- return b
354-}
+0,
-144
1@@ -1,144 +0,0 @@
2-package pubkeys
3-
4-import (
5- "fmt"
6- "strings"
7-
8- "github.com/picosh/pico/db"
9- "github.com/picosh/pico/tui/common"
10- "golang.org/x/crypto/ssh"
11-)
12-
13-func algo(keyType string) string {
14- if idx := strings.Index(keyType, "@"); idx > 0 {
15- return algo(keyType[0:idx])
16- }
17- parts := strings.Split(keyType, "-")
18- if len(parts) == 2 {
19- return parts[1]
20- }
21- if parts[0] == "sk" {
22- return algo(strings.TrimPrefix(keyType, "sk-"))
23- }
24- return parts[0]
25-}
26-
27-type Fingerprint struct {
28- Type string
29- Value string
30- Algorithm string
31- Styles common.Styles
32-}
33-
34-// String outputs a string representation of the fingerprint.
35-func (f Fingerprint) String() string {
36- return fmt.Sprintf(
37- "%s %s",
38- f.Styles.ListKey.Render(strings.ToUpper(f.Algorithm)),
39- f.Styles.ListKey.Render(f.Type+":"+f.Value),
40- )
41-}
42-
43-// FingerprintSHA256 returns the algorithm and SHA256 fingerprint for the given
44-// key.
45-func FingerprintSHA256(styles common.Styles, k *db.PublicKey) (Fingerprint, error) {
46- key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
47- if err != nil {
48- return Fingerprint{}, fmt.Errorf("failed to parse public key: %w", err)
49- }
50-
51- return Fingerprint{
52- Algorithm: algo(key.Type()),
53- Type: "SHA256",
54- Value: strings.TrimPrefix(ssh.FingerprintSHA256(key), "SHA256:"),
55- Styles: styles,
56- }, nil
57-}
58-
59-// wrap fingerprint to support additional states.
60-type fingerprint struct {
61- Fingerprint
62-}
63-
64-func (f fingerprint) state(s keyState, styles common.Styles) string {
65- if s == keyDeleting {
66- return fmt.Sprintf(
67- "%s %s",
68- styles.Delete.Render(strings.ToUpper(f.Algorithm)),
69- styles.Delete.Render(f.Type+":"+f.Value),
70- )
71- }
72- return f.String()
73-}
74-
75-type styledKey struct {
76- styles common.Styles
77- date string
78- fingerprint fingerprint
79- gutter string
80- keyLabel string
81- dateLabel string
82- commentLabel string
83- commentVal string
84- dateVal string
85- note string
86-}
87-
88-func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool) styledKey {
89- date := key.CreatedAt.Format(common.DateFormat)
90- fp, err := FingerprintSHA256(styles, key)
91- if err != nil {
92- fp = Fingerprint{Value: "[error generating fingerprint]"}
93- }
94-
95- var note string
96- if active {
97- note = m.shared.Styles.Note.Render("• Current Key")
98- }
99-
100- // Default state
101- return styledKey{
102- styles: styles,
103- date: date,
104- fingerprint: fingerprint{fp},
105- gutter: " ",
106- keyLabel: "Key:",
107- dateLabel: "Added:",
108- commentLabel: "Name:",
109- commentVal: key.Name,
110- dateVal: styles.Label.Render(date),
111- note: note,
112- }
113-}
114-
115-// Selected state.
116-func (k *styledKey) selected() {
117- k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
118- k.keyLabel = k.styles.Label.Render("Key:")
119- k.dateLabel = k.styles.Label.Render("Added:")
120- k.commentLabel = k.styles.Label.Render("Name:")
121-}
122-
123-// Deleting state.
124-func (k *styledKey) deleting() {
125- k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
126- k.keyLabel = k.styles.Delete.Render("Key:")
127- k.dateLabel = k.styles.Delete.Render("Added:")
128- k.commentLabel = k.styles.Delete.Render("Name:")
129- k.dateVal = k.styles.Delete.Render(k.date)
130-}
131-
132-func (k styledKey) render(state keyState) string {
133- switch state {
134- case keySelected:
135- k.selected()
136- case keyDeleting:
137- k.deleting()
138- }
139- return fmt.Sprintf(
140- "%s %s %s %s\n%s %s %s\n%s %s %s\n\n",
141- k.gutter, k.commentLabel, k.commentVal, k.note,
142- k.gutter, k.keyLabel, k.fingerprint.state(state, k.styles),
143- k.gutter, k.dateLabel, k.dateVal,
144- )
145-}
+29,
-0
1@@ -0,0 +1,29 @@
2+package tui
3+
4+import (
5+ "io"
6+
7+ "github.com/picosh/pico/shared"
8+)
9+
10+type SenpaiCmd struct {
11+ Shared *SharedModel
12+}
13+
14+func (m *SenpaiCmd) Run() error {
15+ pass, err := m.Shared.Dbpool.UpsertToken(m.Shared.User.ID, "pico-chat")
16+ if err != nil {
17+ return err
18+ }
19+ app, err := shared.NewSenpaiApp(m.Shared.Session, m.Shared.User.Name, pass)
20+ if err != nil {
21+ return err
22+ }
23+ app.Run()
24+ app.Close()
25+ return nil
26+}
27+
28+func (m *SenpaiCmd) SetStdin(io.Reader) {}
29+func (m *SenpaiCmd) SetStdout(io.Writer) {}
30+func (m *SenpaiCmd) SetStderr(io.Writer) {}
+0,
-214
1@@ -1,214 +0,0 @@
2-package settings
3-
4-import (
5- "fmt"
6- "time"
7-
8- tea "github.com/charmbracelet/bubbletea"
9- "github.com/charmbracelet/lipgloss"
10- "github.com/charmbracelet/lipgloss/table"
11- "github.com/picosh/pico/db"
12- "github.com/picosh/pico/tui/common"
13- "github.com/picosh/pico/tui/pages"
14- "github.com/picosh/utils"
15-)
16-
17-var maxWidth = 50
18-
19-type state int
20-
21-const (
22- stateLoading state = iota
23- stateReady
24-)
25-
26-type focus int
27-
28-const (
29- focusNone = iota
30- focusAnalytics
31-)
32-
33-type featuresLoadedMsg []*db.FeatureFlag
34-
35-type Model struct {
36- shared *common.SharedModel
37- features []*db.FeatureFlag
38- state state
39- focus focus
40-}
41-
42-func NewModel(shrd *common.SharedModel) Model {
43- return Model{
44- shared: shrd,
45- state: stateLoading,
46- focus: focusNone,
47- }
48-}
49-
50-func (m Model) Init() tea.Cmd {
51- return m.fetchFeatures()
52-}
53-
54-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
55- switch msg := msg.(type) {
56- case tea.KeyMsg:
57- switch msg.String() {
58- case "q", "esc":
59- return m, pages.Navigate(pages.MenuPage)
60- case "tab":
61- if m.focus == focusNone {
62- m.focus = focusAnalytics
63- } else {
64- m.focus = focusNone
65- }
66- case "enter":
67- if m.focus == focusAnalytics {
68- return m, m.toggleAnalytics()
69- }
70- }
71-
72- case featuresLoadedMsg:
73- m.state = stateReady
74- m.focus = focusNone
75- m.features = msg
76- }
77- return m, nil
78-}
79-
80-func (m Model) View() string {
81- if m.state == stateLoading {
82- return "Loading ..."
83- }
84- return m.servicesView() + "\n" + m.featuresView() + "\n" + m.analyticsView()
85-}
86-
87-func (m Model) findAnalyticsFeature() *db.FeatureFlag {
88- for _, feature := range m.features {
89- if feature.Name == "analytics" {
90- return feature
91- }
92- }
93- return nil
94-}
95-
96-func (m Model) analyticsView() string {
97- banner := `Get usage statistics on your blog, blog posts, and pages sites. For example, see unique visitors, most popular URLs, and top referers.
98-
99-We do not collect usage statistic unless analytics is enabled. Further, when analytics are disabled we do not purge usage statistics.
100-
101-We will only store usage statistics for 1 year from when the event was created.`
102-
103- str := ""
104- hasPlus := m.shared.PlusFeatureFlag != nil
105- if hasPlus {
106- ff := m.findAnalyticsFeature()
107- hasFocus := m.focus == focusAnalytics
108- if ff == nil {
109- str += banner + "\n\nEnable analytics " + common.OKButtonView(m.shared.Styles, hasFocus, false)
110- } else {
111- str += "Disable analytics " + common.OKButtonView(m.shared.Styles, hasFocus, false)
112- }
113- } else {
114- str += banner + "\n\n" + m.shared.Styles.Error.SetString("Analytics is only available to pico+ users.").String()
115- }
116-
117- return m.shared.Styles.RoundedBorder.Width(maxWidth).SetString(str).String()
118-}
119-
120-func (m Model) servicesView() string {
121- headers := []string{
122- "Name",
123- "Status",
124- }
125-
126- hasPlus := m.shared.PlusFeatureFlag != nil
127-
128- data := [][]string{
129- {"prose", "active"},
130- {"pipe", "active"},
131- {"pastes", "active"},
132- {"rss-to-email", "active"},
133- }
134-
135- if hasPlus {
136- data = append(
137- data,
138- []string{"pages", "active"},
139- []string{"tuns", "active"},
140- []string{"irc bouncer", "active"},
141- )
142- } else {
143- data = append(
144- data,
145- []string{"pages", "free tier"},
146- []string{"tuns", "requires pico+"},
147- []string{"IRC bouncer", "requires pico+"},
148- )
149- }
150-
151- t := table.New().
152- Border(lipgloss.RoundedBorder()).
153- BorderStyle(m.shared.Styles.Renderer.NewStyle().BorderForeground(common.Indigo)).
154- Width(maxWidth).
155- Headers(headers...).
156- Rows(data...)
157- return "Services\n" + t.String()
158-}
159-
160-func (m Model) featuresView() string {
161- headers := []string{
162- "Name",
163- "Quota (GB)",
164- "Expires At",
165- }
166-
167- data := [][]string{}
168- for _, feature := range m.features {
169- storeMax := utils.BytesToGB(int(feature.FindStorageMax(0)))
170- row := []string{
171- feature.Name,
172- fmt.Sprintf("%.2f", storeMax),
173- feature.ExpiresAt.Format(common.DateFormat),
174- }
175- data = append(data, row)
176- }
177- t := table.New().
178- Border(lipgloss.RoundedBorder()).
179- BorderStyle(m.shared.Styles.Renderer.NewStyle().BorderForeground(common.Indigo)).
180- Width(maxWidth).
181- Headers(headers...).
182- Rows(data...)
183- return "Features\n" + t.String()
184-}
185-
186-func (m Model) fetchFeatures() tea.Cmd {
187- return func() tea.Msg {
188- features, err := m.shared.Dbpool.FindFeaturesForUser(m.shared.User.ID)
189- if err != nil {
190- return common.ErrMsg{Err: err}
191- }
192- return featuresLoadedMsg(features)
193- }
194-}
195-
196-func (m Model) toggleAnalytics() tea.Cmd {
197- return func() tea.Msg {
198- if m.findAnalyticsFeature() == nil {
199- now := time.Now()
200- expiresAt := now.AddDate(100, 0, 0)
201- _, err := m.shared.Dbpool.InsertFeature(m.shared.User.ID, "analytics", expiresAt)
202- if err != nil {
203- return common.ErrMsg{Err: err}
204- }
205- } else {
206- err := m.shared.Dbpool.RemoveFeature(m.shared.User.ID, "analytics")
207- if err != nil {
208- return common.ErrMsg{Err: err}
209- }
210- }
211-
212- cmd := m.fetchFeatures()
213- return cmd()
214- }
215-}
+117,
-0
1@@ -0,0 +1,117 @@
2+package tui
3+
4+import (
5+ "fmt"
6+
7+ "git.sr.ht/~rockorager/vaxis"
8+ "git.sr.ht/~rockorager/vaxis/vxfw"
9+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
12+ "github.com/picosh/pico/db"
13+ "github.com/picosh/utils"
14+ "golang.org/x/crypto/ssh"
15+)
16+
17+type SignupPage struct {
18+ shared *SharedModel
19+ focus string
20+ err error
21+
22+ input *TextInput
23+ btn *button.Button
24+}
25+
26+func NewSignupPage(shrd *SharedModel) *SignupPage {
27+ btn := button.New("SIGNUP", func() (vxfw.Command, error) { return nil, nil })
28+ btn.Style = button.StyleSet{
29+ Default: vaxis.Style{Background: grey},
30+ Focus: vaxis.Style{Background: oj, Foreground: black},
31+ }
32+ input := NewTextInput("signup")
33+ return &SignupPage{shared: shrd, btn: btn, input: input}
34+}
35+
36+func (m *SignupPage) createAccount(name string) (*db.User, error) {
37+ if name == "" {
38+ return nil, fmt.Errorf("name cannot be empty")
39+ }
40+ key := utils.KeyForKeyText(m.shared.Session.PublicKey())
41+ return m.shared.Dbpool.RegisterUser(name, key, "")
42+}
43+
44+func (m *SignupPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
45+ switch msg := ev.(type) {
46+ case PageIn:
47+ return m.input.FocusIn()
48+ case vaxis.Key:
49+ if msg.Matches(vaxis.KeyTab) {
50+ if m.focus == "button" {
51+ m.focus = "input"
52+ return m.input.FocusIn()
53+ }
54+ m.focus = "button"
55+ cmd, _ := m.input.FocusOut()
56+ return vxfw.BatchCmd([]vxfw.Command{
57+ cmd,
58+ vxfw.FocusWidgetCmd(m.btn),
59+ }), nil
60+ }
61+ if msg.Matches(vaxis.KeyEnter) {
62+ if m.focus == "button" {
63+ user, err := m.createAccount(m.input.GetValue())
64+ if err != nil {
65+ m.err = err
66+ return vxfw.RedrawCmd{}, nil
67+ }
68+ m.shared.User = user
69+ m.shared.App.PostEvent(Navigate{To: HOME})
70+ }
71+ }
72+ }
73+
74+ return nil, nil
75+}
76+
77+func (m *SignupPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
78+ w := ctx.Max.Width
79+ h := ctx.Max.Height
80+
81+ root := vxfw.NewSurface(w, h, m)
82+
83+ fp := ssh.FingerprintSHA256(m.shared.Session.PublicKey())
84+ intro := richtext.New([]vaxis.Segment{
85+ {Text: "Welcome to pico.sh's management TUI!\n\n"},
86+ {Text: "By creating an account you get access to our pico services. We have free and paid services."},
87+ {Text: " After you create an account, you can go to our docs site to get started:\n\n "},
88+ {Text: "https://pico.sh/getting-started\n\n", Style: vaxis.Style{Hyperlink: "https://pico.sh/getting-started"}},
89+ {Text: fmt.Sprintf("pubkey: %s\n\n", fp), Style: vaxis.Style{Foreground: purp}},
90+ })
91+ introSurf, _ := intro.Draw(ctx)
92+ ah := 0
93+ root.AddChild(0, ah, introSurf)
94+ ah += int(introSurf.Size.Height)
95+
96+ inpSurf, _ := m.input.Draw(vxfw.DrawContext{
97+ Characters: ctx.Characters,
98+ Max: vxfw.Size{Width: ctx.Max.Width, Height: 4},
99+ })
100+ root.AddChild(0, ah, inpSurf)
101+ ah += int(inpSurf.Size.Height)
102+
103+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
104+ Characters: ctx.Characters,
105+ Max: vxfw.Size{Width: 10, Height: 1},
106+ })
107+ root.AddChild(0, ah, btnSurf)
108+ ah += int(btnSurf.Size.Height)
109+
110+ if m.err != nil {
111+ errTxt := text.New(m.err.Error())
112+ errTxt.Style = vaxis.Style{Foreground: red}
113+ errSurf, _ := errTxt.Draw(ctx)
114+ root.AddChild(0, ah, errSurf)
115+ }
116+
117+ return root, nil
118+}
+264,
-0
1@@ -0,0 +1,264 @@
2+package tui
3+
4+import (
5+ "fmt"
6+ "time"
7+
8+ "git.sr.ht/~rockorager/vaxis"
9+ "git.sr.ht/~rockorager/vaxis/vxfw"
10+ "git.sr.ht/~rockorager/vaxis/vxfw/button"
11+ "git.sr.ht/~rockorager/vaxis/vxfw/list"
12+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
13+ "git.sr.ht/~rockorager/vaxis/vxfw/text"
14+ "github.com/picosh/pico/db"
15+)
16+
17+type TokensPage struct {
18+ shared *SharedModel
19+ list list.Dynamic
20+
21+ tokens []*db.Token
22+ err error
23+ confirm bool
24+}
25+
26+func NewTokensPage(shrd *SharedModel) *TokensPage {
27+ m := &TokensPage{
28+ shared: shrd,
29+ }
30+ m.list = list.Dynamic{DrawCursor: true, Builder: m.getWidget}
31+ return m
32+}
33+
34+type FetchTokens struct{}
35+
36+func (m *TokensPage) Footer() []Shortcut {
37+ return []Shortcut{
38+ {Shortcut: "j/k", Text: "choose"},
39+ {Shortcut: "x", Text: "delete"},
40+ {Shortcut: "c", Text: "create"},
41+ }
42+}
43+
44+func (m *TokensPage) fetchTokens() error {
45+ tokens, err := m.shared.Dbpool.FindTokensForUser(m.shared.User.ID)
46+ if err != nil {
47+ return err
48+
49+ }
50+ m.tokens = tokens
51+ return nil
52+}
53+
54+func (m *TokensPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
55+ switch msg := ev.(type) {
56+ case PageIn:
57+ m.err = m.fetchTokens()
58+ return vxfw.FocusWidgetCmd(&m.list), nil
59+ case vaxis.Key:
60+ if msg.Matches('c') {
61+ m.shared.App.PostEvent(Navigate{To: "add-token"})
62+ }
63+ if msg.Matches('x') {
64+ m.confirm = true
65+ return vxfw.RedrawCmd{}, nil
66+ }
67+ if msg.Matches('y') {
68+ if m.confirm {
69+ m.confirm = false
70+ err := m.shared.Dbpool.RemoveToken(m.tokens[m.list.Cursor()].ID)
71+ if err != nil {
72+ m.err = err
73+ return nil, nil
74+ }
75+ m.err = m.fetchTokens()
76+ return vxfw.RedrawCmd{}, nil
77+ }
78+ }
79+ if msg.Matches('n') {
80+ m.confirm = false
81+ return vxfw.RedrawCmd{}, nil
82+ }
83+ }
84+
85+ return nil, nil
86+}
87+
88+func (m *TokensPage) getWidget(i uint, cursor uint) vxfw.Widget {
89+ if int(i) >= len(m.tokens) {
90+ return nil
91+ }
92+
93+ style := vaxis.Style{Foreground: grey}
94+ isSelected := i == cursor
95+ if isSelected {
96+ style = vaxis.Style{Foreground: fuschia}
97+ }
98+
99+ token := m.tokens[i]
100+ txt := richtext.New([]vaxis.Segment{
101+ {Text: "Name: ", Style: style},
102+ {Text: token.Name + "\n"},
103+
104+ {Text: "Created: ", Style: style},
105+ {Text: token.CreatedAt.Format(time.DateOnly)},
106+ })
107+
108+ return txt
109+}
110+
111+func (m *TokensPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
112+ w := ctx.Max.Width
113+ h := ctx.Max.Height
114+ root := vxfw.NewSurface(w, h, m)
115+
116+ header := richtext.New([]vaxis.Segment{
117+ {
118+ Text: fmt.Sprintf(
119+ "%d tokens\n",
120+ len(m.tokens),
121+ ),
122+ },
123+ })
124+ headerSurf, _ := header.Draw(createDrawCtx(ctx, 2))
125+ root.AddChild(0, 0, headerSurf)
126+
127+ listSurf, _ := m.list.Draw(createDrawCtx(ctx, h-5))
128+ root.AddChild(0, 3, listSurf)
129+
130+ segs := []vaxis.Segment{}
131+ if m.confirm {
132+ segs = append(segs, vaxis.Segment{
133+ Text: "are you sure? y/n\n",
134+ Style: vaxis.Style{Foreground: red},
135+ })
136+ }
137+ if m.err != nil {
138+ segs = append(segs, vaxis.Segment{
139+ Text: m.err.Error() + "\n",
140+ Style: vaxis.Style{Foreground: red},
141+ })
142+ }
143+ segs = append(segs, vaxis.Segment{Text: "\n"})
144+
145+ footer := richtext.New(segs)
146+ footerSurf, _ := footer.Draw(createDrawCtx(ctx, 3))
147+ root.AddChild(0, int(h)-3, footerSurf)
148+
149+ return root, nil
150+}
151+
152+type AddTokenPage struct {
153+ shared *SharedModel
154+
155+ token string
156+ err error
157+ focus string
158+ input *TextInput
159+ btn *button.Button
160+}
161+
162+func NewAddTokenPage(shrd *SharedModel) *AddTokenPage {
163+ btn := button.New("ADD", func() (vxfw.Command, error) { return nil, nil })
164+ btn.Style = button.StyleSet{
165+ Default: vaxis.Style{Background: grey},
166+ Focus: vaxis.Style{Background: oj, Foreground: black},
167+ }
168+ return &AddTokenPage{
169+ shared: shrd,
170+
171+ input: NewTextInput("enter name"),
172+ btn: btn,
173+ }
174+}
175+
176+func (m *AddTokenPage) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
177+ switch msg := ev.(type) {
178+ case PageIn:
179+ m.focus = "input"
180+ m.input.Reset()
181+ return m.input.FocusIn()
182+ case vaxis.Key:
183+ if msg.Matches(vaxis.KeyTab) {
184+ if m.token != "" {
185+ return nil, nil
186+ }
187+ if m.focus == "input" {
188+ m.focus = "button"
189+ cmd, _ := m.input.FocusOut()
190+ return vxfw.BatchCmd([]vxfw.Command{
191+ cmd,
192+ vxfw.FocusWidgetCmd(m.btn),
193+ }), nil
194+ }
195+ m.focus = "input"
196+ return m.input.FocusIn()
197+ }
198+ if msg.Matches(vaxis.KeyEnter) {
199+ if m.focus == "button" {
200+ if m.token != "" {
201+ m.token = ""
202+ m.err = nil
203+ m.input.Reset()
204+ m.shared.App.PostEvent(Navigate{To: "tokens"})
205+ return vxfw.RedrawCmd{}, nil
206+ }
207+ token, err := m.addToken(m.input.GetValue())
208+ m.token = token
209+ m.err = err
210+ return vxfw.RedrawCmd{}, nil
211+ }
212+ }
213+ }
214+
215+ return nil, nil
216+}
217+
218+func (m *AddTokenPage) addToken(name string) (string, error) {
219+ return m.shared.Dbpool.InsertToken(m.shared.User.ID, name)
220+}
221+
222+func (m *AddTokenPage) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
223+ w := ctx.Max.Width
224+ h := ctx.Max.Height
225+ root := vxfw.NewSurface(w, h, m)
226+
227+ if m.token == "" {
228+ header := text.New("Enter a name for the token")
229+ headerSurf, _ := header.Draw(ctx)
230+ root.AddChild(0, 0, headerSurf)
231+
232+ inputSurf, _ := m.input.Draw(createDrawCtx(ctx, 4))
233+ root.AddChild(0, 3, inputSurf)
234+
235+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
236+ Characters: ctx.Characters,
237+ Max: vxfw.Size{Width: 5, Height: 1},
238+ })
239+ root.AddChild(0, 6, btnSurf)
240+ } else {
241+ header := text.New(
242+ fmt.Sprintf(
243+ "Save this token: %s\n\nAfter you exit this screen you will *not* be able to see it again.",
244+ m.token,
245+ ),
246+ )
247+ headerSurf, _ := header.Draw(ctx)
248+ root.AddChild(0, 0, headerSurf)
249+
250+ btnSurf, _ := m.btn.Draw(vxfw.DrawContext{
251+ Characters: ctx.Characters,
252+ Max: vxfw.Size{Width: 5, Height: 1},
253+ })
254+ root.AddChild(0, 7, btnSurf)
255+ }
256+
257+ if m.err != nil {
258+ e := text.New(m.err.Error())
259+ e.Style = vaxis.Style{Foreground: red}
260+ errSurf, _ := e.Draw(ctx)
261+ root.AddChild(0, 9, errSurf)
262+ }
263+
264+ return root, nil
265+}
+0,
-304
1@@ -1,304 +0,0 @@
2-package tokens
3-
4-import (
5- pager "github.com/charmbracelet/bubbles/paginator"
6- tea "github.com/charmbracelet/bubbletea"
7- "github.com/picosh/pico/db"
8- "github.com/picosh/pico/tui/common"
9- "github.com/picosh/pico/tui/pages"
10-)
11-
12-const keysPerPage = 4
13-
14-type state int
15-
16-const (
17- stateLoading state = iota
18- stateNormal
19- stateDeletingKey
20-)
21-
22-type keyState int
23-
24-const (
25- keyNormal keyState = iota
26- keySelected
27- keyDeleting
28-)
29-
30-type errMsg struct {
31- err error
32-}
33-
34-func (e errMsg) Error() string { return e.err.Error() }
35-
36-type (
37- keysLoadedMsg []*db.Token
38- unlinkedKeyMsg int
39-)
40-
41-// Model is the Tea state model for this user interface.
42-type Model struct {
43- shared *common.SharedModel
44-
45- state state
46- err error
47- activeKeyIndex int // index of the key in the below slice which is currently in use
48- tokens []*db.Token // keys linked to user's account
49- index int // index of selected key in relation to the current page
50-
51- pager pager.Model
52-}
53-
54-// getSelectedIndex returns the index of the cursor in relation to the total
55-// number of items.
56-func (m *Model) getSelectedIndex() int {
57- return m.index + m.pager.Page*m.pager.PerPage
58-}
59-
60-// UpdatePaging runs an update against the underlying pagination model as well
61-// as performing some related tasks on this model.
62-func (m *Model) UpdatePaging(msg tea.Msg) {
63- // Handle paging
64- m.pager.SetTotalPages(len(m.tokens))
65- m.pager, _ = m.pager.Update(msg)
66-
67- // If selected item is out of bounds, put it in bounds
68- numItems := m.pager.ItemsOnPage(len(m.tokens))
69- m.index = min(m.index, numItems-1)
70-}
71-
72-// NewModel creates a new model with defaults.
73-func NewModel(shared *common.SharedModel) Model {
74- p := pager.New()
75- p.PerPage = keysPerPage
76- p.Type = pager.Dots
77- p.InactiveDot = shared.Styles.InactivePagination.Render("•")
78-
79- return Model{
80- shared: shared,
81-
82- state: stateLoading,
83- err: nil,
84- activeKeyIndex: -1,
85- tokens: []*db.Token{},
86- index: 0,
87-
88- pager: p,
89- }
90-}
91-
92-// Init is the Tea initialization function.
93-func (m Model) Init() tea.Cmd {
94- return FetchTokens(m.shared)
95-}
96-
97-// Update is the tea update function which handles incoming messages.
98-func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
99- var (
100- cmds []tea.Cmd
101- )
102-
103- switch msg := msg.(type) {
104- case tea.KeyMsg:
105- switch msg.String() {
106- case "q", "esc":
107- return m, pages.Navigate(pages.MenuPage)
108- case "up", "k":
109- m.index--
110- if m.index < 0 && m.pager.Page > 0 {
111- m.index = m.pager.PerPage - 1
112- m.pager.PrevPage()
113- }
114- m.index = max(0, m.index)
115- case "down", "j":
116- itemsOnPage := m.pager.ItemsOnPage(len(m.tokens))
117- m.index++
118- if m.index > itemsOnPage-1 && m.pager.Page < m.pager.TotalPages-1 {
119- m.index = 0
120- m.pager.NextPage()
121- }
122- m.index = min(itemsOnPage-1, m.index)
123-
124- case "n":
125- return m, pages.Navigate(pages.CreateTokenPage)
126-
127- // Delete
128- case "x":
129- m.state = stateDeletingKey
130- m.UpdatePaging(msg)
131- return m, nil
132-
133- // Confirm Delete
134- case "y":
135- switch m.state {
136- case stateDeletingKey:
137- m.state = stateNormal
138- return m, m.unlinkKey()
139- }
140- }
141-
142- case errMsg:
143- m.err = msg.err
144- return m, nil
145-
146- case keysLoadedMsg:
147- m.state = stateNormal
148- m.index = 0
149- m.tokens = msg
150-
151- case unlinkedKeyMsg:
152- i := m.getSelectedIndex()
153-
154- // Remove key from array
155- m.tokens = append(m.tokens[:i], m.tokens[i+1:]...)
156-
157- // Update pagination
158- m.pager.SetTotalPages(len(m.tokens))
159- m.pager.Page = min(m.pager.Page, m.pager.TotalPages-1)
160-
161- // Update cursor
162- m.index = min(m.index, m.pager.ItemsOnPage(len(m.tokens)-1))
163-
164- return m, nil
165-
166- // leaving page so reset model
167- case pages.NavigateMsg:
168- next := NewModel(m.shared)
169- return next, next.Init()
170- }
171-
172- switch m.state {
173- case stateDeletingKey:
174- // If an item is being confirmed for delete, any key (other than the key
175- // used for confirmation above) cancels the deletion
176- k, ok := msg.(tea.KeyMsg)
177- if ok && k.String() != "y" {
178- m.state = stateNormal
179- }
180- }
181-
182- m.UpdatePaging(msg)
183- return m, tea.Batch(cmds...)
184-}
185-
186-// View renders the current UI into a string.
187-func (m Model) View() string {
188- if m.err != nil {
189- return m.err.Error()
190- }
191-
192- var s string
193-
194- switch m.state {
195- case stateLoading:
196- s = "Loading...\n\n"
197- default:
198- s = "Here are the tokens linked to your account. An API token can be used for connecting to our IRC bouncer or your pico RSS feed.\n\n"
199-
200- // Keys
201- s += keysView(m)
202- if m.pager.TotalPages > 1 {
203- s += m.pager.View()
204- }
205-
206- // Footer
207- switch m.state {
208- case stateDeletingKey:
209- s += m.promptView("Delete this key?")
210- default:
211- s += "\n\n" + m.helpView()
212- }
213- }
214-
215- return s
216-}
217-
218-func keysView(m Model) string {
219- var (
220- s string
221- state keyState
222- start, end = m.pager.GetSliceBounds(len(m.tokens))
223- slice = m.tokens[start:end]
224- )
225-
226- destructiveState := m.state == stateDeletingKey
227-
228- // Render key info
229- for i, key := range slice {
230- if destructiveState && m.index == i {
231- state = keyDeleting
232- } else if m.index == i {
233- state = keySelected
234- } else {
235- state = keyNormal
236- }
237- s += newStyledKey(m.shared.Styles, key, i+start == m.activeKeyIndex).render(state)
238- }
239-
240- // If there aren't enough keys to fill the view, fill the missing parts
241- // with whitespace
242- if len(slice) < m.pager.PerPage {
243- for i := len(slice); i < m.pager.PerPage; i++ {
244- s += "\n\n\n"
245- }
246- }
247-
248- return s
249-}
250-
251-func (m *Model) helpView() string {
252- var items []string
253- if len(m.tokens) > 1 {
254- items = append(items, "j/k, ↑/↓: choose")
255- }
256- if m.pager.TotalPages > 1 {
257- items = append(items, "h/l, ←/→: page")
258- }
259- items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
260- return common.HelpView(m.shared.Styles, items...)
261-}
262-
263-func (m *Model) promptView(prompt string) string {
264- st := m.shared.Styles.Delete.MarginTop(2).MarginRight(1)
265- return st.Render(prompt) +
266- m.shared.Styles.Delete.Render("(y/N)")
267-}
268-
269-func FetchTokens(shrd *common.SharedModel) tea.Cmd {
270- return func() tea.Msg {
271- ak, err := shrd.Dbpool.FindTokensForUser(shrd.User.ID)
272- if err != nil {
273- return errMsg{err}
274- }
275- return keysLoadedMsg(ak)
276- }
277-}
278-
279-// unlinkKey deletes the selected key.
280-func (m *Model) unlinkKey() tea.Cmd {
281- return func() tea.Msg {
282- id := m.tokens[m.getSelectedIndex()].ID
283- err := m.shared.Dbpool.RemoveToken(id)
284- if err != nil {
285- return errMsg{err}
286- }
287- return unlinkedKeyMsg(m.index)
288- }
289-}
290-
291-// Utils
292-
293-func min(a, b int) int {
294- if a < b {
295- return a
296- }
297- return b
298-}
299-
300-func max(a, b int) int {
301- if a > b {
302- return a
303- }
304- return b
305-}
+0,
-62
1@@ -1,62 +0,0 @@
2-package tokens
3-
4-import (
5- "fmt"
6-
7- "github.com/picosh/pico/db"
8- "github.com/picosh/pico/tui/common"
9-)
10-
11-type styledKey struct {
12- styles common.Styles
13- nameLabel string
14- name string
15- date string
16- gutter string
17- dateLabel string
18- dateVal string
19-}
20-
21-func newStyledKey(styles common.Styles, token *db.Token, active bool) styledKey {
22- date := token.CreatedAt.Format(common.DateFormat)
23-
24- // Default state
25- return styledKey{
26- styles: styles,
27- date: date,
28- name: token.Name,
29- gutter: " ",
30- nameLabel: "Name:",
31- dateLabel: "Added:",
32- dateVal: styles.Label.Render(date),
33- }
34-}
35-
36-// Selected state.
37-func (k *styledKey) selected() {
38- k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
39- k.nameLabel = k.styles.Label.Render("Name:")
40- k.dateLabel = k.styles.Label.Render("Added:")
41-}
42-
43-// Deleting state.
44-func (k *styledKey) deleting() {
45- k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
46- k.nameLabel = k.styles.Delete.Render("Name:")
47- k.dateLabel = k.styles.Delete.Render("Added:")
48- k.dateVal = k.styles.Delete.Render(k.date)
49-}
50-
51-func (k styledKey) render(state keyState) string {
52- switch state {
53- case keySelected:
54- k.selected()
55- case keyDeleting:
56- k.deleting()
57- }
58- return fmt.Sprintf(
59- "%s %s %s\n%s %s %s\n\n",
60- k.gutter, k.nameLabel, k.name,
61- k.gutter, k.dateLabel, k.dateVal,
62- )
63-}
+312,
-176
1@@ -1,214 +1,350 @@
2 package tui
3
4 import (
5+ "errors"
6 "fmt"
7+ "log/slog"
8+ "strings"
9
10- tea "github.com/charmbracelet/bubbletea"
11- "github.com/charmbracelet/lipgloss"
12- "github.com/charmbracelet/wish"
13- "github.com/muesli/reflow/wordwrap"
14- "github.com/muesli/reflow/wrap"
15- "github.com/picosh/pico/tui/analytics"
16- "github.com/picosh/pico/tui/chat"
17- "github.com/picosh/pico/tui/common"
18- "github.com/picosh/pico/tui/createaccount"
19- "github.com/picosh/pico/tui/createkey"
20- "github.com/picosh/pico/tui/createtoken"
21- "github.com/picosh/pico/tui/logs"
22- "github.com/picosh/pico/tui/menu"
23- "github.com/picosh/pico/tui/notifications"
24- "github.com/picosh/pico/tui/pages"
25- "github.com/picosh/pico/tui/plus"
26- "github.com/picosh/pico/tui/pubkeys"
27- "github.com/picosh/pico/tui/settings"
28- "github.com/picosh/pico/tui/tokens"
29+ "git.sr.ht/~rockorager/vaxis"
30+ "git.sr.ht/~rockorager/vaxis/vxfw"
31+ "git.sr.ht/~rockorager/vaxis/vxfw/richtext"
32+ "github.com/charmbracelet/ssh"
33+ "github.com/picosh/pico/db"
34+ "github.com/picosh/pico/shared"
35+ "github.com/picosh/utils"
36 )
37
38-type state int
39+var HOME = "dash"
40
41-const (
42- initState state = iota
43- readyState
44-)
45+type SharedModel struct {
46+ Logger *slog.Logger
47+ Session ssh.Session
48+ Cfg *shared.ConfigSite
49+ Dbpool db.DB
50+ User *db.User
51+ PlusFeatureFlag *db.FeatureFlag
52+ BouncerFeatureFlag *db.FeatureFlag
53+ Impersonator string
54+ App *vxfw.App
55+}
56
57-// Just a generic tea.Model to demo terminal information of ssh.
58-type UI struct {
59- shared *common.SharedModel
60+type Navigate struct{ To string }
61+type PageIn struct{}
62+type PageOut struct{}
63
64- state state
65- activePage pages.Page
66- pages []tea.Model
67-}
68+var fuschia = vaxis.HexColor(0xEE6FF8)
69+var cream = vaxis.HexColor(0xFFFDF5)
70+var green = vaxis.HexColor(0x04B575)
71+var grey = vaxis.HexColor(0x5C5C5C)
72+var red = vaxis.HexColor(0xED567A)
73
74-func NewUI(shared *common.SharedModel) *UI {
75- m := &UI{
76- shared: shared,
77- state: initState,
78- pages: make([]tea.Model, 12),
79+// var white = vaxis.HexColor(0xFFFFFF).
80+var oj = vaxis.HexColor(0xFFCA80)
81+var purp = vaxis.HexColor(0xBD93F9)
82+var black = vaxis.HexColor(0x282A36)
83+
84+func createDrawCtx(ctx vxfw.DrawContext, h uint16) vxfw.DrawContext {
85+ return vxfw.DrawContext{
86+ Characters: ctx.Characters,
87+ Max: vxfw.Size{
88+ Width: ctx.Max.Width,
89+ Height: h,
90+ },
91 }
92- return m
93 }
94
95-func (m *UI) updateActivePage(msg tea.Msg) tea.Cmd {
96- nm, cmd := m.pages[m.activePage].Update(msg)
97- m.pages[m.activePage] = nm
98- return cmd
99+type App struct {
100+ shared *SharedModel
101+ pages map[string]vxfw.Widget
102+ page string
103 }
104
105-func (m *UI) setupUser() error {
106- user, err := findUser(m.shared)
107- if err != nil {
108- m.shared.Logger.Error("cannot find user", "err", err)
109- wish.Errorf(m.shared.Session, "\nERROR: %s\n\n", err)
110- return err
111- }
112-
113- m.shared.User = user
114-
115- ff, _ := findPlusFeatureFlag(m.shared)
116- m.shared.PlusFeatureFlag = ff
117-
118- bff, _ := findFeatureFlag(m.shared, "bouncer")
119- m.shared.BouncerFeatureFlag = bff
120-
121- return nil
122-}
123-
124-func (m *UI) Init() tea.Cmd {
125- // header height is required to calculate viewport for
126- // some pages
127- m.shared.HeaderHeight = lipgloss.Height(m.header()) + 1
128-
129- m.pages[pages.MenuPage] = menu.NewModel(m.shared)
130- m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
131- m.pages[pages.CreatePubkeyPage] = createkey.NewModel(m.shared)
132- m.pages[pages.CreateTokenPage] = createtoken.NewModel(m.shared)
133- m.pages[pages.CreateAccountPage] = createaccount.NewModel(m.shared)
134- m.pages[pages.PubkeysPage] = pubkeys.NewModel(m.shared)
135- m.pages[pages.TokensPage] = tokens.NewModel(m.shared)
136- m.pages[pages.NotificationsPage] = notifications.NewModel(m.shared)
137- m.pages[pages.PlusPage] = plus.NewModel(m.shared)
138- m.pages[pages.SettingsPage] = settings.NewModel(m.shared)
139- m.pages[pages.LogsPage] = logs.NewModel(m.shared)
140- m.pages[pages.AnalyticsPage] = analytics.NewModel(m.shared)
141- m.pages[pages.ChatPage] = chat.NewModel(m.shared)
142- if m.shared.User == nil {
143- m.activePage = pages.CreateAccountPage
144- } else {
145- m.activePage = pages.MenuPage
146- }
147- m.state = readyState
148- return nil
149-}
150-
151-func (m *UI) updateModels(msg tea.Msg) tea.Cmd {
152- cmds := []tea.Cmd{}
153- for i, page := range m.pages {
154- if page == nil {
155- continue
156+func (app *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) {
157+ switch msg := ev.(type) {
158+ case vaxis.Key:
159+ if msg.Matches('c', vaxis.ModCtrl) {
160+ return vxfw.QuitCmd{}, nil
161+ }
162+ if msg.Matches(vaxis.KeyEsc) {
163+ if app.page == "signup" || app.page == HOME {
164+ return nil, nil
165+ }
166+ app.shared.App.PostEvent(Navigate{To: HOME})
167 }
168- nm, cmd := page.Update(msg)
169- m.pages[i] = nm
170- cmds = append(cmds, cmd)
171 }
172- return tea.Batch(cmds...)
173+ return nil, nil
174+}
175+
176+type WidgetFooter interface {
177+ Footer() []Shortcut
178+}
179+
180+func (app *App) GetCurPage() vxfw.Widget {
181+ return app.pages[app.page]
182 }
183
184-func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185- var cmds []tea.Cmd
186+func (app *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
187+ switch msg := ev.(type) {
188+ case vxfw.Init:
189+ page := HOME
190+ // no user? kick them to the create account page
191+ if app.shared.User == nil {
192+ page = "signup"
193+ }
194+ app.shared.App.PostEvent(Navigate{To: page})
195+ return nil, nil
196+ case Navigate:
197+ cmds := []vxfw.Command{}
198+ cur := app.GetCurPage()
199+ if cur != nil {
200+ // send event to page notifying that we are leaving
201+ cmd, _ := cur.HandleEvent(PageOut{}, vxfw.TargetPhase)
202+ if cmd != nil {
203+ cmds = append(cmds, cmd)
204+ }
205+ }
206
207- switch msg := msg.(type) {
208- case tea.WindowSizeMsg:
209- m.shared.Width = msg.Width
210- m.shared.Height = msg.Height
211- return m, m.updateModels(msg)
212+ // switch the page
213+ app.page = msg.To
214
215- case tea.KeyMsg:
216- switch msg.Type {
217- case tea.KeyCtrlC:
218- m.shared.Dbpool.Close()
219- return m, tea.Quit
220+ cur = app.GetCurPage()
221+ if cur != nil {
222+ // send event to page notifying that we are entering
223+ cmd, _ := app.GetCurPage().HandleEvent(PageIn{}, vxfw.TargetPhase)
224+ if cmd != nil {
225+ cmds = append(cmds, cmd)
226+ }
227 }
228
229- case pages.NavigateMsg:
230- // send message to the active page so it can teardown
231- // and reset itself
232- cmds = append(cmds, m.updateActivePage(msg))
233- m.activePage = msg.Page
234-
235- // user created account
236- case createaccount.CreateAccountMsg:
237- _ = m.setupUser()
238- // reset model and pages
239- return m, m.Init()
240-
241- case menu.MenuChoiceMsg:
242- switch msg.MenuChoice {
243- case menu.KeysChoice:
244- m.activePage = pages.PubkeysPage
245- case menu.TokensChoice:
246- m.activePage = pages.TokensPage
247- case menu.NotificationsChoice:
248- m.activePage = pages.NotificationsPage
249- case menu.PlusChoice:
250- m.activePage = pages.PlusPage
251- case menu.SettingsChoice:
252- m.activePage = pages.SettingsPage
253- case menu.LogsChoice:
254- m.activePage = pages.LogsPage
255- case menu.AnalyticsChoice:
256- m.activePage = pages.AnalyticsPage
257- case menu.ChatChoice:
258- m.activePage = pages.ChatPage
259- case menu.ExitChoice:
260- m.shared.Dbpool.Close()
261- return m, tea.Quit
262+ cmds = append(
263+ cmds,
264+ vxfw.RedrawCmd{},
265+ )
266+ return vxfw.BatchCmd(cmds), nil
267+ }
268+ return nil, nil
269+}
270+
271+func (app *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
272+ w := ctx.Max.Width
273+ h := ctx.Max.Height
274+ root := vxfw.NewSurface(w, ctx.Min.Height, app)
275+
276+ ah := 1
277+ header := NewHeaderWdt(app.shared, app.page)
278+ headerSurf, _ := header.Draw(vxfw.DrawContext{
279+ Max: vxfw.Size{Width: w, Height: 2},
280+ Characters: ctx.Characters,
281+ })
282+ root.AddChild(1, ah, headerSurf)
283+ ah += int(headerSurf.Size.Height)
284+
285+ cur := app.GetCurPage()
286+ if cur != nil {
287+ pageCtx := vxfw.DrawContext{
288+ Characters: ctx.Characters,
289+ Max: vxfw.Size{Width: ctx.Max.Width - 1, Height: h - 2 - uint16(ah)},
290 }
291+ surface, _ := app.GetCurPage().Draw(pageCtx)
292+ root.AddChild(1, ah, surface)
293+ }
294
295- cmds = append(cmds, m.pages[m.activePage].Init())
296+ wdgt, ok := cur.(WidgetFooter)
297+ segs := []Shortcut{
298+ {Shortcut: "^c", Text: "quit"},
299+ {Shortcut: "esc", Text: "prev page"},
300 }
301+ if ok {
302+ segs = append(segs, wdgt.Footer()...)
303+ }
304+ footer := NewFooterWdt(app.shared, segs)
305+ footerSurf, _ := footer.Draw(vxfw.DrawContext{
306+ Max: vxfw.Size{Width: w, Height: 2},
307+ Characters: ctx.Characters,
308+ })
309+ root.AddChild(1, int(ctx.Max.Height)-2, footerSurf)
310+
311+ return root, nil
312+}
313+
314+type HeaderWdgt struct {
315+ shared *SharedModel
316
317- cmd := m.updateActivePage(msg)
318- cmds = append(cmds, cmd)
319+ page string
320+}
321+
322+func NewHeaderWdt(shrd *SharedModel, page string) *HeaderWdgt {
323+ return &HeaderWdgt{
324+ shared: shrd,
325+ page: page,
326+ }
327+}
328
329- return m, tea.Batch(cmds...)
330+func (m *HeaderWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
331+ return nil, nil
332 }
333
334-func (m *UI) header() string {
335+func (m *HeaderWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
336 logoTxt := "pico.sh"
337 ff := m.shared.PlusFeatureFlag
338 if ff != nil && ff.IsValid() {
339 logoTxt = "pico+"
340 }
341
342- logo := m.shared.
343- Styles.
344- Logo.
345- SetString(logoTxt)
346- title := m.shared.
347- Styles.
348- Note.
349- SetString(pages.ToTitle(m.activePage))
350- div := m.shared.
351- Styles.
352- HelpDivider.
353- Foreground(common.Green)
354- s := fmt.Sprintf("%s%s%s\n\n", logo, div, title)
355- return s
356-}
357-
358-func (m *UI) View() string {
359- s := m.header()
360-
361- if m.pages[m.activePage] != nil {
362- s += m.pages[m.activePage].View()
363- }
364-
365- width := m.shared.Width - m.shared.Styles.App.GetHorizontalFrameSize()
366- maxWidth := width
367- str := wrap.String(
368- wordwrap.String(s, maxWidth),
369- maxWidth,
370- )
371- return m.shared.Styles.App.Render(str)
372+ root := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, m)
373+ // header
374+ wdgt := richtext.New([]vaxis.Segment{
375+ vaxis.Segment{Text: " " + logoTxt + " ", Style: vaxis.Style{Background: purp, Foreground: black}},
376+ vaxis.Segment{Text: " • " + m.page, Style: vaxis.Style{Foreground: green}},
377+ })
378+ surf, _ := wdgt.Draw(ctx)
379+ root.AddChild(0, 0, surf)
380+
381+ if m.shared.User != nil {
382+ user := richtext.New([]vaxis.Segment{
383+ vaxis.Segment{Text: "~" + m.shared.User.Name, Style: vaxis.Style{Foreground: cream}},
384+ })
385+ surf, _ = user.Draw(ctx)
386+ root.AddChild(int(ctx.Max.Width)-int(surf.Size.Width)-1, 0, surf)
387+ }
388+
389+ return root, nil
390+}
391+
392+type Shortcut struct {
393+ Text string
394+ Shortcut string
395+}
396+
397+type FooterWdgt struct {
398+ shared *SharedModel
399+
400+ cmds []Shortcut
401+}
402+
403+func NewFooterWdt(shrd *SharedModel, cmds []Shortcut) *FooterWdgt {
404+ return &FooterWdgt{
405+ shared: shrd,
406+ cmds: cmds,
407+ }
408+}
409+
410+func (m *FooterWdgt) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) {
411+ return nil, nil
412+}
413+
414+func (m *FooterWdgt) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) {
415+ segs := []vaxis.Segment{}
416+ for idx, shortcut := range m.cmds {
417+ segs = append(
418+ segs,
419+ vaxis.Segment{Text: shortcut.Shortcut, Style: vaxis.Style{Foreground: fuschia}},
420+ vaxis.Segment{Text: " " + shortcut.Text},
421+ )
422+ if idx < len(m.cmds)-1 {
423+ segs = append(segs, vaxis.Segment{Text: " • "})
424+ }
425+ }
426+ wdgt := richtext.New(segs)
427+ return wdgt.Draw(ctx)
428+}
429+
430+func initData(shrd *SharedModel) {
431+ user, err := FindUser(shrd)
432+ if err != nil {
433+ panic(err)
434+ }
435+ shrd.User = user
436+
437+ ff, _ := FindFeatureFlag(shrd, "plus")
438+ shrd.PlusFeatureFlag = ff
439+
440+ bff, _ := FindFeatureFlag(shrd, "bouncer")
441+ shrd.BouncerFeatureFlag = bff
442+}
443+
444+func FindUser(shrd *SharedModel) (*db.User, error) {
445+ logger := shrd.Cfg.Logger
446+ var user *db.User
447+ usr := shrd.Session.User()
448+
449+ if shrd.Session.PublicKey() == nil {
450+ return nil, fmt.Errorf("unable to find public key")
451+ }
452+
453+ key := utils.KeyForKeyText(shrd.Session.PublicKey())
454+
455+ user, err := shrd.Dbpool.FindUserForKey(usr, key)
456+ if err != nil {
457+ logger.Error("no user found for public key", "err", err.Error())
458+ // we only want to throw an error for specific cases
459+ if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
460+ return nil, err
461+ }
462+ // no user and not error indicates we need to create an account
463+ return nil, nil
464+ }
465+ origUserName := user.Name
466+
467+ // impersonation
468+ adminPrefix := "admin__"
469+ if strings.HasPrefix(usr, adminPrefix) {
470+ hasFeature := shrd.Dbpool.HasFeatureForUser(user.ID, "admin")
471+ if !hasFeature {
472+ return nil, fmt.Errorf("only admins can impersonate a user")
473+ }
474+ impersonate := strings.Replace(usr, adminPrefix, "", 1)
475+ user, err = shrd.Dbpool.FindUserForName(impersonate)
476+ if err != nil {
477+ return nil, err
478+ }
479+ shrd.Impersonator = origUserName
480+ }
481+
482+ return user, nil
483+}
484+
485+func FindFeatureFlag(shrd *SharedModel, name string) (*db.FeatureFlag, error) {
486+ if shrd.User == nil {
487+ return nil, nil
488+ }
489+
490+ ff, err := shrd.Dbpool.FindFeatureForUser(shrd.User.ID, name)
491+ if err != nil {
492+ return nil, err
493+ }
494+
495+ return ff, nil
496+}
497+
498+func NewTui(opts vaxis.Options, shrd *SharedModel) {
499+ initData(shrd)
500+ app, err := vxfw.NewApp(opts)
501+ if err != nil {
502+ panic(err)
503+ }
504+
505+ shrd.App = app
506+ pages := map[string]vxfw.Widget{
507+ HOME: NewMenuPage(shrd),
508+ "pubkeys": NewPubkeysPage(shrd),
509+ "add-pubkey": NewAddPubkeyPage(shrd),
510+ "tokens": NewTokensPage(shrd),
511+ "add-token": NewAddTokenPage(shrd),
512+ "signup": NewSignupPage(shrd),
513+ "pico+": NewPlusPage(shrd),
514+ "logs": NewLogsPage(shrd),
515+ "analytics": NewAnalyticsPage(shrd),
516+ "chat": NewChatPage(shrd),
517+ }
518+ root := &App{
519+ shared: shrd,
520+ pages: pages,
521+ }
522+
523+ err = app.Run(root)
524+ if err != nil {
525+ panic(err)
526+ }
527 }
+0,
-68
1@@ -1,68 +0,0 @@
2-package tui
3-
4-import (
5- "errors"
6- "fmt"
7- "strings"
8-
9- "github.com/picosh/pico/db"
10- "github.com/picosh/pico/tui/common"
11- "github.com/picosh/utils"
12-)
13-
14-func findUser(shrd *common.SharedModel) (*db.User, error) {
15- logger := shrd.Cfg.Logger
16- var user *db.User
17- usr := shrd.Session.User()
18-
19- if shrd.Session.PublicKey() == nil {
20- return nil, fmt.Errorf("unable to find public key")
21- }
22-
23- key := utils.KeyForKeyText(shrd.Session.PublicKey())
24-
25- user, err := shrd.Dbpool.FindUserForKey(usr, key)
26- if err != nil {
27- logger.Error("no user found for public key", "err", err.Error())
28- // we only want to throw an error for specific cases
29- if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
30- return nil, err
31- }
32- // no user and not error indicates we need to create an account
33- return nil, nil
34- }
35-
36- // impersonation
37- adminPrefix := "admin__"
38- if strings.HasPrefix(usr, adminPrefix) {
39- hasFeature := shrd.Dbpool.HasFeatureForUser(user.ID, "admin")
40- if !hasFeature {
41- return nil, fmt.Errorf("only admins can impersonate a user")
42- }
43- impersonate := strings.Replace(usr, adminPrefix, "", 1)
44- user, err = shrd.Dbpool.FindUserForName(impersonate)
45- if err != nil {
46- return nil, err
47- }
48- shrd.Impersonated = true
49- }
50-
51- return user, nil
52-}
53-
54-func findPlusFeatureFlag(shrd *common.SharedModel) (*db.FeatureFlag, error) {
55- return findFeatureFlag(shrd, "plus")
56-}
57-
58-func findFeatureFlag(shrd *common.SharedModel, feature string) (*db.FeatureFlag, error) {
59- if shrd.User == nil {
60- return nil, nil
61- }
62-
63- ff, err := shrd.Dbpool.FindFeatureForUser(shrd.User.ID, feature)
64- if err != nil {
65- return nil, err
66- }
67-
68- return ff, nil
69-}
+3,
-8
1@@ -5,8 +5,6 @@ import (
2
3 "github.com/charmbracelet/ssh"
4 "github.com/charmbracelet/wish"
5- bm "github.com/charmbracelet/wish/bubbletea"
6- "github.com/picosh/pico/tui/common"
7 )
8
9 func SessionMessage(sesh ssh.Session, msg string) {
10@@ -16,15 +14,12 @@ func SessionMessage(sesh ssh.Session, msg string) {
11 func DeprecatedNotice() wish.Middleware {
12 return func(next ssh.Handler) ssh.Handler {
13 return func(sesh ssh.Session) {
14- renderer := bm.MakeRenderer(sesh)
15- styles := common.DefaultStyles(renderer)
16-
17 msg := fmt.Sprintf(
18 "%s\n\nRun %s to access pico's TUI",
19- styles.Logo.Render("DEPRECATED"),
20- styles.Code.Render("ssh pico.sh"),
21+ "DEPRECATED",
22+ "ssh pico.sh",
23 )
24- SessionMessage(sesh, styles.RoundedBorder.Render(msg))
25+ SessionMessage(sesh, msg)
26 next(sesh)
27 }
28 }