initial commit with some vdom apps

This commit is contained in:
sawka
2024-11-06 21:51:44 -08:00
commit 463e8aa108
10 changed files with 1152 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
*.log
bin/
*.dmg
*.exe
.DS_Store
*~
*.a
+237
View File
@@ -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 <path>",
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()
}
+106
View File
@@ -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;
}
+26
View File
@@ -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
+42
View File
@@ -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=
+150
View File
@@ -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()
}
+245
View File
@@ -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()
}
+80
View File
@@ -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;
}
+68
View File
@@ -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);
}
+189
View File
@@ -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()
}