this repo has no description

revision 1, 30 minutes, dropping in the bluesky_hose.ex module and letting claude 3.7 sonnet thinking do the rest

+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
.iex.exs
··· 1 + Logger.configure(level: :error) 2 +
+2 -2
config/dev.exs
··· 2 2 3 3 # Configure your database 4 4 config :bookmarker, Bookmarker.Repo, 5 - username: "postgres", 6 - password: "postgres", 5 + username: "robertgrayson", 6 + password: "", 7 7 hostname: "localhost", 8 8 database: "bookmarker_dev", 9 9 stacktrace: true,
lib/.bluesky_hose.ex.swp

This is a binary file and will not be displayed.

+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
+181
lib/bookmarker/tags.ex
··· 1 + defmodule Bookmarker.Tags do 2 + @moduledoc """ 3 + The Tags context. 4 + """ 5 + 6 + import Ecto.Query, warn: false 7 + alias Bookmarker.Repo 8 + 9 + alias Bookmarker.Tags.Tag 10 + 11 + @doc """ 12 + Returns the list of tags. 13 + 14 + ## Examples 15 + 16 + iex> list_tags() 17 + [%Tag{}, ...] 18 + 19 + """ 20 + def list_tags do 21 + Repo.all(Tag) 22 + end 23 + 24 + @doc """ 25 + Gets a single tag. 26 + 27 + Raises `Ecto.NoResultsError` if the Tag does not exist. 28 + 29 + ## Examples 30 + 31 + iex> get_tag!(123) 32 + %Tag{} 33 + 34 + iex> get_tag!(456) 35 + ** (Ecto.NoResultsError) 36 + 37 + """ 38 + def get_tag!(id), do: Repo.get!(Tag, id) 39 + 40 + @doc """ 41 + Gets a single tag by name. 42 + 43 + Returns nil if the Tag does not exist. 44 + 45 + ## Examples 46 + 47 + iex> get_tag_by_name("elixir") 48 + %Tag{} 49 + 50 + iex> get_tag_by_name("nonexistent") 51 + nil 52 + 53 + """ 54 + def get_tag_by_name(name) when is_binary(name) do 55 + Repo.get_by(Tag, name: String.downcase(name)) 56 + end 57 + 58 + @doc """ 59 + Creates a tag if it doesn't exist. 60 + 61 + ## Examples 62 + 63 + iex> create_or_get_tag("elixir") 64 + {:ok, %Tag{}} 65 + 66 + """ 67 + def create_or_get_tag(name) when is_binary(name) do 68 + name = String.downcase(name) 69 + case get_tag_by_name(name) do 70 + nil -> 71 + create_tag(%{name: name}) 72 + tag -> 73 + {:ok, tag} 74 + end 75 + end 76 + 77 + @doc """ 78 + Creates a tag. 79 + 80 + ## Examples 81 + 82 + iex> create_tag(%{field: value}) 83 + {:ok, %Tag{}} 84 + 85 + iex> create_tag(%{field: bad_value}) 86 + {:error, %Ecto.Changeset{}} 87 + 88 + """ 89 + def create_tag(attrs \\ %{}) do 90 + %Tag{} 91 + |> Tag.changeset(attrs) 92 + |> Repo.insert() 93 + end 94 + 95 + @doc """ 96 + Updates a tag. 97 + 98 + ## Examples 99 + 100 + iex> update_tag(tag, %{field: new_value}) 101 + {:ok, %Tag{}} 102 + 103 + iex> update_tag(tag, %{field: bad_value}) 104 + {:error, %Ecto.Changeset{}} 105 + 106 + """ 107 + def update_tag(%Tag{} = tag, attrs) do 108 + tag 109 + |> Tag.changeset(attrs) 110 + |> Repo.update() 111 + end 112 + 113 + @doc """ 114 + Deletes a tag. 115 + 116 + ## Examples 117 + 118 + iex> delete_tag(tag) 119 + {:ok, %Tag{}} 120 + 121 + iex> delete_tag(tag) 122 + {:error, %Ecto.Changeset{}} 123 + 124 + """ 125 + def delete_tag(%Tag{} = tag) do 126 + Repo.delete(tag) 127 + end 128 + 129 + @doc """ 130 + Returns an `%Ecto.Changeset{}` for tracking tag changes. 131 + 132 + ## Examples 133 + 134 + iex> change_tag(tag) 135 + %Ecto.Changeset{data: %Tag{}} 136 + 137 + """ 138 + def change_tag(%Tag{} = tag, attrs \\ %{}) do 139 + Tag.changeset(tag, attrs) 140 + end 141 + 142 + @doc """ 143 + Creates tags from a list of names and returns them. 144 + 145 + ## Examples 146 + 147 + iex> get_or_create_tags(["elixir", "phoenix"]) 148 + [%Tag{}, %Tag{}] 149 + 150 + """ 151 + def get_or_create_tags(tag_names) when is_list(tag_names) do 152 + tag_names 153 + |> Enum.map(&String.trim/1) 154 + |> Enum.reject(&(&1 == "")) 155 + |> Enum.map(fn name -> 156 + {:ok, tag} = create_or_get_tag(name) 157 + tag 158 + end) 159 + end 160 + 161 + @doc """ 162 + Returns the list of tags with bookmark counts. 163 + The result includes the tag and a count of how many bookmarks are associated with it. 164 + 165 + ## Examples 166 + 167 + iex> list_tags_with_counts() 168 + [%{tag: %Tag{}, count: 5}, %{tag: %Tag{}, count: 2}, ...] 169 + 170 + """ 171 + def list_tags_with_counts do 172 + query = from t in Tag, 173 + left_join: bt in "bookmark_tags", 174 + on: t.id == bt.tag_id, 175 + group_by: t.id, 176 + select: {t, count(bt.bookmark_id)} 177 + 178 + Repo.all(query) 179 + |> Enum.map(fn {tag, count} -> %{tag: tag, count: count} end) 180 + end 181 + end
+23
lib/bookmarker/tags/tag.ex
··· 1 + defmodule Bookmarker.Tags.Tag do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + 5 + schema "tags" do 6 + field :name, :string 7 + 8 + many_to_many :bookmarks, Bookmarker.Bookmarks.Bookmark, 9 + join_through: Bookmarker.Bookmarks.BookmarkTag 10 + 11 + timestamps() 12 + end 13 + 14 + @doc false 15 + def changeset(tag, attrs) do 16 + tag 17 + |> cast(attrs, [:name]) 18 + |> validate_required([:name]) 19 + |> validate_length(:name, min: 1, max: 50) 20 + |> unique_constraint(:name) 21 + |> update_change(:name, &String.downcase/1) 22 + end 23 + end
+2
lib/bookmarker_web.ex
··· 89 89 import Phoenix.HTML 90 90 # Core UI components 91 91 import BookmarkerWeb.CoreComponents 92 + # Form helpers 93 + import Phoenix.HTML.Form 92 94 93 95 # Shortcut for generating JS commands 94 96 alias Phoenix.LiveView.JS
+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
··· 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
··· 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
··· 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">&rarr;</span> 22 + Add Bookmark <span aria-hidden="true">&rarr;</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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
+13
priv/repo/migrations/20250309190627_create_tags.exs
··· 1 + defmodule Bookmarker.Repo.Migrations.CreateTags do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:tags) do 6 + add :name, :string, null: false 7 + 8 + timestamps() 9 + end 10 + 11 + create unique_index(:tags, [:name]) 12 + end 13 + end
+16
priv/repo/migrations/20250309190632_create_bookmark_tags.exs
··· 1 + defmodule Bookmarker.Repo.Migrations.CreateBookmarkTags do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create table(:bookmark_tags, primary_key: false) do 6 + add :bookmark_id, references(:bookmarks, on_delete: :delete_all), null: false 7 + add :tag_id, references(:tags, on_delete: :delete_all), null: false 8 + 9 + timestamps() 10 + end 11 + 12 + create index(:bookmark_tags, [:bookmark_id]) 13 + create index(:bookmark_tags, [:tag_id]) 14 + create unique_index(:bookmark_tags, [:bookmark_id, :tag_id]) 15 + end 16 + end
+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