This commit is contained in:
Mikko Ahlroth 2022-10-09 12:46:45 +03:00
commit c401961e70
74 changed files with 10587 additions and 0 deletions

5
.formatter.exs Normal file
View file

@ -0,0 +1,5 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs,heex}"],
plugins: [Phoenix.LiveView.HTMLFormatter]
]

29
.gitignore vendored Normal file
View file

@ -0,0 +1,29 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where third-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Ignore package tarball (built via "mix hex.build").
talk_tool-*.tar
# Temporary files, for example, from tests.
/tmp/
/priv/db/
/.env

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
erlang 25.1.1
elixir 1.14.0-otp-25

1
README.md Normal file
View file

@ -0,0 +1 @@
# TalkTool

24
config/config.exs Normal file
View file

@ -0,0 +1,24 @@
import Config
config :talk_tool, TTAdminUI.Endpoint,
render_errors: [accepts: ~w(html json)],
pubsub_server: TTAdminUI.PubSub,
code_reloader: config_env() == :dev,
debug_errors: config_env() == :dev,
check_origin: config_env() == :prod
config :talk_tool, TTClientUI.Endpoint,
render_errors: [accepts: ~w(html json), root_layout: {TTClientUI.ErrorView, :root}],
pubsub_server: TTClientUI.PubSub,
code_reloader: config_env() == :dev,
debug_errors: config_env() == :dev,
check_origin: config_env() == :prod
config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
config :talk_tool,
compile_env: Mix.env()
config :phoenix, :json_library, Jason

60
config/runtime.exs Normal file
View file

@ -0,0 +1,60 @@
import Config
import TalkTool.ConfigHelpers, only: [get_env: 3, get_env: 2, get_env: 1]
if Config.config_env() == :dev do
DotenvParser.load_file(".env")
end
config :talk_tool,
db_dir: Application.app_dir(:talk_tool, "priv") |> Path.join(get_env("DB_DIR", "db"))
config :talk_tool, TTAdminUI.Endpoint,
http: [port: get_env("ADMIN_PORT", 6969, :int)],
url: [
host: get_env("ADMIN_HOST", "localhost"),
port: get_env("ADMIN_HOST_PORT", 6969, :int),
scheme: get_env("ADMIN_SCHEME", "http")
],
# LiveView
live_view: [signing_salt: get_env("LIVE_VIEW_SIGNING_SALT", "FxvcOwl5788vwgJD")]
config :talk_tool, TTClientUI.Endpoint,
http: [port: get_env("CLIENT_PORT", 4242, :int)],
url: [
host: get_env("CLIENT_HOST", "localhost"),
port: get_env("CLIENT_HOST_PORT", 4242, :int),
scheme: get_env("CLIENT_SCHEME", "http")
],
# LiveView
live_view: [signing_salt: get_env("LIVE_VIEW_SIGNING_SALT", "FxvgVmWxHFVVmwDB")]
case config_env() do
:dev ->
config :talk_tool, TTAdminUI.Endpoint,
secret_key_base: "asd+0f98dfasd+098dfas+0d98dfa+0sd98df0+a9s8df+09a8sdf+09a8sdf+098adf"
config :talk_tool, TTClientUI.Endpoint,
secret_key_base: "aw.4,5nmaw3.,4m5wrnraw3.,5mnaaw34,.m5nwaw3.,m5naw3.,m5na.w3,m5na.,w3mn5"
config :talk_tool, TTAdminUI.Endpoint,
live_reload: [
patterns: [
~r{priv/static/admin/.*(js|s?css|png|jpeg|jpg|gif|svg)$},
~r{lib/(t_t_admin_u_i|talk_tool|t_t_u_i_common|t_t_admin)/.*(\.ex)$},
~r{lib/(t_t_admin_u_i)/.*(\.[hl]?eex)$}
]
]
config :talk_tool, TTClientUI.Endpoint,
live_reload: [
patterns: [
~r{priv/static/client/.*(js|s?css|png|jpeg|jpg|gif|svg)$},
~r{lib/(t_t_client_u_i|talk_tool|t_t_u_i_common|t_t_admin)/.*(\.ex)$},
~r{lib/(t_t_client_u_i)/.*(\.[hl]?eex)$}
]
]
config :phoenix, :stacktrace_depth, 20
end

View file

@ -0,0 +1,58 @@
defmodule TTAdmin.Presentation.PubSub do
alias Phoenix.PubSub
alias TTAdmin.Schemas.Reaction
alias TTAdmin.Schemas.Presentation
alias TTAdmin.Schemas.Question
@spec publish_presentation(Presentation.t()) :: :ok
def publish_presentation(presentation) do
PubSub.broadcast!(
__MODULE__,
"#{presentation.id}",
{:update_presentation, presentation}
)
end
@spec listen_presentation(Presentation.t()) :: :ok | {:error, term()}
def listen_presentation(presentation) do
PubSub.subscribe(__MODULE__, "#{presentation.id}")
end
@spec publish_admin_pid(Presentation.t(), pid() | nil) :: :ok
def publish_admin_pid(presentation, pid) do
PubSub.broadcast!(__MODULE__, "#{presentation.id}:pid", {:new_admin_pid, pid})
end
@spec listen_admin_pid(Presentation.t()) :: :ok | {:error, term()}
def listen_admin_pid(presentation) do
PubSub.subscribe(__MODULE__, "#{presentation.id}:pid")
end
@spec publish_reaction(Reaction.t()) :: :ok
def publish_reaction(reaction) do
PubSub.broadcast!(
__MODULE__,
"#{reaction.presentation_id}:reactions",
{:new_reaction, reaction.presentation_id, reaction}
)
end
@spec listen_reactions(Presentation.t()) :: :ok | {:error, term()}
def listen_reactions(presentation) do
PubSub.subscribe(__MODULE__, "#{presentation.id}:reactions")
end
@spec publish_question(Question.t()) :: :ok
def publish_question(question) do
PubSub.broadcast!(
__MODULE__,
"#{question.presentation_id}:questions",
{:new_question, question.presentation_id, question}
)
end
@spec listen_questions(Presentation.t()) :: :ok | {:error, term()}
def listen_questions(presentation) do
PubSub.subscribe(__MODULE__, "#{presentation.id}:questions")
end
end

View file

@ -0,0 +1,108 @@
defmodule TTAdmin.Presentation.Server do
use GenServer, restart: :transient
import TalkTool.TypedStruct
alias TTAdmin.Schemas.Presentation
alias TTAdmin.Storage.DB
alias TTAdmin.Presentation.PubSub
defmodule Options do
deftypedstruct(%{
id: Presentation.id()
})
end
defmodule State do
deftypedstruct(%{
presentation: Presentation.t(),
admin: {pid() | nil, nil}
})
end
@spec start_link(Options.t()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts.id, name: via(opts.id))
end
@impl GenServer
def init(id) do
presentation = DB.get_presentation!(id)
{:ok, %State{presentation: presentation}}
end
@impl GenServer
def handle_call(msg, from, state)
def handle_call(:get, _from, state) do
{:reply, state.presentation, state}
end
def handle_call(:get_admin, _from, state) do
{:reply, state.admin, state}
end
def handle_call({:update, changeset}, _from, state) do
case Ecto.Changeset.apply_action(changeset, :update) do
{:ok, presentation} ->
DB.put_presentation(presentation)
PubSub.publish_presentation(presentation)
{:reply, {:ok, presentation}, %State{state | presentation: presentation}}
{:error, changeset} ->
{:reply, {:error, changeset}, state}
end
end
def handle_call({:set_admin, pid}, from, state) do
new_admin =
cond do
pid == nil and state.admin == from -> nil
pid != nil -> pid
true -> state.admin
end
PubSub.publish_admin_pid(state.presentation, new_admin)
{:reply, :ok, %State{state | admin: new_admin}}
end
@spec get_server!(Presentation.id()) :: GenServer.name()
def get_server!(id) do
case Registry.lookup(__MODULE__.Registry, id) do
[{pid, _}] ->
pid
_ ->
case DynamicSupervisor.start_child(__MODULE__.Supervisor, {__MODULE__, %Options{id: id}}) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
{:error, error} -> raise DB.NoResultsError, error
end
end
end
@spec get(GenServer.name()) :: Presentation.t()
def get(server) do
GenServer.call(server, :get)
end
@spec update(GenServer.name(), Ecto.Changeset.t()) ::
{:ok, Presentation.t()} | {:error, Ecto.Changeset.t()}
def update(server, changeset) do
GenServer.call(server, {:update, changeset})
end
@spec get_admin(GenServer.name()) :: pid() | nil
def get_admin(server) do
GenServer.call(server, :get_admin)
end
@spec set_admin(GenServer.name(), pid() | nil) :: :ok
def set_admin(server, pid) do
GenServer.call(server, {:set_admin, pid})
end
@spec via(Presentation.id()) :: GenServer.name()
defp via(id), do: {:via, Registry, {__MODULE__.Registry, id}}
end

View file

View file

@ -0,0 +1,32 @@
defmodule TTAdmin.Schemas.Presentation do
import TalkTool.TypedSchema
alias Ecto.Changeset
@type id() :: Ecto.UUID.t()
deftypedschema(:embedded) do
field(:name, :string, String.t())
field(:can_screenshot, :boolean, boolean())
end
@spec create_changeset(map()) :: Changeset.t()
def create_changeset(params) do
%__MODULE__{}
|> Changeset.cast(params, [:name])
|> Changeset.validate_required([:name])
|> Changeset.put_change(:id, Ecto.UUID.autogenerate())
|> Changeset.put_change(:can_screenshot, false)
end
@spec update_screenshot(t(), map()) :: Changeset.t()
def update_screenshot(presentation, params) do
presentation
|> Changeset.cast(params, [:can_screenshot])
|> Changeset.validate_required([:can_screenshot])
|> Changeset.validate_change(:can_screenshot, fn
:can_screenshot, val when is_boolean(val) -> []
:can_screenshot, _ -> [can_screenshot: "Must be boolean."]
end)
end
end

View file

@ -0,0 +1,28 @@
defmodule TTAdmin.Schemas.Question do
import TalkTool.TypedSchema
alias Ecto.Changeset
alias TTAdmin.Schemas.Presentation
@type id() :: Ecto.UUID.t()
deftypedschema(:embedded) do
field(:presentation_id, Ecto.UUID, Presentation.id() | nil)
field(:text, :string, String.t())
field(:at, :utc_datetime, DateTime.t())
field(:answered, :boolean, boolean())
field(:picture, :binary, binary() | nil)
end
@spec create_changeset(Presentation.t(), map()) :: Changeset.t()
def create_changeset(presentation, params) do
%__MODULE__{}
|> Changeset.cast(params, [:text, :picture])
|> Changeset.validate_required([:text])
|> Changeset.put_change(:presentation_id, presentation.id)
|> Changeset.put_change(:at, DateTime.utc_now() |> DateTime.truncate(:second))
|> Changeset.put_change(:id, Ecto.UUID.autogenerate())
|> Changeset.put_change(:answered, false)
end
end

View file

@ -0,0 +1,26 @@
defmodule TTAdmin.Schemas.Reaction do
import TalkTool.TypedSchema
alias Ecto.Changeset
alias TTAdmin.Schemas.Presentation
@type id() :: Ecto.UUID.t()
deftypedschema(:embedded) do
field(:presentation_id, Ecto.UUID, Presentation.id() | nil)
field(:emoji, :string, String.t())
field(:at, :utc_datetime, DateTime.t())
end
@spec create_changeset(Presentation.t(), map()) :: Changeset.t()
def create_changeset(presentation, params) do
%__MODULE__{}
|> Changeset.cast(params, [:emoji])
|> Changeset.validate_required([:emoji])
|> Changeset.validate_length(:emoji, is: 1)
|> Changeset.put_change(:presentation_id, presentation.id)
|> Changeset.put_change(:at, DateTime.utc_now() |> DateTime.truncate(:second))
|> Changeset.put_change(:id, Ecto.UUID.autogenerate())
end
end

134
lib/t_t_admin/storage/db.ex Normal file
View file

@ -0,0 +1,134 @@
defmodule TTAdmin.Storage.DB do
alias TTAdmin.Schemas
alias Ecto.Changeset
defmodule NoResultsError do
defexception [:message]
end
@spec get_presentations() :: [Schemas.Presentation.t()]
def get_presentations() do
CubDB.get(__MODULE__, :presentations, []) |> Enum.map(&load_presentation/1)
end
@spec get_presentation!(Schemas.Presentation.id()) :: Schemas.Presentation.t() | no_return()
def get_presentation!(id) do
p =
get_presentations()
|> Enum.find(&(&1.id == id))
if p != nil do
load_presentation(p)
else
raise NoResultsError, "Cannot find presentation with id #{inspect(id)}"
end
end
@spec create_presentation(Changeset.t()) ::
{:ok, Schemas.Presentation.t()} | {:error, Changeset.t()}
def create_presentation(changeset) do
case Changeset.apply_action(changeset, :insert) do
{:ok, data} ->
CubDB.update(__MODULE__, :presentations, [data], fn old ->
[data | old]
end)
{:ok, data}
{:error, cset} ->
{:error, cset}
end
end
@spec put_presentation(Schemas.Presentation.t()) :: :ok
def put_presentation(presentation) do
presentation = %{
id: presentation.id,
name: presentation.name
}
CubDB.update(__MODULE__, :presentations, [presentation], fn old ->
i = Enum.find_index(old, &(&1.id == presentation.id))
if i != nil do
List.replace_at(old, i, presentation)
else
[presentation | old]
end
end)
end
@spec create_reaction(Changeset.t()) :: {:ok, Schemas.Reaction.t()} | {:error, Changeset.t()}
def create_reaction(changeset) do
case Changeset.apply_action(changeset, :insert) do
{:ok, data} ->
CubDB.update(__MODULE__, reactions_id(data.presentation_id), [data], fn old ->
[data | old]
end)
{:ok, data}
{:error, cset} ->
{:error, cset}
end
end
@spec get_questions(Schemas.Presentation.t()) :: [Schemas.Question.t()]
def get_questions(presentation) do
CubDB.get(__MODULE__, questions_id(presentation.id), [])
end
@spec create_question(Changeset.t()) :: {:ok, Schemas.Question.t()} | {:error, Changeset.t()}
def create_question(changeset) do
case Changeset.apply_action(changeset, :insert) do
{:ok, data} ->
CubDB.update(__MODULE__, questions_id(data.presentation_id), [data], fn old ->
[data | old]
end)
{:ok, data}
{:error, cset} ->
{:error, cset}
end
end
@spec put_question(Schemas.Presentation.t(), Schemas.Question.t()) :: :ok
def put_question(presentation, question) do
CubDB.update(__MODULE__, questions_id(presentation.id), [question], fn old ->
i = Enum.find_index(old, &(&1.id == question.id))
if i != nil do
List.replace_at(old, i, question)
else
[question | old]
end
end)
end
@spec remove_question(Schemas.Presentation.t(), Schemas.Question.id()) :: :ok
def remove_question(presentation, id) do
CubDB.update(__MODULE__, questions_id(presentation.id), [], fn old ->
Enum.reject(old, &(&1.id == id))
end)
end
@spec load_presentation(map()) :: Presentation.t()
defp load_presentation(data) do
%Schemas.Presentation{
id: data.id,
name: data.name,
can_screenshot: false
}
end
@spec reactions_id(Schemas.Presentation.id()) :: String.t()
defp reactions_id(id) do
"reactions-#{id}"
end
@spec questions_id(Schemas.Presentation.id()) :: String.t()
defp questions_id(id) do
"questions-#{id}"
end
end

View file

@ -0,0 +1,103 @@
defmodule TTAdminUI.Endpoint do
use Phoenix.Endpoint, otp_app: :talk_tool
# 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.
@session_options [
store: :cookie,
key: "_talk_tool_admin_key",
signing_salt: "whoahman"
]
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug(Plug.Static,
at: "/static/",
from: "priv/static",
gzip: false,
only: ~w(admin client common)
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
end
plug(Plug.RequestId)
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
)
plug(Plug.MethodOverride)
plug(Plug.Head)
plug(Plug.Session, @session_options)
plug(TTAdminUI.Router)
end
# defmodule TTAdminUI.Endpoint do
# import TalkTool.PlugHelpers
# @behaviour Phoenix.Endpoint
# @behaviour Plug
# @config Phoenix.Endpoint.Supervisor.config(:talk_tool, __MODULE__)
# @plugs [
# if @config[:code_reloader] do
# build_plug(Phoenix.LiveReloader)
# end,
# if @config[:code_reloader] do
# build_plug(Phoenix.CodeReloader)
# end,
# build_plug(TTAdminUI.Router)
# ]
# @impl Phoenix.Endpoint
# def init(_key, config) do
# {:ok, config}
# end
# @impl Plug
# def init(opts) do
# opts
# end
# @impl Plug
# def call(conn, _opts) do
# run_plug(@plugs, conn)
# end
# defp run_plug(plugs, conn)
# defp run_plug([], conn), do: conn
# defp run_plug([nil | rest], conn), do: run_plug(rest, conn)
# defp run_plug([{plug, opts} | rest], conn) do
# case plug.(conn, opts) do
# %Plug.Conn{halted: true} = new_conn ->
# new_conn
# %Plug.Conn{} = new_conn ->
# run_plug(rest, new_conn)
# other ->
# raise "Plug did not return Plug.Conn, instead got: #{inspect(other)}"
# end
# end
# defp pubsub_server!() do
# Keyword.fetch!(@config, :pubsub_server)
# end
# end

View file

@ -0,0 +1,7 @@
defmodule TTAdminUI.LayoutView do
use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTAdminUI
alias TTAdminUI.Router.Helpers, as: Routes
def config(), do: {__MODULE__, "live.html"}
end

View file

@ -0,0 +1,34 @@
defmodule TTAdminUI.Live.Components.Presentation do
use Phoenix.Component
alias TTClientUI.Router.Helpers, as: ClientRoutes
def question(assigns) do
~H"""
<div class={if(@question.answered, do: "question answered", else: "question")}>
<p class="question-text"><%= @question.text %></p>
<%= if @question.picture do %>
<img src={@question.picture} class="screenshot" />
<% end %>
<%= if not @question.answered do %>
<button type="button" phx-click="answer-question" value={@question.id}></button>
<% else %>
<button type="button" phx-click="unanswer-question" value={@question.id}></button>
<% end %>
<button type="button" phx-click="remove-question" value={@question.id}>🗑</button>
</div>
"""
end
def qr(assigns) do
~H"""
<img
src={"data:image/svg+xml;base64,#{QRCode.create!(ClientRoutes.live_url(TTClientUI.Endpoint, TTClientUI.Live.ViewPresentation, @presentation.id)) |> QRCode.Svg.to_base64()}"}
class="qr"
/>
"""
end
end

View file

@ -0,0 +1,54 @@
defmodule TTAdminUI.Live.CreatePresentation do
use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTAdminUI.Router.Helpers, as: Routes
alias TTAdmin.Schemas.Presentation
alias TTAdmin.Storage.DB
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
loading = not Phoenix.LiveView.connected?(socket)
changeset =
if not loading do
Presentation.create_changeset(%{})
end
{:ok,
assign(socket,
page_title: "Create New",
loading: loading,
changeset: changeset,
submit_error: false
)}
end
@impl Phoenix.LiveView
def handle_event(evt, params, socket)
def handle_event("change", %{"presentation" => presentation}, socket) do
changeset = Presentation.create_changeset(presentation)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("submit", %{"presentation" => presentation}, socket) do
changeset = Presentation.create_changeset(presentation)
case Ecto.Changeset.apply_action(changeset, :create) do
{:ok, presentation} ->
DB.put_presentation(presentation)
{:noreply,
push_navigate(socket,
to: Routes.live_path(socket, TTAdminUI.Live.ViewPresentation, presentation.id)
)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset, submit_error: true)}
end
end
end

