From e4e0775472cc30ed3b35bf72dda85f5fd58047ec Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Sat, 14 Feb 2026 18:03:13 -0600 Subject: [PATCH] built basic ui structure in /hierarchy --- .../rules/audio-recording-streaming-design.md | 40 +++ river_connect/.agent/rules/audio-streaming.md | 35 +++ river_connect/.agent/rules/style-guide.md | 41 +++ .../.agent/rules/tech-stack-structure.md | 58 ++++ river_connect/.vscode/settings.json | 1 + river_connect/README.md | 2 +- river_connect/assets/bun.lock | 47 +++ river_connect/assets/css/app.css | 23 ++ river_connect/assets/js/app.js | 3 +- .../assets/js/components/HierarchyApp.jsx | 168 ++++++++++ .../assets/js/components/SolidApp.jsx | 55 +++- .../assets/js/hooks/HierarchyBridge.jsx | 48 +++ river_connect/assets/package.json | 1 + river_connect/lib/river_connect/hierarchy.ex | 122 ++++++++ .../lib/river_connect/hierarchy/river.ex | 22 ++ .../lib/river_connect/hierarchy/spring.ex | 24 ++ .../lib/river_connect/hierarchy/stream.ex | 22 ++ .../lib/river_connect/hierarchy/trench.ex | 20 ++ .../lib/river_connect_web/live/audio_live.ex | 292 +++++++++++++----- .../river_connect_web/live/hierarchy_live.ex | 60 ++++ river_connect/lib/river_connect_web/router.ex | 1 + .../20260214234844_create_hierarchy.exs | 43 +++ river_connect/priv/repo/seeds.exs | 84 ++++- river_connect/verify_hierarchy.exs | 68 ++++ 24 files changed, 1174 insertions(+), 106 deletions(-) create mode 100644 river_connect/.agent/rules/audio-recording-streaming-design.md create mode 100644 river_connect/.agent/rules/audio-streaming.md create mode 100644 river_connect/.agent/rules/style-guide.md create mode 100644 river_connect/.agent/rules/tech-stack-structure.md create mode 100644 river_connect/.vscode/settings.json create mode 100644 river_connect/assets/js/components/HierarchyApp.jsx create mode 100644 river_connect/assets/js/hooks/HierarchyBridge.jsx create mode 100644 river_connect/lib/river_connect/hierarchy.ex create mode 100644 river_connect/lib/river_connect/hierarchy/river.ex create mode 100644 river_connect/lib/river_connect/hierarchy/spring.ex create mode 100644 river_connect/lib/river_connect/hierarchy/stream.ex create mode 100644 river_connect/lib/river_connect/hierarchy/trench.ex create mode 100644 river_connect/lib/river_connect_web/live/hierarchy_live.ex create mode 100644 river_connect/priv/repo/migrations/20260214234844_create_hierarchy.exs create mode 100644 river_connect/verify_hierarchy.exs diff --git a/river_connect/.agent/rules/audio-recording-streaming-design.md b/river_connect/.agent/rules/audio-recording-streaming-design.md new file mode 100644 index 0000000..454ab75 --- /dev/null +++ b/river_connect/.agent/rules/audio-recording-streaming-design.md @@ -0,0 +1,40 @@ +--- +trigger: model_decision +description: When working on the voxer-like audio recording, chunking, streaming and playback setup +--- + +# Audio Recording & Streaming Architecture + +This project implements a "Voxer-like" audio messaging system using Phoenix Channels for binary streaming and Oban for background processing. + +## 1. Components & Data Flow +- **Capture**: `AudioRecorder` JS Hook (`assets/js/hooks/audio_recorder.js`) uses `MediaRecorder` with `audio/webm;codecs=opus`. +- **Transport**: Raw binary chunks are pushed via Phoenix Channels (`audio:lobby`). +- **Persistence**: Server appends chunks to a `.webm` file in `priv/static/uploads/`. +- **Processing**: `audio_end` event triggers an **Oban** job (`RiverConnect.Workers.ProcessRecording`) to finalize and update status. +- **Serving**: Files are served from `/uploads/` via a dedicated `Plug.Static` entry in `RiverConnectWeb.Endpoint`. + +## 2. Channel Protocol (`AudioChannel`) +- **`audio_start`**: `%{"id" => id}`. Initializes the recording session and creates a temp file. +- **`audio_chunk`**: `{:binary, payload}`. Appends binary to the file and broadcasts to other listeners for live playback. +- **`audio_end`**: `%{"duration_ms" => ms, "id" => id}`. Creates the `AudioMessage` DB record and enqueues the Oban worker. + +## 3. Real-time Playback +- While a recording is in progress, the channel broadcasts `audio_chunk` events. +- The receiving `AudioRecorder` hook uses `AudioContext.decodeAudioData` to play these chunks immediately, providing a "near-realtime" walkie-talkie experience. + +## 4. Database Schema (`AudioMessage`) +- `user_id`: String (Guest ID or User ID). +- `file_path`: Relative path (e.g., `/uploads/uuid.webm`). +- `duration_ms`: Total recording duration. +- `status`: `"processing"` | `"ready"`. +- **Note**: The schema must `@derive {Jason.Encoder, ...}` to be sent over Phoenix Channels. + +## 5. Environment Constraints (Windows) +- Commands like `mix` may need to be executed via `cmd /c` or with explicit PATH to `erl.exe` and `mix.bat` in backgrounds tasks. +- Static files in `priv/static/uploads` must be exposed explicitly in `endpoint.ex` and may require a server restart to be served correctly after initial creation. + +## 6. Development Tips +- **Restart Required**: Adding new sockets or `Plug.Static` rules in `endpoint.ex` requires a full `mix phx.server` restart. +- **Permissions**: Ensure the browser has microphone permissions for the specific port (default 4000/4005). +- **Streaming Jitter**: Basic playback uses `decodeAudioData`. For production-grade streaming without gaps, a jitter buffer/audio scheduler would be required. diff --git a/river_connect/.agent/rules/audio-streaming.md b/river_connect/.agent/rules/audio-streaming.md new file mode 100644 index 0000000..187e262 --- /dev/null +++ b/river_connect/.agent/rules/audio-streaming.md @@ -0,0 +1,35 @@ +# Audio Recording & Streaming Architecture + +This project implements an asynchronous and real-time audio messaging system using Phoenix Channels for binary streaming and Oban for background processing. + +## 1. Components & Data Flow +- **Capture**: `AudioRecorder` JS Hook (`assets/js/hooks/audio_recorder.js`) utilizes the `MediaRecorder` API with `audio/webm;codecs=opus`. +- **Transport**: Binary chunks are transmitted via Phoenix Channels (`audio:lobby`). +- **Persistence**: The server appends incoming chunks to a `.webm` file located in `priv/static/uploads/`. +- **Finalization**: An `audio_end` event triggers an **Oban** job (`RiverConnect.Workers.ProcessRecording`) to verify the file and update the database status. +- **Serving**: Static audio files are exposed via a dedicated `Plug.Static` entry in `RiverConnectWeb.Endpoint`. + +## 2. Channel Protocol (`AudioChannel`) +- **`audio_start`**: `%{"id" => id}`. Initializes the server-side recording state and creates the target file. +- **`audio_chunk`**: `{:binary, payload}`. Appends binary data to the file and broadcasts the chunk for real-time consumption. +- **`audio_end`**: `%{"duration_ms" => ms, "id" => id}`. Persists the `AudioMessage` record and enqueues the processing worker. + +## 3. Real-time Streaming +- During an active recording session, the channel broadcasts `audio_chunk` events to all subscribers. +- Subscribed clients use `AudioContext.decodeAudioData` to process and play the binary stream with minimal latency. + +## 4. Database Schema (`AudioMessage`) +- `user_id`: Identifies the sender (Guest or Authenticated). +- `file_path`: Relative URL to the static resource. +- `duration_ms`: Integer representing the length of the audio. +- `status`: State machine values (`"processing"`, `"ready"`). +- **Note**: The schema must `@derive {Jason.Encoder, ...}` for binary serialization over the socket. + +## 5. Environment & Infrastructure +- **Windows Support**: `mix` commands in background processes may require explicit PATH configuration for Erlang/Elixir binaries. +- **Static Configuration**: Dynamic directories in `priv/static` require explicit inclusion in the `Endpoint` configuration to be served correctly. + +## 6. Implementation Notes +- **Server Restarts**: Modifications to `endpoint.ex` or `UserSocket` require a server restart to take effect. +- **Hardware Access**: Web-based recording requires secure context (HTTPS/Localhost) and explicit browser microphone permissions. +- **Latency Management**: Real-time playback relies on prompt chunk processing; production environments may require a jitter buffer for consistency. diff --git a/river_connect/.agent/rules/style-guide.md b/river_connect/.agent/rules/style-guide.md new file mode 100644 index 0000000..32be05d --- /dev/null +++ b/river_connect/.agent/rules/style-guide.md @@ -0,0 +1,41 @@ +--- +trigger: model_decision +description: When working on UI +--- + +# Kobalte for Solid.js Coding Rules + +## Core Mandate +1. **Always use [Kobalte](https://kobalte.dev/)** for UI components in Solid.js (e.g., Popovers, Dialogs, Selects, Tooltips, etc.). +2. **DO NOT** implement custom UI logic or accessibility patterns from scratch unless explicitly requested by the user. +3. **DO NOT** use other UI libraries for Solid.js unless explicitly requested. + +## Implementation Guidelines +- **Importing**: Always import from `@kobalte/core`. +- **Structure**: Follow the standard Kobalte pattern: `Component.Root`, `Component.Trigger`, `Component.Portal`, `Component.Content`, etc. +- **Styling**: + - Use **Tailwind CSS** for all styling. + - Apply styles directly to the Kobalte primitive components using the `class` prop. + - Use `data-*` attributes provided by Kobalte (e.g., `data-expanded`, `data-disabled`) for state-based styling in Tailwind. +- **Portals**: Use `` or `` to ensure correct DOM placement and stacking context. +- **Animations**: Prefer Tailwind's `animate-in`, `fade-in`, `zoom-in`, etc. (from `tailwindcss-animate` if available, or custom animations) combined with Kobalte's state attributes. + +## Example Pattern (Popover) +```jsx +import { Popover } from "@kobalte/core"; + + + ... + + + + ... + ... + + + +``` + +## Exceptions +- Only bypass Kobalte if the user explicitly says: "Do not use Kobalte for this" or "Build this from scratch". +- If a component is not available in Kobalte, ask for clarification before choosing an alternative. diff --git a/river_connect/.agent/rules/tech-stack-structure.md b/river_connect/.agent/rules/tech-stack-structure.md new file mode 100644 index 0000000..8c740b0 --- /dev/null +++ b/river_connect/.agent/rules/tech-stack-structure.md @@ -0,0 +1,58 @@ +--- +trigger: always_on +--- + +# Elixir & Solid.js Bridge Architecture + +This project uses a hybrid architecture where **Phoenix LiveView** manages the server-side state and **Solid.js** handles the reactive client-side UI via a "Bridge Hook". + +## 1. Core Architecture +- **State Source of Truth**: `socket.assigns` in Elixir LiveViews. +- **Transport**: JSON payloads via `push_event/3` (Server -> Client) and `pushEvent` (Client -> Server). +- **Client Container**: A `div` with `phx-update="ignore"`, a unique `id`, and `phx-hook="SolidBridge"`. +- **Reactivity**: A Phoenix Hook initializes a **Solid.js Store**. Server events trigger `setState` on the store, which drives Solid's fine-grained reactivity. + +## 2. Frontend Build Pipeline +- **Bundler**: Custom `esbuild` script ([build.js](file:///c:/Users/TimothyCardoza/Documents/AI-Apps/RiverConnect/Rivertalk/river_connect/assets/build.js)). +- **Runtime**: `bun` is used for dependency management and running the build script. +- **JSX Support**: Solid.js JSX is compiled via `esbuild-plugin-solid`. +- **Dependency Paths**: + - Phoenix dependencies (js) are loaded from `package.json` (npm versions). + - Aliases: `phoenix-colocated` is shimmed to `js/phoenix-colocated-shim.js` to prevent dynamic require errors. + +## 3. Directory Structure & Key Files +- `lib/river_connect_web/live/`: Elixir LiveView components. +- `assets/js/hooks/SolidBridge.jsx`: The Phoenix Hook that spawns Solid. +- `assets/js/components/`: Solid.js reactive components (`.jsx`). +- `priv/static/assets/js/app.js`: Generated bundle (Target for `esbuild`). + +## 4. State Syncing Patterns +### Server to Client (Push) +```elixir +# lib/app_live.ex +def handle_event("update", _params, socket) do + new_state = %{count: 1} + {:noreply, push_event(socket, "sync_state", new_state)} +end +``` + +### Client to Server (Push) +```javascript +// assets/js/components/MyComponent.jsx +const update = () => props.push("client_event", { data: "payload" }); +``` + +## 5. UI Components & Styling +- **Styling**: Tailwind CSS + DaisyUI. +- **Headless UI**: [Kobalte](https://kobalte.dev/) is used for complex UI logic (Popovers, Tooltips, Tabs) while maintaining total style control. +- **Glassmorphism**: Use `.glass` helper in CSS for translucent, blurred backgrounds. + +## 6. Development Workflow +- **Watchers**: Run automatically via `mix phx.server` (Configured in `config/dev.exs` using `bun`). +- **Manual Build**: `bun run --prefix assets build`. +- **Adding Dependencies**: Use `bun add` in the `assets/` directory. + +## 7. Important Constraints +- **NEVER** use `phx-update="replace"` on Solid.js root containers; it will destroy the Solid DOM tree. +- **ALWAYS** use `phx-update="ignore"` for Solid mounts. +- **JSX in JS**: The build script is configured to treat `.js` files as JSX, but preferred extension for Solid components is `.jsx`. diff --git a/river_connect/.vscode/settings.json b/river_connect/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/river_connect/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/river_connect/README.md b/river_connect/README.md index f68f0e1..631e0f5 100644 --- a/river_connect/README.md +++ b/river_connect/README.md @@ -15,4 +15,4 @@ Ready to run in production? Please [check our deployment guides](https://hexdocs * Guides: https://hexdocs.pm/phoenix/overview.html * Docs: https://hexdocs.pm/phoenix * Forum: https://elixirforum.com/c/phoenix-forum -* Source: https://github.com/phoenixframework/phoenix +* Source: https://github.com/phoenixframework/phoenix \ No newline at end of file diff --git a/river_connect/assets/bun.lock b/river_connect/assets/bun.lock index 98ab9ae..9ebc7d6 100644 --- a/river_connect/assets/bun.lock +++ b/river_connect/assets/bun.lock @@ -5,6 +5,7 @@ "": { "name": "river_connect_assets", "dependencies": { + "@kobalte/core": "^0.13.11", "phoenix": "^1.8.3", "phoenix_html": "^4.1.1", "phoenix_live_view": "^1.1.0", @@ -74,6 +75,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -126,6 +129,16 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@internationalized/date": ["@internationalized/date@3.11.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-BOx5huLAWhicM9/ZFs84CzP+V3gBW6vlpM02yzsdYC7TGlZJX1OJiEEHcSayF00Z+3jLlm4w79amvSt6RqKN3Q=="], + + "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -136,6 +149,34 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@kobalte/core": ["@kobalte/core@0.13.11", "", { "dependencies": { "@floating-ui/dom": "^1.5.1", "@internationalized/date": "^3.4.0", "@internationalized/number": "^3.2.1", "@kobalte/utils": "^0.9.1", "@solid-primitives/props": "^3.1.8", "@solid-primitives/resize-observer": "^2.0.26", "solid-presence": "^0.1.8", "solid-prevent-scroll": "^0.1.4" }, "peerDependencies": { "solid-js": "^1.8.15" } }, "sha512-hK7TYpdib/XDb/r/4XDBFaO9O+3ZHz4ZWryV4/3BfES+tSQVgg2IJupDnztKXB0BqbSRy/aWlHKw1SPtNPYCFQ=="], + + "@kobalte/utils": ["@kobalte/utils@0.9.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="], + + "@solid-primitives/event-listener": ["@solid-primitives/event-listener@2.4.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="], + + "@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="], + + "@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="], + + "@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="], + + "@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="], + + "@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="], + + "@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], + + "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="], + + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="], + + "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g=="], + + "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.18", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="], "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], @@ -198,8 +239,14 @@ "solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="], + "solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="], + + "solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="], + "topbar": ["topbar@2.0.2", "", {}, "sha512-hCKoSaWxXqGIgjag8rIVajysE41as7ti5z9GDO5rcx2zmII1/rY5zvO9IgKwbf50HL82EzlimL6OmPYPUgbpEw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], diff --git a/river_connect/assets/css/app.css b/river_connect/assets/css/app.css index c01e0e3..66f52f9 100644 --- a/river_connect/assets/css/app.css +++ b/river_connect/assets/css/app.css @@ -103,3 +103,26 @@ [data-phx-session], [data-phx-teleported-src] { display: contents } /* This file is for your main application CSS */ + +/* Custom Animations for Audio UI */ +@keyframes pulse-glow { + 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); transform: scale(1); } + 70% { box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); transform: scale(1.05); } + 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); transform: scale(1); } +} + +.animate-pulse-glow { + animation: pulse-glow 2s infinite; +} + +.glass { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.dark .glass { + background: rgba(15, 23, 42, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); +} diff --git a/river_connect/assets/js/app.js b/river_connect/assets/js/app.js index abab3aa..74d5be8 100644 --- a/river_connect/assets/js/app.js +++ b/river_connect/assets/js/app.js @@ -27,12 +27,13 @@ import topbar from "../vendor/topbar" import { AudioRecorder } from "./hooks/audio_recorder" import { SolidBridge } from "./hooks/SolidBridge" +import { HierarchyBridge } from "./hooks/HierarchyBridge" const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken }, - hooks: { ...colocatedHooks, AudioRecorder, SolidBridge }, + hooks: { ...colocatedHooks, AudioRecorder, SolidBridge, HierarchyBridge }, }) // Show progress bar on live navigation and form submits diff --git a/river_connect/assets/js/components/HierarchyApp.jsx b/river_connect/assets/js/components/HierarchyApp.jsx new file mode 100644 index 0000000..ff1faa5 --- /dev/null +++ b/river_connect/assets/js/components/HierarchyApp.jsx @@ -0,0 +1,168 @@ +import { createSignal, createEffect, For, Show } from "solid-js"; + +export function HierarchyApp(props) { + // props.state contains springs, rivers, streams, trenches, etc. + // props.push is the function to push events to LiveView + // props.setState can be used for local updates if needed, + // but better to just use props.state and fire events. + + const handleSpringClick = (springId) => { + // Optimistic update or just wait for server? + // Let's fire event to server to fetch rivers + props.push("fetch_rivers", { spring_id: springId }); + // We can also update local state to show selection immediately? + // But sending `setState` from parent is cleaner. + // Wait, the parent hook has `setState`. + // We can use a local signal for UI selection state content, + // but the data (rivers) must come from server. + + // Actually, let's just trigger the push. + // And ideally we also set activeSpringId in the store. + props.setState("activeSpringId", springId); + props.setState("activeRiverId", null); + props.setState("activeStreamId", null); + }; + + const handleRiverClick = (riverId) => { + props.push("fetch_streams", { river_id: riverId }); + props.setState("activeRiverId", riverId); + // Maybe toggle expansion? + }; + + const handleStreamClick = (streamId) => { + props.push("fetch_trenches", { stream_id: streamId }); + props.setState("activeStreamId", streamId); + }; + + return ( +
+ {/* 1. Springs (Servers) Column */} +
+ + {(spring) => ( + + )} + + + +
+ + {/* 2. Rivers & Streams (Channels) Column */} + +
+ {/* Header */} +
+ {props.state.springs.find(s => s.id === props.state.activeSpringId)?.name || "Spring"} +
+ + {/* Rivers List */} +
+ Loading rivers...
}> + + {(river) => ( +
+
handleRiverClick(river.id)} + > + v {river.name} + + +
+ + {/* Streams in this river */} + +
+ + {(stream) => ( +
handleStreamClick(stream.id)} + class={`px-2 py-1 rounded mx-2 text-gray-400 hover:bg-gray-800 hover:text-gray-100 cursor-pointer flex items-center ${props.state.activeStreamId === stream.id ? "bg-gray-800 text-white font-medium" : "" + }`} + > + # + {stream.name} +
+ )} +
+
+
+
+ )} +
+ +
+
+ + + {/* 3. Main Stream (Chat) Area */} +
+ +
👋
+

