repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

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.
51 files changed,  +2690, -3866
M go.mod
M go.sum
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 }
M db/postgres/storage.go
+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+}
M db/stub/stub.go
+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=
M pgs/cli.go
+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 {
M pgs/cli_wish.go
+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,
M pico/cli.go
+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
M pico/ssh.go
+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,
M shared/senpai.go
+11, -2
 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"
A tui/analytics.go
+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+}
D tui/analytics/analytics.go
+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-}
A tui/border.go
+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+}
A tui/chat.go
+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+}
D tui/chat/chat.go
+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-}
D tui/chat/senpai.go
+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-}
D tui/common/err.go
+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() }
D tui/common/model.go
+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-}
D tui/common/styles.go
+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-}
D tui/common/tbl.go
+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-}
D tui/common/util.go
+0, -5
1@@ -1,5 +0,0 @@
2-package common
3-
4-import "time"
5-
6-var DateFormat = time.DateOnly
D tui/common/views.go
+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-}
D tui/createaccount/create.go
+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-}
D tui/createkey/create.go
+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-}
D tui/createtoken/create.go
+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-}
A tui/group.go
+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+}
A tui/info.go
+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+}
A tui/input.go
+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+}
A tui/kv.go
+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+}
A tui/logs.go
+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+}
D tui/logs/logs.go
+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-}
D tui/mdw.go
+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-}
A tui/menu.go
+134, -0
  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+}
D tui/menu/menu.go
+0, -179
  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-}
D tui/notifications/notifications.go
+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-}
A tui/pager.go
+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+}
D tui/pages/pages.go
+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-}
A tui/plus.go
+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+}
D tui/plus/plus.go
+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-}
A tui/pubkeys.go
+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+}
D tui/pubkeys/keys.go
+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-}
D tui/pubkeys/keyview.go
+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-}
A tui/senpai.go
+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) {}
D tui/settings/settings.go
+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-}
A tui/signup.go
+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+}
A tui/tokens.go
+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+}
D tui/tokens/tokens.go
+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-}
D tui/tokens/tokenview.go
+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-}
M tui/ui.go
+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 }
D tui/util.go
+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-}
M wish/pty.go
+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 	}