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