Compare commits
4 commits
node-ai-sl
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 430eb90796 | |||
| 0dcd2cead7 | |||
| b942799298 | |||
| 6d029efeaf |
28 changed files with 5153 additions and 1566 deletions
6
.github/workflows/pages.yml
vendored
6
.github/workflows/pages.yml
vendored
|
|
@ -54,7 +54,11 @@ jobs:
|
||||||
node-version: latest
|
node-version: latest
|
||||||
cache: npm
|
cache: npm
|
||||||
- name: Install npm deps
|
- name: Install npm deps
|
||||||
run: npm install
|
run: |
|
||||||
|
npm install
|
||||||
|
cd core
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
- name: Build with vite
|
- name: Build with vite
|
||||||
run: npm run deploy
|
run: npm run deploy
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
247
README.md
247
README.md
|
|
@ -1,247 +0,0 @@
|
||||||
# Fahrtenbuch - Multi-Target Scala.js Application
|
|
||||||
|
|
||||||
A collaborative trip tracking application built with Scala.js, supporting both browser and Node.js environments with real-time peer-to-peer synchronization.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
This project uses a multi-target architecture with shared business logic and platform-specific implementations:
|
|
||||||
|
|
||||||
```
|
|
||||||
fahrtenbuch/
|
|
||||||
├── shared/ # Shared business logic and models
|
|
||||||
│ └── src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── model/ # Data models (Entry, EntryId)
|
|
||||||
│ ├── core/ # Business logic (EntryManager)
|
|
||||||
│ ├── storage/ # Storage interface
|
|
||||||
│ └── sync/ # Synchronization interface
|
|
||||||
├── browser/ # Browser-specific implementation
|
|
||||||
│ └── src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── components/ # Laminar UI components
|
|
||||||
│ ├── storage/ # Dexie (IndexedDB) storage
|
|
||||||
│ ├── sync/ # Trystero (WebRTC) sync
|
|
||||||
│ └── BrowserMain.scala
|
|
||||||
├── nodejs/ # Node.js-specific implementation
|
|
||||||
│ └── src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── storage/ # File system storage
|
|
||||||
│ ├── sync/ # WebSocket sync
|
|
||||||
│ └── NodeMain.scala
|
|
||||||
└── dist/ # Build outputs
|
|
||||||
├── browser/ # Browser build artifacts
|
|
||||||
└── nodejs/ # Node.js build artifacts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Shared Features
|
|
||||||
- **CRDT-based synchronization**: Conflict-free replicated data types ensure consistent state across peers
|
|
||||||
- **Real-time sync**: Changes are automatically synchronized between connected peers
|
|
||||||
- **Offline support**: Works offline with automatic sync when reconnected
|
|
||||||
- **Trip tracking**: Track vehicle trips with distance, cost calculations, and payment status
|
|
||||||
|
|
||||||
### Browser Features
|
|
||||||
- **Modern UI**: Built with Laminar and Bulma CSS framework
|
|
||||||
- **IndexedDB storage**: Persistent local storage using Dexie
|
|
||||||
- **WebRTC P2P**: Direct peer-to-peer communication via Trystero
|
|
||||||
- **Real-time updates**: Live UI updates as data changes
|
|
||||||
|
|
||||||
### Node.js Features
|
|
||||||
- **Command-line interface**: Interactive CLI for managing entries
|
|
||||||
- **File system storage**: JSON-based persistent storage
|
|
||||||
- **WebSocket server**: Acts as a hub for peer communication
|
|
||||||
- **Statistics reporting**: Built-in analytics and reporting
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
- **Scala**: 3.7.1
|
|
||||||
- **Node.js**: 16+
|
|
||||||
- **sbt**: 1.8+
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. Clone the repository:
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd fahrtenbuch
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Install dependencies:
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Build both targets:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Browser Development
|
|
||||||
```bash
|
|
||||||
# Start development server with hot reload
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Build browser version only
|
|
||||||
npm run build:browser
|
|
||||||
|
|
||||||
# Production build
|
|
||||||
npm run build:browser:prod
|
|
||||||
```
|
|
||||||
|
|
||||||
### Node.js Development
|
|
||||||
```bash
|
|
||||||
# Build Node.js version
|
|
||||||
npm run build:nodejs
|
|
||||||
|
|
||||||
# Run Node.js application
|
|
||||||
npm run start:nodejs
|
|
||||||
|
|
||||||
# With custom options
|
|
||||||
node dist/nodejs/main.js --port 8080 --data-dir ./data
|
|
||||||
```
|
|
||||||
|
|
||||||
### SBT Commands
|
|
||||||
```bash
|
|
||||||
# Compile shared code
|
|
||||||
sbt sharedJs/compile
|
|
||||||
|
|
||||||
# Fast build for browser
|
|
||||||
sbt browser/fastOptJS
|
|
||||||
|
|
||||||
# Optimized build for browser
|
|
||||||
sbt browser/fullOptJS
|
|
||||||
|
|
||||||
# Fast build for Node.js
|
|
||||||
sbt nodejs/fastOptJS
|
|
||||||
|
|
||||||
# Optimized build for Node.js
|
|
||||||
sbt nodejs/fullOptJS
|
|
||||||
|
|
||||||
# Build everything
|
|
||||||
sbt compile
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Browser Application
|
|
||||||
|
|
||||||
1. Build and start the browser version:
|
|
||||||
```bash
|
|
||||||
npm run build:browser
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Open your browser and navigate to the displayed URL
|
|
||||||
3. Share the URL (including the hash) with other users for real-time collaboration
|
|
||||||
|
|
||||||
### Node.js Application
|
|
||||||
|
|
||||||
1. Build and start the Node.js version:
|
|
||||||
```bash
|
|
||||||
npm run build:nodejs
|
|
||||||
node dist/nodejs/main.js --port 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Use the interactive CLI:
|
|
||||||
```
|
|
||||||
fahrtenbuch> help
|
|
||||||
fahrtenbuch> add 1000 1050 John Dog
|
|
||||||
fahrtenbuch> list
|
|
||||||
fahrtenbuch> stats
|
|
||||||
fahrtenbuch> peers
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Connect multiple instances:
|
|
||||||
```bash
|
|
||||||
# Start first instance
|
|
||||||
node dist/nodejs/main.js --port 8080
|
|
||||||
|
|
||||||
# Start second instance and connect to first
|
|
||||||
node dist/nodejs/main.js --port 8081 --connect localhost:8080
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Node.js Options
|
|
||||||
- `--port <port>`: WebSocket server port (default: 8080)
|
|
||||||
- `--data-dir <dir>`: Data storage directory (default: ./data)
|
|
||||||
- `--connect <host:port>`: Connect to peer at host:port
|
|
||||||
- `--help`: Show help message
|
|
||||||
|
|
||||||
### Browser Configuration
|
|
||||||
The browser version uses URL fragments for room identification. Users sharing the same URL fragment will be connected in the same sync room.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Entry
|
|
||||||
Each trip entry contains:
|
|
||||||
- `id`: Unique identifier
|
|
||||||
- `startKm`: Starting odometer reading
|
|
||||||
- `endKm`: Ending odometer reading
|
|
||||||
- `driver`: Driver name
|
|
||||||
- `animal`: Animal transported
|
|
||||||
- `paid`: Payment status
|
|
||||||
- `date`: Entry creation date
|
|
||||||
- `gasPricePerKm`: Gas cost per kilometer
|
|
||||||
- `wearPricePerKm`: Wear cost per kilometer
|
|
||||||
|
|
||||||
### Synchronization
|
|
||||||
- Uses CRDT (Conflict-free Replicated Data Types) for conflict resolution
|
|
||||||
- Last-writer-wins semantics for most fields
|
|
||||||
- Automatic merging of concurrent updates
|
|
||||||
- Peer-to-peer synchronization without central server
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
### Browser Storage
|
|
||||||
- **IndexedDB**: Via Dexie library
|
|
||||||
- **Schema versioning**: Automatic migrations
|
|
||||||
- **Live queries**: Real-time UI updates
|
|
||||||
|
|
||||||
### Node.js Storage
|
|
||||||
- **File system**: JSON files in data directory
|
|
||||||
- **Atomic writes**: Safe concurrent access
|
|
||||||
- **Backup friendly**: Human-readable JSON format
|
|
||||||
|
|
||||||
## Networking
|
|
||||||
|
|
||||||
### Browser Networking
|
|
||||||
- **WebRTC**: Direct peer-to-peer via Trystero
|
|
||||||
- **TURN/STUN**: Configurable relay servers
|
|
||||||
- **Room-based**: URL hash determines sync room
|
|
||||||
|
|
||||||
### Node.js Networking
|
|
||||||
- **WebSocket**: Server-client architecture
|
|
||||||
- **Auto-discovery**: Automatic peer detection
|
|
||||||
- **Hub model**: Can act as relay for browser clients
|
|
||||||
|
|
||||||
## Development Tips
|
|
||||||
|
|
||||||
### Adding New Features
|
|
||||||
1. Add shared logic to `shared/src/main/scala/fahrtenbuch/`
|
|
||||||
2. Implement platform-specific parts in `browser/` and `nodejs/`
|
|
||||||
3. Update interfaces in `storage/` and `sync/` packages
|
|
||||||
4. Test both targets with `npm run build`
|
|
||||||
|
|
||||||
### Debugging
|
|
||||||
- Browser: Use browser dev tools, network tab for WebRTC
|
|
||||||
- Node.js: Use `console.log` or Node.js debugging tools
|
|
||||||
- Storage: Check browser IndexedDB or Node.js data directory
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- Use `fullOptJS` for production builds
|
|
||||||
- Browser builds are optimized for incremental loading
|
|
||||||
- Node.js builds are optimized for startup time
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
1. Fork the repository
|
|
||||||
2. Create a feature branch
|
|
||||||
3. Make changes to shared code and both platform implementations
|
|
||||||
4. Test both browser and Node.js targets
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[Add your license here]
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
Summary
|
|
||||||
|
|
||||||
I've successfully converted your sbt project from a single-target setup to a multi-target setup supporting both browser and Node.js environments. Here's what was accomplished:
|
|
||||||
|
|
||||||
### 🎯 **Multi-Target Architecture**
|
|
||||||
|
|
||||||
The project now has three main components:
|
|
||||||
|
|
||||||
1. **`shared/`** - Common business logic and models
|
|
||||||
2. **`browser/`** - Browser-specific implementation with Laminar UI
|
|
||||||
3. **`nodejs/`** - Node.js-specific implementation with CLI
|
|
||||||
|
|
||||||
### 🏗️ **Project Structure**
|
|
||||||
|
|
||||||
```
|
|
||||||
fahrtenbuch/
|
|
||||||
├── shared/src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── model/ # Entry, EntryId data models
|
|
||||||
│ ├── core/ # EntryManager business logic
|
|
||||||
│ ├── storage/ # StorageInterface
|
|
||||||
│ └── sync/ # SyncInterface
|
|
||||||
├── browser/src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── components/ # Laminar UI components
|
|
||||||
│ ├── storage/ # DexieStorage (IndexedDB)
|
|
||||||
│ ├── sync/ # TrysteroSync (WebRTC P2P)
|
|
||||||
│ └── BrowserMain.scala
|
|
||||||
├── nodejs/src/main/scala/fahrtenbuch/
|
|
||||||
│ ├── storage/ # FileStorage (JSON files)
|
|
||||||
│ ├── sync/ # WebSocketSync (WebSocket server)
|
|
||||||
│ └── NodeMain.scala
|
|
||||||
└── dist/
|
|
||||||
├── browser/ # Browser build outputs
|
|
||||||
└── nodejs/ # Node.js build outputs (main.cjs)
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ **What's Working**
|
|
||||||
|
|
||||||
- **✅ Node.js target**: Fully functional with CLI interface
|
|
||||||
- **✅ Shared code**: Compiles successfully, shared between targets
|
|
||||||
- **✅ Build system**: Multi-target sbt configuration
|
|
||||||
- **✅ File storage**: JSON-based persistence for Node.js
|
|
||||||
- **✅ WebSocket networking**: Server-client architecture for Node.js
|
|
||||||
- **✅ Command-line interface**: Interactive CLI with help, add, list, stats commands
|
|
||||||
|
|
||||||
### 🔧 **Build Commands**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build both targets
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Build only browser
|
|
||||||
npm run build:browser
|
|
||||||
|
|
||||||
# Build only Node.js
|
|
||||||
npm run build:nodejs
|
|
||||||
sbt nodejs/fastOptJS
|
|
||||||
|
|
||||||
# Run Node.js app
|
|
||||||
node dist/nodejs/main.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🚀 **Node.js Features**
|
|
||||||
|
|
||||||
- **CLI interface**: Interactive command-line with help, add, list, stats
|
|
||||||
- **File storage**: JSON-based persistence in `./data/` directory
|
|
||||||
- **WebSocket networking**: Acts as hub for peer synchronization
|
|
||||||
- **Configuration**: `--port`, `--data-dir`, `--connect` options
|
|
||||||
- **CRDT synchronization**: Conflict-free entry merging
|
|
||||||
|
|
||||||
### 📋 **Next Steps**
|
|
||||||
|
|
||||||
1. **Browser compilation**: The browser target needs more work due to complex TypeScript dependencies (Trystero, Dexie). This is taking a long time to compile.
|
|
||||||
|
|
||||||
2. **Testing**: You can test the Node.js version immediately:
|
|
||||||
```bash
|
|
||||||
node dist/nodejs/main.cjs
|
|
||||||
# Then type: help, add 1000 1050 John Dog, list, stats
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Browser optimization**: Consider simplifying browser dependencies or using a different sync mechanism for faster compilation.
|
|
||||||
|
|
||||||
The core architecture is working perfectly! The Node.js version demonstrates that the multi-target approach is successful, with shared business logic and platform-specific implementations.
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package fahrtenbuch
|
|
||||||
|
|
||||||
import com.raquo.laminar.api.L.*
|
|
||||||
import fahrtenbuch.components.AppComponent
|
|
||||||
import fahrtenbuch.core.EntryManager
|
|
||||||
import fahrtenbuch.storage.DexieStorage
|
|
||||||
import fahrtenbuch.sync.TrysteroSync
|
|
||||||
import org.scalajs.dom
|
|
||||||
|
|
||||||
import scala.concurrent.ExecutionContext.Implicits.global
|
|
||||||
|
|
||||||
@main
|
|
||||||
def BrowserMain(): Unit = {
|
|
||||||
// Get room ID from URL hash
|
|
||||||
val roomId = dom.window.location.hash
|
|
||||||
|
|
||||||
// Initialize platform-specific implementations
|
|
||||||
val storage = new DexieStorage()
|
|
||||||
val sync = new TrysteroSync(roomId)
|
|
||||||
val entryManager = new EntryManager(storage, sync)
|
|
||||||
|
|
||||||
// Track all entries
|
|
||||||
val allEntriesVar = Var(Set.empty[fahrtenbuch.model.Entry])
|
|
||||||
|
|
||||||
// Update entries whenever storage changes
|
|
||||||
entryManager.onEntriesChanged { entries =>
|
|
||||||
allEntriesVar.set(entries.toSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create online status signal
|
|
||||||
val onlineStatusVar = Var(false)
|
|
||||||
|
|
||||||
// Update online status when peers join/leave
|
|
||||||
sync.onPeerJoin(_ => onlineStatusVar.set(sync.hasConnectedPeers()))
|
|
||||||
sync.onPeerLeave(_ => onlineStatusVar.set(sync.hasConnectedPeers()))
|
|
||||||
|
|
||||||
// Initialize online status
|
|
||||||
onlineStatusVar.set(sync.hasConnectedPeers())
|
|
||||||
|
|
||||||
// Create entry edit observer
|
|
||||||
val entryEditObserver = Observer[fahrtenbuch.model.Entry] { entry =>
|
|
||||||
entryManager.upsertEntry(entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create app component
|
|
||||||
val appComponent = AppComponent(
|
|
||||||
allEntriesVar.signal,
|
|
||||||
onlineStatusVar.signal,
|
|
||||||
entryEditObserver
|
|
||||||
)
|
|
||||||
|
|
||||||
// Render the app
|
|
||||||
renderOnDomContentLoaded(
|
|
||||||
dom.document.getElementById("app"),
|
|
||||||
appComponent.render()
|
|
||||||
)
|
|
||||||
|
|
||||||
println(s"Browser app initialized with room ID: $roomId")
|
|
||||||
println(s"My peer ID: ${sync.getSelfId()}")
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
package fahrtenbuch.storage
|
|
||||||
|
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
|
||||||
import fahrtenbuch.storage.StorageInterface
|
|
||||||
import org.getshaka.nativeconverter.NativeConverter
|
|
||||||
import org.scalablytyped.runtime.StringDictionary
|
|
||||||
import typings.dexie.mod.Dexie
|
|
||||||
import typings.dexie.mod.Observable
|
|
||||||
import typings.dexie.mod.Table
|
|
||||||
import typings.dexie.mod.liveQuery
|
|
||||||
import rdts.base.Lattice
|
|
||||||
|
|
||||||
import scala.concurrent.{Future, ExecutionContext}
|
|
||||||
import scala.scalajs.js
|
|
||||||
import scala.util.{Success, Failure}
|
|
||||||
|
|
||||||
/** Browser-specific implementation of StorageInterface using Dexie (IndexedDB)
|
|
||||||
*/
|
|
||||||
class DexieStorage()(using ExecutionContext) extends StorageInterface {
|
|
||||||
|
|
||||||
private val schemaVersion = 1.3
|
|
||||||
|
|
||||||
private val dexieDB: Dexie = new Dexie.^("fahrtenbuch")
|
|
||||||
dexieDB
|
|
||||||
.version(schemaVersion)
|
|
||||||
.stores(
|
|
||||||
StringDictionary(
|
|
||||||
("entries", "id")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
private val entriesTable: Table[js.Any, String, js.Any] =
|
|
||||||
dexieDB.table("entries")
|
|
||||||
|
|
||||||
// Observable for live queries
|
|
||||||
private val entriesObservable: Observable[Future[Seq[Entry]]] =
|
|
||||||
liveQuery(() => getAllEntries())
|
|
||||||
|
|
||||||
private var changeCallbacks: List[Seq[Entry] => Unit] = List.empty
|
|
||||||
|
|
||||||
// Set up live query subscription
|
|
||||||
entriesObservable.subscribe(entries =>
|
|
||||||
entries.onComplete {
|
|
||||||
case Failure(exception) =>
|
|
||||||
println(s"Failed to get entries from db: $exception")
|
|
||||||
case Success(entrySeq) =>
|
|
||||||
changeCallbacks.foreach(_(entrySeq))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
override def getEntry(id: EntryId): Future[Option[Entry]] = {
|
|
||||||
entriesTable
|
|
||||||
.get(id.delegate)
|
|
||||||
.toFuture
|
|
||||||
.map(_.toOption.map(NativeConverter[Entry].fromNative(_)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getAllEntries(): Future[Seq[Entry]] = {
|
|
||||||
entriesTable.toArray().toFuture.map { entriesJsArray =>
|
|
||||||
entriesJsArray
|
|
||||||
.map(NativeConverter[Entry].fromNative(_))
|
|
||||||
.toSeq
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def upsertEntry(entry: Entry): Future[Unit] = {
|
|
||||||
val future = for {
|
|
||||||
oldEntry <- getEntry(entry.id)
|
|
||||||
mergedEntry = oldEntry match {
|
|
||||||
case Some(existing) =>
|
|
||||||
Lattice[Entry].merge(entry, existing)
|
|
||||||
case None =>
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
_ <- entriesTable.put(mergedEntry.toNative).toFuture
|
|
||||||
} yield ()
|
|
||||||
|
|
||||||
future.recover { case exception =>
|
|
||||||
println(s"Failed to write entry to db: $exception")
|
|
||||||
throw exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def deleteEntry(id: EntryId): Future[Unit] = {
|
|
||||||
entriesTable.delete(id.delegate).toFuture.map(_ => ())
|
|
||||||
}
|
|
||||||
|
|
||||||
override def clearAll(): Future[Unit] = {
|
|
||||||
entriesTable.clear().toFuture.map(_ => ())
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onEntriesChanged(callback: Seq[Entry] => Unit): Unit = {
|
|
||||||
changeCallbacks = callback :: changeCallbacks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
package fahrtenbuch.sync
|
|
||||||
|
|
||||||
import fahrtenbuch.model.Entry
|
|
||||||
import fahrtenbuch.sync.SyncInterface
|
|
||||||
import org.getshaka.nativeconverter.NativeConverter
|
|
||||||
import org.scalajs.dom
|
|
||||||
import org.scalajs.dom.{RTCConfiguration, RTCIceServer, RTCPeerConnection}
|
|
||||||
import typings.trystero.mod.{
|
|
||||||
ActionProgress,
|
|
||||||
ActionReceiver,
|
|
||||||
ActionSender,
|
|
||||||
BaseRoomConfig,
|
|
||||||
RelayConfig,
|
|
||||||
Room,
|
|
||||||
TurnConfig,
|
|
||||||
joinRoom,
|
|
||||||
selfId
|
|
||||||
}
|
|
||||||
|
|
||||||
import scala.scalajs.js
|
|
||||||
import scala.scalajs.js.JSConverters.*
|
|
||||||
|
|
||||||
/** Browser-specific implementation of SyncInterface using Trystero for WebRTC
|
|
||||||
* peer-to-peer communication
|
|
||||||
*/
|
|
||||||
class TrysteroSync(roomId: String) extends SyncInterface {
|
|
||||||
|
|
||||||
// Configure TURN/STUN servers
|
|
||||||
private val eturn = new RTCIceServer:
|
|
||||||
urls = js.Array(
|
|
||||||
"stun:relay1.expressturn.com:443",
|
|
||||||
"turn:relay1.expressturn.com:3478",
|
|
||||||
"turn:relay1.expressturn.com:443"
|
|
||||||
)
|
|
||||||
username = "efMS8M021S1G8NJ8J7"
|
|
||||||
credential = "qrBXTlhKtCJDykOK"
|
|
||||||
|
|
||||||
private val tturn = new RTCIceServer:
|
|
||||||
urls = "stun:stun.t-online.de:3478"
|
|
||||||
|
|
||||||
private val rtcConf = new RTCConfiguration:
|
|
||||||
iceServers = js.Array(eturn, tturn)
|
|
||||||
|
|
||||||
private object MyConfig extends RelayConfig, BaseRoomConfig, TurnConfig {
|
|
||||||
var appId = "fahrtenbuch_149520"
|
|
||||||
rtcConfig = rtcConf
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize room connection
|
|
||||||
private val room: Room = joinRoom(MyConfig, roomId)
|
|
||||||
println(s"Joining room $roomId")
|
|
||||||
|
|
||||||
// Track connected peers
|
|
||||||
private var connectedPeers: List[(String, RTCPeerConnection)] = List.empty
|
|
||||||
private var peerJoinCallbacks: List[String => Unit] = List.empty
|
|
||||||
private var peerLeaveCallbacks: List[String => Unit] = List.empty
|
|
||||||
|
|
||||||
// Set up peer join/leave handlers
|
|
||||||
room.onPeerJoin { peerId =>
|
|
||||||
println(s"$peerId joined")
|
|
||||||
updatePeerList()
|
|
||||||
peerJoinCallbacks.foreach(_(peerId))
|
|
||||||
}
|
|
||||||
|
|
||||||
room.onPeerLeave { peerId =>
|
|
||||||
println(s"$peerId left")
|
|
||||||
updatePeerList()
|
|
||||||
peerLeaveCallbacks.foreach(_(peerId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up entry action for sending/receiving entries
|
|
||||||
private val entryAction: js.Tuple3[ActionSender[js.Any], ActionReceiver[
|
|
||||||
js.Any
|
|
||||||
], ActionProgress] = room.makeAction[js.Any]("entry")
|
|
||||||
|
|
||||||
private val entrySender: ActionSender[js.Any] = entryAction._1
|
|
||||||
private val entryReceiver: ActionReceiver[js.Any] = entryAction._2
|
|
||||||
|
|
||||||
private var receiveCallbacks: List[Entry => Unit] = List.empty
|
|
||||||
|
|
||||||
// Set up entry receiver
|
|
||||||
entryReceiver((data: js.Any, peerId: String, metaData) => {
|
|
||||||
val incoming = NativeConverter[Entry].fromNative(data)
|
|
||||||
receiveCallbacks.foreach(_(incoming))
|
|
||||||
// Update peer list when receiving entries (indicates active connection)
|
|
||||||
updatePeerList()
|
|
||||||
})
|
|
||||||
|
|
||||||
private def updatePeerList(): Unit = {
|
|
||||||
connectedPeers = room.getPeers().toList
|
|
||||||
println(s"Connected peers: $connectedPeers")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize peer list
|
|
||||||
updatePeerList()
|
|
||||||
|
|
||||||
// SyncInterface implementation
|
|
||||||
override def sendEntry(entry: Entry): Unit = {
|
|
||||||
entrySender(entry.toNative)
|
|
||||||
}
|
|
||||||
|
|
||||||
override def sendEntry(entry: Entry, targetPeers: List[String]): Unit = {
|
|
||||||
if (targetPeers.isEmpty) {
|
|
||||||
sendEntry(entry)
|
|
||||||
} else {
|
|
||||||
entrySender(data = entry.toNative, targetPeers = targetPeers.toJSArray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onReceiveEntry(callback: Entry => Unit): Unit = {
|
|
||||||
receiveCallbacks = callback :: receiveCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onPeerJoin(callback: String => Unit): Unit = {
|
|
||||||
peerJoinCallbacks = callback :: peerJoinCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onPeerLeave(callback: String => Unit): Unit = {
|
|
||||||
peerLeaveCallbacks = callback :: peerLeaveCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getPeers(): List[String] = {
|
|
||||||
connectedPeers
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getSelfId(): String = {
|
|
||||||
selfId
|
|
||||||
}
|
|
||||||
|
|
||||||
override def hasConnectedPeers(): Boolean = {
|
|
||||||
connectedPeers.nonEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
163
build.sbt
163
build.sbt
|
|
@ -1,48 +1,85 @@
|
||||||
import org.scalajs.linker.interface.ModuleSplitStyle
|
import org.scalajs.linker.interface.ModuleSplitStyle
|
||||||
import org.portablescala.sbtplatformdeps.PlatformDepsPlugin.autoImport._
|
|
||||||
import sbtcrossproject.CrossPlugin.autoImport.{crossProject, CrossType}
|
|
||||||
|
|
||||||
|
ThisBuild / version := "0.1.0-SNAPSHOT"
|
||||||
ThisBuild / scalaVersion := "3.7.1"
|
ThisBuild / scalaVersion := "3.7.1"
|
||||||
ThisBuild / scalacOptions += "-Xfatal-warnings"
|
ThisBuild / scalacOptions ++= Seq("-Xfatal-warnings", "-Wunused:imports")
|
||||||
ThisBuild / scalacOptions += "-Wunused:imports"
|
|
||||||
|
|
||||||
// Root project
|
lazy val commonDependencies = Seq(
|
||||||
lazy val root = project
|
libraryDependencies += "org.scala-js" %%% "scalajs-dom" % "2.8.0",
|
||||||
.in(file("."))
|
libraryDependencies += "com.raquo" %%% "laminar" % "17.2.1",
|
||||||
.aggregate(sharedJs, browser, nodejs)
|
libraryDependencies += "de.tu-darmstadt.stg" %%% "rdts" % "0.37.0",
|
||||||
.settings(
|
libraryDependencies += "org.getshaka" %%% "native-converter" % "0.9.0"
|
||||||
name := "fahrtenbuch",
|
)
|
||||||
publish := {},
|
|
||||||
publishLocal := {}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Shared code project
|
lazy val core = project
|
||||||
lazy val shared = crossProject(JSPlatform)
|
.in(file("core"))
|
||||||
.crossType(CrossType.Pure)
|
|
||||||
.in(file("shared"))
|
|
||||||
.settings(
|
|
||||||
libraryDependencies ++= Seq(
|
|
||||||
"de.tu-darmstadt.stg" %%% "rdts" % "0.37.0",
|
|
||||||
"org.getshaka" %%% "native-converter" % "0.9.0"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.jsSettings(
|
|
||||||
// JS-specific settings for shared code
|
|
||||||
)
|
|
||||||
|
|
||||||
lazy val sharedJs = shared.js
|
|
||||||
|
|
||||||
// Browser-specific project
|
|
||||||
lazy val browser = project
|
|
||||||
.in(file("browser"))
|
|
||||||
.enablePlugins(
|
.enablePlugins(
|
||||||
ScalaJSPlugin,
|
ScalaJSPlugin,
|
||||||
ScalablyTypedConverterExternalNpmPlugin
|
ScalablyTypedConverterExternalNpmPlugin
|
||||||
)
|
)
|
||||||
.dependsOn(sharedJs)
|
|
||||||
.settings(
|
.settings(
|
||||||
name := "fahrtenbuch-browser",
|
// Tell Scala.js that this is an application with a main method
|
||||||
|
scalaJSUseMainModuleInitializer := true,
|
||||||
|
scalaJSLinkerConfig ~= {
|
||||||
|
_.withModuleKind(ModuleKind.ESModule)
|
||||||
|
.withModuleSplitStyle(
|
||||||
|
ModuleSplitStyle.SmallModulesFor(List("fahrtenbuch"))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// scalably typed config
|
||||||
|
// Ignore several Trystero dependencies in ScalablyTyped to avoid `stImport` errors
|
||||||
|
stIgnore := List(
|
||||||
|
"libp2p",
|
||||||
|
"firebase",
|
||||||
|
"@supabase/supabase-js",
|
||||||
|
"@mdi/font",
|
||||||
|
"bulma",
|
||||||
|
"@types/node"
|
||||||
|
),
|
||||||
|
externalNpm := baseDirectory.value,
|
||||||
|
commonDependencies
|
||||||
|
)
|
||||||
|
|
||||||
|
lazy val node = project
|
||||||
|
.in(file("node"))
|
||||||
|
.dependsOn(core)
|
||||||
|
.enablePlugins(
|
||||||
|
ScalaJSPlugin,
|
||||||
|
ScalablyTypedConverterExternalNpmPlugin
|
||||||
|
)
|
||||||
|
.settings(
|
||||||
|
// Tell Scala.js that this is an application with a main method
|
||||||
|
scalaJSUseMainModuleInitializer := true,
|
||||||
|
scalaJSLinkerConfig ~= {
|
||||||
|
_.withModuleKind(ModuleKind.ESModule)
|
||||||
|
.withModuleSplitStyle(
|
||||||
|
ModuleSplitStyle.SmallModulesFor(List("fahrtenbuch"))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
// scalably typed config
|
||||||
|
// Ignore several Trystero dependencies in ScalablyTyped to avoid `stImport` errors
|
||||||
|
stIgnore := List(
|
||||||
|
"libp2p",
|
||||||
|
"firebase",
|
||||||
|
"@supabase/supabase-js",
|
||||||
|
"@mdi/font",
|
||||||
|
"bulma",
|
||||||
|
"@types/node"
|
||||||
|
),
|
||||||
|
externalNpm := baseDirectory.value,
|
||||||
|
commonDependencies
|
||||||
|
)
|
||||||
|
|
||||||
|
lazy val fahrtenbuch = project
|
||||||
|
.in(file("."))
|
||||||
|
.dependsOn(core)
|
||||||
|
.enablePlugins(
|
||||||
|
ScalaJSPlugin,
|
||||||
|
ScalablyTypedConverterExternalNpmPlugin
|
||||||
|
)
|
||||||
|
.settings(
|
||||||
// Tell Scala.js that this is an application with a main method
|
// Tell Scala.js that this is an application with a main method
|
||||||
scalaJSUseMainModuleInitializer := true,
|
scalaJSUseMainModuleInitializer := true,
|
||||||
|
|
||||||
|
|
@ -53,15 +90,14 @@ lazy val browser = project
|
||||||
"firebase",
|
"firebase",
|
||||||
"@supabase/supabase-js",
|
"@supabase/supabase-js",
|
||||||
"@mdi/font",
|
"@mdi/font",
|
||||||
"bulma",
|
"bulma"
|
||||||
"node"
|
|
||||||
),
|
),
|
||||||
externalNpm := baseDirectory.value.getParentFile,
|
externalNpm := baseDirectory.value,
|
||||||
|
|
||||||
/* Configure Scala.js to emit modules in the optimal way to
|
/* Configure Scala.js to emit modules in the optimal way to
|
||||||
* connect to Vite's incremental reload.
|
* connect to Vite's incremental reload.
|
||||||
* - emit ECMAScript modules
|
* - emit ECMAScript modules
|
||||||
* - emit as many small modules as possible for classes in the "fahrtenbuch" package
|
* - emit as many small modules as possible for classes in the "livechart" package
|
||||||
* - emit as few (large) modules as possible for all other classes
|
* - emit as few (large) modules as possible for all other classes
|
||||||
* (in particular, for the standard library)
|
* (in particular, for the standard library)
|
||||||
*/
|
*/
|
||||||
|
|
@ -72,53 +108,8 @@ lazy val browser = project
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Browser-specific dependencies */
|
/* Depend on the scalajs-dom library.
|
||||||
libraryDependencies ++= Seq(
|
* It provides static types for the browser DOM APIs.
|
||||||
"org.scala-js" %%% "scalajs-dom" % "2.8.0",
|
|
||||||
"com.raquo" %%% "laminar" % "17.2.1",
|
|
||||||
"de.tu-darmstadt.stg" %%% "rdts" % "0.37.0",
|
|
||||||
"org.getshaka" %%% "native-converter" % "0.9.0"
|
|
||||||
),
|
|
||||||
|
|
||||||
// Output directory for browser build
|
|
||||||
Compile / fastOptJS / crossTarget := baseDirectory.value.getParentFile / "dist" / "browser",
|
|
||||||
Compile / fullOptJS / crossTarget := baseDirectory.value.getParentFile / "dist" / "browser"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Node.js-specific project
|
|
||||||
lazy val nodejs = project
|
|
||||||
.in(file("nodejs"))
|
|
||||||
.enablePlugins(ScalaJSPlugin)
|
|
||||||
.dependsOn(sharedJs)
|
|
||||||
.settings(
|
|
||||||
name := "fahrtenbuch-nodejs",
|
|
||||||
|
|
||||||
// Tell Scala.js that this is an application with a main method
|
|
||||||
scalaJSUseMainModuleInitializer := true,
|
|
||||||
|
|
||||||
/* Configure Scala.js for Node.js
|
|
||||||
* - emit CommonJS modules for Node.js compatibility
|
|
||||||
* - optimize for Node.js runtime
|
|
||||||
*/
|
*/
|
||||||
scalaJSLinkerConfig ~= {
|
commonDependencies
|
||||||
_.withModuleKind(ModuleKind.CommonJSModule)
|
|
||||||
.withModuleSplitStyle(ModuleSplitStyle.FewestModules)
|
|
||||||
},
|
|
||||||
|
|
||||||
/* Node.js-specific dependencies */
|
|
||||||
libraryDependencies ++= Seq(
|
|
||||||
"de.tu-darmstadt.stg" %%% "rdts" % "0.37.0",
|
|
||||||
"org.getshaka" %%% "native-converter" % "0.9.0"
|
|
||||||
),
|
|
||||||
|
|
||||||
// Output directory for Node.js build
|
|
||||||
Compile / fastOptJS / crossTarget := baseDirectory.value.getParentFile / "dist" / "nodejs",
|
|
||||||
Compile / fullOptJS / crossTarget := baseDirectory.value.getParentFile / "dist" / "nodejs",
|
|
||||||
|
|
||||||
// Use .cjs extension for CommonJS compatibility
|
|
||||||
Compile / fastOptJS / artifactPath := (Compile / fastOptJS / crossTarget).value / "main.cjs",
|
|
||||||
Compile / fullOptJS / artifactPath := (Compile / fullOptJS / crossTarget).value / "main.cjs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Global settings
|
|
||||||
Global / onChangedBuildSource := ReloadOnSourceChanges
|
|
||||||
|
|
|
||||||
4789
core/package-lock.json
generated
Normal file
4789
core/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
20
core/package.json
Normal file
20
core/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "fahrtenbuch-core",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host",
|
||||||
|
"build": "vite build",
|
||||||
|
"deploy": "vite build --base=$BASE_PATH",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@scala-js/vite-plugin-scalajs": "^1.0.0",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"vite": "^6.2.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"trystero": "^0.22.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
[]
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
package fahrtenbuch
|
|
||||||
|
|
||||||
import fahrtenbuch.core.EntryManager
|
|
||||||
import fahrtenbuch.storage.FileStorage
|
|
||||||
import fahrtenbuch.sync.WebSocketSync
|
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
|
||||||
import rdts.datatypes.LastWriterWins
|
|
||||||
|
|
||||||
import scala.concurrent.ExecutionContext.Implicits.global
|
|
||||||
|
|
||||||
import scala.scalajs.js
|
|
||||||
import scala.util.{Success, Failure}
|
|
||||||
|
|
||||||
@main
|
|
||||||
def NodeMain(args: String*): Unit = {
|
|
||||||
println("Starting Fahrtenbuch Node.js application...")
|
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
val config = parseArgs(args.toList)
|
|
||||||
|
|
||||||
// Initialize platform-specific implementations
|
|
||||||
val storage = new FileStorage(config.dataDir)
|
|
||||||
val sync = new WebSocketSync(config.port)
|
|
||||||
val entryManager = new EntryManager(storage, sync)
|
|
||||||
|
|
||||||
// Connect to peers if specified
|
|
||||||
config.peers.foreach { peer =>
|
|
||||||
val Array(host, port) = peer.split(":")
|
|
||||||
sync.connectToPeer(host, port.toInt)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up graceful shutdown
|
|
||||||
val process = js.Dynamic.global.process
|
|
||||||
process.on(
|
|
||||||
"SIGINT",
|
|
||||||
() => {
|
|
||||||
println("\nShutting down gracefully...")
|
|
||||||
sync.shutdown()
|
|
||||||
process.exit(0)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Display initial statistics
|
|
||||||
entryManager.getAllEntries().onComplete {
|
|
||||||
case Success(entries) =>
|
|
||||||
println(s"Loaded ${entries.size} entries from storage")
|
|
||||||
displayStats(entries)
|
|
||||||
case Failure(ex) =>
|
|
||||||
println(s"Failed to load entries: $ex")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start command line interface
|
|
||||||
startCLI(entryManager)
|
|
||||||
|
|
||||||
println(s"Node.js server started on port ${config.port}")
|
|
||||||
println(s"Data directory: ${config.dataDir}")
|
|
||||||
println(s"Peer ID: ${sync.getSelfId()}")
|
|
||||||
println("Type 'help' for available commands")
|
|
||||||
}
|
|
||||||
|
|
||||||
case class Config(
|
|
||||||
port: Int = 8080,
|
|
||||||
dataDir: String = "./data",
|
|
||||||
peers: List[String] = List.empty
|
|
||||||
)
|
|
||||||
|
|
||||||
def parseArgs(args: List[String]): Config = {
|
|
||||||
def parse(args: List[String], config: Config): Config = {
|
|
||||||
args match {
|
|
||||||
case "--port" :: port :: rest =>
|
|
||||||
parse(rest, config.copy(port = port.toInt))
|
|
||||||
case "--data-dir" :: dir :: rest =>
|
|
||||||
parse(rest, config.copy(dataDir = dir))
|
|
||||||
case "--connect" :: peer :: rest =>
|
|
||||||
parse(rest, config.copy(peers = peer :: config.peers))
|
|
||||||
case "--help" :: _ =>
|
|
||||||
printHelp()
|
|
||||||
js.Dynamic.global.process.exit(0)
|
|
||||||
config
|
|
||||||
case Nil =>
|
|
||||||
config
|
|
||||||
case unknown :: rest =>
|
|
||||||
println(s"Unknown argument: $unknown")
|
|
||||||
parse(rest, config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
parse(args, Config())
|
|
||||||
}
|
|
||||||
|
|
||||||
def printHelp(): Unit = {
|
|
||||||
println("""
|
|
||||||
Usage: node main.js [options]
|
|
||||||
|
|
||||||
Options:
|
|
||||||
--port <port> WebSocket server port (default: 8080)
|
|
||||||
--data-dir <dir> Data storage directory (default: ./data)
|
|
||||||
--connect <host:port> Connect to peer at host:port
|
|
||||||
--help Show this help message
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
node main.js --port 8080
|
|
||||||
node main.js --data-dir ./my-data --connect localhost:8081
|
|
||||||
""")
|
|
||||||
}
|
|
||||||
|
|
||||||
def displayStats(entries: Seq[Entry]): Unit = {
|
|
||||||
if (entries.nonEmpty) {
|
|
||||||
val totalDistance = entries.map(_.distance).sum
|
|
||||||
val totalCost = entries.map(_.costTotal).sum
|
|
||||||
val paidEntries = entries.count(_.paid.payload)
|
|
||||||
val unpaidEntries = entries.size - paidEntries
|
|
||||||
|
|
||||||
println(s"\nStatistics:")
|
|
||||||
println(s" Total entries: ${entries.size}")
|
|
||||||
println(s" Total distance: ${totalDistance} km")
|
|
||||||
println(s" Total cost: €${totalCost}")
|
|
||||||
println(s" Paid entries: $paidEntries")
|
|
||||||
println(s" Unpaid entries: $unpaidEntries")
|
|
||||||
println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def startCLI(entryManager: EntryManager): Unit = {
|
|
||||||
val readline = js.Dynamic.global.require("readline")
|
|
||||||
val rl = readline.createInterface(
|
|
||||||
js.Dynamic.literal(
|
|
||||||
input = js.Dynamic.global.process.stdin,
|
|
||||||
output = js.Dynamic.global.process.stdout,
|
|
||||||
prompt = "fahrtenbuch> "
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
rl.prompt()
|
|
||||||
|
|
||||||
rl.on(
|
|
||||||
"line",
|
|
||||||
(line: String) => {
|
|
||||||
handleCommand(line.trim, entryManager)
|
|
||||||
rl.prompt()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
rl.on(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
println("Goodbye!")
|
|
||||||
js.Dynamic.global.process.exit(0)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
def handleCommand(command: String, entryManager: EntryManager): Unit = {
|
|
||||||
val parts = command.split(" ").toList
|
|
||||||
|
|
||||||
parts match {
|
|
||||||
case "help" :: _ =>
|
|
||||||
printCommands()
|
|
||||||
|
|
||||||
case "list" :: _ =>
|
|
||||||
entryManager.getAllEntries().foreach { entries =>
|
|
||||||
if (entries.isEmpty) {
|
|
||||||
println("No entries found")
|
|
||||||
} else {
|
|
||||||
println(s"\nEntries (${entries.size}):")
|
|
||||||
entries.foreach(printEntry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "stats" :: _ =>
|
|
||||||
entryManager.getAllEntries().foreach(displayStats)
|
|
||||||
|
|
||||||
case "peers" :: _ =>
|
|
||||||
val peers = entryManager.getConnectedPeers()
|
|
||||||
if (peers.isEmpty) {
|
|
||||||
println("No connected peers")
|
|
||||||
} else {
|
|
||||||
println(s"Connected peers: ${peers.mkString(", ")}")
|
|
||||||
}
|
|
||||||
|
|
||||||
case "add" :: startKm :: endKm :: driver :: animal :: rest =>
|
|
||||||
try {
|
|
||||||
val entry = Entry(
|
|
||||||
id = EntryId.gen(),
|
|
||||||
startKm = LastWriterWins.now(BigDecimal(startKm)),
|
|
||||||
endKm = LastWriterWins.now(BigDecimal(endKm)),
|
|
||||||
driver = LastWriterWins.now(driver),
|
|
||||||
animal = LastWriterWins.now(animal),
|
|
||||||
paid = LastWriterWins.now(false)
|
|
||||||
)
|
|
||||||
|
|
||||||
entryManager.upsertEntry(entry).onComplete {
|
|
||||||
case Success(_) =>
|
|
||||||
println(s"Entry added successfully: ${entry.id.delegate}")
|
|
||||||
case Failure(ex) =>
|
|
||||||
println(s"Failed to add entry: $ex")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
println(s"Invalid entry format: $ex")
|
|
||||||
println("Usage: add <startKm> <endKm> <driver> <animal>")
|
|
||||||
}
|
|
||||||
|
|
||||||
case "clear" :: _ =>
|
|
||||||
entryManager.clearAll().onComplete {
|
|
||||||
case Success(_) =>
|
|
||||||
println("All entries cleared")
|
|
||||||
case Failure(ex) =>
|
|
||||||
println(s"Failed to clear entries: $ex")
|
|
||||||
}
|
|
||||||
|
|
||||||
case "exit" :: _ | "quit" :: _ =>
|
|
||||||
js.Dynamic.global.process.exit(0)
|
|
||||||
|
|
||||||
case Nil =>
|
|
||||||
// Empty command, do nothing
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
println(s"Unknown command: $command")
|
|
||||||
println("Type 'help' for available commands")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def printCommands(): Unit = {
|
|
||||||
println("""
|
|
||||||
Available commands:
|
|
||||||
help Show this help message
|
|
||||||
list List all entries
|
|
||||||
stats Show statistics
|
|
||||||
peers Show connected peers
|
|
||||||
add <start> <end> <driver> <animal> Add new entry
|
|
||||||
clear Clear all entries
|
|
||||||
exit, quit Exit the application
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
add 1000 1050 John Dog
|
|
||||||
list
|
|
||||||
stats
|
|
||||||
""")
|
|
||||||
}
|
|
||||||
|
|
||||||
def printEntry(entry: Entry): Unit = {
|
|
||||||
val paid = if (entry.paid.payload) "✓" else "✗"
|
|
||||||
println(
|
|
||||||
s" ${entry.id.delegate.take(8)} | ${entry.startKm.payload} -> ${entry.endKm.payload} km | ${entry.driver.payload} | ${entry.animal.payload} | €${entry.costTotal} | $paid"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
package fahrtenbuch.storage
|
|
||||||
|
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
|
||||||
import fahrtenbuch.storage.StorageInterface
|
|
||||||
import org.getshaka.nativeconverter.NativeConverter
|
|
||||||
import rdts.base.Lattice
|
|
||||||
|
|
||||||
import scala.concurrent.{Future, ExecutionContext, Promise}
|
|
||||||
import scala.scalajs.js
|
|
||||||
import scala.scalajs.js.JSON
|
|
||||||
import scala.scalajs.js.DynamicImplicits.truthValue
|
|
||||||
|
|
||||||
/** Node.js-specific implementation of StorageInterface using file system
|
|
||||||
*/
|
|
||||||
class FileStorage(storageDir: String = "./data")(using ExecutionContext)
|
|
||||||
extends StorageInterface {
|
|
||||||
|
|
||||||
private val fs = js.Dynamic.global.require("fs")
|
|
||||||
private val path = js.Dynamic.global.require("path")
|
|
||||||
|
|
||||||
private val entriesFile = path.join(storageDir, "entries.json")
|
|
||||||
private var changeCallbacks: List[Seq[Entry] => Unit] = List.empty
|
|
||||||
|
|
||||||
// Ensure storage directory exists
|
|
||||||
init()
|
|
||||||
|
|
||||||
private def init(): Unit = {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(storageDir)) {
|
|
||||||
fs.mkdirSync(storageDir, js.Dynamic.literal(recursive = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create empty entries file if it doesn't exist
|
|
||||||
if (!fs.existsSync(entriesFile)) {
|
|
||||||
fs.writeFileSync(entriesFile, JSON.stringify(js.Array()))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
println(s"Failed to initialize file storage: $ex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getEntry(id: EntryId): Future[Option[Entry]] = {
|
|
||||||
getAllEntries().map(_.find(_.id == id))
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getAllEntries(): Future[Seq[Entry]] = {
|
|
||||||
val promise = Promise[Seq[Entry]]()
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.readFile(
|
|
||||||
entriesFile,
|
|
||||||
"utf8",
|
|
||||||
(err: js.Any, data: String) => {
|
|
||||||
if (err != null) {
|
|
||||||
promise.failure(new Exception(s"Failed to read entries file: $err"))
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
val jsonArray = JSON.parse(data).asInstanceOf[js.Array[js.Any]]
|
|
||||||
val entries =
|
|
||||||
jsonArray.map(NativeConverter[Entry].fromNative(_)).toSeq
|
|
||||||
promise.success(entries)
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
promise.failure(new Exception(s"Failed to parse entries: $ex"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
promise.failure(ex)
|
|
||||||
}
|
|
||||||
|
|
||||||
promise.future
|
|
||||||
}
|
|
||||||
|
|
||||||
override def upsertEntry(entry: Entry): Future[Unit] = {
|
|
||||||
for {
|
|
||||||
existingEntries <- getAllEntries()
|
|
||||||
existingEntry = existingEntries.find(_.id == entry.id)
|
|
||||||
mergedEntry = existingEntry match {
|
|
||||||
case Some(existing) =>
|
|
||||||
Lattice[Entry].merge(entry, existing)
|
|
||||||
case None =>
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
updatedEntries = existingEntries.filterNot(
|
|
||||||
_.id == entry.id
|
|
||||||
) :+ mergedEntry
|
|
||||||
_ <- saveEntries(updatedEntries)
|
|
||||||
} yield {
|
|
||||||
// Notify listeners
|
|
||||||
changeCallbacks.foreach(_(updatedEntries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def deleteEntry(id: EntryId): Future[Unit] = {
|
|
||||||
for {
|
|
||||||
existingEntries <- getAllEntries()
|
|
||||||
updatedEntries = existingEntries.filterNot(_.id == id)
|
|
||||||
_ <- saveEntries(updatedEntries)
|
|
||||||
} yield {
|
|
||||||
// Notify listeners
|
|
||||||
changeCallbacks.foreach(_(updatedEntries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def clearAll(): Future[Unit] = {
|
|
||||||
saveEntries(Seq.empty).map { _ =>
|
|
||||||
// Notify listeners
|
|
||||||
changeCallbacks.foreach(_(Seq.empty))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onEntriesChanged(callback: Seq[Entry] => Unit): Unit = {
|
|
||||||
changeCallbacks = callback :: changeCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
private def saveEntries(entries: Seq[Entry]): Future[Unit] = {
|
|
||||||
val promise = Promise[Unit]()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val jsonArray = js.Array(entries.map(_.toNative)*)
|
|
||||||
val jsonString = JSON.stringify(jsonArray, space = 2)
|
|
||||||
|
|
||||||
fs.writeFile(
|
|
||||||
entriesFile,
|
|
||||||
jsonString,
|
|
||||||
"utf8",
|
|
||||||
(err: js.Any) => {
|
|
||||||
if (err != null) {
|
|
||||||
promise.failure(
|
|
||||||
new Exception(s"Failed to write entries file: $err")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
promise.success(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
promise.failure(ex)
|
|
||||||
}
|
|
||||||
|
|
||||||
promise.future
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
package fahrtenbuch.sync
|
|
||||||
|
|
||||||
import fahrtenbuch.model.Entry
|
|
||||||
import fahrtenbuch.sync.SyncInterface
|
|
||||||
import org.getshaka.nativeconverter.NativeConverter
|
|
||||||
import scala.concurrent.ExecutionContext
|
|
||||||
import scala.scalajs.js
|
|
||||||
import scala.scalajs.js.JSON
|
|
||||||
|
|
||||||
/** Node.js-specific implementation of SyncInterface using WebSockets for
|
|
||||||
* peer-to-peer communication
|
|
||||||
*/
|
|
||||||
class WebSocketSync(port: Int = 8080)(using ExecutionContext)
|
|
||||||
extends SyncInterface {
|
|
||||||
|
|
||||||
private val WebSocketServer = js.Dynamic.global.require("ws").Server
|
|
||||||
private val WebSocketClient = js.Dynamic.global.require("ws")
|
|
||||||
private val crypto = js.Dynamic.global.require("crypto")
|
|
||||||
|
|
||||||
// Generate a unique peer ID for this instance
|
|
||||||
private val selfId: String = crypto.randomUUID().asInstanceOf[String]
|
|
||||||
|
|
||||||
// Track connected peers
|
|
||||||
private var connectedPeers: Map[String, js.Dynamic] = Map.empty
|
|
||||||
private var peerJoinCallbacks: List[String => Unit] = List.empty
|
|
||||||
private var peerLeaveCallbacks: List[String => Unit] = List.empty
|
|
||||||
private var receiveCallbacks: List[Entry => Unit] = List.empty
|
|
||||||
|
|
||||||
// WebSocket server for accepting connections
|
|
||||||
private val wss =
|
|
||||||
js.Dynamic.newInstance(WebSocketServer)(js.Dynamic.literal(port = port))
|
|
||||||
|
|
||||||
// Set up WebSocket server
|
|
||||||
init()
|
|
||||||
|
|
||||||
private def init(): Unit = {
|
|
||||||
println(s"Starting WebSocket server on port $port")
|
|
||||||
println(s"My peer ID: $selfId")
|
|
||||||
|
|
||||||
wss.on(
|
|
||||||
"connection",
|
|
||||||
(ws: js.Dynamic) => {
|
|
||||||
println("New WebSocket connection established")
|
|
||||||
|
|
||||||
// Send our peer ID to the new connection
|
|
||||||
val handshakeMsg = js.Dynamic.literal(
|
|
||||||
`type` = "handshake",
|
|
||||||
peerId = selfId
|
|
||||||
)
|
|
||||||
ws.send(JSON.stringify(handshakeMsg))
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"message",
|
|
||||||
(data: js.Any) => {
|
|
||||||
handleMessage(ws, data.toString())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
handlePeerDisconnect(ws)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"error",
|
|
||||||
(error: js.Any) => {
|
|
||||||
println(s"WebSocket error: $error")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
wss.on(
|
|
||||||
"error",
|
|
||||||
(error: js.Any) => {
|
|
||||||
println(s"WebSocket server error: $error")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private def handleMessage(ws: js.Dynamic, message: String): Unit = {
|
|
||||||
try {
|
|
||||||
val parsed = JSON.parse(message)
|
|
||||||
val msgType = parsed.`type`.asInstanceOf[String]
|
|
||||||
|
|
||||||
msgType match {
|
|
||||||
case "handshake" =>
|
|
||||||
val peerId = parsed.peerId.asInstanceOf[String]
|
|
||||||
connectedPeers = connectedPeers + (peerId -> ws)
|
|
||||||
ws.peerId = peerId
|
|
||||||
println(s"Peer $peerId connected")
|
|
||||||
peerJoinCallbacks.foreach(_(peerId))
|
|
||||||
|
|
||||||
case "entry" =>
|
|
||||||
val entryData = parsed.entry
|
|
||||||
val entry = NativeConverter[Entry].fromNative(entryData)
|
|
||||||
receiveCallbacks.foreach(_(entry))
|
|
||||||
|
|
||||||
case _ =>
|
|
||||||
println(s"Unknown message type: $msgType")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
println(s"Failed to handle message: $ex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private def handlePeerDisconnect(ws: js.Dynamic): Unit = {
|
|
||||||
val peerId = ws.peerId.asInstanceOf[String]
|
|
||||||
if (peerId != null) {
|
|
||||||
connectedPeers = connectedPeers - peerId
|
|
||||||
println(s"Peer $peerId disconnected")
|
|
||||||
peerLeaveCallbacks.foreach(_(peerId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncInterface implementation
|
|
||||||
override def sendEntry(entry: Entry): Unit = {
|
|
||||||
val message = js.Dynamic.literal(
|
|
||||||
`type` = "entry",
|
|
||||||
entry = entry.toNative
|
|
||||||
)
|
|
||||||
val messageStr = JSON.stringify(message)
|
|
||||||
|
|
||||||
connectedPeers.values.foreach { ws =>
|
|
||||||
try {
|
|
||||||
ws.send(messageStr)
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
println(s"Failed to send entry to peer: $ex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def sendEntry(entry: Entry, targetPeers: List[String]): Unit = {
|
|
||||||
if (targetPeers.isEmpty) {
|
|
||||||
sendEntry(entry)
|
|
||||||
} else {
|
|
||||||
val message = js.Dynamic.literal(
|
|
||||||
`type` = "entry",
|
|
||||||
entry = entry.toNative
|
|
||||||
)
|
|
||||||
val messageStr = JSON.stringify(message)
|
|
||||||
|
|
||||||
targetPeers.foreach { peerId =>
|
|
||||||
connectedPeers.get(peerId) match {
|
|
||||||
case Some(ws) =>
|
|
||||||
try {
|
|
||||||
ws.send(messageStr)
|
|
||||||
} catch {
|
|
||||||
case ex: Exception =>
|
|
||||||
println(s"Failed to send entry to peer $peerId: $ex")
|
|
||||||
}
|
|
||||||
case None =>
|
|
||||||
println(s"Peer $peerId not found in connected peers")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onReceiveEntry(callback: Entry => Unit): Unit = {
|
|
||||||
receiveCallbacks = callback :: receiveCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onPeerJoin(callback: String => Unit): Unit = {
|
|
||||||
peerJoinCallbacks = callback :: peerJoinCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def onPeerLeave(callback: String => Unit): Unit = {
|
|
||||||
peerLeaveCallbacks = callback :: peerLeaveCallbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getPeers(): List[String] = {
|
|
||||||
connectedPeers.keys.toList
|
|
||||||
}
|
|
||||||
|
|
||||||
override def getSelfId(): String = {
|
|
||||||
selfId
|
|
||||||
}
|
|
||||||
|
|
||||||
override def hasConnectedPeers(): Boolean = {
|
|
||||||
connectedPeers.nonEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connect to another WebSocket server as a client
|
|
||||||
*/
|
|
||||||
def connectToPeer(host: String, port: Int): Unit = {
|
|
||||||
val ws = js.Dynamic.newInstance(WebSocketClient)(s"ws://$host:$port")
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"open",
|
|
||||||
() => {
|
|
||||||
println(s"Connected to peer at $host:$port")
|
|
||||||
|
|
||||||
// Send our handshake
|
|
||||||
val handshakeMsg = js.Dynamic.literal(
|
|
||||||
`type` = "handshake",
|
|
||||||
peerId = selfId
|
|
||||||
)
|
|
||||||
ws.send(JSON.stringify(handshakeMsg))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"message",
|
|
||||||
(data: js.Any) => {
|
|
||||||
handleMessage(ws, data.toString())
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"close",
|
|
||||||
() => {
|
|
||||||
handlePeerDisconnect(ws)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ws.on(
|
|
||||||
"error",
|
|
||||||
(error: js.Any) => {
|
|
||||||
println(s"WebSocket client error: $error")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shutdown the WebSocket server
|
|
||||||
*/
|
|
||||||
def shutdown(): Unit = {
|
|
||||||
wss.close()
|
|
||||||
println("WebSocket server shut down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
package.json
15
package.json
|
|
@ -5,15 +5,9 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "npm run build:browser && npm run build:nodejs",
|
"build": "vite build",
|
||||||
"build:browser": "sbt browser/fastOptJS && vite build",
|
"deploy": "vite build --base=$BASE_PATH",
|
||||||
"build:nodejs": "sbt nodejs/fastOptJS",
|
"preview": "vite preview"
|
||||||
"build:prod": "npm run build:browser:prod && npm run build:nodejs:prod",
|
|
||||||
"build:browser:prod": "sbt browser/fullOptJS && vite build",
|
|
||||||
"build:nodejs:prod": "sbt nodejs/fullOptJS",
|
|
||||||
"deploy": "npm run build:browser:prod --base=$BASE_PATH",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"start:nodejs": "node dist/nodejs/main.js"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@scala-js/vite-plugin-scalajs": "^1.0.0",
|
"@scala-js/vite-plugin-scalajs": "^1.0.0",
|
||||||
|
|
@ -24,7 +18,6 @@
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"bulma": "^1.0.4",
|
"bulma": "^1.0.4",
|
||||||
"dexie": "^4.0.10",
|
"dexie": "^4.0.10",
|
||||||
"trystero": "^0.21.6",
|
"trystero": "^0.21.6"
|
||||||
"ws": "^8.16.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0")
|
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0")
|
||||||
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta44")
|
addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta44")
|
||||||
addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
|
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
package fahrtenbuch.core
|
|
||||||
|
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
|
||||||
import fahrtenbuch.storage.StorageInterface
|
|
||||||
import fahrtenbuch.sync.SyncInterface
|
|
||||||
import rdts.base.Lattice
|
|
||||||
import scala.concurrent.{Future, ExecutionContext}
|
|
||||||
|
|
||||||
/** Core business logic for managing entries and synchronization across
|
|
||||||
* platforms. This class coordinates between storage and sync layers to provide
|
|
||||||
* a unified interface for entry operations.
|
|
||||||
*/
|
|
||||||
class EntryManager(
|
|
||||||
storage: StorageInterface,
|
|
||||||
sync: SyncInterface
|
|
||||||
)(using ExecutionContext) {
|
|
||||||
|
|
||||||
// Set up sync callbacks
|
|
||||||
init()
|
|
||||||
|
|
||||||
private def init(): Unit = {
|
|
||||||
// When we receive an entry from a peer, merge it with local storage
|
|
||||||
sync.onReceiveEntry { receivedEntry =>
|
|
||||||
mergeAndStore(receivedEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// When a peer joins, send them all our entries
|
|
||||||
sync.onPeerJoin { peerId =>
|
|
||||||
storage.getAllEntries().foreach { entries =>
|
|
||||||
entries.foreach { entry =>
|
|
||||||
sync.sendEntry(entry, List(peerId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a single entry by ID
|
|
||||||
*/
|
|
||||||
def getEntry(id: EntryId): Future[Option[Entry]] = {
|
|
||||||
storage.getEntry(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get all entries
|
|
||||||
*/
|
|
||||||
def getAllEntries(): Future[Seq[Entry]] = {
|
|
||||||
storage.getAllEntries()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add or update an entry. This will:
|
|
||||||
* 1. Merge with existing entry if it exists (using CRDT lattice) 2. Store
|
|
||||||
* the result locally 3. Broadcast to connected peers
|
|
||||||
*/
|
|
||||||
def upsertEntry(entry: Entry): Future[Unit] = {
|
|
||||||
for {
|
|
||||||
result <- mergeAndStore(entry)
|
|
||||||
_ = sync.sendEntry(entry) // Broadcast to all peers
|
|
||||||
} yield result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete an entry locally and broadcast the deletion
|
|
||||||
*/
|
|
||||||
def deleteEntry(id: EntryId): Future[Unit] = {
|
|
||||||
storage.deleteEntry(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear all entries from storage
|
|
||||||
*/
|
|
||||||
def clearAll(): Future[Unit] = {
|
|
||||||
storage.clearAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Register a callback for when entries change
|
|
||||||
*/
|
|
||||||
def onEntriesChanged(callback: Seq[Entry] => Unit): Unit = {
|
|
||||||
storage.onEntriesChanged(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get information about connected peers
|
|
||||||
*/
|
|
||||||
def getConnectedPeers(): List[String] = {
|
|
||||||
sync.getPeers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get our own peer ID
|
|
||||||
*/
|
|
||||||
def getSelfId(): String = {
|
|
||||||
sync.getSelfId()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if we have any connected peers
|
|
||||||
*/
|
|
||||||
def isOnline(): Boolean = {
|
|
||||||
sync.hasConnectedPeers()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Private helper to merge an entry with existing data and store it
|
|
||||||
*/
|
|
||||||
private def mergeAndStore(entry: Entry): Future[Unit] = {
|
|
||||||
storage
|
|
||||||
.getEntry(entry.id)
|
|
||||||
.flatMap { existingEntry =>
|
|
||||||
val mergedEntry = existingEntry match {
|
|
||||||
case Some(existing) =>
|
|
||||||
// Merge using CRDT lattice
|
|
||||||
Lattice[Entry].merge(entry, existing)
|
|
||||||
case None =>
|
|
||||||
entry
|
|
||||||
}
|
|
||||||
|
|
||||||
storage.upsertEntry(mergedEntry)
|
|
||||||
}
|
|
||||||
.recover { case ex =>
|
|
||||||
// Log error but don't fail the operation
|
|
||||||
println(s"Failed to merge and store entry ${entry.id}: $ex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
package fahrtenbuch.storage
|
|
||||||
|
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
|
||||||
import scala.concurrent.Future
|
|
||||||
|
|
||||||
/** Platform-agnostic interface for persisting entries. Implementations should
|
|
||||||
* handle the underlying storage mechanism (IndexedDB for browser, file system
|
|
||||||
* for Node.js, etc.)
|
|
||||||
*/
|
|
||||||
trait StorageInterface {
|
|
||||||
|
|
||||||
/** Get a single entry by ID
|
|
||||||
*/
|
|
||||||
def getEntry(id: EntryId): Future[Option[Entry]]
|
|
||||||
|
|
||||||
/** Get all entries from storage
|
|
||||||
*/
|
|
||||||
def getAllEntries(): Future[Seq[Entry]]
|
|
||||||
|
|
||||||
/** Insert or update an entry in storage. If the entry already exists, it
|
|
||||||
* should be merged using the CRDT lattice.
|
|
||||||
*/
|
|
||||||
def upsertEntry(entry: Entry): Future[Unit]
|
|
||||||
|
|
||||||
/** Delete an entry from storage
|
|
||||||
*/
|
|
||||||
def deleteEntry(id: EntryId): Future[Unit]
|
|
||||||
|
|
||||||
/** Clear all entries from storage
|
|
||||||
*/
|
|
||||||
def clearAll(): Future[Unit]
|
|
||||||
|
|
||||||
/** Register a callback for when entries change in storage
|
|
||||||
*/
|
|
||||||
def onEntriesChanged(callback: Seq[Entry] => Unit): Unit
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
package fahrtenbuch.sync
|
|
||||||
|
|
||||||
import fahrtenbuch.model.Entry
|
|
||||||
|
|
||||||
/** Platform-agnostic interface for synchronizing entries between peers.
|
|
||||||
* Implementations should handle the underlying transport mechanism (WebRTC for
|
|
||||||
* browser, WebSockets for Node.js, etc.)
|
|
||||||
*/
|
|
||||||
trait SyncInterface {
|
|
||||||
|
|
||||||
/** Send an entry to all connected peers
|
|
||||||
*/
|
|
||||||
def sendEntry(entry: Entry): Unit
|
|
||||||
|
|
||||||
/** Send an entry to specific peers
|
|
||||||
*/
|
|
||||||
def sendEntry(entry: Entry, targetPeers: List[String]): Unit
|
|
||||||
|
|
||||||
/** Register a callback to receive entries from peers
|
|
||||||
*/
|
|
||||||
def onReceiveEntry(callback: Entry => Unit): Unit
|
|
||||||
|
|
||||||
/** Register a callback for when a peer joins
|
|
||||||
*/
|
|
||||||
def onPeerJoin(callback: String => Unit): Unit
|
|
||||||
|
|
||||||
/** Register a callback for when a peer leaves
|
|
||||||
*/
|
|
||||||
def onPeerLeave(callback: String => Unit): Unit
|
|
||||||
|
|
||||||
/** Get the current list of connected peers
|
|
||||||
*/
|
|
||||||
def getPeers(): List[String]
|
|
||||||
|
|
||||||
/** Get this peer's ID
|
|
||||||
*/
|
|
||||||
def getSelfId(): String
|
|
||||||
|
|
||||||
/** Check if there are any connected peers
|
|
||||||
*/
|
|
||||||
def hasConnectedPeers(): Boolean = getPeers().nonEmpty
|
|
||||||
}
|
|
||||||
76
src/main/scala/fahrtenbuch/Database.scala
Normal file
76
src/main/scala/fahrtenbuch/Database.scala
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
package fahrtenbuch
|
||||||
|
|
||||||
|
import org.getshaka.nativeconverter.NativeConverter
|
||||||
|
import org.scalablytyped.runtime.StringDictionary
|
||||||
|
import typings.dexie.mod.Dexie
|
||||||
|
import typings.dexie.mod.Observable
|
||||||
|
import typings.dexie.mod.Table
|
||||||
|
import typings.dexie.mod.liveQuery
|
||||||
|
|
||||||
|
import scala.concurrent.ExecutionContext.Implicits.global
|
||||||
|
import scala.concurrent.Future
|
||||||
|
import scala.scalajs.js
|
||||||
|
|
||||||
|
import model.Entry
|
||||||
|
import model.EntryId
|
||||||
|
import rdts.base.Lattice
|
||||||
|
import scala.util.Failure
|
||||||
|
import scala.util.Success
|
||||||
|
|
||||||
|
object DexieDB {
|
||||||
|
|
||||||
|
private val schemaVersion = 1.3
|
||||||
|
|
||||||
|
private val dexieDB: Dexie = new Dexie.^("fahrtenbuch")
|
||||||
|
dexieDB
|
||||||
|
.version(schemaVersion)
|
||||||
|
.stores(
|
||||||
|
StringDictionary(
|
||||||
|
("entries", "id")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
private val entriesTable: Table[js.Any, String, js.Any] =
|
||||||
|
dexieDB.table("entries")
|
||||||
|
val entriesObservable: Observable[Future[Seq[Entry]]] =
|
||||||
|
liveQuery(() => getAllEntries())
|
||||||
|
|
||||||
|
def getEntry(id: EntryId): Future[Option[Entry]] =
|
||||||
|
entriesTable
|
||||||
|
.get(id.delegate)
|
||||||
|
.toFuture
|
||||||
|
.map(_.toOption.map(NativeConverter[Entry].fromNative(_)))
|
||||||
|
|
||||||
|
/** Inserts an entry into the database and merges it with an existing entry if
|
||||||
|
* it exists.
|
||||||
|
*
|
||||||
|
* @param entry
|
||||||
|
* The entry to be inserted or updated.
|
||||||
|
*/
|
||||||
|
def upsertEntry(entry: Entry): Unit = {
|
||||||
|
for {
|
||||||
|
oldEntry <- getEntry(entry.id)
|
||||||
|
newEntry = oldEntry match
|
||||||
|
case Some(old) =>
|
||||||
|
Lattice[Entry].merge(entry, old)
|
||||||
|
case _ => entry
|
||||||
|
result <- entriesTable.put(newEntry.toNative).toFuture
|
||||||
|
} yield {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}.onComplete {
|
||||||
|
case Failure(exception) =>
|
||||||
|
println(s"Failed to write entry to db: $exception")
|
||||||
|
case Success(value) => ()
|
||||||
|
}
|
||||||
|
|
||||||
|
def getAllEntries(): Future[Seq[Entry]] = {
|
||||||
|
entriesTable.toArray().toFuture.map { entriesJsArray =>
|
||||||
|
entriesJsArray
|
||||||
|
.map(
|
||||||
|
NativeConverter[Entry].fromNative(_)
|
||||||
|
)
|
||||||
|
.toSeq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main/scala/fahrtenbuch/Main.scala
Normal file
51
src/main/scala/fahrtenbuch/Main.scala
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package fahrtenbuch
|
||||||
|
|
||||||
|
import com.raquo.laminar.api.L.*
|
||||||
|
import fahrtenbuch.DexieDB.entriesObservable
|
||||||
|
import org.scalajs.dom
|
||||||
|
|
||||||
|
import scala.concurrent.ExecutionContext.Implicits.global
|
||||||
|
import scala.util.Failure
|
||||||
|
import scala.util.Success
|
||||||
|
|
||||||
|
import model.Entry
|
||||||
|
import components.AppComponent
|
||||||
|
|
||||||
|
@main
|
||||||
|
def Fahrtenbuch(): Unit =
|
||||||
|
val appComponent = AppComponent(Main.allEntries, Trystero.onlineStatus)
|
||||||
|
|
||||||
|
renderOnDomContentLoaded(
|
||||||
|
dom.document.getElementById("app"),
|
||||||
|
appComponent.render()
|
||||||
|
)
|
||||||
|
|
||||||
|
object Main {
|
||||||
|
|
||||||
|
// track changes to entries
|
||||||
|
val entryEditBus = new EventBus[Entry]
|
||||||
|
val entryDbObserver =
|
||||||
|
Observer[Entry](onNext = DexieDB.upsertEntry(_))
|
||||||
|
entryEditBus.stream.tapEach(_ => println("lalilu"))
|
||||||
|
println("test")
|
||||||
|
|
||||||
|
val allEntriesVar = Var(Set.empty[Entry])
|
||||||
|
|
||||||
|
// update entries whenever db updates
|
||||||
|
entriesObservable.subscribe(entries =>
|
||||||
|
entries.onComplete {
|
||||||
|
case Failure(exception) => println("failed to get entries from db")
|
||||||
|
case Success(value) => allEntriesVar.set(value.toSet)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// update db when edit events happen
|
||||||
|
entryEditBus.stream.addObserver(entryDbObserver)(using unsafeWindowOwner)
|
||||||
|
|
||||||
|
// sync out changes
|
||||||
|
entryEditBus.stream.addObserver(Sync.entrySyncOut)(using unsafeWindowOwner)
|
||||||
|
|
||||||
|
val allEntries: Signal[Set[Entry]] =
|
||||||
|
allEntriesVar.signal
|
||||||
|
|
||||||
|
}
|
||||||
89
src/main/scala/fahrtenbuch/Networking.scala
Normal file
89
src/main/scala/fahrtenbuch/Networking.scala
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
package fahrtenbuch
|
||||||
|
|
||||||
|
import com.raquo.laminar.api.L.*
|
||||||
|
import org.scalajs.dom
|
||||||
|
import org.scalajs.dom.RTCConfiguration
|
||||||
|
import org.scalajs.dom.RTCIceServer
|
||||||
|
import org.scalajs.dom.RTCPeerConnection
|
||||||
|
import typings.trystero.mod.BaseRoomConfig
|
||||||
|
import typings.trystero.mod.RelayConfig
|
||||||
|
import typings.trystero.mod.Room
|
||||||
|
import typings.trystero.mod.TurnConfig
|
||||||
|
import typings.trystero.mod.joinRoom
|
||||||
|
import typings.trystero.mod.selfId
|
||||||
|
|
||||||
|
import scala.scalajs.js
|
||||||
|
import scala.scalajs.js.JSConverters.*
|
||||||
|
import typings.trystero.mod.ActionProgress
|
||||||
|
import typings.trystero.mod.ActionSender
|
||||||
|
import typings.trystero.mod.ActionReceiver
|
||||||
|
import model.Entry
|
||||||
|
import org.getshaka.nativeconverter.NativeConverter
|
||||||
|
import fahrtenbuch.Trystero.updatePeers
|
||||||
|
|
||||||
|
object Trystero:
|
||||||
|
private val eturn = new RTCIceServer:
|
||||||
|
urls = js.Array(
|
||||||
|
"stun:relay1.expressturn.com:443",
|
||||||
|
"turn:relay1.expressturn.com:3478",
|
||||||
|
"turn:relay1.expressturn.com:443"
|
||||||
|
)
|
||||||
|
username = "efMS8M021S1G8NJ8J7"
|
||||||
|
credential = "qrBXTlhKtCJDykOK"
|
||||||
|
|
||||||
|
private val tturn = new RTCIceServer:
|
||||||
|
urls = "stun:stun.t-online.de:3478"
|
||||||
|
|
||||||
|
private val rtcConf = new RTCConfiguration:
|
||||||
|
iceServers = js.Array(eturn, tturn)
|
||||||
|
|
||||||
|
private object MyConfig extends RelayConfig, BaseRoomConfig, TurnConfig {
|
||||||
|
var appId = "fahrtenbuch_149520"
|
||||||
|
rtcConfig = rtcConf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
val roomId = dom.window.location.hash
|
||||||
|
val room: Room = joinRoom(MyConfig, roomId)
|
||||||
|
println(s"joining room $roomId")
|
||||||
|
val userId: Var[String] = Var(selfId)
|
||||||
|
|
||||||
|
// track online peers
|
||||||
|
val peerList: Var[List[(String, RTCPeerConnection)]] = Var(List.empty)
|
||||||
|
def updatePeers(): Unit =
|
||||||
|
println(s"List of peers: ${room.getPeers().toList}")
|
||||||
|
peerList.set(room.getPeers().toList)
|
||||||
|
println(s"my peer ID is $selfId")
|
||||||
|
room.onPeerJoin(peerId =>
|
||||||
|
println(s"$peerId joined")
|
||||||
|
updatePeers()
|
||||||
|
)
|
||||||
|
room.onPeerLeave(peerId =>
|
||||||
|
println(s"$peerId left")
|
||||||
|
updatePeers()
|
||||||
|
)
|
||||||
|
val onlineStatus: Signal[Boolean] = peerList.signal.map(_.nonEmpty)
|
||||||
|
|
||||||
|
object Actions:
|
||||||
|
// setup actions
|
||||||
|
private val entryAction: js.Tuple3[ActionSender[js.Any], ActionReceiver[
|
||||||
|
js.Any
|
||||||
|
], ActionProgress] = Trystero.room.makeAction[js.Any]("entry")
|
||||||
|
private val trysteroReceiveEntry: ActionReceiver[js.Any] = entryAction._2
|
||||||
|
|
||||||
|
def sendEntry(entry: Entry): Unit =
|
||||||
|
entryAction._1(entry.toNative)
|
||||||
|
|
||||||
|
def sendEntry(entry: Entry, targetPeers: List[String]): Unit =
|
||||||
|
if targetPeers.isEmpty then sendEntry(entry)
|
||||||
|
else
|
||||||
|
entryAction._1(data = entry.toNative, targetPeers = targetPeers.toJSArray)
|
||||||
|
|
||||||
|
def receiveEntry(callback: Entry => Unit): Unit =
|
||||||
|
entryAction._2((data: js.Any, peerId: String, metaData) =>
|
||||||
|
val incoming = NativeConverter[Entry].fromNative(data)
|
||||||
|
callback(incoming)
|
||||||
|
)
|
||||||
|
|
||||||
|
// update peers when receiving entries
|
||||||
|
receiveEntry(_ => updatePeers())
|
||||||
20
src/main/scala/fahrtenbuch/Sync.scala
Normal file
20
src/main/scala/fahrtenbuch/Sync.scala
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
package fahrtenbuch
|
||||||
|
import com.raquo.laminar.api.L.*
|
||||||
|
import fahrtenbuch.model.Entry
|
||||||
|
import fahrtenbuch.DexieDB.upsertEntry
|
||||||
|
import fahrtenbuch.Main.allEntriesVar
|
||||||
|
|
||||||
|
object Sync:
|
||||||
|
val entrySyncOut =
|
||||||
|
Observer[Entry](onNext = Actions.sendEntry(_))
|
||||||
|
|
||||||
|
val entrySyncIn =
|
||||||
|
Actions.receiveEntry(received => upsertEntry(received))
|
||||||
|
|
||||||
|
// sync all entries on initial connection
|
||||||
|
Trystero.room.onPeerJoin(peerId =>
|
||||||
|
Trystero.updatePeers()
|
||||||
|
allEntriesVar
|
||||||
|
.now()
|
||||||
|
.foreach(entry => Actions.sendEntry(entry, List(peerId)))
|
||||||
|
)
|
||||||
|
|
@ -2,11 +2,11 @@ package fahrtenbuch.components
|
||||||
|
|
||||||
import com.raquo.laminar.api.L.*
|
import com.raquo.laminar.api.L.*
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
import fahrtenbuch.model.{Entry, EntryId}
|
||||||
|
import fahrtenbuch.Main.entryEditBus
|
||||||
|
|
||||||
class AppComponent(
|
class AppComponent(
|
||||||
allEntries: Signal[Set[Entry]],
|
allEntries: Signal[Set[Entry]],
|
||||||
onlineStatus: Signal[Boolean],
|
onlineStatus: Signal[Boolean]
|
||||||
onEntryEdit: Observer[Entry]
|
|
||||||
):
|
):
|
||||||
// tracks whenever a user clicks on an edit button
|
// tracks whenever a user clicks on an edit button
|
||||||
val editClickBus = new EventBus[(EntryId, Boolean)]
|
val editClickBus = new EventBus[(EntryId, Boolean)]
|
||||||
|
|
@ -29,7 +29,7 @@ class AppComponent(
|
||||||
entry,
|
entry,
|
||||||
editState.getOrElse(entry.id, false),
|
editState.getOrElse(entry.id, false),
|
||||||
editClickBus,
|
editClickBus,
|
||||||
onEntryEdit
|
entryEditBus
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -57,9 +57,7 @@ class AppComponent(
|
||||||
),
|
),
|
||||||
tbody(
|
tbody(
|
||||||
children <-- entryComponents.map(_.map(_.render)),
|
children <-- entryComponents.map(_.map(_.render)),
|
||||||
child(
|
child(NewEntryInput(showNewEntryField).render) <-- showNewEntryField
|
||||||
NewEntryInput(showNewEntryField, onEntryEdit).render
|
|
||||||
) <-- showNewEntryField
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
button(
|
button(
|
||||||
|
|
@ -11,10 +11,16 @@ class EntryComponent(
|
||||||
entry: Entry,
|
entry: Entry,
|
||||||
editMode: Boolean,
|
editMode: Boolean,
|
||||||
editClickBus: EventBus[(EntryId, Boolean)],
|
editClickBus: EventBus[(EntryId, Boolean)],
|
||||||
onEntryEdit: Observer[Entry]
|
entryEditBus: EventBus[Entry]
|
||||||
):
|
):
|
||||||
def render: ReactiveHtmlElement[HTMLTableRowElement] = {
|
def render: ReactiveHtmlElement[HTMLTableRowElement] = {
|
||||||
if editMode then
|
if editMode then
|
||||||
|
val dateInput =
|
||||||
|
input(
|
||||||
|
cls := "input",
|
||||||
|
value := new Date(entry.date.payload).toDateString()
|
||||||
|
)
|
||||||
|
|
||||||
val driverInput = input(cls := "input", value := entry.driver.payload)
|
val driverInput = input(cls := "input", value := entry.driver.payload)
|
||||||
|
|
||||||
val startKmInput =
|
val startKmInput =
|
||||||
|
|
@ -78,7 +84,7 @@ class EntryComponent(
|
||||||
val paidCheckbox =
|
val paidCheckbox =
|
||||||
input(`type` := "checkbox", checked := entry.paid.payload)
|
input(`type` := "checkbox", checked := entry.paid.payload)
|
||||||
tr(
|
tr(
|
||||||
td(),
|
td(dateInput),
|
||||||
td(driverInput),
|
td(driverInput),
|
||||||
td(startKmInput),
|
td(startKmInput),
|
||||||
td(endKmInput),
|
td(endKmInput),
|
||||||
|
|
@ -90,6 +96,10 @@ class EntryComponent(
|
||||||
button(
|
button(
|
||||||
cls := "button is-success",
|
cls := "button is-success",
|
||||||
onClick --> {
|
onClick --> {
|
||||||
|
val newDate =
|
||||||
|
val parsed = Date.parse(dateInput.ref.value)
|
||||||
|
if parsed != entry.date.payload then entry.date.write(parsed)
|
||||||
|
else entry.date
|
||||||
val newDriver =
|
val newDriver =
|
||||||
if driverInput.ref.value != entry.driver.payload then
|
if driverInput.ref.value != entry.driver.payload then
|
||||||
entry.driver.write(driverInput.ref.value)
|
entry.driver.write(driverInput.ref.value)
|
||||||
|
|
@ -110,8 +120,9 @@ class EntryComponent(
|
||||||
if paidCheckbox.ref.checked != entry.paid.payload then
|
if paidCheckbox.ref.checked != entry.paid.payload then
|
||||||
entry.paid.write(paidCheckbox.ref.checked)
|
entry.paid.write(paidCheckbox.ref.checked)
|
||||||
else entry.paid
|
else entry.paid
|
||||||
onEntryEdit.onNext(
|
entryEditBus.emit(
|
||||||
entry.copy(
|
entry.copy(
|
||||||
|
date = newDate,
|
||||||
driver = newDriver,
|
driver = newDriver,
|
||||||
startKm = newStartKm,
|
startKm = newStartKm,
|
||||||
endKm = newEndKm,
|
endKm = newEndKm,
|
||||||
|
|
@ -4,15 +4,12 @@ package fahrtenbuch.components
|
||||||
import com.raquo.laminar.api.L.*
|
import com.raquo.laminar.api.L.*
|
||||||
|
|
||||||
import com.raquo.laminar.api.features.unitArrows
|
import com.raquo.laminar.api.features.unitArrows
|
||||||
|
import fahrtenbuch.Main.entryEditBus
|
||||||
import fahrtenbuch.model.{Entry, EntryId}
|
import fahrtenbuch.model.{Entry, EntryId}
|
||||||
import rdts.datatypes.LastWriterWins
|
import rdts.datatypes.LastWriterWins
|
||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
|
|
||||||
class NewEntryInput(
|
class NewEntryInput(showNewEntryField: Var[Boolean]):
|
||||||
showNewEntryField: Var[Boolean],
|
|
||||||
onEntryEdit: Observer[Entry]
|
|
||||||
):
|
|
||||||
val newEntryDriver = input(cls := "input")
|
val newEntryDriver = input(cls := "input")
|
||||||
val newEntryStartKm = input(`type` := "number")
|
val newEntryStartKm = input(`type` := "number")
|
||||||
val newEntryEndKm = input(`type` := "number")
|
val newEntryEndKm = input(`type` := "number")
|
||||||
|
|
@ -85,7 +82,7 @@ class NewEntryInput(
|
||||||
val endKm = LastWriterWins.now(BigDecimal(newEntryEndKm.ref.value))
|
val endKm = LastWriterWins.now(BigDecimal(newEntryEndKm.ref.value))
|
||||||
val animal = LastWriterWins.now(newEntryAnimal.ref.value)
|
val animal = LastWriterWins.now(newEntryAnimal.ref.value)
|
||||||
val paid = LastWriterWins.now(newEntryPaid.ref.checked)
|
val paid = LastWriterWins.now(newEntryPaid.ref.checked)
|
||||||
onEntryEdit.onNext(
|
entryEditBus.emit(
|
||||||
Entry(id, startKm, endKm, animal, paid, driver)
|
Entry(id, startKm, endKm, animal, paid, driver)
|
||||||
)
|
)
|
||||||
showNewEntryField.set(false)
|
showNewEntryField.set(false)
|
||||||
|
|
@ -2,14 +2,5 @@ import { defineConfig } from "vite";
|
||||||
import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";
|
import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [scalaJSPlugin()],
|
||||||
scalaJSPlugin({
|
|
||||||
// Configure the plugin to use the browser subproject
|
|
||||||
cwd: ".",
|
|
||||||
projectID: "browser",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
build: {
|
|
||||||
outDir: "dist/browser",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue