Skip to content

del.icio.us but make it firehose the bookmarks and build it all in liveview

Notifications You must be signed in to change notification settings

notactuallytreyanastasio/nasty

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Building a little bookmarking thing in Elixir/Phoenix/LiveView (1.0)

Initial Thoughts

At first, I built this on a Saturday. I started the project at around 1pm and made the last major commits by about midnight.

I was able to move this fast because I used an LLM to assist me.

To reflect on the other topic I want to write about, using LLMs to build software today, and learning the tools, I decided to prove I knew how to build this by taking the finished product and breaking it down into bite size pieces that can become a tutorial that builds up the premises step by step.

So, from there, here we go

Goals

This guide explores building a feature-rich bookmarking application that showcases many of the powerful capabilities of Elixir, Phoenix, and LiveView. We'll create a system that publishes real-time events for bookmark creation and discussions, leveraging process-based messaging between domain models and event-driven architecture. The application will demonstrate how to build reactive web UIs, implement efficient caching layers, and handle complex pub/sub patterns through websockets.

I really wanted to build something that could be a talking point that shows off all my favorite touchpoints of modern Elixir/Phoenix development.

This guide doesn't even get into the great new stuff with the type system, but it goes a long way towards showing how all the pieces fit together.

Along the way, we'll explore how the BEAM VM enables us to create robust, concurrent systems. We'll build a traffic simulator to test our application at scale, implement channel-based event publishing, and see how Phoenix and LiveView make traditionally complex features surprisingly straightforward to implement. The end result will be a fully functional bookmarking system that handles real-time updates, filtered feeds, and cached responses - all with clean, maintainable code.

This is meant to be an approachable guide for developers looking to understand how these pieces fit together in practice. We'll start from a fresh Phoenix application and incrementally add features, explaining the concepts and patterns as we go. The focus is on showing how Elixir and Phoenix can elegantly handle complex requirements while keeping the codebase simple and understandable.

We will go so far as to provide a commit for each major step of the way along the project so even if you are a little unclear, you can follow along

Project Overview

Let's break down how we'll implement the features mentioned above. While this guide provides a high-level overview, the accompanying repo serves as a reference implementation that may differ slightly in details.

Key Features:

  • Real-time bookmark and chat streams
  • Tag-based feed filtering
  • Live firehose view of all bookmarks
  • Randomized bookmark discovery page

Implementation Path:

  • User authentication (simplified for this guide)
  • Core bookmark and tag models
  • In-memory caching layer
  • PubSub event system
  • Traffic simulation for testing
  • LiveView UI components
  • PostgreSQL persistence
  • Event broadcasting system
    • Global firehose events
    • Tag-filtered events

While this guide focuses on building a bookmarking system, the patterns and approaches demonstrated here can be applied to many other real-time, event-driven applications. The concepts of caching, pub/sub messaging, and LiveView UIs are foundational for modern Phoenix applications.

Getting Started

We want to start off with a basic new Phoenix LiveView project. This can be accomplished with mix phx.new nasty --live to ensure we have all the nice toys. Note: Modules here dont list a filepath but assume you will name them matching their path-ish. You can save files wherever, but make sure to keep the module names matching here if you are newer to Elixir. The path doesn't matter so long as its compiled in most cases, just the module. We're going to start by building up a relational schema from the ground up. We ultimately will be serving it mostly via a cache...but for now this works well. This can be done with a pretty simple setup.

Users have bookmarks. Bookmarks have a title, description, url, public/not public bool, tags, and a user. Tags are a many to many relationship on tags lumping them into categories.

Every Step in this has a commit we can checkout of the sibling repo, they will be highlighted in each section and you can clone that repo and check out each commit if you are following along and can't get something to compile

We can map these out pretty quickly.

A Basic Data Model and Layer

For the unfamiliar, in Elixir, and generally in Phoenix, we use a library called Ecto to interact with our database. It may not feel quite like an ORM when you see it in use. Because, well, its not. Its a system to follow the repository pattern in order to allow us to interact with our database in a way that is both safe and easy to understand.

So, to start off, we will define a Schema for each of the database tables we want.

In this case, that will be creating bookmarks, tags, and bookmark_tags as a join table.

The schema macro defines the general shape, and changeset is a function that allows us to define the shape of the data we want to insert into the database.

Our migrations are generated with mix ect.gen.migration your_migration_name and you can put the contents we share below right in there.

