From 463e8aa108a1264bf4f81d5ee0ebf52b5332dab8 Mon Sep 17 00:00:00 2001 From: sawka Date: Wed, 6 Nov 2024 21:51:44 -0800 Subject: [PATCH] initial commit with some vdom apps --- .gitignore | 9 ++ gallery/gallery-main.go | 237 ++++++++++++++++++++++++++++++++++++ gallery/style.css | 106 +++++++++++++++++ go.mod | 26 ++++ go.sum | 42 +++++++ particle/particle-main.go | 150 +++++++++++++++++++++++ pomodoro/pomodoro-main.go | 245 ++++++++++++++++++++++++++++++++++++++ pomodoro/style.css | 80 +++++++++++++ todo/style.css | 68 +++++++++++ todo/todo-main.go | 189 +++++++++++++++++++++++++++++ 10 files changed, 1152 insertions(+) create mode 100644 .gitignore create mode 100644 gallery/gallery-main.go create mode 100644 gallery/style.css create mode 100644 go.mod create mode 100644 go.sum create mode 100644 particle/particle-main.go create mode 100644 pomodoro/pomodoro-main.go create mode 100644 pomodoro/style.css create mode 100644 todo/style.css create mode 100644 todo/todo-main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26e0d88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.log +bin/ +*.dmg +*.exe +.DS_Store +*~ +*.a + + diff --git a/gallery/gallery-main.go b/gallery/gallery-main.go new file mode 100644 index 0000000..3990874 --- /dev/null +++ b/gallery/gallery-main.go @@ -0,0 +1,237 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" +) + +//go:embed style.css +var styleCSS []byte + +// Command-line args (accessible in components) +var galleryPath string + +var GalleryClient *vdomclient.Client = vdomclient.MakeClient(vdomclient.ApplicationOpts{ + Name: "gallery", + Use: "gallery ", + Description: "Display an image gallery for a directory", + CloseOnCtrlC: true, + GlobalStyles: styleCSS, +}) + +type ImageInfo struct { + Path string `json:"path"` +} + +// Image gallery components +type GalleryProps struct { + Images []ImageInfo `json:"images"` +} + +type ImageViewProps struct { + Image ImageInfo `json:"image"` + OnClose func() `json:"onClose"` + OnNext func() `json:"onNext"` + OnPrev func() `json:"onPrev"` + HasNext bool `json:"hasNext"` + HasPrev bool `json:"hasPrev"` +} + +var ImageView = vdomclient.DefineComponent[ImageViewProps](GalleryClient, "ImageView", + func(ctx context.Context, props ImageViewProps) any { + return vdom.E("div", + vdom.Class("image-view"), + // Close button + vdom.E("button", + vdom.Class("close-button"), + vdom.P("onClick", props.OnClose), + "×", + ), + // Navigation buttons + vdom.If(props.HasPrev, + vdom.E("button", + vdom.Class("nav-button prev"), + vdom.P("onClick", props.OnPrev), + "←", + ), + ), + vdom.If(props.HasNext, + vdom.E("button", + vdom.Class("nav-button next"), + vdom.P("onClick", props.OnNext), + "→", + ), + ), + // Image + vdom.E("img", + vdom.Class("full-image"), + vdom.P("src", fmt.Sprintf("vdom:///img/%s", props.Image.Path)), + vdom.P("alt", props.Image.Path), + ), + ) + }, +) + +var App = vdomclient.DefineComponent(GalleryClient, "App", + func(ctx context.Context, props vdomclient.AppProps) any { + fmt.Printf("App props: %+v\n", props) + galleryPath = props.Args[0] + log.Printf("galleryPath: %q\n", galleryPath) + // Get images from the provided path + images, err := scanDirectory(galleryPath) + if err != nil { + return vdom.E("div", + vdom.Class("error"), + fmt.Sprintf("Error scanning directory: %v", err), + ) + } + + selectedIndex, setSelectedIndex := vdom.UseState(ctx, -1) + + keyDown := &vdom.VDomFunc{ + Type: vdom.ObjectType_Func, + Fn: func(event vdom.VDomEvent) { + if event.KeyData == nil { + return + } + if selectedIndex >= 0 { + switch event.KeyData.Key { + case "Escape": + setSelectedIndex(-1) + case "ArrowRight": + if selectedIndex < len(images)-1 { + setSelectedIndex(selectedIndex + 1) + } + case "ArrowLeft": + if selectedIndex > 0 { + setSelectedIndex(selectedIndex - 1) + } + } + } + }, + Keys: []string{"Escape", "ArrowRight", "ArrowLeft"}, + } + + // Prepare ImageView props only if we have a valid index + var imageView any + if selectedIndex >= 0 && selectedIndex < len(images) { + imageView = ImageView(ImageViewProps{ + Image: images[selectedIndex], + OnClose: func() { setSelectedIndex(-1) }, + OnNext: func() { + if selectedIndex < len(images)-1 { + setSelectedIndex(selectedIndex + 1) + } + }, + OnPrev: func() { + if selectedIndex > 0 { + setSelectedIndex(selectedIndex - 1) + } + }, + HasNext: selectedIndex < len(images)-1, + HasPrev: selectedIndex > 0, + }) + } + + return vdom.E("div", + vdom.Class("gallery"), + vdom.P("onKeyDown", keyDown), + vdom.E("div", + vdom.Class("gallery-header"), + vdom.E("h1", nil, "Image Gallery"), + ), + vdom.Fragment( + // Grid view when no image is selected + vdom.If(selectedIndex == -1, + vdom.E("div", + vdom.Class("image-grid"), + vdom.ForEachIdx(images, func(img ImageInfo, i int) any { + return vdom.E("div", + vdom.Class("image-item"), + vdom.P("onClick", func() { + setSelectedIndex(i) + }), + vdom.E("img", + vdom.P("src", fmt.Sprintf("vdom:///img/%s", img.Path)), + vdom.P("alt", img.Path), + ), + ) + }), + ), + ), + // Image view + vdom.If(imageView != nil, imageView), + ), + ) + }, +) + +func scanDirectory(root string) ([]ImageInfo, error) { + var images []ImageInfo + validExts := map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, + ".gif": true, ".webp": true, + } + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if validExts[ext] { + relPath, err := filepath.Rel(root, path) + if err != nil { + return err + } + images = append(images, ImageInfo{Path: relPath}) + } + return nil + }) + + return images, err +} + +func main() { + log.Printf("hello\n") + // Set up image handlers + GalleryClient.RegisterFilePrefixHandler("/img/", func(path string) (*vdomclient.FileHandlerOption, error) { + imgPath := strings.TrimPrefix(path, "/img/") + fullPath := filepath.Join(GalleryClient.CommandArgs[0], imgPath) + + // Get file info first for both existence check and ETag generation + fileInfo, err := os.Stat(fullPath) + if os.IsNotExist(err) { + return nil, nil // Return nil for 404 + } + if err != nil { + return nil, err + } + + // Generate ETag from file size and modification time + etag := fmt.Sprintf(`"%x-%x"`, fileInfo.Size(), fileInfo.ModTime().Unix()) + + data, err := os.ReadFile(fullPath) + if err != nil { + return nil, err + } + + return &vdomclient.FileHandlerOption{ + Data: data, + ETag: etag, + }, nil + }) + GalleryClient.Command.Args = cobra.ExactArgs(1) + GalleryClient.RunMain() +} diff --git a/gallery/style.css b/gallery/style.css new file mode 100644 index 0000000..0c72d34 --- /dev/null +++ b/gallery/style.css @@ -0,0 +1,106 @@ +.gallery { + max-width: 1200px; + margin: 0 auto; + padding: 20px; + width: 100%; /* Force full width */ +} + +.gallery-header { + margin-bottom: 20px; +} + +.gallery-header h1 { + font-size: 24px; + font-weight: bold; +} + +.image-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; +} + +.image-item { + width: 200px; /* Fixed width */ + height: 200px; + aspect-ratio: 1; + cursor: pointer; + border-radius: 8px; + overflow: hidden; + position: relative; + background: #f0f0f0; +} + +/* Keep cover for grid thumbnails */ +.image-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-item:hover::after { + content: ''; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.1); +} + +.image-view { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; +} + +.full-image { + max-height: 100%; + max-width: 100%; + width: auto; + height: auto; + object-fit: contain; +} +.close-button { + position: absolute; + top: 20px; + right: 20px; + color: white; + font-size: 24px; + width: 40px; + height: 40px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.1); + cursor: pointer; + border: none; +} + +.close-button:hover { + background: rgba(255, 255, 255, 0.2); +} + +.nav-button { + position: absolute; + top: 50%; + transform: translateY(-50%); + color: white; + font-size: 24px; + width: 40px; + height: 40px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.1); + cursor: pointer; + border: none; +} + +.nav-button:hover { + background: rgba(255, 255, 255, 0.2); +} + +.nav-button.prev { + left: 20px; +} + +.nav-button.next { + right: 20px; +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eedef95 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module vdomtest + +go 1.22.4 + +require ( + github.com/spf13/cobra v1.8.1 + github.com/wavetermdev/waveterm v0.9.1 +) + +require ( + github.com/alexflint/go-filemutex v1.3.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/testify v1.9.0 // indirect + github.com/wavetermdev/htmltoken v0.2.0 // indirect + golang.org/x/net v0.29.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/term v0.25.0 // indirect +) + +replace github.com/wavetermdev/waveterm => ../waveterm diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f845175 --- /dev/null +++ b/go.sum @@ -0,0 +1,42 @@ +github.com/alexflint/go-filemutex v1.3.0 h1:LgE+nTUWnQCyRKbpoceKZsPQbs84LivvgwUymZXdOcM= +github.com/alexflint/go-filemutex v1.3.0/go.mod h1:U0+VA/i30mGBlLCrFPGtTe9y6wGQfNAWPBTekHQ+c8A= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wavetermdev/htmltoken v0.2.0 h1:sFVPPemlDv7/jg7n4Hx1AEF2m9MVAFjFpELWfhi/DlM= +github.com/wavetermdev/htmltoken v0.2.0/go.mod h1:5FM0XV6zNYiNza2iaTcFGj+hnMtgqumFHO31Z8euquk= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= +golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/particle/particle-main.go b/particle/particle-main.go new file mode 100644 index 0000000..73ba4c6 --- /dev/null +++ b/particle/particle-main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "math" + "math/rand" + "time" + + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" +) + +var ParticleVDomClient *vdomclient.Client = vdomclient.MakeClient(vdomclient.ApplicationOpts{ + Name: "particle", + Description: "particle canvas application", + CloseOnCtrlC: true, +}) + +type Particle struct { + X, Y float64 // Position + VelocityX float64 // Horizontal speed + VelocityY float64 // Vertical speed + Color string // Color in rgba format + Size float64 // Radius of the particle +} + +type CanvasUpdaterProps struct { + CanvasRef *vdom.VDomRef +} + +var CanvasUpdaterParticles = vdomclient.DefineComponent[CanvasUpdaterProps](ParticleVDomClient, "CanvasUpdaterParticles", + func(ctx context.Context, props CanvasUpdaterProps) any { + particles, setParticles := vdom.UseState(ctx, initParticles(10)) + lastRenderTs := vdom.UseRef(ctx, int64(0)) + renderTs := vdom.UseRenderTs(ctx) + canvasRef := props.CanvasRef + + vdom.UseEffect(ctx, func() func() { + if !canvasRef.HasCurrent { + return nil + } + if renderTs-lastRenderTs.Current < 30 { + return nil + } + lastRenderTs.Current = renderTs + + // Update particle positions and redraw + newParticles := updateParticles(particles) + setParticles(newParticles) + + // Drawing operations on canvas + vdom.QueueRefOp(ctx, canvasRef, vdom.VDomRefOperation{ + Op: "clearRect", + Params: []any{0, 0, 300, 300}, + }) + + for _, particle := range newParticles { + vdom.QueueRefOp(ctx, canvasRef, vdom.VDomRefOperation{ + Op: "fillStyle", + Params: []any{particle.Color}, + }) + vdom.QueueRefOp(ctx, canvasRef, vdom.VDomRefOperation{ + Op: "beginPath", + Params: []any{}, + }) + vdom.QueueRefOp(ctx, canvasRef, vdom.VDomRefOperation{ + Op: "arc", + Params: []any{particle.X, particle.Y, particle.Size, 0, 2 * math.Pi}, + }) + vdom.QueueRefOp(ctx, canvasRef, vdom.VDomRefOperation{ + Op: "fill", + Params: []any{}, + }) + } + + // Trigger re-render based on tickNum, not particles + go func() { + time.Sleep(60 * time.Millisecond) + ParticleVDomClient.SendAsyncInitiation() + }() + + return nil + }, []any{renderTs}) // Using tickNum as the dependency + + return nil + }, +) + +// Helper function to get a random direction (-1 or 1) +func randomDirection() float64 { + if rand.Intn(2) == 0 { + return -1 + } + return 1 +} + +// Initialize particles with random positions, velocities, sizes, and colors +func initParticles(count int) []Particle { + particles := make([]Particle, count) + for i := 0; i < count; i++ { + particles[i] = Particle{ + X: float64(rand.Intn(300)), + Y: float64(rand.Intn(300)), + VelocityX: float64(rand.Intn(3)+1) * randomDirection(), + VelocityY: float64(rand.Intn(3)+1) * randomDirection(), + Color: fmt.Sprintf("rgba(%d, %d, %d, 0.7)", rand.Intn(256), rand.Intn(256), rand.Intn(256)), + Size: float64(rand.Intn(10) + 3), // Radius between 3 and 12 + } + } + return particles +} + +// Update particle positions, bouncing off edges +func updateParticles(particles []Particle) []Particle { + for i, particle := range particles { + // Update position based on velocity + particle.X += particle.VelocityX + particle.Y += particle.VelocityY + + // Bounce off edges + if particle.X <= 0 || particle.X >= 300 { + particle.VelocityX = -particle.VelocityX + } + if particle.Y <= 0 || particle.Y >= 300 { + particle.VelocityY = -particle.VelocityY + } + particles[i] = particle + } + return particles +} + +var App = vdomclient.DefineComponent[struct{}](ParticleVDomClient, "App", + func(ctx context.Context, _ struct{}) any { + canvasRef := vdom.UseVDomRef(ctx) + return vdom.E("div", + vdom.E("canvas", + vdom.P("ref", canvasRef), + vdom.P("width", "300"), vdom.P("height", "300"), + vdom.PStyle("width", 300), vdom.PStyle("height", 300), + ), + CanvasUpdaterParticles(CanvasUpdaterProps{canvasRef}), + ) + }, +) + +func main() { + ParticleVDomClient.RunMain() +} diff --git a/pomodoro/pomodoro-main.go b/pomodoro/pomodoro-main.go new file mode 100644 index 0000000..5f2a697 --- /dev/null +++ b/pomodoro/pomodoro-main.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "time" + + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" +) + +//go:embed style.css +var styleCSS []byte + +var PomodoroVDomClient *vdomclient.Client = vdomclient.MakeClient(vdomclient.ApplicationOpts{ + Name: "pomodoro", + Description: "pomodoro timer application", + CloseOnCtrlC: true, + GlobalStyles: styleCSS, +}) + +type Mode struct { + Name string `json:"name"` + Duration int `json:"duration"` // in minutes +} + +var ( + WorkMode = Mode{Name: "Work", Duration: 25} + BreakMode = Mode{Name: "Break", Duration: 5} +) + +type TimerDisplayProps struct { + Minutes int `json:"minutes"` + Seconds int `json:"seconds"` + Mode string `json:"mode"` +} + +type ControlButtonsProps struct { + IsRunning bool `json:"isRunning"` + OnStart func() `json:"onStart"` + OnPause func() `json:"onPause"` + OnReset func() `json:"onReset"` + OnMode func(int) `json:"onMode"` +} + +type TimerState struct { + ticker *time.Ticker + done chan bool + startTime time.Time + duration time.Duration + isActive bool // Track if the timer goroutine is running +} + +var TimerDisplay = vdomclient.DefineComponent[TimerDisplayProps](PomodoroVDomClient, "TimerDisplay", + func(ctx context.Context, props TimerDisplayProps) any { + return vdom.E("div", + vdom.Class("timer-display"), + vdom.E("div", + vdom.Class("mode-indicator"), + props.Mode, + ), + vdom.E("div", + vdom.Class("time"), + fmt.Sprintf("%02d:%02d", props.Minutes, props.Seconds), + ), + ) + }, +) + +var ControlButtons = vdomclient.DefineComponent[ControlButtonsProps](PomodoroVDomClient, "ControlButtons", + func(ctx context.Context, props ControlButtonsProps) any { + return vdom.E("div", + vdom.Class("control-buttons"), + vdom.IfElse(props.IsRunning, + vdom.E("button", + vdom.Class("control-btn"), + vdom.P("onClick", props.OnPause), + "Pause", + ), + vdom.E("button", + vdom.Class("control-btn"), + vdom.P("onClick", props.OnStart), + "Start", + ), + ), + vdom.E("button", + vdom.Class("control-btn"), + vdom.P("onClick", props.OnReset), + "Reset", + ), + vdom.E("div", + vdom.Class("mode-buttons"), + vdom.E("button", + vdom.Class("mode-btn"), + vdom.P("onClick", func() { props.OnMode(WorkMode.Duration) }), + "Work Mode", + ), + vdom.E("button", + vdom.Class("mode-btn"), + vdom.P("onClick", func() { props.OnMode(BreakMode.Duration) }), + "Break Mode", + ), + ), + ) + }, +) + +var App = vdomclient.DefineComponent[struct{}](PomodoroVDomClient, "App", + func(ctx context.Context, _ struct{}) any { + isRunning, setIsRunning := vdom.UseState(ctx, false) + minutes, setMinutes := vdom.UseState(ctx, WorkMode.Duration) + seconds, setSeconds := vdom.UseState(ctx, 0) + mode, setMode := vdom.UseState(ctx, WorkMode.Name) + _, setIsComplete := vdom.UseState(ctx, false) + timerRef := vdom.UseRef(ctx, &TimerState{ + done: make(chan bool), + }) + + stopTimer := func() { + if timerRef.Current.ticker != nil { + timerRef.Current.ticker.Stop() + timerRef.Current.ticker = nil + } + if timerRef.Current.isActive { + close(timerRef.Current.done) + timerRef.Current.isActive = false + } + timerRef.Current.done = make(chan bool) + } + + startTimer := func() { + if timerRef.Current.isActive { + return // Timer already running + } + + // Stop any existing timer first + stopTimer() + + setIsComplete(false) + timerRef.Current.startTime = time.Now() + timerRef.Current.duration = time.Duration(minutes) * time.Minute + timerRef.Current.isActive = true + setIsRunning(true) + timerRef.Current.ticker = time.NewTicker(1 * time.Second) + + go func() { + for { + select { + case <-timerRef.Current.done: + return + case <-timerRef.Current.ticker.C: + elapsed := time.Since(timerRef.Current.startTime) + remaining := timerRef.Current.duration - elapsed + + if remaining <= 0 { + // Timer completed + setIsRunning(false) + setMinutes(0) + setSeconds(0) + setIsComplete(true) + stopTimer() + PomodoroVDomClient.SendAsyncInitiation() + return + } + + m := int(remaining.Minutes()) + s := int(remaining.Seconds()) % 60 + + // Only send update if values actually changed + if m != minutes || s != seconds { + setMinutes(m) + setSeconds(s) + PomodoroVDomClient.SendAsyncInitiation() + } + } + } + }() + } + + pauseTimer := func() { + stopTimer() + setIsRunning(false) + PomodoroVDomClient.SendAsyncInitiation() + } + + resetTimer := func() { + stopTimer() + setIsRunning(false) + setIsComplete(false) + if mode == WorkMode.Name { + setMinutes(WorkMode.Duration) + } else { + setMinutes(BreakMode.Duration) + } + setSeconds(0) + PomodoroVDomClient.SendAsyncInitiation() + } + + changeMode := func(duration int) { + stopTimer() + setIsRunning(false) + setIsComplete(false) + setMinutes(duration) + setSeconds(0) + if duration == WorkMode.Duration { + setMode(WorkMode.Name) + } else { + setMode(BreakMode.Name) + } + PomodoroVDomClient.SendAsyncInitiation() + } + + // Cleanup on unmount + vdom.UseEffect(ctx, func() func() { + return func() { + stopTimer() + } + }, []any{}) + + return vdom.E("div", + vdom.Class("pomodoro-app"), + vdom.E("h1", + vdom.Class("title"), + "Pomodoro Timer", + ), + TimerDisplay(TimerDisplayProps{ + Minutes: minutes, + Seconds: seconds, + Mode: mode, + }), + ControlButtons(ControlButtonsProps{ + IsRunning: isRunning, + OnStart: startTimer, + OnPause: pauseTimer, + OnReset: resetTimer, + OnMode: changeMode, + }), + ) + }, +) + +func main() { + PomodoroVDomClient.RunMain() +} diff --git a/pomodoro/style.css b/pomodoro/style.css new file mode 100644 index 0000000..a595fdc --- /dev/null +++ b/pomodoro/style.css @@ -0,0 +1,80 @@ +.pomodoro-app { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + background: #2c3e50; + border-radius: 10px; + color: #ecf0f1; + font-family: system-ui, -apple-system, sans-serif; +} + +.title { + text-align: center; + color: #ecf0f1; + margin-bottom: 2rem; + font-size: 2rem; +} + +.timer-display { + background: #34495e; + padding: 2rem; + border-radius: 8px; + margin-bottom: 2rem; + text-align: center; +} + +.mode-indicator { + font-size: 1.2rem; + color: #3498db; + margin-bottom: 0.5rem; +} + +.time { + font-size: 3.5rem; + font-weight: bold; + font-family: monospace; + color: #ecf0f1; +} + +.control-buttons { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.control-btn { + padding: 0.8rem 1.5rem; + font-size: 1.1rem; + border: none; + border-radius: 5px; + background: #3498db; + color: white; + cursor: pointer; + transition: background-color 0.2s; +} + +.control-btn:hover { + background: #2980b9; +} + +.mode-buttons { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.mode-btn { + flex: 1; + padding: 0.8rem; + font-size: 1rem; + border: none; + border-radius: 5px; + background: #2ecc71; + color: white; + cursor: pointer; + transition: background-color 0.2s; +} + +.mode-btn:hover { + background: #27ae60; +} \ No newline at end of file diff --git a/todo/style.css b/todo/style.css new file mode 100644 index 0000000..e5954bd --- /dev/null +++ b/todo/style.css @@ -0,0 +1,68 @@ +.todo-app { + max-width: 500px; + margin: 20px; + font-family: sans-serif; +} +.todo-header { + margin-bottom: 20px; +} +.todo-form { + display: flex; + gap: 10px; + margin-bottom: 20px; +} +.todo-input { + flex: 1; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-color); +} +.todo-button { + padding: 8px 16px; + background: var(--button-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-color); + cursor: pointer; +} +.todo-button:hover { + background: var(--button-hover-bg); +} +.todo-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.todo-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--block-bg); +} +.todo-item.completed { + opacity: 0.7; +} +.todo-item.completed .todo-text { + text-decoration: line-through; +} +.todo-text { + flex: 1; +} +.todo-checkbox { + width: 16px; + height: 16px; +} +.todo-delete { + color: var(--error-color); + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} +.todo-delete:hover { + background: var(--error-bg); +} \ No newline at end of file diff --git a/todo/todo-main.go b/todo/todo-main.go new file mode 100644 index 0000000..5dadd79 --- /dev/null +++ b/todo/todo-main.go @@ -0,0 +1,189 @@ +package main + +import ( + "context" + _ "embed" + + "github.com/wavetermdev/waveterm/pkg/vdom" + "github.com/wavetermdev/waveterm/pkg/vdom/vdomclient" +) + +//go:embed style.css +var styleCSS []byte + +var TodoVDomClient *vdomclient.Client = vdomclient.MakeClient(vdomclient.ApplicationOpts{ + Name: "todo", + Description: "todo list application", + CloseOnCtrlC: true, + GlobalStyles: styleCSS, +}) + +type Todo struct { + Id int `json:"id"` + Text string `json:"text"` + Completed bool `json:"completed"` +} + +type TodoListProps struct { + Todos []Todo `json:"todos"` + OnToggle func(int) `json:"onToggle"` + OnDelete func(int) `json:"onDelete"` +} + +type TodoItemProps struct { + Todo Todo `json:"todo"` + OnToggle func() `json:"onToggle"` + OnDelete func() `json:"onDelete"` +} + +type InputFieldProps struct { + Value string `json:"value"` + OnChange func(string) `json:"onChange"` + OnEnter func() `json:"onEnter"` +} + +var InputField = vdomclient.DefineComponent[InputFieldProps](TodoVDomClient, "InputField", + func(ctx context.Context, props InputFieldProps) any { + keyDown := &vdom.VDomFunc{ + Type: vdom.ObjectType_Func, + Fn: func(event vdom.VDomEvent) { + if props.OnEnter != nil { + props.OnEnter() + } + }, + StopPropagation: true, + PreventDefault: true, + Keys: []string{"Enter", "Cmd:Enter"}, // Handle both Enter and Cmd+Enter + } + + return vdom.E("input", + vdom.Class("todo-input"), + vdom.P("type", "text"), + vdom.P("placeholder", "What needs to be done?"), + vdom.P("value", props.Value), + vdom.P("onChange", func(e vdom.VDomEvent) { + props.OnChange(e.TargetValue) + }), + vdom.P("onKeyDown", keyDown), + ) + }, +) + +var TodoItem = vdomclient.DefineComponent[TodoItemProps](TodoVDomClient, "TodoItem", + func(ctx context.Context, props TodoItemProps) any { + return vdom.E("div", + vdom.Class("todo-item"), + vdom.ClassIf(props.Todo.Completed, "completed"), + vdom.E("input", + vdom.P("type", "checkbox"), + vdom.Class("todo-checkbox"), + vdom.P("checked", props.Todo.Completed), + vdom.P("onChange", props.OnToggle), + ), + vdom.E("span", + vdom.Class("todo-text"), + props.Todo.Text, + ), + vdom.E("button", + vdom.Class("todo-delete"), + vdom.P("onClick", props.OnDelete), + "×", + ), + ) + }, +) + +var TodoList = vdomclient.DefineComponent[TodoListProps](TodoVDomClient, "TodoList", + func(ctx context.Context, props TodoListProps) any { + return vdom.E("div", + vdom.Class("todo-list"), + vdom.ForEach(props.Todos, func(todo Todo) any { + return TodoItem(TodoItemProps{ + Todo: todo, + OnToggle: func() { props.OnToggle(todo.Id) }, + OnDelete: func() { props.OnDelete(todo.Id) }, + }) + }), + ) + }, +) + +var App = vdomclient.DefineComponent[struct{}](TodoVDomClient, "App", + func(ctx context.Context, _ struct{}) any { + // State using hooks + todos, setTodos := vdom.UseState(ctx, []Todo{ + {Id: 1, Text: "Learn VDOM", Completed: false}, + {Id: 2, Text: "Build a todo app", Completed: false}, + {Id: 3, Text: "Profit!", Completed: false}, + }) + nextId, setNextId := vdom.UseState(ctx, 4) + inputText, setInputText := vdom.UseState(ctx, "") + + // Event handlers + addTodo := func() { + if inputText == "" { + return + } + newTodo := Todo{ + Id: nextId, + Text: inputText, + Completed: false, + } + setNextId(nextId + 1) + setTodos(append(todos, newTodo)) + setInputText("") + } + + toggleTodo := func(id int) { + newTodos := make([]Todo, len(todos)) + copy(newTodos, todos) + for i := range newTodos { + if newTodos[i].Id == id { + newTodos[i].Completed = !newTodos[i].Completed + break + } + } + setTodos(newTodos) + } + + deleteTodo := func(id int) { + newTodos := make([]Todo, 0, len(todos)-1) + for _, todo := range todos { + if todo.Id != id { + newTodos = append(newTodos, todo) + } + } + setTodos(newTodos) + } + + return vdom.E("div", + vdom.Class("todo-app"), + vdom.E("div", + vdom.Class("todo-header"), + vdom.E("h1", nil, "Todo List"), + ), + vdom.E("div", + vdom.Class("todo-form"), + InputField(InputFieldProps{ + Value: inputText, + OnChange: setInputText, + OnEnter: addTodo, + }), + vdom.E("button", + vdom.Class("todo-button"), + vdom.P("onClick", addTodo), + "Add Todo", + ), + ), + TodoList(TodoListProps{ + Todos: todos, + OnToggle: toggleTodo, + OnDelete: deleteTodo, + }), + ) + }, +) + +func main() { + TodoVDomClient.RunMain() +}