built basic ui structure in /hierarchy

This commit is contained in:
2026-02-14 18:03:13 -06:00
parent d645bda942
commit e4e0775472
24 changed files with 1174 additions and 106 deletions
@@ -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.
@@ -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.
+41
View File
@@ -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 `<Popover.Portal>` or `<Dialog.Portal>` 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";
<Popover.Root>
<Popover.Trigger class="...">...</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="...">
<Popover.Arrow />
<Popover.Title class="...">...</Popover.Title>
<Popover.Description class="...">...</Popover.Description>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
```
## 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.
@@ -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`.
+1
View File
@@ -0,0 +1 @@
{}
+1 -1
View File
@@ -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
+47
View File
@@ -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=="],
+23
View File
@@ -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);
}
+2 -1
View File
@@ -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
@@ -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 (
<div class="flex h-screen bg-gray-900 text-white font-sans">
{/* 1. Springs (Servers) Column */}
<div class="w-20 flex flex-col items-center py-4 bg-gray-950 border-r border-gray-800 space-y-4">
<For each={props.state.springs}>
{(spring) => (
<button
onClick={() => handleSpringClick(spring.id)}
class={`w-12 h-12 rounded-full flex items-center justify-center transition-all duration-200 hover:rounded-2xl ${props.state.activeSpringId === spring.id
? "bg-indigo-500 rounded-2xl shadow-lg ring-2 ring-indigo-400"
: "bg-gray-800 hover:bg-indigo-600"
}`}
title={spring.name}
>
{spring.name.substring(0, 2).toUpperCase()}
</button>
)}
</For>
<button class="w-12 h-12 rounded-full bg-gray-800 hover:bg-green-600 flex items-center justify-center text-green-400 hover:text-white transition-all duration-200 hover:rounded-2xl">
<span class="text-2xl">+</span>
</button>
</div>
{/* 2. Rivers & Streams (Channels) Column */}
<Show when={props.state.activeSpringId}>
<div class="w-64 bg-gray-900 flex flex-col border-r border-gray-800">
{/* Header */}
<div class="h-12 border-b border-gray-800 flex items-center px-4 font-bold shadow-sm">
{props.state.springs.find(s => s.id === props.state.activeSpringId)?.name || "Spring"}
</div>
{/* Rivers List */}
<div class="flex-1 overflow-y-auto p-2 space-y-2">
<Show when={props.state.rivers[props.state.activeSpringId]} fallback={<div class="p-4 text-gray-500 text-sm">Loading rivers...</div>}>
<For each={props.state.rivers[props.state.activeSpringId]}>
{(river) => (
<div class="mb-2">
<div
class="px-2 py-1 text-xs font-semibold text-gray-400 uppercase tracking-wider hover:text-gray-200 cursor-pointer flex items-center justify-between group"
onClick={() => handleRiverClick(river.id)}
>
<span>v {river.name}</span>
<span class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-white">+</span>
</div>
{/* Streams in this river */}
<Show when={props.state.streams[river.id]}>
<div class="mt-1 space-y-0.5">
<For each={props.state.streams[river.id]}>
{(stream) => (
<div
onClick={() => 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" : ""
}`}
>
<span class="mr-1 text-gray-500">#</span>
{stream.name}
</div>
)}
</For>
</div>
</Show>
</div>
)}
</For>
</Show>
</div>
</div>
</Show>
{/* 3. Main Stream (Chat) Area */}
<div class="flex-1 flex flex-col bg-gray-800">
<Show when={props.state.activeStreamId} fallback={
<div class="flex-1 flex items-center justify-center text-gray-500 flex-col">
<div class="text-4xl mb-4">👋</div>
<p>Select a stream to start chatting</p>
</div>
}>
{/* Header */}
<div class="h-12 border-b border-gray-700 flex items-center px-4 shadow-sm bg-gray-900 justify-between">
<div class="flex items-center">
<span class="text-2xl text-gray-500 mr-2">#</span>
<span class="font-bold">
{/* Need to find stream name across all rivers? inefficient.
Better to store activeStream details? Or just find it. */}
Stream Name
</span>
</div>
</div>
{/* Messages */}
<div class="flex-1 overflow-y-auto p-4 space-y-4">
{/* Placeholder messages */}
<div class="flex items-start">
<div class="w-10 h-10 rounded-full bg-gray-600 mr-3"></div>
<div>
<div class="flex items-baseline">
<span class="font-bold mr-2 text-white">User A</span>
<span class="text-xs text-gray-400">10:30 AM</span>
</div>
<p class="text-gray-300">Welcome to the stream!</p>
</div>
</div>
<Show when={props.state.trenches[props.state.activeStreamId]}>
<div class="pl-12 pt-2">
<div class="text-xs font-bold text-gray-400 mb-1">Trenches (Threads):</div>
<For each={props.state.trenches[props.state.activeStreamId]}>
{(trench) => (
<div class="text-sm text-indigo-400 hover:underline cursor-pointer">
{trench.name}
</div>
)}
</For>
</div>
</Show>
</div>
{/* Input */}
<div class="p-4 bg-gray-900">
<input
type="text"
class="w-full bg-gray-800 text-white rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Message #stream"
/>
</div>
</Show>
</div>
</div>
);
}
+42 -13
View File
@@ -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 (
<div class="p-6 border-2 border-blue-500 rounded-lg bg-white shadow-md">
<h2 class="text-xl font-semibold text-blue-700 mb-4">Solid.js Component</h2>
<div class="p-6 border-2 border-blue-500 rounded-2xl bg-white shadow-xl max-w-sm mx-auto">
<h2 class="text-2xl font-black text-blue-700 mb-6 tracking-tight">Solid + Kobalte</h2>
<div class="flex items-center justify-between mb-8 p-4 bg-slate-50 rounded-xl border border-slate-100">
<div class="flex flex-col">
<span class="text-xs font-bold text-slate-400 uppercase tracking-widest">Current Count</span>
<span class="text-5xl font-black text-slate-900">{props.state.count}</span>
</div>
<div class="flex items-center space-x-4 mb-6">
<span class="text-4xl font-bold">{props.state.count}</span>
<div class="flex flex-col space-y-2">
<button
onClick={increment}
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
class="w-12 h-12 bg-indigo-600 text-white rounded-full flex items-center justify-center hover:bg-indigo-700 transition-all active:scale-90 shadow-lg shadow-indigo-200"
>
Increment
<span class="text-2xl">+</span>
</button>
<button
onClick={decrement}
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
class="w-12 h-12 bg-slate-200 text-slate-600 rounded-full flex items-center justify-center hover:bg-slate-300 transition-all active:scale-90"
>
Decrement
<span class="text-2xl"></span>
</button>
</div>
</div>
<p class="text-sm text-gray-500 italic">
This UI is rendered by Solid.js. Changes are synced back to Elixir via push_event.
<div class="flex justify-center">
<Popover.Root>
<Popover.Trigger class="px-6 py-2.5 bg-slate-900 text-white text-sm font-bold rounded-full hover:bg-slate-800 transition-colors flex items-center space-x-2">
<span>Kobalte Info</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content class="z-50 w-64 p-4 bg-white rounded-xl shadow-2xl border border-slate-100 animate-in fade-in zoom-in-95 duration-200 origin-top">
<Popover.Arrow class="text-white fill-current" />
<div class="flex flex-col">
<Popover.Title class="text-sm font-bold text-slate-900 mb-1">Kobalte Logic</Popover.Title>
<Popover.Description class="text-xs text-slate-500 leading-relaxed">
Kobalte is a headless UI toolkit for Solid.js. It provides the logic while you provide the styles!
</Popover.Description>
<Popover.CloseButton class="absolute top-2 right-2 text-slate-400 hover:text-slate-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Popover.CloseButton>
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
<p class="mt-8 text-[10px] text-slate-400 font-bold uppercase tracking-tighter text-center">
Bridge: Phoenix LiveView &harr; Solid.js
</p>
</div>
);
@@ -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(() => (
<HierarchyApp
state={state}
push={(e, p) => 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();
}
};
+1
View File
@@ -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",
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -46,95 +46,227 @@ defmodule RiverConnectWeb.AudioLive do
@impl true
def render(assigns) do
~H"""
<div class="p-4 max-w-md mx-auto h-screen flex flex-col">
<h1 class="text-2xl font-bold mb-4 text-center">Voxer Clone</h1>
<div class="flex-1 overflow-y-auto mb-4 space-y-4 pr-2">
<h2 class="text-xl font-semibold sticky top-0 bg-white p-2 border-b">Recent Messages</h2>
<div class="max-w-md mx-auto h-screen flex flex-col bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl overflow-hidden">
<!-- Header -->
<header class="p-6 flex items-center justify-between border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md sticky top-0 z-10">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 rounded-full bg-indigo-600 flex items-center justify-center text-white shadow-lg">
<span class="text-xl font-bold">R</span>
</div>
<div>
<h1 class="text-lg font-bold text-slate-900 dark:text-white leading-tight">
Rivertalk Audio
</h1>
<div class="flex items-center space-x-1.5">
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
<span class="text-[10px] uppercase tracking-wider font-semibold text-slate-500 dark:text-slate-400">
Live Workspace
</span>
</div>
</div>
</div>
<button class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
<.icon name="hero-ellipsis-vertical" class="w-6 h-6 text-slate-500" />
</button>
</header>
<!-- Message List -->
<div class="flex-1 overflow-y-auto px-4 py-6 space-y-6 scroll-smooth custom-scrollbar">
<%= if Enum.empty?(@messages) do %>
<p class="text-gray-500 text-center py-8">No messages yet. Start talking!</p>
<% end %>
<%= for message <- @messages do %>
<div class="bg-gray-50 p-3 rounded-lg shadow-sm border border-gray-200">
<div class="flex justify-between text-xs text-gray-500 mb-2">
<span class="font-mono truncate w-1/2" title={message.user_id}>{message.user_id}</span>
<span>{Calendar.strftime(message.inserted_at, "%H:%M:%S")}</span>
<div class="flex flex-col items-center justify-center h-full opacity-40 py-20">
<div class="w-24 h-24 rounded-full bg-slate-200 dark:bg-slate-800 flex items-center justify-center mb-4">
<.icon name="hero-microphone" class="w-12 h-12 text-slate-400" />
</div>
<%= if message.status == "ready" do %>
<audio controls src={message.file_path} class="w-full h-8"></audio>
<div class="text-right text-xs text-gray-400 mt-1">{message.duration_ms}ms</div>
<% else %>
<div class={[
"flex items-center justify-center p-2 text-xs rounded animate-pulse",
message.status == "recording" && "bg-red-50 text-red-700",
message.status != "recording" && "bg-yellow-50 text-yellow-700"
]}>
<%= if message.status == "recording" do %>
<span class="flex h-2 w-2 mr-2">
<span class="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-red-400 opacity-75">
</span> <span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
🔴 LIVE
<button
type="button"
phx-click={Phoenix.LiveView.JS.dispatch("play_live", to: "#audio-recorder")}
class="ml-3 bg-red-600 hover:bg-red-700 text-white px-2 py-1 rounded text-[10px] font-bold shadow-sm active:scale-95 transition-all"
>
▶ PLAY
</button>
<% else %>
<svg class="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
>
</circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
Processing...
<% end %>
<p class="text-slate-500 text-center font-medium">
No messages yet.<br />Start the conversation!
</p>
</div>
<% end %>
<%= for message <- Enum.reverse(@messages) do %>
<div class="group animate-in fade-in slide-in-from-bottom-2 duration-300">
<div class="flex items-start space-x-3">
<!-- Avatar -->
<div class={"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm " <> user_color(message.user_id)}>
{user_initials(message.user_id)}
</div>
<% end %>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-baseline justify-between mb-1">
<span class="text-xs font-semibold text-slate-700 dark:text-slate-300 truncate pr-4">
{message.user_id}
</span>
<span class="text-[10px] font-medium text-slate-400">
{Calendar.strftime(message.inserted_at, "%I:%M %p")}
</span>
</div>
<div class="relative">
<%= if message.status == "ready" do %>
<div class="glass dark:bg-slate-800/40 p-1.5 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700/50 hover:shadow-md transition-shadow">
<audio controls src={message.file_path} class="w-full h-10"></audio>
<div class="absolute -bottom-4 right-1 text-[9px] font-bold text-slate-400 uppercase tracking-tighter">
{format_duration(message.duration_ms)}
</div>
</div>
<% else %>
<div class={[
"flex items-center justify-between p-3 rounded-2xl border transition-all duration-300",
message.status == "recording" &&
"bg-red-50/50 border-red-100 text-red-700 dark:bg-red-900/10 dark:border-red-900/30",
message.status != "recording" &&
"bg-amber-50/50 border-amber-100 text-amber-700 dark:bg-amber-900/10 dark:border-amber-900/30"
]}>
<div class="flex items-center space-x-3">
<%= if message.status == "recording" do %>
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75">
</span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
</span>
<span class="text-xs font-bold tracking-widest uppercase">
Recording Live
</span>
<% else %>
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-amber-600">
</div>
<span class="text-xs font-bold tracking-widest uppercase italic">
Optimizing audio...
</span>
<% end %>
</div>
<%= if message.status == "recording" do %>
<button
type="button"
phx-click={Phoenix.LiveView.JS.dispatch("play_live", to: "#audio-recorder")}
class="bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded-full text-[10px] font-bold shadow-md active:scale-95 transition-all"
>
LISTEN
</button>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
<div
id="audio-recorder"
phx-hook="AudioRecorder"
class="pb-8 pt-4 border-t bg-gray-50 -mx-4 px-4 flex flex-col items-center justify-center"
>
<button
id="record-btn"
class={"w-24 h-24 rounded-full flex items-center justify-center text-white text-4xl shadow-lg transition-all transform active:scale-95 " <>
if(@recording, do: "bg-red-600 animate-pulse ring-4 ring-red-200", else: "bg-blue-600 hover:bg-blue-700 hover:shadow-xl")}
>
<span class={
if @recording,
do:
"animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75",
else: "hidden"
}>
</span> {if @recording, do: "🎤", else: "🎙️"}
</button>
<p class={"mt-3 font-medium transition-colors " <> if(@recording, do: "text-red-600", else: "text-gray-600")}>
{if @recording, do: "Click to finish", else: "Click to Talk"}
</p>
</div>
<!-- Footer / Recorder -->
<footer class="p-8 pb-10 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 relative shadow-inner">
<div id="audio-recorder" phx-hook="AudioRecorder" class="flex flex-col items-center">
<!-- Progress ring container -->
<div class="relative group">
<!-- Glow effect -->
<div class={[
"absolute inset-0 rounded-full transition-all duration-700 blur-2xl opacity-40",
@recording && "bg-red-500 animate-pulse-glow"
]}>
</div>
<button
id="record-btn"
class={[
"relative z-10 w-28 h-28 rounded-full flex flex-col items-center justify-center text-white transition-all duration-500 shadow-2xl active:scale-90 overflow-hidden",
@recording && "bg-gradient-to-br from-red-500 to-rose-700 scale-110",
!@recording &&
"bg-gradient-to-br from-indigo-500 to-blue-700 hover:shadow-indigo-500/50"
]}
>
<div class="text-4xl mb-1 transform transition-transform duration-500">
{if @recording, do: "⏹", else: "🎤"}
</div>
<span class="text-[10px] font-black tracking-widest uppercase opacity-80">
{if @recording, do: "STOP", else: "HOLD"}
</span>
<!-- Waveform bars animation during recording -->
<%= if @recording do %>
<div class="absolute bottom-4 flex items-end justify-center space-x-0.5 h-6">
<%= for i <- 1..8 do %>
<div class={"w-1 bg-white/40 rounded-full animate-waveform-" <> Integer.to_string(rem(i, 4) + 1)}>
</div>
<% end %>
</div>
<% end %>
</button>
</div>
<div class="mt-6 flex flex-col items-center">
<p class={[
"text-xs font-bold tracking-widest uppercase transition-all duration-300",
@recording && "text-red-500",
!@recording && "text-slate-400"
]}>
{if @recording, do: "TRANSMITTING...", else: "HOLD TO SPEAK"}
</p>
</div>
</div>
</footer>
</div>
<style>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.dark .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; }
<%= if @recording do %>
@keyframes waveform {
0%, 100% { height: 4px; opacity: 0.3; }
50% { height: 16px; opacity: 1; }
}
.animate-waveform-1 { animation: waveform 0.6s ease-in-out infinite; }
.animate-waveform-2 { animation: waveform 0.8s ease-in-out infinite 0.1s; }
.animate-waveform-3 { animation: waveform 0.5s ease-in-out infinite 0.2s; }
.animate-waveform-4 { animation: waveform 0.7s ease-in-out infinite 0.3s; }
<% end %>
</style>
"""
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
@@ -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"""
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden">
<div
id="hierarchy-app"
phx-update="ignore"
phx-hook="HierarchyBridge"
data-initial-springs={Jason.encode!(@springs)}
>
</div>
</div>
"""
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
@@ -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.
@@ -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
+73 -11
View File
@@ -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
+68
View File
@@ -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