235 lines
7.0 kB
1
defmodule BlogWeb.PostLive do
2
use BlogWeb, :live_view
3
alias BlogWeb.Presence
4
require Logger
5
6
@presence_topic "blog_presence"
7
8
def mount(%{"slug" => slug}, _session, socket) do
9
if connected?(socket) do
10
# Subscribe first to get presence updates
11
Phoenix.PubSub.subscribe(Blog.PubSub, @presence_topic)
12
13
# Generate a random ID for this reader
14
reader_id = "reader_#{:crypto.strong_rand_bytes(8) |> Base.encode16()}"
15
16
# Track presence with the slug
17
{:ok, _} =
18
Presence.track(self(), @presence_topic, reader_id, %{
19
name: "Reader-#{:rand.uniform(999)}",
20
anonymous: true,
21
joined_at: DateTime.utc_now(),
22
slug: slug
23
})
24
end
25
26
require Logger
27
Logger.debug("Mounting PostLive with slug: #{slug}")
28
29
case Blog.Content.Post.get_by_slug(slug) do
30
nil ->
31
Logger.debug("No post found for slug: #{slug}")
32
{:ok, push_navigate(socket, to: "/")}
33
34
post ->
35
# Set meta tags for the page
36
meta_attrs = [
37
%{name: "title", content: post.title},
38
%{name: "description", content: truncated_post(post.body)},
39
%{property: "og:title", content: post.title},
40
%{property: "og:description", content: truncated_post(post.body)},
41
%{property: "og:type", content: "website"}
42
]
43
44
socket =
45
socket
46
|> assign_meta_tags(post)
47
|> assign(:post, post)
48
|> assign(meta_attrs: meta_attrs)
49
|> assign(page_title: post.title)
50
51
# require IEx; IEx.pry
52
Logger.debug("Found post: #{inspect(post, pretty: true)}")
53
54
case Earmark.as_html(post.body, code_class_prefix: "language-") do
55
{:ok, html, _} ->
56
headers = extract_headers(post.body)
57
58
socket =
59
assign(socket,
60
html: html,
61
headers: headers,
62
reader_count: get_reader_count(slug)
63
)
64
65
{:ok, socket}
66
67
{:error, html, errors} ->
68
# Still show the content even if there are markdown errors
69
Logger.error("Markdown parsing warnings: #{inspect(errors)}")
70
headers = extract_headers(post.body)
71
72
socket =
73
assign(socket,
74
html: html,
75
headers: headers,
76
post: post
77
)
78
79
{:ok, socket}
80
end
81
end
82
end
83
84
defp truncated_post(body) do
85
String.slice(body, 0, 250) <> "..."
86
end
87
88
def handle_info(%{event: "presence_diff"} = _diff, socket) do
89
socket =
90
assign(socket,
91
reader_count: get_reader_count(socket.assigns.post.slug)
92
)
93
94
{:noreply, socket}
95
end
96
97
defp get_reader_count(slug) do
98
Presence.list(@presence_topic)
99
|> Enum.count(fn {_key, %{metas: [meta | _]}} ->
100
# Only count readers that have a slug and are reading this post
101
Map.get(meta, :slug) == slug
102
end)
103
end
104
105
def render(assigns) do
106
~H"""
107
<div class="px-8 py-12 font-mono text-gray-700">
108
<div class="max-w-7xl mx-auto">
109
<div class="mb-4 text-sm text-gray-500">
110
<%= if @reader_count > 1 do %>
111
{@reader_count - 1} other {if @reader_count == 2, do: "person", else: "people"} reading this post
112
<% end %>
113
</div>
114
<div class="mb-12 p-6 bg-gray-50 rounded-lg border-2 border-gray-200">
115
<h2 class="text-xl font-bold mb-4 pb-2 border-b-2 border-gray-200">Table of Contents</h2>
116
<ul class="space-y-2">
117
<%= for {text, level} <- @headers do %>
118
<li class={[
119
"hover:text-blue-600 transition-colors",
120
level_to_padding(level)
121
]}>
122
<a href={"##{generate_id(text)}"}>{text}</a>
123
</li>
124
<% end %>
125
</ul>
126
</div>
127
128
<article class="p-8 bg-white rounded-lg border-2 border-gray-200">
129
<div
130
id="post-content"
131
phx-hook="Highlight"
132
class="prose prose-lg prose-headings:font-mono prose-headings:font-bold prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl max-w-none"
133
>
134
{raw(@html)}
135
</div>
136
</article>
137
</div>
138
</div>
139
"""
140
end
141
142
defp extract_headers(markdown) do
143
markdown
144
|> String.split("\n")
145
|> Enum.filter(&header?/1)
146
|> Enum.map(fn line ->
147
{text, level} = parse_header(line)
148
{text, level}
149
end)
150
end
151
152
defp header?(line) do
153
String.match?(line, ~r/^#+\s+.+$/)
154
end
155
156
defp parse_header(line) do
157
[hashes | words] = String.split(line, " ")
158
level = String.length(hashes)
159
text = Enum.join(words, " ")
160
{text, level}
161
end
162
163
defp generate_id(text) do
164
text
165
|> String.downcase()
166
|> String.replace(~r/[^\w-]+/, "-")
167
|> String.trim("-")
168
end
169
170
defp level_to_padding(1), do: "pl-0"
171
defp level_to_padding(2), do: "pl-4"
172
defp level_to_padding(3), do: "pl-8"
173
defp level_to_padding(4), do: "pl-12"
174
defp level_to_padding(_), do: "pl-16"
175
176
defp assign_meta_tags(socket, post) do
177
description = get_preview(post.body)
178
179
# Get image path, fallback to nil if generation fails
180
image_path = Blog.Content.ImageGenerator.ensure_post_image(post.slug)
181
image_url = if image_path, do: BlogWeb.Endpoint.url() <> image_path
182
183
meta_tags = [
184
%{name: "description", content: description},
185
%{property: "og:title", content: post.title},
186
%{property: "og:description", content: description},
187
%{property: "og:type", content: "article"},
188
%{property: "og:site_name", content: "Thoughts and Tidbits"},
189
%{
190
property: "article:published_time",
191
content: DateTime.from_naive!(post.written_on, "Etc/UTC") |> DateTime.to_iso8601()
192
},
193
%{name: "twitter:card", content: "summary_large_image"},
194
%{name: "twitter:title", content: post.title},
195
%{name: "twitter:description", content: description}
196
]
197
198
# Add image tags only if we have an image
199
meta_tags =
200
if image_url do
201
meta_tags ++
202
[
203
%{property: "og:image", content: image_url},
204
%{property: "og:image:width", content: "1200"},
205
%{property: "og:image:height", content: "630"},
206
%{name: "twitter:image", content: image_url}
207
]
208
else
209
meta_tags
210
end
211
212
assign(socket,
213
page_title: post.title,
214
meta_tags: meta_tags ++ tag_meta_tags(post.tags)
215
)
216
end
217
218
defp tag_meta_tags(tags) do
219
Enum.map(tags, fn tag ->
220
%{property: "article:tag", content: tag.name}
221
end)
222
end
223
224
defp get_preview(content, max_length \\ 200) do
225
content
226
|> String.split("\n")
227
|> Enum.reject(&String.starts_with?(&1, "tags:"))
228
|> Enum.join(" ")
229
|> String.replace(~r/[#*`]/, "")
230
|> String.replace(~r/\s+/, " ")
231
|> String.trim()
232
|> String.slice(0, max_length)
233
|> Kernel.<>("...")
234
end
235
end
236