defmodule Nasty.Bookmarks.Bookmark do
  use Ecto.Schema
  import Ecto.Changeset

  schema "bookmarks" do
    field :title, :string
    field :description, :string
    field :url, :string
    field :public, :boolean, default: true

    belongs_to :user, Nasty.Accounts.User
    many_to_many :tags, Nasty.Bookmarks.Tag, join_through: Nasty.Bookmarks.BookmarkTag

    timestamps()
  end

  @doc false
  def changeset(bookmark, attrs) do
    bookmark
    |> cast(attrs, [:title, :description, :url, :public, :user_id])
    |> validate_required([:title, :url, :user_id])
    |> validate_url(:url)
    |> assoc_constraint(:user)
  end

  def new_changeset do
    %__MODULE__{
      public: true,
      tags: []
    }
    |> changeset(%{})
  end

  defp validate_url(changeset, field) do
    validate_change(changeset, field, fn _, url ->
      case URI.parse(url) do
        %URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
          []

        _ ->
          [{field, "must be a valid URL"}]
      end
    end)
  end
end

Simple enough. This is just represents everything in standard ecto and leverages some URI parsing to check URLs. Nex we represent a bookmark tag.

defmodule Nasty.Bookmarks.BookmarkTag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "bookmark_tags" do
    belongs_to :bookmark, Nasty.Bookmarks.Bookmark
    belongs_to :tag, Nasty.Bookmarks.Tag

    timestamps()
  end

  @doc false
  def changeset(bookmark_tag, attrs) do
    bookmark_tag
    |> cast(attrs, [:bookmark_id, :tag_id])
    |> validate_required([:bookmark_id, :tag_id])
    |> unique_constraint([:bookmark_id, :tag_id])
    |> assoc_constraint(:bookmark)
    |> assoc_constraint(:tag)
  end
end

Same story.

And finally the tag.

defmodule Nasty.Bookmarks.Tag do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tags" do
    field :name, :string

    many_to_many :bookmarks, Nasty.Bookmarks.Bookmark, join_through: Nasty.Bookmarks.BookmarkTag

    timestamps()
  end

  @doc false
  def changeset(tag, attrs) do
    tag
    |> cast(attrs, [:name])
    |> validate_required([:name])
    |> unique_constraint(:name)
    |> update_change(:name, &String.downcase/1)
  end
end

Now we can add some migrations to create all of this.

defmodule Nasty.Repo.Migrations.CreateBookmarks do
  use Ecto.Migration

  def change do
    create table(:bookmarks) do
      add :title, :string, null: false
      add :description, :text
      add :url, :string, null: false
      add :public, :boolean, default: true, null: false
      add :user_id, references(:users, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:bookmarks, [:user_id])
    create index(:bookmarks, [:public])
  end
end

And tags.

defmodule Nasty.Repo.Migrations.CreateTags do
  use Ecto.Migration

  def change do
    create table(:tags) do
      add :name, :string, null: false

      timestamps()
    end

    create unique_index(:tags, [:name])
  end
end

And our join table.

defmodule Nasty.Repo.Migrations.CreateBookmarkTags do
  use Ecto.Migration

  def change do
    create table(:bookmark_tags) do
      add :bookmark_id, references(:bookmarks, on_delete: :delete_all), null: false
      add :tag_id, references(:tags, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:bookmark_tags, [:bookmark_id])
    create index(:bookmark_tags, [:tag_id])
    create unique_index(:bookmark_tags, [:bookmark_id, :tag_id])
  end
end

Give it a mix do deps.get, compile, ecto.create, ecto.migrate, phx.server and lets see if we can create anything.

$ mix do deps.get, compile, ecto.create, ecto.migrate
$ iex -S mix phx.server
iex> alias Nasty.Bookmarks.Bookmark
iex> alias Nasty.Repo
iex> bm = %Bookmark{
  title: "My stuff",
  url: "https://google.com",
  description: "winning",
  tags: []
} |> Repo.insert
{:ok,
 %Nasty.Bookmarks.Bookmark{
   __meta__: #Ecto.Schema.Metadata<:loaded, "bookmarks">,
   id: 2886,
   title: "My stuff",
   description: "winning",
   url: "https://google.com",
   public: true,
   tags: [],
   inserted_at: ~N[2025-01-20 05:49:37],
   updated_at: ~N[2025-01-20 05:49:37]
 }}

Commit 53aca37178085861a4523d2b9c214b861dee843d

Great, so we can insert bookmarks, and lets just assume we have tags working (they do).

Next, we want to start thinking about the higher order usage of our system.

