summaryrefslogtreecommitdiff
path: root/lib/web
diff options
context:
space:
mode:
Diffstat (limited to 'lib/web')
-rw-r--r--lib/web/channels/user_socket.ex37
-rw-r--r--lib/web/components/component.ex44
-rw-r--r--lib/web/components/event_component.ex43
-rw-r--r--lib/web/components/message_component.ex12
-rw-r--r--lib/web/context_plug.ex92
-rw-r--r--lib/web/controllers/alcoolog_controller.ex323
-rw-r--r--lib/web/controllers/gpt_controller.ex33
-rw-r--r--lib/web/controllers/icecast_see_controller.ex41
-rw-r--r--lib/web/controllers/irc_auth_sse_controller.ex66
-rw-r--r--lib/web/controllers/irc_controller.ex101
-rw-r--r--lib/web/controllers/network_controller.ex11
-rw-r--r--lib/web/controllers/open_id_controller.ex64
-rw-r--r--lib/web/controllers/page_controller.ex53
-rw-r--r--lib/web/controllers/sms_controller.ex10
-rw-r--r--lib/web/controllers/untappd_controller.ex18
-rw-r--r--lib/web/endpoint.ex62
-rw-r--r--lib/web/gettext.ex24
-rw-r--r--lib/web/live/chat_live.ex120
-rw-r--r--lib/web/live/chat_live.html.heex91
-rw-r--r--lib/web/router.ex85
-rw-r--r--lib/web/templates/alcoolog/auth.html.eex43
-rw-r--r--lib/web/templates/alcoolog/index.html.eex205
-rw-r--r--lib/web/templates/alcoolog/user.html.eex170
-rw-r--r--lib/web/templates/irc/index.html.eex44
-rw-r--r--lib/web/templates/irc/txt.html.eex27
-rw-r--r--lib/web/templates/irc/txts.html.eex49
-rw-r--r--lib/web/templates/layout/app.html.eex126
-rw-r--r--lib/web/templates/layout/root.html.leex18
-rw-r--r--lib/web/templates/network/index.html.eex1
-rw-r--r--lib/web/templates/open_id/error.html.eex3
-rw-r--r--lib/web/templates/page/api.html.eex35
-rw-r--r--lib/web/templates/page/index.html.eex1
-rw-r--r--lib/web/templates/page/irc.html.eex19
-rw-r--r--lib/web/templates/page/user.html.eex43
-rw-r--r--lib/web/templates/page/widget.html.eex20
-rw-r--r--lib/web/views/alcoolog_view.ex6
-rw-r--r--lib/web/views/error_helpers.ex40
-rw-r--r--lib/web/views/error_view.ex17
-rw-r--r--lib/web/views/irc_view.ex3
-rw-r--r--lib/web/views/layout_view.ex81
-rw-r--r--lib/web/views/network_view.ex4
-rw-r--r--lib/web/views/open_id_view.ex4
-rw-r--r--lib/web/views/page_view.ex3
-rw-r--r--lib/web/web.ex99
44 files changed, 2391 insertions, 0 deletions
diff --git a/lib/web/channels/user_socket.ex b/lib/web/channels/user_socket.ex
new file mode 100644
index 0000000..eadd4e0
--- /dev/null
+++ b/lib/web/channels/user_socket.ex
@@ -0,0 +1,37 @@
+defmodule NolaWeb.UserSocket do
+ use Phoenix.Socket
+
+ ## Channels
+ # channel "room:*", NolaWeb.RoomChannel
+
+ ## Transports
+ #transport :websocket, Phoenix.Transports.WebSocket
+ # transport :longpoll, Phoenix.Transports.LongPoll
+
+ # Socket params are passed from the client and can
+ # be used to verify and authenticate a user. After
+ # verification, you can put default assigns into
+ # the socket that will be set for all channels, ie
+ #
+ # {:ok, assign(socket, :user_id, verified_user_id)}
+ #
+ # To deny connection, return `:error`.
+ #
+ # See `Phoenix.Token` documentation for examples in
+ # performing token verification on connect.
+ def connect(_params, socket) do
+ {:ok, socket}
+ end
+
+ # Socket id's are topics that allow you to identify all sockets for a given user:
+ #
+ # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
+ #
+ # Would allow you to broadcast a "disconnect" event and terminate
+ # all active sockets and channels for a given user:
+ #
+ # NolaWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+ #
+ # Returning `nil` makes this socket anonymous.
+ def id(_socket), do: nil
+end
diff --git a/lib/web/components/component.ex b/lib/web/components/component.ex
new file mode 100644
index 0000000..fff8263
--- /dev/null
+++ b/lib/web/components/component.ex
@@ -0,0 +1,44 @@
+defmodule NolaWeb.Component do
+ use Phoenix.Component
+
+ @date_time_default_format "%F %H:%M"
+ @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"}
+ def naive_date_time_utc(assigns = %{at: nil}) do
+ ""
+ end
+
+ def naive_date_time_utc(assigns = %{format: format}) do
+ assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format))
+ ~H"""
+ <time class="component"
+ id={"time-#{:erlang.phash2(@datetime)}"}
+ phx-hook="NaiveDateTimeUTC"
+ data-time-format={get_luxon_format(@format)}
+ datetime={NaiveDateTime.to_iso8601(@datetime)}>
+ <%= Timex.format!(@datetime, @format, :strftime) %>
+ </time>
+ """
+ end
+ def naive_date_time_utc(assigns) do
+ naive_date_time_utc(assign(assigns, :format, "%F %H:%M"))
+ end
+ def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS"
+
+ def nick(assigns = %{self: false}) do
+ ~H"""
+ <span class="nickname" data-account-id={@account_id} data-user-id={@user_id}>
+ <%= @nick %>
+ </span>
+ """
+ end
+
+ def nick(assigns = %{self: true}) do
+ ~H"""
+ <span class="nickname self" data-account-id={@account_id} data-user-id={@user_id}>
+ You
+ </span>
+ """
+ end
+
+
+end
diff --git a/lib/web/components/event_component.ex b/lib/web/components/event_component.ex
new file mode 100644
index 0000000..8af3c67
--- /dev/null
+++ b/lib/web/components/event_component.ex
@@ -0,0 +1,43 @@
+defmodule NolaWeb.EventComponent do
+ use Phoenix.Component
+
+ def content(assigns = %{event: %{type: :day_changed}}) do
+ ~H"""
+ Day changed:
+ <span class="reason"><%= Date.to_string(@date) %></span>
+ """
+ end
+
+ def content(assigns = %{event: %{type: :quit}}) do
+ ~H"""
+ <NolaWeb.Component.nick self={@self} nick={@user.nick} user_id={@user.id} account_id={@user.account} />
+ has quit:
+ <span class="reason"><%= @reason %></span>
+ """
+ end
+
+ def content(assigns = %{event: %{type: :part}}) do
+ ~H"""
+ <NolaWeb.Component.nick self={@self} nick={@user.nick} user_id={@user.id} account_id={@user.account} />
+ has left:
+ <span class="reason"><%= @reason %></span>
+ """
+ end
+
+ def content(assigns = %{event: %{type: :nick}}) do
+ ~H"""
+ <span class="old-nick"><%= @old_nick %></span>
+ is now known as
+ <NolaWeb.Component.nick self={@self} nick={@user.nick} user_id={@user.id} account_id={@user.account} />
+ """
+ end
+
+ def content(assigns = %{event: %{type: :join}}) do
+ ~H"""
+ <NolaWeb.Component.nick self={@self} nick={@user.nick} user_id={@user.id} account_id={@user.account} />
+ joined
+ """
+ end
+
+
+end
diff --git a/lib/web/components/message_component.ex b/lib/web/components/message_component.ex
new file mode 100644
index 0000000..5d0386b
--- /dev/null
+++ b/lib/web/components/message_component.ex
@@ -0,0 +1,12 @@
+defmodule NolaWeb.MessageComponent do
+ use Phoenix.Component
+
+ def content(assigns) do
+ ~H"""
+ <NolaWeb.Component.naive_date_time_utc datetime={@message.at} format="time-24-with-seconds" />
+ <div class="inline-block font-bold flex-none cursor-default"><%= @message.sender.nick %></div>
+ <div class="inline-block flex-grow cursor-default"><%= @text %></div>
+ """
+ end
+
+end
diff --git a/lib/web/context_plug.ex b/lib/web/context_plug.ex
new file mode 100644
index 0000000..ebededa
--- /dev/null
+++ b/lib/web/context_plug.ex
@@ -0,0 +1,92 @@
+defmodule NolaWeb.ContextPlug do
+ import Plug.Conn
+ import Phoenix.Controller
+
+ def init(opts \\ []) do
+ opts || []
+ end
+
+ def get_account(conn) do
+ cond do
+ get_session(conn, :account) -> get_session(conn, :account)
+ get_session(conn, :oidc_id) -> if account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id)), do: account.id
+ true -> nil
+ end
+ end
+
+ def call(conn, opts) do
+ account = with \
+ {:account, account_id} when is_binary(account_id) <- {:account, get_account(conn)},
+ {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)}
+ do
+ account
+ else
+ _ -> nil
+ end
+
+ network = Map.get(conn.params, "network")
+ network = if network == "-", do: nil, else: network
+
+ oidc_account = IRC.Account.find_meta_account("identity-id", get_session(conn, :oidc_id))
+
+ conns = IRC.Connection.get_network(network)
+ chan = if c = Map.get(conn.params, "chan") do
+ NolaWeb.reformat_chan(c)
+ end
+ chan_conn = IRC.Connection.get_network(network, chan)
+
+ memberships = if account do
+ IRC.Membership.of_account(account)
+ end
+
+ auth_required = cond do
+ Keyword.get(opts, :restrict) == :public -> false
+ account == nil -> true
+ network == nil -> false
+ Keyword.get(opts, :restrict) == :logged_in -> false
+ network && chan ->
+ !Enum.member?(memberships, {network, chan})
+ network ->
+ !Enum.any?(memberships, fn({n, _}) -> n == network end)
+ end
+
+ bot = cond do
+ network && chan && chan_conn -> chan_conn.nick
+ network && conns -> conns.nick
+ true -> nil
+ end
+
+
+ cond do
+ account && auth_required ->
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ |> halt()
+ auth_required ->
+ conn
+ |> put_status(403)
+ |> render(NolaWeb.AlcoologView, "auth.html", bot: bot, no_header: true, network: network)
+ |> halt()
+ (network && !conns) ->
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ |> halt()
+ (chan && !chan_conn) ->
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ |> halt()
+ true ->
+ conn = conn
+ |> assign(:network, network)
+ |> assign(:chan, chan)
+ |> assign(:bot, bot)
+ |> assign(:account, account)
+ |> assign(:oidc_account, oidc_account)
+ |> assign(:memberships, memberships)
+ end
+ end
+
+end
diff --git a/lib/web/controllers/alcoolog_controller.ex b/lib/web/controllers/alcoolog_controller.ex
new file mode 100644
index 0000000..3c812c5
--- /dev/null
+++ b/lib/web/controllers/alcoolog_controller.ex
@@ -0,0 +1,323 @@
+defmodule NolaWeb.AlcoologController do
+ use NolaWeb, :controller
+ require Logger
+
+ plug NolaWeb.ContextPlug when action not in [:token]
+ plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token]
+
+ def token(conn, %{"token" => token}) do
+ case Nola.Token.lookup(token) do
+ {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel)
+ err ->
+ Logger.debug("AlcoologControler: token #{inspect err} invalid")
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ end
+ end
+
+ def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ days = String.to_integer(Map.get(params, "days", "180"))
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ if friend? do
+ stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id)
+ history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do
+ %{
+ at: ts |> DateTime.from_unix!(:millisecond),
+ points: points,
+ active: active,
+ cl: cl,
+ deg: deg,
+ type: type,
+ description: descr,
+ meta: meta
+ }
+ end
+ history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt))
+ |> IO.inspect()
+ conn
+ |> assign(:title, "alcoolog #{nick}")
+ |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats)
+ else
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ end
+ end
+
+ def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ if friend? do
+ stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id)
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(stats))
+ else
+ conn
+ |> put_status(404)
+ |> json([])
+ end
+ end
+
+ def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ count = String.to_integer(Map.get(params, "days", "180"))
+ if friend? do
+ data = Nola.Plugins.Alcoolog.user_over_time_gl(profile_account, count)
+ delay = count*((24 * 60)*60)
+ now = DateTime.utc_now()
+ start_date = DateTime.utc_now()
+ |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
+ |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase)
+ |> DateTime.to_date()
+ |> Date.to_erl()
+ filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl))
+ |> Enum.to_list
+ |> Enum.map(&(:calendar.gregorian_days_to_date(&1)))
+ |> Enum.map(&Date.from_erl!(&1))
+ |> Enum.map(fn(date) ->
+ %{date: date, gls: Map.get(data, date, 0)}
+ end)
+ |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt))
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(filled))
+ else
+ conn
+ |> put_status(404)
+ |> json([])
+ end
+ end
+
+
+
+ def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ count = String.to_integer(Map.get(params, "days", "180"))
+ if friend? do
+ data = Nola.Plugins.Alcoolog.user_over_time(profile_account, count)
+ delay = count*((24 * 60)*60)
+ now = DateTime.utc_now()
+ start_date = DateTime.utc_now()
+ |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
+ |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase)
+ |> DateTime.to_date()
+ |> Date.to_erl()
+ filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl))
+ |> Enum.to_list
+ |> Enum.map(&(:calendar.gregorian_days_to_date(&1)))
+ |> Enum.map(&Date.from_erl!(&1))
+ |> Enum.map(fn(date) ->
+ %{date: date, volumes: Map.get(data, date, 0)}
+ end)
+ |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt))
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(filled))
+ else
+ conn
+ |> put_status(404)
+ |> json([])
+ end
+ end
+
+ def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ if friend? do
+ history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do
+ %{
+ at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(),
+ points: points,
+ active: active,
+ cl: cl,
+ deg: deg,
+ type: type,
+ description: descr,
+ meta: meta
+ }
+ end
+ last = List.last(history)
+ {_, active} = Nola.Plugins.Alcoolog.user_stats(profile_account)
+ last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()}
+ history = history ++ [last]
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(history))
+ else
+ conn
+ |> put_status(404)
+ |> json([])
+ end
+ end
+
+ def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do
+ profile_account = IRC.Account.find_always_by_nick(network, nick, nick)
+ friend? = Enum.member?(IRC.Membership.friends(account), profile_account.id)
+ if friend? do
+ history = for {_, date, value} <- Nola.Plugs.AlcoologAnnouncer.log(profile_account) do
+ %{date: DateTime.to_iso8601(date), value: value}
+ end
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(history))
+ else
+ conn
+ |> put_status(404)
+ |> json([])
+ end
+ end
+
+ def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do
+ index(conn, account, network, NolaWeb.reformat_chan(channel))
+ end
+
+ def index(conn = %{assigns: %{account: account}}, _) do
+ index(conn, account, nil, nil)
+ end
+
+ #def index(conn, params) do
+ # network = Map.get(params, "network")
+ # chan = if c = Map.get(params, "chan") do
+ # NolaWeb.reformat_chan(c)
+ # end
+ # irc_conn = if network do
+ # IRC.Connection.get_network(network, chan)
+ # end
+ # bot = if(irc_conn, do: irc_conn.nick)#
+ #
+ # conn
+ # |> put_status(403)
+ # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot)
+ #end
+
+ def index(conn, account, network, channel) do
+ aday = ((24 * 60)*60)
+ now = DateTime.utc_now()
+ before7 = now
+ |> DateTime.add(-(7*aday), :second)
+ |> DateTime.to_unix(:millisecond)
+ before15 = now
+ |> DateTime.add(-(15*aday), :second)
+ |> DateTime.to_unix(:millisecond)
+ before31 = now
+ |> DateTime.add(-(31*aday), :second)
+ |> DateTime.to_unix(:millisecond)
+ #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end)
+ match = [
+ {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_},
+ [
+ {:>, :"$1", {:const, before15}},
+ ], [:"$_"]}
+ ]
+
+ # tuple ets: {{nick, date}, volumes, current, nom, commentaire}
+ members = IRC.Membership.expanded_members_or_friends(account, network, channel)
+ members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end)
+ member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end)
+ drinks = :ets.select(Nola.Plugins.Alcoolog.ETS, match)
+ |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end)
+ |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end)
+ |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2)
+
+ stats = Nola.Plugins.Alcoolog.get_channel_statistics(account, network, channel)
+
+ top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) ->
+ nick = Map.get(member_names, account_id)
+ all = Map.get(acc, nick, 0)
+ Map.put(acc, nick, all + vol)
+ end)
+ |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2)
+ # {date, single_peak}
+ #
+ conn
+ |> assign(:title, "alcoolog")
+ |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats)
+ end
+
+ def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do
+ count = 30
+ channel = NolaWeb.reformat_chan(channel)
+ members = IRC.Membership.expanded_members_or_friends(account, network, channel)
+ members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end)
+ member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end)
+ delay = count*((24 * 60)*60)
+ now = DateTime.utc_now()
+ start_date = DateTime.utc_now()
+ |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
+ |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase)
+ |> DateTime.to_date()
+ |> Date.to_erl()
+ filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl))
+ |> Enum.to_list
+ |> Enum.map(&(:calendar.gregorian_days_to_date(&1)))
+ |> Enum.map(&Date.from_erl!(&1))
+ |> Enum.map(fn(date) ->
+ {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})}
+ end)
+ |> Enum.into(Map.new)
+
+ gls = Enum.reduce(members, filled, fn({account, _, _}, gls) ->
+ Enum.reduce(Nola.Plugins.Alcoolog.user_over_time_gl(account, count), gls, fn({date, gl}, gls) ->
+ u = Map.get(gls, date, %{})
+ |> Map.put(Map.get(member_names, account.id, account.id), gl)
+ Map.put(gls, date, u)
+ end)
+ end)
+
+ dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl))
+ |> Enum.to_list
+ |> Enum.map(&(:calendar.gregorian_days_to_date(&1)))
+ |> Enum.map(&Date.from_erl!(&1))
+
+ filled2 = Enum.map(member_names, fn({_, name}) ->
+ history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl))
+ |> Enum.to_list
+ |> Enum.map(&(:calendar.gregorian_days_to_date(&1)))
+ |> Enum.map(&Date.from_erl!(&1))
+ |> Enum.map(fn(date) ->
+ get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])}
+ end)
+ if Enum.all?(history, fn(x) -> x == 0 end) do
+ nil
+ else
+ %{name: name, history: history}
+ end
+ end)
+ |> Enum.filter(fn(x) -> x end)
+
+ conn
+ |> put_resp_content_type("application/json")
+ |> text(Jason.encode!(%{labels: dates, data: filled2}))
+ end
+
+ def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do
+ account = IRC.Account.get(user_id)
+ if account do
+ ds = Nola.Plugins.Alcoolog.data_state()
+ meta = Nola.Plugins.Alcoolog.get_user_meta(ds, account.id)
+ case Float.parse(value) do
+ {val, _} ->
+ new_meta = Map.put(meta, String.to_existing_atom(key), val)
+ Nola.Plugins.Alcoolog.put_user_meta(ds, account.id, new_meta)
+ _ ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> text("invalid value")
+ end
+ else
+ conn
+ |> put_status(:not_found)
+ |> text("not found")
+ end
+ end
+
+end
diff --git a/lib/web/controllers/gpt_controller.ex b/lib/web/controllers/gpt_controller.ex
new file mode 100644
index 0000000..810a875
--- /dev/null
+++ b/lib/web/controllers/gpt_controller.ex
@@ -0,0 +1,33 @@
+defmodule NolaWeb.GptController do
+ use NolaWeb, :controller
+ require Logger
+
+ plug NolaWeb.ContextPlug
+
+ def result(conn, params = %{"id" => result_id}) do
+ case Nola.Plugins.Gpt.get_result(result_id) do
+ {:ok, result} ->
+ network = Map.get(params, "network")
+ channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c)
+ render(conn, "result.html", network: network, channel: channel, result: result)
+ {:error, :not_found} ->
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ end
+ end
+
+ def prompt(conn, params = %{"id" => prompt_id}) do
+ case Nola.Plugins.Gpt.get_prompt(prompt_id) do
+ {:ok, prompt} ->
+ network = Map.get(params, "network")
+ channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c)
+ render(conn, "prompt.html", network: network, channel: channel, prompt: prompt)
+ {:error, :not_found} ->
+ conn
+ |> put_status(404)
+ |> text("Page not found")
+ end
+ end
+
+end
diff --git a/lib/web/controllers/icecast_see_controller.ex b/lib/web/controllers/icecast_see_controller.ex
new file mode 100644
index 0000000..877ad4e
--- /dev/null
+++ b/lib/web/controllers/icecast_see_controller.ex
@@ -0,0 +1,41 @@
+defmodule NolaWeb.IcecastSseController do
+ use NolaWeb, :controller
+ require Logger
+
+ @ping_interval 20_000
+
+ def sse(conn, _params) do
+ conn
+ |> put_resp_header("X-Accel-Buffering", "no")
+ |> put_resp_header("content-type", "text/event-stream")
+ |> send_chunked(200)
+ |> subscribe
+ |> send_sse_message("ping", "ping")
+ |> send_sse_message("icecast", Nola.IcecastAgent.get)
+ |> sse_loop
+ end
+
+ def subscribe(conn) do
+ :timer.send_interval(@ping_interval, {:event, :ping})
+ {:ok, _} = Registry.register(Nola.BroadcastRegistry, "icecast", [])
+ conn
+ end
+
+ def sse_loop(conn) do
+ {type, event} = receive do
+ {:event, :ping} -> {"ping", "ping"}
+ {:icecast, stats} -> {"icecast", stats}
+ end
+
+ conn
+ |> send_sse_message(type, event)
+ |> sse_loop()
+ end
+
+ defp send_sse_message(conn, type, data) do
+ json = Jason.encode!(%{type => data})
+ {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{json}\n\n")
+ conn
+ end
+
+end
diff --git a/lib/web/controllers/irc_auth_sse_controller.ex b/lib/web/controllers/irc_auth_sse_controller.ex
new file mode 100644
index 0000000..62ee2b5
--- /dev/null
+++ b/lib/web/controllers/irc_auth_sse_controller.ex
@@ -0,0 +1,66 @@
+defmodule NolaWeb.IrcAuthSseController do
+ use NolaWeb, :controller
+ require Logger
+
+ @ping_interval 20_000
+ @expire_delay :timer.minutes(3)
+
+ def sse(conn, params) do
+ perks = if uri = Map.get(params, "redirect_to") do
+ {:redirect, uri}
+ else
+ nil
+ end
+ token = String.downcase(EntropyString.random_string(65))
+ conn
+ |> assign(:token, token)
+ |> assign(:perks, perks)
+ |> put_resp_header("X-Accel-Buffering", "no")
+ |> put_resp_header("content-type", "text/event-stream")
+ |> send_chunked(200)
+ |> subscribe()
+ |> send_sse_message("token", token)
+ |> sse_loop
+ end
+
+ def subscribe(conn) do
+ :timer.send_interval(@ping_interval, {:event, :ping})
+ :timer.send_after(@expire_delay, {:event, :expire})
+ {:ok, _} = Registry.register(IRC.PubSub, "messages:private", [])
+ conn
+ end
+
+ def sse_loop(conn) do
+ {type, event, exit} = receive do
+ {:event, :ping} -> {"ping", "ping", false}
+ {:event, :expire} -> {"expire", "expire", true}
+ {:irc, :text, %{account: account, text: token} = m} ->
+ if String.downcase(String.trim(token)) == conn.assigns.token do
+ path = Nola.AuthToken.new_path(account.id, conn.assigns.perks)
+ m.replyfun.("ok!")
+ {"authenticated", path, true}
+ else
+ {nil, nil, false}
+ end
+ _ -> {nil, nil, false}
+ end
+
+ conn = if type do
+ send_sse_message(conn, type, event)
+ else
+ conn
+ end
+
+ if exit do
+ conn
+ else
+ sse_loop(conn)
+ end
+ end
+
+ defp send_sse_message(conn, type, data) do
+ {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n")
+ conn
+ end
+
+end
diff --git a/lib/web/controllers/irc_controller.ex b/lib/web/controllers/irc_controller.ex
new file mode 100644
index 0000000..d6114e6
--- /dev/null
+++ b/lib/web/controllers/irc_controller.ex
@@ -0,0 +1,101 @@
+defmodule NolaWeb.IrcController do
+ use NolaWeb, :controller
+
+ plug NolaWeb.ContextPlug
+
+ def index(conn, params) do
+ network = Map.get(params, "network")
+ channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c)
+ commands = for mod <- Enum.uniq([Nola.Plugins.Account] ++ Nola.Plugins.enabled()) do
+ if is_atom(mod) do
+ identifier = Module.split(mod) |> List.last |> Macro.underscore
+ {identifier, mod.irc_doc()}
+ end
+ end
+ |> Enum.filter(& &1)
+ |> Enum.filter(fn({_, doc}) -> doc end)
+ members = cond do
+ network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end)
+ true ->
+ IRC.Membership.of_account(conn.assigns.account)
+ end
+ render conn, "index.html", network: network, commands: commands, channel: channel, members: members
+ end
+
+ def txt(conn, %{"name" => name}) do
+ if String.contains?(name, ".txt") do
+ name = String.replace(name, ".txt", "")
+ data = data()
+ if Map.has_key?(data, name) do
+ lines = Enum.join(data[name], "\n")
+ text(conn, lines)
+ else
+ conn
+ |> put_status(404)
+ |> text("Not found")
+ end
+ else
+ do_txt(conn, name)
+ end
+ end
+ def txt(conn, _), do: do_txt(conn, nil)
+
+
+ defp do_txt(conn, nil) do
+ doc = Nola.IRC.Txt.irc_doc()
+ data = data()
+ main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new)
+ system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new)
+ lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end)
+ conn
+ |> assign(:title, "txt")
+ |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system)
+ end
+
+ defp do_txt(conn, txt) do
+ data = data()
+ base_url = cond do
+ conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}"
+ true -> "/-"
+ end
+ if lines = Map.get(data, txt) do
+ lines = Enum.map(lines, fn(line) ->
+ line
+ |> String.split("\\\\")
+ |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br))
+ end)
+ conn
+ |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}])
+ |> assign(:title, "#{txt}.txt")
+ |> render("txt.html", name: txt, data: lines, doc: nil)
+ else
+ conn
+ |> put_status(404)
+ |> text("Not found")
+ end
+ end
+
+ defp data() do
+ dir = Application.get_env(:nola, :data_path) <> "/irc.txt/"
+ Path.wildcard(dir <> "/*.txt")
+ |> Enum.reduce(%{}, fn(path, m) ->
+ path = String.split(path, "/")
+ file = List.last(path)
+ key = String.replace(file, ".txt", "")
+ data = dir <> file
+ |> File.read!
+ |> String.split("\n")
+ |> Enum.reject(fn(line) ->
+ cond do
+ line == "" -> true
+ !line -> true
+ true -> false
+ end
+ end)
+ Map.put(m, key, data)
+ end)
+ |> Enum.sort
+ |> Enum.into(Map.new)
+ end
+
+end
diff --git a/lib/web/controllers/network_controller.ex b/lib/web/controllers/network_controller.ex
new file mode 100644
index 0000000..800294f
--- /dev/null
+++ b/lib/web/controllers/network_controller.ex
@@ -0,0 +1,11 @@
+defmodule NolaWeb.NetworkController do
+ use NolaWeb, :controller
+ plug NolaWeb.ContextPlug
+
+ def index(conn, %{"network" => network}) do
+ conn
+ |> assign(:title, network)
+ |> render("index.html")
+ end
+
+end
diff --git a/lib/web/controllers/open_id_controller.ex b/lib/web/controllers/open_id_controller.ex
new file mode 100644
index 0000000..d3fef5d
--- /dev/null
+++ b/lib/web/controllers/open_id_controller.ex
@@ -0,0 +1,64 @@
+defmodule NolaWeb.OpenIdController do
+ use NolaWeb, :controller
+ plug NolaWeb.ContextPlug, restrict: :public
+ require Logger
+
+ def login(conn, _) do
+ url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false))
+ redirect(conn, external: url)
+ end
+
+ def callback(conn, %{"error" => error_code, "error_description" => error}) do
+ Logger.warn("OpenId error: #{error_code} #{error}")
+ render(conn, "error.html", error: error)
+ end
+
+ def callback(conn, %{"code" => code, "state" => state}) do
+ with \
+ client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code),
+ {:ok, %{"access_token" => token}} <- Jason.decode(json),
+ client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}},
+ {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"),
+ {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body)
+ do
+ if account = conn.assigns.account do
+ if !IRC.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet
+ IRC.Account.put_meta(account, "identity-id", id)
+ end
+ IRC.Account.put_meta(account, "identity-username", username)
+ conn
+ else
+ conn
+ end
+
+ conn
+ |> put_session(:oidc_id, id)
+ |> put_flash(:info, "Logged in!")
+ |> redirect(to: Routes.path(conn, "/"))
+ else
+ {:error, %OAuth2.Response{status_code: 401}} ->
+ Logger.error("OpenID: Unauthorized token")
+ render(conn, "error.html", error: "The token is invalid.")
+ {:error, %OAuth2.Error{reason: reason}} ->
+ Logger.error("Error: #{inspect reason}")
+ render(conn, "error.html", error: reason)
+ end
+ end
+
+ def callback(conn, _params) do
+ render(conn, "error.html", error: "Unspecified error.")
+ end
+
+ defp new_client() do
+ config = Application.get_env(:nola, :oidc)
+ OAuth2.Client.new([
+ strategy: OAuth2.Strategy.AuthCode,
+ client_id: config[:client_id],
+ client_secret: config[:client_secret],
+ site: config[:base_url],
+ authorize_url: config[:authorize_url],
+ token_url: config[:token_url],
+ redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback)
+ ])
+ end
+end
diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex
new file mode 100644
index 0000000..2ac4d0a
--- /dev/null
+++ b/lib/web/controllers/page_controller.ex
@@ -0,0 +1,53 @@
+defmodule NolaWeb.PageController do
+ use NolaWeb, :controller
+
+ plug NolaWeb.ContextPlug when action not in [:token]
+ plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token]
+
+ def token(conn, %{"token" => token}) do
+ with \
+ {:ok, account, perks} <- Nola.AuthToken.lookup(token)
+ do
+ IO.puts("Authenticated account #{inspect account}")
+ conn = put_session(conn, :account, account)
+ case perks do
+ nil -> redirect(conn, to: "/")
+ {:redirect, path} -> redirect(conn, to: path)
+ {:external_redirect, url} -> redirect(conn, external: url)
+ end
+ else
+ z ->
+ IO.inspect(z)
+ text(conn, "Error: invalid or expired token")
+ end
+ end
+
+ def index(conn = %{assigns: %{account: account}}, _) do
+ memberships = IRC.Membership.of_account(account)
+ users = IRC.UserTrack.find_by_account(account)
+ metas = IRC.Account.get_all_meta(account)
+ predicates = IRC.Account.get_predicates(account)
+ conn
+ |> assign(:title, account.name)
+ |> render("user.html", users: users, memberships: memberships, metas: metas, predicates: predicates)
+ end
+
+ def irc(conn, _) do
+ bot_helps = for mod <- Nola.IRC.env(:handlers) do
+ mod.irc_doc()
+ end
+ render conn, "irc.html", bot_helps: bot_helps
+ end
+
+ def authenticate(conn, _) do
+ with \
+ {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)},
+ {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)}
+ do
+ assign(conn, :account, account)
+ else
+ _ -> conn
+ end
+ end
+
+end
diff --git a/lib/web/controllers/sms_controller.ex b/lib/web/controllers/sms_controller.ex
new file mode 100644
index 0000000..0fffa23
--- /dev/null
+++ b/lib/web/controllers/sms_controller.ex
@@ -0,0 +1,10 @@
+defmodule NolaWeb.SmsController do
+ use NolaWeb, :controller
+ require Logger
+
+ def ovh_callback(conn, %{"senderid" => from, "message" => message}) do
+ spawn(fn() -> Nola.Plugins.Sms.incoming(from, String.trim(message)) end)
+ text(conn, "")
+ end
+
+end
diff --git a/lib/web/controllers/untappd_controller.ex b/lib/web/controllers/untappd_controller.ex
new file mode 100644
index 0000000..d3a540d
--- /dev/null
+++ b/lib/web/controllers/untappd_controller.ex
@@ -0,0 +1,18 @@
+defmodule NolaWeb.UntappdController do
+ use NolaWeb, :controller
+
+ def callback(conn, %{"code" => code}) do
+ with \
+ {:account, account_id} when is_binary(account_id) <- {:account, get_session(conn, :account)},
+ {:account, account} when not is_nil(account) <- {:account, IRC.Account.get(account_id)},
+ {:ok, auth_token} <- Untappd.auth_callback(code)
+ do
+ IRC.Account.put_meta(account, "untappd-token", auth_token)
+ text(conn, "OK!")
+ else
+ {:account, _} -> text(conn, "Error: account not found")
+ :error -> text(conn, "Error: untappd authentication failed")
+ end
+ end
+
+end
diff --git a/lib/web/endpoint.ex b/lib/web/endpoint.ex
new file mode 100644
index 0000000..a401f54
--- /dev/null
+++ b/lib/web/endpoint.ex
@@ -0,0 +1,62 @@
+defmodule NolaWeb.Endpoint do
+ use Sentry.PlugCapture
+ use Phoenix.Endpoint, otp_app: :nola
+
+ # Serve at "/" the static files from "priv/static" directory.
+ #
+ # You should set gzip to true if you are running phoenix.digest
+ # when deploying your static files in production.
+ plug Plug.Static,
+ at: "/", from: :nola, gzip: false,
+ only: ~w(assets css js fonts images favicon.ico robots.txt)
+
+ # Code reloading can be explicitly enabled under the
+ # :code_reloader configuration of your endpoint.
+ if 42==43 && code_reloading? do
+ socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+ plug Phoenix.LiveReloader
+ plug Phoenix.CodeReloader
+ end
+
+ plug Plug.RequestId
+ plug Plug.Logger
+
+ plug Plug.Parsers,
+ parsers: [:urlencoded, :multipart, :json],
+ pass: ["*/*"],
+ json_decoder: Jason
+
+ plug Sentry.PlugContext
+ plug Plug.MethodOverride
+ plug Plug.Head
+
+ @session_options [store: :cookie,
+ key: "_nola_key",
+ signing_salt: "+p7K3wrj"]
+
+
+ socket "/live", Phoenix.LiveView.Socket,
+ websocket: [connect_info: [session: @session_options]]
+
+ # The session will be stored in the cookie and signed,
+ # this means its contents can be read but not tampered with.
+ # Set :encryption_salt if you would also like to encrypt it.
+ plug Plug.Session, @session_options
+
+ plug NolaWeb.Router
+
+ @doc """
+ Callback invoked for dynamically configuring the endpoint.
+
+ It receives the endpoint configuration and checks if
+ configuration should be loaded from the system environment.
+ """
+ def init(_key, config) do
+ if config[:load_from_system_env] do
+ port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
+ {:ok, Keyword.put(config, :http, [:inet6, port: port])}
+ else
+ {:ok, config}
+ end
+ end
+end
diff --git a/lib/web/gettext.ex b/lib/web/gettext.ex
new file mode 100644
index 0000000..a43cb0d
--- /dev/null
+++ b/lib/web/gettext.ex
@@ -0,0 +1,24 @@
+defmodule NolaWeb.Gettext do
+ @moduledoc """
+ A module providing Internationalization with a gettext-based API.
+
+ By using [Gettext](https://hexdocs.pm/gettext),
+ your module gains a set of macros for translations, for example:
+
+ import NolaWeb.Gettext
+
+ # Simple translation
+ gettext "Here is the string to translate"
+
+ # Plural translation
+ ngettext "Here is the string to translate",
+ "Here are the strings to translate",
+ 3
+
+ # Domain-based translation
+ dgettext "errors", "Here is the error message to translate"
+
+ See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
+ """
+ use Gettext, otp_app: :nola
+end
diff --git a/lib/web/live/chat_live.ex b/lib/web/live/chat_live.ex
new file mode 100644
index 0000000..678ea36
--- /dev/null
+++ b/lib/web/live/chat_live.ex
@@ -0,0 +1,120 @@
+defmodule NolaWeb.ChatLive do
+ use Phoenix.LiveView
+ use Phoenix.HTML
+ require Logger
+
+ def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do
+ chan = NolaWeb.reformat_chan(chan)
+ connection = IRC.Connection.get_network(network, chan)
+ account = IRC.Account.get(account_id)
+ membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb"))
+ if account && connection && Enum.member?(membership, {connection.network, chan}) do
+ {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__)
+ for t <- ["messages", "triggers", "outputs", "events"] do
+ {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__)
+ end
+
+ IRC.PuppetConnection.start(account, connection)
+
+ users = IRC.UserTrack.channel(connection.network, chan)
+ |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end)
+ |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) ->
+ Map.put(acc, id, user)
+ end)
+
+ backlog = case Nola.IRC.Buffer.select_buffer(connection.network, chan) do
+ {backlog, _} ->
+ {backlog, _} = Enum.reduce(backlog, {backlog, nil}, &reduce_contextual_event/2)
+ Enum.reverse(backlog)
+ _ -> []
+ end
+
+ socket = socket
+ |> assign(:connection_id, connection.id)
+ |> assign(:network, connection.network)
+ |> assign(:chan, chan)
+ |> assign(:title, "live")
+ |> assign(:channel, chan)
+ |> assign(:account_id, account.id)
+ |> assign(:backlog, backlog)
+ |> assign(:users, users)
+ |> assign(:counter, 0)
+
+ {:ok, socket}
+ else
+ {:ok, redirect(socket, to: "/")}
+ end
+ end
+
+ def handle_event("send", %{"message" => %{"text" => text}}, socket) do
+ account = IRC.Account.get(socket.assigns.account_id)
+ IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true)
+ {:noreply, assign(socket, :counter, socket.assigns.counter + 1)}
+ end
+
+ def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do
+ if user = IRC.UserTrack.lookup(id) do
+ socket = socket
+ |> assign(:users, Map.put(socket.assigns.users, id, user))
+ |> append_to_backlog(event)
+ {:noreply, socket}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do
+ socket = socket
+ |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick))
+ |> append_to_backlog(event)
+ {:noreply, socket}
+ end
+
+ def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do
+ socket = socket
+ |> assign(:users, Map.delete(socket.assigns.users, id))
+ |> append_to_backlog(event)
+ {:noreply, socket}
+ end
+
+ def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do
+ socket = socket
+ |> assign(:users, Map.delete(socket.assigns.users, id))
+ |> append_to_backlog(event)
+ {:noreply, socket}
+ end
+
+ def handle_info({:irc, :trigger, _, message}, socket) do
+ handle_info({:irc, nil, message}, socket)
+ end
+
+ def handle_info({:irc, :text, message}, socket) do
+ IO.inspect({:live_message, message})
+ socket = socket
+ |> append_to_backlog(message)
+ {:noreply, socket}
+ end
+
+ def handle_info(info, socket) do
+ Logger.debug("Unhandled info: #{inspect info}")
+ {:noreply, socket}
+ end
+
+ defp append_to_backlog(socket, line) do
+ {add, _} = reduce_contextual_event(line, {[], List.last(socket.assigns.backlog)})
+ assign(socket, :backlog, socket.assigns.backlog ++ add)
+ end
+
+ defp reduce_contextual_event(line, {acc, nil}) do
+ {[line | acc], line}
+ end
+ defp reduce_contextual_event(line, {acc, last}) do
+ if NaiveDateTime.to_date(last.at) != NaiveDateTime.to_date(line.at) do
+ {[%{type: :day_changed, date: NaiveDateTime.to_date(line.at), at: nil}, line | acc], line}
+ else
+ {[line | acc], line}
+ end
+
+ end
+
+end
diff --git a/lib/web/live/chat_live.html.heex b/lib/web/live/chat_live.html.heex
new file mode 100644
index 0000000..29cd6a1
--- /dev/null
+++ b/lib/web/live/chat_live.html.heex
@@ -0,0 +1,91 @@
+<div class="chat" data-turbo="false">
+
+ <div class="py-4 px-4 bg-gradient-to-b from-black to-gray-900">
+ <div class="grid grid-cols-2">
+ <h1 class="text-gray-50 tracking-tight font-extrabold text-xl">
+ <%= @network %>
+ <span class="font-bold"><%= @chan %></span>
+ </h1>
+ <div class="text-right">
+ <a href="/" class="text-gray-400"><%= @account_id %></a>
+ </div>
+ </div>
+ </div>
+
+ <div class="body">
+
+ <div class="log">
+ <p class="disconnected text-center text-6xl tracking-tight font-extrabold text-red-800 w-full my-24 mx-auto overflow-y-auto">
+ Disconnected <span class="text-mono">:'(</span>
+ </p>
+ <p class="phx-errored text-center text-6xl tracking-tight font-extrabold text-red-800 w-full my-24 mx-auto overflow-y-auto">
+ Oh no error <span class="text-mono">>:(</span>
+ </p>
+
+ <ul class="pt-4 pl-4">
+ <%= for message <- @backlog do %>
+ <%= if is_map(message) && Map.get(message, :__struct__) == IRC.Message do %>
+ <li class="flex gap-2 place-items-center message"
+ data-account-id={message.account.id}>
+ <NolaWeb.MessageComponent.content
+ message={message}
+ self={message.account.id == @account_id}
+ text={message.text}
+ />
+ </li>
+ <% end %>
+
+ <%= if is_binary(message) do %>
+ <li class="notice"><%= message %></li>
+ <% end %>
+
+ <%= if is_map(message) && Map.get(message, :type) do %>
+ <li class="flex gap-2 place-items-center event">
+ <NolaWeb.Component.naive_date_time_utc datetime={message.at} format="time-24-with-seconds" />
+ <span class="inline-block font-bold flex-none cursor-default text-gray-700">*&nbsp;*&nbsp;*</span>
+ <span class="inline-block flex-grow cursor-default text-gray-700">
+ <NolaWeb.EventComponent.content event={message}
+ self={@users[message.user_id] && @users[message.user_id].account == @account_id}
+ user={@users[message.user_id]}
+ />
+ </span>
+ </li>
+ <% end %>
+ <% end %>
+ </ul>
+ </div>
+
+ <aside>
+ <%= for {_, user} <- @users do %>
+ <details class="user dropdown">
+ <summary><%= user.nick %></summary>
+ <div class="content">
+ <h3 class="text-xl font-bold"><%= user.nick %></h3>
+
+ <ul class="mt-4 space-y-2">
+ <li class="">User: <span class="font-bold"><%= user.username %></span></li>
+ <li class="">Name: <%= user.realname || user.nick %></li>
+ <li class="">Host: <span class="font-mono"><%= user.host %></span></li>
+ </ul>
+
+ <div class="mt-4 font-xs text-gray-300 text-center">
+ UID: <%= user.id %>
+ <br />
+ AID: <%= user.account %>
+ </div>
+ </div>
+ </details>
+ <% end %>
+ </aside>
+
+ </div>
+
+ <.form let={f} id={"form-#{@counter}"} for={:message} phx-submit="send" class="w-full px-4 pt-4">
+ <div>
+ <div class="mt-1 flex rounded-md shadow-sm border border-gray-300">
+ <%= text_input f, :text, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full border rounded-md pl-4 sm:text-sm border-gray-300", autofocus: true, 'phx-hook': "AutoFocus", autocomplete: "off", placeholder: "Don't be shy, say something…" %>
+ <%= submit content_tag(:span, "Send"), class: "-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"%>
+ </div>
+ </div>
+ </.form>
+</div>
diff --git a/lib/web/router.ex b/lib/web/router.ex
new file mode 100644
index 0000000..5658fda
--- /dev/null
+++ b/lib/web/router.ex
@@ -0,0 +1,85 @@
+defmodule NolaWeb.Router do
+ use NolaWeb, :router
+
+ pipeline :browser do
+ plug :accepts, ["html", "txt"]
+ plug :fetch_session
+ plug :fetch_flash
+ plug :fetch_live_flash
+ plug :protect_from_forgery
+ plug :put_secure_browser_headers
+ plug :put_root_layout, {NolaWeb.LayoutView, :root}
+ end
+
+ pipeline :api do
+ plug :accepts, ["json", "sse"]
+ end
+
+ pipeline :matrix_app_service do
+ plug :accepts, ["json"]
+ plug Nola.Matrix.Plug.Auth
+ plug Nola.Matrix.Plug.SetConfig
+ end
+
+ scope "/api", NolaWeb do
+ pipe_through :api
+ get "/irc-auth.sse", IrcAuthSseController, :sse
+ post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms
+ end
+
+ scope "/", NolaWeb do
+ pipe_through :browser
+ get "/", PageController, :index
+
+ get "/login/irc/:token", PageController, :token, as: :login
+ get "/login/oidc", OpenIdController, :login
+ get "/login/oidc/callback", OpenIdController, :callback
+
+ get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback
+
+ get "/-", IrcController, :index
+ get "/-/txt", IrcController, :txt
+ get "/-/txt/:name", IrcController, :txt
+ get "/-/gpt/prompt/:id", GptController, :task
+ get "/-/gpt/result/:id", GptController, :result
+
+ get "/-/alcoolog", AlcoologController, :index
+ get "/-/alcoolog/~/:account_name", AlcoologController, :index
+ get "/:network", NetworkController, :index
+ get "/:network/~:nick/alcoolog", AlcoologController, :nick
+ get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json
+ get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json
+ get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json
+ get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json
+ get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json
+ get "/:network/:chan/alcoolog", AlcoologController, :index
+ get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json
+ get "/:network/:chan/gpt/prompt/:id", GptController, :task
+ get "/:network/:chan/gpt/result/:id", GptController, :result
+ put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta
+
+ get "/:network/:chan", IrcController, :index
+ live "/:network/:chan/live", ChatLive
+ get "/:network/:chan/txt", IrcController, :txt
+ get "/:network/:chan/txt/:name", IrcController, :txt
+ get "/:network/:channel/preums", IrcController, :preums
+ get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token
+ end
+
+ scope "/_matrix/:appservice", MatrixAppServiceWeb.V1, as: :matrix do
+ pipe_through :matrix_app_service
+
+ put "/transactions/:txn_id", TransactionController, :push
+
+ get "/users/:user_id", UserController, :query
+ get "/rooms/*room_alias", RoomController, :query
+
+ get "/thirdparty/protocol/:protocol", ThirdPartyController, :query_protocol
+ get "/thirdparty/user/:protocol", ThirdPartyController, :query_users
+ get "/thirdparty/location/:protocol", ThirdPartyController, :query_locations
+ get "/thirdparty/location", ThirdPartyController, :query_location_by_alias
+ get "/thirdparty/user", ThirdPartyController, :query_user_by_id
+ end
+
+
+end
diff --git a/lib/web/templates/alcoolog/auth.html.eex b/lib/web/templates/alcoolog/auth.html.eex
new file mode 100644
index 0000000..6e5cedc
--- /dev/null
+++ b/lib/web/templates/alcoolog/auth.html.eex
@@ -0,0 +1,43 @@
+<div class="grid grid-cols-2">
+ <h1 class="text-2xl font-bold">authentication</h1>
+ <div class="text-right">
+ <%= link("connect using random.sh", to: "/login/oidc", class: "inline-block font-medium underline") %>
+ </div>
+</div>
+
+<div id="authenticator" class="mt-12 h-32 place-self-end justify-self-start">
+ <p>
+ <%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %><br /><br />
+ <strong>/msg <%= @bot || "the-bot-nickname" %> web</strong>
+ <br /><br />
+ ... then come back to this address.
+ </p>
+</div>
+
+<script type="text/javascript">
+ var authSse = new EventSource("/api/irc-auth.sse?redirect_to=" + window.location.pathname);
+ authSse.addEventListener("token", function(event) {
+ html = "<%= if @bot, do: "Send this to #{@bot} on #{@network}:", else: "Find your bot nickname and send:" %><br /><br /><strong class='text-xl font-mono ml-12'>/msg <%= @bot || "<the-bot-nickname>" %> "+event.data+"</strong>";
+ elem = document.getElementById("authenticator");
+ elem.innerHTML = html;
+ });
+ authSse.addEventListener("authenticated", function(event) {
+ elem = document.getElementById("authenticator");
+ elem.innerHTML = "<strong>success, redirecting...</strong>";
+ window.keepInner = true;
+ window.location.pathname = event.data;
+ });
+ authSse.addEventListener("expire", function(event) {
+ elem = document.getElementById("authenticator");
+ elem.innerHTML = "<strong>authentication expired</strong>";
+ window.keepInner = true;
+ });
+ authSse.onerror = function() {
+ authSse.close();
+ if (!window.keepInner) {
+ elem = document.getElementById("authenticator");
+ elem.innerHTML = "<strong>server closed connection :(</strong>";
+ }
+ };
+
+</script>
diff --git a/lib/web/templates/alcoolog/index.html.eex b/lib/web/templates/alcoolog/index.html.eex
new file mode 100644
index 0000000..5a5423a
--- /dev/null
+++ b/lib/web/templates/alcoolog/index.html.eex
@@ -0,0 +1,205 @@
+<style type="text/css">
+ol li {
+ margin-bottom: 5px
+}
+</style>
+
+<%= if @stats == [] do %>
+ <div class="rounded-md bg-red-50 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm leading-5 font-medium text-red-800">
+ CATASTROPHE! Personne n'a bu!!!!
+ </h3>
+ </div>
+ </div>
+ </div>
+<% end %>
+
+<ul class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
+ <%= for {nick, status} <- @stats do %>
+ <li class="col-span-1 bg-white rounded-lg shadow">
+ <div class="w-full flex items-center justify-between p-6 space-x-6">
+ <div class="flex-1 truncate">
+ <div class="flex items-center space-x-3">
+ <h3 class="text-gray-900 text-base leading-5 font-semibold truncate"><%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %></h3>
+ <% rising_class = if status.rising, do: "teal", else: "red" %>
+ <span class="flex-shrink-0 inline-block px-2 py-0.5 text-<%= rising_class %>-800 text-sm leading-4 font-medium bg-<%= rising_class %>-100 rounded-full">
+ <%= status.trend_symbol %> <%= Float.round(status.active, 4) %> g/l
+ </span>
+ </div>
+ <p class="mt-1 text-gray-700 text-sm leading-5 truncate">
+ <span class="text-base"><%= status.last_cl %>cl @ <%= status.last_deg %>°</span>
+ <%= if status.last_descr && status.last_descr != "" do %>
+ <br /><%= status.last_descr %>
+ <% end %>
+ <br/><small><%= NolaWeb.LayoutView.format_time(status.last_at) %></small>
+ </p>
+
+ <p class="mt-1 text-gray-500 text-sm leading-5 truncate">
+ <br />
+ &mdash; sobre dans: <%= status.sober_in_s %><br />
+ <%= if status.since do %>
+ &mdash; depuis: <%= status.since_s %><br />
+ <% end %>
+ <small>
+ &mdash; 15m: <%= status.active15m %> g/l - 30m: <%= status.active30m %> g/l - 1h: <%= status.active1h %> g/l<br />
+ &mdash; aujourd'hui: <%= status.daily_volumes %> points, <%= status.daily_gl %> g/l
+ </small>
+ </p>
+
+
+ </div>
+ </div>
+ </li>
+ <% end %>
+</ul>
+
+<%= if @stats == %{} do %>
+ <div class="rounded-md bg-red-50 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+ </svg>
+ </div>
+ <div class="ml-3">
+ <h3 class="text-sm leading-5 font-medium text-red-800">
+ ENCORE PIRE! Aucune boisson enregistrée!
+ </h3>
+ </div>
+ </div>
+ </div>
+<% else %>
+
+<canvas id="myChart" class="w-full" height="200"></canvas>
+
+ <h2 class="leading-8 m-4 font-semibold text-2xl">Classement <span class="font-medium text-gray-600 text-lg">15 jours</span></h2>
+
+<ul class="grid grid-cols-1 gap-6 sm:grid-cols-5 lg:grid-cols-5">
+ <%= for {{nick, count}, rank} <- Enum.with_index(@top) do %>
+ <% rank = rank + 1 %>
+ <% trophy = rank <= 3 %>
+ <% {colour, text} = case rank do
+1 -> {"yellow-500", "font-semibold text-base"}
+2 -> {"gray-500", "font-medium text-base"}
+3 -> {"orange-300", "font-medium text-base"}
+_ -> {"gray-300", ""}
+ end %>
+ <li class="col-span-1 bg-white rounded-lg shadow text-center <%= text %>">
+ <%= if trophy do %>
+ <span class="text-<%= colour %>">
+ <svg style="width: 1.125em; height: 1.5em; display: inline-block;" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="trophy" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512" data-fa-i2svg=""><path fill="currentColor" d="M552 64H448V24c0-13.3-10.7-24-24-24H152c-13.3 0-24 10.7-24 24v40H24C10.7 64 0 74.7 0 88v56c0 35.7 22.5 72.4 61.9 100.7 31.5 22.7 69.8 37.1 110 41.7C203.3 338.5 240 360 240 360v72h-48c-35.3 0-64 20.7-64 56v12c0 6.6 5.4 12 12 12h296c6.6 0 12-5.4 12-12v-12c0-35.3-28.7-56-64-56h-48v-72s36.7-21.5 68.1-73.6c40.3-4.6 78.6-19 110-41.7 39.3-28.3 61.9-65 61.9-100.7V88c0-13.3-10.7-24-24-24zM99.3 192.8C74.9 175.2 64 155.6 64 144v-16h64.2c1 32.6 5.8 61.2 12.8 86.2-15.1-5.2-29.2-12.4-41.7-21.4zM512 144c0 16.1-17.7 36.1-35.3 48.8-12.5 9-26.7 16.2-41.8 21.4 7-25 11.8-53.6 12.8-86.2H512v16z"></path></svg>
+ </span>
+ <% end %>
+ #<%= rank %>: <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %>
+ <br />
+ <span class="text-xs"><%= Float.round(count, 4) %></span>
+ </li>
+ <% end %>
+</ul>
+
+ <h2 class="leading-8 m-4 font-semibold text-2xl">Historique</h2>
+<div class="flex flex-col">
+ <div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
+ <div class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
+ <table class="min-w-full divide-y divide-gray-200">
+ <thead>
+ <tr>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ date
+ </th>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ nick
+ </th>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ &nbsp;
+ </th>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ &nbsp;
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <%= for {{{{account, date}, points, _active, cl, deg, nom, comment, _meta}, nick}, index} <- Enum.with_index(@drinks) do %>
+ <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %>
+ <% date = DateTime.from_unix!(date, :millisecond) %>
+ <tr class="<%= class %>">
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-900">
+ <%= NolaWeb.LayoutView.format_time(date, false) %>
+ </td>
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-900">
+ <%= link nick, to: alcoolog_path(@conn, :nick, @network, nick) %>
+ </td>
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500"><%= cl %>cl <%= deg %>°</td>
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500"><%= comment||"" %></td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+<% end %>
+
+<%= if @conn.assigns.account && (@network || @channel) do %>
+ <%= link("alcoolog global", to: alcoolog_path(@conn, :index)) %>
+<% end %>
+
+<link href='/css/metricsgraphics.css' rel='stylesheet' type='text/css'>
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.css" integrity="sha512-SUJFImtiT87gVCOXl3aGC00zfDl6ggYAw5+oheJvRJ8KBXZrr/TMISSdVJ5bBarbQDRC2pR5Kto3xTR0kpZInA==" crossorigin="anonymous" />
+<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js" integrity="sha512-vBmx0N/uQOXznm/Nbkp7h0P1RfLSj0HQrFSzV8m7rOGyj30fYAOKHYvCNez+yM8IrfnW0TCodDEjRqf6fodf/Q==" crossorigin="anonymous"></script>
+<script src="/js/jquery3.1.0.min.js"></script>
+<script src="/js/d3.v4.min.js"></script>
+<script src="/js/metricsgraphics.min.js"></script>
+ <script type="text/javascript">
+ (function() {
+ // oui s trè moch :( :( :(
+var ctx = document.getElementById('myChart').getContext('2d');
+ d3.json('<%= alcoolog_path(@conn, :index_gls_json, @network, NolaWeb.format_chan(@channel)) %>', function(data) {
+
+ var dynamicColors = function() {
+ var r = Math.floor(Math.random() * 255);
+ var g = Math.floor(Math.random() * 255);
+ var b = Math.floor(Math.random() * 255);
+ return "rgb(" + r + "," + g + "," + b + ")";
+ };
+
+ labels = data.labels;
+ datasets = $.map($.makeArray(data.data), function(set) {
+ return {label: set.name, data: set.history, fill: false, borderColor: dynamicColors()}
+ });
+
+
+var myChart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: labels,
+ datasets: datasets
+ },
+ options: {
+ scales: {
+ xAxes: [{
+ type: 'time',
+ time: {
+ unit: 'day'
+ }
+ }],
+ yAxes: [{
+ ticks: {
+ beginAtZero: true
+ }
+ }]
+ }
+ }
+});
+});
+
+
+})();
+</script>
diff --git a/lib/web/templates/alcoolog/user.html.eex b/lib/web/templates/alcoolog/user.html.eex
new file mode 100644
index 0000000..d7f716b
--- /dev/null
+++ b/lib/web/templates/alcoolog/user.html.eex
@@ -0,0 +1,170 @@
+<%= if @stats.active > 0 do %>
+ <h3 class="text-gray-900 text-xl leading-5 font-semibold truncate">
+ <% rising_class = if @stats.rising, do: "teal", else: "red" %>
+ <span class="flex-shrink-0 inline-block px-3 py-3 text-<%= rising_class %>-800 text-2xl leading-4 font-medium bg-<%= rising_class %>-100 rounded-full">
+ <%= @stats.trend_symbol %> <%= Float.round(@stats.active, 4) %> g/l
+ </span>
+
+ <span class="px-3"><%= @stats.last_cl %>cl @ <%= @stats.last_deg %>°</span>
+
+ <span class="px-3 text-lg text-gray-700"><%= if @stats.last_descr && @stats.last_descr != "" do %>
+ <%= @stats.last_descr %>
+ <% end %>
+ </span>
+
+ <span class="px-3 text-gray-500 text-sm font-thin"><%= NolaWeb.LayoutView.format_time(@stats.last_at) %></span>
+
+ </h3>
+
+ <p class="text-gray-500 font-thin text-lg">
+ a commencé il y a <span class="text-gray-700 font-semibold"><%= @stats.since_s %></span>
+ &mdash;
+ sobre dans <span class="text-gray-700 font-semibold"><%= @stats.sober_in_s %></span>
+ </p>
+<% else %>
+ <h3 class="text-gray-900 text-xl leading-5 font-semibold truncate">
+ est sobre!
+
+ <p class="text-gray-500 font-thin text-lg">
+ dernier verre
+ <span class="px-3"><%= @stats.last_cl %>cl @ <%= @stats.last_deg %>°</span>
+
+ <span class="px-3 text-lg text-gray-700"><%= if @stats.last_descr && @stats.last_descr != "" do %>
+ <%= @stats.last_descr %>
+ <% end %>
+ </span>
+
+ <span class="px-3 text-gray-500 text-sm font-thin"><%= NolaWeb.LayoutView.format_time(@stats.last_at) %></span>
+ </h3>
+<% end %>
+
+<canvas id="myChart" class="w-full" height="200"></canvas>
+<canvas id="myChartGl" class="w-full" height="200"></canvas>
+
+
+<h2 class="leading-8 m-4 font-semibold text-2xl">Historique</h2>
+<div class="flex flex-col">
+ <div class="-my-2 py-2 overflow-x-auto sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
+ <div class="align-middle inline-block min-w-full shadow overflow-hidden sm:rounded-lg border-b border-gray-200">
+ <table class="min-w-full divide-y divide-gray-200">
+ <thead>
+ <tr>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ date
+ </th>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ &nbsp;
+ </th>
+ <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-gray-500 uppercase tracking-wider">
+ &nbsp;
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <%= for {%{at: date, cl: cl, deg: deg, description: comment}, index} <- Enum.with_index(@history) do %>
+ <% class = if(Integer.is_even(index), do: "bg-gray-50", else: "bg-white") %>
+ <tr class="<%= class %>">
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 font-medium text-gray-900">
+ <%= NolaWeb.LayoutView.format_time(date, false) %>
+ </td>
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500"><%= cl %>cl <%= deg %>°</td>
+ <td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500"><%= comment||"" %></td>
+ </tr>
+ <% end %>
+ </tbody>
+ </table>
+ </div>
+ </div>
+</div>
+
+<div id="metrics-gl" class="w-full"></div>
+
+<link href='/css/metricsgraphics.css' rel='stylesheet' type='text/css'>
+<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.css" integrity="sha512-SUJFImtiT87gVCOXl3aGC00zfDl6ggYAw5+oheJvRJ8KBXZrr/TMISSdVJ5bBarbQDRC2pR5Kto3xTR0kpZInA==" crossorigin="anonymous" />
+<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.3/Chart.bundle.min.js" integrity="sha512-vBmx0N/uQOXznm/Nbkp7h0P1RfLSj0HQrFSzV8m7rOGyj30fYAOKHYvCNez+yM8IrfnW0TCodDEjRqf6fodf/Q==" crossorigin="anonymous"></script>
+<script src="/js/jquery3.1.0.min.js"></script>
+<script src="/js/d3.v4.min.js"></script>
+<script src="/js/metricsgraphics.min.js"></script>
+ <script type="text/javascript">
+ (function() {
+var ctx = document.getElementById('myChart').getContext('2d');
+ d3.json('<%= alcoolog_path(@conn, :nick_volumes_json, @network, @nick, days: @days) %>', function(data) {
+var myChart = new Chart(ctx, {
+ type: 'bar',
+ data: {
+ labels: data.map(function(data) { return new Date(data.date) }),
+ datasets: [{
+ label: 'Volumes d\'alcool',
+ data: data.map(function(data) { return data.volumes }),
+ borderWidth: 1
+ }]
+ },
+ options: {
+ scales: {
+ xAxes: [{
+ type: 'time',
+ time: {
+ unit: 'day'
+ }
+ }],
+ yAxes: [{
+ ticks: {
+ beginAtZero: true
+ }
+ }]
+ }
+ }
+});
+});
+
+
+
+
+var ctxgl = document.getElementById('myChartGl').getContext('2d');
+ d3.json('<%= alcoolog_path(@conn, :nick_gls_json, @network, @nick, days: @days) %>', function(data) {
+var myChartgl = new Chart(ctxgl, {
+ type: 'bar',
+ data: {
+ labels: data.map(function(data) { return new Date(data.date) }),
+ datasets: [{
+ label: 'g/l ingérés',
+ data: data.map(function(data) { return data.gls }),
+ borderWidth: 1
+ }]
+ },
+ options: {
+ scales: {
+ xAxes: [{
+ type: 'time',
+ time: {
+ unit: 'day'
+ }
+ }],
+ yAxes: [{
+ ticks: {
+ beginAtZero: true
+ }
+ }]
+ }
+ }
+});
+});
+
+
+ d3.json('<%= alcoolog_path(@conn, :nick_log_json, @network, @nick, days: @days) %>', function(data) {
+ data = data.map(function(d) {
+ date = new Date(d.at);
+ return {"date": date, "active": d.active};
+ });
+ MG.data_graphic({
+ title: "g/l, All Time",
+ data: data,
+ width: 1000,
+ height: 400,
+ target: document.getElementById('metrics-gl'),
+ x_accessor: 'date',
+ y_accessor: 'active',
+ });
+ });
+})();
+</script>
diff --git a/lib/web/templates/irc/index.html.eex b/lib/web/templates/irc/index.html.eex
new file mode 100644
index 0000000..182624d
--- /dev/null
+++ b/lib/web/templates/irc/index.html.eex
@@ -0,0 +1,44 @@
+<div class="hidden sm:block">
+ <nav class="flex flex-wrap space-x-4">
+ <%= link("live", to: NolaWeb.Router.Helpers.live_path(NolaWeb.Endpoint, NolaWeb.ChatLive, @network, NolaWeb.format_chan(@chan)), class: "py-4 font-medium leading-5 rounded-md text-gray-500 hover:text-gray-700 focus:outline-none focus:text-indigo-600 focus:bg-indigo-50", 'data-turbo': false) %>
+ <% list = for {identifier, _} <- @commands do %>
+ <% name = String.replace(identifier, "_", " ") %>
+ <%= link(name, to: "##{identifier}", class: "py-4 font-medium leading-5 rounded-md text-gray-500 hover:text-gray-700 focus:outline-none focus:text-indigo-600 focus:bg-indigo-50") %>
+ <% end %>
+ <%= list %>
+ </nav>
+</div>
+
+<%= if @members != [] do %>
+ <ul class="flex flew-wrap space-x-4 mt-12">
+ <%= for user <- @members do %><li><%= user.nick %></li><% end %>
+ </ul>
+<% end %>
+
+<div class="irchelps space-y-6 mt-12">
+ <%= for {identifier, help} <- @commands do %>
+ <%= if help do %>
+ <div class="bg-white border-b border-gray-200" id="<%= identifier %>">
+ <div class="prose w-auto max-w-full"><%= NolaWeb.LayoutView.liquid_markdown(@conn, help) %></div>
+ </div>
+ <% end %>
+ <% end %>
+</div>
+
+<br/><br/><br/>
+
+<p>
+<small>
+ <strong>Légende:</strong><br />
+ entre <code>< ></code>: argument obligatoire,<br />
+ entre <code>[ ]</code>: argument optionel; <code>[1 | ]</code>: argument optionel avec valeur par défaut.
+</small>
+</p>
+
+<br/><br/><br/>
+
+<p>
+<small>
+ running beautte version <%= Nola.version() %> &mdash; <a href="<%= Nola.source_url() %>">git</a>
+</small>
+</p>
diff --git a/lib/web/templates/irc/txt.html.eex b/lib/web/templates/irc/txt.html.eex
new file mode 100644
index 0000000..fd4ea00
--- /dev/null
+++ b/lib/web/templates/irc/txt.html.eex
@@ -0,0 +1,27 @@
+<style type="text/css">
+:target {
+ background-color: #ffa;
+ font-weight: bold;
+}
+ol > li > a {
+ display: none;
+}
+ol > li:hover > a {
+ display: inline-block;
+ color: gray;
+}
+</style>
+
+<ol class="prose space-y-3 w-auto max-w-full list-outside list-decimal m-4 leading-loose">
+ <%= for {txt, id} <- Enum.with_index(@data) do %>
+ <li class="text-lg" id="q<%= id+1 %>">
+
+ <%= txt %>
+ <a href="#q<%= id+1 %>" class="text-xs text-gray-500 space-x-3">#</a>
+ </li>
+ <% end %>
+</ol>
+
+<p>
+ <a href="/-/txt/<%= @name %>.txt" class="text-xs text-gray-500">télécharger au format texte</a>
+</p>
diff --git a/lib/web/templates/irc/txts.html.eex b/lib/web/templates/irc/txts.html.eex
new file mode 100644
index 0000000..aff0c5d
--- /dev/null
+++ b/lib/web/templates/irc/txts.html.eex
@@ -0,0 +1,49 @@
+<div>
+ <h2 class="text-gray-500 text-xs font-medium uppercase tracking-wide">
+ <strong><%= @lines %></strong> lignes dans <strong><%= @files %></strong> fichiers
+ <a href="#help" class="ml-4 font-bold">Aide</a>
+ <a href="#system" class="text-gray-300 ml-4">Fichiers système</a>
+ </h2>
+ <ul class="mt-3 grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-4 lg:grid-cols-6">
+ <%= for {txt, data} <- @data do %>
+ <% base_url = cond do
+ @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}"
+ true -> "/-"
+ end %>
+ <li class="col-span-1 flex shadow-sm rounded-m">
+ <div class="flex-1 flex items-center justify-between border-l border-t border-r border-b border-gray-200 bg-white rounded-md truncate">
+ <div class="flex-1 px-4 py-2 text-sm leading-5 truncate">
+ <a href="<%= base_url %>/txt/<%= txt %>" class="text-gray-900 text-lg font-medium hover:text-gray-600 transition ease-in-out duration-150"><%= txt %></a>
+ <p class="text-gray-500"><%= Enum.count(data) %> lignes</p>
+ </div>
+ </div>
+ </li>
+ <% end %>
+ </ul>
+</div>
+
+<div class="prose mt-12" id="help""><%= NolaWeb.LayoutView.liquid_markdown(@conn, @doc) %></div>
+
+<div class="mt-24">
+ <h2 class="text-gray-500 text-xs font-medium uppercase tracking-wide">
+ Fichiers système
+ </h2>
+
+ <ul class="mt-3 grid grid-cols-1 gap-5 sm:gap-6 sm:grid-cols-4 lg:grid-cols-6">
+ <%= for {txt, data} <- @system do %>
+ <% base_url = cond do
+ @conn.assigns[:chan] -> "/#{@conn.assigns.network}/#{NolaWeb.format_chan(@conn.assigns.chan)}"
+ true -> "/-"
+ end %>
+ <li class="col-span-1 flex shadow-sm rounded-m">
+ <div class="flex-1 flex items-center justify-between border-l border-t border-r border-b border-gray-200 bg-white rounded-md truncate">
+ <div class="flex-1 px-4 py-2 text-sm leading-5 truncate">
+ <a href="<%= base_url %>/txt/<%= txt %>" class="text-gray-900 text-lg font-medium hover:text-gray-600 transition ease-in-out duration-150"><%= txt %></a>
+ <p class="text-gray-500"><%= Enum.count(data) %> lignes</p>
+ </div>
+ </div>
+ </li>
+ <% end %>
+ </ul>
+
+</div>
diff --git a/lib/web/templates/layout/app.html.eex b/lib/web/templates/layout/app.html.eex
new file mode 100644
index 0000000..c774369
--- /dev/null
+++ b/lib/web/templates/layout/app.html.eex
@@ -0,0 +1,126 @@
+<div>
+ <div class="bg-gray-800 pb-32">
+ <nav class="bg-gray-800">
+ <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
+ <div class="border-b border-gray-700">
+ <div class="flex items-center justify-between h-16 px-4 sm:px-0">
+ <div class="flex items-center">
+ <div class="flex-shrink-0">
+ <img class="h-8 w-8" src="https://tailwindui.com/img/logos/workflow-mark-on-dark.svg" alt="Workflow logo">
+ </div>
+ <div class="hidden md:block">
+ <div class="ml-10 flex items-baseline">
+ <a href="/" class="px-3 py-2 rounded-md text-sm font-medium text-white bg-gray-900 focus:outline-none focus:text-white focus:bg-gray-700"><%= Nola.name() %></a>
+ <!--<a href="#" class="ml-4 px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Team</a>
+ <a href="#" class="ml-4 px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Projects</a>
+ <a href="#" class="ml-4 px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Calendar</a>
+ <a href="#" class="ml-4 px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Reports</a>-->
+ </div>
+ </div>
+ </div>
+ <div class="hidden md:block">
+ <div class="ml-4 flex items-center md:ml-6">
+ <!--<button class="p-1 border-2 border-transparent text-gray-400 rounded-full hover:text-white focus:outline-none focus:text-white focus:bg-gray-700" aria-label="Notifications">
+ <svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
+ </svg>
+ </button>-->
+ <!-- Profile dropdown -->
+ <div class="ml-3 relative">
+ <div>
+ <button class="max-w-xs flex items-center text-sm rounded-full text-white focus:outline-none focus:shadow-solid" id="user-menu" aria-label="User menu" aria-haspopup="true">
+ <!--<img class="h-8 w-8 rounded-full" src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="">-->
+ ~<%= if @conn.assigns[:account], do: @conn.assigns.account.name %>
+
+ </button>
+ </div>
+ <!--
+ Profile dropdown panel, show/hide based on dropdown state.
+
+ Entering: "transition ease-out duration-100"
+ From: "transform opacity-0 scale-95"
+ To: "transform opacity-100 scale-100"
+ Leaving: "transition ease-in duration-75"
+ From: "transform opacity-100 scale-100"
+ To: "transform opacity-0 scale-95"
+ -->
+ <!--<div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg">
+ <div class="py-1 rounded-md bg-white shadow-xs">
+ <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Your Profile</a>
+ <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Settings</a>
+ <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Sign out</a>
+ </div>
+ </div>-->
+ </div>
+ </div>
+ </div>
+ <div class="-mr-2 flex md:hidden">
+ <!-- Mobile menu button -->
+ <button class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:bg-gray-700 focus:text-white">
+ <!-- Menu open: "hidden", Menu closed: "block" -->
+ <svg class="block h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
+ </svg>
+ <!-- Menu open: "block", Menu closed: "hidden" -->
+ <svg class="hidden h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!--
+ Mobile menu, toggle classes based on menu state.
+
+ Open: "block", closed: "hidden"
+ -->
+ <div class="hidden border-b border-gray-700 md:hidden">
+ <div class="px-2 py-3 sm:px-3">
+ <a href="#" class="block px-3 py-2 rounded-md text-base font-medium text-white bg-gray-900 focus:outline-none focus:text-white focus:bg-gray-700">Dashboard</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Team</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Projects</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Calendar</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700">Reports</a>
+ </div>
+ <div class="pt-4 pb-3 border-t border-gray-700">
+ <div class="flex items-center px-5">
+ <div class="flex-shrink-0">
+ <img class="h-10 w-10 rounded-full" src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" alt="">
+ </div>
+ <div class="ml-3">
+ <div class="text-base font-medium leading-none text-white">Tom Cook</div>
+ <div class="mt-1 text-sm font-medium leading-none text-gray-400">tom@example.com</div>
+ </div>
+ </div>
+ <div class="mt-3 px-2" role="menu" aria-orientation="vertical" aria-labelledby="user-menu">
+ <a href="#" class="block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700" role="menuitem">Your Profile</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700" role="menuitem">Settings</a>
+ <a href="#" class="mt-1 block px-3 py-2 rounded-md text-base font-medium text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:text-white focus:bg-gray-700" role="menuitem">Sign out</a>
+ </div>
+ </div>
+ </div>
+ </nav>
+ <header class="py-10">
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
+ <h1 class="text-3xl leading-9 font-bold text-white">
+ <%= if n = @conn.assigns[:network] do %><a href="/<%= n %>"><%= n %></a> &rsaquo; <% end %>
+ <%= if c = @conn.assigns[:chan] do %><a href="/<%= @conn.assigns.network %>/<%= NolaWeb.format_chan(c) %>"><%= c %></a> &rsaquo; <% end %>
+ <%= for({name, href} <- Enum.uniq(@conn.assigns[:breadcrumbs]||[]), do: [link(name, to: href), raw(" &rsaquo; ")]) %>
+ <%= @conn.assigns[:title] %>
+ </h1>
+ </div>
+ </header>
+ </div>
+
+ <main class="-mt-32 h-full">
+ <div class="max-w-7xl h-full mx-auto pb-12 px-4 sm:px-6 lg:px-8">
+ <!-- Replace with your content -->
+ <div class="bg-white h-full rounded-lg shadow px-5 py-6 sm:px-6">
+ <%= @inner_content %>
+ </div>
+ <!-- /End replace -->
+ </div>
+ </main>
+</div>
diff --git a/lib/web/templates/layout/root.html.leex b/lib/web/templates/layout/root.html.leex
new file mode 100644
index 0000000..6a48506
--- /dev/null
+++ b/lib/web/templates/layout/root.html.leex
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <%= page_title(@conn) %>
+ <meta charset="utf-8">
+ <meta name="robots" content="noindex, nofollow, nosnippet">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="robots" content="noindex, noarchive, nofollow, nosnippet" />
+ <title><%= Map.get(assigns, :title, "") %></title>
+ <link rel="stylesheet" href="<%= static_path(@conn, "/assets/site.css") %>">
+ <%= csrf_meta_tag() %>
+ <script src="<%= static_path(@conn, "/assets/site.js") %>" defer></script>
+ </head>
+ <body>
+ <%= @inner_content %>
+ </body>
+</html>
diff --git a/lib/web/templates/network/index.html.eex b/lib/web/templates/network/index.html.eex
new file mode 100644
index 0000000..fc024dd
--- /dev/null
+++ b/lib/web/templates/network/index.html.eex
@@ -0,0 +1 @@
+pouet
diff --git a/lib/web/templates/open_id/error.html.eex b/lib/web/templates/open_id/error.html.eex
new file mode 100644
index 0000000..d1b35b9
--- /dev/null
+++ b/lib/web/templates/open_id/error.html.eex
@@ -0,0 +1,3 @@
+<h1 class="text-xl font-bold text-red-800">OpenID authentication error</h1>
+
+<p class="mt-12 text-base prose"><%= @error %></p>
diff --git a/lib/web/templates/page/api.html.eex b/lib/web/templates/page/api.html.eex
new file mode 100644
index 0000000..03dfa6b
--- /dev/null
+++ b/lib/web/templates/page/api.html.eex
@@ -0,0 +1,35 @@
+<h1>sys.115ans.net/api</h1>
+
+<h2>Icecast Status</h2>
+
+<h3>GET /api/icecast.json</h3>
+
+<p>
+ Content-Type: <code>application/json</code>
+</p>
+
+<pre><code>
+{
+ "np": String,
+ "genre": null | String,
+ "live": false | true
+}
+</pre></code>
+
+<h3>GET /api/icecast.sse</h3>
+<p>
+ Content-Type: <code>text/event-stream</code>
+</p>
+
+<p>
+ Stream of:
+</p>
+
+ <ul>
+ <li><strong>icecast</strong> events (same format as <code>/api/icecast.json</code>)</li>
+ <li><strong>ping</strong> events (to keep-alive connection. You can safely ignore them)</li>
+ </ul>
+<p>
+ On client connection, the server sends the latest <code>icecast</code> status known.
+</p>
+
diff --git a/lib/web/templates/page/index.html.eex b/lib/web/templates/page/index.html.eex
new file mode 100644
index 0000000..1b8519a
--- /dev/null
+++ b/lib/web/templates/page/index.html.eex
@@ -0,0 +1 @@
+<p>vOv</p>
diff --git a/lib/web/templates/page/irc.html.eex b/lib/web/templates/page/irc.html.eex
new file mode 100644
index 0000000..f6598ee
--- /dev/null
+++ b/lib/web/templates/page/irc.html.eex
@@ -0,0 +1,19 @@
+<h1>bot `115ans</h1>
+
+<p>Si vous cherchez l'IRC c'est <a href="https://115ans.net/irc/">par là</a>.</p>
+
+<style type="text/css">
+.help-entry h1 {
+ font-size: 18px;
+}
+.help-entry h2 {
+ font-size: 16px;
+}
+</style>
+
+<div class="irchelps">
+ <%= for help <- @bot_helps do %>
+ <div class="help-entry"><%= help |> Earmark.as_html! |> raw() %></div>
+ <% end %>
+</div>
+
diff --git a/lib/web/templates/page/user.html.eex b/lib/web/templates/page/user.html.eex
new file mode 100644
index 0000000..de9f718
--- /dev/null
+++ b/lib/web/templates/page/user.html.eex
@@ -0,0 +1,43 @@
+<div class="prose prose-lg">
+<ul>
+ <li><%= link("Help", to: "/-") %></li>
+ <%= unless List.keyfind(@metas, "identity-id", 0) do %>
+ <li><%= link("Connect with random.sh", to: Routes.open_id_path(@conn, :login)) %></li>
+ <% end %>
+</ul>
+
+<h2>channels</h2>
+<ul>
+ <%= for {net, channel} <- @memberships do %>
+ <li>
+ <% url = NolaWeb.Router.Helpers.irc_path(NolaWeb.Endpoint, :index, net, NolaWeb.format_chan(channel)) %>
+ <%= link([net, ": ", content_tag(:strong, channel)], to: url) %>
+ </li>
+ <% end %>
+</ul>
+
+<h2>connections</h2>
+<ul>
+ <%= for user <- @users do %>
+ <li>
+ <strong><%= user.network %></strong>: <strong><%= user.nick %></strong>!<%= user.username %>@<%= user.host %> <i><%= user.realname %></i><br />
+ <%= Enum.join(Enum.intersperse(Enum.map(user.privileges, fn({c, _}) -> c end), ", ")) %>
+ </li>
+ <% end %>
+</ul>
+
+<h2>account</h2>
+<ul>
+ <li>account-id: <%= @conn.assigns.account.id %></li>
+ <%= for {k, v} <- @metas do %>
+ <li><%= k %>: <%= to_string(v) %></li>
+ <% end %>
+</ul>
+
+<strong>irc auths:</strong>
+<ul>
+ <%= for {net, {predicate, v}} <- @predicates do %>
+ <li><%= net %>: <%= to_string(predicate) %>, <%= v %></li>
+ <% end %>
+</ul>
+</div>
diff --git a/lib/web/templates/page/widget.html.eex b/lib/web/templates/page/widget.html.eex
new file mode 100644
index 0000000..65853b3
--- /dev/null
+++ b/lib/web/templates/page/widget.html.eex
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<link rel="stylesheet" href="<%= static_path(@conn, "/assets/css/widget.css") %>">
+<script src="<%= static_path(@conn, "/assets/js/soundmanager2-jsmin.js") %>"></script>
+<script src="<%= static_path(@conn, "/assets/js/widget.js") %>"></script>
+</head>
+<body>
+ <div id="player" class="<%= if @icecast.live, do: "live", else: "autodj" %>">
+ <div id="state">▶</div>
+ <div id="titles">
+ <div id="genre"><%= @icecast.genre %></div>
+ <div id="np"><%= @icecast.np %></div>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/lib/web/views/alcoolog_view.ex b/lib/web/views/alcoolog_view.ex
new file mode 100644
index 0000000..ad52472
--- /dev/null
+++ b/lib/web/views/alcoolog_view.ex
@@ -0,0 +1,6 @@
+defmodule NolaWeb.AlcoologView do
+ use NolaWeb, :view
+ require Integer
+
+end
+
diff --git a/lib/web/views/error_helpers.ex b/lib/web/views/error_helpers.ex
new file mode 100644
index 0000000..25214bd
--- /dev/null
+++ b/lib/web/views/error_helpers.ex
@@ -0,0 +1,40 @@
+defmodule NolaWeb.ErrorHelpers do
+ @moduledoc """
+ Conveniences for translating and building error messages.
+ """
+
+ use Phoenix.HTML
+
+ @doc """
+ Generates tag for inlined form input errors.
+ """
+ def error_tag(form, field) do
+ Enum.map(Keyword.get_values(form.errors, field), fn (error) ->
+ content_tag :span, translate_error(error), class: "help-block"
+ end)
+ end
+
+ @doc """
+ Translates an error message using gettext.
+ """
+ def translate_error({msg, opts}) do
+ # Because error messages were defined within Ecto, we must
+ # call the Gettext module passing our Gettext backend. We
+ # also use the "errors" domain as translations are placed
+ # in the errors.po file.
+ # Ecto will pass the :count keyword if the error message is
+ # meant to be pluralized.
+ # On your own code and templates, depending on whether you
+ # need the message to be pluralized or not, this could be
+ # written simply as:
+ #
+ # dngettext "errors", "1 file", "%{count} files", count
+ # dgettext "errors", "is invalid"
+ #
+ if count = opts[:count] do
+ Gettext.dngettext(NolaWeb.Gettext, "errors", msg, msg, count, opts)
+ else
+ Gettext.dgettext(NolaWeb.Gettext, "errors", msg, opts)
+ end
+ end
+end
diff --git a/lib/web/views/error_view.ex b/lib/web/views/error_view.ex
new file mode 100644
index 0000000..5cad939
--- /dev/null
+++ b/lib/web/views/error_view.ex
@@ -0,0 +1,17 @@
+defmodule NolaWeb.ErrorView do
+ use NolaWeb, :view
+
+ def render("404.html", _assigns) do
+ "Page not found"
+ end
+
+ def render("500.html", _assigns) do
+ "Internal server error"
+ end
+
+ # In case no render clause matches or no
+ # template is found, let's render it as 500
+ def template_not_found(_template, assigns) do
+ render "500.html", assigns
+ end
+end
diff --git a/lib/web/views/irc_view.ex b/lib/web/views/irc_view.ex
new file mode 100644
index 0000000..331d91f
--- /dev/null
+++ b/lib/web/views/irc_view.ex
@@ -0,0 +1,3 @@
+defmodule NolaWeb.IrcView do
+ use NolaWeb, :view
+end
diff --git a/lib/web/views/layout_view.ex b/lib/web/views/layout_view.ex
new file mode 100644
index 0000000..2bffc6f
--- /dev/null
+++ b/lib/web/views/layout_view.ex
@@ -0,0 +1,81 @@
+defmodule NolaWeb.LayoutView do
+ use NolaWeb, :view
+
+ def liquid_markdown(conn, text) do
+ context_path = cond do
+ conn.assigns[:chan] -> "/#{conn.assigns[:network]}/#{NolaWeb.format_chan(conn.assigns[:chan])}"
+ conn.assigns[:network] -> "/#{conn.assigns[:network]}/-"
+ true -> "/-"
+ end
+
+ {:ok, ast} = Liquex.parse(text)
+ context = Liquex.Context.new(%{
+ "context_path" => context_path
+ })
+ {content, _} = Liquex.render(ast, context)
+ content
+ |> to_string()
+ |> Earmark.as_html!()
+ |> raw()
+ end
+
+ def page_title(conn) do
+ target = cond do
+ conn.assigns[:chan] ->
+ "#{conn.assigns.chan} @ #{conn.assigns.network}"
+ conn.assigns[:network] -> conn.assigns.network
+ true -> Keyword.get(Nola.name())
+ end
+
+ breadcrumb_title = Enum.map(Map.get(conn.assigns, :breadcrumbs)||[], fn({title, _href}) -> title end)
+
+ title = [conn.assigns[:title], breadcrumb_title, target]
+ |> List.flatten()
+ |> Enum.uniq()
+ |> Enum.filter(fn(x) -> x end)
+ |> Enum.intersperse(" / ")
+ |> Enum.join()
+
+ content_tag(:title, title)
+ end
+
+ def format_time(date, with_relative \\ true) do
+ alias Timex.Format.DateTime.Formatters
+ alias Timex.Timezone
+ date = if is_integer(date) do
+ date
+ |> DateTime.from_unix!(:millisecond)
+ |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
+ else
+ date
+ |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase)
+ end
+
+ now = DateTime.now!("Europe/Paris", Tzdata.TimeZoneDatabase)
+
+ now_week = Timex.iso_week(now)
+ date_week = Timex.iso_week(date)
+
+ {y, w} = now_week
+ now_last_week = {y, w-1}
+ now_last_roll = 7-Timex.days_to_beginning_of_week(now)
+
+ date_date = DateTime.to_date(date)
+ now_date = DateTime.to_date(date)
+
+ format = cond do
+ date.year != now.year -> "{D}/{M}/{YYYY} {h24}:{m}"
+ date_date == now_date -> "{h24}:{m}"
+ (now_week == date_week) || (date_week == now_last_week && (Date.day_of_week(date) >= now_last_roll)) -> "{WDfull} {h24}:{m}"
+ (now.year == date.year && now.month == date.month) -> "{WDfull} {D} {h24}:{m}"
+ true -> "{WDfull} {D} {M} {h24}:{m}"
+ end
+
+ {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr")
+ {:ok, full} = Formatters.Default.lformat(date, "{WDfull} {D} {YYYY} {h24}:{m}", "fr") #"{h24}:{m} {WDfull} {D}", "fr")
+ {:ok, detail} = Formatters.Default.lformat(date, format, "fr") #"{h24}:{m} {WDfull} {D}", "fr")
+
+ content_tag(:time, if(with_relative, do: relative, else: detail), [title: full])
+ end
+
+end
diff --git a/lib/web/views/network_view.ex b/lib/web/views/network_view.ex
new file mode 100644
index 0000000..7a24db1
--- /dev/null
+++ b/lib/web/views/network_view.ex
@@ -0,0 +1,4 @@
+defmodule NolaWeb.NetworkView do
+ use NolaWeb, :view
+
+end
diff --git a/lib/web/views/open_id_view.ex b/lib/web/views/open_id_view.ex
new file mode 100644
index 0000000..bd8089b
--- /dev/null
+++ b/lib/web/views/open_id_view.ex
@@ -0,0 +1,4 @@
+defmodule NolaWeb.OpenIdView do
+ use NolaWeb, :view
+
+end
diff --git a/lib/web/views/page_view.ex b/lib/web/views/page_view.ex
new file mode 100644
index 0000000..1bfaadd
--- /dev/null
+++ b/lib/web/views/page_view.ex
@@ -0,0 +1,3 @@
+defmodule NolaWeb.PageView do
+ use NolaWeb, :view
+end
diff --git a/lib/web/web.ex b/lib/web/web.ex
new file mode 100644
index 0000000..906e961
--- /dev/null
+++ b/lib/web/web.ex
@@ -0,0 +1,99 @@
+defmodule NolaWeb do
+ @moduledoc """
+ The entrypoint for defining your web interface, such
+ as controllers, views, channels and so on.
+
+ This can be used in your application as:
+
+ use NolaWeb, :controller
+ use NolaWeb, :view
+
+ The definitions below will be executed for every view,
+ controller, etc, so keep them short and clean, focused
+ on imports, uses and aliases.
+
+ Do NOT define functions inside the quoted expressions
+ below. Instead, define any helper function in modules
+ and import those modules here.
+ """
+
+ def format_chan("##") do
+ "♯♯"
+ end
+
+ def format_chan("#") do
+ "♯"
+ end
+
+ def format_chan("#"<>chan) do
+ chan
+ end
+
+ def format_chan(chan = "!"<>_), do: chan
+
+ def reformat_chan("♯") do
+ "#"
+ end
+ def reformat_chan("♯♯") do
+ "##"
+ end
+ def reformat_chan(chan = "!"<>_), do: chan
+
+ def reformat_chan(chan) do
+ "#"<>chan
+ end
+
+ def controller do
+ quote do
+ use Phoenix.Controller, namespace: NolaWeb
+ import Plug.Conn
+ import NolaWeb.Router.Helpers
+ import NolaWeb.Gettext
+ alias NolaWeb.Router.Helpers, as: Routes
+ end
+ end
+
+ def view do
+ quote do
+ use Phoenix.View, root: "lib/nola_web/templates",
+ namespace: NolaWeb
+
+ # Import convenience functions from controllers
+ import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
+
+ # Use all HTML functionality (forms, tags, etc)
+ use Phoenix.HTML
+
+ import NolaWeb.Router.Helpers
+ import NolaWeb.ErrorHelpers
+ import NolaWeb.Gettext
+
+ import Phoenix.LiveView.Helpers
+
+ alias NolaWeb.Router.Helpers, as: Routes
+ end
+ end
+
+ def router do
+ quote do
+ use Phoenix.Router
+ import Plug.Conn
+ import Phoenix.Controller
+ import Phoenix.LiveView.Router
+ end
+ end
+
+ def channel do
+ quote do
+ use Phoenix.Channel
+ import NolaWeb.Gettext
+ end
+ end
+
+ @doc """
+ When used, dispatch to the appropriate controller/view/etc.
+ """
+ defmacro __using__(which) when is_atom(which) do
+ apply(__MODULE__, which, [])
+ end
+end