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