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