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
This commit is contained in:
parent
7e2010efb0
commit
ef937ca0eb
12 changed files with 476 additions and 815 deletions
|
|
@ -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.*"
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</.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.
|
||||
</.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"""
|
||||
<div
|
||||
id={@id}
|
||||
phx-mounted={@show && show_modal(@id)}
|
||||
phx-remove={hide_modal(@id)}
|
||||
data-cancel={JS.exec(@on_cancel, "phx-remove")}
|
||||
class="relative z-50 hidden"
|
||||
>
|
||||
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
|
||||
<div
|
||||
class="fixed inset-0 overflow-y-auto"
|
||||
aria-labelledby={"#{@id}-title"}
|
||||
aria-describedby={"#{@id}-description"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex min-h-full items-center justify-center">
|
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
|
||||
<.focus_wrap
|
||||
id={"#{@id}-container"}
|
||||
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
|
||||
phx-key="escape"
|
||||
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
|
||||
class="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
|
||||
>
|
||||
<div class="absolute top-6 right-5">
|
||||
<button
|
||||
phx-click={JS.exec("data-cancel", to: "##{@id}")}
|
||||
type="button"
|
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
|
||||
aria-label={gettext("close")}
|
||||
>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div id={"#{@id}-content"}>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</.focus_wrap>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
|
||||
"""
|
||||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
|
||||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class={[
|
||||
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
|
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
|
||||
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
|
||||
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
|
||||
<%= @title %>
|
||||
</p>
|
||||
<p class="mt-2 text-sm leading-5"><%= msg %></p>
|
||||
<button type="button" class="group absolute top-1 right-1 p-2" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Shows the flash group with standard titles and content.
|
||||
|
||||
## Examples
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
attr :flash, :map, required: true, doc: "the map of flash messages"
|
||||
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
|
||||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.flash kind={:info} title={gettext("Success!")} flash={@flash} />
|
||||
<.flash kind={:error} title={gettext("Error!")} flash={@flash} />
|
||||
<.flash
|
||||
id="client-error"
|
||||
kind={:error}
|
||||
title={gettext("We can't find the internet")}
|
||||
phx-disconnected={show(".phx-client-error #client-error")}
|
||||
phx-connected={hide("#client-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Attempting to reconnect") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
|
||||
<.flash
|
||||
id="server-error"
|
||||
kind={:error}
|
||||
title={gettext("Something went wrong!")}
|
||||
phx-disconnected={show(".phx-server-error #server-error")}
|
||||
phx-connected={hide("#server-error")}
|
||||
hidden
|
||||
>
|
||||
<%= gettext("Hang in there while we get back on track") %>
|
||||
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
|
||||
</.flash>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a simple form.
|
||||
|
||||
## Examples
|
||||
|
||||
<.simple_form for={@form} phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label="Email"/>
|
||||
<.input field={@form[:username]} label="Username" />
|
||||
<:actions>
|
||||
<.button>Save</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
"""
|
||||
attr :for, :any, required: true, doc: "the data structure for the form"
|
||||
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(autocomplete name rel action enctype method novalidate target multipart),
|
||||
doc: "the arbitrary HTML attributes to apply to the form tag"
|
||||
|
||||
slot :inner_block, required: true
|
||||
slot :actions, doc: "the slot for form actions, such as a submit button"
|
||||
|
||||
def simple_form(assigns) do
|
||||
~H"""
|
||||
<.form :let={f} for={@for} as={@as} {@rest}>
|
||||
<div class="mt-10 space-y-8 bg-white">
|
||||
<%= render_slot(@inner_block, f) %>
|
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
|
||||
<%= render_slot(action, f) %>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a button.
|
||||
|
||||
## Examples
|
||||
|
||||
<.button>Send!</.button>
|
||||
<.button phx-click="go" class="ml-2">Send!</.button>
|
||||
"""
|
||||
attr :type, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :rest, :global, include: ~w(disabled form name value)
|
||||
|
||||
slot :inner_block, required: true
|
||||
|
||||
def button(assigns) do
|
||||
~H"""
|
||||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
|
||||
"text-sm font-semibold leading-6 text-white active:text-white/80",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
A `Phoenix.HTML.FormField` may be passed as argument,
|
||||
which is used to retrieve the input name, id, and values.
|
||||
Otherwise all attributes may be passed explicitly.
|
||||
|
||||
## Types
|
||||
|
||||
This function accepts all HTML input types, considering that:
|
||||
|
||||
* You may also set `type="select"` to render a `<select>` tag
|
||||
|
||||
* `type="checkbox"` is used exclusively to render boolean values
|
||||
|
||||
* For live file uploads, see `Phoenix.Component.live_file_input/1`
|
||||
|
||||
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
|
||||
for more information. Unsupported types, such as hidden and radio,
|
||||
are best written directly in your templates.
|
||||
|
||||
## Examples
|
||||
|
||||
<.input field={@form[:email]} type="email" />
|
||||
<.input name="my-input" errors={["oh no!"]} />
|
||||
"""
|
||||
attr :id, :any, default: nil
|
||||
attr :name, :any
|
||||
attr :label, :string, default: nil
|
||||
attr :value, :any
|
||||
|
||||
attr :type, :string,
|
||||
default: "text",
|
||||
values: ~w(checkbox color date datetime-local email file month number password
|
||||
range search select tel text textarea time url week)
|
||||
|
||||
attr :field, Phoenix.HTML.FormField,
|
||||
doc: "a form field struct retrieved from the form, for example: @form[:email]"
|
||||
|
||||
attr :errors, :list, default: []
|
||||
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
|
||||
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
|
||||
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
|
||||
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
|
||||
|
||||
attr :rest, :global,
|
||||
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
assigns
|
||||
|> assign(field: nil, id: assigns.id || field.id)
|
||||
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|
||||
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|
||||
|> assign_new(:value, fn -> field.value end)
|
||||
|> input()
|
||||
end
|
||||
|
||||
def input(%{type: "checkbox"} = assigns) do
|
||||
assigns =
|
||||
assign_new(assigns, :checked, fn ->
|
||||
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
|
||||
end)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<input
|
||||
type="checkbox"
|
||||
id={@id}
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
|
||||
{@rest}
|
||||
/>
|
||||
<%= @label %>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
<option :if={@prompt} value=""><%= @prompt %></option>
|
||||
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
|
||||
</select>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.label for={@id}><%= @label %></.label>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
|
||||
@errors == [] && "border-zinc-300 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
{@rest}
|
||||
/>
|
||||
<.error :for={msg <- @errors}><%= msg %></.error>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a label.
|
||||
"""
|
||||
attr :for, :string, default: nil
|
||||
slot :inner_block, required: true
|
||||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a generic error message.
|
||||
"""
|
||||
slot :inner_block, required: true
|
||||
|
||||
def error(assigns) do
|
||||
~H"""
|
||||
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
|
||||
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
"""
|
||||
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"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</h1>
|
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
<%= render_slot(@subtitle) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-none"><%= render_slot(@actions) %></div>
|
||||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Renders a table with generic styling.
|
||||
|
||||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id"><%= user.id %></:col>
|
||||
<:col :let={user} label="username"><%= user.username %></:col>
|
||||
</.table>
|
||||
"""
|
||||
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"""
|
||||
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
|
||||
<table class="w-[40rem] mt-11 sm:w-full">
|
||||
<thead class="text-sm text-left leading-6 text-zinc-500">
|
||||
<tr>
|
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
|
||||
<th :if={@action != []} class="relative p-0 pb-4">
|
||||
<span class="sr-only"><%= gettext("Actions") %></span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
id={@id}
|
||||
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
|
||||
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
|
||||
>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
|
||||
<td
|
||||
:for={{col, i} <- Enum.with_index(@col)}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
<div class="block py-4 pr-6">
|
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
|
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
|
||||
<%= render_slot(col, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td :if={@action != []} class="relative w-14 p-0">
|
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
|
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
|
||||
<span
|
||||
:for={action <- @action}
|
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
|
||||
>
|
||||
<%= render_slot(action, @row_item.(row)) %>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a data list.
|
||||
|
||||
## Examples
|
||||
|
||||
<.list>
|
||||
<:item title="Title"><%= @post.title %></:item>
|
||||
<:item title="Views"><%= @post.views %></:item>
|
||||
</.list>
|
||||
"""
|
||||
slot :item, required: true do
|
||||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
|
||||
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a back navigation link.
|
||||
|
||||
## Examples
|
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back>
|
||||
"""
|
||||
attr :navigate, :any, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
def back(assigns) do
|
||||
~H"""
|
||||
<div class="mt-16">
|
||||
<.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) %>
|
||||
</.link>
|
||||
</div>
|
||||
"""
|
||||
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"""
|
||||
<span class={[@name, @class]} />
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,17 +1,3 @@
|
|||
<main>
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<!--
|
||||
<footer>
|
||||
<p>
|
||||
<a href="https://madr.se" rel="home">madr.se</a>
|
||||
av Anders Englöf Ytterström, sedan 2006. <a href="/colophon">Kolofon</a>.
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/madr" rel="external">Github</a></li>
|
||||
<li><a href="https://linkedin.com/anders-ytterstrom" rel="external">LinkedIn</a></li>
|
||||
<li><a href="https://discogs.com/madr" rel="external">Discogs</a></li>
|
||||
<li><a href="https://songkick.com/madr" rel="external">Songkick</a></li>
|
||||
</ul>
|
||||
</footer>
|
||||
-->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
252
lib/mse25_web/controllers/feed_view.ex
Normal file
252
lib/mse25_web/controllers/feed_view.ex
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
defmodule Mse25Web.FeedView do
|
||||
use Mse25Web, :html
|
||||
|
||||
def rss(items, _host) do
|
||||
~s"""
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:georss="http://www.georss.org/georss" xmlns:gml="http://www.opengis.net/gml">
|
||||
<channel>
|
||||
<title>madr.se</title>
|
||||
<description>The online home of Anders Englöf Ytterström, a metalhead and musician living and working in Borlänge, Sweden.</description>
|
||||
<language>sv</language>
|
||||
<link>https://madr.se/</link>
|
||||
<managingEditor>yttan@fastmail.se (Anders Englöf Ytterström)</managingEditor>
|
||||
<webMaster>yttan@fastmail.se (Anders Englöf Ytterström)</webMaster>
|
||||
<atom:link href="https://madr.se/prenumerera.xml" rel="self" type="application/rss+xml" />
|
||||
#{Enum.map(items, &rss_item/1)}
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
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 (<link>) to <head> and <script> to
|
||||
// bottom of <body>, and initiate the map when the assets have
|
||||
// loaded.
|
||||
var install = function() {
|
||||
var styles, leaflet;
|
||||
|
||||
styles = document.createElement("link");
|
||||
styles.rel = "stylesheet";
|
||||
styles.href = "https://unpkg.com/leaflet@1.6.0/dist/leaflet.css";
|
||||
styles.integrity =
|
||||
"sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ==";
|
||||
styles.crossOrigin = "";
|
||||
|
||||
document.head.appendChild(styles);
|
||||
|
||||
leaflet = document.createElement("script");
|
||||
leaflet.src = "https://unpkg.com/leaflet@1.6.0/dist/leaflet.js";
|
||||
leaflet.integrity =
|
||||
"sha512-gZwIG9x3wUXg2hdXF6+rVkLF/0Vi9U8D2Ntg4Ga5I5BZpVkVxlJWbSQtXPSiUTtC0TjtGOmxa1AJPuV0CPthew==";
|
||||
leaflet.crossOrigin = "";
|
||||
leaflet.async = true;
|
||||
leaflet.onload = function() {
|
||||
init(mapData);
|
||||
};
|
||||
|
||||
document.body.appendChild(leaflet);
|
||||
};
|
||||
|
||||
// inititate the map.
|
||||
// create markers for all events: one marker per venue. Display all
|
||||
// events on each venue through a popup by clicking on the marker.
|
||||
// set bounds to have all markers visible.
|
||||
var init = function(mapData) {
|
||||
var events,
|
||||
markers = {},
|
||||
L = g.L;
|
||||
|
||||
if (L) {
|
||||
// create map and set initial bounds
|
||||
events = L.map("leaflet", { zoomDelta: 0.25, zoomSnap: 0 }).fitBounds(
|
||||
mapData.map(function(e) {
|
||||
return e.location;
|
||||
})
|
||||
);
|
||||
|
||||
// use OpenStreetMap tile layer: it's free!
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png?{foo}", {
|
||||
foo: "bar",
|
||||
attribution:
|
||||
'Map data © <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>'
|
||||
}).addTo(events);
|
||||
|
||||
// group events by venue
|
||||
mapData.forEach(function(e) {
|
||||
if (markers[e.venue]) {
|
||||
markers[e.venue].events.push(e.date + " - " + e.title);
|
||||
} else {
|
||||
markers[e.venue] = {
|
||||
region: e.region,
|
||||
location: e.location,
|
||||
events: [e.date + " - " + e.title]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// create markers
|
||||
for (var m in markers) {
|
||||
if (markers.hasOwnProperty(m)) {
|
||||
L.marker(markers[m].location)
|
||||
.addTo(events)
|
||||
.bindPopup(
|
||||
"<strong style='color: black'>" +
|
||||
m +
|
||||
", " +
|
||||
markers[m].region +
|
||||
"</strong><br>" +
|
||||
markers[m].events.join("<br>")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
install();
|
||||
})(this, document);
|
||||
"""
|
||||
end
|
||||
|
||||
def pub_date(nil), do: ""
|
||||
def pub_date(%{"pubDate" => date}), do: format_rfc822(date)
|
||||
def pub_date(%{"started_at" => date}), do: format_rfc822(date)
|
||||
def pub_date(%{"purchased_at" => date}), do: format_rfc822(date)
|
||||
|
||||
defp format_rfc822(date_time) when is_binary(date_time) do
|
||||
date_time
|
||||
|> Date.from_iso8601!()
|
||||
|> format_rfc822()
|
||||
end
|
||||
|
||||
defp format_rfc822(date_time), do: Calendar.strftime(date_time, "%a, %d %b %Y 06:06:06 +0200")
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :articles,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"pubDate" => date
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>https://madr.se/#{slug}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :events,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"location" => %{
|
||||
"position" => %{
|
||||
"coordinates" => [lng, lat]
|
||||
}
|
||||
},
|
||||
"started_at" => date
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>https://madr.se/#{slug}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
<georss:where>
|
||||
<gml:Point>
|
||||
<gml:pos>#{to_string(lng) <> " " <> to_string(lat)}</gml:pos>
|
||||
</gml:Point>
|
||||
</georss:where>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :links,
|
||||
"title" => title,
|
||||
"contents" => contents,
|
||||
"slug" => slug,
|
||||
"pubDate" => date,
|
||||
"source" => link
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{title}</title>
|
||||
<link>#{link}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{slug}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp rss_item(%{
|
||||
:t => :albums,
|
||||
"summary" => summary,
|
||||
"contents" => contents,
|
||||
"purchased_at" => date,
|
||||
"externalId" => id,
|
||||
"purchase_year" => year
|
||||
}) do
|
||||
~s"""
|
||||
<item>
|
||||
<title>#{summary}</title>
|
||||
<link>https://madr.se/#{year}/#{id}</link>
|
||||
<description>
|
||||
<![CDATA[#{Earmark.as_html!(contents)}]]>
|
||||
</description>
|
||||
<pubDate>#{format_rfc822(date)}</pubDate>
|
||||
<guid>https://madr.se/#{year}/#{id}</guid>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
@ -4,6 +4,8 @@ defmodule Mse25Web.PageController do
|
|||
alias Mse25.Directus
|
||||
alias Mse25.Timeline
|
||||
|
||||
@almost_infinity 9999
|
||||
|
||||
def home(conn, _params) do
|
||||
[most_recent_article, older_article] = Directus.get_articles!(limit: 2)
|
||||
recent_event = Directus.get_events!(limit: 1)
|
||||
|
|
@ -48,8 +50,11 @@ defmodule Mse25Web.PageController do
|
|||
def articles(conn, params) do
|
||||
articles =
|
||||
case params do
|
||||
%{"q" => query_string} -> Directus.get_articles!(limit: 9999, query: query_string)
|
||||
_ -> Directus.get_articles!(limit: 9999)
|
||||
%{"q" => query_string} ->
|
||||
Directus.get_articles!(limit: @almost_infinity, query: query_string)
|
||||
|
||||
_ ->
|
||||
Directus.get_articles!(limit: @almost_infinity)
|
||||
end
|
||||
|> group_annually
|
||||
|
||||
|
|
@ -66,8 +71,11 @@ defmodule Mse25Web.PageController do
|
|||
|
||||
events =
|
||||
case params do
|
||||
%{"q" => query_string} -> Directus.get_events!(limit: 9999, query: query_string)
|
||||
_ -> Directus.get_events!(limit: 9999)
|
||||
%{"q" => query_string} ->
|
||||
Directus.get_events!(limit: @almost_infinity, query: query_string)
|
||||
|
||||
_ ->
|
||||
Directus.get_events!(limit: @almost_infinity)
|
||||
end
|
||||
|> group_annually
|
||||
|
||||
|
|
@ -81,7 +89,7 @@ defmodule Mse25Web.PageController do
|
|||
end
|
||||
|
||||
def links(conn, _params) do
|
||||
links = Directus.get_links!(limit: 9999) |> group_by_date
|
||||
links = Directus.get_links!(limit: @almost_infinity) |> group_by_date
|
||||
|
||||
render(conn, :links,
|
||||
page_title: "Delningar",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
defmodule Mse25Web.PageHTML do
|
||||
@moduledoc """
|
||||
This module contains pages rendered by PageController.
|
||||
|
||||
See the `page_html` directory for all templates available.
|
||||
"""
|
||||
use Mse25Web, :html
|
||||
import Mse25.EventHelpers
|
||||
|
||||
|
|
|
|||
|
|
@ -8,20 +8,27 @@
|
|||
</li>
|
||||
</ol>
|
||||
<h1><%= @page_title %></h1>
|
||||
<%= raw(@contents) %>
|
||||
<p>
|
||||
</header>
|
||||
<%= raw(@contents) %>
|
||||
<section id="map">
|
||||
<h2>Geografisk utspridning</h2>
|
||||
<figure>
|
||||
<div id="leaflet" style="aspect-ratio: 1"></div>
|
||||
</figure>
|
||||
</section>
|
||||
<p>
|
||||
<%= if @nosearch? do %>
|
||||
Gå direkt till:
|
||||
<% end %>
|
||||
</p>
|
||||
<ul class="months">
|
||||
</p>
|
||||
<ul class="months">
|
||||
<%= for {year, events} <- @events do %>
|
||||
<li>
|
||||
<a href={"#y" <> year}><%= year %></a> (<%= Enum.count(events) %>)
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<form method="get" action="/evenemang">
|
||||
</ul>
|
||||
<form method="get" action="/evenemang">
|
||||
<p>
|
||||
<%= if @nosearch? do %>
|
||||
Eller
|
||||
|
|
@ -30,8 +37,8 @@
|
|||
<input type="search" value={@q} name="q" id="q" size="7" />
|
||||
<button>Sök</button>
|
||||
</p>
|
||||
</form>
|
||||
<%= for {year, events} <- @events do %>
|
||||
</form>
|
||||
<%= for {year, events} <- @events do %>
|
||||
<section id={"y" <> year}>
|
||||
<h2 class="sticky"><%= year %></h2>
|
||||
<div class="events">
|
||||
|
|
@ -68,5 +75,6 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
</header>
|
||||
<% end %>
|
||||
<script src="/event-map.js">
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@
|
|||
<a href="/evenemang">Evenemangstidslinje</a>
|
||||
</div>
|
||||
<div>
|
||||
Värt att uppmärksamma:
|
||||
<a href="/kommande-evenemang.ics">Kommande evenemang</a> (vcalendar)
|
||||
</div>
|
||||
<div>
|
||||
<a href="/delningar">
|
||||
Delningar
|
||||
</a>
|
||||
|
|
@ -58,6 +60,9 @@
|
|||
Anders, 39, Hårdrockare
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/prenumerera.xml">Prenumerera</a> (RSS 2.0)
|
||||
</div>
|
||||
<div>
|
||||
<a href="/colophon">
|
||||
Kontakt & Kolofon
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue