built basic ui structure in /hierarchy
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user