View file

@ -0,0 +1,16 @@
<section class="create-new-form">
<header>
<h2>Create New</h2>
</header>
<%= if @loading do %>
<p>Loading…</p>
<% else %>
<.form :let={f} for={@changeset} phx-change="change" phx-submit="submit">
<%= label(f, :name) %>
<%= text_input(f, :name, placeholder: "The Joys of Elixir") %>
<%= submit("Create", disabled: not @changeset.valid?) %>
</.form>
<% end %>
</section>

View file

@ -0,0 +1,16 @@
defmodule TTAdminUI.Live.Main do
use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTAdminUI.Router.Helpers, as: Routes
alias TTAdmin.Storage.DB
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
presentations = DB.get_presentations()
{:ok, assign(socket, presentations: presentations, page_title: "Presentations")}
end
end

View file

@ -0,0 +1,23 @@
<section class="presentations">
<header>
<h2>Presentations</h2>
</header>
<nav>
<ul>
<%= for p <- @presentations do %>
<li>
<.link navigate={Routes.live_path(@socket, TTAdminUI.Live.ViewPresentation, p.id)}>
<%= p.name %>
</.link>
</li>
<% end %>
</ul>
</nav>
<footer>
<.link navigate={Routes.live_path(@socket, TTAdminUI.Live.CreatePresentation)} class="button">
Create New
</.link>
</footer>
</section>

View file

@ -0,0 +1,165 @@
defmodule TTAdminUI.Live.ViewPresentation do
use Phoenix.LiveView, layout: TTAdminUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTAdminUI.Router.Helpers, as: Routes
alias Phoenix.LiveView.JS
alias TTAdmin.Storage.DB
alias TTAdmin.Presentation.PubSub
alias TTAdmin.Presentation.Server
alias TTAdmin.Schemas.Presentation
@impl Phoenix.LiveView
def mount(params, _session, socket) do
server_pid = Server.get_server!(Map.get(params, "id"))
presentation = Server.get(server_pid)
questions = DB.get_questions(presentation) |> sort()
if connected?(socket) do
PubSub.listen_presentation(presentation)
PubSub.listen_reactions(presentation)
PubSub.listen_questions(presentation)
Server.set_admin(server_pid, self())
end
{:ok,
assign(socket,
page_title: presentation.name,
presentation: presentation,
server_pid: server_pid,
reactions: [],
questions: questions,
waiting_screenshot: []
), temporary_assigns: [reactions: []]}
end
@impl Phoenix.LiveView
def handle_event(event, params, socket)
def handle_event("got-screenshot-perm", %{"has" => has}, socket) do
changeset =
Presentation.update_screenshot(socket.assigns.presentation, %{can_screenshot: has})
socket =
case Server.update(socket.assigns.server_pid, changeset) do
{:ok, presentation} -> assign(socket, presentation: presentation)
{:error, _error} -> socket
end
{:noreply, socket}
end
def handle_event("took-screenshot", %{"data" => data}, socket) do
socket =
if socket.assigns.waiting_screenshot != [] do
for waiter <- socket.assigns.waiting_screenshot do
GenServer.reply(waiter, data)
end
assign(socket, waiting_screenshot: [])
else
socket
end
{:noreply, socket}
end
def handle_event("answer-question", %{"value" => id}, socket) do
questions =
update_question(
socket.assigns.presentation,
socket.assigns.questions,
id,
&%{&1 | answered: true}
)
{:noreply, assign(socket, questions: questions)}
end
def handle_event("unanswer-question", %{"value" => id}, socket) do
questions =
update_question(
socket.assigns.presentation,
socket.assigns.questions,
id,
&%{&1 | answered: false}
)
{:noreply, assign(socket, questions: questions)}
end
def handle_event("remove-question", %{"value" => id}, socket) do
DB.remove_question(socket.assigns.presentation, id)
questions = Enum.reject(socket.assigns.questions, &(&1.id == id))
{:noreply, assign(socket, questions: questions)}
end
@impl Phoenix.LiveView
def handle_call(msg, from, socket)
def handle_call(:take_screenshot, from, socket) do
socket =
if socket.assigns.waiting_screenshot == [] do
socket |> push_event("take-screenshot", %{}) |> assign(waiting_screenshot: [from])
else
assign(socket, waiting_screenshot: [from | socket.assigns.waiting_screenshot])
end
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_info(msg, socket)
def handle_info({:update_presentation, presentation}, socket) do
{:noreply, assign(socket, presentation: presentation)}
end
def handle_info({:new_reaction, _id, reaction}, socket) do
{:noreply, assign(socket, reactions: [reaction])}
end
def handle_info({:new_question, _id, question}, socket) do
{:noreply, assign(socket, questions: socket.assigns.questions ++ [question])}
end
@impl Phoenix.LiveView
def terminate(_reason, socket) do
changeset =
Presentation.update_screenshot(socket.assigns.presentation, %{can_screenshot: false})
Server.update(socket.assigns.server_pid, changeset)
Server.set_admin(socket.assigns.server_pid, nil)
end
@spec update_question(
Presentation.t(),
[TTAdmin.Schemas.Question.t()],
TTAdmin.Schemas.Question.id(),
(TTAdmin.Schemas.Question.t() -> TTAdmin.Schemas.Question.t())
) :: [TTAdmin.Schemas.Question.t()]
defp update_question(presentation, questions, id, op) do
question = Enum.find(questions, &(&1.id == id))
if question != nil do
question = op.(question)
DB.put_question(presentation, question)
questions |> Enum.reject(&(&1.id == id)) |> then(&[question | &1]) |> sort()
else
questions
end
end
@spec reaction_id(TTAdmin.Schemas.Reaction.t()) :: String.t()
defp reaction_id(reaction) do
"emoji-reaction-#{reaction.id}"
end
@spec sort([TTAdmin.Schemas.Question.t()]) :: [TTAdmin.Schemas.Question.t()]
defp sort(questions) do
Enum.sort_by(questions, & &1.at, {:asc, DateTime})
end
end

View file

@ -0,0 +1,38 @@
<section id="view-presentation" phx-hook="Screenshot">
<header>
<h2><%= @presentation.name %></h2>
</header>
<button type="button" phx-click={JS.toggle(to: "#share-qr")}>📤</button>
<div id="share-qr">
<TTAdminUI.Live.Components.Presentation.qr presentation={@presentation} />
</div>
<h3>Permissions</h3>
<%= label() do %>
<input id="screenshot-check" type="checkbox" checked={@presentation.can_screenshot} />
Allow screenshots
<% end %>
<div id="screenshot-preview" phx-update="ignore"></div>
<h3>Questions</h3>
<div id="presentation-questions">
<%= for q <- @questions do %>
<TTAdminUI.Live.Components.Presentation.question question={q} />
<% end %>
<%= if @questions == [] do %>
<p>No one has asked anything yet.</p>
<% end %>
</div>
<div id="emoji-reactions" phx-update="append">
<%= for reaction <- @reactions do %>
<span id={reaction_id(reaction)} class="emoji-reaction" phx-hook="EmojiReaction">
<%= reaction.emoji %>
</span>
<% end %>
</div>
</section>

View file

@ -0,0 +1,22 @@
defmodule TTAdminUI.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, {TTAdminUI.LayoutView, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(TTUICommon.FLoCPlug)
end
scope "/" do
pipe_through(:browser)
live("/", TTAdminUI.Live.Main)
live("/create", TTAdminUI.Live.CreatePresentation)
live("/presentation/:id", TTAdminUI.Live.ViewPresentation)
end
end

View file

@ -0,0 +1,17 @@
<main class="container">
<header>
<Phoenix.Component.link navigate={Routes.live_path(@socket, TTAdminUI.Live.Main)}>
<h1>TalkTool</h1>
</Phoenix.Component.link>
</header>
<p class="alert alert-info" role="alert" phx-click="lv:clear-flash" phx-value-key="info">
<%= Phoenix.Component.live_flash(@flash, :info) %>
</p>
<p class="alert alert-danger" role="alert" phx-click="lv:clear-flash" phx-value-key="error">
<%= Phoenix.Component.live_flash(@flash, :error) %>
</p>
<%= @inner_content %>
</main>

View file

@ -0,0 +1,27 @@
<!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.0" />
<%= Phoenix.HTML.Tag.csrf_meta_tag() %>
<Phoenix.Component.live_title suffix=" · TalkTool">
<%= assigns[:page_title] %>
</Phoenix.Component.live_title>
<link
phx-track-static
rel="stylesheet"
href={Routes.static_path(@conn, "/static/admin/app.css")}
/>
<script
async
phx-track-static
type="module"
src={Routes.static_path(@conn, "/static/admin/app.js")}
>
</script>
</head>
<body>
<%= @inner_content %>
</body>
</html>

View file

@ -0,0 +1,46 @@
defmodule TTClientUI.Endpoint do
use Phoenix.Endpoint, otp_app: :talk_tool
# 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.
@session_options [
store: :cookie,
key: "_talk_tool_client_key",
signing_salt: "thatssorad"
]
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
# when deploying your static files in production.
plug(Plug.Static,
at: "/static/",
from: "priv/static",
gzip: false,
only: ~w(admin client common)
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket)
plug(Phoenix.LiveReloader)
plug(Phoenix.CodeReloader)
end
plug(Plug.RequestId)
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
)
plug(Plug.MethodOverride)
plug(Plug.Head)
plug(Plug.Session, @session_options)
plug(TTClientUI.Router)
end

