Hackfest
This commit is contained in:
commit
c401961e70
74 changed files with 10587 additions and 0 deletions
5
.formatter.exs
Normal file
5
.formatter.exs
Normal 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
29
.gitignore
vendored
Normal 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
2
.tool-versions
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
erlang 25.1.1
|
||||||
|
elixir 1.14.0-otp-25
|
1
README.md
Normal file
1
README.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# TalkTool
|
24
config/config.exs
Normal file
24
config/config.exs
Normal 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
60
config/runtime.exs
Normal 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
|
58
lib/t_t_admin/presentation/pubsub.ex
Normal file
58
lib/t_t_admin/presentation/pubsub.ex
Normal 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
|
108
lib/t_t_admin/presentation/server.ex
Normal file
108
lib/t_t_admin/presentation/server.ex
Normal 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
|
0
lib/t_t_admin/presentation/storage.ex
Normal file
0
lib/t_t_admin/presentation/storage.ex
Normal file
32
lib/t_t_admin/schemas/presentation.ex
Normal file
32
lib/t_t_admin/schemas/presentation.ex
Normal 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
|
28
lib/t_t_admin/schemas/question.ex
Normal file
28
lib/t_t_admin/schemas/question.ex
Normal 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
|
26
lib/t_t_admin/schemas/reaction.ex
Normal file
26
lib/t_t_admin/schemas/reaction.ex
Normal 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
134
lib/t_t_admin/storage/db.ex
Normal 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
|
103
lib/t_t_admin_u_i/endpoint.ex
Normal file
103
lib/t_t_admin_u_i/endpoint.ex
Normal 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
|
7
lib/t_t_admin_u_i/layout_view.ex
Normal file
7
lib/t_t_admin_u_i/layout_view.ex
Normal 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
|
34
lib/t_t_admin_u_i/live/components/presentation.ex
Normal file
34
lib/t_t_admin_u_i/live/components/presentation.ex
Normal 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
|
54
lib/t_t_admin_u_i/live/create_presentation.ex
Normal file
54
lib/t_t_admin_u_i/live/create_presentation.ex
Normal 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
|
16
lib/t_t_admin_u_i/live/create_presentation.html.heex
Normal file
16
lib/t_t_admin_u_i/live/create_presentation.html.heex
Normal 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>
|
16
lib/t_t_admin_u_i/live/main.ex
Normal file
16
lib/t_t_admin_u_i/live/main.ex
Normal 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
|
23
lib/t_t_admin_u_i/live/main.html.heex
Normal file
23
lib/t_t_admin_u_i/live/main.html.heex
Normal 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>
|
165
lib/t_t_admin_u_i/live/view_presentation.ex
Normal file
165
lib/t_t_admin_u_i/live/view_presentation.ex
Normal 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
|
38
lib/t_t_admin_u_i/live/view_presentation.html.heex
Normal file
38
lib/t_t_admin_u_i/live/view_presentation.html.heex
Normal 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>
|
22
lib/t_t_admin_u_i/router.ex
Normal file
22
lib/t_t_admin_u_i/router.ex
Normal 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
|
17
lib/t_t_admin_u_i/templates/layout/live.html.heex
Normal file
17
lib/t_t_admin_u_i/templates/layout/live.html.heex
Normal 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>
|
27
lib/t_t_admin_u_i/templates/layout/root.html.heex
Normal file
27
lib/t_t_admin_u_i/templates/layout/root.html.heex
Normal 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>
|
46
lib/t_t_client_u_i/endpoint.ex
Normal file
46
lib/t_t_client_u_i/endpoint.ex
Normal 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
|
5
lib/t_t_client_u_i/error_view.ex
Normal file
5
lib/t_t_client_u_i/error_view.ex
Normal 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
|
7
lib/t_t_client_u_i/layout_view.ex
Normal file
7
lib/t_t_client_u_i/layout_view.ex
Normal 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
|
84
lib/t_t_client_u_i/live/ask_question.ex
Normal file
84
lib/t_t_client_u_i/live/ask_question.ex
Normal 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
|
36
lib/t_t_client_u_i/live/ask_question.html.heex
Normal file
36
lib/t_t_client_u_i/live/ask_question.html.heex
Normal 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>
|
1942
lib/t_t_client_u_i/live/components/emoji_picker/data.ex
Normal file
1942
lib/t_t_client_u_i/live/components/emoji_picker/data.ex
Normal file
File diff suppressed because it is too large
Load diff
18
lib/t_t_client_u_i/live/components/emoji_picker/emoji.ex
Normal file
18
lib/t_t_client_u_i/live/components/emoji_picker/emoji.ex
Normal 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
|
|
@ -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
|
23
lib/t_t_client_u_i/live/components/screenshot/screenshot.ex
Normal file
23
lib/t_t_client_u_i/live/components/screenshot/screenshot.ex
Normal 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
|
|
@ -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>
|
12
lib/t_t_client_u_i/live/main.ex
Normal file
12
lib/t_t_client_u_i/live/main.ex
Normal 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
|
11
lib/t_t_client_u_i/live/main.html.heex
Normal file
11
lib/t_t_client_u_i/live/main.html.heex
Normal 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>
|
41
lib/t_t_client_u_i/live/screenshot.ex
Normal file
41
lib/t_t_client_u_i/live/screenshot.ex
Normal 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
|
17
lib/t_t_client_u_i/live/screenshot.html.heex
Normal file
17
lib/t_t_client_u_i/live/screenshot.html.heex
Normal 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>
|
51
lib/t_t_client_u_i/live/view_presentation.ex
Normal file
51
lib/t_t_client_u_i/live/view_presentation.ex
Normal 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
|
31
lib/t_t_client_u_i/live/view_presentation.html.heex
Normal file
31
lib/t_t_client_u_i/live/view_presentation.html.heex
Normal 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>
|
23
lib/t_t_client_u_i/router.ex
Normal file
23
lib/t_t_client_u_i/router.ex
Normal 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
|
7
lib/t_t_client_u_i/templates/error/404.html.heex
Normal file
7
lib/t_t_client_u_i/templates/error/404.html.heex
Normal 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>
|
7
lib/t_t_client_u_i/templates/error/500.html.heex
Normal file
7
lib/t_t_client_u_i/templates/error/500.html.heex
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<section>
|
||||||
|
<header>
|
||||||
|
<h1>500</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p>Something blew up, oops.</p>
|
||||||
|
</section>
|
27
lib/t_t_client_u_i/templates/error/root.html.heex
Normal file
27
lib/t_t_client_u_i/templates/error/root.html.heex
Normal 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>
|
11
lib/t_t_client_u_i/templates/layout/live.html.heex
Normal file
11
lib/t_t_client_u_i/templates/layout/live.html.heex
Normal 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>
|
27
lib/t_t_client_u_i/templates/layout/root.html.heex
Normal file
27
lib/t_t_client_u_i/templates/layout/root.html.heex
Normal 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>
|
4
lib/t_t_u_i_common/db_errors.ex
Normal file
4
lib/t_t_u_i_common/db_errors.ex
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
defimpl Plug.Exception, for: TTAdmin.Storage.DB.NoResultsError do
|
||||||
|
def status(_exception), do: 404
|
||||||
|
def actions(_exception), do: []
|
||||||
|
end
|
16
lib/t_t_u_i_common/floc_plug.ex
Normal file
16
lib/t_t_u_i_common/floc_plug.ex
Normal 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
|
6
lib/t_t_u_i_common/plug_helpers.ex
Normal file
6
lib/t_t_u_i_common/plug_helpers.ex
Normal 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
|
28
lib/talk_tool/application.ex
Normal file
28
lib/talk_tool/application.ex
Normal 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
|
35
lib/talk_tool/config_helpers.ex
Normal file
35
lib/talk_tool/config_helpers.ex
Normal 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
|
108
lib/talk_tool/typed_schema.ex
Normal file
108
lib/talk_tool/typed_schema.ex
Normal 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
|
74
lib/talk_tool/typed_struct.ex
Normal file
74
lib/talk_tool/typed_struct.ex
Normal 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
40
mix.exs
Normal 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
34
mix.lock
Normal 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
20
priv/static/admin/app.css
Normal 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
92
priv/static/admin/app.js
Normal 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;
|
||||||
|
|
13
priv/static/admin/emoji-reaction.css
Normal file
13
priv/static/admin/emoji-reaction.css
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
#emoji-reactions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-reaction {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
font-size: 10rem;
|
||||||
|
}
|
18
priv/static/admin/emoji-reaction.js
Normal file
18
priv/static/admin/emoji-reaction.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
7
priv/static/admin/screenshot.css
Normal file
7
priv/static/admin/screenshot.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
#screenshot-video {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screenshot {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
41
priv/static/admin/screenshot.js
Normal file
41
priv/static/admin/screenshot.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
16
priv/static/client/app.css
Normal file
16
priv/static/client/app.css
Normal 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
29
priv/static/client/app.js
Normal 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;
|
||||||
|
|
30
priv/static/client/emoji-picker.css
Normal file
30
priv/static/client/emoji-picker.css
Normal 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;
|
||||||
|
}
|
14
priv/static/client/emoji-picker.js
Normal file
14
priv/static/client/emoji-picker.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
3
priv/static/client/screenshot.css
Normal file
3
priv/static/client/screenshot.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.screenshot {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
18
priv/static/client/screenshot.js
Normal file
18
priv/static/client/screenshot.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
29
priv/static/common/common.css
Normal file
29
priv/static/common/common.css
Normal 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
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
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
645
priv/static/common/vendor/milligram.css
vendored
Normal 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
379
priv/static/common/vendor/normalize.css
vendored
Normal 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
157
priv/static/common/vendor/topbar.js
vendored
Normal 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));
|
Loading…
Reference in a new issue