This isn't just a bookmarking tool, we want people to build on top of the feeds of bookmarks coming in as things develop.

In order to work towards a pubsub system, first we will need an in-memory store representing all of this so that we can track these records dynamically.

These cache setups in ETS are very easy in Elixir/Erlang, and really shine here.

We can have the ETS process listen to pubsub messages, and write accordingly.

Let's take a look at our highest level layer: Cache.

defmodule Nasty.Bookmarks.Cache do
  use GenServer
  require Logger

  @table_name :bookmarks_cache

  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init(_) do
    table = :ets.new(@table_name, [:set, :protected, :named_table])
    {:ok, %{table: table}, {:continue, :load_bookmarks}}
  end

  def handle_cast({:create_bookmark, attrs, tags}, state) do
    # TODO handle tags
    GenServer.cast(__MODULE__, {:update_bookmark, attrs})
    {:noreply, state}
  end

  # Server callbacks
  def handle_cast({:update_bookmark, bookmark}, state) do
    [{:all_bookmarks, bookmarks}] = :ets.lookup(@table_name, :all_bookmarks)
    :ets.insert(@table_name, {:all_bookmarks, bookmarks ++ [bookmark]})
    {:noreply, state}
  end

  def handle_continue(:load_bookmarks, state) do
    :ets.insert(@table_name, {:all_bookmarks, []})
    {:noreply, state}
  end
end

We can start by playing in IEx with this interface

iex> alias Nasty.Bookmarks.Cache
iex> alias Nasty.Repo
iex> GenServer.cast(
  Nasty.Bookmarks.Cache,
  {
    :create_bookmark,
    %{url: "https://foo.com", title: "bar", description: "baz", public: true},
    []
  }
)
:ok

Now, if we go in and look at things with :ets.all we can see that we have a table with all of our bookmarks.

This enforces no typing contract of what an entry in here looks like, and we have one of those though.

Let's instead wire things up to use Bookmark structs that are representing the Ecto table.

Since this is all native Elixir, we can just have the Ecto struct can just be the one that the cache uses too.

  ## cache.ex
defmodule NastyClone.Bookmarks.Cache do
  alias Nasty.Bookmarks.Bookmark
  # --- snip ---
  def handle_cast({:create_bookmark, attrs = %{title: title, description: description, url: url, public: public}, tags}, state) do
    bookmark = %Bookmark{
      title: title,
      description: description,
      url: url,
      public: public,
      # TODO tags
      # tags: tags
    }

    GenServer.cast(__MODULE__, {:update_bookmark, bookmark})
    {:noreply, state}
  end

  def handle_cast({:create_bookmark, attrs, tags}, state) do
    Logger.error("Invalid props given to :create_bookmark. provide title, description, url, and public")
    {:noreply, state}
  end
  # --- snip ---

Now, we are at least passing around Bookmark structs. We are still hand waving away tags, but we can come back to that. We at least are enforcing the shape of the data that we are passing around.

