+4
-4
.cursorrules
+4
-4
.cursorrules
···
19
19
- Use functional programming patterns and leverage immutability.
20
20
- Prefer higher-order functions and recursion over imperative loops.
21
21
- Structure files according to Phoenix conventions (controllers, contexts, views, etc.).
22
+
- Req for requests
22
23
23
24
Naming Conventions
24
25
- Use snake_case for file names, function names, and variables.
···
39
40
- Use Phoenix LiveView for dynamic, real-time interactions.
40
41
- Implement responsive design with Tailwind CSS.
41
42
- Implement subtle javascript microinteractions as appropriate using Tailwind + Phoenix.LiveView.JS
42
-
43
+
43
44
Performance Optimization
44
45
- Use database indexing effectively.
45
-
- Implement caching strategies using Nebulex
46
46
- Use Ecto's preload to avoid N+1 queries.
47
47
- Optimize database queries using preload, joins, or select.
48
-
48
+
49
49
Testing
50
50
- Write comprehensive tests using ExUnit.
51
51
- Follow TDD practices.
52
52
- Use ExMachina for test data generation.
53
-
53
+
54
54
Follow the official Phoenix guides for best practices in routing, controllers, contexts, views, and other Phoenix components.
55
55
56
56
Reuse code as appropriate, but also remember that premature abstraction and DRY can be problematic in their own right. If we are going to reuse code, we should do our best to not need to modify existing interfaces
+2
-2
config/dev.exs
+2
-2
config/dev.exs
lib/.bluesky_hose.ex.swp
lib/.bluesky_hose.ex.swp
This is a binary file and will not be displayed.
+164
lib/bluesky_hose.ex
+164
lib/bluesky_hose.ex
···
1
+
defmodule BlueskyHose do
2
+
use WebSockex
3
+
require Logger
4
+
5
+
alias Bookmarker.Bookmarks
6
+
alias Bookmarker.Accounts
7
+
alias Phoenix.PubSub
8
+
9
+
@table_name :reddit_links
10
+
@topic "bookmarks"
11
+
12
+
def start_link(opts \\ []) do
13
+
# Create ETS table if it doesn't exist
14
+
:ets.new(@table_name, [:named_table, :ordered_set, :public, read_concurrency: true])
15
+
16
+
WebSockex.start_link(
17
+
"wss://bsky-relay.c.theo.io/subscribe?wantedCollections=app.bsky.feed.post",
18
+
__MODULE__,
19
+
%{processed_links: MapSet.new()}, # Track processed links to avoid duplicates
20
+
opts
21
+
)
22
+
rescue
23
+
ArgumentError ->
24
+
# Table already exists
25
+
WebSockex.start_link(
26
+
"wss://bsky-relay.c.theo.io/subscribe?wantedCollections=app.bsky.feed.post",
27
+
__MODULE__,
28
+
%{processed_links: MapSet.new()},
29
+
opts
30
+
)
31
+
end
32
+
33
+
def handle_connect(_conn, state) do
34
+
Logger.info("Connected to Bluesky relay!")
35
+
IO.puts("#{DateTime.utc_now()}")
36
+
{:ok, state}
37
+
end
38
+
39
+
def handle_frame({:text, msg}, state) do
40
+
msg = Jason.decode!(msg)
41
+
42
+
case msg do
43
+
%{"commit" => record = %{"record" => %{"text" => skeet}}} = _msg ->
44
+
# Check if this post has an external link
45
+
case record do
46
+
%{
47
+
"record" => %{
48
+
"embed" => %{
49
+
"external" => %{
50
+
"uri" => uri
51
+
}
52
+
}
53
+
}
54
+
} when is_binary(uri) ->
55
+
# Only process if we haven't seen this link before
56
+
if not MapSet.member?(state.processed_links, uri) do
57
+
Logger.info("Found link in Bluesky post: #{uri}")
58
+
59
+
# Extract title from the embed
60
+
title =
61
+
record
62
+
|> Map.get("record", %{})
63
+
|> Map.get("embed", %{})
64
+
|> Map.get("external", %{})
65
+
|> Map.get("title", "Untitled Bluesky Share")
66
+
67
+
# Extract description if available
68
+
description =
69
+
record
70
+
|> Map.get("record", %{})
71
+
|> Map.get("embed", %{})
72
+
|> Map.get("external", %{})
73
+
|> Map.get("description", skeet)
74
+
75
+
# Create tags based on the post content
76
+
tags = extract_tags_from_text(skeet)
77
+
78
+
# Get a demo user for posting the bookmark
79
+
user = get_or_create_demo_user()
80
+
81
+
# Create the bookmark in our system
82
+
bookmark_attrs = %{
83
+
url: uri,
84
+
title: title,
85
+
description: description,
86
+
user_id: user.id
87
+
}
88
+
89
+
case Bookmarks.create_bookmark(bookmark_attrs, tags) do
90
+
{:ok, bookmark} ->
91
+
Logger.info("Created bookmark: #{bookmark.title}")
92
+
# Broadcast is handled in Bookmarks.create_bookmark
93
+
94
+
# Update state to track this link
95
+
updated_state = %{state | processed_links: MapSet.put(state.processed_links, uri)}
96
+
{:ok, updated_state}
97
+
98
+
{:error, changeset} ->
99
+
Logger.error("Failed to create bookmark: #{inspect(changeset.errors)}")
100
+
{:ok, state}
101
+
end
102
+
else
103
+
# Already processed this link
104
+
{:ok, state}
105
+
end
106
+
107
+
_ ->
108
+
# No external link in this post
109
+
{:ok, state}
110
+
end
111
+
112
+
_ ->
113
+
# Not a post we're interested in
114
+
{:ok, state}
115
+
end
116
+
end
117
+
118
+
# Extract potential tags from the text of a post
119
+
defp extract_tags_from_text(text) do
120
+
# Extract hashtags (words starting with #)
121
+
hashtags =
122
+
~r/#([a-zA-Z0-9_]+)/
123
+
|> Regex.scan(text)
124
+
|> Enum.map(fn [_, tag] -> tag end)
125
+
126
+
# Combine and ensure uniqueness
127
+
hashtags
128
+
|> Enum.uniq()
129
+
|> Enum.reject(&is_nil/1)
130
+
|> Enum.map(&String.downcase/1)
131
+
end
132
+
133
+
# Get a demo user for posting bookmarks
134
+
defp get_or_create_demo_user do
135
+
case Accounts.get_user_by_username("bluesky_bot") do
136
+
nil ->
137
+
{:ok, user} = Accounts.create_user(%{
138
+
username: "bluesky_bot",
139
+
email: "bluesky_bot@example.com",
140
+
display_name: "Bluesky Bot"
141
+
})
142
+
user
143
+
user ->
144
+
user
145
+
end
146
+
end
147
+
148
+
def handle_disconnect(%{reason: {:local, reason}}, state) do
149
+
Logger.info("Local close with reason: #{inspect(reason)}")
150
+
{:ok, state}
151
+
end
152
+
153
+
def handle_disconnect(disconnect_map, state) do
154
+
Logger.warning("Disconnected from Bluesky: #{inspect(disconnect_map)}")
155
+
# Reconnect after a delay
156
+
Process.send_after(self(), :reconnect, 5000)
157
+
{:ok, state}
158
+
end
159
+
160
+
def handle_info(:reconnect, state) do
161
+
Logger.info("Attempting to reconnect to Bluesky...")
162
+
{:reconnect, state}
163
+
end
164
+
end
+122
lib/bookmarker/accounts.ex
+122
lib/bookmarker/accounts.ex
···
1
+
defmodule Bookmarker.Accounts do
2
+
@moduledoc """
3
+
The Accounts context.
4
+
"""
5
+
6
+
import Ecto.Query, warn: false
7
+
alias Bookmarker.Repo
8
+
9
+
alias Bookmarker.Accounts.User
10
+
11
+
@doc """
12
+
Returns the list of users.
13
+
14
+
## Examples
15
+
16
+
iex> list_users()
17
+
[%User{}, ...]
18
+
19
+
"""
20
+
def list_users do
21
+
Repo.all(User)
22
+
end
23
+
24
+
@doc """
25
+
Gets a single user.
26
+
27
+
Raises `Ecto.NoResultsError` if the User does not exist.
28
+
29
+
## Examples
30
+
31
+
iex> get_user!(123)
32
+
%User{}
33
+
34
+
iex> get_user!(456)
35
+
** (Ecto.NoResultsError)
36
+
37
+
"""
38
+
def get_user!(id), do: Repo.get!(User, id)
39
+
40
+
@doc """
41
+
Gets a single user by username.
42
+
43
+
Returns nil if the User does not exist.
44
+
45
+
## Examples
46
+
47
+
iex> get_user_by_username("johndoe")
48
+
%User{}
49
+
50
+
iex> get_user_by_username("nonexistent")
51
+
nil
52
+
53
+
"""
54
+
def get_user_by_username(username) when is_binary(username) do
55
+
Repo.get_by(User, username: username)
56
+
end
57
+
58
+
@doc """
59
+
Creates a user.
60
+
61
+
## Examples
62
+
63
+
iex> create_user(%{field: value})
64
+
{:ok, %User{}}
65
+
66
+
iex> create_user(%{field: bad_value})
67
+
{:error, %Ecto.Changeset{}}
68
+
69
+
"""
70
+
def create_user(attrs \\ %{}) do
71
+
%User{}
72
+
|> User.changeset(attrs)
73
+
|> Repo.insert()
74
+
end
75
+
76
+
@doc """
77
+
Updates a user.
78
+
79
+
## Examples
80
+
81
+
iex> update_user(user, %{field: new_value})
82
+
{:ok, %User{}}
83
+
84
+
iex> update_user(user, %{field: bad_value})
85
+
{:error, %Ecto.Changeset{}}
86
+
87
+
"""
88
+
def update_user(%User{} = user, attrs) do
89
+
user
90
+
|> User.changeset(attrs)
91
+
|> Repo.update()
92
+
end
93
+
94
+
@doc """
95
+
Deletes a user.
96
+
97
+
## Examples
98
+
99
+
iex> delete_user(user)
100
+
{:ok, %User{}}
101
+
102
+
iex> delete_user(user)
103
+
{:error, %Ecto.Changeset{}}
104
+
105
+
"""
106
+
def delete_user(%User{} = user) do
107
+
Repo.delete(user)
108
+
end
109
+
110
+
@doc """
111
+
Returns an `%Ecto.Changeset{}` for tracking user changes.
112
+
113
+
## Examples
114
+
115
+
iex> change_user(user)
116
+
%Ecto.Changeset{data: %User{}}
117
+
118
+
"""
119
+
def change_user(%User{} = user, attrs \\ %{}) do
120
+
User.changeset(user, attrs)
121
+
end
122
+
end
+27
lib/bookmarker/accounts/user.ex
+27
lib/bookmarker/accounts/user.ex
···
1
+
defmodule Bookmarker.Accounts.User do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
schema "users" do
6
+
field :username, :string
7
+
field :email, :string
8
+
field :display_name, :string
9
+
10
+
has_many :bookmarks, Bookmarker.Bookmarks.Bookmark
11
+
has_many :comments, Bookmarker.Comments.Comment
12
+
13
+
timestamps()
14
+
end
15
+
16
+
@doc false
17
+
def changeset(user, attrs) do
18
+
user
19
+
|> cast(attrs, [:username, :email, :display_name])
20
+
|> validate_required([:username, :email])
21
+
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
22
+
|> validate_length(:username, min: 3, max: 30)
23
+
|> validate_length(:email, max: 160)
24
+
|> unique_constraint(:username)
25
+
|> unique_constraint(:email)
26
+
end
27
+
end
+4
-2
lib/bookmarker/application.ex
+4
-2
lib/bookmarker/application.ex
···
14
14
{Phoenix.PubSub, name: Bookmarker.PubSub},
15
15
# Start the Finch HTTP client for sending emails
16
16
{Finch, name: Bookmarker.Finch},
17
-
# Start a worker by calling: Bookmarker.Worker.start_link(arg)
18
-
# {Bookmarker.Worker, arg},
17
+
# Start the ETS cache for bookmarks
18
+
{Bookmarker.Cache, []},
19
+
# Start the Bluesky connection for simulating bookmark traffic
20
+
{BlueskyHose, []},
19
21
# Start to serve requests, typically the last entry
20
22
BookmarkerWeb.Endpoint
21
23
]
+274
lib/bookmarker/bookmarks.ex
+274
lib/bookmarker/bookmarks.ex
···
1
+
defmodule Bookmarker.Bookmarks do
2
+
@moduledoc """
3
+
The Bookmarks context.
4
+
"""
5
+
6
+
import Ecto.Query, warn: false
7
+
alias Bookmarker.Repo
8
+
alias Bookmarker.Bookmarks.Bookmark
9
+
alias Bookmarker.Tags
10
+
alias Phoenix.PubSub
11
+
12
+
@topic "bookmarks"
13
+
14
+
@doc """
15
+
Returns the list of bookmarks.
16
+
17
+
## Examples
18
+
19
+
iex> list_bookmarks()
20
+
[%Bookmark{}, ...]
21
+
22
+
"""
23
+
def list_bookmarks do
24
+
Repo.all(Bookmark)
25
+
|> Repo.preload([:user, :tags])
26
+
end
27
+
28
+
@doc """
29
+
Returns the list of recent bookmarks, ordered by insertion date.
30
+
31
+
## Examples
32
+
33
+
iex> list_recent_bookmarks(20)
34
+
[%Bookmark{}, ...]
35
+
36
+
"""
37
+
def list_recent_bookmarks(limit \\ 20) do
38
+
Bookmark
39
+
|> order_by([b], desc: b.inserted_at)
40
+
|> limit(^limit)
41
+
|> Repo.all()
42
+
|> Repo.preload([:user, :tags])
43
+
end
44
+
45
+
@doc """
46
+
Gets a single bookmark.
47
+
48
+
Raises `Ecto.NoResultsError` if the Bookmark does not exist.
49
+
50
+
## Examples
51
+
52
+
iex> get_bookmark!(123)
53
+
%Bookmark{}
54
+
55
+
iex> get_bookmark!(456)
56
+
** (Ecto.NoResultsError)
57
+
58
+
"""
59
+
def get_bookmark!(id) do
60
+
Bookmark
61
+
|> Repo.get!(id)
62
+
|> Repo.preload([:user, :tags, comments: [:user]])
63
+
end
64
+
65
+
@doc """
66
+
Creates a bookmark.
67
+
68
+
## Examples
69
+
70
+
iex> create_bookmark(%{field: value}, ["tag1", "tag2"])
71
+
{:ok, %Bookmark{}}
72
+
73
+
iex> create_bookmark(%{field: bad_value}, [])
74
+
{:error, %Ecto.Changeset{}}
75
+
76
+
"""
77
+
def create_bookmark(attrs \\ %{}, tag_names \\ []) do
78
+
tags = Tags.get_or_create_tags(tag_names)
79
+
80
+
result =
81
+
%Bookmark{}
82
+
|> Bookmark.changeset(attrs, tags)
83
+
|> Repo.insert()
84
+
85
+
case result do
86
+
{:ok, bookmark} ->
87
+
bookmark = Repo.preload(bookmark, [:user, :tags])
88
+
broadcast({:bookmark_created, bookmark})
89
+
{:ok, bookmark}
90
+
91
+
error ->
92
+
error
93
+
end
94
+
end
95
+
96
+
@doc """
97
+
Updates a bookmark.
98
+
99
+
## Examples
100
+
101
+
iex> update_bookmark(bookmark, %{field: new_value}, ["tag1", "tag2"])
102
+
{:ok, %Bookmark{}}
103
+
104
+
iex> update_bookmark(bookmark, %{field: bad_value}, [])
105
+
{:error, %Ecto.Changeset{}}
106
+
107
+
"""
108
+
def update_bookmark(%Bookmark{} = bookmark, attrs, tag_names \\ []) do
109
+
tags = Tags.get_or_create_tags(tag_names)
110
+
111
+
result =
112
+
bookmark
113
+
|> Bookmark.changeset(attrs, tags)
114
+
|> Repo.update()
115
+
116
+
case result do
117
+
{:ok, bookmark} ->
118
+
bookmark = Repo.preload(bookmark, [:user, :tags])
119
+
broadcast({:bookmark_updated, bookmark})
120
+
{:ok, bookmark}
121
+
122
+
error ->
123
+
error
124
+
end
125
+
end
126
+
127
+
@doc """
128
+
Deletes a bookmark.
129
+
130
+
## Examples
131
+
132
+
iex> delete_bookmark(bookmark)
133
+
{:ok, %Bookmark{}}
134
+
135
+
iex> delete_bookmark(bookmark)
136
+
{:error, %Ecto.Changeset{}}
137
+
138
+
"""
139
+
def delete_bookmark(%Bookmark{} = bookmark) do
140
+
result = Repo.delete(bookmark)
141
+
142
+
case result do
143
+
{:ok, bookmark} ->
144
+
broadcast({:bookmark_deleted, bookmark})
145
+
{:ok, bookmark}
146
+
147
+
error ->
148
+
error
149
+
end
150
+
end
151
+
152
+
@doc """
153
+
Returns an `%Ecto.Changeset{}` for tracking bookmark changes.
154
+
155
+
## Examples
156
+
157
+
iex> change_bookmark(bookmark)
158
+
%Ecto.Changeset{data: %Bookmark{}}
159
+
160
+
"""
161
+
def change_bookmark(%Bookmark{} = bookmark, attrs \\ %{}) do
162
+
Bookmark.changeset(bookmark, attrs)
163
+
end
164
+
165
+
@doc """
166
+
Returns the list of bookmarks for a specific user.
167
+
168
+
## Examples
169
+
170
+
iex> list_user_bookmarks(user_id)
171
+
[%Bookmark{}, ...]
172
+
173
+
"""
174
+
def list_user_bookmarks(user_id) do
175
+
Bookmark
176
+
|> where([b], b.user_id == ^user_id)
177
+
|> order_by([b], desc: b.inserted_at)
178
+
|> Repo.all()
179
+
|> Repo.preload([:user, :tags])
180
+
end
181
+
182
+
@doc """
183
+
Returns the list of bookmarks with a specific tag.
184
+
185
+
## Examples
186
+
187
+
iex> list_bookmarks_by_tag("elixir")
188
+
[%Bookmark{}, ...]
189
+
190
+
"""
191
+
def list_bookmarks_by_tag(tag_name) when is_binary(tag_name) do
192
+
tag = Tags.get_tag_by_name(tag_name)
193
+
194
+
if tag do
195
+
Bookmark
196
+
|> join(:inner, [b], bt in "bookmark_tags", on: b.id == bt.bookmark_id)
197
+
|> where([_, bt], bt.tag_id == ^tag.id)
198
+
|> order_by([b], desc: b.inserted_at)
199
+
|> Repo.all()
200
+
|> Repo.preload([:user, :tags])
201
+
else
202
+
[]
203
+
end
204
+
end
205
+
206
+
@doc """
207
+
Returns the list of bookmarks with all of the specified tags.
208
+
209
+
## Examples
210
+
211
+
iex> list_bookmarks_by_tags(["elixir", "phoenix"])
212
+
[%Bookmark{}, ...]
213
+
214
+
"""
215
+
def list_bookmarks_by_tags(tag_names) when is_list(tag_names) and length(tag_names) > 0 do
216
+
# Get tag IDs
217
+
tag_ids =
218
+
tag_names
219
+
|> Enum.map(&Tags.get_tag_by_name/1)
220
+
|> Enum.reject(&is_nil/1)
221
+
|> Enum.map(& &1.id)
222
+
223
+
if Enum.empty?(tag_ids) do
224
+
[]
225
+
else
226
+
# Count how many of the requested tags each bookmark has
227
+
tag_count_query =
228
+
from bt in "bookmark_tags",
229
+
where: bt.tag_id in ^tag_ids,
230
+
group_by: bt.bookmark_id,
231
+
select: {bt.bookmark_id, count(bt.tag_id)}
232
+
233
+
# Only select bookmarks that have all the requested tags
234
+
Bookmark
235
+
|> join(:inner, [b], tc in subquery(tag_count_query), on: b.id == tc.bookmark_id)
236
+
|> where([_, tc], tc.count == ^length(tag_ids))
237
+
|> order_by([b], desc: b.inserted_at)
238
+
|> Repo.all()
239
+
|> Repo.preload([:user, :tags])
240
+
end
241
+
end
242
+
243
+
def list_bookmarks_by_tags(_), do: []
244
+
245
+
@doc """
246
+
Subscribe to bookmark events.
247
+
"""
248
+
def subscribe do
249
+
PubSub.subscribe(Bookmarker.PubSub, @topic)
250
+
end
251
+
252
+
@doc """
253
+
Subscribe to bookmark events for a specific tag.
254
+
"""
255
+
def subscribe_to_tag(tag_name) when is_binary(tag_name) do
256
+
PubSub.subscribe(Bookmarker.PubSub, "#{@topic}:tag:#{tag_name}")
257
+
end
258
+
259
+
# Broadcast a bookmark event.
260
+
defp broadcast({_event, bookmark} = message) do
261
+
PubSub.broadcast(Bookmarker.PubSub, @topic, message)
262
+
263
+
# Also broadcast to tag-specific topics
264
+
Enum.each(bookmark.tags, fn tag ->
265
+
PubSub.broadcast(
266
+
Bookmarker.PubSub,
267
+
"#{@topic}:tag:#{tag.name}",
268
+
message
269
+
)
270
+
end)
271
+
272
+
:ok
273
+
end
274
+
end
+29
lib/bookmarker/bookmarks/bookmark.ex
+29
lib/bookmarker/bookmarks/bookmark.ex
···
1
+
defmodule Bookmarker.Bookmarks.Bookmark do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
schema "bookmarks" do
6
+
field :url, :string
7
+
field :title, :string
8
+
field :description, :string
9
+
10
+
belongs_to :user, Bookmarker.Accounts.User
11
+
many_to_many :tags, Bookmarker.Tags.Tag,
12
+
join_through: Bookmarker.Bookmarks.BookmarkTag,
13
+
on_replace: :delete
14
+
has_many :comments, Bookmarker.Comments.Comment
15
+
16
+
timestamps()
17
+
end
18
+
19
+
@doc false
20
+
def changeset(bookmark, attrs, tags \\ []) do
21
+
bookmark
22
+
|> cast(attrs, [:url, :title, :description, :user_id])
23
+
|> validate_required([:url, :title, :user_id])
24
+
|> validate_length(:title, min: 1, max: 255)
25
+
|> validate_length(:url, min: 5, max: 2048)
26
+
|> foreign_key_constraint(:user_id)
27
+
|> put_assoc(:tags, tags)
28
+
end
29
+
end
+20
lib/bookmarker/bookmarks/bookmark_tag.ex
+20
lib/bookmarker/bookmarks/bookmark_tag.ex
···
1
+
defmodule Bookmarker.Bookmarks.BookmarkTag do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
@primary_key false
6
+
schema "bookmark_tags" do
7
+
belongs_to :bookmark, Bookmarker.Bookmarks.Bookmark
8
+
belongs_to :tag, Bookmarker.Tags.Tag
9
+
10
+
timestamps()
11
+
end
12
+
13
+
@doc false
14
+
def changeset(bookmark_tag, attrs) do
15
+
bookmark_tag
16
+
|> cast(attrs, [:bookmark_id, :tag_id])
17
+
|> validate_required([:bookmark_id, :tag_id])
18
+
|> unique_constraint([:bookmark_id, :tag_id])
19
+
end
20
+
end
+192
lib/bookmarker/cache.ex
+192
lib/bookmarker/cache.ex
···
1
+
defmodule Bookmarker.Cache do
2
+
@moduledoc """
3
+
ETS-based cache for bookmarks and related data.
4
+
"""
5
+
6
+
use GenServer
7
+
alias Bookmarker.Bookmarks
8
+
alias Bookmarker.Tags
9
+
alias Phoenix.PubSub
10
+
11
+
@bookmarks_table :bookmarks_cache
12
+
@tags_table :tags_cache
13
+
@tag_bookmarks_table :tag_bookmarks_cache
14
+
15
+
# Client API
16
+
17
+
def start_link(_opts) do
18
+
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
19
+
end
20
+
21
+
@doc """
22
+
Get recent bookmarks from the cache.
23
+
"""
24
+
def get_recent_bookmarks(limit \\ 20) do
25
+
case :ets.lookup(@bookmarks_table, :recent) do
26
+
[{:recent, bookmarks}] -> Enum.take(bookmarks, limit)
27
+
[] -> []
28
+
end
29
+
end
30
+
31
+
@doc """
32
+
Get bookmarks for a specific tag from the cache.
33
+
"""
34
+
def get_bookmarks_by_tag(tag_name) do
35
+
case :ets.lookup(@tag_bookmarks_table, tag_name) do
36
+
[{^tag_name, bookmarks}] -> bookmarks
37
+
[] -> []
38
+
end
39
+
end
40
+
41
+
@doc """
42
+
Get all tags from the cache.
43
+
"""
44
+
def get_all_tags do
45
+
case :ets.lookup(@tags_table, :all) do
46
+
[{:all, tags}] -> tags
47
+
[] -> []
48
+
end
49
+
end
50
+
51
+
# Server callbacks
52
+
53
+
@impl true
54
+
def init(:ok) do
55
+
# Create ETS tables
56
+
:ets.new(@bookmarks_table, [:set, :named_table, :public, read_concurrency: true])
57
+
:ets.new(@tags_table, [:set, :named_table, :public, read_concurrency: true])
58
+
:ets.new(@tag_bookmarks_table, [:set, :named_table, :public, read_concurrency: true])
59
+
60
+
# Initialize cache with data from the database
61
+
initialize_cache()
62
+
63
+
# Subscribe to bookmark events
64
+
PubSub.subscribe(Bookmarker.PubSub, "bookmarks")
65
+
66
+
{:ok, %{}}
67
+
end
68
+
69
+
@impl true
70
+
def handle_info({:bookmark_created, bookmark}, state) do
71
+
update_bookmarks_cache(bookmark, :add)
72
+
update_tag_bookmarks_cache(bookmark, :add)
73
+
{:noreply, state}
74
+
end
75
+
76
+
@impl true
77
+
def handle_info({:bookmark_updated, bookmark}, state) do
78
+
update_bookmarks_cache(bookmark, :update)
79
+
update_tag_bookmarks_cache(bookmark, :update)
80
+
{:noreply, state}
81
+
end
82
+
83
+
@impl true
84
+
def handle_info({:bookmark_deleted, bookmark}, state) do
85
+
update_bookmarks_cache(bookmark, :remove)
86
+
update_tag_bookmarks_cache(bookmark, :remove)
87
+
{:noreply, state}
88
+
end
89
+
90
+
@impl true
91
+
def handle_info(_, state), do: {:noreply, state}
92
+
93
+
# Private functions
94
+
95
+
defp initialize_cache do
96
+
# Cache recent bookmarks
97
+
bookmarks = Bookmarks.list_recent_bookmarks(100)
98
+
:ets.insert(@bookmarks_table, {:recent, bookmarks})
99
+
100
+
# Cache all tags
101
+
tags = Tags.list_tags()
102
+
:ets.insert(@tags_table, {:all, tags})
103
+
104
+
# Cache bookmarks by tag
105
+
Enum.each(tags, fn tag ->
106
+
bookmarks = Bookmarks.list_bookmarks_by_tag(tag.name)
107
+
:ets.insert(@tag_bookmarks_table, {tag.name, bookmarks})
108
+
end)
109
+
end
110
+
111
+
defp update_bookmarks_cache(bookmark, action) do
112
+
case :ets.lookup(@bookmarks_table, :recent) do
113
+
[{:recent, bookmarks}] ->
114
+
updated_bookmarks =
115
+
case action do
116
+
:add ->
117
+
[bookmark | bookmarks]
118
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
119
+
|> Enum.take(100)
120
+
121
+
:update ->
122
+
bookmarks
123
+
|> Enum.map(fn b -> if b.id == bookmark.id, do: bookmark, else: b end)
124
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
125
+
126
+
:remove ->
127
+
bookmarks
128
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
129
+
end
130
+
131
+
:ets.insert(@bookmarks_table, {:recent, updated_bookmarks})
132
+
133
+
[] ->
134
+
:ets.insert(@bookmarks_table, {:recent, [bookmark]})
135
+
end
136
+
end
137
+
138
+
defp update_tag_bookmarks_cache(bookmark, action) do
139
+
# For each tag in the bookmark, update the tag's bookmarks list
140
+
Enum.each(bookmark.tags, fn tag ->
141
+
case :ets.lookup(@tag_bookmarks_table, tag.name) do
142
+
[{tag_name, bookmarks}] ->
143
+
updated_bookmarks =
144
+
case action do
145
+
:add ->
146
+
[bookmark | bookmarks]
147
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
148
+
149
+
:update ->
150
+
bookmarks
151
+
|> Enum.map(fn b -> if b.id == bookmark.id, do: bookmark, else: b end)
152
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
153
+
154
+
:remove ->
155
+
bookmarks
156
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
157
+
end
158
+
159
+
:ets.insert(@tag_bookmarks_table, {tag_name, updated_bookmarks})
160
+
161
+
[] ->
162
+
:ets.insert(@tag_bookmarks_table, {tag.name, [bookmark]})
163
+
end
164
+
end)
165
+
166
+
# If this is an update or remove, we need to check if any tags were removed
167
+
if action in [:update, :remove] do
168
+
# Get the bookmark from the database to compare tags
169
+
case action do
170
+
:update ->
171
+
old_bookmark = Bookmarks.get_bookmark!(bookmark.id)
172
+
old_tags = MapSet.new(old_bookmark.tags, & &1.name)
173
+
new_tags = MapSet.new(bookmark.tags, & &1.name)
174
+
removed_tags = MapSet.difference(old_tags, new_tags)
175
+
176
+
# For each removed tag, update its bookmarks list
177
+
Enum.each(removed_tags, fn tag_name ->
178
+
case :ets.lookup(@tag_bookmarks_table, tag_name) do
179
+
[{^tag_name, bookmarks}] ->
180
+
updated_bookmarks = Enum.reject(bookmarks, fn b -> b.id == bookmark.id end)
181
+
:ets.insert(@tag_bookmarks_table, {tag_name, updated_bookmarks})
182
+
[] -> :ok
183
+
end
184
+
end)
185
+
186
+
:remove ->
187
+
# For a delete, we've already handled the tags in the bookmark
188
+
:ok
189
+
end
190
+
end
191
+
end
192
+
end
+189
lib/bookmarker/comments.ex
+189
lib/bookmarker/comments.ex
···
1
+
defmodule Bookmarker.Comments do
2
+
@moduledoc """
3
+
The Comments context.
4
+
"""
5
+
6
+
import Ecto.Query, warn: false
7
+
alias Bookmarker.Repo
8
+
alias Bookmarker.Comments.Comment
9
+
alias Phoenix.PubSub
10
+
11
+
@topic "comments"
12
+
13
+
@doc """
14
+
Returns the list of comments.
15
+
16
+
## Examples
17
+
18
+
iex> list_comments()
19
+
[%Comment{}, ...]
20
+
21
+
"""
22
+
def list_comments do
23
+
Repo.all(Comment)
24
+
|> Repo.preload([:user, :bookmark])
25
+
end
26
+
27
+
@doc """
28
+
Gets a single comment.
29
+
30
+
Raises `Ecto.NoResultsError` if the Comment does not exist.
31
+
32
+
## Examples
33
+
34
+
iex> get_comment!(123)
35
+
%Comment{}
36
+
37
+
iex> get_comment!(456)
38
+
** (Ecto.NoResultsError)
39
+
40
+
"""
41
+
def get_comment!(id) do
42
+
Comment
43
+
|> Repo.get!(id)
44
+
|> Repo.preload([:user, :bookmark])
45
+
end
46
+
47
+
@doc """
48
+
Creates a comment.
49
+
50
+
## Examples
51
+
52
+
iex> create_comment(%{field: value})
53
+
{:ok, %Comment{}}
54
+
55
+
iex> create_comment(%{field: bad_value})
56
+
{:error, %Ecto.Changeset{}}
57
+
58
+
"""
59
+
def create_comment(attrs \\ %{}) do
60
+
result =
61
+
%Comment{}
62
+
|> Comment.changeset(attrs)
63
+
|> Repo.insert()
64
+
65
+
case result do
66
+
{:ok, comment} ->
67
+
comment = Repo.preload(comment, [:user, :bookmark])
68
+
broadcast({:comment_created, comment})
69
+
broadcast_to_bookmark(comment.bookmark_id, {:comment_created, comment})
70
+
{:ok, comment}
71
+
72
+
error ->
73
+
error
74
+
end
75
+
end
76
+
77
+
@doc """
78
+
Updates a comment.
79
+
80
+
## Examples
81
+
82
+
iex> update_comment(comment, %{field: new_value})
83
+
{:ok, %Comment{}}
84
+
85
+
iex> update_comment(comment, %{field: bad_value})
86
+
{:error, %Ecto.Changeset{}}
87
+
88
+
"""
89
+
def update_comment(%Comment{} = comment, attrs) do
90
+
result =
91
+
comment
92
+
|> Comment.changeset(attrs)
93
+
|> Repo.update()
94
+
95
+
case result do
96
+
{:ok, comment} ->
97
+
comment = Repo.preload(comment, [:user, :bookmark])
98
+
broadcast({:comment_updated, comment})
99
+
broadcast_to_bookmark(comment.bookmark_id, {:comment_updated, comment})
100
+
{:ok, comment}
101
+
102
+
error ->
103
+
error
104
+
end
105
+
end
106
+
107
+
@doc """
108
+
Deletes a comment.
109
+
110
+
## Examples
111
+
112
+
iex> delete_comment(comment)
113
+
{:ok, %Comment{}}
114
+
115
+
iex> delete_comment(comment)
116
+
{:error, %Ecto.Changeset{}}
117
+
118
+
"""
119
+
def delete_comment(%Comment{} = comment) do
120
+
bookmark_id = comment.bookmark_id
121
+
result = Repo.delete(comment)
122
+
123
+
case result do
124
+
{:ok, comment} ->
125
+
broadcast({:comment_deleted, comment})
126
+
broadcast_to_bookmark(bookmark_id, {:comment_deleted, comment})
127
+
{:ok, comment}
128
+
129
+
error ->
130
+
error
131
+
end
132
+
end
133
+
134
+
@doc """
135
+
Returns an `%Ecto.Changeset{}` for tracking comment changes.
136
+
137
+
## Examples
138
+
139
+
iex> change_comment(comment)
140
+
%Ecto.Changeset{data: %Comment{}}
141
+
142
+
"""
143
+
def change_comment(%Comment{} = comment, attrs \\ %{}) do
144
+
Comment.changeset(comment, attrs)
145
+
end
146
+
147
+
@doc """
148
+
Returns the list of comments for a specific bookmark.
149
+
150
+
## Examples
151
+
152
+
iex> list_bookmark_comments(bookmark_id)
153
+
[%Comment{}, ...]
154
+
155
+
"""
156
+
def list_bookmark_comments(bookmark_id) do
157
+
Comment
158
+
|> where([c], c.bookmark_id == ^bookmark_id)
159
+
|> order_by([c], asc: c.inserted_at)
160
+
|> Repo.all()
161
+
|> Repo.preload([:user])
162
+
end
163
+
164
+
@doc """
165
+
Subscribe to comment events.
166
+
"""
167
+
def subscribe do
168
+
PubSub.subscribe(Bookmarker.PubSub, @topic)
169
+
end
170
+
171
+
@doc """
172
+
Subscribe to comment events for a specific bookmark.
173
+
"""
174
+
def subscribe_to_bookmark(bookmark_id) do
175
+
PubSub.subscribe(Bookmarker.PubSub, "#{@topic}:bookmark:#{bookmark_id}")
176
+
end
177
+
178
+
# Broadcast a comment event.
179
+
defp broadcast(message) do
180
+
PubSub.broadcast(Bookmarker.PubSub, @topic, message)
181
+
:ok
182
+
end
183
+
184
+
# Broadcast a comment event to a specific bookmark topic.
185
+
defp broadcast_to_bookmark(bookmark_id, message) do
186
+
PubSub.broadcast(Bookmarker.PubSub, "#{@topic}:bookmark:#{bookmark_id}", message)
187
+
:ok
188
+
end
189
+
end
+23
lib/bookmarker/comments/comment.ex
+23
lib/bookmarker/comments/comment.ex
···
1
+
defmodule Bookmarker.Comments.Comment do
2
+
use Ecto.Schema
3
+
import Ecto.Changeset
4
+
5
+
schema "comments" do
6
+
field :content, :string
7
+
8
+
belongs_to :user, Bookmarker.Accounts.User
9
+
belongs_to :bookmark, Bookmarker.Bookmarks.Bookmark
10
+
11
+
timestamps()
12
+
end
13
+
14
+
@doc false
15
+
def changeset(comment, attrs) do
16
+
comment
17
+
|> cast(attrs, [:content, :user_id, :bookmark_id])
18
+
|> validate_required([:content, :user_id, :bookmark_id])
19
+
|> validate_length(:content, min: 1, max: 2000)
20
+
|> foreign_key_constraint(:user_id)
21
+
|> foreign_key_constraint(:bookmark_id)
22
+
end
23
+
end
+2
lib/bookmarker_web.ex
+2
lib/bookmarker_web.ex
+66
lib/bookmarker_web/channels/bookmark_channel.ex
+66
lib/bookmarker_web/channels/bookmark_channel.ex
···
1
+
defmodule BookmarkerWeb.BookmarkChannel do
2
+
use BookmarkerWeb, :channel
3
+
4
+
alias Bookmarker.Bookmarks
5
+
alias Bookmarker.Cache
6
+
7
+
@impl true
8
+
def join("bookmarks:all", _payload, socket) do
9
+
# Send initial data to the client
10
+
bookmarks = Cache.get_recent_bookmarks(20)
11
+
12
+
# Subscribe to bookmark events
13
+
Bookmarks.subscribe()
14
+
15
+
{:ok, %{bookmarks: bookmarks}, socket}
16
+
end
17
+
18
+
@impl true
19
+
def join("bookmarks:tag:" <> tag_name, _payload, socket) do
20
+
# Send initial data to the client
21
+
bookmarks = Cache.get_bookmarks_by_tag(tag_name)
22
+
23
+
# Subscribe to tag-specific bookmark events
24
+
Bookmarks.subscribe_to_tag(tag_name)
25
+
26
+
{:ok, %{bookmarks: bookmarks}, assign(socket, :tag, tag_name)}
27
+
end
28
+
29
+
@impl true
30
+
def join("bookmarks:user:" <> user_id, _payload, socket) do
31
+
{user_id, _} = Integer.parse(user_id)
32
+
bookmarks = Bookmarks.list_user_bookmarks(user_id)
33
+
34
+
{:ok, %{bookmarks: bookmarks}, assign(socket, :user_id, user_id)}
35
+
end
36
+
37
+
# Handle incoming messages from clients
38
+
@impl true
39
+
def handle_in("new_bookmark", %{"url" => url, "title" => title, "description" => description, "tags" => tags, "user_id" => user_id}, socket) do
40
+
case Bookmarks.create_bookmark(%{url: url, title: title, description: description, user_id: user_id}, tags) do
41
+
{:ok, bookmark} ->
42
+
{:reply, {:ok, %{bookmark: bookmark}}, socket}
43
+
{:error, changeset} ->
44
+
{:reply, {:error, %{errors: changeset}}, socket}
45
+
end
46
+
end
47
+
48
+
# Handle PubSub events
49
+
@impl true
50
+
def handle_info({:bookmark_created, bookmark}, socket) do
51
+
push(socket, "bookmark_created", %{bookmark: bookmark})
52
+
{:noreply, socket}
53
+
end
54
+
55
+
@impl true
56
+
def handle_info({:bookmark_updated, bookmark}, socket) do
57
+
push(socket, "bookmark_updated", %{bookmark: bookmark})
58
+
{:noreply, socket}
59
+
end
60
+
61
+
@impl true
62
+
def handle_info({:bookmark_deleted, bookmark}, socket) do
63
+
push(socket, "bookmark_deleted", %{bookmark: bookmark})
64
+
{:noreply, socket}
65
+
end
66
+
end
+43
lib/bookmarker_web/channels/user_socket.ex
+43
lib/bookmarker_web/channels/user_socket.ex
···
1
+
defmodule BookmarkerWeb.UserSocket do
2
+
use Phoenix.Socket
3
+
4
+
# A Socket handler
5
+
#
6
+
# It's possible to control the websocket connection and
7
+
# assign values that can be accessed by your channel topics.
8
+
9
+
## Channels
10
+
channel "bookmarks:*", BookmarkerWeb.BookmarkChannel
11
+
12
+
# Socket params are passed from the client and can
13
+
# be used to verify and authenticate a user. After
14
+
# verification, you can put default assigns into
15
+
# the socket that will be set for all channels, ie
16
+
#
17
+
# {:ok, assign(socket, :user_id, verified_user_id)}
18
+
#
19
+
# To deny connection, return `:error` or `{:error, term}`. To control the
20
+
# response the client receives in that case, use `{:error, view, options}`.
21
+
#
22
+
# See `Phoenix.Token` documentation for examples in
23
+
# performing token verification on connect.
24
+
@impl true
25
+
def connect(_params, socket, _connect_info) do
26
+
# For simplicity, we're not implementing authentication
27
+
# In a real app, you would authenticate the user here
28
+
{:ok, socket}
29
+
end
30
+
31
+
# Socket id's are topics that allow you to identify all sockets for a given user:
32
+
#
33
+
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
34
+
#
35
+
# Would allow you to broadcast a "disconnect" event and terminate
36
+
# all active sockets and channels for a given user:
37
+
#
38
+
# Elixir.BookmarkerWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
39
+
#
40
+
# Returning `nil` makes this socket anonymous.
41
+
@impl true
42
+
def id(_socket), do: nil
43
+
end
+80
-10
lib/bookmarker_web/components/core_components.ex
+80
-10
lib/bookmarker_web/components/core_components.ex
···
650
650
Translates an error message using gettext.
651
651
"""
652
652
def translate_error({msg, opts}) do
653
-
# When using gettext, we typically pass the strings we want
654
-
# to translate as a static argument:
655
-
#
656
-
# # Translate the number of files with plural rules
657
-
# dngettext("errors", "1 file", "%{count} files", count)
658
-
#
659
-
# However the error messages in our forms and APIs are generated
660
-
# dynamically, so we need to translate them by calling Gettext
661
-
# with our gettext backend as first argument. Translations are
662
-
# available in the errors.po file (as we use the "errors" domain).
663
653
if count = opts[:count] do
664
654
Gettext.dngettext(BookmarkerWeb.Gettext, "errors", msg, msg, count, opts)
665
655
else
···
672
662
"""
673
663
def translate_errors(errors, field) when is_list(errors) do
674
664
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
665
+
end
666
+
667
+
@doc """
668
+
Renders an error message for the given field.
669
+
670
+
## Examples
671
+
672
+
<.error_tag field={@form[:email]} />
673
+
674
+
"""
675
+
attr :field, Phoenix.HTML.FormField, required: true
676
+
677
+
def error_tag(assigns) do
678
+
~H"""
679
+
<%= for error <- Enum.map(@field.errors, &translate_error(&1)) do %>
680
+
<span class="invalid-feedback block mt-1 text-sm text-red-600" phx-feedback-for={@field.name}>
681
+
<%= error %>
682
+
</span>
683
+
<% end %>
684
+
"""
685
+
end
686
+
687
+
# Renders a text input field
688
+
attr :form, :any, required: true
689
+
attr :field, :atom, required: true
690
+
attr :rest, :global
691
+
692
+
def text_input(assigns) do
693
+
~H"""
694
+
<input
695
+
type="text"
696
+
name={Phoenix.HTML.Form.input_name(@form, @field)}
697
+
id={Phoenix.HTML.Form.input_id(@form, @field)}
698
+
value={Phoenix.HTML.Form.input_value(@form, @field)}
699
+
class="rounded-md border-0 bg-white/5 px-3.5 py-2 shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6"
700
+
{@rest}
701
+
/>
702
+
"""
703
+
end
704
+
705
+
# Renders a textarea field
706
+
attr :form, :any, required: true
707
+
attr :field, :atom, required: true
708
+
attr :rest, :global
709
+
710
+
def textarea(assigns) do
711
+
~H"""
712
+
<textarea
713
+
name={Phoenix.HTML.Form.input_name(@form, @field)}
714
+
id={Phoenix.HTML.Form.input_id(@form, @field)}
715
+
class="min-h-[6rem] w-full rounded-lg border-2 border-zinc-300 p-3 leading-snug placeholder:text-zinc-500 focus:border-zinc-500 focus:outline-none"
716
+
{@rest}
717
+
><%= Phoenix.HTML.Form.input_value(@form, @field) %></textarea>
718
+
"""
719
+
end
720
+
721
+
# Renders a form field label
722
+
attr :form, :any, required: true
723
+
attr :field, :atom, required: true
724
+
attr :text, :string, required: true
725
+
attr :rest, :global, default: %{}
726
+
727
+
def form_label(assigns) do
728
+
~H"""
729
+
<label for={Phoenix.HTML.Form.input_id(@form, @field)} {@rest}>
730
+
<%= @text %>
731
+
</label>
732
+
"""
733
+
end
734
+
735
+
# Renders a submit button
736
+
attr :text, :string, required: true
737
+
attr :rest, :global, default: %{}
738
+
739
+
def submit(assigns) do
740
+
~H"""
741
+
<button type="submit" {@rest}>
742
+
<%= @text %>
743
+
</button>
744
+
"""
675
745
end
676
746
end
+10
-10
lib/bookmarker_web/components/layouts/app.html.heex
+10
-10
lib/bookmarker_web/components/layouts/app.html.heex
···
5
5
<img src={~p"/images/logo.svg"} width="36" />
6
6
</a>
7
7
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
8
-
v{Application.spec(:phoenix, :vsn)}
8
+
Bookmarker
9
9
</p>
10
10
</div>
11
11
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
12
-
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
13
-
@elixirphoenix
12
+
<a href={~p"/"} class="hover:text-zinc-700">
13
+
Home
14
14
</a>
15
-
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
16
-
GitHub
15
+
<a href={~p"/tags"} class="hover:text-zinc-700">
16
+
Tag Cloud
17
17
</a>
18
18
<a
19
-
href="https://hexdocs.pm/phoenix/overview.html"
20
-
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
19
+
href={~p"/bookmarks/new"}
20
+
class="rounded-lg bg-indigo-500 text-white px-3 py-1 hover:bg-indigo-600"
21
21
>
22
-
Get Started <span aria-hidden="true">→</span>
22
+
Add Bookmark <span aria-hidden="true">→</span>
23
23
</a>
24
24
</div>
25
25
</div>
26
26
</header>
27
-
<main class="px-4 py-20 sm:px-6 lg:px-8">
28
-
<div class="mx-auto max-w-2xl">
27
+
<main class="px-4 py-10 sm:px-6 lg:px-8">
28
+
<div class="mx-auto max-w-4xl">
29
29
<.flash_group flash={@flash} />
30
30
{@inner_content}
31
31
</div>
+4
lib/bookmarker_web/endpoint.ex
+4
lib/bookmarker_web/endpoint.ex
···
11
11
same_site: "Lax"
12
12
]
13
13
14
+
socket "/socket", BookmarkerWeb.UserSocket,
15
+
websocket: true,
16
+
longpoll: false
17
+
14
18
socket "/live", Phoenix.LiveView.Socket,
15
19
websocket: [connect_info: [session: @session_options]],
16
20
longpoll: [connect_info: [session: @session_options]]
+71
lib/bookmarker_web/live/bookmark_live/new.ex
+71
lib/bookmarker_web/live/bookmark_live/new.ex
···
1
+
defmodule BookmarkerWeb.BookmarkLive.New do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Bookmarks
5
+
alias Bookmarker.Bookmarks.Bookmark
6
+
alias Bookmarker.Accounts
7
+
8
+
@impl true
9
+
def mount(_params, _session, socket) do
10
+
# For demo purposes, let's get the first user or create one
11
+
user = get_or_create_demo_user()
12
+
13
+
{:ok,
14
+
socket
15
+
|> assign(:current_user, user)
16
+
|> assign(:changeset, Bookmarks.change_bookmark(%Bookmark{}))
17
+
|> assign(:tag_input, "")
18
+
|> assign(:page_title, "Add Bookmark")}
19
+
end
20
+
21
+
@impl true
22
+
def handle_event("validate", %{"bookmark" => bookmark_params, "tag_input" => tag_input}, socket) do
23
+
changeset =
24
+
%Bookmark{}
25
+
|> Bookmarks.change_bookmark(bookmark_params)
26
+
|> Map.put(:action, :validate)
27
+
28
+
{:noreply,
29
+
socket
30
+
|> assign(:changeset, changeset)
31
+
|> assign(:tag_input, tag_input)}
32
+
end
33
+
34
+
@impl true
35
+
def handle_event("save", %{"bookmark" => bookmark_params, "tag_input" => tag_input}, socket) do
36
+
# Add the current user's ID to the bookmark params
37
+
bookmark_params = Map.put(bookmark_params, "user_id", socket.assigns.current_user.id)
38
+
39
+
# Parse tags from the tag input (comma-separated)
40
+
tags =
41
+
tag_input
42
+
|> String.split(",")
43
+
|> Enum.map(&String.trim/1)
44
+
|> Enum.reject(&(&1 == ""))
45
+
46
+
case Bookmarks.create_bookmark(bookmark_params, tags) do
47
+
{:ok, bookmark} ->
48
+
{:noreply,
49
+
socket
50
+
|> put_flash(:info, "Bookmark created successfully")
51
+
|> push_navigate(to: ~p"/bookmarks/#{bookmark}")}
52
+
53
+
{:error, %Ecto.Changeset{} = changeset} ->
54
+
{:noreply, assign(socket, changeset: changeset)}
55
+
end
56
+
end
57
+
58
+
defp get_or_create_demo_user do
59
+
case Accounts.get_user_by_username("demo_user") do
60
+
nil ->
61
+
{:ok, user} = Accounts.create_user(%{
62
+
username: "demo_user",
63
+
email: "demo@example.com",
64
+
display_name: "Demo User"
65
+
})
66
+
user
67
+
user ->
68
+
user
69
+
end
70
+
end
71
+
end
+64
lib/bookmarker_web/live/bookmark_live/new.html.heex
+64
lib/bookmarker_web/live/bookmark_live/new.html.heex
···
1
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+
<div class="mb-8">
3
+
<.link navigate={~p"/"} class="text-indigo-600 hover:text-indigo-900 flex items-center">
4
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
5
+
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
6
+
</svg>
7
+
Back to bookmarks
8
+
</.link>
9
+
</div>
10
+
11
+
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
12
+
<div class="px-4 py-5 sm:px-6">
13
+
<h1 class="text-2xl font-bold text-gray-900">Add Bookmark</h1>
14
+
</div>
15
+
<div class="border-t border-gray-200 px-4 py-5 sm:px-6">
16
+
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save" class="space-y-6">
17
+
<div>
18
+
<.input
19
+
field={f[:url]}
20
+
type="text"
21
+
label="URL"
22
+
placeholder="https://example.com"
23
+
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
24
+
/>
25
+
</div>
26
+
27
+
<div>
28
+
<.input
29
+
field={f[:title]}
30
+
type="text"
31
+
label="Title"
32
+
placeholder="Title of the bookmark"
33
+
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
34
+
/>
35
+
</div>
36
+
37
+
<div>
38
+
<.input
39
+
field={f[:description]}
40
+
type="textarea"
41
+
label="Description"
42
+
placeholder="Optional description"
43
+
rows={3}
44
+
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
45
+
/>
46
+
</div>
47
+
48
+
<div>
49
+
<label for="tag_input" class="block text-sm font-medium text-gray-700">Tags</label>
50
+
<div class="mt-1">
51
+
<input type="text" name="tag_input" id="tag_input" value={@tag_input} class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="Comma-separated tags (e.g. elixir, phoenix, programming)">
52
+
</div>
53
+
<p class="mt-1 text-sm text-gray-500">Separate tags with commas</p>
54
+
</div>
55
+
56
+
<div>
57
+
<.button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
58
+
Save Bookmark
59
+
</.button>
60
+
</div>
61
+
</.form>
62
+
</div>
63
+
</div>
64
+
</div>
+81
lib/bookmarker_web/live/bookmark_live/show.ex
+81
lib/bookmarker_web/live/bookmark_live/show.ex
···
1
+
defmodule BookmarkerWeb.BookmarkLive.Show do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Bookmarks
5
+
alias Bookmarker.Comments
6
+
alias Bookmarker.Accounts
7
+
8
+
@impl true
9
+
def mount(%{"id" => id}, _session, socket) do
10
+
if connected?(socket) do
11
+
Comments.subscribe_to_bookmark(id)
12
+
end
13
+
14
+
bookmark = Bookmarks.get_bookmark!(id)
15
+
16
+
# For demo purposes, let's get the first user or create one
17
+
user = get_or_create_demo_user()
18
+
19
+
{:ok,
20
+
socket
21
+
|> assign(:bookmark, bookmark)
22
+
|> assign(:current_user, user)
23
+
|> assign(:comment_changeset, Comments.change_comment(%Comments.Comment{}))
24
+
|> assign(:page_title, bookmark.title)}
25
+
end
26
+
27
+
@impl true
28
+
def handle_event("save_comment", %{"comment" => comment_params}, socket) do
29
+
comment_params = Map.merge(comment_params, %{
30
+
"user_id" => socket.assigns.current_user.id,
31
+
"bookmark_id" => socket.assigns.bookmark.id
32
+
})
33
+
34
+
case Comments.create_comment(comment_params) do
35
+
{:ok, _comment} ->
36
+
# The comment will be added via PubSub
37
+
{:noreply,
38
+
socket
39
+
|> put_flash(:info, "Comment added successfully")
40
+
|> assign(:comment_changeset, Comments.change_comment(%Comments.Comment{}))}
41
+
42
+
{:error, changeset} ->
43
+
{:noreply, assign(socket, :comment_changeset, changeset)}
44
+
end
45
+
end
46
+
47
+
@impl true
48
+
def handle_info({:comment_created, _comment}, socket) do
49
+
# Reload the bookmark to get the updated comments
50
+
bookmark = Bookmarks.get_bookmark!(socket.assigns.bookmark.id)
51
+
{:noreply, assign(socket, :bookmark, bookmark)}
52
+
end
53
+
54
+
@impl true
55
+
def handle_info({:comment_updated, _comment}, socket) do
56
+
# Reload the bookmark to get the updated comments
57
+
bookmark = Bookmarks.get_bookmark!(socket.assigns.bookmark.id)
58
+
{:noreply, assign(socket, :bookmark, bookmark)}
59
+
end
60
+
61
+
@impl true
62
+
def handle_info({:comment_deleted, _comment}, socket) do
63
+
# Reload the bookmark to get the updated comments
64
+
bookmark = Bookmarks.get_bookmark!(socket.assigns.bookmark.id)
65
+
{:noreply, assign(socket, :bookmark, bookmark)}
66
+
end
67
+
68
+
defp get_or_create_demo_user do
69
+
case Accounts.get_user_by_username("demo_user") do
70
+
nil ->
71
+
{:ok, user} = Accounts.create_user(%{
72
+
username: "demo_user",
73
+
email: "demo@example.com",
74
+
display_name: "Demo User"
75
+
})
76
+
user
77
+
user ->
78
+
user
79
+
end
80
+
end
81
+
end
+98
lib/bookmarker_web/live/bookmark_live/show.html.heex
+98
lib/bookmarker_web/live/bookmark_live/show.html.heex
···
1
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+
<div class="mb-8">
3
+
<.link navigate={~p"/"} class="text-indigo-600 hover:text-indigo-900 flex items-center">
4
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
5
+
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
6
+
</svg>
7
+
Back to bookmarks
8
+
</.link>
9
+
</div>
10
+
11
+
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
12
+
<div class="px-4 py-5 sm:px-6">
13
+
<h1 class="text-2xl font-bold text-gray-900"><%= @bookmark.title %></h1>
14
+
<div class="mt-2 flex items-center text-sm text-gray-500">
15
+
<.link navigate={~p"/users/#{@bookmark.user.username}"} class="hover:text-gray-700">
16
+
<%= @bookmark.user.display_name || @bookmark.user.username %>
17
+
</.link>
18
+
<span class="mx-2">•</span>
19
+
<span><%= Calendar.strftime(@bookmark.inserted_at, "%B %d, %Y at %I:%M %p") %></span>
20
+
</div>
21
+
</div>
22
+
<div class="border-t border-gray-200 px-4 py-5 sm:px-6">
23
+
<div class="mb-4">
24
+
<a href={@bookmark.url} target="_blank" class="text-indigo-600 hover:text-indigo-900 break-all">
25
+
<%= @bookmark.url %>
26
+
</a>
27
+
</div>
28
+
29
+
<%= if @bookmark.description && @bookmark.description != "" do %>
30
+
<div class="prose max-w-none mb-6">
31
+
<p><%= @bookmark.description %></p>
32
+
</div>
33
+
<% end %>
34
+
35
+
<div class="flex flex-wrap gap-2 mb-4">
36
+
<%= for tag <- @bookmark.tags do %>
37
+
<.link navigate={~p"/tags/#{tag.name}"} class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 hover:bg-indigo-200">
38
+
<%= tag.name %>
39
+
</.link>
40
+
<% end %>
41
+
</div>
42
+
</div>
43
+
</div>
44
+
45
+
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
46
+
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
47
+
<h2 class="text-lg font-medium text-gray-900">Comments</h2>
48
+
</div>
49
+
50
+
<div class="divide-y divide-gray-200">
51
+
<%= if Enum.empty?(@bookmark.comments) do %>
52
+
<div class="px-4 py-5 sm:px-6 text-gray-500">
53
+
No comments yet. Be the first to comment!
54
+
</div>
55
+
<% else %>
56
+
<%= for comment <- @bookmark.comments do %>
57
+
<div id={"comment-#{comment.id}"} class="px-4 py-5 sm:px-6">
58
+
<div class="flex items-start">
59
+
<div class="flex-1">
60
+
<div class="flex items-center mb-1">
61
+
<h3 class="text-sm font-medium text-gray-900">
62
+
<%= comment.user.display_name || comment.user.username %>
63
+
</h3>
64
+
<span class="ml-2 text-xs text-gray-500">
65
+
<%= Calendar.strftime(comment.inserted_at, "%B %d, %Y at %I:%M %p") %>
66
+
</span>
67
+
</div>
68
+
<div class="text-sm text-gray-700">
69
+
<%= comment.content %>
70
+
</div>
71
+
</div>
72
+
</div>
73
+
</div>
74
+
<% end %>
75
+
<% end %>
76
+
</div>
77
+
78
+
<div class="px-4 py-5 sm:px-6 border-t border-gray-200">
79
+
<h3 class="text-lg font-medium text-gray-900 mb-4">Add a comment</h3>
80
+
<.form :let={f} for={@comment_changeset} phx-submit="save_comment" class="space-y-4">
81
+
<div>
82
+
<.input
83
+
field={f[:content]}
84
+
type="textarea"
85
+
placeholder="Write your comment here..."
86
+
rows={3}
87
+
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
88
+
/>
89
+
</div>
90
+
<div>
91
+
<.button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
92
+
Post Comment
93
+
</.button>
94
+
</div>
95
+
</.form>
96
+
</div>
97
+
</div>
98
+
</div>
+166
lib/bookmarker_web/live/home_live.ex
+166
lib/bookmarker_web/live/home_live.ex
···
1
+
defmodule BookmarkerWeb.HomeLive do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Bookmarks
5
+
alias Bookmarker.Cache
6
+
alias Bookmarker.Accounts
7
+
alias Bookmarker.Tags
8
+
9
+
@impl true
10
+
def mount(_params, _session, socket) do
11
+
if connected?(socket) do
12
+
Bookmarks.subscribe()
13
+
end
14
+
15
+
# Get recent bookmarks from cache
16
+
bookmarks = Cache.get_recent_bookmarks(20)
17
+
18
+
# Get popular tags for the sidebar
19
+
tags_with_counts = Tags.list_tags_with_counts()
20
+
|> Enum.filter(fn %{count: count} -> count > 1 end)
21
+
|> Enum.sort_by(fn %{count: count} -> count end, :desc)
22
+
|> Enum.take(20) # Limit to top 20 tags
23
+
24
+
# For demo purposes, let's get the first user or create one
25
+
user = get_or_create_demo_user()
26
+
27
+
{:ok, assign(socket,
28
+
bookmarks: bookmarks,
29
+
filtered_bookmarks: bookmarks,
30
+
page_title: "Recent Bookmarks",
31
+
current_user: user,
32
+
tags_with_counts: tags_with_counts,
33
+
selected_tag: nil
34
+
)}
35
+
end
36
+
37
+
@impl true
38
+
def handle_params(_params, _url, socket) do
39
+
{:noreply, socket}
40
+
end
41
+
42
+
@impl true
43
+
def handle_event("filter_by_tag", %{"tag" => tag_name}, socket) do
44
+
filtered_bookmarks =
45
+
if tag_name == "" do
46
+
# Clear filter
47
+
socket.assigns.bookmarks
48
+
else
49
+
# Filter bookmarks by selected tag
50
+
Enum.filter(socket.assigns.bookmarks, fn bookmark ->
51
+
Enum.any?(bookmark.tags, fn tag -> tag.name == tag_name end)
52
+
end)
53
+
end
54
+
55
+
{:noreply, assign(socket,
56
+
filtered_bookmarks: filtered_bookmarks,
57
+
selected_tag: if(tag_name == "", do: nil, else: tag_name)
58
+
)}
59
+
end
60
+
61
+
@impl true
62
+
def handle_info({:bookmark_created, bookmark}, socket) do
63
+
# Add the new bookmark to the main list
64
+
updated_bookmarks =
65
+
[bookmark | socket.assigns.bookmarks]
66
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
67
+
|> Enum.take(20)
68
+
69
+
# Apply the update to filtered_bookmarks based on current filter
70
+
updated_filtered_bookmarks =
71
+
if socket.assigns.selected_tag do
72
+
# If there's a selected tag, check if the new bookmark has that tag
73
+
has_selected_tag =
74
+
Enum.any?(bookmark.tags, fn tag -> tag.name == socket.assigns.selected_tag end)
75
+
76
+
if has_selected_tag do
77
+
# Add the new bookmark to filtered list and sort
78
+
[bookmark | socket.assigns.filtered_bookmarks]
79
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
80
+
else
81
+
# Keep the filtered list as is
82
+
socket.assigns.filtered_bookmarks
83
+
end
84
+
else
85
+
# No tag filter, show all bookmarks
86
+
updated_bookmarks
87
+
end
88
+
89
+
{:noreply, assign(socket,
90
+
bookmarks: updated_bookmarks,
91
+
filtered_bookmarks: updated_filtered_bookmarks
92
+
)}
93
+
end
94
+
95
+
@impl true
96
+
def handle_info({:bookmark_updated, bookmark}, socket) do
97
+
# Update the bookmark in the main list
98
+
updated_bookmarks =
99
+
socket.assigns.bookmarks
100
+
|> Enum.map(fn b -> if b.id == bookmark.id, do: bookmark, else: b end)
101
+
102
+
# Apply the update to filtered_bookmarks based on current filter
103
+
updated_filtered_bookmarks =
104
+
if socket.assigns.selected_tag do
105
+
has_selected_tag =
106
+
Enum.any?(bookmark.tags, fn tag -> tag.name == socket.assigns.selected_tag end)
107
+
108
+
# Update or remove from filtered list based on tag presence
109
+
if has_selected_tag do
110
+
# Update bookmark in filtered list if it's already there
111
+
if Enum.any?(socket.assigns.filtered_bookmarks, fn b -> b.id == bookmark.id end) do
112
+
socket.assigns.filtered_bookmarks
113
+
|> Enum.map(fn b -> if b.id == bookmark.id, do: bookmark, else: b end)
114
+
else
115
+
# Add bookmark to filtered list if it wasn't there before but now has the tag
116
+
[bookmark | socket.assigns.filtered_bookmarks]
117
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
118
+
end
119
+
else
120
+
# Remove from filtered list if it no longer has the tag
121
+
socket.assigns.filtered_bookmarks
122
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
123
+
end
124
+
else
125
+
# No tag filter, show all bookmarks
126
+
updated_bookmarks
127
+
end
128
+
129
+
{:noreply, assign(socket,
130
+
bookmarks: updated_bookmarks,
131
+
filtered_bookmarks: updated_filtered_bookmarks
132
+
)}
133
+
end
134
+
135
+
@impl true
136
+
def handle_info({:bookmark_deleted, bookmark}, socket) do
137
+
# Remove the bookmark from the main list
138
+
updated_bookmarks =
139
+
socket.assigns.bookmarks
140
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
141
+
142
+
# Remove the bookmark from the filtered list if it's present
143
+
updated_filtered_bookmarks =
144
+
socket.assigns.filtered_bookmarks
145
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
146
+
147
+
{:noreply, assign(socket,
148
+
bookmarks: updated_bookmarks,
149
+
filtered_bookmarks: updated_filtered_bookmarks
150
+
)}
151
+
end
152
+
153
+
defp get_or_create_demo_user do
154
+
case Accounts.get_user_by_username("demo_user") do
155
+
nil ->
156
+
{:ok, user} = Accounts.create_user(%{
157
+
username: "demo_user",
158
+
email: "demo@example.com",
159
+
display_name: "Demo User"
160
+
})
161
+
user
162
+
user ->
163
+
user
164
+
end
165
+
end
166
+
end
+119
lib/bookmarker_web/live/home_live.html.heex
+119
lib/bookmarker_web/live/home_live.html.heex
···
1
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+
<div class="flex justify-between items-center mb-8">
3
+
<h1 class="text-3xl font-bold text-gray-900">
4
+
<%= if @selected_tag do %>
5
+
Bookmarks tagged with <span class="text-indigo-600">#<%= @selected_tag %></span>
6
+
<% else %>
7
+
Recent Bookmarks
8
+
<% end %>
9
+
</h1>
10
+
<div>
11
+
<.link navigate={~p"/bookmarks/new"} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
12
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
13
+
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
14
+
</svg>
15
+
Add Bookmark
16
+
</.link>
17
+
</div>
18
+
</div>
19
+
20
+
<div class="flex flex-col md:flex-row gap-8">
21
+
<!-- Tag Filter Sidebar -->
22
+
<div class="w-full md:w-64 flex-shrink-0">
23
+
<div class="bg-white shadow overflow-hidden sm:rounded-md p-4">
24
+
<h2 class="text-lg font-medium text-gray-900 mb-4">Filter by Tag</h2>
25
+
26
+
<div class="space-y-2">
27
+
<button phx-click="filter_by_tag" phx-value-tag=""
28
+
class={"flex items-center w-full px-3 py-2 text-left text-sm rounded-md transition-colors duration-150
29
+
#{if is_nil(@selected_tag), do: "bg-indigo-100 text-indigo-800 font-medium", else: "text-gray-700 hover:bg-gray-100"}"}>
30
+
<span class="flex-1">All Bookmarks</span>
31
+
<span class="text-xs text-gray-500"><%= length(@bookmarks) %></span>
32
+
</button>
33
+
34
+
<%= for %{tag: tag, count: count} <- @tags_with_counts do %>
35
+
<button phx-click="filter_by_tag" phx-value-tag={tag.name}
36
+
class={"flex items-center w-full px-3 py-2 text-left text-sm rounded-md transition-colors duration-150
37
+
#{if @selected_tag == tag.name, do: "bg-indigo-100 text-indigo-800 font-medium", else: "text-gray-700 hover:bg-gray-100"}"}>
38
+
<span class="flex-1">#<%= tag.name %></span>
39
+
<span class="text-xs text-gray-500"><%= count %></span>
40
+
</button>
41
+
<% end %>
42
+
</div>
43
+
44
+
<div class="mt-4 pt-4 border-t border-gray-200">
45
+
<.link navigate={~p"/tags"} class="text-sm text-indigo-600 hover:text-indigo-900">
46
+
View Tag Cloud →
47
+
</.link>
48
+
</div>
49
+
</div>
50
+
</div>
51
+
52
+
<!-- Bookmarks List -->
53
+
<div class="flex-1">
54
+
<div class="bg-white shadow overflow-hidden sm:rounded-md">
55
+
<ul role="list" class="divide-y divide-gray-200">
56
+
<%= if Enum.empty?(@filtered_bookmarks) do %>
57
+
<li class="px-6 py-4 text-center text-gray-500">
58
+
<%= if @selected_tag do %>
59
+
No bookmarks found with tag #<%= @selected_tag %>.
60
+
<% else %>
61
+
No bookmarks yet. Be the first to add one!
62
+
<% end %>
63
+
</li>
64
+
<% else %>
65
+
<%= for bookmark <- @filtered_bookmarks do %>
66
+
<li id={"bookmark-#{bookmark.id}"} class="px-6 py-4 animate-fade-in">
67
+
<div class="flex items-center justify-between">
68
+
<div class="flex-1 min-w-0">
69
+
<.link navigate={~p"/bookmarks/#{bookmark.id}"} class="text-lg font-medium text-indigo-600 hover:text-indigo-900 truncate">
70
+
<%= bookmark.title %>
71
+
</.link>
72
+
<p class="text-sm text-gray-500 truncate">
73
+
<a href={bookmark.url} target="_blank" class="hover:underline">
74
+
<%= bookmark.url %>
75
+
</a>
76
+
</p>
77
+
<div class="mt-2 flex flex-wrap gap-2">
78
+
<%= for tag <- bookmark.tags do %>
79
+
<button phx-click="filter_by_tag" phx-value-tag={tag.name}
80
+
class={"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
81
+
#{if @selected_tag == tag.name, do: "bg-indigo-200 text-indigo-800", else: "bg-indigo-100 text-indigo-800 hover:bg-indigo-200"}"}>
82
+
<%= tag.name %>
83
+
</button>
84
+
<% end %>
85
+
</div>
86
+
</div>
87
+
<div class="ml-4 flex-shrink-0 flex items-center">
88
+
<.link navigate={~p"/users/#{bookmark.user.username}"} class="text-sm text-gray-500 hover:text-gray-700">
89
+
<%= bookmark.user.display_name || bookmark.user.username %>
90
+
</.link>
91
+
<span class="mx-2 text-gray-300">•</span>
92
+
<span class="text-sm text-gray-500">
93
+
<%= Calendar.strftime(bookmark.inserted_at, "%b %d, %Y") %>
94
+
</span>
95
+
</div>
96
+
</div>
97
+
<%= if bookmark.description && bookmark.description != "" do %>
98
+
<p class="mt-2 text-sm text-gray-600">
99
+
<%= bookmark.description %>
100
+
</p>
101
+
<% end %>
102
+
</li>
103
+
<% end %>
104
+
<% end %>
105
+
</ul>
106
+
</div>
107
+
</div>
108
+
</div>
109
+
</div>
110
+
111
+
<style>
112
+
@keyframes fadeIn {
113
+
from { opacity: 0; transform: translateY(-10px); }
114
+
to { opacity: 1; transform: translateY(0); }
115
+
}
116
+
.animate-fade-in {
117
+
animation: fadeIn 0.5s ease-out;
118
+
}
119
+
</style>
+108
lib/bookmarker_web/live/tag_cloud_live.ex
+108
lib/bookmarker_web/live/tag_cloud_live.ex
···
1
+
defmodule BookmarkerWeb.TagCloudLive do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Tags
5
+
6
+
@impl true
7
+
def mount(_params, _session, socket) do
8
+
# Get all tags with counts
9
+
all_tags_with_counts = Tags.list_tags_with_counts()
10
+
11
+
# Filter to only include tags with more than one occurrence
12
+
tags_with_counts = all_tags_with_counts
13
+
|> Enum.filter(fn %{count: count} -> count > 0 end)
14
+
|> Enum.sort_by(fn %{count: count} -> count end, :desc)
15
+
16
+
# Calculate min and max counts for scaling
17
+
{min_count, max_count} =
18
+
case tags_with_counts do
19
+
[] -> {0, 0}
20
+
_ ->
21
+
counts = Enum.map(tags_with_counts, & &1.count)
22
+
{Enum.min(counts), Enum.max(counts)}
23
+
end
24
+
25
+
{:ok, assign(socket,
26
+
tags_with_counts: tags_with_counts,
27
+
min_count: min_count,
28
+
max_count: max_count,
29
+
page_title: "Tag Cloud",
30
+
total_tag_count: length(all_tags_with_counts),
31
+
filtered_tag_count: length(tags_with_counts)
32
+
)}
33
+
end
34
+
35
+
@impl true
36
+
def render(assigns) do
37
+
~H"""
38
+
<div class="max-w-4xl mx-auto px-4 py-8">
39
+
<h1 class="text-3xl font-bold mb-6 text-center">Tag Cloud</h1>
40
+
41
+
<div class="bg-white rounded-lg shadow p-6">
42
+
<%= if @tags_with_counts == [] do %>
43
+
<p class="text-gray-500 text-center py-8">
44
+
No tags found.
45
+
<%= if @total_tag_count > 0 do %>
46
+
There are <%= @total_tag_count %> tags with only one bookmark each.
47
+
<% end %>
48
+
</p>
49
+
<% else %>
50
+
<p class="text-sm text-gray-500 mb-4 text-center">
51
+
Showing <%= @filtered_tag_count %> tags that appear in multiple bookmarks
52
+
<%= if @total_tag_count > @filtered_tag_count do %>
53
+
(filtered out <%= @total_tag_count - @filtered_tag_count %> tags that only appear once)
54
+
<% end %>
55
+
</p>
56
+
<div class="flex flex-wrap justify-center gap-4 py-4">
57
+
<%= for %{tag: tag, count: count} <- @tags_with_counts do %>
58
+
<.link
59
+
navigate={~p"/tags/#{tag.name}"}
60
+
class="inline-block rounded px-3 py-1 transition-all duration-200"
61
+
style={"font-size: #{tag_size_from_count(count, @min_count, @max_count)}rem; #{tag_color_from_count(count, @min_count, @max_count)}"}
62
+
>
63
+
<%= tag.name %>
64
+
<span class="text-xs text-gray-500 align-top">(<%= count %>)</span>
65
+
</.link>
66
+
<% end %>
67
+
</div>
68
+
<% end %>
69
+
</div>
70
+
</div>
71
+
"""
72
+
end
73
+
74
+
# Calculate font size based on count (minimum 1rem, maximum 3rem)
75
+
defp tag_size_from_count(count, min_count, max_count) when min_count != max_count do
76
+
# Linear scaling
77
+
min_size = 1.0
78
+
max_size = 3.0
79
+
80
+
normalized = (count - min_count) / (max_count - min_count)
81
+
min_size + normalized * (max_size - min_size)
82
+
end
83
+
84
+
defp tag_size_from_count(_count, _min_count, _max_count), do: 1.5
85
+
86
+
# Calculate color gradient based on count
87
+
defp tag_color_from_count(count, min_count, max_count) when min_count != max_count do
88
+
# Calculate color gradient from blue (less frequent) to indigo (more frequent)
89
+
normalized = (count - min_count) / (max_count - min_count)
90
+
91
+
# Convert to HSL colors (indigo-100 to indigo-700)
92
+
hue = 238 # Indigo hue
93
+
saturation = 60 + (normalized * 30) # 60% to 90%
94
+
lightness = 90 - (normalized * 45) # 90% to 45%
95
+
96
+
"background-color: hsl(#{hue}, #{saturation}%, #{lightness}%); " <>
97
+
"color: #{if lightness < 65, do: "white", else: "rgb(67, 56, 202)"}; " <>
98
+
"border: 1px solid hsl(#{hue}, #{saturation}%, #{lightness - 10}%); " <>
99
+
"box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); " <>
100
+
"transform: scale(1); " <>
101
+
"hover:transform: scale(1.05); " <>
102
+
"hover:box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);"
103
+
end
104
+
105
+
defp tag_color_from_count(_count, _min_count, _max_count) do
106
+
"background-color: rgb(224, 231, 255); color: rgb(67, 56, 202); border: 1px solid rgb(199, 210, 254);"
107
+
end
108
+
end
+89
lib/bookmarker_web/live/tag_live/show.ex
+89
lib/bookmarker_web/live/tag_live/show.ex
···
1
+
defmodule BookmarkerWeb.TagLive.Show do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Bookmarks
5
+
alias Bookmarker.Cache
6
+
alias Bookmarker.Accounts
7
+
8
+
@impl true
9
+
def mount(%{"name" => tag_name}, _session, socket) do
10
+
if connected?(socket) do
11
+
Bookmarks.subscribe_to_tag(tag_name)
12
+
end
13
+
14
+
# Get bookmarks for this tag from cache
15
+
bookmarks = Cache.get_bookmarks_by_tag(tag_name)
16
+
17
+
# For demo purposes, let's get the first user or create one
18
+
user = get_or_create_demo_user()
19
+
20
+
{:ok,
21
+
socket
22
+
|> assign(:tag_name, tag_name)
23
+
|> assign(:bookmarks, bookmarks)
24
+
|> assign(:current_user, user)
25
+
|> assign(:page_title, "Bookmarks tagged with #{tag_name}")}
26
+
end
27
+
28
+
@impl true
29
+
def handle_info({:bookmark_created, bookmark}, socket) do
30
+
# Check if the bookmark has the current tag
31
+
if has_tag?(bookmark, socket.assigns.tag_name) do
32
+
updated_bookmarks = [bookmark | socket.assigns.bookmarks]
33
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
34
+
35
+
{:noreply, assign(socket, :bookmarks, updated_bookmarks)}
36
+
else
37
+
{:noreply, socket}
38
+
end
39
+
end
40
+
41
+
@impl true
42
+
def handle_info({:bookmark_updated, bookmark}, socket) do
43
+
# Check if the bookmark has the current tag
44
+
if has_tag?(bookmark, socket.assigns.tag_name) do
45
+
# Update the bookmark in the list
46
+
updated_bookmarks =
47
+
socket.assigns.bookmarks
48
+
|> Enum.map(fn b -> if b.id == bookmark.id, do: bookmark, else: b end)
49
+
|> Enum.sort_by(& &1.inserted_at, {:desc, NaiveDateTime})
50
+
51
+
{:noreply, assign(socket, :bookmarks, updated_bookmarks)}
52
+
else
53
+
# The bookmark no longer has this tag, remove it from the list
54
+
updated_bookmarks =
55
+
socket.assigns.bookmarks
56
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
57
+
58
+
{:noreply, assign(socket, :bookmarks, updated_bookmarks)}
59
+
end
60
+
end
61
+
62
+
@impl true
63
+
def handle_info({:bookmark_deleted, bookmark}, socket) do
64
+
# Remove the bookmark from the list
65
+
updated_bookmarks =
66
+
socket.assigns.bookmarks
67
+
|> Enum.reject(fn b -> b.id == bookmark.id end)
68
+
69
+
{:noreply, assign(socket, :bookmarks, updated_bookmarks)}
70
+
end
71
+
72
+
defp has_tag?(bookmark, tag_name) do
73
+
Enum.any?(bookmark.tags, fn tag -> tag.name == tag_name end)
74
+
end
75
+
76
+
defp get_or_create_demo_user do
77
+
case Accounts.get_user_by_username("demo_user") do
78
+
nil ->
79
+
{:ok, user} = Accounts.create_user(%{
80
+
username: "demo_user",
81
+
email: "demo@example.com",
82
+
display_name: "Demo User"
83
+
})
84
+
user
85
+
user ->
86
+
user
87
+
end
88
+
end
89
+
end
+84
lib/bookmarker_web/live/tag_live/show.html.heex
+84
lib/bookmarker_web/live/tag_live/show.html.heex
···
1
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+
<div class="mb-8">
3
+
<.link navigate={~p"/"} class="text-indigo-600 hover:text-indigo-900 flex items-center">
4
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
5
+
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
6
+
</svg>
7
+
Back to bookmarks
8
+
</.link>
9
+
</div>
10
+
11
+
<div class="flex justify-between items-center mb-8">
12
+
<h1 class="text-3xl font-bold text-gray-900">
13
+
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800">
14
+
<%= @tag_name %>
15
+
</span>
16
+
</h1>
17
+
<div>
18
+
<.link navigate={~p"/bookmarks/new"} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
19
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
20
+
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
21
+
</svg>
22
+
Add Bookmark
23
+
</.link>
24
+
</div>
25
+
</div>
26
+
27
+
<div class="bg-white shadow overflow-hidden sm:rounded-md">
28
+
<ul role="list" class="divide-y divide-gray-200">
29
+
<%= if Enum.empty?(@bookmarks) do %>
30
+
<li class="px-6 py-4 text-center text-gray-500">
31
+
No bookmarks with this tag yet. Be the first to add one!
32
+
</li>
33
+
<% else %>
34
+
<%= for bookmark <- @bookmarks do %>
35
+
<li id={"bookmark-#{bookmark.id}"} class="px-6 py-4 animate-fade-in">
36
+
<div class="flex items-center justify-between">
37
+
<div class="flex-1 min-w-0">
38
+
<.link navigate={~p"/bookmarks/#{bookmark.id}"} class="text-lg font-medium text-indigo-600 hover:text-indigo-900 truncate">
39
+
<%= bookmark.title %>
40
+
</.link>
41
+
<p class="text-sm text-gray-500 truncate">
42
+
<a href={bookmark.url} target="_blank" class="hover:underline">
43
+
<%= bookmark.url %>
44
+
</a>
45
+
</p>
46
+
<div class="mt-2 flex flex-wrap gap-2">
47
+
<%= for tag <- bookmark.tags do %>
48
+
<.link navigate={~p"/tags/#{tag.name}"} class={"inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium #{if tag.name == @tag_name, do: "bg-indigo-500 text-white", else: "bg-indigo-100 text-indigo-800 hover:bg-indigo-200"}"}>
49
+
<%= tag.name %>
50
+
</.link>
51
+
<% end %>
52
+
</div>
53
+
</div>
54
+
<div class="ml-4 flex-shrink-0 flex items-center">
55
+
<.link navigate={~p"/users/#{bookmark.user.username}"} class="text-sm text-gray-500 hover:text-gray-700">
56
+
<%= bookmark.user.display_name || bookmark.user.username %>
57
+
</.link>
58
+
<span class="mx-2 text-gray-300">•</span>
59
+
<span class="text-sm text-gray-500">
60
+
<%= Calendar.strftime(bookmark.inserted_at, "%b %d, %Y") %>
61
+
</span>
62
+
</div>
63
+
</div>
64
+
<%= if bookmark.description && bookmark.description != "" do %>
65
+
<p class="mt-2 text-sm text-gray-600">
66
+
<%= bookmark.description %>
67
+
</p>
68
+
<% end %>
69
+
</li>
70
+
<% end %>
71
+
<% end %>
72
+
</ul>
73
+
</div>
74
+
</div>
75
+
76
+
<style>
77
+
@keyframes fadeIn {
78
+
from { opacity: 0; transform: translateY(-10px); }
79
+
to { opacity: 1; transform: translateY(0); }
80
+
}
81
+
.animate-fade-in {
82
+
animation: fadeIn 0.5s ease-out;
83
+
}
84
+
</style>
+34
lib/bookmarker_web/live/user_live/show.ex
+34
lib/bookmarker_web/live/user_live/show.ex
···
1
+
defmodule BookmarkerWeb.UserLive.Show do
2
+
use BookmarkerWeb, :live_view
3
+
4
+
alias Bookmarker.Accounts
5
+
alias Bookmarker.Bookmarks
6
+
7
+
@impl true
8
+
def mount(%{"username" => username}, _session, socket) do
9
+
user = Accounts.get_user_by_username(username)
10
+
11
+
if user do
12
+
bookmarks = Bookmarks.list_user_bookmarks(user.id)
13
+
14
+
# Get all unique tags used by this user
15
+
user_tags =
16
+
bookmarks
17
+
|> Enum.flat_map(& &1.tags)
18
+
|> Enum.uniq_by(& &1.id)
19
+
|> Enum.sort_by(& &1.name)
20
+
21
+
{:ok,
22
+
socket
23
+
|> assign(:user, user)
24
+
|> assign(:bookmarks, bookmarks)
25
+
|> assign(:user_tags, user_tags)
26
+
|> assign(:page_title, "#{user.display_name || user.username}'s Bookmarks")}
27
+
else
28
+
{:ok,
29
+
socket
30
+
|> put_flash(:error, "User not found")
31
+
|> push_navigate(to: ~p"/")}
32
+
end
33
+
end
34
+
end
+96
lib/bookmarker_web/live/user_live/show.html.heex
+96
lib/bookmarker_web/live/user_live/show.html.heex
···
1
+
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
2
+
<div class="mb-8">
3
+
<.link navigate={~p"/"} class="text-indigo-600 hover:text-indigo-900 flex items-center">
4
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" viewBox="0 0 20 20" fill="currentColor">
5
+
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
6
+
</svg>
7
+
Back to bookmarks
8
+
</.link>
9
+
</div>
10
+
11
+
<div class="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
12
+
<div class="px-4 py-5 sm:px-6">
13
+
<h1 class="text-2xl font-bold text-gray-900">
14
+
<%= @user.display_name || @user.username %>
15
+
</h1>
16
+
<p class="mt-1 text-sm text-gray-500">
17
+
@<%= @user.username %>
18
+
</p>
19
+
</div>
20
+
<div class="border-t border-gray-200 px-4 py-5 sm:px-6">
21
+
<h2 class="text-lg font-medium text-gray-900 mb-4">Tags</h2>
22
+
<div class="flex flex-wrap gap-2">
23
+
<%= if Enum.empty?(@user_tags) do %>
24
+
<p class="text-gray-500">No tags yet</p>
25
+
<% else %>
26
+
<%= for tag <- @user_tags do %>
27
+
<.link navigate={~p"/tags/#{tag.name}"} class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 hover:bg-indigo-200">
28
+
<%= tag.name %>
29
+
<span class="ml-1 text-indigo-600">
30
+
<%= Enum.count(Enum.filter(@bookmarks, fn b -> Enum.any?(b.tags, fn t -> t.id == tag.id end) end)) %>
31
+
</span>
32
+
</.link>
33
+
<% end %>
34
+
<% end %>
35
+
</div>
36
+
</div>
37
+
</div>
38
+
39
+
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
40
+
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
41
+
<div class="flex justify-between items-center">
42
+
<h2 class="text-lg font-medium text-gray-900">Bookmarks</h2>
43
+
<div>
44
+
<.link navigate={~p"/bookmarks/new"} class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
45
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
46
+
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
47
+
</svg>
48
+
Add Bookmark
49
+
</.link>
50
+
</div>
51
+
</div>
52
+
</div>
53
+
54
+
<ul role="list" class="divide-y divide-gray-200">
55
+
<%= if Enum.empty?(@bookmarks) do %>
56
+
<li class="px-6 py-4 text-center text-gray-500">
57
+
No bookmarks yet
58
+
</li>
59
+
<% else %>
60
+
<%= for bookmark <- @bookmarks do %>
61
+
<li id={"bookmark-#{bookmark.id}"} class="px-6 py-4">
62
+
<div class="flex items-center justify-between">
63
+
<div class="flex-1 min-w-0">
64
+
<.link navigate={~p"/bookmarks/#{bookmark.id}"} class="text-lg font-medium text-indigo-600 hover:text-indigo-900 truncate">
65
+
<%= bookmark.title %>
66
+
</.link>
67
+
<p class="text-sm text-gray-500 truncate">
68
+
<a href={bookmark.url} target="_blank" class="hover:underline">
69
+
<%= bookmark.url %>
70
+
</a>
71
+
</p>
72
+
<div class="mt-2 flex flex-wrap gap-2">
73
+
<%= for tag <- bookmark.tags do %>
74
+
<.link navigate={~p"/tags/#{tag.name}"} class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 hover:bg-indigo-200">
75
+
<%= tag.name %>
76
+
</.link>
77
+
<% end %>
78
+
</div>
79
+
</div>
80
+
<div class="ml-4 flex-shrink-0">
81
+
<span class="text-sm text-gray-500">
82
+
<%= Calendar.strftime(bookmark.inserted_at, "%b %d, %Y") %>
83
+
</span>
84
+
</div>
85
+
</div>
86
+
<%= if bookmark.description && bookmark.description != "" do %>
87
+
<p class="mt-2 text-sm text-gray-600">
88
+
<%= bookmark.description %>
89
+
</p>
90
+
<% end %>
91
+
</li>
92
+
<% end %>
93
+
<% end %>
94
+
</ul>
95
+
</div>
96
+
</div>
+6
-1
lib/bookmarker_web/router.ex
+6
-1
lib/bookmarker_web/router.ex
···
17
17
scope "/", BookmarkerWeb do
18
18
pipe_through :browser
19
19
20
-
get "/", PageController, :home
20
+
live "/", HomeLive
21
+
live "/bookmarks/new", BookmarkLive.New
22
+
live "/bookmarks/:id", BookmarkLive.Show
23
+
live "/tags", TagCloudLive
24
+
live "/tags/:name", TagLive.Show
25
+
live "/users/:username", UserLive.Show
21
26
end
22
27
23
28
# Other scopes may use custom stacks.
+3
-2
mix.exs
+3
-2
mix.exs
···
1
1
defmodule Bookmarker.MixProject do
2
2
use Mix.Project
3
-
4
3
def project do
5
4
[
6
5
app: :bookmarker,
···
57
56
{:gettext, "~> 0.26"},
58
57
{:jason, "~> 1.2"},
59
58
{:dns_cluster, "~> 0.1.1"},
60
-
{:bandit, "~> 1.5"}
59
+
{:bandit, "~> 1.5"},
60
+
{:req, "~> 0.4"},
61
+
{:websockex, "~> 0.4"}
61
62
]
62
63
end
63
64
+2
mix.lock
+2
mix.lock
···
30
30
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
31
31
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
32
32
"postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"},
33
+
"req": {:hex, :req, "0.5.8", "50d8d65279d6e343a5e46980ac2a70e97136182950833a1968b371e753f6a662", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "d7fc5898a566477e174f26887821a3c5082b243885520ee4b45555f5d53f40ef"},
33
34
"swoosh": {:hex, :swoosh, "1.18.2", "41279e8449b65d14b571b66afe9ab352c3b0179291af8e5f4ad9207f489ad11a", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "032fcb2179f6d4e3b90030514ddc8d3946d8b046be939d121db480ca78adbc38"},
34
35
"tailwind": {:hex, :tailwind, "0.3.1", "a89d2835c580748c7a975ad7dd3f2ea5e63216dc16d44f9df492fbd12c094bed", [:mix], [], "hexpm", "98a45febdf4a87bc26682e1171acdedd6317d0919953c353fcd1b4f9f4b676a2"},
35
36
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
···
38
39
"thousand_island": {:hex, :thousand_island, "1.3.11", "b68f3e91f74d564ae20b70d981bbf7097dde084343c14ae8a33e5b5fbb3d6f37", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "555c18c62027f45d9c80df389c3d01d86ba11014652c00be26e33b1b64e98d29"},
39
40
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
40
41
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
42
+
"websockex": {:hex, :websockex, "0.4.3", "92b7905769c79c6480c02daacaca2ddd49de936d912976a4d3c923723b647bf0", [:mix], [], "hexpm", "95f2e7072b85a3a4cc385602d42115b73ce0b74a9121d0d6dbbf557645ac53e4"},
41
43
}
+16
priv/repo/migrations/20250309190526_create_users.exs
+16
priv/repo/migrations/20250309190526_create_users.exs
···
1
+
defmodule Bookmarker.Repo.Migrations.CreateUsers do
2
+
use Ecto.Migration
3
+
4
+
def change do
5
+
create table(:users) do
6
+
add :username, :string, null: false
7
+
add :email, :string, null: false
8
+
add :display_name, :string
9
+
10
+
timestamps()
11
+
end
12
+
13
+
create unique_index(:users, [:username])
14
+
create unique_index(:users, [:email])
15
+
end
16
+
end
+17
priv/repo/migrations/20250309190622_create_bookmarks.exs
+17
priv/repo/migrations/20250309190622_create_bookmarks.exs
···
1
+
defmodule Bookmarker.Repo.Migrations.CreateBookmarks do
2
+
use Ecto.Migration
3
+
4
+
def change do
5
+
create table(:bookmarks) do
6
+
add :url, :string, null: false
7
+
add :title, :string, null: false
8
+
add :description, :text
9
+
add :user_id, references(:users, on_delete: :delete_all), null: false
10
+
11
+
timestamps()
12
+
end
13
+
14
+
create index(:bookmarks, [:user_id])
15
+
create index(:bookmarks, [:inserted_at])
16
+
end
17
+
end
+17
priv/repo/migrations/20250309190636_create_comments.exs
+17
priv/repo/migrations/20250309190636_create_comments.exs
···
1
+
defmodule Bookmarker.Repo.Migrations.CreateComments do
2
+
use Ecto.Migration
3
+
4
+
def change do
5
+
create table(:comments) do
6
+
add :content, :text, null: false
7
+
add :user_id, references(:users, on_delete: :delete_all), null: false
8
+
add :bookmark_id, references(:bookmarks, on_delete: :delete_all), null: false
9
+
10
+
timestamps()
11
+
end
12
+
13
+
create index(:comments, [:user_id])
14
+
create index(:comments, [:bookmark_id])
15
+
create index(:comments, [:inserted_at])
16
+
end
17
+
end