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
@@ -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.