iex> alias NastyClone.Bookmarks.Cache
NastyClone.Bookmarks.Cache
iex> Cache.start_link(nil)
{:ok, #PID<0.358.0>}
iex> GenServer.cast(NastyClone.Bookmarks.Cache, {:create_bookmark, %{url: "https://bizzle.com", title: "baz", description: "bizz", public: true}, []})
:ok
iex> :ets.tab2list(:bookmarks_cache)
[
  all_bookmarks: [
    %NastyClone.Bookmarks.Bookmark{
      __meta__: #Ecto.Schema.Metadata<:built, "bookmarks">,
      id: nil,
      title: "baz",
      description: "bizz",
      url: "https://bizzle.com",
      public: true,
      user_id: nil,
      user: #Ecto.Association.NotLoaded<association :user is not loaded>,
      tags: #Ecto.Association.NotLoaded<association :tags is not loaded>,
      inserted_at: nil,
      updated_at: nil
    }
  ]
]

Commit 818e3d65422581e03538544d905b292e2960aefa

For now, let's create a common interface for the cache and also back this into Postgres.

We can do this pretty simply by creating a 'Bookmarks' context module. This module will be how we access 'state' in bookmarks as a whole. Since we have both postgres and the cache now, we want to have a single interface to set these values.

We will start with the Bookmarks context module.

defmodule NastyClone.Bookmarks do
  import Ecto.Query

  alias NastyClone.Repo
  alias NastyClone.Bookmarks.Bookmark
  alias Tag
  alias Cache

  def get(id), do: Repo.get!(Bookmark, id) |> Repo.preload(:tags)

  def create(attrs \\ %{}, tags) do
    # TODO tags
    bookmark =
      %Bookmark{}
      |> Bookmark.changeset(attrs)
      |> Repo.insert!()

    GenServer.cast(Cache, {:create_bookmark, bookmark, tags})
    bookmark
  end
end

This does two things: create a bookmark, and then persist it to the cache as well. It still returns the bookmark, for simplicity's sake.

Now, we will make some changes in the cache to handle this. We are really just updating our handle_cast to accept the bookmark struct and then pass it to the cache.

# Cache

  # snip
  def handle_cast(
        {
          :create_bookmark,
          bookmark = %Bookmark{title: title, description: description, url: url, public: public},
          tags
        },
        state
  ) do

    GenServer.cast(__MODULE__, {:update_bookmark, bookmark})
    {:noreply, state}
  end
  # snip

And finally, when we get a create event, we can update the cache as well as postgres.

With all of this wired up, we can really start to get real with things if we add PubSub.

However, first we will make a brief detour and quickly build a chrome extension that we can create bookmarks with, and set up an API endpoint to service that usecase.

PubSub

Now, its time to wire up pubsub. This takes quite a few moving parts, but it will all make quite a bit of sense once we start seeing event streams. We will start this by connecting to PubSub from the beginning, and proceed to broadcast messages to it when we write to the cache & postgres.

# Cache
   def init(_) do
     # our new line
+    Phoenix.PubSub.subscribe(NastyClone.PubSub, "bookmarks")
     table = :ets.new(@table_name, [:set, :protected, :named_table])
     {:ok, %{table: table}, {:continue, :load_bookmarks}}
   end

And next, we delete our handle_cast function and instead just handle info now.

# Cache
  def handle_info({:bookmark_created, bookmark, tags}, state) do
    Logger.info("Received bookmark created event, updating cache")
    [{:all_bookmarks, bookmarks}] = :ets.lookup(@table_name, :all_bookmarks)
    :ets.insert(@table_name, {:all_bookmarks, bookmarks ++ [bookmark]})
    {:noreply, state}
  end

With this, we can implement a channel:

defmodule NastyCloneWeb.BookmarkChannel do
  use NastyCloneWeb, :channel

  @impl true
  def join("bookmarks:firehose", _payload, socket) do
    Phoenix.PubSub.subscribe(NastyClone.PubSub, "bookmarks")
    {:ok, socket}
  end

  @impl true
  def handle_info({:bookmark_created, bookmark, tags}, socket) do
    broadcast_bookmark = %{
      id: bookmark.id,
      title: bookmark.title,
      description: bookmark.description,
      url: bookmark.url,
      public: bookmark.public,
      tags: tags,
      inserted_at: bookmark.inserted_at
    }

    push(socket, "bookmark_created", broadcast_bookmark)
    {:noreply, socket}
  end
end

WRITINGTODO elaborate here

This is pretty straightforward thanks to Phoenix's PubSub. We are simply handling a basic join when someone comes for the firehose, and then if a bookmark is created pushing it to the client over the socket.

WRITINGTODO Use less elixiry language

They key point to take away is how now we dont have to handle the cast. We simple created the top level flow of data, and by subscribing, we can intercept exactly what we need and do with it as we please. In this case, that is handling the creation end to end.

WRITINGTODO explain what the user socket even does

We need to make a generic user socket to start things with.

defmodule NastyCloneWeb.UserSocket do
  use Phoenix.Socket

  # Channels
  channel "bookmarks:*", NastyCloneWeb.BookmarkChannel

  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end

WRITINGTODO explain what this means

Now, for this channel to work, we need to add a subscription to the socket in our endpoint.ex

  socket "/socket", NastyWeb.UserSocket,
    websocket: true,
    longpoll: false

WRITINGTODO Go into contexts, what they are, and how this wraps things up so nicely for so many interfaces (web, firehose client, bookmarklet, internal pubsub)

And finally we make a final entrypoint to interface with Bookmarks now.

defmodule NastyClone.Bookmarks do
  import Ecto.Query

  alias NastyClone.Repo
  alias NastyClone.Bookmarks.Bookmark
  alias Tag
  alias Cache

  def get(id), do: Repo.get!(Bookmark, id) |> Repo.preload(:tags)

  def create(attrs \\ %{}, tags) do
    # TODO tags
    bookmark =
      %Bookmark{}
      |> Bookmark.changeset(attrs)
      |> Repo.insert!()

    Phoenix.PubSub.broadcast(NastyClone.PubSub, "bookmarks", {:bookmark_created, bookmark, tags})
    bookmark
  end
end

Commit 8991bf63b993a1de5f87395c9e0e8330a3c6b53c

With this, lets create a JSON API endpoint to create bookmarks easily from our chrome extension.

defmodule NastyCloneWeb.Api.BookmarkController do
  use NastyCloneWeb, :controller

  alias NastyClone.Bookmarks

  def create(conn, %{"bookmark" => bookmark_params}) do
    # Extract tags from params or default to empty list
    tags = Map.get(bookmark_params, "tags", [])

    # Create the bookmark
    bookmark = Bookmarks.create(bookmark_params, tags)
    resp = %{
      data: %{
        id: bookmark.id,
        title: bookmark.title,
        description: bookmark.description,
        url: bookmark.url,
        public: bookmark.public,
        inserted_at: bookmark.inserted_at
      },
      message: "Bookmark created successfully"
    }

    conn
    |> put_status(:created)
    |> json(%{bookmark: resp})
  end

  # Add a fallback for invalid params
  def create(conn, _params) do
    conn
    |> put_status(:bad_request)
    |> json(%{error: "Invalid parameters. Expected 'bookmark' object in request body"})
  end
end

This is about as simple as phoenix can get, we are just taking the shape of the data being sent from the chrome extension and then creating a bookmark. And it will work fine with curl or whatever else too.

Next, we add a route to the router.

# Router
  scope "/api", NastyCloneWeb.Api do
    pipe_through :api

    resources "/bookmarks", BookmarkController, only: [:create]
  end

Commit c76ca38af883ca71b7195c1387a72130d5936d53

So now we have a pretty fully functioning system. It can create bookmarks and pushes all this out to the firehose, and we have a cache we can serve a web app from. We also have the tags system for soon creating multiple types of feeds. We are backing everything in postgres so we can load the prior cache state pretty easily.

Restart your server, and give this a shot:

curl -X POST http://localhost:4000/api/bookmarks \
  -H "Content-Type: application/json" \
  -d '{
    "bookmark": {
      "title": "Example Site",
      "description": "An example bookmark",
      "url": "https://example.com",
      "public": true,
      "tags": ["example", "test"]
    }
  }'

