WASM reactive UI framework for Go: - reactive/ - Signal[T], Effect, Runtime - ui/ - Button, Text, Input, View, Canvas, SVG components - navigation/ - Router, guards, history management - auth/ - OIDC client for WASM applications - host/ - Static file server Extracted from arcadia as open-source component. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
21
.gitea/workflows/ci.yaml
Normal file
21
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: '1.23'
|
||||||
|
- name: Build WASM
|
||||||
|
run: GOOS=js GOARCH=wasm go build ./...
|
||||||
|
- name: Build Host
|
||||||
|
run: go build ./host/...
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.wasm
|
||||||
|
/dist/
|
||||||
|
/build/
|
||||||
|
/bin/
|
||||||
|
|
||||||
|
# Go
|
||||||
|
/vendor/
|
||||||
101
CLAUDE.md
Normal file
101
CLAUDE.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Iris
|
||||||
|
|
||||||
|
WASM reactive UI framework for Go.
|
||||||
|
|
||||||
|
## Organization Context
|
||||||
|
|
||||||
|
This repo is part of Flowmade. See:
|
||||||
|
- [Organization manifesto](../architecture/manifesto.md) - who we are, what we believe
|
||||||
|
- [Repository map](../architecture/repos.md) - how this fits in the bigger picture
|
||||||
|
- [Vision](./vision.md) - what this specific product does
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone git@git.flowmade.one:flowmade-one/iris.git
|
||||||
|
cd iris
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
iris/
|
||||||
|
├── reactive/ # Signal[T], Effect, Runtime - core reactivity
|
||||||
|
├── ui/ # Components: Button, Text, Input, View, Canvas, SVG
|
||||||
|
├── navigation/ # Router, guards, history management
|
||||||
|
├── auth/ # OIDC client for WASM applications
|
||||||
|
├── host/ # Static file server for WASM apps
|
||||||
|
└── internal/ # Internal element abstraction
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build (WASM target)
|
||||||
|
GOOS=js GOARCH=wasm go build ./...
|
||||||
|
|
||||||
|
# Build host server (native)
|
||||||
|
go build ./host/...
|
||||||
|
|
||||||
|
# Test
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
golangci-lint run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Reactivity Model
|
||||||
|
|
||||||
|
Iris uses a signals-based reactivity model:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create a signal
|
||||||
|
count := reactive.NewSignal(0)
|
||||||
|
|
||||||
|
// Read value
|
||||||
|
fmt.Println(count.Get())
|
||||||
|
|
||||||
|
// Update value - triggers dependent effects
|
||||||
|
count.Set(count.Get() + 1)
|
||||||
|
|
||||||
|
// Create derived state
|
||||||
|
doubled := reactive.Computed(func() int {
|
||||||
|
return count.Get() * 2
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
All UI components implement a common interface and render to DOM elements:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create a button
|
||||||
|
btn := ui.Button("Click me", func() {
|
||||||
|
count.Set(count.Get() + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create reactive text
|
||||||
|
text := ui.Text(func() string {
|
||||||
|
return fmt.Sprintf("Count: %d", count.Get())
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
Client-side routing with guards:
|
||||||
|
|
||||||
|
```go
|
||||||
|
router := navigation.NewRouter()
|
||||||
|
router.Route("/", homeView)
|
||||||
|
router.Route("/users/:id", userView)
|
||||||
|
router.Guard("/admin/*", authGuard)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Patterns
|
||||||
|
|
||||||
|
- **No virtual DOM** - Direct DOM manipulation via syscall/js
|
||||||
|
- **Signals propagate** - Changes flow through the dependency graph
|
||||||
|
- **Components are functions** - Return DOM elements, not component instances
|
||||||
|
- **WASM-only UI code** - Use build tags for browser-specific code
|
||||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
Copyright 2024-2026 Flowmade
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: build build-wasm test lint clean
|
||||||
|
|
||||||
|
build: build-wasm
|
||||||
|
go build ./host/...
|
||||||
|
|
||||||
|
build-wasm:
|
||||||
|
GOOS=js GOARCH=wasm go build ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f *.wasm
|
||||||
195
auth/http_wasm.go
Normal file
195
auth/http_wasm.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"syscall/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WASMHTTPClient handles HTTP requests in WASM environment
|
||||||
|
type WASMHTTPClient struct{}
|
||||||
|
|
||||||
|
// HTTPResult represents the result of an HTTP request
|
||||||
|
type HTTPResult struct {
|
||||||
|
Data []byte
|
||||||
|
Error error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPCallback is called when HTTP request completes
|
||||||
|
type HTTPCallback func(result HTTPResult)
|
||||||
|
|
||||||
|
// FetchJSON performs a GET request and unmarshals JSON response
|
||||||
|
// This method blocks and should only be called from the main thread
|
||||||
|
func (c *WASMHTTPClient) FetchJSON(url string, dest interface{}) error {
|
||||||
|
result := make(chan HTTPResult, 1)
|
||||||
|
|
||||||
|
c.FetchJSONAsync(url, func(r HTTPResult) {
|
||||||
|
result <- r
|
||||||
|
})
|
||||||
|
|
||||||
|
r := <-result
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(r.Data, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchJSONAsync performs a GET request asynchronously using callback
|
||||||
|
func (c *WASMHTTPClient) FetchJSONAsync(url string, callback HTTPCallback) {
|
||||||
|
// Create fetch promise
|
||||||
|
promise := js.Global().Call("fetch", url)
|
||||||
|
|
||||||
|
// Success handler
|
||||||
|
var successFunc js.Func
|
||||||
|
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
response := args[0]
|
||||||
|
if !response.Get("ok").Bool() {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
|
||||||
|
})
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response text
|
||||||
|
textPromise := response.Call("text")
|
||||||
|
var textFunc js.Func
|
||||||
|
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
text := args[0].String()
|
||||||
|
callback(HTTPResult{
|
||||||
|
Data: []byte(text),
|
||||||
|
})
|
||||||
|
// Cleanup after callback completes
|
||||||
|
textFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handler for text promise
|
||||||
|
var textErrorFunc js.Func
|
||||||
|
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("failed to read response text: %v", args[0]),
|
||||||
|
})
|
||||||
|
// Cleanup
|
||||||
|
textFunc.Release()
|
||||||
|
textErrorFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
var errorFunc js.Func
|
||||||
|
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("fetch error: %s", args[0].String()),
|
||||||
|
})
|
||||||
|
// Cleanup after callback completes
|
||||||
|
errorFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
promise.Call("then", successFunc).Call("catch", errorFunc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostForm performs a POST request with form data
|
||||||
|
func (c *WASMHTTPClient) PostForm(url string, data url.Values, dest interface{}) error {
|
||||||
|
result := make(chan HTTPResult, 1)
|
||||||
|
|
||||||
|
c.PostFormAsync(url, data, func(r HTTPResult) {
|
||||||
|
result <- r
|
||||||
|
})
|
||||||
|
|
||||||
|
r := <-result
|
||||||
|
if r.Error != nil {
|
||||||
|
return r.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(r.Data, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostFormAsync performs a POST request asynchronously using callback
|
||||||
|
func (c *WASMHTTPClient) PostFormAsync(url string, data url.Values, callback HTTPCallback) {
|
||||||
|
// Prepare fetch options
|
||||||
|
headers := map[string]interface{}{
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
options := map[string]interface{}{
|
||||||
|
"method": "POST",
|
||||||
|
"headers": headers,
|
||||||
|
"body": data.Encode(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert options to JS object
|
||||||
|
jsOptions := js.ValueOf(options)
|
||||||
|
|
||||||
|
// Make the request
|
||||||
|
promise := js.Global().Call("fetch", url, jsOptions)
|
||||||
|
|
||||||
|
// Success handler
|
||||||
|
var successFunc js.Func
|
||||||
|
successFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
response := args[0]
|
||||||
|
if !response.Get("ok").Bool() {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("HTTP %d: %s", response.Get("status").Int(), response.Get("statusText").String()),
|
||||||
|
})
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get response text
|
||||||
|
textPromise := response.Call("text")
|
||||||
|
var textFunc js.Func
|
||||||
|
textFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
text := args[0].String()
|
||||||
|
callback(HTTPResult{
|
||||||
|
Data: []byte(text),
|
||||||
|
})
|
||||||
|
// Cleanup after callback completes
|
||||||
|
textFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handler for text promise
|
||||||
|
var textErrorFunc js.Func
|
||||||
|
textErrorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("failed to read response text: %v", args[0]),
|
||||||
|
})
|
||||||
|
// Cleanup
|
||||||
|
textFunc.Release()
|
||||||
|
textErrorFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
textPromise.Call("then", textFunc).Call("catch", textErrorFunc)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
var errorFunc js.Func
|
||||||
|
errorFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
callback(HTTPResult{
|
||||||
|
Error: fmt.Errorf("fetch error: %s", args[0].String()),
|
||||||
|
})
|
||||||
|
// Cleanup after callback completes
|
||||||
|
errorFunc.Release()
|
||||||
|
successFunc.Release()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
promise.Call("then", successFunc).Call("catch", errorFunc)
|
||||||
|
}
|
||||||
306
auth/oidc.go
Normal file
306
auth/oidc.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"syscall/js"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDC types are defined in types.go
|
||||||
|
|
||||||
|
// OIDCClient handles OIDC authentication flows
|
||||||
|
type OIDCClient struct {
|
||||||
|
config *OIDCConfig
|
||||||
|
clientID string
|
||||||
|
redirectURI string
|
||||||
|
scopes []string
|
||||||
|
|
||||||
|
// Browser storage
|
||||||
|
localStorage js.Value
|
||||||
|
sessionStorage js.Value
|
||||||
|
|
||||||
|
// HTTP client for WASM
|
||||||
|
httpClient *WASMHTTPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token and user info types are defined in types.go
|
||||||
|
|
||||||
|
// NewOIDCClient creates a new OIDC client
|
||||||
|
func NewOIDCClient(issuer, clientID, redirectURI string) *OIDCClient {
|
||||||
|
client := &OIDCClient{
|
||||||
|
clientID: clientID,
|
||||||
|
redirectURI: redirectURI,
|
||||||
|
scopes: []string{"openid", "email", "profile"},
|
||||||
|
localStorage: js.Global().Get("localStorage"),
|
||||||
|
sessionStorage: js.Global().Get("sessionStorage"),
|
||||||
|
httpClient: &WASMHTTPClient{},
|
||||||
|
}
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverConfig fetches the OIDC discovery configuration synchronously
|
||||||
|
func (c *OIDCClient) DiscoverConfig(issuer string) error {
|
||||||
|
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
// Use fetch to get the configuration
|
||||||
|
return c.fetchJSON(discoveryURL, &c.config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverConfigAsync fetches the OIDC discovery configuration asynchronously
|
||||||
|
func (c *OIDCClient) DiscoverConfigAsync(issuer string, callback func(error)) {
|
||||||
|
discoveryURL := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
c.httpClient.FetchJSONAsync(discoveryURL, func(result HTTPResult) {
|
||||||
|
if result.Error != nil {
|
||||||
|
callback(result.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the configuration
|
||||||
|
var config OIDCConfig
|
||||||
|
if err := json.Unmarshal(result.Data, &config); err != nil {
|
||||||
|
callback(fmt.Errorf("failed to parse OIDC config: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.config = &config
|
||||||
|
callback(nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureConfigLoaded ensures OIDC configuration is loaded before proceeding
|
||||||
|
func (c *OIDCClient) EnsureConfigLoaded(issuer string) error {
|
||||||
|
if c.config != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.DiscoverConfig(issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAuthFlow initiates the OIDC authentication flow
|
||||||
|
func (c *OIDCClient) StartAuthFlow() error {
|
||||||
|
if c.config == nil {
|
||||||
|
return fmt.Errorf("OIDC configuration not loaded - call DiscoverConfig first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate PKCE parameters
|
||||||
|
verifier, err := c.generateCodeVerifier()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate code verifier: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
challenge := c.generateCodeChallenge(verifier)
|
||||||
|
state := c.generateState()
|
||||||
|
|
||||||
|
// Store PKCE verifier and state in session storage
|
||||||
|
c.sessionStorage.Call("setItem", "pkce_verifier", verifier)
|
||||||
|
c.sessionStorage.Call("setItem", "auth_state", state)
|
||||||
|
|
||||||
|
// Build authorization URL
|
||||||
|
authURL, err := c.buildAuthURL(challenge, state)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build auth URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to Dex
|
||||||
|
js.Global().Get("window").Get("location").Set("href", authURL)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCallback processes the OAuth callback
|
||||||
|
func (c *OIDCClient) HandleCallback(issuer string) (*TokenResponse, error) {
|
||||||
|
// Ensure configuration is loaded before processing
|
||||||
|
if err := c.EnsureConfigLoaded(issuer); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load OIDC configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current URL parameters
|
||||||
|
urlParams := c.getURLParams()
|
||||||
|
|
||||||
|
code := urlParams.Get("code")
|
||||||
|
state := urlParams.Get("state")
|
||||||
|
errorParam := urlParams.Get("error")
|
||||||
|
|
||||||
|
if errorParam != "" {
|
||||||
|
return nil, fmt.Errorf("OAuth error: %s", errorParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("authorization code not found in callback")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify state parameter
|
||||||
|
storedState := c.sessionStorage.Call("getItem", "auth_state").String()
|
||||||
|
if state != storedState {
|
||||||
|
return nil, fmt.Errorf("invalid state parameter")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get PKCE verifier
|
||||||
|
verifier := c.sessionStorage.Call("getItem", "pkce_verifier").String()
|
||||||
|
if verifier == "" {
|
||||||
|
return nil, fmt.Errorf("PKCE verifier not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange code for tokens
|
||||||
|
return c.exchangeCodeForTokens(code, verifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStoredTokens retrieves tokens from local storage
|
||||||
|
func (c *OIDCClient) GetStoredTokens() *TokenResponse {
|
||||||
|
accessToken := c.localStorage.Call("getItem", "access_token")
|
||||||
|
idToken := c.localStorage.Call("getItem", "id_token")
|
||||||
|
refreshToken := c.localStorage.Call("getItem", "refresh_token")
|
||||||
|
|
||||||
|
if accessToken.IsNull() || idToken.IsNull() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenResponse{
|
||||||
|
AccessToken: accessToken.String(),
|
||||||
|
IDToken: idToken.String(),
|
||||||
|
RefreshToken: refreshToken.String(),
|
||||||
|
TokenType: "Bearer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreTokens saves tokens to local storage
|
||||||
|
func (c *OIDCClient) StoreTokens(tokens *TokenResponse) {
|
||||||
|
c.localStorage.Call("setItem", "access_token", tokens.AccessToken)
|
||||||
|
c.localStorage.Call("setItem", "id_token", tokens.IDToken)
|
||||||
|
if tokens.RefreshToken != "" {
|
||||||
|
c.localStorage.Call("setItem", "refresh_token", tokens.RefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store expiration time
|
||||||
|
if tokens.ExpiresIn > 0 {
|
||||||
|
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||||||
|
c.localStorage.Call("setItem", "token_expires_at", expiresAt.Unix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTokens removes all stored tokens
|
||||||
|
func (c *OIDCClient) ClearTokens() {
|
||||||
|
c.localStorage.Call("removeItem", "access_token")
|
||||||
|
c.localStorage.Call("removeItem", "id_token")
|
||||||
|
c.localStorage.Call("removeItem", "refresh_token")
|
||||||
|
c.localStorage.Call("removeItem", "token_expires_at")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticated checks if the user has valid tokens
|
||||||
|
func (c *OIDCClient) IsAuthenticated() bool {
|
||||||
|
tokens := c.GetStoredTokens()
|
||||||
|
if tokens == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
expiresAtStr := c.localStorage.Call("getItem", "token_expires_at")
|
||||||
|
if !expiresAtStr.IsNull() {
|
||||||
|
// localStorage returns strings, need to convert to int
|
||||||
|
expiresAtString := expiresAtStr.String()
|
||||||
|
var expiresAt int64
|
||||||
|
if _, err := fmt.Sscanf(expiresAtString, "%d", &expiresAt); err == nil {
|
||||||
|
if time.Now().Unix() > expiresAt {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens.AccessToken != "" && tokens.IDToken != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout clears all authentication data
|
||||||
|
func (c *OIDCClient) Logout() {
|
||||||
|
c.ClearTokens()
|
||||||
|
c.sessionStorage.Call("removeItem", "pkce_verifier")
|
||||||
|
c.sessionStorage.Call("removeItem", "auth_state")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthHeader returns the Authorization header value
|
||||||
|
func (c *OIDCClient) GetAuthHeader() string {
|
||||||
|
tokens := c.GetStoredTokens()
|
||||||
|
if tokens == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "Bearer " + tokens.IDToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
func (c *OIDCClient) generateCodeVerifier() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OIDCClient) generateCodeChallenge(verifier string) string {
|
||||||
|
hash := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OIDCClient) generateState() string {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
rand.Read(bytes)
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OIDCClient) buildAuthURL(challenge, state string) (string, error) {
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {c.clientID},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {strings.Join(c.scopes, " ")},
|
||||||
|
"redirect_uri": {c.redirectURI},
|
||||||
|
"code_challenge": {challenge},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"state": {state},
|
||||||
|
}
|
||||||
|
|
||||||
|
authURL := c.config.AuthURL + "?" + params.Encode()
|
||||||
|
return authURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OIDCClient) getURLParams() url.Values {
|
||||||
|
location := js.Global().Get("window").Get("location")
|
||||||
|
search := location.Get("search").String()
|
||||||
|
params, _ := url.ParseQuery(strings.TrimPrefix(search, "?"))
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OIDCClient) exchangeCodeForTokens(code, verifier string) (*TokenResponse, error) {
|
||||||
|
// Prepare form data
|
||||||
|
data := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {c.clientID},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {c.redirectURI},
|
||||||
|
"code_verifier": {verifier},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make POST request to token endpoint
|
||||||
|
var tokens TokenResponse
|
||||||
|
if err := c.postForm(c.config.TokenURL, data, &tokens); err != nil {
|
||||||
|
return nil, fmt.Errorf("token exchange failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tokens, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchJSON performs a GET request and unmarshals JSON response
|
||||||
|
func (c *OIDCClient) fetchJSON(url string, dest interface{}) error {
|
||||||
|
return c.httpClient.FetchJSON(url, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postForm performs a POST request with form data
|
||||||
|
func (c *OIDCClient) postForm(url string, data url.Values, dest interface{}) error {
|
||||||
|
return c.httpClient.PostForm(url, data, dest)
|
||||||
|
}
|
||||||
30
auth/types.go
Normal file
30
auth/types.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// OIDCConfig holds the OIDC provider configuration
|
||||||
|
type OIDCConfig struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthURL string `json:"authorization_endpoint"`
|
||||||
|
TokenURL string `json:"token_endpoint"`
|
||||||
|
UserInfoURL string `json:"userinfo_endpoint"`
|
||||||
|
JWKSURL string `json:"jwks_uri"`
|
||||||
|
ScopesSupported []string `json:"scopes_supported"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse represents the response from token exchange
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo represents user information from the OIDC provider
|
||||||
|
type UserInfo struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
EmailVerified bool `json:"email_verified"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PreferredUsername string `json:"preferred_username"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
}
|
||||||
150
host/server.go
Normal file
150
host/server.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package host
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server provides a high-performance static file server with gzip compression
|
||||||
|
// optimized for serving WASM applications built with the Iris framework.
|
||||||
|
type Server struct {
|
||||||
|
publicDir string
|
||||||
|
indexFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Server instance
|
||||||
|
func New(publicDir, indexFile string) *Server {
|
||||||
|
return &Server{
|
||||||
|
publicDir: publicDir,
|
||||||
|
indexFile: indexFile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler interface
|
||||||
|
// Falls back to index file when:
|
||||||
|
// (1) Request path is not found
|
||||||
|
// (2) Request path is a directory
|
||||||
|
// Otherwise serves the requested file with gzip compression if supported.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := filepath.Join(s.publicDir, filepath.Clean(r.URL.Path))
|
||||||
|
|
||||||
|
if info, err := os.Stat(p); err != nil {
|
||||||
|
s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile))
|
||||||
|
return
|
||||||
|
} else if info.IsDir() {
|
||||||
|
s.serveFileWithCompression(w, r, filepath.Join(s.publicDir, s.indexFile))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.serveFileWithCompression(w, r, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveFileWithCompression serves a file with gzip compression if supported by client
|
||||||
|
func (s *Server) serveFileWithCompression(w http.ResponseWriter, r *http.Request, filePath string) {
|
||||||
|
// Check if client accepts gzip
|
||||||
|
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||||
|
http.ServeFile(w, r, filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open and read file
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Get file info for headers
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error reading file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set content type based on file extension
|
||||||
|
contentType := getContentType(filepath.Ext(filePath))
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
|
||||||
|
// Check if we should compress this file type
|
||||||
|
if shouldCompress(contentType) {
|
||||||
|
// Set gzip headers
|
||||||
|
w.Header().Set("Content-Encoding", "gzip")
|
||||||
|
w.Header().Set("Vary", "Accept-Encoding")
|
||||||
|
|
||||||
|
// Create gzip writer
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
defer gz.Close()
|
||||||
|
|
||||||
|
// Copy file content through gzip
|
||||||
|
_, err = io.Copy(gz, file)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Error compressing file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Serve without compression
|
||||||
|
http.ServeContent(w, r, fileInfo.Name(), fileInfo.ModTime(), file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldCompress determines if the content should be compressed based on Content-Type
|
||||||
|
func shouldCompress(contentType string) bool {
|
||||||
|
compressibleTypes := []string{
|
||||||
|
"text/html",
|
||||||
|
"text/css",
|
||||||
|
"text/javascript",
|
||||||
|
"application/javascript",
|
||||||
|
"application/json",
|
||||||
|
"application/wasm",
|
||||||
|
"text/plain",
|
||||||
|
"image/svg+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range compressibleTypes {
|
||||||
|
if strings.Contains(contentType, t) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContentType returns the appropriate content type for a file extension
|
||||||
|
func getContentType(ext string) string {
|
||||||
|
switch ext {
|
||||||
|
case ".html":
|
||||||
|
return "text/html; charset=utf-8"
|
||||||
|
case ".css":
|
||||||
|
return "text/css"
|
||||||
|
case ".js":
|
||||||
|
return "application/javascript"
|
||||||
|
case ".wasm":
|
||||||
|
return "application/wasm"
|
||||||
|
case ".json":
|
||||||
|
return "application/json"
|
||||||
|
case ".ico":
|
||||||
|
return "image/x-icon"
|
||||||
|
case ".png":
|
||||||
|
return "image/png"
|
||||||
|
case ".jpg", ".jpeg":
|
||||||
|
return "image/jpeg"
|
||||||
|
case ".gif":
|
||||||
|
return "image/gif"
|
||||||
|
case ".svg":
|
||||||
|
return "image/svg+xml"
|
||||||
|
case ".woff":
|
||||||
|
return "font/woff"
|
||||||
|
case ".woff2":
|
||||||
|
return "font/woff2"
|
||||||
|
case ".ttf":
|
||||||
|
return "font/ttf"
|
||||||
|
case ".eot":
|
||||||
|
return "application/vnd.ms-fontobject"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
283
internal/element/element.go
Normal file
283
internal/element/element.go
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
//build +js,wasm
|
||||||
|
|
||||||
|
package element
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Element struct {
|
||||||
|
js.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = js.Global().Get("document")
|
||||||
|
|
||||||
|
func Document() Element {
|
||||||
|
return Element{doc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewElement(tag string) Element {
|
||||||
|
doc := Document()
|
||||||
|
e := doc.Call("createElement", tag)
|
||||||
|
return Element{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTextNode(text string) Element {
|
||||||
|
doc := Document()
|
||||||
|
textNode := doc.Call("createTextNode", text)
|
||||||
|
return Element{textNode}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) On(event string, fn func()) Element {
|
||||||
|
e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
fn()
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Margin(margin string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "margin", margin)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Padding(padding string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "padding", padding)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Height(height string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "height", height)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) MinHeight(height string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "min-height", height)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Color(color string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "color", color)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Width(width string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "width", width)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) BackgroundColor(color string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "background-color", color)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Attr(name, value string) Element {
|
||||||
|
e.Call("setAttribute", name, value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Text(text string) Element {
|
||||||
|
doc := Document()
|
||||||
|
textNode := doc.Call("createTextNode", text)
|
||||||
|
e.Call("appendChild", textNode)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Child(child Element) Element {
|
||||||
|
e.Call("appendChild", child.Value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mount(root Element) {
|
||||||
|
doc := Document()
|
||||||
|
body := doc.Get("body")
|
||||||
|
|
||||||
|
body.Call("appendChild", root.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Flex() Element {
|
||||||
|
e.Get("style").Call("setProperty", "display", "flex")
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) FlexDirection(direction string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "flex-direction", direction)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) JustifyContent(justify string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "justify-content", justify)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) JustifyItems(justify string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "justify-items", justify)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) AlignItems(align string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "align-items", align)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) FlexGrow(grow int) Element {
|
||||||
|
e.Get("style").Call("setProperty", "flex-grow", grow)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) FlexWrap(wrap string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "flex-wrap", wrap)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Grid() Element {
|
||||||
|
e.Get("style").Call("setProperty", "display", "grid")
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) GridAutoFlow(flow string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "grid-auto-flow", flow)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) GridTemplateColumns(columns string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "grid-template-columns", columns)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) GridTemplateRows(rows string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "grid-template-rows", rows)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) MaxWidth(width string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "max-width", width)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnWithEvent adds an event listener that provides access to the event object
|
||||||
|
func (e Element) OnWithEvent(event string, fn func(js.Value)) Element {
|
||||||
|
e.Call("addEventListener", event, js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
if len(args) > 0 {
|
||||||
|
fn(args[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positioning methods
|
||||||
|
|
||||||
|
func (e Element) Position(pos string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "position", pos)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Left(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "left", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Top(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "top", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Right(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "right", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Bottom(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "bottom", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) ZIndex(value int) Element {
|
||||||
|
e.Get("style").Call("setProperty", "z-index", fmt.Sprintf("%d", value))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform methods
|
||||||
|
|
||||||
|
func (e Element) Transform(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "transform", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) TransformOrigin(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "transform-origin", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor and interaction
|
||||||
|
|
||||||
|
func (e Element) Cursor(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "cursor", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) UserSelect(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "user-select", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) PointerEvents(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "pointer-events", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow
|
||||||
|
|
||||||
|
func (e Element) Overflow(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "overflow", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box styling
|
||||||
|
|
||||||
|
func (e Element) BoxShadow(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "box-shadow", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) BorderRadius(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "border-radius", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) Border(value string) Element {
|
||||||
|
e.Get("style").Call("setProperty", "border", value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM manipulation
|
||||||
|
|
||||||
|
func (e Element) RemoveChild(child Element) Element {
|
||||||
|
e.Call("removeChild", child.Value)
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Element) ClearChildren() Element {
|
||||||
|
e.Set("innerHTML", "")
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG support
|
||||||
|
|
||||||
|
func NewElementNS(namespace, tag string) Element {
|
||||||
|
doc := Document()
|
||||||
|
e := doc.Call("createElementNS", namespace, tag)
|
||||||
|
return Element{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AttrNS sets an attribute with namespace (for SVG)
|
||||||
|
func (e Element) AttrNS(namespace, name, value string) Element {
|
||||||
|
if namespace == "" {
|
||||||
|
e.Call("setAttribute", name, value)
|
||||||
|
} else {
|
||||||
|
e.Call("setAttributeNS", namespace, name, value)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
90
navigation/components.go
Normal file
90
navigation/components.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package navigation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
||||||
|
"git.flowmade.one/flowmade-one/iris/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var globalRouter *Router
|
||||||
|
var globalHistory *HistoryManager
|
||||||
|
|
||||||
|
func SetGlobalRouter(router *Router) {
|
||||||
|
globalRouter = router
|
||||||
|
globalHistory = NewHistoryManager(router)
|
||||||
|
globalHistory.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetGlobalRouter() *Router {
|
||||||
|
return globalRouter
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouterView renders the current route's view
|
||||||
|
func RouterView() ui.View {
|
||||||
|
if globalRouter == nil {
|
||||||
|
return ui.TextFromString("Router not initialized").Color("#ff4444")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a view that updates when the route changes
|
||||||
|
view := ui.NewView()
|
||||||
|
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentView := globalRouter.GetCurrentView()
|
||||||
|
// Clear previous content and add new view
|
||||||
|
// Note: This is a simplified approach - in a real implementation
|
||||||
|
// we'd need better DOM diffing
|
||||||
|
view = currentView
|
||||||
|
})
|
||||||
|
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link creates a navigational link that updates the route
|
||||||
|
func Link(path string, content ui.View) ui.View {
|
||||||
|
if globalHistory == nil {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a simple clickable element that navigates with proper styling for horizontal layout
|
||||||
|
button := ui.Button(func() {
|
||||||
|
globalHistory.PushState(path)
|
||||||
|
}, content)
|
||||||
|
|
||||||
|
// Style the button to look like a link and work in horizontal layouts
|
||||||
|
return button.Padding("8px 12px").Background("transparent").Border("none")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate programmatically navigates to a path
|
||||||
|
func Navigate(path string) {
|
||||||
|
if globalHistory != nil {
|
||||||
|
globalHistory.PushState(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace programmatically replaces current path
|
||||||
|
func Replace(path string) {
|
||||||
|
if globalHistory != nil {
|
||||||
|
globalHistory.ReplaceState(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back navigates back in history
|
||||||
|
func Back() {
|
||||||
|
if globalHistory != nil {
|
||||||
|
globalHistory.Back()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward navigates forward in history
|
||||||
|
func Forward() {
|
||||||
|
if globalHistory != nil {
|
||||||
|
globalHistory.Forward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentPath returns the current route path
|
||||||
|
func GetCurrentPath() string {
|
||||||
|
if globalRouter != nil {
|
||||||
|
return globalRouter.GetCurrentPath()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
69
navigation/core.go
Normal file
69
navigation/core.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package navigation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Core routing logic separated from UI dependencies
|
||||||
|
|
||||||
|
type RoutePattern struct {
|
||||||
|
parts []string
|
||||||
|
params []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func CompileRoutePattern(path string) RoutePattern {
|
||||||
|
var params []string
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(parts) == 1 && parts[0] == "" {
|
||||||
|
parts = []string{} // Handle root path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find parameters like :id, :name, etc.
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasPrefix(part, ":") {
|
||||||
|
paramName := part[1:] // Remove the ':'
|
||||||
|
// Simple validation: param name should start with letter
|
||||||
|
if len(paramName) > 0 && isLetterCore(paramName[0]) {
|
||||||
|
params = append(params, paramName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoutePattern{parts, params}
|
||||||
|
}
|
||||||
|
|
||||||
|
func MatchRoute(routePattern RoutePattern, path string) map[string]string {
|
||||||
|
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(pathParts) == 1 && pathParts[0] == "" {
|
||||||
|
pathParts = []string{} // Handle root path
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pathParts) != len(routePattern.parts) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(map[string]string)
|
||||||
|
paramIndex := 0
|
||||||
|
|
||||||
|
for i, routePart := range routePattern.parts {
|
||||||
|
if strings.HasPrefix(routePart, ":") {
|
||||||
|
// This is a parameter
|
||||||
|
if paramIndex < len(routePattern.params) {
|
||||||
|
params[routePattern.params[paramIndex]] = pathParts[i]
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Static part must match exactly
|
||||||
|
if routePart != pathParts[i] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLetterCore checks if a byte is a letter
|
||||||
|
func isLetterCore(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||||
|
}
|
||||||
91
navigation/guards.go
Normal file
91
navigation/guards.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package navigation
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// Common route guards
|
||||||
|
|
||||||
|
// AuthGuard checks if user is authenticated
|
||||||
|
func AuthGuard(isAuthenticated func() bool) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
return isAuthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoleGuard checks if user has required role
|
||||||
|
func RoleGuard(userRole func() string, requiredRole string) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
return userRole() == requiredRole
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionGuard checks if user has specific permission
|
||||||
|
func PermissionGuard(hasPermission func(permission string) bool, permission string) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
return hasPermission(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParamValidationGuard validates route parameters
|
||||||
|
func ParamValidationGuard(validator func(params map[string]string) bool) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
return validator(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathPrefixGuard checks if the current path starts with a specific prefix
|
||||||
|
func PathPrefixGuard(prefix string) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
currentPath := GetCurrentPath()
|
||||||
|
return strings.HasPrefix(currentPath, prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CombineGuards combines multiple guards with AND logic
|
||||||
|
func CombineGuards(guards ...RouteGuard) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
for _, guard := range guards {
|
||||||
|
if !guard(route, params) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnyGuard combines multiple guards with OR logic
|
||||||
|
func AnyGuard(guards ...RouteGuard) RouteGuard {
|
||||||
|
return func(route *Route, params map[string]string) bool {
|
||||||
|
for _, guard := range guards {
|
||||||
|
if guard(route, params) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage patterns:
|
||||||
|
|
||||||
|
// AdminOnlyGuard - combines authentication and role checks
|
||||||
|
func AdminOnlyGuard(isAuth func() bool, userRole func() string) RouteGuard {
|
||||||
|
return CombineGuards(
|
||||||
|
AuthGuard(isAuth),
|
||||||
|
RoleGuard(userRole, "admin"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumericIdGuard - validates that :id parameter is numeric
|
||||||
|
func NumericIdGuard() RouteGuard {
|
||||||
|
return ParamValidationGuard(func(params map[string]string) bool {
|
||||||
|
if id, exists := params["id"]; exists {
|
||||||
|
// Simple numeric check
|
||||||
|
for _, char := range id {
|
||||||
|
if char < '0' || char > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(id) > 0
|
||||||
|
}
|
||||||
|
return true // No id param, so validation passes
|
||||||
|
})
|
||||||
|
}
|
||||||
63
navigation/history.go
Normal file
63
navigation/history.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
//build +js,wasm
|
||||||
|
|
||||||
|
package navigation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"syscall/js"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HistoryManager struct {
|
||||||
|
router *Router
|
||||||
|
window js.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHistoryManager(router *Router) *HistoryManager {
|
||||||
|
return &HistoryManager{
|
||||||
|
router: router,
|
||||||
|
window: js.Global().Get("window"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) Start() {
|
||||||
|
// Handle initial navigation
|
||||||
|
currentPath := h.getCurrentPath()
|
||||||
|
h.router.Navigate(currentPath)
|
||||||
|
|
||||||
|
// Listen for browser back/forward events
|
||||||
|
h.window.Call("addEventListener", "popstate", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||||
|
path := h.getCurrentPath()
|
||||||
|
h.router.Navigate(path)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) PushState(path string) {
|
||||||
|
h.window.Get("history").Call("pushState", nil, "", path)
|
||||||
|
h.router.Navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) ReplaceState(path string) {
|
||||||
|
h.window.Get("history").Call("replaceState", nil, "", path)
|
||||||
|
h.router.Navigate(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) Back() {
|
||||||
|
h.window.Get("history").Call("back")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) Forward() {
|
||||||
|
h.window.Get("history").Call("forward")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HistoryManager) getCurrentPath() string {
|
||||||
|
location := h.window.Get("location")
|
||||||
|
pathname := location.Get("pathname").String()
|
||||||
|
search := location.Get("search").String()
|
||||||
|
|
||||||
|
path := pathname
|
||||||
|
if search != "" {
|
||||||
|
path += search
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
157
navigation/router.go
Normal file
157
navigation/router.go
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
package navigation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
||||||
|
"git.flowmade.one/flowmade-one/iris/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RouteGuard func(route *Route, params map[string]string) bool
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
Path string
|
||||||
|
Handler func(params map[string]string) ui.View
|
||||||
|
Guards []RouteGuard
|
||||||
|
parts []string
|
||||||
|
params []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
routes []Route
|
||||||
|
currentPath *reactive.Signal[string]
|
||||||
|
currentView *reactive.Signal[ui.View]
|
||||||
|
notFound func() ui.View
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(routes []Route) *Router {
|
||||||
|
currentPath := reactive.NewSignal("")
|
||||||
|
currentView := reactive.NewSignal(ui.NewView())
|
||||||
|
|
||||||
|
router := &Router{
|
||||||
|
routes: make([]Route, len(routes)),
|
||||||
|
currentPath: ¤tPath,
|
||||||
|
currentView: ¤tView,
|
||||||
|
notFound: defaultNotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse route patterns
|
||||||
|
for i, route := range routes {
|
||||||
|
parsedRoute := route
|
||||||
|
parsedRoute.parts, parsedRoute.params = parseRoute(route.Path)
|
||||||
|
router.routes[i] = parsedRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) SetNotFoundHandler(handler func() ui.View) {
|
||||||
|
r.notFound = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) Navigate(path string) {
|
||||||
|
r.currentPath.Set(path)
|
||||||
|
r.updateView(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) GetCurrentPath() string {
|
||||||
|
return r.currentPath.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) GetCurrentView() ui.View {
|
||||||
|
return r.currentView.Get()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) updateView(path string) {
|
||||||
|
for _, route := range r.routes {
|
||||||
|
if params := r.matchRoute(&route, path); params != nil {
|
||||||
|
// Check guards
|
||||||
|
if r.checkGuards(&route, params) {
|
||||||
|
view := route.Handler(params)
|
||||||
|
r.currentView.Set(view)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No route matched or guards failed
|
||||||
|
r.currentView.Set(r.notFound())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) matchRoute(route *Route, path string) map[string]string {
|
||||||
|
// Strip query parameters for route matching
|
||||||
|
if queryIndex := strings.Index(path, "?"); queryIndex != -1 {
|
||||||
|
path = path[:queryIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
pathParts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(pathParts) == 1 && pathParts[0] == "" {
|
||||||
|
pathParts = []string{} // Handle root path
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pathParts) != len(route.parts) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
params := make(map[string]string)
|
||||||
|
paramIndex := 0
|
||||||
|
|
||||||
|
for i, routePart := range route.parts {
|
||||||
|
if strings.HasPrefix(routePart, ":") {
|
||||||
|
// This is a parameter
|
||||||
|
if paramIndex < len(route.params) {
|
||||||
|
params[route.params[paramIndex]] = pathParts[i]
|
||||||
|
paramIndex++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Static part must match exactly
|
||||||
|
if routePart != pathParts[i] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) checkGuards(route *Route, params map[string]string) bool {
|
||||||
|
for _, guard := range route.Guards {
|
||||||
|
if !guard(route, params) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRoute(path string) ([]string, []string) {
|
||||||
|
var params []string
|
||||||
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
if len(parts) == 1 && parts[0] == "" {
|
||||||
|
parts = []string{} // Handle root path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find parameters like :id, :name, etc.
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.HasPrefix(part, ":") {
|
||||||
|
paramName := part[1:] // Remove the ':'
|
||||||
|
// Simple validation: param name should start with letter
|
||||||
|
if len(paramName) > 0 && isLetter(paramName[0]) {
|
||||||
|
params = append(params, paramName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts, params
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLetter checks if a byte is a letter
|
||||||
|
func isLetter(b byte) bool {
|
||||||
|
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultNotFound() ui.View {
|
||||||
|
return ui.VerticalGroup(
|
||||||
|
ui.TextFromString("404 - Page Not Found").Color("#ff4444"),
|
||||||
|
ui.TextFromString("The requested page could not be found.").Color("#666"),
|
||||||
|
)
|
||||||
|
}
|
||||||
18
reactive/effect.go
Normal file
18
reactive/effect.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package reactive
|
||||||
|
|
||||||
|
type EffectId int
|
||||||
|
|
||||||
|
func NewEffect(f func()) {
|
||||||
|
rt := GetRuntime()
|
||||||
|
effectId := EffectId(len(rt.effects))
|
||||||
|
rt.effects = append(rt.effects, f)
|
||||||
|
|
||||||
|
runEffect(rt, effectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEffectWithRuntime(rt *Runtime, f func()) {
|
||||||
|
effectId := EffectId(len(rt.effects))
|
||||||
|
rt.effects = append(rt.effects, f)
|
||||||
|
|
||||||
|
runEffect(rt, effectId)
|
||||||
|
}
|
||||||
39
reactive/runtime.go
Normal file
39
reactive/runtime.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package reactive
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Runtime struct {
|
||||||
|
signalValues []any
|
||||||
|
runningEffect *EffectId
|
||||||
|
signalSubscribers map[SignalId][]EffectId
|
||||||
|
effects []func()
|
||||||
|
}
|
||||||
|
|
||||||
|
var lock = &sync.Mutex{}
|
||||||
|
var runtimeInstance *Runtime
|
||||||
|
|
||||||
|
func GetRuntime() *Runtime {
|
||||||
|
if runtimeInstance == nil {
|
||||||
|
lock.Lock()
|
||||||
|
defer lock.Unlock()
|
||||||
|
if runtimeInstance == nil {
|
||||||
|
runtimeInstance = newRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return runtimeInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRuntime() *Runtime {
|
||||||
|
return &Runtime{
|
||||||
|
signalValues: []any{},
|
||||||
|
signalSubscribers: map[SignalId][]EffectId{},
|
||||||
|
effects: []func(){},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEffect(rt *Runtime, id EffectId) {
|
||||||
|
previous := rt.runningEffect
|
||||||
|
rt.runningEffect = &id
|
||||||
|
rt.effects[id]()
|
||||||
|
rt.runningEffect = previous
|
||||||
|
}
|
||||||
52
reactive/signal.go
Normal file
52
reactive/signal.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package reactive
|
||||||
|
|
||||||
|
type SignalId int
|
||||||
|
|
||||||
|
type Signal[T any] struct {
|
||||||
|
*Runtime
|
||||||
|
SignalId
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignal[T any](initial T) Signal[T] {
|
||||||
|
rt := GetRuntime()
|
||||||
|
id := SignalId(len(rt.signalValues))
|
||||||
|
rt.signalValues = append(rt.signalValues, initial)
|
||||||
|
return Signal[T]{rt, id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSignalWithRuntime[T any](rt *Runtime, initial T) Signal[T] {
|
||||||
|
id := SignalId(len(rt.signalValues))
|
||||||
|
rt.signalValues = append(rt.signalValues, initial)
|
||||||
|
return Signal[T]{rt, id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Signal[T]) Get() T {
|
||||||
|
val := s.Runtime.signalValues[s.SignalId]
|
||||||
|
|
||||||
|
if rt := s.Runtime; rt.runningEffect != nil {
|
||||||
|
// Check if this effect is already subscribed to this signal
|
||||||
|
subscribers := rt.signalSubscribers[s.SignalId]
|
||||||
|
alreadySubscribed := false
|
||||||
|
for _, effectId := range subscribers {
|
||||||
|
if effectId == *rt.runningEffect {
|
||||||
|
alreadySubscribed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !alreadySubscribed {
|
||||||
|
rt.signalSubscribers[s.SignalId] = append(rt.signalSubscribers[s.SignalId], *rt.runningEffect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return val.(T)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Signal[T]) Set(v T) {
|
||||||
|
s.Runtime.signalValues[s.SignalId] = v
|
||||||
|
|
||||||
|
if subscribers, ok := s.Runtime.signalSubscribers[s.SignalId]; ok {
|
||||||
|
for _, effectId := range subscribers {
|
||||||
|
runEffect(s.Runtime, effectId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
ui/app.go
Normal file
15
ui/app.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "syscall/js"
|
||||||
|
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewApp(view View) {
|
||||||
|
app := NewView()
|
||||||
|
app.e.Grid()
|
||||||
|
app.MinHeight("100vh")
|
||||||
|
app.Child(view)
|
||||||
|
element.Mount(app.e)
|
||||||
|
}
|
||||||
29
ui/app_router.go
Normal file
29
ui/app_router.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Router interface to avoid import cycle
|
||||||
|
type Router interface {
|
||||||
|
GetCurrentView() View
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAppWithRouter creates a new app with navigation support
|
||||||
|
func NewAppWithRouter(router Router) {
|
||||||
|
app := NewView()
|
||||||
|
app.e.Grid()
|
||||||
|
app.MinHeight("100vh")
|
||||||
|
|
||||||
|
// Create a reactive view that updates when routes change
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentView := router.GetCurrentView()
|
||||||
|
// Clear the app and add the current view
|
||||||
|
// Note: In a production implementation, we'd want better DOM diffing
|
||||||
|
app.e.Set("innerHTML", "")
|
||||||
|
app.Child(currentView)
|
||||||
|
})
|
||||||
|
|
||||||
|
element.Mount(app.e)
|
||||||
|
}
|
||||||
9
ui/button.go
Normal file
9
ui/button.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
|
||||||
|
func Button(action func(), label View) View {
|
||||||
|
btn := View{element.NewElement("button").On("click", action)}
|
||||||
|
btn.Child(label)
|
||||||
|
return btn
|
||||||
|
}
|
||||||
1124
ui/canvas.go
Normal file
1124
ui/canvas.go
Normal file
File diff suppressed because it is too large
Load Diff
51
ui/canvas_config.go
Normal file
51
ui/canvas_config.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
// CanvasDefaults contains default values for canvas rendering and interaction
|
||||||
|
type CanvasDefaults struct {
|
||||||
|
// Interaction thresholds
|
||||||
|
EdgeThreshold float64 // Distance from edge to trigger connection drawing (default: 15)
|
||||||
|
DragThreshold float64 // Minimum movement to count as drag vs click (default: 2)
|
||||||
|
|
||||||
|
// Resize constraints
|
||||||
|
MinResizeWidth float64 // Minimum width when resizing (default: 100)
|
||||||
|
MinResizeHeight float64 // Minimum height when resizing (default: 100)
|
||||||
|
|
||||||
|
// Resize handle sizes
|
||||||
|
CornerHandleSize float64 // Size of corner resize handles (default: 12)
|
||||||
|
EdgeHandleWidth float64 // Width of edge resize handles (default: 8)
|
||||||
|
EdgeHandleLength float64 // Length of edge resize handles (default: 24)
|
||||||
|
|
||||||
|
// Connection rendering
|
||||||
|
ConnectionHitWidth float64 // Width of invisible hit area for connections (default: 12)
|
||||||
|
LabelBackgroundWidth float64 // Width of label background rect (default: 60)
|
||||||
|
LabelBackgroundHeight float64 // Height of label background rect (default: 20)
|
||||||
|
|
||||||
|
// Container layout
|
||||||
|
ContainerHeaderHeight float64 // Height of container header bar for click detection (default: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultCanvasDefaults returns the default configuration values
|
||||||
|
var DefaultCanvasDefaults = CanvasDefaults{
|
||||||
|
// Interaction thresholds
|
||||||
|
EdgeThreshold: 15.0,
|
||||||
|
DragThreshold: 2.0,
|
||||||
|
|
||||||
|
// Resize constraints
|
||||||
|
MinResizeWidth: 100.0,
|
||||||
|
MinResizeHeight: 100.0,
|
||||||
|
|
||||||
|
// Resize handle sizes
|
||||||
|
CornerHandleSize: 12.0,
|
||||||
|
EdgeHandleWidth: 8.0,
|
||||||
|
EdgeHandleLength: 24.0,
|
||||||
|
|
||||||
|
// Connection rendering
|
||||||
|
ConnectionHitWidth: 12.0,
|
||||||
|
LabelBackgroundWidth: 60.0,
|
||||||
|
LabelBackgroundHeight: 20.0,
|
||||||
|
|
||||||
|
// Container layout
|
||||||
|
ContainerHeaderHeight: 32.0,
|
||||||
|
}
|
||||||
36
ui/canvas_errors.go
Normal file
36
ui/canvas_errors.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
// CanvasWarning represents a type of canvas warning
|
||||||
|
type CanvasWarning int
|
||||||
|
|
||||||
|
const (
|
||||||
|
WarnItemNotFound CanvasWarning = iota
|
||||||
|
WarnConnectionNotFound
|
||||||
|
WarnInvalidOperation
|
||||||
|
WarnSelfConnection
|
||||||
|
WarnMissingCallback
|
||||||
|
)
|
||||||
|
|
||||||
|
// warningNames maps warning types to human-readable names
|
||||||
|
var warningNames = map[CanvasWarning]string{
|
||||||
|
WarnItemNotFound: "ItemNotFound",
|
||||||
|
WarnConnectionNotFound: "ConnectionNotFound",
|
||||||
|
WarnInvalidOperation: "InvalidOperation",
|
||||||
|
WarnSelfConnection: "SelfConnection",
|
||||||
|
WarnMissingCallback: "MissingCallback",
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogCanvasWarning logs a warning for recoverable canvas issues.
|
||||||
|
// These are situations where the operation cannot complete but the
|
||||||
|
// application can continue safely.
|
||||||
|
func LogCanvasWarning(warn CanvasWarning, format string, args ...any) {
|
||||||
|
name := warningNames[warn]
|
||||||
|
if name == "" {
|
||||||
|
name = "Unknown"
|
||||||
|
}
|
||||||
|
log.Printf("[Canvas %s] "+format, append([]any{name}, args...)...)
|
||||||
|
}
|
||||||
144
ui/canvas_geometry.go
Normal file
144
ui/canvas_geometry.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
)
|
||||||
|
|
||||||
|
// snapToGrid snaps a position to the grid if enabled
|
||||||
|
func snapToGrid(pos Point, gridSize int) Point {
|
||||||
|
if gridSize <= 0 {
|
||||||
|
return pos
|
||||||
|
}
|
||||||
|
return Point{
|
||||||
|
X: float64(int(pos.X/float64(gridSize)+0.5) * gridSize),
|
||||||
|
Y: float64(int(pos.Y/float64(gridSize)+0.5) * gridSize),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findItemUnderPoint finds which canvas item is under the given screen coordinates
|
||||||
|
// Returns the item with highest ZIndex that contains the point
|
||||||
|
func findItemUnderPoint(state *CanvasState, viewport element.Element, clientX, clientY float64) string {
|
||||||
|
// Get viewport bounds
|
||||||
|
rect := viewport.Call("getBoundingClientRect")
|
||||||
|
viewportLeft := rect.Get("left").Float()
|
||||||
|
viewportTop := rect.Get("top").Float()
|
||||||
|
|
||||||
|
// Convert to canvas coordinates
|
||||||
|
x := clientX - viewportLeft
|
||||||
|
y := clientY - viewportTop
|
||||||
|
canvasPos := state.ScreenToCanvas(Point{X: x, Y: y})
|
||||||
|
|
||||||
|
// Sort items by ZIndex descending (higher values first = top items first)
|
||||||
|
items := state.Items.Get()
|
||||||
|
sortedItems := make([]CanvasItem, len(items))
|
||||||
|
copy(sortedItems, items)
|
||||||
|
sort.Slice(sortedItems, func(i, j int) bool {
|
||||||
|
return sortedItems[i].ZIndex > sortedItems[j].ZIndex
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check items in z-order (top to bottom)
|
||||||
|
for _, item := range sortedItems {
|
||||||
|
if canvasPos.X >= item.Position.X && canvasPos.X <= item.Position.X+item.Size.X &&
|
||||||
|
canvasPos.Y >= item.Position.Y && canvasPos.Y <= item.Position.Y+item.Size.Y {
|
||||||
|
return item.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNearEdge checks if a click position is near the edge of an element
|
||||||
|
func isNearEdge(clickX, clickY, elemWidth, elemHeight float64) bool {
|
||||||
|
edgeThreshold := DefaultCanvasDefaults.EdgeThreshold
|
||||||
|
return clickX < edgeThreshold ||
|
||||||
|
clickX > elemWidth-edgeThreshold ||
|
||||||
|
clickY < edgeThreshold ||
|
||||||
|
clickY > elemHeight-edgeThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateEdgePoint calculates the edge point for a connection based on click position
|
||||||
|
func calculateEdgePoint(item CanvasItem, clickX, clickY, elemWidth, elemHeight float64) Point {
|
||||||
|
// Calculate center of element
|
||||||
|
centerX := item.Position.X + item.Size.X/2
|
||||||
|
centerY := item.Position.Y + item.Size.Y/2
|
||||||
|
|
||||||
|
// Determine which edge is closest
|
||||||
|
distLeft := clickX
|
||||||
|
distRight := elemWidth - clickX
|
||||||
|
distTop := clickY
|
||||||
|
distBottom := elemHeight - clickY
|
||||||
|
|
||||||
|
minDist := distLeft
|
||||||
|
edgeX := item.Position.X
|
||||||
|
edgeY := centerY
|
||||||
|
|
||||||
|
if distRight < minDist {
|
||||||
|
minDist = distRight
|
||||||
|
edgeX = item.Position.X + item.Size.X
|
||||||
|
edgeY = centerY
|
||||||
|
}
|
||||||
|
if distTop < minDist {
|
||||||
|
minDist = distTop
|
||||||
|
edgeX = centerX
|
||||||
|
edgeY = item.Position.Y
|
||||||
|
}
|
||||||
|
if distBottom < minDist {
|
||||||
|
edgeX = centerX
|
||||||
|
edgeY = item.Position.Y + item.Size.Y
|
||||||
|
}
|
||||||
|
|
||||||
|
return Point{X: edgeX, Y: edgeY}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateConnectionEdgePoint calculates where a line from center to target
|
||||||
|
// should intersect with the element's bounding box
|
||||||
|
func calculateConnectionEdgePoint(center, target, size Point) Point {
|
||||||
|
dx := target.X - center.X
|
||||||
|
dy := target.Y - center.Y
|
||||||
|
|
||||||
|
// Handle edge case of overlapping centers
|
||||||
|
if dx == 0 && dy == 0 {
|
||||||
|
return center
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate intersection with bounding box
|
||||||
|
halfW := size.X / 2
|
||||||
|
halfH := size.Y / 2
|
||||||
|
|
||||||
|
// Check which edge we intersect
|
||||||
|
if dx == 0 {
|
||||||
|
// Vertical line
|
||||||
|
if dy > 0 {
|
||||||
|
return Point{X: center.X, Y: center.Y + halfH}
|
||||||
|
}
|
||||||
|
return Point{X: center.X, Y: center.Y - halfH}
|
||||||
|
}
|
||||||
|
|
||||||
|
slope := dy / dx
|
||||||
|
|
||||||
|
// Check horizontal edges
|
||||||
|
if abs(slope) <= halfH/halfW {
|
||||||
|
// Intersects left or right edge
|
||||||
|
if dx > 0 {
|
||||||
|
return Point{X: center.X + halfW, Y: center.Y + slope*halfW}
|
||||||
|
}
|
||||||
|
return Point{X: center.X - halfW, Y: center.Y - slope*halfW}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersects top or bottom edge
|
||||||
|
if dy > 0 {
|
||||||
|
return Point{X: center.X + halfH/slope, Y: center.Y + halfH}
|
||||||
|
}
|
||||||
|
return Point{X: center.X - halfH/slope, Y: center.Y - halfH}
|
||||||
|
}
|
||||||
|
|
||||||
|
// abs returns the absolute value of x
|
||||||
|
func abs(x float64) float64 {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
42
ui/font.go
Normal file
42
ui/font.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
type Font struct {
|
||||||
|
family string
|
||||||
|
size string
|
||||||
|
weight string
|
||||||
|
lineheight string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFont() Font {
|
||||||
|
return Font{
|
||||||
|
family: "sans-serif",
|
||||||
|
size: "16px",
|
||||||
|
weight: "400",
|
||||||
|
lineheight: "20px",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Font) Family(family string) Font {
|
||||||
|
f.family = family
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Font) Size(size string) Font {
|
||||||
|
f.size = size
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Font) Weight(weight string) Font {
|
||||||
|
f.weight = weight
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f Font) String() string {
|
||||||
|
return f.weight + " " + f.size + " " + f.family + ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo we can't apply this straight via the css, we need to apply it to the lineheight of the parent
|
||||||
|
func (f Font) LineHeight(lineheight string) Font {
|
||||||
|
f.lineheight = lineheight
|
||||||
|
return f
|
||||||
|
}
|
||||||
7
ui/image.go
Normal file
7
ui/image.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
|
||||||
|
func Image(src string) View {
|
||||||
|
return View{element.NewElement("img").Attr("src", src)}
|
||||||
|
}
|
||||||
208
ui/input.go
Normal file
208
ui/input.go
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TextInput creates a reactive text input field
|
||||||
|
func TextInput(value *reactive.Signal[string], placeholder string) View {
|
||||||
|
input := element.NewElement("input")
|
||||||
|
input.Attr("type", "text")
|
||||||
|
input.Attr("placeholder", placeholder)
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
input.Set("value", value.Get())
|
||||||
|
|
||||||
|
// Update input when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentValue := value.Get()
|
||||||
|
if input.Get("value").String() != currentValue {
|
||||||
|
input.Set("value", currentValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user types
|
||||||
|
input.On("input", func() {
|
||||||
|
newValue := input.Get("value").String()
|
||||||
|
if value.Get() != newValue {
|
||||||
|
value.Set(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{input}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextArea creates a multi-line text input
|
||||||
|
func TextArea(value *reactive.Signal[string], placeholder string, rows int) View {
|
||||||
|
textarea := element.NewElement("textarea")
|
||||||
|
textarea.Attr("placeholder", placeholder)
|
||||||
|
textarea.Attr("rows", string(rune(rows)))
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
textarea.Set("value", value.Get())
|
||||||
|
|
||||||
|
// Update textarea when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentValue := value.Get()
|
||||||
|
if textarea.Get("value").String() != currentValue {
|
||||||
|
textarea.Set("value", currentValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user types
|
||||||
|
textarea.On("input", func() {
|
||||||
|
newValue := textarea.Get("value").String()
|
||||||
|
if value.Get() != newValue {
|
||||||
|
value.Set(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{textarea}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox creates a reactive checkbox input
|
||||||
|
func Checkbox(checked *reactive.Signal[bool], label string) View {
|
||||||
|
container := element.NewElement("label")
|
||||||
|
container.Get("style").Call("setProperty", "display", "flex")
|
||||||
|
container.Get("style").Call("setProperty", "align-items", "center")
|
||||||
|
container.Get("style").Call("setProperty", "gap", "8px")
|
||||||
|
container.Get("style").Call("setProperty", "cursor", "pointer")
|
||||||
|
|
||||||
|
checkbox := element.NewElement("input")
|
||||||
|
checkbox.Attr("type", "checkbox")
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
if checked.Get() {
|
||||||
|
checkbox.Set("checked", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checkbox when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
isChecked := checked.Get()
|
||||||
|
checkbox.Set("checked", isChecked)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user clicks
|
||||||
|
checkbox.On("change", func() {
|
||||||
|
newValue := checkbox.Get("checked").Bool()
|
||||||
|
checked.Set(newValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add label text
|
||||||
|
labelText := element.NewTextNode(label)
|
||||||
|
|
||||||
|
container.Child(checkbox)
|
||||||
|
container.Child(labelText)
|
||||||
|
|
||||||
|
return View{container}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select creates a dropdown selection component
|
||||||
|
func Select(selected *reactive.Signal[string], options []string, placeholder string) View {
|
||||||
|
selectElem := element.NewElement("select")
|
||||||
|
|
||||||
|
// Add placeholder option if provided
|
||||||
|
if placeholder != "" {
|
||||||
|
placeholderOption := element.NewElement("option")
|
||||||
|
placeholderOption.Set("value", "")
|
||||||
|
placeholderOption.Set("disabled", true)
|
||||||
|
placeholderOption.Set("selected", true)
|
||||||
|
placeholderOption.Set("textContent", placeholder)
|
||||||
|
selectElem.Child(placeholderOption)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add options
|
||||||
|
for _, option := range options {
|
||||||
|
optionElem := element.NewElement("option")
|
||||||
|
optionElem.Set("value", option)
|
||||||
|
optionElem.Set("textContent", option)
|
||||||
|
selectElem.Child(optionElem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
selectElem.Set("value", selected.Get())
|
||||||
|
|
||||||
|
// Update select when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentValue := selected.Get()
|
||||||
|
selectElem.Set("value", currentValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user selects
|
||||||
|
selectElem.On("change", func() {
|
||||||
|
newValue := selectElem.Get("value").String()
|
||||||
|
selected.Set(newValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{selectElem}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slider creates a range input for numeric values
|
||||||
|
func Slider(value *reactive.Signal[int], min, max int) View {
|
||||||
|
slider := element.NewElement("input")
|
||||||
|
slider.Attr("type", "range")
|
||||||
|
slider.Attr("min", strconv.Itoa(min))
|
||||||
|
slider.Attr("max", strconv.Itoa(max))
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
slider.Set("value", strconv.Itoa(value.Get()))
|
||||||
|
|
||||||
|
// Update slider when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentValue := value.Get()
|
||||||
|
slider.Set("value", strconv.Itoa(currentValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user drags
|
||||||
|
slider.On("input", func() {
|
||||||
|
newValueStr := slider.Get("value").String()
|
||||||
|
if newValue, err := strconv.Atoi(newValueStr); err == nil {
|
||||||
|
value.Set(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{slider}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumberInput creates a reactive numeric input field for float64 values
|
||||||
|
func NumberInput(value *reactive.Signal[float64], placeholder string) View {
|
||||||
|
input := element.NewElement("input")
|
||||||
|
input.Attr("type", "number")
|
||||||
|
input.Attr("step", "0.01")
|
||||||
|
input.Attr("min", "0")
|
||||||
|
input.Attr("placeholder", placeholder)
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
if value.Get() != 0 {
|
||||||
|
input.Set("value", strconv.FormatFloat(value.Get(), 'f', -1, 64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update input when signal changes
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
currentValue := value.Get()
|
||||||
|
var displayValue string
|
||||||
|
if currentValue == 0 {
|
||||||
|
displayValue = ""
|
||||||
|
} else {
|
||||||
|
displayValue = strconv.FormatFloat(currentValue, 'f', -1, 64)
|
||||||
|
}
|
||||||
|
if input.Get("value").String() != displayValue {
|
||||||
|
input.Set("value", displayValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update signal when user types
|
||||||
|
input.On("input", func() {
|
||||||
|
newValueStr := input.Get("value").String()
|
||||||
|
if newValueStr == "" {
|
||||||
|
value.Set(0.0)
|
||||||
|
} else if newValue, err := strconv.ParseFloat(newValueStr, 64); err == nil {
|
||||||
|
value.Set(newValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{input}
|
||||||
|
}
|
||||||
90
ui/layout.go
Normal file
90
ui/layout.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// VerticalGroup is a view that arranges its children vertically
|
||||||
|
// It uses CSS Grid with a single column
|
||||||
|
func VerticalGroup(children ...View) View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Grid()
|
||||||
|
v.e.GridTemplateColumns("1fr") // Force single column
|
||||||
|
v.e.GridAutoFlow("row")
|
||||||
|
v.e.Width("100%")
|
||||||
|
for _, child := range children {
|
||||||
|
v.Child(child)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// HorizontalGroup is a view that arranges its children horizontally
|
||||||
|
// It uses flexbox to do so
|
||||||
|
func HorizontalGroup(children ...View) View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Grid()
|
||||||
|
v.e.GridTemplateColumns("repeat(auto-fit, minmax(250px, 1fr))")
|
||||||
|
v.e.Width("100%")
|
||||||
|
for _, child := range children {
|
||||||
|
v.Child(child)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacer is a view that takes up all available space
|
||||||
|
// It uses flexbox to do so
|
||||||
|
func Spacer() View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Get("style").Call("setProperty", "flex", "1")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayGroup creates a container where children are stacked on top of each other
|
||||||
|
func OverlayGroup(children ...View) View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Get("style").Call("setProperty", "position", "relative")
|
||||||
|
v.e.Get("style").Call("setProperty", "display", "block")
|
||||||
|
|
||||||
|
// Add children with absolute positioning (except first one)
|
||||||
|
for i, child := range children {
|
||||||
|
if i == 0 {
|
||||||
|
// First child acts as the base layer
|
||||||
|
v.Child(child)
|
||||||
|
} else {
|
||||||
|
// Subsequent children are absolutely positioned as overlays
|
||||||
|
// They need pointer-events: none so clicks pass through to layers below
|
||||||
|
child.e.Get("style").Call("setProperty", "position", "absolute")
|
||||||
|
child.e.Get("style").Call("setProperty", "top", "0")
|
||||||
|
child.e.Get("style").Call("setProperty", "left", "0")
|
||||||
|
child.e.Get("style").Call("setProperty", "width", "100%")
|
||||||
|
child.e.Get("style").Call("setProperty", "height", "100%")
|
||||||
|
child.e.Get("style").Call("setProperty", "pointer-events", "none")
|
||||||
|
v.Child(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider creates a visual separator line
|
||||||
|
func Divider() View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Get("style").Call("setProperty", "border-top", "1px solid #ccc")
|
||||||
|
v.e.Get("style").Call("setProperty", "margin", "8px 0")
|
||||||
|
v.e.Get("style").Call("setProperty", "width", "100%")
|
||||||
|
v.e.Get("style").Call("setProperty", "height", "1px")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexLayout creates a horizontal layout with explicit column sizing
|
||||||
|
// Example: FlexLayout([]string{"48px", "1fr", "240px"}, left, center, right)
|
||||||
|
// Supports fixed widths (e.g., "48px"), flexible ("1fr"), and auto-sized ("auto")
|
||||||
|
func FlexLayout(columns []string, children ...View) View {
|
||||||
|
v := NewView()
|
||||||
|
v.e.Grid()
|
||||||
|
v.e.Get("style").Call("setProperty", "grid-template-columns", strings.Join(columns, " "))
|
||||||
|
v.e.Width("100%")
|
||||||
|
v.e.Height("100%")
|
||||||
|
for _, child := range children {
|
||||||
|
v.Child(child)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
172
ui/modifiers.go
Normal file
172
ui/modifiers.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
func (v View) SpaceEvenly() View {
|
||||||
|
v.e.Get("style").Call("setProperty", "justify-content", "space-evenly")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) SpaceBetween() View {
|
||||||
|
v.e.Get("style").Call("setProperty", "justify-content", "space-between")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) AlignCenter() View {
|
||||||
|
v.e.Get("style").Call("setProperty", "align-items", "center")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) AlignRight() View {
|
||||||
|
v.e.Get("style").Call("setProperty", "align-items", "flex-end")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) AlignLeft() View {
|
||||||
|
v.e.Get("style").Call("setProperty", "align-items", "flex-start")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) JustifyContent(justify string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "justify-content", justify)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Margin(margin string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "margin", margin)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Padding(padding string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "padding", padding)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Font(font Font) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "font", font.String())
|
||||||
|
v.e.Get("style").Call("setProperty", "line-height", font.lineheight)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Color(color string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "color", color)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Gap(gap string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "gap", gap)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Border(border string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "border", border)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BorderTop(border string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "border-top", border)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BorderBottom(border string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "border-bottom", border)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BorderLeft(border string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "border-left", border)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BorderRight(border string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "border-right", border)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) TextAlign(align string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "text-align", align)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) MaxWidth(width string) View {
|
||||||
|
v.e.MaxWidth(width)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Width(width string) View {
|
||||||
|
v.e.Width(width)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Height(height string) View {
|
||||||
|
v.e.Height(height)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Background(color string) View {
|
||||||
|
v.e.BackgroundColor(color)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Foreground(color string) View {
|
||||||
|
v.e.Color(color)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) MinHeight(height string) View {
|
||||||
|
v.e.MinHeight(height)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BoxShadow(shadow string) View {
|
||||||
|
v.e.BoxShadow(shadow)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) BorderRadius(radius string) View {
|
||||||
|
v.e.BorderRadius(radius)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Cursor(cursor string) View {
|
||||||
|
v.e.Cursor(cursor)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Position(pos string) View {
|
||||||
|
v.e.Position(pos)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Left(value string) View {
|
||||||
|
v.e.Left(value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Top(value string) View {
|
||||||
|
v.e.Top(value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Overflow(value string) View {
|
||||||
|
v.e.Overflow(value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Display(value string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "display", value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) GridTemplateColumns(value string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "grid-template-columns", value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) AlignItems(value string) View {
|
||||||
|
v.e.Get("style").Call("setProperty", "align-items", value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) PointerEvents(value string) View {
|
||||||
|
v.e.PointerEvents(value)
|
||||||
|
return v
|
||||||
|
}
|
||||||
264
ui/svg.go
Normal file
264
ui/svg.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"syscall/js"
|
||||||
|
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
)
|
||||||
|
|
||||||
|
const svgNS = "http://www.w3.org/2000/svg"
|
||||||
|
|
||||||
|
// SVG creates an SVG container element
|
||||||
|
func SVG() SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "svg")
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGElement wraps an SVG element
|
||||||
|
type SVGElement struct {
|
||||||
|
e element.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attr sets an attribute on the SVG element
|
||||||
|
func (s SVGElement) Attr(name, value string) SVGElement {
|
||||||
|
s.e.Attr(name, value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width sets the width attribute
|
||||||
|
func (s SVGElement) Width(value string) SVGElement {
|
||||||
|
s.e.Attr("width", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height sets the height attribute
|
||||||
|
func (s SVGElement) Height(value string) SVGElement {
|
||||||
|
s.e.Attr("height", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position sets CSS position
|
||||||
|
func (s SVGElement) Position(pos string) SVGElement {
|
||||||
|
s.e.Position(pos)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left sets CSS left
|
||||||
|
func (s SVGElement) Left(value string) SVGElement {
|
||||||
|
s.e.Left(value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top sets CSS top
|
||||||
|
func (s SVGElement) Top(value string) SVGElement {
|
||||||
|
s.e.Top(value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// PointerEvents sets CSS pointer-events
|
||||||
|
func (s SVGElement) PointerEvents(value string) SVGElement {
|
||||||
|
s.e.PointerEvents(value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child adds a child SVG element
|
||||||
|
func (s SVGElement) ChildSVG(child SVGElement) SVGElement {
|
||||||
|
s.e.Child(child.e)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearChildren removes all children
|
||||||
|
func (s SVGElement) ClearChildren() SVGElement {
|
||||||
|
s.e.ClearChildren()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToView converts the SVG element to a View (for embedding in layouts)
|
||||||
|
func (s SVGElement) ToView() View {
|
||||||
|
return View{s.e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGDefs creates a defs element
|
||||||
|
func SVGDefs() SVGElement {
|
||||||
|
return SVGElement{element.NewElementNS(svgNS, "defs")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGMarker creates a marker element for arrowheads
|
||||||
|
func SVGMarker(id string) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "marker")
|
||||||
|
e.Attr("id", id)
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkerWidth sets marker width
|
||||||
|
func (s SVGElement) MarkerWidth(value string) SVGElement {
|
||||||
|
s.e.Attr("markerWidth", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkerHeight sets marker height
|
||||||
|
func (s SVGElement) MarkerHeight(value string) SVGElement {
|
||||||
|
s.e.Attr("markerHeight", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefX sets refX
|
||||||
|
func (s SVGElement) RefX(value string) SVGElement {
|
||||||
|
s.e.Attr("refX", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefY sets refY
|
||||||
|
func (s SVGElement) RefY(value string) SVGElement {
|
||||||
|
s.e.Attr("refY", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orient sets orient
|
||||||
|
func (s SVGElement) Orient(value string) SVGElement {
|
||||||
|
s.e.Attr("orient", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkerUnits sets markerUnits
|
||||||
|
func (s SVGElement) MarkerUnits(value string) SVGElement {
|
||||||
|
s.e.Attr("markerUnits", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGPath creates a path element
|
||||||
|
func SVGPath(d string) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "path")
|
||||||
|
e.Attr("d", d)
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill sets fill color
|
||||||
|
func (s SVGElement) Fill(color string) SVGElement {
|
||||||
|
s.e.Attr("fill", color)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stroke sets stroke color
|
||||||
|
func (s SVGElement) Stroke(color string) SVGElement {
|
||||||
|
s.e.Attr("stroke", color)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrokeWidth sets stroke width
|
||||||
|
func (s SVGElement) StrokeWidth(value string) SVGElement {
|
||||||
|
s.e.Attr("stroke-width", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// StrokeDasharray sets stroke dash pattern
|
||||||
|
func (s SVGElement) StrokeDasharray(value string) SVGElement {
|
||||||
|
s.e.Attr("stroke-dasharray", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkerEnd sets the end marker
|
||||||
|
func (s SVGElement) MarkerEnd(value string) SVGElement {
|
||||||
|
s.e.Attr("marker-end", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGLine creates a line element
|
||||||
|
func SVGLine(x1, y1, x2, y2 float64) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "line")
|
||||||
|
e.Attr("x1", fmt.Sprintf("%f", x1))
|
||||||
|
e.Attr("y1", fmt.Sprintf("%f", y1))
|
||||||
|
e.Attr("x2", fmt.Sprintf("%f", x2))
|
||||||
|
e.Attr("y2", fmt.Sprintf("%f", y2))
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGRect creates a rect element
|
||||||
|
func SVGRect(x, y, width, height float64) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "rect")
|
||||||
|
e.Attr("x", fmt.Sprintf("%f", x))
|
||||||
|
e.Attr("y", fmt.Sprintf("%f", y))
|
||||||
|
e.Attr("width", fmt.Sprintf("%f", width))
|
||||||
|
e.Attr("height", fmt.Sprintf("%f", height))
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGCircle creates a circle element
|
||||||
|
func SVGCircle(cx, cy, r float64) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "circle")
|
||||||
|
e.Attr("cx", fmt.Sprintf("%f", cx))
|
||||||
|
e.Attr("cy", fmt.Sprintf("%f", cy))
|
||||||
|
e.Attr("r", fmt.Sprintf("%f", r))
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGText creates a text element
|
||||||
|
func SVGText(text string, x, y float64) SVGElement {
|
||||||
|
e := element.NewElementNS(svgNS, "text")
|
||||||
|
e.Attr("x", fmt.Sprintf("%f", x))
|
||||||
|
e.Attr("y", fmt.Sprintf("%f", y))
|
||||||
|
e.Text(text)
|
||||||
|
return SVGElement{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVGGroup creates a g (group) element
|
||||||
|
func SVGGroup() SVGElement {
|
||||||
|
return SVGElement{element.NewElementNS(svgNS, "g")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClick adds a click handler to the SVG element
|
||||||
|
func (s SVGElement) OnClick(fn func()) SVGElement {
|
||||||
|
s.e.On("click", fn)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClickWithEvent adds a click handler with event access
|
||||||
|
func (s SVGElement) OnClickWithEvent(fn func(js.Value)) SVGElement {
|
||||||
|
s.e.OnWithEvent("click", fn)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataAttr sets a data attribute (data-{name}="{value}")
|
||||||
|
func (s SVGElement) DataAttr(name, value string) SVGElement {
|
||||||
|
s.e.Attr("data-"+name, value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID sets the id attribute
|
||||||
|
func (s SVGElement) ID(id string) SVGElement {
|
||||||
|
s.e.Attr("id", id)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor sets the CSS cursor property
|
||||||
|
func (s SVGElement) Cursor(value string) SVGElement {
|
||||||
|
s.e.Cursor(value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontSize sets the font-size attribute
|
||||||
|
func (s SVGElement) FontSize(value string) SVGElement {
|
||||||
|
s.e.Attr("font-size", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// FontFamily sets the font-family attribute
|
||||||
|
func (s SVGElement) FontFamily(value string) SVGElement {
|
||||||
|
s.e.Attr("font-family", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextAnchor sets the text-anchor attribute (start, middle, end)
|
||||||
|
func (s SVGElement) TextAnchor(value string) SVGElement {
|
||||||
|
s.e.Attr("text-anchor", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// DominantBaseline sets the dominant-baseline attribute
|
||||||
|
func (s SVGElement) DominantBaseline(value string) SVGElement {
|
||||||
|
s.e.Attr("dominant-baseline", value)
|
||||||
|
return s
|
||||||
|
}
|
||||||
23
ui/text.go
Normal file
23
ui/text.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
"git.flowmade.one/flowmade-one/iris/reactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TextFromString(text string) View {
|
||||||
|
v := View{element.NewElement("p")}
|
||||||
|
v.e.Set("textContent", text)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TextFromFunction(fn func() string) View {
|
||||||
|
textNode := element.NewElement("p")
|
||||||
|
|
||||||
|
reactive.NewEffect(func() {
|
||||||
|
value := fn()
|
||||||
|
textNode.Set("textContent", value)
|
||||||
|
})
|
||||||
|
|
||||||
|
return View{textNode}
|
||||||
|
}
|
||||||
10
ui/utils.go
Normal file
10
ui/utils.go
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//go:build js && wasm
|
||||||
|
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// FormatPx formats a float as a CSS pixel value
|
||||||
|
func FormatPx(v float64) string {
|
||||||
|
return fmt.Sprintf("%.0fpx", v)
|
||||||
|
}
|
||||||
29
ui/view.go
Normal file
29
ui/view.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "git.flowmade.one/flowmade-one/iris/internal/element"
|
||||||
|
|
||||||
|
type View struct {
|
||||||
|
e element.Element
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v View) Child(child View) View {
|
||||||
|
v.e.Child(child.e)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewView() View {
|
||||||
|
e := element.NewElement("div")
|
||||||
|
e.JustifyItems("center")
|
||||||
|
e.AlignItems("center")
|
||||||
|
return View{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewViewFromElement creates a View from an element (for advanced use cases)
|
||||||
|
func NewViewFromElement(e element.Element) View {
|
||||||
|
return View{e}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element returns the underlying element (for advanced use cases)
|
||||||
|
func (v View) Element() element.Element {
|
||||||
|
return v.e
|
||||||
|
}
|
||||||
37
vision.md
Normal file
37
vision.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Iris Vision
|
||||||
|
|
||||||
|
WASM reactive UI framework for Go - build browser applications without JavaScript.
|
||||||
|
|
||||||
|
## Organization Context
|
||||||
|
|
||||||
|
This repo is part of Flowmade. See [organization manifesto](../architecture/manifesto.md) for who we are and what we believe.
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
Iris is an open-source UI framework that enables Go developers to build reactive browser applications compiled to WebAssembly. It provides:
|
||||||
|
|
||||||
|
- **Signals-based reactivity** - Automatic DOM updates when state changes
|
||||||
|
- **Component library** - Button, Text, Input, View, Canvas, SVG
|
||||||
|
- **Client-side routing** - Router with guards and history management
|
||||||
|
- **OIDC authentication** - Browser-based auth flows for WASM apps
|
||||||
|
- **Static file server** - Host component for serving WASM applications
|
||||||
|
|
||||||
|
## Who This Serves
|
||||||
|
|
||||||
|
- **Go developers** who want to build web UIs without learning JavaScript
|
||||||
|
- **Teams** building internal tools and dashboards
|
||||||
|
- **Projects** that benefit from shared Go code between server and client
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Simple reactivity** - Signals that just work, no complex state management
|
||||||
|
2. **Familiar patterns** - Go idioms, not React/Vue patterns forced into Go
|
||||||
|
3. **WASM-first** - Designed for WebAssembly, not ported from elsewhere
|
||||||
|
4. **Minimal dependencies** - Only syscall/js for browser interop
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Server-side rendering (SSR)
|
||||||
|
- Virtual DOM diffing
|
||||||
|
- CSS-in-Go styling system
|
||||||
|
- Component marketplace
|
||||||
Reference in New Issue
Block a user