254 lines
9.8 kB
1
defmodule BlogWeb.HackerNewsLive do
2
use BlogWeb, :live_view
3
require Logger
4
5
@hn_api_base "https://hacker-news.firebaseio.com/v0"
6
@topic "hacker_news:stories"
7
@refresh_interval 3 * 60 * 1000 # 3 minutes in milliseconds
8
9
@impl true
10
def mount(_params, _session, socket) do
11
# lib/blog_web/components/.elixir-tools/# Get initial stories
12
initial_stories = fetch_top_stories(50)
13
14
if connected?(socket) do
15
# Subscribe to the Hacker News stories topic
16
Phoenix.PubSub.subscribe(Blog.PubSub, @topic)
17
18
# Start timer to periodically refresh stories
19
Process.send_after(self(), :refresh_stories, @refresh_interval)
20
end
21
22
{:ok,
23
assign(socket,
24
page_title: "Top Hacker News Stories",
25
stories: initial_stories,
26
stories_by_id: index_stories_by_id(initial_stories),
27
last_updated: DateTime.utc_now(),
28
meta_attrs: [
29
%{name: "description", content: "Real-time feed of top Hacker News stories"},
30
%{property: "og:title", content: "Top Hacker News Stories"},
31
%{property: "og:description", content: "Real-time feed of top Hacker News stories"},
32
%{property: "og:type", content: "website"}
33
]
34
)}
35
end
36
37
@impl true
38
def handle_info(:refresh_stories, socket) do
39
# Fetch fresh stories
40
Task.start(fn ->
41
stories = fetch_top_stories(50)
42
send(self(), {:stories_refreshed, stories})
43
end)
44
45
# Schedule next refresh
46
if connected?(socket) do
47
Process.send_after(self(), :refresh_stories, @refresh_interval)
48
end
49
50
{:noreply, socket}
51
end
52
53
@impl true
54
def handle_info({:stories_refreshed, stories}, socket) do
55
# Update socket with new stories
56
stories_by_id = index_stories_by_id(stories)
57
58
# Broadcast updates to all clients
59
for story <- stories do
60
Phoenix.PubSub.broadcast(Blog.PubSub, @topic, {:story_update, story})
61
end
62
63
{:noreply, assign(socket, stories: stories, stories_by_id: stories_by_id, last_updated: DateTime.utc_now())}
64
end
65
66
@impl true
67
def handle_info({:story_update, story}, socket) do
68
# Update stories map first to ensure stable rendering
69
updated_stories_by_id = Map.put(socket.assigns.stories_by_id, story.id, story)
70
71
# Get all stories, preserving order by rank
72
updated_stories =
73
(socket.assigns.stories ++ [story])
74
|> Enum.uniq_by(& &1.id)
75
|> Enum.sort_by(& &1.rank)
76
|> Enum.take(50)
77
78
{:noreply,
79
assign(socket,
80
stories: updated_stories,
81
stories_by_id: updated_stories_by_id
82
)}
83
end
84
85
# Fetch top stories directly from Hacker News API
86
defp fetch_top_stories(limit) do
87
# Get IDs of top stories
88
{:ok, response} = Req.get("#{@hn_api_base}/topstories.json")
89
story_ids = Enum.take(response.body, limit)
90
91
# Fetch story details in parallel
92
story_ids
93
|> Enum.with_index(1) # Add rank starting from 1
94
|> Enum.map(fn {id, rank} ->
95
Task.async(fn -> fetch_story_details(id, rank) end)
96
end)
97
|> Enum.map(&Task.await/1)
98
|> Enum.filter(& &1) # Remove nils if any requests failed
99
end
100
101
# Fetch details for a specific story
102
defp fetch_story_details(id, rank) do
103
case Req.get("#{@hn_api_base}/item/#{id}.json") do
104
{:ok, response} ->
105
# Format the story from the API response
106
format_story(response.body, rank)
107
{:error, error} ->
108
Logger.error("Failed to fetch story #{id}: #{inspect(error)}")
109
nil
110
end
111
end
112
113
# Format raw story data from the API
114
defp format_story(story_data, rank) do
115
story_id = story_data["id"]
116
default_url = "https://news.ycombinator.com/item?id=#{story_id}"
117
118
%{
119
id: story_id,
120
title: story_data["title"],
121
url: story_data["url"] || default_url,
122
score: story_data["score"],
123
by: story_data["by"],
124
time: story_data["time"],
125
descendants: story_data["descendants"] || 0,
126
rank: rank,
127
timestamp: DateTime.utc_now()
128
}
129
end
130
131
# Index stories by ID for efficient updates
132
defp index_stories_by_id(stories) do
133
stories |> Enum.map(& {&1.id, &1}) |> Map.new()
134
end
135
136
# Format Unix timestamp as a human-readable date
137
defp format_time(unix_time) when is_integer(unix_time) do
138
unix_time
139
|> DateTime.from_unix!()
140
|> Calendar.strftime("%b %d, %Y %H:%M")
141
end
142
defp format_time(_), do: ""
143
144
# Format the domain from a URL
145
defp format_domain(nil), do: ""
146
defp format_domain(url) when is_binary(url) do
147
case URI.parse(url) do
148
%URI{host: nil} -> ""
149
%URI{host: host} -> host
150
end
151
end
152
153
@impl true
154
def render(assigns) do
155
~H"""
156
<div class="min-h-screen bg-gray-50">
157
<div class="max-w-5xl mx-auto py-8 px-4">
158
<header class="mb-8">
159
<div class="flex items-center justify-between">
160
<h1 class="text-3xl font-bold text-gray-900">Top Hacker News Stories</h1>
161
<div class="flex items-center">
162
<span class="bg-orange-100 text-orange-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded-full">
163
Live Updates
164
</span>
165
<span class="text-xs text-gray-500">
166
Last updated: <%= if assigns[:last_updated], do: Calendar.strftime(@last_updated, "%H:%M:%S"), else: "loading..." %>
167
</span>
168
</div>
169
</div>
170
<p class="text-gray-600 mt-2">
171
Real-time feed of the top 50 stories from Hacker News, updated every 3 minutes.
172
</p>
173
</header>
174
175
<div class="bg-white rounded-lg shadow divide-y">
176
<%= for story <- @stories do %>
177
<article
178
id={"story-#{story.id}"}
179
class="p-4 hover:bg-orange-50 transition-colors"
180
>
181
<div class="flex items-baseline space-x-2">
182
<span class="text-orange-500 font-mono font-semibold"><%= story.rank %>.</span>
183
<h2 class="text-lg font-medium text-gray-900 flex-grow">
184
<a href={story.url} target="_blank" rel="noopener noreferrer" class="hover:underline">
185
<%= story.title %>
186
</a>
187
</h2>
188
</div>
189
190
<div class="ml-6 mt-1 flex flex-wrap text-sm text-gray-500 gap-x-4">
191
<%= if domain = format_domain(story.url) do %>
192
<span class="inline-flex items-center">
193
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
194
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
195
</svg>
196
<%= domain %>
197
</span>
198
<% end %>
199
200
<span class="inline-flex items-center">
201
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
202
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
203
</svg>
204
<%= story.by %>
205
</span>
206
207
<span class="inline-flex items-center">
208
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
209
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 11l5-5m0 0l5 5m-5-5v12" />
210
</svg>
211
<%= story.score %>
212
</span>
213
214
<span class="inline-flex items-center">
215
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
216
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
217
</svg>
218
<%= story.descendants %>
219
</span>
220
221
<span class="inline-flex items-center">
222
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
223
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
224
</svg>
225
<%= format_time(story.time) %>
226
</span>
227
228
<a
229
href={"https://news.ycombinator.com/item?id=#{story.id}"}
230
target="_blank"
231
rel="noopener noreferrer"
232
class="inline-flex items-center text-orange-600 hover:underline"
233
>
234
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
235
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z" />
236
</svg>
237
Comments
238
</a>
239
</div>
240
</article>
241
<% end %>
242
243
<%= if Enum.empty?(@stories) do %>
244
<div class="p-8 text-center">
245
<p class="text-gray-500">Loading Hacker News stories...</p>
246
<p class="text-sm text-gray-400 mt-2">This could take a moment to fetch data from the API.</p>
247
</div>
248
<% end %>
249
</div>
250
</div>
251
</div>
252
"""
253
end
254
end
255