mirror of
https://github.com/wavetermdev/waveapps.git
synced 2026-04-22 15:27:43 -07:00
initial commit with some vdom apps
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
*.log
|
||||
bin/
|
||||
*.dmg
|
||||
*.exe
|
||||
.DS_Store
|
||||
*~
|
||||
*.a
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user