Compare commits
2 Commits
4970133b83
...
e4e0775472
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e0775472 | |||
| d645bda942 |
@@ -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.
|
||||
@@ -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`.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,53 @@
|
||||
const esbuild = require("esbuild");
|
||||
const { solidPlugin } = require("esbuild-plugin-solid");
|
||||
const path = require("path");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const watch = args.includes("--watch");
|
||||
const deploy = args.includes("--deploy");
|
||||
|
||||
const plugins = [
|
||||
solidPlugin(),
|
||||
];
|
||||
|
||||
const nodePaths = [
|
||||
path.resolve(__dirname, "../deps"),
|
||||
path.resolve(__dirname, "../_build/dev/lib")
|
||||
];
|
||||
|
||||
let opts = {
|
||||
entryPoints: ["js/app.js"],
|
||||
bundle: true,
|
||||
logLevel: "info",
|
||||
target: "es2022",
|
||||
outdir: "../priv/static/assets/js",
|
||||
external: ["/fonts/*", "/images/*"],
|
||||
alias: {
|
||||
"phoenix-colocated/river_connect": path.resolve(__dirname, "js/phoenix-colocated-shim.js")
|
||||
},
|
||||
loader: {
|
||||
".js": "jsx",
|
||||
},
|
||||
plugins: plugins,
|
||||
};
|
||||
|
||||
process.env.NODE_PATH = nodePaths.join(path.delimiter);
|
||||
|
||||
if (deploy) {
|
||||
opts = {
|
||||
...opts,
|
||||
minify: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (watch) {
|
||||
opts = {
|
||||
...opts,
|
||||
sourcemap: "inline",
|
||||
};
|
||||
esbuild.context(opts).then(ctx => {
|
||||
ctx.watch();
|
||||
});
|
||||
} else {
|
||||
esbuild.build(opts).catch(() => process.exit(1));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"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",
|
||||
"solid-js": "^1.8.15",
|
||||
"topbar": "^2.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-solid": "^0.6.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="],
|
||||
|
||||
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
|
||||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="],
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="],
|
||||
|
||||
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="],
|
||||
|
||||
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"esbuild-plugin-solid": ["esbuild-plugin-solid@0.6.0", "", { "dependencies": { "@babel/core": "^7.20.12", "@babel/preset-typescript": "^7.18.6", "babel-preset-solid": "^1.6.9" }, "peerDependencies": { "esbuild": ">=0.20", "solid-js": ">= 1.0" } }, "sha512-V1FvDALwLDX6K0XNYM9CMRAnMzA0+Ecu55qBUT9q/eAJh1KIDsTMFoOzMSgyHqbOfvrVfO3Mws3z7TW2GVnIZA=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"morphdom": ["morphdom@2.7.8", "", {}, "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"phoenix": ["phoenix@1.8.3", "", {}, "sha512-5bMYQI30wl3erxbHnXMdt1xuQeRTeEOpQrakf3yqj/1HRHl7Gj4Cdk2NKXkUcCD5WpbxrilvZEMexM1VhWbnDg=="],
|
||||
|
||||
"phoenix_html": ["phoenix_html@4.3.0", "", {}, "sha512-6vnR2Y7KxEPX1DdN59AuO98TTFtsYBqfUiUOo3NY6vyVp3o94RTawdaKtELFOKfiUD7LCEclBcpCltJM2GWuKw=="],
|
||||
|
||||
"phoenix_live_view": ["phoenix_live_view@1.1.23", "", { "dependencies": { "morphdom": "2.7.8" } }, "sha512-P6yLsrjzDIK0S8ios5xHegvZnb4mUYKMp0CfO3kqVvrdlELEi9k4FWkdYMbXortujC9I+ELWIZcfZqYIhnn01Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,12 +26,14 @@ import { hooks as colocatedHooks } from "phoenix-colocated/river_connect"
|
||||
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 },
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSignal } from "solid-js";
|
||||
import { Popover } from "@kobalte/core";
|
||||
|
||||
export function SolidApp(props) {
|
||||
// Local state for optimistic updates or other UI-only logic
|
||||
const [isUpdating, setIsUpdating] = createSignal(false);
|
||||
|
||||
const increment = () => {
|
||||
setIsUpdating(true);
|
||||
props.push("increment", {});
|
||||
};
|
||||
|
||||
const decrement = () => {
|
||||
setIsUpdating(true);
|
||||
props.push("decrement", {});
|
||||
};
|
||||
|
||||
return (
|
||||
<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 flex-col space-y-2">
|
||||
<button
|
||||
onClick={increment}
|
||||
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"
|
||||
>
|
||||
<span class="text-2xl">+</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={decrement}
|
||||
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"
|
||||
>
|
||||
<span class="text-2xl">−</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ↔ 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();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from "solid-js/web";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { SolidApp } from "../components/SolidApp";
|
||||
|
||||
export const SolidBridge = {
|
||||
mounted() {
|
||||
console.log("SolidBridge mounted");
|
||||
|
||||
// 1. Initialize a reactive store with initial data from the element attributes
|
||||
const initialCount = parseInt(this.el.dataset.initialCount || "0");
|
||||
const [state, setState] = createStore({
|
||||
count: initialCount
|
||||
});
|
||||
|
||||
// 2. Render Solid and pass the 'push' helper
|
||||
// We store the cleanup function to call on destroyed()
|
||||
this._cleanup = render(() => (
|
||||
<SolidApp
|
||||
state={state}
|
||||
push={(e, p) => this.pushEvent(e, p)}
|
||||
/>
|
||||
), this.el);
|
||||
|
||||
// 3. Sync server state to Solid store
|
||||
this.handleEvent("sync_state", (payload) => {
|
||||
console.log("Received sync_state:", payload);
|
||||
setState(payload);
|
||||
});
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const hooks = {};
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "river_connect_assets",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "js/app.js",
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "test"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"watch": "node build.js --watch"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.11",
|
||||
"phoenix": "^1.8.3",
|
||||
"phoenix_html": "^4.1.1",
|
||||
"phoenix_live_view": "^1.1.0",
|
||||
"solid-js": "^1.8.15",
|
||||
"topbar": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"esbuild-plugin-solid": "^0.6.0"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ config :river_connect, RiverConnectWeb.Endpoint,
|
||||
debug_errors: true,
|
||||
secret_key_base: "yzfuJmd+Bd93mCTaH1Yo5MNn1buUd4qrO5BoJKfgvSgbSAP3Pe0IMD86k0WB+8wi",
|
||||
watchers: [
|
||||
esbuild: {Esbuild, :install_and_run, [:river_connect, ~w(--sourcemap=inline --watch)]},
|
||||
bun: ["run", "watch", cd: Path.expand("../assets", __DIR__)],
|
||||
tailwind: {Tailwind, :install_and_run, [:river_connect, ~w(--watch)]}
|
||||
]
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<%= 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="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 %>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 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 %>
|
||||
<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>
|
||||
<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-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"
|
||||
"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="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 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>
|
||||
🔴 LIVE
|
||||
<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="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"
|
||||
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"
|
||||
>
|
||||
▶ PLAY
|
||||
LISTEN
|
||||
</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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</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>
|
||||
|
||||
<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")}
|
||||
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"
|
||||
]}
|
||||
>
|
||||
<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: "🎙️"}
|
||||
<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>
|
||||
<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"}
|
||||
</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
|
||||
@@ -0,0 +1,49 @@
|
||||
defmodule RiverConnectWeb.SolidDemoLive do
|
||||
use RiverConnectWeb, :live_view
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
# Optional: Send initial state on mount if needed,
|
||||
# but SolidBridge will handle the initial render and push events.
|
||||
:ok
|
||||
end
|
||||
|
||||
{:ok, assign(socket, count: 0)}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="p-8">
|
||||
<h1 class="text-2xl font-bold mb-4">Solid.js + LiveView Bridge</h1>
|
||||
|
||||
<div
|
||||
id="solid-app"
|
||||
phx-update="ignore"
|
||||
phx-hook="SolidBridge"
|
||||
data-initial-count={@count}
|
||||
></div>
|
||||
|
||||
<div class="mt-8 p-4 bg-gray-100 rounded">
|
||||
<h2 class="font-semibold text-gray-700 mb-2">Server State (LiveView Assigns):</h2>
|
||||
<p class="text-lg">Count: <%= @count %></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("increment", _params, socket) do
|
||||
new_count = socket.assigns.count + 1
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(count: new_count)
|
||||
|> push_event("sync_state", %{count: new_count})}
|
||||
end
|
||||
|
||||
def handle_event("decrement", _params, socket) do
|
||||
new_count = socket.assigns.count - 1
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(count: new_count)
|
||||
|> push_event("sync_state", %{count: new_count})}
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,8 @@ defmodule RiverConnectWeb.Router do
|
||||
|
||||
get "/", PageController, :home
|
||||
live "/audio", AudioLive
|
||||
live "/solid-demo", SolidDemoLive
|
||||
live "/hierarchy", HierarchyLive
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
||||
@@ -82,11 +82,11 @@ defmodule RiverConnect.MixProject do
|
||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
|
||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||
"assets.build": ["compile", "tailwind river_connect", "esbuild river_connect"],
|
||||
"assets.setup": ["tailwind.install --if-missing"],
|
||||
"assets.build": ["compile", "tailwind river_connect", "bun run --prefix assets build"],
|
||||
"assets.deploy": [
|
||||
"tailwind river_connect --minify",
|
||||
"esbuild river_connect --minify",
|
||||
"bun run --prefix assets build -- --deploy",
|
||||
"phx.digest"
|
||||
],
|
||||
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user