Select a stream to start chatting

+
+ }> + {/* Header */} +
+
+ # + + {/* Need to find stream name across all rivers? inefficient. + Better to store activeStream details? Or just find it. */} + Stream Name + +
+
+ + {/* Messages */} +
+ {/* Placeholder messages */} +
+
+
+
+ User A + 10:30 AM +
+

Welcome to the stream!

+
+
+ + +
+
Trenches (Threads):
+ + {(trench) => ( +
+ ↳ {trench.name} +
+ )} +
+
+
+
+ + {/* Input */} +
+ +
+ + + + ); +} diff --git a/river_connect/assets/js/components/SolidApp.jsx b/river_connect/assets/js/components/SolidApp.jsx index 629c30f..7449efc 100644 --- a/river_connect/assets/js/components/SolidApp.jsx +++ b/river_connect/assets/js/components/SolidApp.jsx @@ -1,4 +1,5 @@ import { createSignal } from "solid-js"; +import { Popover } from "@kobalte/core"; export function SolidApp(props) { // Local state for optimistic updates or other UI-only logic @@ -14,33 +15,61 @@ export function SolidApp(props) { props.push("decrement", {}); }; - // Reset updating flag when state changes from server - // (In a real app, you might use a more sophisticated approach) - return ( -
-

Solid.js Component

+
+

Solid + Kobalte

+ +
+
+ Current Count + {props.state.count} +
-
- {props.state.count}
-

- This UI is rendered by Solid.js. Changes are synced back to Elixir via push_event. +

+ + + Kobalte Info + + + + + + + +
+ Kobalte Logic + + Kobalte is a headless UI toolkit for Solid.js. It provides the logic while you provide the styles! + + + + + + +
+
+
+
+
+ +

+ Bridge: Phoenix LiveView ↔ Solid.js

); diff --git a/river_connect/assets/js/hooks/HierarchyBridge.jsx b/river_connect/assets/js/hooks/HierarchyBridge.jsx new file mode 100644 index 0000000..44816ba --- /dev/null +++ b/river_connect/assets/js/hooks/HierarchyBridge.jsx @@ -0,0 +1,48 @@ +import { render } from "solid-js/web"; +import { createStore } from "solid-js/store"; +import { HierarchyApp } from "../components/HierarchyApp"; + +export const HierarchyBridge = { + mounted() { + console.log("HierarchyBridge mounted"); + + // 1. Initialize store + const initialSprings = JSON.parse(this.el.dataset.initialSprings || "[]"); + + const [state, setState] = createStore({ + springs: initialSprings, + rivers: {}, // Map spring_id -> rivers + streams: {}, // Map river_id -> streams + trenches: {}, // Map stream_id -> trenches + activeSpringId: null, + activeRiverId: null, + activeStreamId: null + }); + + // 2. Render parameters + this._cleanup = render(() => ( + this.pushEvent(e, p)} + setState={setState} + /> + ), this.el); + + // 3. Listen for server assignments + this.handleEvent("update_rivers", ({ spring_id, rivers }) => { + setState("rivers", spring_id, rivers); + }); + + this.handleEvent("update_streams", ({ river_id, streams }) => { + setState("streams", river_id, streams); + }); + + this.handleEvent("update_trenches", ({ stream_id, trenches }) => { + setState("trenches", stream_id, trenches); + }); + }, + + destroyed() { + if (this._cleanup) this._cleanup(); + } +}; diff --git a/river_connect/assets/package.json b/river_connect/assets/package.json index 75665e0..4b72768 100644 --- a/river_connect/assets/package.json +++ b/river_connect/assets/package.json @@ -15,6 +15,7 @@ "author": "", "license": "ISC", "dependencies": { + "@kobalte/core": "^0.13.11", "phoenix": "^1.8.3", "phoenix_html": "^4.1.1", "phoenix_live_view": "^1.1.0", diff --git a/river_connect/lib/river_connect/hierarchy.ex b/river_connect/lib/river_connect/hierarchy.ex new file mode 100644 index 0000000..3937545 --- /dev/null +++ b/river_connect/lib/river_connect/hierarchy.ex @@ -0,0 +1,122 @@ +defmodule RiverConnect.Hierarchy do + @moduledoc """ + The Hierarchy context. + """ + + import Ecto.Query, warn: false + alias RiverConnect.Repo + + alias RiverConnect.Hierarchy.{Spring, River, Stream, Trench} + + ## Springs + + def list_springs do + Repo.all(Spring) + end + + def get_spring!(id), do: Repo.get!(Spring, id) + + def create_spring(attrs \\ %{}) do + %Spring{} + |> Spring.changeset(attrs) + |> Repo.insert() + end + + def update_spring(%Spring{} = spring, attrs) do + spring + |> Spring.changeset(attrs) + |> Repo.update() + end + + def delete_spring(%Spring{} = spring) do + Repo.delete(spring) + end + + def change_spring(%Spring{} = spring, attrs \\ %{}) do + Spring.changeset(spring, attrs) + end + + ## Rivers + + def list_rivers do + Repo.all(River) + end + + def get_river!(id), do: Repo.get!(River, id) + + def create_river(attrs \\ %{}) do + %River{} + |> River.changeset(attrs) + |> Repo.insert() + end + + def update_river(%River{} = river, attrs) do + river + |> River.changeset(attrs) + |> Repo.update() + end + + def delete_river(%River{} = river) do + Repo.delete(river) + end + + def change_river(%River{} = river, attrs \\ %{}) do + River.changeset(river, attrs) + end + + ## Streams + + def list_streams do + Repo.all(Stream) + end + + def get_stream!(id), do: Repo.get!(Stream, id) + + def create_stream(attrs \\ %{}) do + %Stream{} + |> Stream.changeset(attrs) + |> Repo.insert() + end + + def update_stream(%Stream{} = stream, attrs) do + stream + |> Stream.changeset(attrs) + |> Repo.update() + end + + def delete_stream(%Stream{} = stream) do + Repo.delete(stream) + end + + def change_stream(%Stream{} = stream, attrs \\ %{}) do + Stream.changeset(stream, attrs) + end + + ## Trenches + + def list_trenches do + Repo.all(Trench) + end + + def get_trench!(id), do: Repo.get!(Trench, id) + + def create_trench(attrs \\ %{}) do + %Trench{} + |> Trench.changeset(attrs) + |> Repo.insert() + end + + def update_trench(%Trench{} = trench, attrs) do + trench + |> Trench.changeset(attrs) + |> Repo.update() + end + + def delete_trench(%Trench{} = trench) do + Repo.delete(trench) + end + + def change_trench(%Trench{} = trench, attrs \\ %{}) do + Trench.changeset(trench, attrs) + end +end diff --git a/river_connect/lib/river_connect/hierarchy/river.ex b/river_connect/lib/river_connect/hierarchy/river.ex new file mode 100644 index 0000000..f031d28 --- /dev/null +++ b/river_connect/lib/river_connect/hierarchy/river.ex @@ -0,0 +1,22 @@ +defmodule RiverConnect.Hierarchy.River do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:id, :name, :description, :spring_id, :inserted_at, :updated_at]} + schema "rivers" do + field :name, :string + field :description, :string + + belongs_to :spring, RiverConnect.Hierarchy.Spring + has_many :streams, RiverConnect.Hierarchy.Stream + + timestamps() + end + + @doc false + def changeset(river, attrs) do + river + |> cast(attrs, [:name, :description, :spring_id]) + |> validate_required([:name, :spring_id]) + end +end diff --git a/river_connect/lib/river_connect/hierarchy/spring.ex b/river_connect/lib/river_connect/hierarchy/spring.ex new file mode 100644 index 0000000..7facbe5 --- /dev/null +++ b/river_connect/lib/river_connect/hierarchy/spring.ex @@ -0,0 +1,24 @@ +defmodule RiverConnect.Hierarchy.Spring do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:id, :name, :type, :description, :inserted_at, :updated_at]} + schema "springs" do + field :name, :string + + # consider enum Ecto.Enum, values: [:dm, :job, :custom] if using atoms, but string is flexible for now + field :type, :string + field :description, :string + + has_many :rivers, RiverConnect.Hierarchy.River + + timestamps() + end + + @doc false + def changeset(spring, attrs) do + spring + |> cast(attrs, [:name, :type, :description]) + |> validate_required([:name, :type]) + end +end diff --git a/river_connect/lib/river_connect/hierarchy/stream.ex b/river_connect/lib/river_connect/hierarchy/stream.ex new file mode 100644 index 0000000..79f47e8 --- /dev/null +++ b/river_connect/lib/river_connect/hierarchy/stream.ex @@ -0,0 +1,22 @@ +defmodule RiverConnect.Hierarchy.Stream do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:id, :name, :topic, :river_id, :inserted_at, :updated_at]} + schema "streams" do + field :name, :string + field :topic, :string + + belongs_to :river, RiverConnect.Hierarchy.River + has_many :trenches, RiverConnect.Hierarchy.Trench + + timestamps() + end + + @doc false + def changeset(stream, attrs) do + stream + |> cast(attrs, [:name, :topic, :river_id]) + |> validate_required([:name, :river_id]) + end +end diff --git a/river_connect/lib/river_connect/hierarchy/trench.ex b/river_connect/lib/river_connect/hierarchy/trench.ex new file mode 100644 index 0000000..f10c4fe --- /dev/null +++ b/river_connect/lib/river_connect/hierarchy/trench.ex @@ -0,0 +1,20 @@ +defmodule RiverConnect.Hierarchy.Trench do + use Ecto.Schema + import Ecto.Changeset + + @derive {Jason.Encoder, only: [:id, :name, :stream_id, :inserted_at, :updated_at]} + schema "trenches" do + field :name, :string + + belongs_to :stream, RiverConnect.Hierarchy.Stream + + timestamps() + end + + @doc false + def changeset(trench, attrs) do + trench + |> cast(attrs, [:name, :stream_id]) + |> validate_required([:stream_id]) + end +end diff --git a/river_connect/lib/river_connect_web/live/audio_live.ex b/river_connect/lib/river_connect_web/live/audio_live.ex index b63f8d8..99b38c4 100644 --- a/river_connect/lib/river_connect_web/live/audio_live.ex +++ b/river_connect/lib/river_connect_web/live/audio_live.ex @@ -46,95 +46,227 @@ defmodule RiverConnectWeb.AudioLive do @impl true def render(assigns) do ~H""" -
-

Voxer Clone

- -
-

Recent Messages

+
+ +
+
+
+ R +
+ +
+

+ Rivertalk Audio +

+ +
+ + + Live Workspace + +
+
+
+ +
+ +
<%= if Enum.empty?(@messages) do %> -

No messages yet. Start talking!

- <% end %> - - <%= for message <- @messages do %> -
-
- {message.user_id} - {Calendar.strftime(message.inserted_at, "%H:%M:%S")} +
+
+ <.icon name="hero-microphone" class="w-12 h-12 text-slate-400" />
- <%= if message.status == "ready" do %> - -
{message.duration_ms}ms
- <% else %> -
- <%= if message.status == "recording" do %> - - - - - 🔴 LIVE - - <% else %> - - - - - - - - Processing... - <% end %> +

+ No messages yet.
Start the conversation! +

+
+ <% end %> + + <%= for message <- Enum.reverse(@messages) do %> +
+
+ +
user_color(message.user_id)}> + {user_initials(message.user_id)}
- <% end %> + +
+
+ + {message.user_id} + + + {Calendar.strftime(message.inserted_at, "%I:%M %p")} + +
+ +
+ <%= if message.status == "ready" do %> +
+ +
+ {format_duration(message.duration_ms)} +
+
+ <% else %> +
+
+ <%= if message.status == "recording" do %> + + + + + + + Recording Live + + <% else %> +
+
+ + + Optimizing audio... + + <% end %> +
+ + <%= if message.status == "recording" do %> + + <% end %> +
+ <% end %> +
+
+
<% end %>
- -
- -

if(@recording, do: "text-red-600", else: "text-gray-600")}> - {if @recording, do: "Click to finish", else: "Click to Talk"} -

