783 lines
36 kB
1
defmodule BlogWeb.PostLive.Index do
2
use BlogWeb, :live_view
3
alias BlogWeb.Presence
4
alias Blog.Content
5
alias Blog.Chat
6
require Logger
7
8
@presence_topic "blog_presence"
9
@chat_topic "blog_chat"
10
@default_rooms ["general", "random", "programming", "music"]
11
@url_regex ~r/(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/i
12
13
# TODO add meta tags
14
def mount(_params, _session, socket) do
15
# Ensure ETS chat store is started
16
Chat.ensure_started()
17
demos = [
18
%{
19
title: "Python Playground",
20
description: "Run Python code directly in your browser",
21
path: ~p"/python-demo",
22
icon: "code", # We'll use heroicons for these
23
color: "emerald" # Each demo gets a signature color
24
},
25
%{
26
title: "Cursor Tracker",
27
description: "Track each others cursors like this, and draw points to show your favorite spots",
28
path: ~p"/cursor-tracker",
29
icon: "cursor-arrow-rays",
30
color: "fuchsia"
31
},
32
%{
33
title: "SSR animation, rainbows, fun, keyboard interaction (try typing on it)",
34
description: "A stupid demo to show some animations and interaction off",
35
path: ~p"/gay_chaos",
36
icon: "sparkles",
37
color: "violet"
38
},
39
%{
40
title: "Bluesky youtube videos",
41
description: "Live feed of YT links on blueskystreaming",
42
path: ~p"/reddit-links",
43
icon: "newspaper",
44
color: "orange"
45
},
46
%{
47
title: "Emoji Skeets",
48
description: "Filter bluesky firehose by emojis live",
49
path: ~p"/emoji-skeets",
50
icon: "face-smile",
51
color: "yellow"
52
},
53
%{
54
title: "Hacker News Live",
55
description: "Real-time tech news feed",
56
path: ~p"/hacker-news",
57
icon: "command-line",
58
color: "red"
59
}
60
]
61
62
63
reader_id = if connected?(socket) do
64
id = "reader_#{:crypto.strong_rand_bytes(8) |> Base.encode16()}"
65
66
# Generate a random color for this visitor
67
hue = :rand.uniform(360)
68
color = "hsl(#{hue}, 70%, 60%)"
69
70
{:ok, _} =
71
Presence.track(self(), @presence_topic, id, %{
72
page: "index",
73
joined_at: DateTime.utc_now(),
74
cursor_position: nil,
75
color: color,
76
display_name: nil,
77
current_room: "general"
78
})
79
80
# Subscribe to presence and chat topics
81
Phoenix.PubSub.subscribe(Blog.PubSub, @presence_topic)
82
Phoenix.PubSub.subscribe(Blog.PubSub, @chat_topic)
83
84
Logger.debug("User #{id} mounted and subscribed to topics: #{@presence_topic}, #{@chat_topic}")
85
id
86
else
87
nil
88
end
89
90
posts = Blog.Content.Post.all()
91
%{tech: tech_posts, non_tech: non_tech_posts} = Content.categorize_posts(posts)
92
93
# Get all current visitors from presence
94
visitor_cursors =
95
Presence.list(@presence_topic)
96
|> Enum.map(fn {id, %{metas: [meta | _]}} -> {id, meta} end)
97
|> Enum.into(%{})
98
99
total_readers = map_size(visitor_cursors)
100
101
# Get messages for the default room - always fetch from ETS
102
messages = Chat.get_messages("general")
103
Logger.debug("Loaded #{length(messages)} messages for general room during mount")
104
105
{:ok,
106
assign(socket,
107
tech_posts: tech_posts,
108
demos: demos,
109
non_tech_posts: non_tech_posts,
110
total_readers: total_readers,
111
page_title: "Thoughts & Tidbits",
112
cursor_position: nil,
113
reader_id: reader_id,
114
visitor_cursors: visitor_cursors,
115
name_form: %{"name" => ""},
116
name_submitted: false,
117
show_chat: false,
118
chat_messages: messages,
119
chat_form: %{"message" => ""},
120
current_room: "general",
121
chat_rooms: @default_rooms,
122
room_users: %{
123
"general" => 0,
124
"random" => 0,
125
"programming" => 0,
126
"music" => 0
127
},
128
show_mod_panel: false,
129
banned_word_form: %{"word" => ""},
130
mod_password: "letmein" # This is a simple example - in a real app you'd use proper auth
131
)}
132
end
133
134
def handle_info(%{event: "presence_diff"}, socket) do
135
visitor_cursors =
136
Presence.list(@presence_topic)
137
|> Enum.map(fn {id, %{metas: [meta | _]}} -> {id, meta} end)
138
|> Enum.into(%{})
139
140
total_readers = map_size(visitor_cursors)
141
142
# Count users in each room
143
room_users =
144
visitor_cursors
145
|> Enum.reduce(%{
146
"general" => 0,
147
"random" => 0,
148
"programming" => 0,
149
"music" => 0
150
}, fn {_id, meta}, acc ->
151
room = Map.get(meta, :current_room, "general")
152
Map.update(acc, room, 1, &(&1 + 1))
153
end)
154
155
{:noreply, assign(socket, total_readers: total_readers, visitor_cursors: visitor_cursors, room_users: room_users)}
156
end
157
158
def handle_info({:new_chat_message, message}, socket) do
159
Logger.debug("Received new chat message: #{inspect(message.id)} in room #{message.room} from #{message.sender_name}")
160
161
# Only update messages if we're in the same room as the message
162
if message.room == socket.assigns.current_room do
163
# Get all messages from ETS to ensure we have the latest data
164
updated_messages = Chat.get_messages(socket.assigns.current_room)
165
Logger.debug("Updated chat messages for room #{socket.assigns.current_room}, now have #{length(updated_messages)} messages")
166
{:noreply, assign(socket, chat_messages: updated_messages)}
167
else
168
Logger.debug("Ignoring message for room #{message.room} since user is in room #{socket.assigns.current_room}")
169
{:noreply, socket}
170
end
171
end
172
173
def handle_event("mousemove", %{"x" => x, "y" => y}, socket) do
174
reader_id = socket.assigns.reader_id
175
176
# Update local cursor position
177
cursor_position = %{x: x, y: y}
178
socket = assign(socket, cursor_position: cursor_position)
179
180
if reader_id do
181
# Update the presence with the new cursor position
182
Presence.update(self(), @presence_topic, reader_id, fn meta ->
183
Map.put(meta, :cursor_position, cursor_position)
184
end)
185
end
186
187
{:noreply, socket}
188
end
189
190
def handle_event("save_name", %{"name" => name}, socket) do
191
reader_id = socket.assigns.reader_id
192
trimmed_name = String.trim(name)
193
194
if reader_id && trimmed_name != "" do
195
# Update the presence with the display name
196
Presence.update(self(), @presence_topic, reader_id, fn meta ->
197
Map.put(meta, :display_name, trimmed_name)
198
end)
199
200
{:noreply, assign(socket, name_submitted: true)}
201
else
202
{:noreply, socket}
203
end
204
end
205
206
def handle_event("validate_name", %{"name" => name}, socket) do
207
{:noreply, assign(socket, name_form: %{"name" => name})}
208
end
209
210
def handle_event("toggle_chat", _params, socket) do
211
{:noreply, assign(socket, show_chat: !socket.assigns.show_chat)}
212
end
213
214
def handle_event("toggle_mod_panel", _params, socket) do
215
{:noreply, assign(socket, show_mod_panel: !socket.assigns.show_mod_panel)}
216
end
217
218
def handle_event("add_banned_word", %{"word" => word, "password" => password}, socket) do
219
if password == socket.assigns.mod_password do
220
case Chat.add_banned_word(word) do
221
{:ok, _} ->
222
{:noreply, assign(socket, banned_word_form: %{"word" => ""})}
223
{:error, _} ->
224
{:noreply, socket}
225
end
226
else
227
{:noreply, socket}
228
end
229
end
230
231
def handle_event("validate_banned_word", %{"word" => word}, socket) do
232
{:noreply, assign(socket, banned_word_form: %{"word" => word})}
233
end
234
235
def handle_event("change_room", %{"room" => room}, socket) when room in @default_rooms do
236
reader_id = socket.assigns.reader_id
237
238
if reader_id do
239
# Update the presence with the new room
240
Presence.update(self(), @presence_topic, reader_id, fn meta ->
241
Map.put(meta, :current_room, room)
242
end)
243
244
# Get messages for the new room
245
messages = Chat.get_messages(room)
246
Logger.debug("Changed room to #{room}, loaded #{length(messages)} messages")
247
248
{:noreply, assign(socket, current_room: room, chat_messages: messages)}
249
else
250
{:noreply, socket}
251
end
252
end
253
254
def handle_event("send_chat_message", %{"message" => message}, socket) do
255
reader_id = socket.assigns.reader_id
256
current_room = socket.assigns.current_room
257
trimmed_message = String.trim(message)
258
259
Logger.debug("Handling send_chat_message event for #{reader_id} in room #{current_room}")
260
261
if reader_id && trimmed_message != "" do
262
# Check for banned words
263
case Chat.check_for_banned_words(trimmed_message) do
264
{:ok, _} ->
265
# Message is clean, proceed with sending
266
# Get display name from presence
267
visitor_meta =
268
case Presence.get_by_key(@presence_topic, reader_id) do
269
%{metas: [meta | _]} -> meta
270
_ -> %{display_name: nil, color: "hsl(200, 70%, 60%)"}
271
end
272
273
display_name = visitor_meta.display_name || "visitor #{String.slice(reader_id, -4, 4)}"
274
color = visitor_meta.color
275
276
# Create the message
277
new_message = %{
278
id: System.os_time(:millisecond),
279
sender_id: reader_id,
280
sender_name: display_name,
281
sender_color: color,
282
content: trimmed_message,
283
timestamp: DateTime.utc_now(),
284
room: current_room
285
}
286
287
# Save message to ETS
288
saved_message = Chat.save_message(new_message)
289
Logger.debug("Saved new message to ETS with ID: #{inspect(saved_message.id)}")
290
291
# Broadcast the message to all clients - use broadcast! to raise errors
292
Phoenix.PubSub.broadcast!(
293
Blog.PubSub,
294
@chat_topic,
295
{:new_chat_message, saved_message}
296
)
297
Logger.debug("Broadcast message to topic #{@chat_topic} succeeded")
298
299
# Get updated messages from ETS to ensure consistency
300
updated_messages = Chat.get_messages(current_room)
301
Logger.debug("After sending: room #{current_room} has #{length(updated_messages)} messages")
302
303
{:noreply, assign(socket, chat_form: %{"message" => ""}, chat_messages: updated_messages)}
304
305
{:error, :contains_banned_words} ->
306
# Message contains banned words, reject it
307
system_message = %{
308
id: System.os_time(:millisecond),
309
sender_id: "system",
310
sender_name: "ChatBot",
311
sender_color: "hsl(0, 100%, 50%)",
312
content: "Your message was not sent because it contains prohibited words.",
313
timestamp: DateTime.utc_now(),
314
room: current_room
315
}
316
317
# Only show the warning to the sender
318
updated_messages = [system_message | socket.assigns.chat_messages] |> Enum.take(50)
319
320
{:noreply, assign(socket, chat_form: %{"message" => ""}, chat_messages: updated_messages)}
321
end
322
else
323
{:noreply, socket}
324
end
325
end
326
327
def handle_event("validate_chat_message", %{"message" => message}, socket) do
328
{:noreply, assign(socket, chat_form: %{"message" => message})}
329
end
330
331
# Function to format message text and make URLs clickable
332
def format_message_with_links(content) when is_binary(content) do
333
Regex.replace(@url_regex, content, fn url, _ ->
334
# Ensure URL has http/https prefix for the href attribute
335
href = if String.starts_with?(url, ["http://", "https://"]) do
336
url
337
else
338
"https://#{url}"
339
end
340
341
# Create the anchor tag with appropriate attributes
342
# Note: We use target="_blank" and rel="noopener noreferrer" for security
343
"<a href=\"#{href}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-blue-600 hover:underline break-all\">#{url}</a>"
344
end)
345
end
346
347
# Add a debug function to be used for troubleshooting
348
def debug_chat_state(socket) do
349
Logger.debug("------- CHAT DEBUG -------")
350
Logger.debug("Reader ID: #{socket.assigns.reader_id}")
351
Logger.debug("Current room: #{socket.assigns.current_room}")
352
Logger.debug("Message count: #{length(socket.assigns.chat_messages)}")
353
354
# Dump the contents of the ETS table for this room
355
room_messages = Chat.get_messages(socket.assigns.current_room)
356
Logger.debug("ETS messages for room: #{length(room_messages)}")
357
358
if length(room_messages) > 0 do
359
sample = Enum.take(room_messages, 2)
360
Logger.debug("Sample messages: #{inspect(sample)}")
361
end
362
363
Logger.debug("-------------------------")
364
socket
365
end
366
367
def render(assigns) do
368
~H"""
369
<div
370
class="py-12 px-4 sm:px-6 lg:px-8 min-h-screen"
371
id="cursor-tracker-container"
372
phx-hook="CursorTracker"
373
>
374
<!-- Name input form if not yet submitted -->
375
<%= if @reader_id && !@name_submitted do %>
376
<div class="fixed top-4 left-4 z-50">
377
<.form for={%{}} phx-submit="save_name" phx-change="validate_name" class="flex items-center space-x-2">
378
<div class="bg-gradient-to-r from-fuchsia-500 to-cyan-500 p-0.5 rounded-lg shadow-md">
379
<div class="bg-white rounded-md px-3 py-2 flex items-center space-x-2">
380
<input
381
type="text"
382
name="name"
383
value={@name_form["name"]}
384
placeholder="WHAT'S YOUR NAME?"
385
maxlength="20"
386
class="text-sm font-mono text-gray-800 focus:outline-none"
387
/>
388
<button type="submit" class="bg-gradient-to-r from-fuchsia-500 to-cyan-500 text-white text-xs font-bold px-3 py-1 rounded-md">
389
SET
390
</button>
391
</div>
392
</div>
393
</.form>
394
</div>
395
<% end %>
396
397
<!-- Moderator Panel (Hidden from regular users) -->
398
<%= if @show_mod_panel do %>
399
<div class="fixed top-20 left-4 z-50 bg-gray-900 text-white p-4 rounded-lg shadow-xl border border-red-500 w-80">
400
<div class="flex justify-between items-center mb-3">
401
<h3 class="font-bold">Moderator Panel</h3>
402
<button phx-click="toggle_mod_panel" class="text-red-400 hover:text-red-300">
403
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
404
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
405
</svg>
406
</button>
407
</div>
408
409
<div class="mb-4">
410
<h4 class="text-sm font-bold mb-2 text-red-400">Add Word to Ban List</h4>
411
<.form for={%{}} phx-submit="add_banned_word" phx-change="validate_banned_word">
412
<div class="flex flex-col space-y-2">
413
<input
414
type="text"
415
name="word"
416
value={@banned_word_form["word"]}
417
placeholder="Enter word to ban"
418
class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm"
419
/>
420
<input
421
type="password"
422
name="password"
423
placeholder="Moderator password"
424
class="px-2 py-1 bg-gray-800 border border-gray-700 rounded text-sm"
425
/>
426
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white py-1 px-2 rounded text-sm">
427
Add to Ban List
428
</button>
429
</div>
430
</.form>
431
</div>
432
433
<div class="text-xs text-gray-400 mt-2">
434
This panel is not visible to users. The banned word list is not displayed anywhere.
435
</div>
436
</div>
437
<% end %>
438
439
<!-- Join Chat Button -->
440
<div class="fixed bottom-4 right-4 z-50">
441
<button
442
phx-click="toggle_chat"
443
class="group bg-gradient-to-r from-yellow-400 to-yellow-500 border-2 border-yellow-600 rounded-md px-4 py-2 font-bold text-blue-900 shadow-lg hover:shadow-xl transition-all duration-300"
444
style="font-family: 'Comic Sans MS', cursive, sans-serif; text-shadow: 1px 1px 0 #fff;"
445
>
446
<div class="flex items-center">
447
<div class="w-3 h-3 rounded-full bg-green-500 mr-2 group-hover:bg-red-500 transition-colors"></div>
448
<%= if @show_chat, do: "Close Chat", else: "Join Chat" %>
449
</div>
450
</button>
451
</div>
452
453
<!-- Expanded AIM-style Chat Window -->
454
<%= if @show_chat do %>
455
<div class="fixed bottom-16 right-4 w-[90vw] md:w-[40rem] h-[70vh] z-50 shadow-2xl flex">
456
<!-- Room Sidebar -->
457
<div class="w-48 bg-gray-100 border-2 border-r-0 border-gray-400 rounded-l-md flex flex-col">
458
<!-- Room Header -->
459
<div class="bg-blue-800 text-white px-3 py-2 font-bold border-b-2 border-gray-400" style="font-family: 'Comic Sans MS', cursive, sans-serif;">
460
Chat Rooms
461
</div>
462
463
<!-- Room List -->
464
<div class="flex-1 overflow-y-auto p-2">
465
<%= for room <- @chat_rooms do %>
466
<button
467
phx-click="change_room"
468
phx-value-room={room}
469
class={"w-full text-left mb-2 px-3 py-2 rounded #{if @current_room == room, do: "bg-yellow-100 border border-yellow-300", else: "hover:bg-gray-200"}"}
470
>
471
<div class="flex items-center justify-between">
472
<div class="flex items-center">
473
<!-- Room Icon based on room name -->
474
<%= case room do %>
475
<% "general" -> %>
476
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
477
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
478
</svg>
479
<% "random" -> %>
480
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
481
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
482
</svg>
483
<% "programming" -> %>
484
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
485
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
486
</svg>
487
<% "music" -> %>
488
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
489
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
490
</svg>
491
<% end %>
492
<span style="font-family: 'Tahoma', sans-serif;" class="text-sm"><%= String.capitalize(room) %></span>
493
</div>
494
<span class="bg-blue-100 text-blue-800 text-xs px-2 py-0.5 rounded-full">
495
<%= Map.get(@room_users, room, 0) %>
496
</span>
497
</div>
498
</button>
499
<% end %>
500
</div>
501
502
<!-- Online Users -->
503
<div class="p-2 border-t-2 border-gray-400">
504
<div class="text-xs text-gray-600 mb-1 font-bold">Online Users</div>
505
<div class="max-h-40 overflow-y-auto">
506
<%= for {_id, user} <- @visitor_cursors do %>
507
<div class="flex items-center mb-1">
508
<div class="w-2 h-2 rounded-full bg-green-500 mr-1"></div>
509
<span class="text-xs truncate" style={"color: #{user.color};"}>
510
<%= if user.display_name, do: user.display_name, else: "Anonymous" %>
511
</span>
512
</div>
513
<% end %>
514
</div>
515
</div>
516
</div>
517
518
<!-- Main Chat Area -->
519
<div class="flex-1 flex flex-col">
520
<!-- Chat Window Header -->
521
<div class="bg-blue-800 text-white px-3 py-2 flex justify-between items-center rounded-tr-md border-2 border-b-0 border-l-0 border-gray-400">
522
<div class="font-bold flex items-center" style="font-family: 'Comic Sans MS', cursive, sans-serif;">
523
<%= case @current_room do %>
524
<% "general" -> %>
525
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
526
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
527
</svg>
528
General Chat
529
<% "random" -> %>
530
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
531
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
532
</svg>
533
Random Chat
534
<% "programming" -> %>
535
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
536
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
537
</svg>
538
Programming Chat
539
<% "music" -> %>
540
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
541
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
542
</svg>
543
Music Chat
544
<% end %>
545
</div>
546
<div class="flex space-x-1">
547
<div class="w-3 h-3 rounded-full bg-yellow-400 border border-yellow-600"></div>
548
<div class="w-3 h-3 rounded-full bg-green-400 border border-green-600"></div>
549
<div class="w-3 h-3 rounded-full bg-red-400 border border-red-600 cursor-pointer" phx-click="toggle_chat"></div>
550
</div>
551
</div>
552
553
<!-- Chat Window Body -->
554
<div class="bg-white border-2 border-t-0 border-b-0 border-l-0 border-gray-400 flex-1 overflow-y-auto p-3" style="font-family: 'Courier New', monospace;" id="chat-messages">
555
<%= for message <- @chat_messages do %>
556
<div class="mb-3 hover:bg-gray-50 p-2 rounded">
557
<div class="flex items-center mb-1">
558
<span class="font-bold" style={"color: #{message.sender_color};"}>
559
<%= message.sender_name %>
560
</span>
561
<span class="text-xs text-gray-500 ml-2">
562
<%= Calendar.strftime(message.timestamp, "%I:%M %p") %>
563
</span>
564
</div>
565
<div class="text-gray-800 break-words pl-1 border-l-2" style={"border-color: #{message.sender_color};"}>
566
<%= raw format_message_with_links(message.content) %>
567
</div>
568
</div>
569
<% end %>
570
<%= if Enum.empty?(@chat_messages) do %>
571
<div class="text-center text-gray-500 italic mt-8">
572
<div class="mb-2">Welcome to the <%= String.capitalize(@current_room) %> room!</div>
573
<div>No messages yet. Be the first to say hello!</div>
574
</div>
575
<% end %>
576
</div>
577
578
<!-- Chat Input Area -->
579
<div class="bg-gray-200 border-2 border-t-0 border-l-0 border-gray-400 rounded-br-md p-3">
580
<.form for={%{}} phx-submit="send_chat_message" phx-change="validate_chat_message" class="flex">
581
<input
582
type="text"
583
name="message"
584
value={@chat_form["message"]}
585
placeholder={"Type a message in #{String.capitalize(@current_room)}..."}
586
maxlength="500"
587
class="flex-1 border border-gray-400 rounded px-3 py-2 text-sm"
588
autocomplete="off"
589
/>
590
<button type="submit" class="ml-2 bg-yellow-400 hover:bg-yellow-500 text-blue-900 font-bold px-4 py-2 rounded border border-yellow-600">
591
Send
592
</button>
593
</.form>
594
</div>
595
</div>
596
</div>
597
<% end %>
598
599
<%= if @cursor_position do %>
600
<div class="fixed top-4 right-4 bg-gradient-to-r from-fuchsia-500 to-cyan-500 text-white px-3 py-1 rounded-lg shadow-md text-sm font-mono z-50">
601
x: <%= @cursor_position.x %>, y: <%= @cursor_position.y %>
602
</div>
603
604
<!-- Full screen crosshair with gradient and smooth transitions -->
605
<div class="fixed inset-0 pointer-events-none z-40">
606
<!-- Horizontal line across entire screen with gradient -->
607
<div
608
class="absolute w-full h-0.5 opacity-40 transition-all duration-200 ease-out"
609
style={"top: #{@cursor_position.y}px; background: linear-gradient(to right, #d946ef, #0891b2);"}
610
></div>
611
612
<!-- Vertical line across entire screen with gradient -->
613
<div
614
class="absolute h-full w-0.5 opacity-40 transition-all duration-200 ease-out"
615
style={"left: #{@cursor_position.x}px; background: linear-gradient(to bottom, #d946ef, #0891b2);"}
616
></div>
617
</div>
618
<% end %>
619
620
<!-- Show all visitor cursors except our own -->
621
<%= for {id, meta} <- @visitor_cursors do %>
622
<%= if id != @reader_id && Map.get(meta, :cursor_position) do %>
623
<div
624
class="absolute w-4 h-4 pointer-events-none transform -translate-x-2 -translate-y-2 transition-all duration-100"
625
style={"left: #{Map.get(meta, :cursor_position, %{})[:x]}px; top: #{Map.get(meta, :cursor_position, %{})[:y]}px;"}
626
>
627
<div class="relative">
628
<div class={"w-4 h-4 absolute animate-ping rounded-full opacity-20"} style={"background-color: #{meta.color}"}></div>
629
<div class={"w-4 h-4 rounded-full"} style={"background-color: #{meta.color}"}></div>
630
<%= if meta.display_name do %>
631
<div class="absolute left-5 top-0 px-2 py-1 text-xs rounded bg-gray-800 text-white whitespace-nowrap">
632
<%= meta.display_name %>
633
</div>
634
<% end %>
635
</div>
636
</div>
637
<% end %>
638
<% end %>
639
640
<div class="max-w-7xl mx-auto">
641
<!-- Header with retro styling -->
642
<header class="mb-12 text-center">
643
<div class="inline-block p-1 bg-gradient-to-r from-fuchsia-500 to-cyan-500 rounded-lg shadow-lg mb-6">
644
<h1 class="text-4xl md:text-5xl font-bold bg-white px-6 py-3 rounded-md">
645
<span class="text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-600 to-cyan-600">
646
Thoughts & Tidbits
647
</span>
648
</h1>
649
</div>
650
651
<div class="flex justify-center items-center space-x-2 text-sm text-gray-600 mb-4">
652
<div class="inline-flex items-center px-3 py-1 rounded-full bg-gradient-to-r from-fuchsia-100 to-cyan-100 border border-fuchsia-200">
653
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1 text-fuchsia-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
654
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
655
</svg>
656
<span><%= @total_readers %> <%= if @total_readers == 1, do: "person", else: "people" %> browsing</span>
657
</div>
658
</div>
659
660
<p class="text-gray-600 max-w-2xl mx-auto">
661
A collection of thoughts on technology, life, and weird little things I make.
662
</p>
663
</header>
664
665
<!-- Two column layout for posts -->
666
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
667
<!-- Tech Posts Column -->
668
<div class="bg-gradient-to-br from-fuchsia-50 to-cyan-50 rounded-xl p-6 shadow-lg border border-fuchsia-100">
669
<div class="flex items-center mb-6">
670
<div class="w-3 h-3 rounded-full bg-fuchsia-400 mr-2"></div>
671
<div class="w-3 h-3 rounded-full bg-cyan-400 mr-2"></div>
672
<div class="w-3 h-3 rounded-full bg-indigo-400 mr-4"></div>
673
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-fuchsia-600 to-cyan-600">
674
Tech & Programming
675
</h2>
676
</div>
677
678
<div class="space-y-4">
679
<%= for post <- @tech_posts do %>
680
<div class="group bg-white rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-300 border-l-4 border-fuchsia-400">
681
<.link navigate={~p"/post/#{post.slug}"} class="block">
682
<h3 class="text-xl font-bold text-gray-800 group-hover:text-fuchsia-600 transition-colors">
683
<%= post.title %>
684
</h3>
685
<div class="flex flex-wrap gap-2 mt-2">
686
<%= for tag <- post.tags do %>
687
<span class="inline-block px-2 py-1 bg-gradient-to-r from-fuchsia-100 to-cyan-100 rounded-full text-xs font-medium text-gray-700">
688
<%= tag.name %>
689
</span>
690
<% end %>
691
</div>
692
</.link>
693
</div>
694
<% end %>
695
</div>
696
</div>
697
698
<!-- Non-Tech Posts Column -->
699
<div class="bg-gradient-to-br from-cyan-50 to-fuchsia-50 rounded-xl p-6 shadow-lg border border-cyan-100">
700
<div class="flex items-center mb-6">
701
<div class="w-3 h-3 rounded-full bg-cyan-400 mr-2"></div>
702
<div class="w-3 h-3 rounded-full bg-fuchsia-400 mr-2"></div>
703
<div class="w-3 h-3 rounded-full bg-indigo-400 mr-4"></div>
704
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-fuchsia-600">
705
Life & Everything Else
706
</h2>
707
</div>
708
709
<div class="space-y-4">
710
<%= for post <- @non_tech_posts do %>
711
<div class="group bg-white rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-300 border-l-4 border-cyan-400">
712
<.link navigate={~p"/post/#{post.slug}"} class="block">
713
<h3 class="text-xl font-bold text-gray-800 group-hover:text-cyan-600 transition-colors">
714
<%= post.title %>
715
</h3>
716
<div class="flex flex-wrap gap-2 mt-2">
717
<%= for tag <- post.tags do %>
718
<span class="inline-block px-2 py-1 bg-gradient-to-r from-cyan-100 to-fuchsia-100 rounded-full text-xs font-medium text-gray-700">
719
<%= tag.name %>
720
</span>
721
<% end %>
722
</div>
723
</.link>
724
</div>
725
<% end %>
726
</div>
727
</div>
728
729
<!-- Tech Demos Column -->
730
<div class="bg-gradient-to-br from-indigo-50 to-purple-50 rounded-xl p-6 shadow-lg border border-indigo-100">
731
<div class="flex items-center mb-6">
732
<div class="w-3 h-3 rounded-full bg-indigo-400 mr-2"></div>
733
<div class="w-3 h-3 rounded-full bg-purple-400 mr-2"></div>
734
<div class="w-3 h-3 rounded-full bg-pink-400 mr-4"></div>
735
<h2 class="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600">
736
Interactive Demos
737
</h2>
738
</div>
739
740
<div class="space-y-4">
741
<%= for demo <- @demos do %>
742
<.link navigate={demo.path} class="block">
743
<div class={"group bg-white rounded-lg p-4 shadow-sm hover:shadow-md transition-all duration-300 border-l-4 border-#{demo.color}-400 hover:border-#{demo.color}-500"}>
744
<div class="flex items-center justify-between">
745
<div>
746
<h3 class={"text-xl font-bold text-gray-800 group-hover:text-#{demo.color}-600 transition-colors"}>
747
<%= demo.title %>
748
</h3>
749
<p class="text-sm text-gray-600 mt-1">
750
<%= demo.description %>
751
</p>
752
</div>
753
<span class={"text-#{demo.color}-600 group-hover:translate-x-0.5 transition-transform"}>→</span>
754
</div>
755
</div>
756
</.link>
757
<% end %>
758
</div>
759
</div>
760
</div>
761
</div>
762
763
<!-- Retro footer -->
764
<footer class="mt-16 text-center">
765
<span class="font-mono">/* Crafted with ♥ and Elixir */</span>
766
767
<!-- Moderator Button - subtle but visible -->
768
<div class="mt-4 flex justify-center">
769
<button
770
phx-click="toggle_mod_panel"
771
class="flex items-center px-3 py-1 text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded-md transition-colors duration-200 bg-white hover:bg-gray-50"
772
>
773
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
774
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
775
</svg>
776
Moderator Access
777
</button>
778
</div>
779
</footer>
780
</div>
781
"""
782
end
783
end
784