View file

@ -0,0 +1,5 @@
defmodule TTClientUI.ErrorView do
use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTClientUI
alias TTClientUI.Router.Helpers, as: Routes
end

View file

@ -0,0 +1,7 @@
defmodule TTClientUI.LayoutView do
use Phoenix.View, root: Path.expand("#{__DIR__}/templates"), namespace: TTClientUI
alias TTClientUI.Router.Helpers, as: Routes
def config(), do: {__MODULE__, "live.html"}
end

View file

@ -0,0 +1,84 @@
defmodule TTClientUI.Live.AskQuestion do
use Phoenix.LiveView, layout: TTClientUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTClientUI.Router.Helpers, as: Routes
alias TTAdmin.Schemas.Question
alias TTAdmin.Storage.DB
alias TTAdmin.Presentation.Server
alias TTAdmin.Presentation.PubSub
@impl Phoenix.LiveView
def mount(params, _session, socket) do
server_pid = Server.get_server!(Map.get(params, "id"))
presentation = Server.get(server_pid)
admin_pid = Server.get_admin(server_pid)
changeset = Question.create_changeset(presentation, %{})
if connected?(socket) do
PubSub.listen_presentation(presentation)
PubSub.listen_admin_pid(presentation)
end
{:ok,
assign(socket,
page_title: presentation.name,
presentation: presentation,
admin_pid: admin_pid,
changeset: changeset,
picture: nil
)}
end
@impl Phoenix.LiveView
def handle_event(evt, params, socket)
def handle_event("change", %{"question" => question}, socket) do
changeset = Question.create_changeset(socket.assigns.presentation, question)
{:noreply, assign(socket, changeset: changeset)}
end
def handle_event("submit", %{"question" => question}, socket) do
changeset =
Question.create_changeset(
socket.assigns.presentation,
Map.put(question, "picture", socket.assigns.picture)
)
case DB.create_question(changeset) do
{:ok, question} ->
TTAdmin.Presentation.PubSub.publish_question(question)
{:noreply,
push_navigate(socket,
to:
Routes.live_path(socket, TTClientUI.Live.ViewPresentation, question.presentation_id)
)}
{:error, changeset} ->
{:noreply, assign(socket, changeset: changeset, submit_error: true)}
end
end
def handle_event("select-screenshot", %{"value" => data}, socket) do
{:noreply, assign(socket, picture: data)}
end
def handle_event("remove-screenshot", _params, socket) do
{:noreply, assign(socket, picture: nil)}
end
@impl Phoenix.LiveView
def handle_info(msg, socket)
def handle_info({:update_presentation, presentation}, socket) do
{:noreply, assign(socket, presentation: presentation)}
end
def handle_info({:new_admin_pid, pid}, socket) do
{:noreply, assign(socket, admin_pid: pid)}
end
end

View file

@ -0,0 +1,36 @@
<section class="ask-question-form">
<header>
<h2>Ask Question</h2>
</header>
<.form :let={f} for={@changeset} phx-change="change" phx-submit="submit">
<%= label(f, :text) %>
<%= text_input(f, :text) %>
<%= submit("✓", disabled: not @changeset.valid?) %>
<.link navigate={
Routes.live_path(@socket, TTClientUI.Live.ViewPresentation, @presentation.id)
}>
Back
</.link>
<h3>Attach Screenshot</h3>
<%= if @picture do %>
<p>Selected picture:</p>
<img src={@picture} class="screenshot" />
<button type="button" phx-click="remove-screenshot">🗑</button>
<% else %>
<%= if @presentation.can_screenshot and @admin_pid != nil do %>
<.live_component
id="screenshot-component"
module={TTClientUI.Live.Components.Screenshot}
presentation={@presentation}
admin_pid={@admin_pid}
mode={:attach}
/>
<% else %>
<p>Taking a screenshot is not available at this moment.</p>
<% end %>
<% end %>
</.form>
</section>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,18 @@
defmodule TTClientUI.Live.Components.EmojiPicker.Emoji do
import TalkTool.TypedStruct
deftypedstruct(%{
char: String.t(),
name: String.t()
})
defimpl String.Chars, for: __MODULE__ do
@spec to_string(TTClientUI.Live.Components.EmojiPicker.Emoji.t()) :: String.t()
def to_string(emoji), do: emoji.char
end
defimpl Phoenix.HTML.Safe, for: __MODULE__ do
@spec to_iodata(TTClientUI.Live.Components.EmojiPicker.Emoji.t()) :: String.t()
def to_iodata(emoji), do: emoji.char
end
end

View file

@ -0,0 +1,59 @@
defmodule TTClientUI.Live.Components.EmojiPicker do
use Phoenix.LiveComponent
alias __MODULE__.Data
alias Phoenix.LiveView.JS
@impl Phoenix.LiveComponent
def mount(socket) do
categories = Data.categories()
emojis = Data.emojis()
{:ok, assign(socket, open_view: nil, categories: categories, emojis: emojis)}
end
@impl Phoenix.LiveComponent
def render(assigns) do
~H"""
<div id={element_id(@id)} class="emoji-picker" phx-hook="EmojiPicker">
<div class="emoji-picker-categories">
<%= for c <- @categories do %>
<button
type="button"
phx-click={
JS.dispatch("scroll",
to: "#" <> element_id(@id),
detail: %{id: category_id(@id, c)}
)
}
phx-target="@myself"
value={c}
>
<%= Data.category_icon(c) %>
</button>
<% end %>
</div>
<div id={content_id(@id)} class="emoji-picker-content">
<%= for c <- @categories do %>
<div id={category_id(@id, c)} class="emoji-picker-category">
<%= for emoji <- Map.fetch!(@emojis, c) do %>
<button type="button" phx-click="emoji-select" value={emoji.char}>
<%= emoji %>
</button>
<% end %>
</div>
<% end %>
</div>
</div>
"""
end
@spec element_id(String.t()) :: String.t()
defp element_id(id), do: "emoji-picker-#{id}"
@spec content_id(String.t()) :: String.t()
defp content_id(id), do: "#{element_id(id)}-content"
@spec category_id(String.t(), Data.category()) :: String.t()
defp category_id(id, category), do: "#{element_id(id)}-category-#{category}"
end

View file

@ -0,0 +1,23 @@
defmodule TTClientUI.Live.Components.Screenshot do
use Phoenix.LiveComponent
@impl Phoenix.LiveComponent
def mount(socket) do
{:ok,
assign(socket,
picture: nil
)}
end
@impl Phoenix.LiveComponent
def handle_event(event, params, socket)
def handle_event("take-screenshot", _params, socket) do
if socket.assigns.admin_pid != nil do
picture = GenServer.call(socket.assigns.admin_pid, :take_screenshot)
{:noreply, assign(socket, picture: picture)}
else
{:noreply, socket}
end
end
end

View file

@ -0,0 +1,30 @@
<div class="screenshot-component">
<div class="screenshot-picture">
<%= if @picture != nil do %>
<img id="screenshot-img" class="screenshot" src={@picture} />
<% else %>
<p>Take a screenshot by using the button below.</p>
<% end %>
</div>
<button
type="button"
phx-click="take-screenshot"
phx-target={@myself}
disabled={not @presentation.can_screenshot || @admin_pid == nil}
>
📸
</button>
<%= if @picture != nil do %>
<%= if @mode == :individual do %>
<button id="screenshot-download-button" type="button" phx-hook="ScreenshotDownload">
💾
</button>
<% else %>
<button type="button" phx-click="select-screenshot" value={@picture}>✓</button>
<% end %>
<% end %>
<%= inspect(@presentation.can_screenshot) %> <%= inspect(@admin_pid) %>
</div>

View file

@ -0,0 +1,12 @@
defmodule TTClientUI.Live.Main do
use Phoenix.LiveView, layout: {TTClientUI.LayoutView, "live.html"}
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTClientUI.Router.Helpers, as: Routes
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, page_title: "TalkTool")}
end
end

View file

@ -0,0 +1,11 @@
<section>
<header>
<h1>TalkTool</h1>
</header>
<p>
You will need to open a presentation with a specific URL that you can get from the presenter.
</p>
<p>Please ask your presenter for more information.</p>
</section>

View file

@ -0,0 +1,41 @@
defmodule TTClientUI.Live.Screenshot do
use Phoenix.LiveView, layout: TTClientUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTClientUI.Router.Helpers, as: Routes
alias TTAdmin.Presentation.PubSub
alias TTAdmin.Presentation.Server
@impl Phoenix.LiveView
def mount(params, _session, socket) do
server_pid = Server.get_server!(Map.get(params, "id"))
presentation = Server.get(server_pid)
admin_pid = Server.get_admin(server_pid)
if connected?(socket) do
PubSub.listen_presentation(presentation)
PubSub.listen_admin_pid(presentation)
end
{:ok,
assign(socket,
page_title: "Grab a Screenshot",
presentation: presentation,
admin_pid: admin_pid,
picture: nil
)}
end
@impl Phoenix.LiveView
def handle_info(msg, socket)
def handle_info({:update_presentation, presentation}, socket) do
{:noreply, assign(socket, presentation: presentation)}
end
def handle_info({:new_admin_pid, pid}, socket) do
{:noreply, assign(socket, admin_pid: pid)}
end
end

View file

@ -0,0 +1,17 @@
<section class="screenshot">
<header>
<h2>Screenshot</h2>
</header>
<.live_component
id="screenshot-component"
module={TTClientUI.Live.Components.Screenshot}
presentation={@presentation}
admin_pid={@admin_pid}
mode={:individual}
/>
<.link navigate={Routes.live_path(@socket, TTClientUI.Live.ViewPresentation, @presentation.id)}>
Back
</.link>
</section>

View file

@ -0,0 +1,51 @@
defmodule TTClientUI.Live.ViewPresentation do
use Phoenix.LiveView, layout: TTClientUI.LayoutView.config()
use Phoenix.HTML
import Phoenix.LiveView.Helpers
import Phoenix.View
alias TTClientUI.Router.Helpers, as: Routes
alias Phoenix.LiveView.JS
alias TTAdmin.Schemas.Reaction
alias TTAdmin.Storage.DB
alias TTAdmin.Presentation.Server
@impl Phoenix.LiveView
def mount(params, _session, socket) do
server_pid = Server.get_server!(Map.get(params, "id"))
presentation = Server.get(server_pid)
if connected?(socket) do
TTAdmin.Presentation.PubSub.listen_presentation(presentation)
end
{:ok,
assign(socket,
page_title: presentation.name,
presentation: presentation
)}
end
@impl Phoenix.LiveView
def handle_event(evt, params, socket)
def handle_event("emoji-select", %{"value" => emoji}, socket) do
changeset = Reaction.create_changeset(socket.assigns.presentation, %{emoji: emoji})
if changeset.valid? do
case DB.create_reaction(changeset) do
{:ok, reaction} -> TTAdmin.Presentation.PubSub.publish_reaction(reaction)
_ -> :ok
end
end
{:noreply, socket}
end
@impl Phoenix.LiveView
def handle_info(msg, socket)
def handle_info({:update_presentation, presentation}, socket) do
{:noreply, assign(socket, presentation: presentation)}
end
end

View file

@ -0,0 +1,31 @@
<section class="view-presentation">
<header>
<h2><%= @presentation.name %></h2>
</header>
<div class="client-view-actions">
<button type="button" phx-click={JS.toggle(to: "#emoji-picker-wrapper")}>😜</button>
<.link
navigate={Routes.live_path(@socket, TTClientUI.Live.AskQuestion, @presentation.id)}
class="button"
>
</.link>
<%= if @presentation.can_screenshot do %>
<.link
navigate={Routes.live_path(@socket, TTClientUI.Live.Screenshot, @presentation.id)}
class="button"
>
📸
</.link>
<% else %>
<button type="button" disabled>📸</button>
<% end %>
</div>
<div id="emoji-picker-wrapper">
<.live_component id="emoji-picker" module={TTClientUI.Live.Components.EmojiPicker} />
</div>
</section>

View file

@ -0,0 +1,23 @@
defmodule TTClientUI.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, {TTClientUI.LayoutView, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
plug(TTUICommon.FLoCPlug)
end
scope "/" do
pipe_through(:browser)
live("/", TTClientUI.Live.Main)
live("/presentation/:id", TTClientUI.Live.ViewPresentation)
live("/presentation/:id/question", TTClientUI.Live.AskQuestion)
live("/presentation/:id/screenshot", TTClientUI.Live.Screenshot)
end
end

View file

@ -0,0 +1,7 @@
<section>
<header>
<h1>404</h1>
</header>
<p>The specified page cannot be found. Please try some other page.</p>
</section>

View file

@ -0,0 +1,7 @@
<section>
<header>
<h1>500</h1>
</header>
<p>Something blew up, oops.</p>
</section>

View file

@ -0,0 +1,27 @@
<!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.0" />
<%= Phoenix.HTML.Tag.csrf_meta_tag() %>
<Phoenix.Component.live_title suffix=" · TalkTool">
<%= assigns[:page_title] %>
</Phoenix.Component.live_title>
<link
phx-track-static
rel="stylesheet"
href={Routes.static_path(@conn, "/static/client/app.css")}
/>
<script
async
phx-track-static
type="module"
src={Routes.static_path(@conn, "/static/client/app.js")}
>
</script>
</head>
<body>
<main class="container"><%= @inner_content %></main>
</body>
</html>

View file

@ -0,0 +1,11 @@
<main class="container">
<p class="alert alert-info" role="alert"
phx-click="lv:clear-flash"
phx-value-key="info"><%= Phoenix.Component.live_flash(@flash, :info) %></p>
<p class="alert alert-danger" role="alert"
phx-click="lv:clear-flash"
phx-value-key="error"><%= Phoenix.Component.live_flash(@flash, :error) %></p>
<%= @inner_content %>
</main>

View file

@ -0,0 +1,27 @@
<!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.0" />
<%= Phoenix.HTML.Tag.csrf_meta_tag() %>
<Phoenix.Component.live_title suffix=" · TalkTool">
<%= assigns[:page_title] %>
</Phoenix.Component.live_title>
<link
phx-track-static
rel="stylesheet"
href={Routes.static_path(@conn, "/static/client/app.css")}
/>
<script
async
phx-track-static
type="module"
src={Routes.static_path(@conn, "/static/client/app.js")}
>
</script>
</head>
<body>
<%= @inner_content %>
</body>
</html>

View file

@ -0,0 +1,4 @@
defimpl Plug.Exception, for: TTAdmin.Storage.DB.NoResultsError do
def status(_exception), do: 404
def actions(_exception), do: []
end

View file

@ -0,0 +1,16 @@
defmodule TTUICommon.FLoCPlug do
@moduledoc """
Plug to opt out of Chrome's Federated Learning of Cohorts.
"""
@behaviour Plug
@impl Plug
def init(opts), do: opts
@impl Plug
@spec call(Plug.Conn.t(), any) :: Plug.Conn.t()
def call(conn, _opts) do
Plug.Conn.put_resp_header(conn, "permissions-policy", "interest-cohort=()")
end
end

View file

@ -0,0 +1,6 @@
defmodule TTUICommon.PlugHelpers do
@spec build_plug(module(), Plug.opts()) :: {module(), Plug.opts()}
def build_plug(plug_mod, opts \\ []) do
{plug_mod, plug_mod.init(opts)}
end
end

View file

@ -0,0 +1,28 @@
defmodule TalkTool.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl Application
def start(_type, _args) do
children = [
Supervisor.child_spec({Phoenix.PubSub, name: TTAdmin.Presentation.PubSub},
id: TTAdmin.Presentation.PubSub
),
{Registry, keys: :unique, name: TTAdmin.Presentation.Server.Registry},
{DynamicSupervisor, strategy: :one_for_one, name: TTAdmin.Presentation.Server.Supervisor},
{CubDB, data_dir: Application.fetch_env!(:talk_tool, :db_dir), name: TTAdmin.Storage.DB},
Supervisor.child_spec({Phoenix.PubSub, name: TTAdminUI.PubSub}, id: TTAdminUI.PubSub),
TTAdminUI.Endpoint,
Supervisor.child_spec({Phoenix.PubSub, name: TTClientUI.PubSub}, id: TTClientUI.PubSub),
TTClientUI.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: TalkTool.Supervisor]
Supervisor.start_link(children, opts)
end
end

View file

@ -0,0 +1,35 @@
defmodule TalkTool.ConfigHelpers do
@type config_type :: :str | :int | :bool | :json
@doc """
Get value from environment variable, converting it to the given type if needed.
If no default value is given, or `:no_default` is given as the default, an error is raised if the variable is not
set.
"""
@spec get_env(String.t(), :no_default | any(), config_type()) :: any()
def get_env(var, default \\ :no_default, type \\ :str)
def get_env(var, :no_default, type) do
System.fetch_env!(var)
|> get_with_type(type)
end
def get_env(var, default, type) do
with {:ok, val} <- System.fetch_env(var) do
get_with_type(val, type)
else
:error -> default
end
end
@spec get_with_type(String.t(), config_type()) :: any()
defp get_with_type(val, type)
defp get_with_type(val, :str), do: val
defp get_with_type(val, :int), do: String.to_integer(val)
defp get_with_type("true", :bool), do: true
defp get_with_type("false", :bool), do: false
defp get_with_type(val, :json), do: Jason.decode!(val)
defp get_with_type(val, type), do: raise("Cannot convert to #{inspect(type)}: #{inspect(val)}")
end

View file

@ -0,0 +1,108 @@
defmodule TalkTool.TypedSchema do
@doc """
Define an Ecto schema with an associated `@type t` specification.
Works the same as normal Ecto schemas, but third argument of each field is the typespec to use
for that field. Typespec for `timestamps` is automatically generated and cannot be specified.
Supported Ecto macros are `field`, `belongs_to`, `has_many`, `has_one`. `many_to_many` is not
supported.
Note: For `has_many`, remember to specify the typespec as a list.
Does not work for embedded schemas.
"""
defmacro deftypedschema(table, do: fields) do
fields =
case fields do
{:__block__, _meta, flist} -> flist
field -> [field]
end
fielddatas = for field <- fields, do: parse_spec(field)
typespecs =
Enum.reduce(fielddatas, [], fn
%{field: :timestamps, fieldspec: {:timestamps, _, [opts]}}, acc ->
acc =
if Keyword.get(opts, :updated_at, true) != false do
[{:updated_at, quote(do: DateTime.t())} | acc]
else
acc
end
if Keyword.get(opts, :inserted_at, true) != false do
[{:inserted_at, quote(do: DateTime.t())} | acc]
else
acc
end
%{field: field, typespec: typespec, func: func}, acc ->
acc = [{field, typespec} | acc]
if func == :belongs_to do
# If given spec includes nil, add nil to ID spec too
spec =
case typespec do
{:|, _, [x, y]} when is_nil(x) or is_nil(y) -> quote(do: pos_integer() | nil)
_ -> quote(do: pos_integer())
end
[{String.to_atom("#{field}_id"), spec} | acc]
else
acc
end
end)
|> Enum.reverse()
fieldspecs = Enum.map(fielddatas, & &1.fieldspec)
if table == :embedded do
quote do
use Ecto.Schema
@type t :: %__MODULE__{
unquote_splicing(typespecs),
id: Ecto.UUID.t()
}
embedded_schema do
(unquote_splicing(fieldspecs))
end
end
else
quote do
use Ecto.Schema
@type t :: %__MODULE__{
unquote_splicing(typespecs),
__meta__: Ecto.Schema.Metadata.t(),
id: pos_integer()
}
schema unquote(table) do
(unquote_splicing(fieldspecs))
end
end
end
end
defp parse_spec(ast)
defp parse_spec({:timestamps, _meta, _args} = ast) do
%{
field: :timestamps,
func: :timestamps,
fieldspec: ast,
typespec: nil
}
end
defp parse_spec({func, meta, [field, type, typespec | rest]}) do
%{
field: field,
func: func,
fieldspec: {func, meta, [field, type | rest]},
typespec: typespec
}
end
end

View file

@ -0,0 +1,74 @@
defmodule TalkTool.TypedStruct do
@doc ~S/
Create typed struct with a type, default values, and enforced keys.
Input should be a map where the key names are names of the struct keys and values are the
field information. The value can be a typespec, in which case the field will be enforced, or
a 2-tuple of `{typespec, default_value}`, making the field unenforced.
To prevent ambiguity, a value of `{typespec, :ts_enforced}` will be interpreted as enforced,
this will allow you to typespec a 2-tuple.
NOTE: Due to the ambiguity removal technique above, `:ts_enforced` is not allowed as a default
value.
Example:
```elixir
deftypedstruct(%{
# Enforced with simple type
foo: integer(),
# Enforced 2-tuple typed field, written like this to remove ambiguity
bar: {{String.t(), integer()}, :ts_enforced},
# Non-enforced field with default value
baz: {any(), ""}
}, """
Optional typedoc for the struct type `t`.
""")
```
/
defmacro deftypedstruct(fields, typedoc \\ "") do
fields_list =
case fields do
{:%{}, _, flist} -> flist
_ -> raise ArgumentError, "Fields must be a map!"
end
enforced_list =
fields_list
|> Enum.filter(fn
{_, {_, :ts_enforced}} -> true
{_, {_, _}} -> false
{_, _} -> true
end)
|> Enum.map(&elem(&1, 0))
field_specs =
Enum.map(fields_list, fn
{field, {typespec, :ts_enforced}} ->
{field, typespec}
{field, {typespec, _}} ->
{field, typespec}
{field, typespec} ->
{field, typespec}
end)
field_vals =
Enum.map(fields_list, fn
{field, {_, :ts_enforced}} -> field
{field, {_, default}} -> {field, default}
{field, _} -> field
end)
quote do
@typedoc unquote(typedoc)
@type t :: %__MODULE__{unquote_splicing(field_specs)}
@enforce_keys unquote(enforced_list)
defstruct unquote(field_vals)
end
end
end

40
mix.exs Normal file
View file

@ -0,0 +1,40 @@
defmodule TalkTool.MixProject do
use Mix.Project
def project do
[
app: :talk_tool,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {TalkTool.Application, []}
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:phoenix, "~> 1.6.13"},
{:phoenix_live_view, "~> 0.18.0"},
{:phoenix_html, "~> 3.2"},
{:phoenix_pubsub, "~> 2.1"},
{:phoenix_view, "~> 1.1"},
{:phoenix_live_reload, "~> 1.3"},
{:plug_cowboy, "~> 2.5"},
{:ecto, "~> 3.9.1"},
{:phoenix_ecto, "~> 4.4"},
{:jason, "~> 1.4"},
{:dotenv_parser, "~> 2.0"},
{:cubdb, "~> 2.0"},
{:qr_code, "~> 2.3"}
]
end
end

34
mix.lock Normal file
View file

@ -0,0 +1,34 @@
%{
"bandit": {:hex, :bandit, "0.5.6", "c81618003f457e77548348665450707cac18201cf6a1eb203a4e0f05b75a5701", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:sock, "~> 0.2.5", [hex: :sock, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.5.10", [hex: :thousand_island, repo: "hexpm", optional: false]}], "hexpm", "1e09e18ba5fce47838c57440b3c35733c0878e27dc9b2114669883004301c5c2"},
"castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"},
"cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"},
"cubdb": {:hex, :cubdb, "2.0.1", "24cab8fb4128df704c52ed641f5ed70af352f7a3a80cebbb44c3bbadc3fd5f45", [:mix], [], "hexpm", "57cf25aebfc34f4580d9075da06882b4fe3e0739f5353d4dcc213e9cc1b10cdf"},
"decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
"dotenv_parser": {:hex, :dotenv_parser, "2.0.0", "0f999196857e4ee18cbba1413018d5e4980ab16b397e3a2f8d0cf541fe683181", [:mix], [], "hexpm", "e769bde2dbff5b0cd0d9d877a9ccfd2c6dd84772dfb405d5a43cceb4f93616c5"},
"ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"},
"ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"},
"matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"},
"mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
"phoenix": {:hex, :phoenix, "1.6.13", "5b3152907afdb8d3a6cdafb4b149e8aa7aabbf1422fd9f7ef4c2a67ead57d24a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "13d8806c31176e2066da4df2d7443c144211305c506ed110ad4044335b90171d"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.4.0", "0672ed4e4808b3fbed494dded89958e22fb882de47a97634c0b13e7b0b5f7720", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "09864e558ed31ee00bd48fcc1d4fc58ae9678c9e81649075431e69dbabb43cc1"},
"phoenix_html": {:hex, :phoenix_html, "3.2.0", "1c1219d4b6cb22ac72f12f73dc5fad6c7563104d083f711c3fcd8551a1f4ae11", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "36ec97ba56d25c0136ef1992c37957e4246b649d620958a1f9fa86165f8bc54f"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.3.3", "3a53772a6118d5679bf50fc1670505a290e32a1d195df9e069d8c53ab040c054", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "766796676e5f558dbae5d1bdb066849673e956005e3730dfd5affd7a6da4abac"},
"phoenix_live_view": {:hex, :phoenix_live_view, "0.18.2", "635cf07de947235deb030cd6b776c71a3b790ab04cebf526aa8c879fe17c7784", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6 or ~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "da287a77327e996cc166e4c440c3ad5ab33ccdb151b91c793209b39ebbce5b75"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.1", "ba04e489ef03763bf28a17eb2eaddc2c20c6d217e2150a61e3298b0f4c2012b5", [:mix], [], "hexpm", "81367c6d1eea5878ad726be80808eb5a787a23dee699f96e72b1109c57cdd8d9"},
"phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"},
"plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
"qr_code": {:hex, :qr_code, "2.3.1", "c195d8064921068a28807f93e6ac4f535e0c5d3fa639f73d04a0e2edd94b6eea", [:mix], [{:ex_maybe, "~> 1.1.1", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:matrix_reloaded, "~> 2.3", [hex: :matrix_reloaded, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.2", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "7b98c36dda09a135206ebf49179e67a6bad5002101592ef1374abe01d4fe68fa"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"result": {:hex, :result, "1.7.2", "a57c569f7cf5c158d2299d3b5624a48b69bd1520d0771dc711bcf9f3916e8ab6", [:mix], [], "hexpm", "89f98e98cfbf64237ecf4913aa36b76b80463e087775d19953dc4b435a35f087"},
"sock": {:hex, :sock, "0.2.5", "ba9c7e4b69f1749dc3063e482136af1c1a159e61a8081f5973e62e06186c0f56", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f51c78ab732a968b53ed46990fe749eea9b26d8582a72ef541decfc742e48b14"},
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
"thousand_island": {:hex, :thousand_island, "0.5.11", "5771bd889b16e20bdad2f0ab9831017511c229485f6461193b78e72bf3df51d9", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7e866dac9fdcd9225020021f7df1d8cef1896c0550684e9c0cc28ebb2cc3085b"},
"xml_builder": {:hex, :xml_builder, "2.2.0", "cc5f1eeefcfcde6e90a9b77fb6c490a20bc1b856a7010ce6396f6da9719cbbab", [:mix], [], "hexpm", "9d66d52fb917565d358166a4314078d39ef04d552904de96f8e73f68f64a62c9"},
}

20
priv/static/admin/app.css Normal file
View file

@ -0,0 +1,20 @@
@import "../common/common.css";
@import "./emoji-reaction.css";
@import "./screenshot.css";
.question {
margin-top: 2.5rem;
border-bottom: 1px solid #9b4dca;
}
.question:last-of-type {
border-bottom: none;
}
.answered .question-text {
text-decoration: line-through;
}
#share-qr {
display: none;
}

92
priv/static/admin/app.js Normal file
View file

@ -0,0 +1,92 @@
import { Socket } from "../common/phx.js";
import { LiveSocket } from "../common/lv.js";
import "../common/vendor/topbar.js";
import { emojiReactionHook } from "./emoji-reaction.js";
import { askForScreenshotPerm, takeScreenshot } from "./screenshot.js";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let mediaStream = null;
const hooks = {
EmojiReaction: emojiReactionHook,
Screenshot: {
mounted() {
const check = document.getElementById("screenshot-check");
const preview = document.getElementById("screenshot-preview");
check.addEventListener("click", async () => {
if (check.checked) {
if (!mediaStream) {
const video = document.createElement("video");
video.setAttribute("id", "screenshot-video");
video.setAttribute("autoplay", "autoplay");
preview.appendChild(video);
mediaStream = await askForScreenshotPerm();
if (mediaStream) {
for (const track of mediaStream.getVideoTracks()) {
track.addEventListener("ended", () => {
this.pushEvent("got-screenshot-perm", { has: false });
mediaStream = null;
video.remove();
});
}
video.srcObject = mediaStream;
} else {
check.checked = false;
video.remove();
}
}
this.pushEvent("got-screenshot-perm", { has: !!mediaStream });
} else {
for (const child of Array.from(preview.children)) {
preview.removeChild(child);
}
if (mediaStream) {
for (const track of mediaStream.getVideoTracks()) {
track.stop();
mediaStream = null;
}
}
check.checked = false;
this.pushEvent("got-screenshot-perm", { has: false });
}
});
this.handleEvent("take-screenshot", () => {
if (!mediaStream) {
return;
}
const screenshot = takeScreenshot(mediaStream);
if (screenshot) {
this.pushEvent("took-screenshot", { data: screenshot });
}
});
}
}
};
let liveSocket = new LiveSocket("/live", Socket, { hooks, params: { _csrf_token: csrfToken } });
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
window.addEventListener("phx:page-loading-start", info => topbar.show());
window.addEventListener("phx:page-loading-stop", info => topbar.hide());
// connect if there are any LiveViews on the page
liveSocket.connect();
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket;

View file

@ -0,0 +1,13 @@
#emoji-reactions {
position: fixed;
bottom: 20rem;
}
.emoji-reaction {
display: block;
position: fixed;
left: 0;
font-size: 10rem;
}

View file

@ -0,0 +1,18 @@
const EMOJI_TIME = 5_000;
export const emojiReactionHook = {
mounted() {
let position = Math.random() * window.innerWidth - 50;
position = Math.max(0, position);
position = Math.min(window.innerWidth - 120, position);
this.el.style.left = `${position}px`;
this.el.classList.add("fade-out");
window.setTimeout(() => {
if (this.el) {
this.el.remove();
}
}, EMOJI_TIME);
}
};

View file

@ -0,0 +1,7 @@
#screenshot-video {
max-width: 100%;
}
.screenshot {
max-width: 100%;
}

View file

@ -0,0 +1,41 @@
export async function askForScreenshotPerm() {
try {
return await navigator.mediaDevices.getDisplayMedia({ video: { cursor: "always" } });
} catch (e) {
console.error(e);
return null;
}
}
export function takeScreenshot(mediaStream) {
const canvas = document.createElement("canvas");
const video = document.getElementById("screenshot-video");
try {
const track = mediaStream.getVideoTracks()[0];
if (track && video) {
let { width, height } = track.getSettings();
width = width || window.innerWidth;
height = height || window.innerHeight;
canvas.width = width;
canvas.height = height;
if (video.srcObject !== mediaStream) {
video.srcObject = mediaStream;
}
const context = canvas.getContext("2d");
context.drawImage(video, 0, 0);
const frame = canvas.toDataURL("image/png");
return frame;
} else {
return null;
}
} catch (e) {
console.error(e);
return null;
}
}

View file

@ -0,0 +1,16 @@
@import "../common/common.css";
@import "./emoji-picker.css";
@import "./screenshot.css";
.view-presentation {
display: flex;
flex-direction: column;
max-height: 95vh;
overflow: hidden;
}
.view-presentation #emoji-picker-wrapper {
flex-grow: 1;
min-height: 0;
}

29
priv/static/client/app.js Normal file
View file

@ -0,0 +1,29 @@
import { Socket } from "../common/phx.js";
import { LiveSocket } from "../common/lv.js";
import "../common/vendor/topbar.js";
import { emojiPickerHook } from "./emoji-picker.js";
import { screenshotDownloadHook } from "./screenshot.js";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
const hooks = {
EmojiPicker: emojiPickerHook,
ScreenshotDownload: screenshotDownloadHook,
}
let liveSocket = new LiveSocket("/live", Socket, { hooks, params: { _csrf_token: csrfToken } });
// Show progress bar on live navigation and form submits
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
window.addEventListener("phx:page-loading-start", info => topbar.show());
window.addEventListener("phx:page-loading-stop", info => topbar.hide());
// connect if there are any LiveViews on the page
liveSocket.connect();
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket;

View file

@ -0,0 +1,30 @@
.emoji-picker {
display: flex;
flex-direction: row;
align-items: flex-start;
max-height: 80vh;
}
.emoji-picker-content {
overflow-y: scroll;
align-self: stretch;
}
.emoji-picker-categories button,
.emoji-picker-category button {
background-color: rgba(0, 0, 0, 0);
border: none;
padding: 5px;
font-size: 2.5rem;
}
.emoji-picker-categories {
background-color: #9b4dca;
border-radius: 5px;
}
#emoji-picker-wrapper {
display: none;
}

View file

@ -0,0 +1,14 @@
export const emojiPickerHook = {
mounted() {
this.el.addEventListener("scroll", e => {
const targetId = e.detail?.id;
if (targetId) {
const el = document.getElementById(targetId);
if (el) {
el.scrollIntoView();
}
}
});
}
};

View file

@ -0,0 +1,3 @@
.screenshot {
max-width: 100%;
}

View file

@ -0,0 +1,18 @@
export const screenshotDownloadHook = {
mounted() {
this.el.addEventListener("click", () => {
const img = document.getElementById("screenshot-img");
if (img) {
// Don't you just love web programming?!
const a = document.createElement("a");
a.href = img.getAttribute("src");
a.target = "_blank";
a.download = "screenshot.png";
const evt = new MouseEvent("click");
a.dispatchEvent(evt);
}
});
}
};

View file

@ -0,0 +1,29 @@
@import "./vendor/normalize.css";
@import "./vendor/milligram.css";
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
html {
height: 100%;
}
.fade-out {
opacity: 0;
animation-fill-mode: forwards;
animation: fade-out 1s linear 0s;
}
button,
.button {
font-size: 3rem;
padding: 0 1rem;
height: initial;
}

4066
priv/static/common/lv.js Normal file

File diff suppressed because it is too large Load diff

1121
priv/static/common/phx.js Normal file

File diff suppressed because it is too large Load diff

645
priv/static/common/vendor/milligram.css vendored Normal file
View file

@ -0,0 +1,645 @@
/*!
* Milligram v1.4.1
* https://milligram.io
*
* Copyright (c) 2020 CJ Patoilo
* Licensed under the MIT license
*/
*,
*:after,
*:before {
box-sizing: inherit;
}
html {
box-sizing: border-box;
font-size: 62.5%;
}
body {
color: #606c76;
font-family: 'Roboto', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
font-size: 1.6em;
font-weight: 300;
letter-spacing: .01em;
line-height: 1.6;
}
blockquote {
border-left: 0.3rem solid #d1d1d1;
margin-left: 0;
margin-right: 0;
padding: 1rem 1.5rem;
}
blockquote *:last-child {
margin-bottom: 0;
}
.button,
button,
input[type='button'],
input[type='reset'],
input[type='submit'] {
background-color: #9b4dca;
border: 0.1rem solid #9b4dca;
border-radius: .4rem;
color: #fff;
cursor: pointer;
display: inline-block;
font-size: 1.1rem;
font-weight: 700;
height: 3.8rem;
letter-spacing: .1rem;
line-height: 3.8rem;
padding: 0 3.0rem;
text-align: center;
text-decoration: none;
text-transform: uppercase;
white-space: nowrap;
}
.button:focus,
.button:hover,
button:focus,
button:hover,
input[type='button']:focus,
input[type='button']:hover,
input[type='reset']:focus,
input[type='reset']:hover,
input[type='submit']:focus,
input[type='submit']:hover {
background-color: #606c76;
border-color: #606c76;
color: #fff;
outline: 0;
}
.button[disabled],
button[disabled],
input[type='button'][disabled],
input[type='reset'][disabled],
input[type='submit'][disabled] {
cursor: default;
opacity: .5;
}
.button[disabled]:focus,
.button[disabled]:hover,
button[disabled]:focus,
button[disabled]:hover,
input[type='button'][disabled]:focus,
input[type='button'][disabled]:hover,
input[type='reset'][disabled]:focus,
input[type='reset'][disabled]:hover,
input[type='submit'][disabled]:focus,
input[type='submit'][disabled]:hover {
background-color: #9b4dca;
border-color: #9b4dca;
}
.button.button-outline,
button.button-outline,
input[type='button'].button-outline,
input[type='reset'].button-outline,
input[type='submit'].button-outline {
background-color: transparent;
color: #9b4dca;
}
.button.button-outline:focus,
.button.button-outline:hover,
button.button-outline:focus,
button.button-outline:hover,
input[type='button'].button-outline:focus,
input[type='button'].button-outline:hover,
input[type='reset'].button-outline:focus,
input[type='reset'].button-outline:hover,
input[type='submit'].button-outline:focus,
input[type='submit'].button-outline:hover {
background-color: transparent;
border-color: #606c76;
color: #606c76;
}
.button.button-outline[disabled]:focus,
.button.button-outline[disabled]:hover,
button.button-outline[disabled]:focus,
button.button-outline[disabled]:hover,
input[type='button'].button-outline[disabled]:focus,
input[type='button'].button-outline[disabled]:hover,
input[type='reset'].button-outline[disabled]:focus,
input[type='reset'].button-outline[disabled]:hover,
input[type='submit'].button-outline[disabled]:focus,
input[type='submit'].button-outline[disabled]:hover {
border-color: inherit;
color: #9b4dca;
}
.button.button-clear,
button.button-clear,
input[type='button'].button-clear,
input[type='reset'].button-clear,
input[type='submit'].button-clear {
background-color: transparent;
border-color: transparent;
color: #9b4dca;
}
.button.button-clear:focus,
.button.button-clear:hover,
button.button-clear:focus,
button.button-clear:hover,
input[type='button'].button-clear:focus,
input[type='button'].button-clear:hover,
input[type='reset'].button-clear:focus,
input[type='reset'].button-clear:hover,
input[type='submit'].button-clear:focus,
input[type='submit'].button-clear:hover {
background-color: transparent;
border-color: transparent;
color: #606c76;
}
.button.button-clear[disabled]:focus,
.button.button-clear[disabled]:hover,
button.button-clear[disabled]:focus,
button.button-clear[disabled]:hover,
input[type='button'].button-clear[disabled]:focus,
input[type='button'].button-clear[disabled]:hover,
input[type='reset'].button-clear[disabled]:focus,
input[type='reset'].button-clear[disabled]:hover,
input[type='submit'].button-clear[disabled]:focus,
input[type='submit'].button-clear[disabled]:hover {
color: #9b4dca;
}
code {
background: #f4f5f6;
border-radius: .4rem;
font-size: 86%;
margin: 0 .2rem;
padding: .2rem .5rem;
white-space: nowrap;
}
pre {
background: #f4f5f6;
border-left: 0.3rem solid #9b4dca;
overflow-y: hidden;
}
pre>code {
border-radius: 0;
display: block;
padding: 1rem 1.5rem;
white-space: pre;
}
hr {
border: 0;
border-top: 0.1rem solid #f4f5f6;
margin: 3.0rem 0;
}
input[type='color'],
input[type='date'],
input[type='datetime'],
input[type='datetime-local'],
input[type='email'],
input[type='month'],
input[type='number'],
input[type='password'],
input[type='search'],
input[type='tel'],
input[type='text'],
input[type='url'],
input[type='week'],
input:not([type]),
textarea,
select {
-webkit-appearance: none;
background-color: transparent;
border: 0.1rem solid #d1d1d1;
border-radius: .4rem;
box-shadow: none;
box-sizing: inherit;
height: 3.8rem;
padding: .6rem 1.0rem .7rem;
width: 100%;
}
input[type='color']:focus,
input[type='date']:focus,
input[type='datetime']:focus,
input[type='datetime-local']:focus,
input[type='email']:focus,
input[type='month']:focus,
input[type='number']:focus,
input[type='password']:focus,
input[type='search']:focus,
input[type='tel']:focus,
input[type='text']:focus,
input[type='url']:focus,
input[type='week']:focus,
input:not([type]):focus,
textarea:focus,
select:focus {
border-color: #9b4dca;
outline: 0;
}
select {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%23d1d1d1" d="M0,0l6,8l6-8"/></svg>') center right no-repeat;
padding-right: 3.0rem;
}
select:focus {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 8" width="30"><path fill="%239b4dca" d="M0,0l6,8l6-8"/></svg>');
}
select[multiple] {
background: none;
height: auto;
}
textarea {
min-height: 6.5rem;
}
label,
legend {
display: block;
font-size: 1.6rem;
font-weight: 700;
margin-bottom: .5rem;
}
fieldset {
border-width: 0;
padding: 0;
}
input[type='checkbox'],
input[type='radio'] {
display: inline;
}
.label-inline {
display: inline-block;
font-weight: normal;
margin-left: .5rem;
}
.container {
margin: 0 auto;
max-width: 112.0rem;
padding: 0 2.0rem;
position: relative;
width: 100%;
}
.row {
display: flex;
flex-direction: column;
padding: 0;
width: 100%;
}
.row.row-no-padding {
padding: 0;
}
.row.row-no-padding>.column {
padding: 0;
}
.row.row-wrap {
flex-wrap: wrap;
}
.row.row-top {
align-items: flex-start;
}
.row.row-bottom {
align-items: flex-end;
}
.row.row-center {
align-items: center;
}
.row.row-stretch {
align-items: stretch;
}
.row.row-baseline {
align-items: baseline;
}
.row .column {
display: block;
flex: 1 1 auto;
margin-left: 0;
max-width: 100%;
width: 100%;
}
.row .column.column-offset-10 {
margin-left: 10%;
}
.row .column.column-offset-20 {
margin-left: 20%;
}
.row .column.column-offset-25 {
margin-left: 25%;
}
.row .column.column-offset-33,
.row .column.column-offset-34 {
margin-left: 33.3333%;
}
.row .column.column-offset-40 {
margin-left: 40%;
}
.row .column.column-offset-50 {
margin-left: 50%;
}
.row .column.column-offset-60 {
margin-left: 60%;
}
.row .column.column-offset-66,
.row .column.column-offset-67 {
margin-left: 66.6666%;
}
.row .column.column-offset-75 {
margin-left: 75%;
}
.row .column.column-offset-80 {
margin-left: 80%;
}
.row .column.column-offset-90 {
margin-left: 90%;
}
.row .column.column-10 {
flex: 0 0 10%;
max-width: 10%;
}
.row .column.column-20 {
flex: 0 0 20%;
max-width: 20%;
}
.row .column.column-25 {
flex: 0 0 25%;
max-width: 25%;
}
.row .column.column-33,
.row .column.column-34 {
flex: 0 0 33.3333%;
max-width: 33.3333%;
}
.row .column.column-40 {
flex: 0 0 40%;
max-width: 40%;
}
.row .column.column-50 {
flex: 0 0 50%;
max-width: 50%;
}
.row .column.column-60 {
flex: 0 0 60%;
max-width: 60%;
}
.row .column.column-66,
.row .column.column-67 {
flex: 0 0 66.6666%;
max-width: 66.6666%;
}
.row .column.column-75 {
flex: 0 0 75%;
max-width: 75%;
}
.row .column.column-80 {
flex: 0 0 80%;
max-width: 80%;
}
.row .column.column-90 {
flex: 0 0 90%;
max-width: 90%;
}
.row .column .column-top {
align-self: flex-start;
}
.row .column .column-bottom {
align-self: flex-end;
}
.row .column .column-center {
align-self: center;
}
@media (min-width: 40rem) {
.row {
flex-direction: row;
margin-left: -1.0rem;
width: calc(100% + 2.0rem);
}
.row .column {
margin-bottom: inherit;
padding: 0 1.0rem;
}
}
a {
color: #9b4dca;
text-decoration: none;
}
a:focus,
a:hover {
color: #606c76;
}
dl,
ol,
ul {
list-style: none;
margin-top: 0;
padding-left: 0;
}
dl dl,
dl ol,
dl ul,
ol dl,
ol ol,
ol ul,
ul dl,
ul ol,
ul ul {
font-size: 90%;
margin: 1.5rem 0 1.5rem 3.0rem;
}
ol {
list-style: decimal inside;
}
ul {
list-style: circle inside;
}
.button,
button,
dd,
dt,
li {
margin-bottom: 1.0rem;
}
fieldset,
input,
select,
textarea {
margin-bottom: 1.5rem;
}
blockquote,
dl,
figure,
form,
ol,
p,
pre,
table,
ul {
margin-bottom: 2.5rem;
}
table {
border-spacing: 0;
display: block;
overflow-x: auto;
text-align: left;
width: 100%;
}
td,
th {
border-bottom: 0.1rem solid #e1e1e1;
padding: 1.2rem 1.5rem;
}
td:first-child,
th:first-child {
padding-left: 0;
}
td:last-child,
th:last-child {
padding-right: 0;
}
@media (min-width: 40rem) {
table {
display: table;
overflow-x: initial;
}
}
b,
strong {
font-weight: bold;
}
p {
margin-top: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 300;
letter-spacing: -.1rem;
margin-bottom: 2.0rem;
margin-top: 0;
}
h1 {
font-size: 4.6rem;
line-height: 1.2;
}
h2 {
font-size: 3.6rem;
line-height: 1.25;
}
h3 {
font-size: 2.8rem;
line-height: 1.3;
}
h4 {
font-size: 2.2rem;
letter-spacing: -.08rem;
line-height: 1.35;
}
h5 {
font-size: 1.8rem;
letter-spacing: -.05rem;
line-height: 1.5;
}
h6 {
font-size: 1.6rem;
letter-spacing: 0;
line-height: 1.4;
}
img {
max-width: 100%;
}
.clearfix:after {
clear: both;
content: ' ';
display: table;
}
.float-left {
float: left;
}
.float-right {
float: right;
}

379
priv/static/common/vendor/normalize.css vendored Normal file
View file

@ -0,0 +1,379 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box;
/* 1 */
height: 0;
/* 1 */
overflow: visible;
/* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none;
/* 1 */
text-decoration: underline;
/* 2 */
text-decoration: underline dotted;
/* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
line-height: 1.15;
/* 1 */
margin: 0;
/* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input {
/* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select {
/* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box;
/* 1 */
color: inherit;
/* 2 */
display: table;
/* 1 */
max-width: 100%;
/* 1 */
padding: 0;
/* 3 */
white-space: normal;
/* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
/* 1 */
padding: 0;
/* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

157
priv/static/common/vendor/topbar.js vendored Normal file
View file

@ -0,0 +1,157 @@
/**
* @license MIT
* topbar 1.0.0, 2021-01-06
* http://buunguyen.github.io/topbar
* Copyright (c) 2021 Buu Nguyen
*/
(function (window, document) {
"use strict";
// https://gist.github.com/paulirish/1579671
(function () {
var lastTime = 0;
var vendors = ["ms", "moz", "webkit", "o"];
for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
window.requestAnimationFrame =
window[vendors[x] + "RequestAnimationFrame"];
window.cancelAnimationFrame =
window[vendors[x] + "CancelAnimationFrame"] ||
window[vendors[x] + "CancelRequestAnimationFrame"];
}
if (!window.requestAnimationFrame)
window.requestAnimationFrame = function (callback, element) {
var currTime = new Date().getTime();
var timeToCall = Math.max(0, 16 - (currTime - lastTime));
var id = window.setTimeout(function () {
callback(currTime + timeToCall);
}, timeToCall);
lastTime = currTime + timeToCall;
return id;
};
if (!window.cancelAnimationFrame)
window.cancelAnimationFrame = function (id) {
clearTimeout(id);
};
})();
var canvas,
progressTimerId,
fadeTimerId,
currentProgress,
showing,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
document.body.appendChild(canvas);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function () {
if (showing) return;
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(window, window, document));