-
+ +
+
+ +
+ +
+
+ + +
+ +
+

+ {if @recording, do: "TRANSMITTING...", else: "HOLD TO SPEAK"} +

+
+
+
+ + """ end + + # UI Helpers + + defp user_initials(nil), do: "?" + + defp user_initials(id) do + id + |> String.split([" ", "-", "_", "."]) + |> Enum.map(&String.at(&1, 0)) + |> Enum.take(2) + |> Enum.join("") + |> String.upcase() + end + + defp user_color(id) do + colors = [ + "bg-pink-500", + "bg-purple-500", + "bg-indigo-500", + "bg-blue-500", + "bg-cyan-500", + "bg-teal-500", + "bg-emerald-500", + "bg-orange-500", + "bg-rose-500" + ] + + # Simple hash to deterministic color + index = :erlang.phash2(id, length(colors)) + Enum.at(colors, index) + end + + defp format_duration(nil), do: "0:00" + + defp format_duration(ms) do + total_seconds = div(ms, 1000) + minutes = div(total_seconds, 60) + seconds = rem(total_seconds, 60) + "#{minutes}:#{String.pad_leading(Integer.to_string(seconds), 2, "0")}" + end end diff --git a/river_connect/lib/river_connect_web/live/hierarchy_live.ex b/river_connect/lib/river_connect_web/live/hierarchy_live.ex new file mode 100644 index 0000000..8f579e8 --- /dev/null +++ b/river_connect/lib/river_connect_web/live/hierarchy_live.ex @@ -0,0 +1,60 @@ +defmodule RiverConnectWeb.HierarchyLive do + use RiverConnectWeb, :live_view + alias RiverConnect.Hierarchy + + def mount(_params, _session, socket) do + if connected?(socket) do + # Fetch initial data + springs = Hierarchy.list_springs() + # We might want to preload structure, but for now just springs. + # To make the UI snappy, let's just send springs. + # But the UI needs to know which one is active. + # Let's say we default to the first one or none. + + {:ok, + socket + |> assign(springs: springs) + |> assign(context: %{active_spring_id: nil, active_river_id: nil, active_stream_id: nil})} + else + {:ok, assign(socket, springs: [], context: %{})} + end + end + + def render(assigns) do + ~H""" +
+
+
+
+ """ + end + + # Handle event from client to fetch rivers for a spring + def handle_event("fetch_rivers", %{"spring_id" => spring_id}, socket) do + spring = Hierarchy.get_spring!(spring_id) |> RiverConnect.Repo.preload(:rivers) + + {:noreply, + push_event(socket, "update_rivers", %{spring_id: spring.id, rivers: spring.rivers})} + end + + # Handle event from client to fetch streams for a river + def handle_event("fetch_streams", %{"river_id" => river_id}, socket) do + river = Hierarchy.get_river!(river_id) |> RiverConnect.Repo.preload(:streams) + + {:noreply, + push_event(socket, "update_streams", %{river_id: river.id, streams: river.streams})} + end + + # Handle event from client to fetch trenches for a stream + def handle_event("fetch_trenches", %{"stream_id" => stream_id}, socket) do + stream = Hierarchy.get_stream!(stream_id) |> RiverConnect.Repo.preload(:trenches) + + {:noreply, + push_event(socket, "update_trenches", %{stream_id: stream.id, trenches: stream.trenches})} + end +end diff --git a/river_connect/lib/river_connect_web/router.ex b/river_connect/lib/river_connect_web/router.ex index bfb5435..f31b493 100644 --- a/river_connect/lib/river_connect_web/router.ex +++ b/river_connect/lib/river_connect_web/router.ex @@ -20,6 +20,7 @@ defmodule RiverConnectWeb.Router do get "/", PageController, :home live "/audio", AudioLive live "/solid-demo", SolidDemoLive + live "/hierarchy", HierarchyLive end # Other scopes may use custom stacks. diff --git a/river_connect/priv/repo/migrations/20260214234844_create_hierarchy.exs b/river_connect/priv/repo/migrations/20260214234844_create_hierarchy.exs new file mode 100644 index 0000000..491ab06 --- /dev/null +++ b/river_connect/priv/repo/migrations/20260214234844_create_hierarchy.exs @@ -0,0 +1,43 @@ +defmodule RiverConnect.Repo.Migrations.CreateHierarchy do + use Ecto.Migration + + def change do + create table(:springs) do + add :name, :string, null: false + # :dm, :job, :custom + add :type, :string, null: false + add :description, :text + + timestamps() + end + + create table(:rivers) do + add :name, :string, null: false + add :description, :text + add :spring_id, references(:springs, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:rivers, [:spring_id]) + + create table(:streams) do + add :name, :string, null: false + add :topic, :string + add :river_id, references(:rivers, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:streams, [:river_id]) + + create table(:trenches) do + add :name, :string + add :stream_id, references(:streams, on_delete: :delete_all), null: false + + timestamps() + end + + create index(:trenches, [:stream_id]) + end +end diff --git a/river_connect/priv/repo/seeds.exs b/river_connect/priv/repo/seeds.exs index ad9d570..78c5f20 100644 --- a/river_connect/priv/repo/seeds.exs +++ b/river_connect/priv/repo/seeds.exs @@ -1,11 +1,73 @@ -# Script for populating the database. You can run it as: -# -# mix run priv/repo/seeds.exs -# -# Inside the script, you can read and write to any of your -# repositories directly: -# -# RiverConnect.Repo.insert!(%RiverConnect.SomeSchema{}) -# -# We recommend using the bang functions (`insert!`, `update!` -# and so on) as they will fail if something goes wrong. +actions = [ + fn -> + alias RiverConnect.Hierarchy + alias RiverConnect.Repo + + # 1. Create DM Spring + case Repo.get_by(Hierarchy.Spring, name: "Direct Messages") do + nil -> + {:ok, spring} = + Hierarchy.create_spring(%{ + name: "Direct Messages", + type: "dm", + description: "Private conversations between users" + }) + + IO.puts("Created 'Direct Messages' Spring") + spring + + spring -> + IO.puts("'Direct Messages' Spring already exists") + spring + end + + # 2. Create Jobs Spring + {:ok, jobs_spring} = + case Repo.get_by(Hierarchy.Spring, name: "Jobs") do + nil -> + {:ok, spring} = + Hierarchy.create_spring(%{ + name: "Jobs", + type: "job", + description: "Job-specific rivers" + }) + + IO.puts("Created 'Jobs' Spring") + {:ok, spring} + + spring -> + IO.puts("'Jobs' Spring already exists") + {:ok, spring} + end + + # 3. Create a sample River in Jobs + case Repo.get_by(Hierarchy.River, name: "General Construction", spring_id: jobs_spring.id) do + nil -> + {:ok, river} = + Hierarchy.create_river(%{ + name: "General Construction", + description: "General discussions about construction jobs", + spring_id: jobs_spring.id + }) + + IO.puts("Created 'General Construction' River") + + # 4. Create sample Stream + {:ok, _stream} = + Hierarchy.create_stream(%{ + name: "Announcements", + topic: "Important announcements", + river_id: river.id + }) + + IO.puts("Created 'Announcements' Stream") + + _ -> + IO.puts("'General Construction' River already exists") + end + end +] + +for action <- actions do + action.() +end diff --git a/river_connect/verify_hierarchy.exs b/river_connect/verify_hierarchy.exs new file mode 100644 index 0000000..91562ed --- /dev/null +++ b/river_connect/verify_hierarchy.exs @@ -0,0 +1,68 @@ +alias RiverConnect.Hierarchy +alias RiverConnect.Hierarchy.{Spring, River, Stream, Trench} +alias RiverConnect.Repo +import Ecto.Query + +# Clean up existing data to avoid conflicts +Repo.delete_all(Trench) +Repo.delete_all(Stream) +Repo.delete_all(River) +Repo.delete_all(Spring) + +IO.puts("Cleaned up existing data.") + +try do + # Create Spring + case Hierarchy.create_spring(%{ + name: "Engineering", + type: "job", + description: "Engineering Department" + }) do + {:ok, spring} -> + IO.puts("Created Spring: #{spring.name}") + + # Create River + case Hierarchy.create_river(%{ + name: "Backend", + description: "Backend Team", + spring_id: spring.id + }) do + {:ok, river} -> + IO.puts("Created River: #{river.name}") + + # Create Stream + case Hierarchy.create_stream(%{ + name: "Elixir", + topic: "Elixir Development", + river_id: river.id + }) do + {:ok, stream} -> + IO.puts("Created Stream: #{stream.name}") + + # Create Trench + case Hierarchy.create_trench(%{ + name: "Architecture Discussion", + stream_id: stream.id + }) do + {:ok, trench} -> + IO.puts("Created Trench: #{trench.name}") + IO.puts("Verification Successful!") + + {:error, changeset} -> + IO.inspect(changeset, label: "Trench Creation Error") + end + + {:error, changeset} -> + IO.inspect(changeset, label: "Stream Creation Error") + end + + {:error, changeset} -> + IO.inspect(changeset, label: "River Creation Error") + end + + {:error, changeset} -> + IO.inspect(changeset, label: "Spring Creation Error") + end +rescue + e -> IO.inspect(e, label: "Unexpected Error") +end