From ef937ca0eb8abf7bad981f3213a164e4788c9b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20Engl=C3=B6f=20Ytterstr=C3=B6m?= Date: Mon, 7 Oct 2024 23:44:22 +0200 Subject: [PATCH] Add feeds: event-map.js, RSS feed, ICS feed (#23) * Add Feed view * Update Directus client to support feeds - Provide content for list data - Add virtual fields to albums. * Finalize archive view for Timeline module * Remove unused scaffold code * Add feed controller views: rss, calendar, js map * Use module var to set fallback limit value * Setup routes to feeds * Fix warnings and typos --- lib/mse25/directus.ex | 38 +- lib/mse25/timeline.ex | 34 +- lib/mse25_web.ex | 30 - lib/mse25_web/components/core_components.ex | 674 ------------------ .../components/layouts/app.html.heex | 14 - lib/mse25_web/controllers/feed_controller.ex | 76 +- lib/mse25_web/controllers/feed_view.ex | 252 +++++++ lib/mse25_web/controllers/page_controller.ex | 18 +- lib/mse25_web/controllers/page_html.ex | 5 - .../controllers/page_html/events.html.heex | 122 ++-- .../controllers/page_html/home.html.heex | 7 +- lib/mse25_web/router.ex | 21 +- 12 files changed, 476 insertions(+), 815 deletions(-) create mode 100644 lib/mse25_web/controllers/feed_view.ex diff --git a/lib/mse25/directus.ex b/lib/mse25/directus.ex index e261cb7..6e2fb25 100644 --- a/lib/mse25/directus.ex +++ b/lib/mse25/directus.ex @@ -1,4 +1,19 @@ defmodule Mse25.Directus do + @moduledoc """ + Simple Directus client, utilizing Req to do CRUD + operations. + + Currently, this client only read data, and supports + various ways of filtering data. + + It is by no means generic, since fieldsets are not + agnostic. It may however be used as a base to create + a more generic client implementation in Elixir. + + Directus documentation: + https://docs.directus.io/ + """ + @draft_filter "filter[status][_eq]=published" def get_article(slug) do @@ -15,7 +30,8 @@ defmodule Mse25.Directus do "slug", "title", "date_updated", - "pubDate" + "pubDate", + "contents" ], "," ) @@ -57,9 +73,22 @@ defmodule Mse25.Directus do |> query_params_string(options, :brutal_legends) get("/albums?" <> params) - |> Enum.map(fn m = %{"songs" => [%{"artist" => %{"name" => a}} | _], "purchased_at" => pa} -> - m |> Map.put("artist", a) |> Map.put("purchase_year", String.slice(pa, 0..3)) - end) + |> Enum.map( + fn m = %{ + "album" => album, + "year" => year, + "songs" => [%{"artist" => %{"name" => artist}} | _], + "purchased_at" => purchased_at + } -> + m + |> Map.put("artist", artist) + |> Map.put( + "purchase_year", + String.slice(purchased_at, 0..3) + ) + |> Map.put("summary", "#{artist} - #{album} (#{to_string(year)})") + end + ) end def get_event(slug) do @@ -98,6 +127,7 @@ defmodule Mse25.Directus do "category", "started_at", "ended_at", + "contents", "bands.artists_id.name", "mia.artists_id.name", "location.*" diff --git a/lib/mse25/timeline.ex b/lib/mse25/timeline.ex index dae3675..d4985a9 100644 --- a/lib/mse25/timeline.ex +++ b/lib/mse25/timeline.ex @@ -1,23 +1,35 @@ defmodule Mse25.Timeline do alias Mse25.Directus - def archive() do + @almost_infinity 9999 + + def archive(limit \\ @almost_infinity) do items = Task.await_many([ Task.async(fn -> Directus.get_albums!() end), - Task.async(fn -> Directus.get_articles!(limit: 9999) end), - Task.async(fn -> Directus.get_links!(limit: 9999) end), - Task.async(fn -> Directus.get_events!(limit: 9999) end) + Task.async(fn -> Directus.get_articles!(limit: limit) end), + Task.async(fn -> Directus.get_links!(limit: limit) end), + Task.async(fn -> Directus.get_events!(limit: limit) end) ]) + + archive = + items + |> List.flatten() + |> Enum.sort_by(&sort_key/1) + |> Enum.reverse() + |> Enum.take(limit) + |> Enum.map(&categorize/1) + + {:ok, %{archive: archive}} end def annual(year) do items = Task.await_many([ - Task.async(fn -> Directus.get_albums!(limit: 9999, year: year) end), - Task.async(fn -> Directus.get_articles!(limit: 9999, year: year) end), - Task.async(fn -> Directus.get_links!(limit: 9999, year: year) end), - Task.async(fn -> Directus.get_events!(limit: 9999, year: year) end) + Task.async(fn -> Directus.get_albums!(limit: @almost_infinity, year: year) end), + Task.async(fn -> Directus.get_articles!(limit: @almost_infinity, year: year) end), + Task.async(fn -> Directus.get_links!(limit: @almost_infinity, year: year) end), + Task.async(fn -> Directus.get_events!(limit: @almost_infinity, year: year) end) ]) counts = @@ -40,9 +52,9 @@ defmodule Mse25.Timeline do def search(query) do items = Task.await_many([ - Task.async(fn -> Directus.get_articles!(limit: 9999, query: query) end), - Task.async(fn -> Directus.get_links!(limit: 9999, query: query) end), - Task.async(fn -> Directus.get_events!(limit: 9999, query: query) end) + Task.async(fn -> Directus.get_articles!(limit: @almost_infinity, query: query) end), + Task.async(fn -> Directus.get_links!(limit: @almost_infinity, query: query) end), + Task.async(fn -> Directus.get_events!(limit: @almost_infinity, query: query) end) ]) results = diff --git a/lib/mse25_web.ex b/lib/mse25_web.ex index 107b6b8..46af4be 100644 --- a/lib/mse25_web.ex +++ b/lib/mse25_web.ex @@ -26,13 +26,6 @@ defmodule Mse25Web do # Import common connection and controller functions to use in pipelines import Plug.Conn import Phoenix.Controller - import Phoenix.LiveView.Router - end - end - - def channel do - quote do - use Phoenix.Channel end end @@ -49,23 +42,6 @@ defmodule Mse25Web do end end - def live_view do - quote do - use Phoenix.LiveView, - layout: {Mse25Web.Layouts, :app} - - unquote(html_helpers()) - end - end - - def live_component do - quote do - use Phoenix.LiveComponent - - unquote(html_helpers()) - end - end - def html do quote do use Phoenix.Component @@ -81,16 +57,10 @@ defmodule Mse25Web do defp html_helpers do quote do - # HTML escaping functionality import Phoenix.HTML - # Core UI components and translation import Mse25Web.CoreComponents import Mse25Web.Gettext - # Shortcut for generating JS commands - alias Phoenix.LiveView.JS - - # Routes generation with the ~p sigil unquote(verified_routes()) end end diff --git a/lib/mse25_web/components/core_components.ex b/lib/mse25_web/components/core_components.ex index 31d4ae8..2c0ff73 100644 --- a/lib/mse25_web/components/core_components.ex +++ b/lib/mse25_web/components/core_components.ex @@ -1,676 +1,2 @@ defmodule Mse25Web.CoreComponents do - @moduledoc """ - Provides core UI components. - - At first glance, this module may seem daunting, but its goal is to provide - core building blocks for your application, such as modals, tables, and - forms. The components consist mostly of markup and are well-documented - with doc strings and declarative assigns. You may customize and style - them in any way you want, based on your application growth and needs. - - The default components use Tailwind CSS, a utility-first CSS framework. - See the [Tailwind CSS documentation](https://tailwindcss.com) to learn - how to customize them or feel free to swap in another framework altogether. - - Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. - """ - use Phoenix.Component - - alias Phoenix.LiveView.JS - import Mse25Web.Gettext - - @doc """ - Renders a modal. - - ## Examples - - <.modal id="confirm-modal"> - This is a modal. - - - JS commands may be passed to the `:on_cancel` to configure - the closing/cancel event, for example: - - <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> - This is another modal. - - - """ - attr :id, :string, required: true - attr :show, :boolean, default: false - attr :on_cancel, JS, default: %JS{} - slot :inner_block, required: true - - def modal(assigns) do - ~H""" - - """ - end - - def input(%{type: "select"} = assigns) do - ~H""" -
- <.label for={@id}><%= @label %> - - <.error :for={msg <- @errors}><%= msg %> -
- """ - end - - def input(%{type: "textarea"} = assigns) do - ~H""" -
- <.label for={@id}><%= @label %> - - <.error :for={msg <- @errors}><%= msg %> -
- """ - end - - # All other inputs text, datetime-local, url, password, etc. are handled here... - def input(assigns) do - ~H""" -
- <.label for={@id}><%= @label %> - - <.error :for={msg <- @errors}><%= msg %> -
- """ - end - - @doc """ - Renders a label. - """ - attr :for, :string, default: nil - slot :inner_block, required: true - - def label(assigns) do - ~H""" - - """ - end - - @doc """ - Generates a generic error message. - """ - slot :inner_block, required: true - - def error(assigns) do - ~H""" -

- <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> - <%= render_slot(@inner_block) %> -

- """ - end - - @doc """ - Renders a header with title. - """ - attr :class, :string, default: nil - - slot :inner_block, required: true - slot :subtitle - slot :actions - - def header(assigns) do - ~H""" -
-
-

- <%= render_slot(@inner_block) %> -

-

- <%= render_slot(@subtitle) %> -

-
-
<%= render_slot(@actions) %>
-
- """ - end - - @doc ~S""" - Renders a table with generic styling. - - ## Examples - - <.table id="users" rows={@users}> - <:col :let={user} label="id"><%= user.id %> - <:col :let={user} label="username"><%= user.username %> - - """ - attr :id, :string, required: true - attr :rows, :list, required: true - attr :row_id, :any, default: nil, doc: "the function for generating the row id" - attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" - - attr :row_item, :any, - default: &Function.identity/1, - doc: "the function for mapping each row before calling the :col and :action slots" - - slot :col, required: true do - attr :label, :string - end - - slot :action, doc: "the slot for showing user actions in the last table column" - - def table(assigns) do - assigns = - with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do - assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) - end - - ~H""" -
- - - - - - - - - - - - - -
<%= col[:label] %> - <%= gettext("Actions") %> -
-
- - - <%= render_slot(col, @row_item.(row)) %> - -
-
-
- - - <%= render_slot(action, @row_item.(row)) %> - -
-
-
- """ - end - - @doc """ - Renders a data list. - - ## Examples - - <.list> - <:item title="Title"><%= @post.title %> - <:item title="Views"><%= @post.views %> - - """ - slot :item, required: true do - attr :title, :string, required: true - end - - def list(assigns) do - ~H""" -
-
-
-
<%= item.title %>
-
<%= render_slot(item) %>
-
-
-
- """ - end - - @doc """ - Renders a back navigation link. - - ## Examples - - <.back navigate={~p"/posts"}>Back to posts - """ - attr :navigate, :any, required: true - slot :inner_block, required: true - - def back(assigns) do - ~H""" -
- <.link - navigate={@navigate} - class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" - > - <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> - <%= render_slot(@inner_block) %> - -
- """ - end - - @doc """ - Renders a [Heroicon](https://heroicons.com). - - Heroicons come in three styles – outline, solid, and mini. - By default, the outline style is used, but solid and mini may - be applied by using the `-solid` and `-mini` suffix. - - You can customize the size and colors of the icons by setting - width, height, and background color classes. - - Icons are extracted from the `deps/heroicons` directory and bundled within - your compiled app.css by the plugin in your `assets/tailwind.config.js`. - - ## Examples - - <.icon name="hero-x-mark-solid" /> - <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> - """ - attr :name, :string, required: true - attr :class, :string, default: nil - - def icon(%{name: "hero-" <> _} = assigns) do - ~H""" - - """ - end - - ## JS Commands - - def show(js \\ %JS{}, selector) do - JS.show(js, - to: selector, - time: 300, - transition: - {"transition-all transform ease-out duration-300", - "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", - "opacity-100 translate-y-0 sm:scale-100"} - ) - end - - def hide(js \\ %JS{}, selector) do - JS.hide(js, - to: selector, - time: 200, - transition: - {"transition-all transform ease-in duration-200", - "opacity-100 translate-y-0 sm:scale-100", - "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} - ) - end - - def show_modal(js \\ %JS{}, id) when is_binary(id) do - js - |> JS.show(to: "##{id}") - |> JS.show( - to: "##{id}-bg", - time: 300, - transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} - ) - |> show("##{id}-container") - |> JS.add_class("overflow-hidden", to: "body") - |> JS.focus_first(to: "##{id}-content") - end - - def hide_modal(js \\ %JS{}, id) do - js - |> JS.hide( - to: "##{id}-bg", - transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} - ) - |> hide("##{id}-container") - |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) - |> JS.remove_class("overflow-hidden", to: "body") - |> JS.pop_focus() - end - - @doc """ - Translates an error message using gettext. - """ - def translate_error({msg, opts}) do - # When using gettext, we typically pass the strings we want - # to translate as a static argument: - # - # # Translate the number of files with plural rules - # dngettext("errors", "1 file", "%{count} files", count) - # - # However the error messages in our forms and APIs are generated - # dynamically, so we need to translate them by calling Gettext - # with our gettext backend as first argument. Translations are - # available in the errors.po file (as we use the "errors" domain). - if count = opts[:count] do - Gettext.dngettext(Mse25Web.Gettext, "errors", msg, msg, count, opts) - else - Gettext.dgettext(Mse25Web.Gettext, "errors", msg, opts) - end - end - - @doc """ - Translates the errors for a field from a keyword list of errors. - """ - def translate_errors(errors, field) when is_list(errors) do - for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) - end end diff --git a/lib/mse25_web/components/layouts/app.html.heex b/lib/mse25_web/components/layouts/app.html.heex index 7ffbd85..c753bc6 100644 --- a/lib/mse25_web/components/layouts/app.html.heex +++ b/lib/mse25_web/components/layouts/app.html.heex @@ -1,17 +1,3 @@
<%= @inner_content %>
- diff --git a/lib/mse25_web/controllers/feed_controller.ex b/lib/mse25_web/controllers/feed_controller.ex index c3500df..e4c702c 100644 --- a/lib/mse25_web/controllers/feed_controller.ex +++ b/lib/mse25_web/controllers/feed_controller.ex @@ -2,16 +2,52 @@ defmodule Mse25Web.FeedController do use Mse25Web, :controller alias Mse25.Directus alias Mse25.Timeline + plug :put_layout, false - def atom_feed() do - :tbw + def feed(conn, _params) do + {:ok, %{archive: items}} = Timeline.archive(20) + + text( + conn |> put_resp_content_type("application/rss+xml"), + items + |> Mse25Web.FeedView.rss(conn.host) + ) end - def upcoming_events_ics() do - :tbw + def calendar(conn, _) do + text( + conn |> put_resp_content_type("text/calendar"), + Directus.get_events!(upcoming: true, limit: 9999) + |> Enum.map(fn %{ + "title" => title, + "lead" => lead, + "started_at" => starts_at, + "ended_at" => ends_at, + "location" => %{ + "name" => venue, + "address" => region, + "position" => %{ + "coordinates" => [lat, lng] + } + } + } -> + %{ + title: title, + lead: lead, + region: region, + venue: venue, + latitude: lat, + longitude: lng, + all_day?: true, + starts_at: String.replace(starts_at, "-", ""), + ends_at: String.replace(ends_at, "-", "") + } + end) + |> Mse25Web.FeedView.calendar() + ) end - def albums_json(conn, _) do + def albums(conn, _) do json( conn, Directus.get_albums!() @@ -41,7 +77,7 @@ defmodule Mse25Web.FeedController do ) end - def events_json(conn, _) do + def events(conn, _) do json( conn, Directus.get_events!(limit: 9999) @@ -73,7 +109,31 @@ defmodule Mse25Web.FeedController do ) end - def event_map_js() do - :tbw + def interactive_event_map(conn, _) do + text( + conn |> put_resp_content_type("text/javascript"), + Directus.get_events!(limit: 9999) + |> Enum.map(fn %{ + "title" => title, + "started_at" => date, + "location" => %{ + "name" => venue, + "address" => region, + "position" => %{ + "coordinates" => [lat, lng] + } + } + } -> + %{ + title: title, + date: String.slice(date, 0..9), + region: region, + venue: venue, + longitude: lng, + latitude: lat + } + end) + |> Mse25Web.FeedView.event_map() + ) end end diff --git a/lib/mse25_web/controllers/feed_view.ex b/lib/mse25_web/controllers/feed_view.ex new file mode 100644 index 0000000..6066dc6 --- /dev/null +++ b/lib/mse25_web/controllers/feed_view.ex @@ -0,0 +1,252 @@ +defmodule Mse25Web.FeedView do + use Mse25Web, :html + + def rss(items, _host) do + ~s""" + + + + madr.se + The online home of Anders Englöf Ytterström, a metalhead and musician living and working in Borlänge, Sweden. + sv + https://madr.se/ + yttan@fastmail.se (Anders Englöf Ytterström) + yttan@fastmail.se (Anders Englöf Ytterström) + + #{Enum.map(items, &rss_item/1)} + + + """ + end + + def calendar(upcoming) do + ~s""" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//https://madr.se//kommande-evenemang + METHOD:PUBLISH + #{upcoming |> Enum.map(fn %{title: title, starts_at: starts_at, ends_at: ends_at, longitude: longitude, latitude: latitude, lead: lead, venue: venue, region: region} -> ~s""" + BEGIN:VEVENT + UID:#{title}.#{starts_at}@madr.se + DTSTAMP:#{starts_at}T000000 + DTSTART;VALUE=DATE:#{starts_at} + DTEND;VALUE=DATE:#{ends_at} + SUMMARY:#{title} + DESCRIPTION:#{lead} + LOCATION:#{venue}\, #{region} + GEO:#{latitude};#{longitude} + END:VEVENT + """ end) |> Enum.join("")}END:VCALENDAR + """ + end + + def event_map(markers) do + ~s""" + (function(g, document) { + "use strict"; + + const mapData = [ + #{markers |> Enum.map(fn %{date: date, latitude: latitude, longitude: longitude, title: title, region: region, venue: venue} -> ~s""" + { + location: [#{longitude}, #{latitude}], + title: "#{title}", + date: "#{date}", + region: "#{region}", + venue: "#{venue}" + } + """ end) |> Enum.join(",")} + ] + + // insert Leaflet styles () to and diff --git a/lib/mse25_web/controllers/page_html/home.html.heex b/lib/mse25_web/controllers/page_html/home.html.heex index 7c70d10..966a6b3 100644 --- a/lib/mse25_web/controllers/page_html/home.html.heex +++ b/lib/mse25_web/controllers/page_html/home.html.heex @@ -34,7 +34,9 @@ Evenemangstidslinje
- Värt att uppmärksamma: + Kommande evenemang (vcalendar) +
+
Delningar @@ -58,6 +60,9 @@ Anders, 39, Hårdrockare
+
+ Prenumerera (RSS 2.0) +
Kontakt & Kolofon diff --git a/lib/mse25_web/router.ex b/lib/mse25_web/router.ex index d54c115..186f52f 100644 --- a/lib/mse25_web/router.ex +++ b/lib/mse25_web/router.ex @@ -4,16 +4,26 @@ defmodule Mse25Web.Router do pipeline :browser do plug :accepts, ["html"] plug :fetch_session - plug :fetch_live_flash plug :put_root_layout, html: {Mse25Web.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers end + pipeline :scripts do + plug :accepts, ["js"] + plug :put_secure_browser_headers + end + pipeline :api do plug :accepts, ["json"] end + scope "/", Mse25Web do + pipe_through :scripts + + get "/event-map.js", FeedController, :interactive_event_map + end + scope "/", Mse25Web do pipe_through :browser @@ -23,11 +33,10 @@ defmodule Mse25Web.Router do get "/delningar", PageController, :links get "/sok", PageController, :search - # get "/kommande-evenemang.ics", EventController, :calendar - # get "/event-map.js", EventController, :interactive_map - # get "/prenumerera.xml", TimelineController, :feed - get "/albums.json", FeedController, :albums_json - get "/events.json", FeedController, :events_json + get "/prenumerera.xml", FeedController, :feed + get "/albums.json", FeedController, :albums + get "/events.json", FeedController, :events + get "/kommande-evenemang.ics", FeedController, :calendar get "/*path", ItemController, :index end