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