Next, we kind of hand wave away building a Chrome extension. It's super simple and you can skip it and just follow the advice in line 2 of the section.

Traffic

Now, for this to be interesting, we need to simulate some traffic to consume this firehose feed, and we need to have some clients active to see any of this happening.

We will quickly make a python client to listen to the firehose, and then we will also make a GenServer that will create bookmarks as if it were an API request as well.

We will wrap this all up to run alongside the system if an environment variable is set, and if so start creating fake traffic.

The only interesting thing we do here is start this up with the application at boot. We will see this after we look over this code for the simulator. The simulator itself just sends itself a message every 2 seconds to queue up sending another bookmark.

defmodule NastyClone.Bookmarks.Simulator do
  use GenServer
  require Logger
  alias NastyClone.Bookmarks

  @interval 2_000 # 2 seconds between bookmarks = ~30 per minute
  @adjectives ~w(Amazing Brilliant Clever Dynamic Elegant Fantastic Great Helpful Innovative Joyful)
  @nouns ~w(Tutorial Guide Project Framework Library Tool Resource Platform Service Application)
  @topics ~w(Elixir Phoenix JavaScript React Vue Angular Ruby Python Go Rust)

  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  @impl true
  def init(_) do
    schedule_next_bookmark()
    {:ok, %{count: 0}}
  end

  # make it use the link generator above...
  @impl true
  def handle_info(:create_bookmark, state) do
    create_random_bookmark()
    schedule_next_bookmark()
    {:noreply, %{state | count: state.count + 1}}
  end

  defp schedule_next_bookmark do
    Process.send_after(self(), :create_bookmark, @interval)
  end

  defp create_random_bookmark do
    LinkGenerator
    adjective = Enum.random(@adjectives)
    noun = Enum.random(@nouns)
    topic = Enum.random(@topics)

    title = "#{adjective} #{topic} #{noun}"
    description = "A #{String.downcase(adjective)} #{String.downcase(noun)} for #{topic} developers"
    url = generate_url(title)

    bookmark_params = %{
      "title" => title,
      "description" => description,
      "url" => url,
      "public" => true
    }

    tags = [topic, String.downcase(noun)]

    case Bookmarks.create(bookmark_params, tags) do
      %{id: id} ->
        Logger.info("Created simulated bookmark: #{title} (ID: #{id})")
      _ ->
        Logger.error("Failed to create simulated bookmark: #{title}")
    end
  end

  defp generate_url(title) do
    slug = title
    |> String.downcase()
    |> String.replace(~r/[^a-z0-9\s]/, "")
    |> String.replace(~r/\s+/, "-")

    domains = [
      "example.com",
      "tutorial-site.dev",
      "learn-tech.io",
      "codebase.edu",
      "dev-resources.net"
    ]

    "https://#{Enum.random(domains)}/#{slug}"
  end
end

If you are new to GenServers, this ones a great example of how we can have such nice tools out of the box. We have had quite a few more complicated ones, but this gets us a nice thing out of the box: recurring task running.

Now, we start it up in application.ex

  # snip
  def start(_type, _args) do
    children = [
      NastyCloneWeb.Telemetry,
      NastyClone.Repo,
      {DNSCluster, query: Application.get_env(:nasty_clone, :dns_cluster_query) || :ignore},
      {Phoenix.PubSub, name: NastyClone.PubSub},
      # Start the Finch HTTP client for sending emails
      {Finch, name: NastyClone.Finch},
      # Start a worker by calling: NastyClone.Worker.start_link(arg)
      # {NastyClone.Worker, arg},
      # Start to serve requests, typically the last entry
      NastyCloneWeb.Endpoint,
      NastyClone.Bookmarks.Cache,
      # our new line, the simulator
      NastyClone.Bookmarks.Simulator
    ]

    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: NastyClone.Supervisor]
    Supervisor.start_link(children, opts)
  end

  # snip

With this, we will start seeing entries coming across the channel regularly.

Soon, we will see all of this come together quite gracefully, I promise.

Commit ABCD

A LiveView Showing the Link Feed

Finally, let's view all these links that are coming across the wire. This is where we get to see LiveView in action.

Sewing tags into all of this

Filtered By Tag Publishing/Topic

You might be saying, "we dont have tags yet", but we are going to after we make this channel.

Chrome Extension

We're going to handwave most of this away, because its not the point of the guide.

In short, here is a chrome extension that we can use to create bookmarks.

Here is a super brief guide of the code.

We have a popup.html that is the UI of the extension.

We have popup.js that is the logic of the extension to submit the form and send the data to our API endpoint.

And finally we have manifest.json that is the metadata of the extension.

Now, you can throw this directory wherever you want. But to load it, enable developer mode in Chrome and guide it to the directory.

The popup is mostly styles, the real meat is the input form:

   <form id="bookmarkForm">
     <input type="text" id="title" placeholder="Title" required>
     <input type="text" id="tags" placeholder="Tags (comma separated)">
     <textarea id="description" placeholder="Description" rows="3"></textarea>
     <button type="submit">Save Bookmark</button>
   </form>
document.addEventListener('DOMContentLoaded', function() {
  // Get current tab URL
  chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
    const currentTab = tabs[0];

    // Pre-fill form with page details
    document.getElementById('title').value = currentTab.title || '';

    // Handle form submission
    document.getElementById('bookmarkForm').addEventListener('submit', function(e) {
      e.preventDefault();
      const bookmark = {
        title: document.getElementById('title').value,
        url: currentTab.url,
        description: document.getElementById('description').value,
        tags: document.getElementById('tags').value,
        public: true
      };

      saveBookmark(bookmark);
    });
  });
});

async function saveBookmark(bookmark) {
  const statusDiv = document.getElementById('status');
  try {
    const response = await fetch('http://localhost:4000/api/bookmarks', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      body: JSON.stringify({ bookmark }),
    });

    const responseText = await response.text();
    let data;
    try {
      data = JSON.parse(responseText);
    } catch (e) {
      console.error('Failed to parse response as JSON:', e);
      throw new Error('Server returned invalid JSON');
    }

    if (!response.ok) {
      console.error('Error response:', data);
      throw new Error(JSON.stringify(data.errors || data.error || 'Unknown error'));
    }

    statusDiv.style.color = '#00ff00';
    statusDiv.textContent = 'Bookmark saved!';
    setTimeout(() => window.close(), 3000);
  } catch (error) {
    console.error('Error:', error);
    statusDiv.textContent = `Error: ${error.message}`;
  }
}

Final Features, Reflections

About

del.icio.us but make it firehose the bookmarks and build it all in liveview

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published