mirror of
https://github.com/algora-io/tv.git
synced 2024-11-16 00:58:59 +02:00
implement in-video ads & analytics (#59)
This commit is contained in:
parent
199e568a67
commit
c79d0e8f2f
@ -29,4 +29,5 @@ node_modules
|
||||
/.local
|
||||
/priv/cache
|
||||
*.patch
|
||||
/.fly
|
||||
/.fly
|
||||
/xref*
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,4 +41,5 @@ npm-debug.log
|
||||
/.local
|
||||
/priv/cache
|
||||
*.patch
|
||||
/.fly
|
||||
/.fly
|
||||
/xref*
|
4
.iex.exs
4
.iex.exs
@ -4,7 +4,3 @@ import Ecto.Changeset
|
||||
alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows}
|
||||
|
||||
IEx.configure(inspect: [charlists: :as_lists])
|
||||
|
||||
if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do
|
||||
ExSync.register_group_leader()
|
||||
end
|
||||
|
@ -20,6 +20,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 0.25em;
|
||||
height: 0.25em;
|
||||
|
@ -152,8 +152,11 @@ const Hooks = {
|
||||
|
||||
this.playerId = this.el.id;
|
||||
|
||||
// TODO: remove this once we have a better way to handle autoplay
|
||||
const autoplay = this.el.id.startsWith("analytics-") ? false : "any";
|
||||
|
||||
this.player = videojs(this.el, {
|
||||
autoplay: "any",
|
||||
autoplay: autoplay,
|
||||
liveui: true,
|
||||
html5: {
|
||||
vhs: {
|
||||
|
@ -6,16 +6,15 @@ import Config
|
||||
# to provide built-in test partitioning in CI environment.
|
||||
# Run `mix help test` for more information.
|
||||
config :algora, Algora.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
database: "algora_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
hostname: "localhost",
|
||||
url: System.get_env("TEST_DATABASE_URL"),
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: 10
|
||||
|
||||
config :algora, Algora.Repo.Local,
|
||||
url: System.get_env("DATABASE_URL"),
|
||||
url: System.get_env("TEST_DATABASE_URL"),
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: 10,
|
||||
priv: "priv/repo"
|
||||
|
||||
@ -27,7 +26,7 @@ config :algora, AlgoraWeb.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4002],
|
||||
server: false
|
||||
|
||||
config :algora, AlgoraWeb.Embed.Endpoint,
|
||||
config :algora, AlgoraWeb.Embed.Endpoint,
|
||||
http: [ip: {127, 0, 0, 1}, port: 4003],
|
||||
server: false
|
||||
|
||||
|
132
lib/algora/admin/generate_blurb_thumbnails.ex
Normal file
132
lib/algora/admin/generate_blurb_thumbnails.ex
Normal file
@ -0,0 +1,132 @@
|
||||
defmodule Algora.Admin.GenerateBlurbThumbnails do
|
||||
import Ecto.Query
|
||||
alias Algora.Ads.ProductReview
|
||||
alias Algora.{Clipper, Repo}
|
||||
|
||||
def run do
|
||||
for product_review <- fetch_product_reviews() do
|
||||
:ok = process_product_review(product_review)
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_product_reviews do
|
||||
Repo.all(
|
||||
from pr in ProductReview,
|
||||
join: v in assoc(pr, :video),
|
||||
preload: [video: v]
|
||||
)
|
||||
end
|
||||
|
||||
defp process_product_review(product_review) do
|
||||
with {:ok, thumbnail_path} <- create_thumbnail(product_review),
|
||||
{:ok, thumbnail_url} <- upload_thumbnail(thumbnail_path),
|
||||
{:ok, _updated_review} <- update_product_review(product_review, thumbnail_url) do
|
||||
IO.puts("Successfully processed ProductReview #{product_review.id}")
|
||||
else
|
||||
{:error, step, reason} ->
|
||||
IO.puts(
|
||||
"Error processing ProductReview #{product_review.id} at step #{step}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp create_thumbnail(product_review) do
|
||||
video_path = generate_thumbnails(product_review)
|
||||
output_path = "/tmp/thumbnail_#{product_review.id}.jpg"
|
||||
|
||||
case Thumbnex.create_thumbnail(video_path, output_path, time: product_review.clip_from) do
|
||||
:ok -> {:ok, output_path}
|
||||
error -> {:error, :create_thumbnail, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp upload_thumbnail(file_path) do
|
||||
uuid = Ecto.UUID.generate()
|
||||
remote_path = "blurbs/#{uuid}.jpg"
|
||||
|
||||
case Algora.Storage.upload_from_filename(file_path, remote_path, fn _ -> nil end,
|
||||
content_type: "image/jpeg"
|
||||
) do
|
||||
{:ok, _} ->
|
||||
bucket = Algora.config([:buckets, :media])
|
||||
%{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{})
|
||||
thumbnail_url = "#{scheme}#{host}/#{bucket}/#{remote_path}"
|
||||
{:ok, thumbnail_url}
|
||||
|
||||
error ->
|
||||
{:error, :upload_thumbnail, error}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_product_review(product_review, thumbnail_url) do
|
||||
product_review
|
||||
|> Ecto.Changeset.change(thumbnail_url: thumbnail_url)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated_review} -> {:ok, updated_review}
|
||||
error -> {:error, :update_product_review, error}
|
||||
end
|
||||
end
|
||||
|
||||
def generate_thumbnails(product_review) do
|
||||
# Generate clipped manifest
|
||||
%{playlist: playlist, ss: _ss} =
|
||||
Clipper.clip(product_review.video, product_review.clip_from, product_review.clip_to)
|
||||
|
||||
# Find MediaInit and first Segment
|
||||
{init_tag, segment_tag} = find_init_and_segment(playlist.timeline)
|
||||
|
||||
# Download and concatenate files
|
||||
video_path = download_and_concatenate(init_tag, segment_tag, product_review.video)
|
||||
|
||||
video_path
|
||||
end
|
||||
|
||||
defp find_init_and_segment(timeline) do
|
||||
init_tag = Enum.find(timeline, &match?(%ExM3U8.Tags.MediaInit{}, &1))
|
||||
segments = Enum.filter(timeline, &match?(%ExM3U8.Tags.Segment{}, &1))
|
||||
|
||||
segment_tag =
|
||||
case segments do
|
||||
[_, second | _] -> second
|
||||
[first | _] -> first
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
{init_tag, segment_tag}
|
||||
end
|
||||
|
||||
defp download_and_concatenate(init_tag, segment_tag, video) do
|
||||
temp_dir = Path.join(System.tmp_dir!(), video.uuid)
|
||||
File.mkdir_p!(temp_dir)
|
||||
|
||||
output_path = Path.join(temp_dir, "output.mp4")
|
||||
|
||||
init_url = maybe_to_absolute(init_tag.uri, video)
|
||||
segment_url = maybe_to_absolute(segment_tag.uri, video)
|
||||
|
||||
init_path = download_file(init_url, Path.join(temp_dir, "init.mp4"))
|
||||
segment_path = download_file(segment_url, Path.join(temp_dir, "segment.m4s"))
|
||||
|
||||
ffmpeg_command = "ffmpeg -y -i \"concat:#{init_path}|#{segment_path}\" -c copy #{output_path}"
|
||||
System.cmd("sh", ["-c", ffmpeg_command])
|
||||
|
||||
output_path
|
||||
end
|
||||
|
||||
defp download_file(url, path) do
|
||||
{:ok, %{body: body}} = HTTPoison.get(url)
|
||||
File.write!(path, body)
|
||||
path
|
||||
end
|
||||
|
||||
defp maybe_to_absolute(uri, video) do
|
||||
if URI.parse(uri).scheme do
|
||||
uri
|
||||
else
|
||||
Algora.Clipper.to_absolute(:video, video.uuid, uri)
|
||||
end
|
||||
end
|
||||
end
|
279
lib/algora/ads.ex
Normal file
279
lib/algora/ads.ex
Normal file
@ -0,0 +1,279 @@
|
||||
defmodule Algora.Ads do
|
||||
@moduledoc """
|
||||
The Ads context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Algora.Repo
|
||||
|
||||
alias Algora.Ads.{Ad, Visit, Impression, Events, ContentMetrics, Appearance, ProductReview}
|
||||
|
||||
@pubsub Algora.PubSub
|
||||
|
||||
def display_duration, do: :timer.minutes(2)
|
||||
def rotation_interval, do: :timer.minutes(30)
|
||||
|
||||
def unsubscribe_to_ads() do
|
||||
Phoenix.PubSub.unsubscribe(@pubsub, topic())
|
||||
end
|
||||
|
||||
def subscribe_to_ads() do
|
||||
Phoenix.PubSub.subscribe(@pubsub, topic())
|
||||
end
|
||||
|
||||
defp topic(), do: "ads"
|
||||
|
||||
defp broadcast!(topic, msg) do
|
||||
Phoenix.PubSub.broadcast!(@pubsub, topic, {__MODULE__, msg})
|
||||
end
|
||||
|
||||
def broadcast_ad_created!(ad) do
|
||||
broadcast!(topic(), %Events.AdCreated{ad: ad})
|
||||
end
|
||||
|
||||
def broadcast_ad_updated!(ad) do
|
||||
broadcast!(topic(), %Events.AdUpdated{ad: ad})
|
||||
end
|
||||
|
||||
def broadcast_ad_deleted!(ad) do
|
||||
broadcast!(topic(), %Events.AdDeleted{ad: ad})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of ads.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_ads()
|
||||
[%Ad{}, ...]
|
||||
|
||||
"""
|
||||
|
||||
def list_ads do
|
||||
Ad
|
||||
|> order_by(asc: :id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_active_ads do
|
||||
Ad
|
||||
|> where(verified: true, status: :active)
|
||||
|> order_by(asc: :id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single ad.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Ad does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_ad!(123)
|
||||
%Ad{}
|
||||
|
||||
iex> get_ad!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_ad!(id), do: Repo.get!(Ad, id)
|
||||
|
||||
def get_ad_by_slug!(slug) do
|
||||
Repo.get_by!(Ad, slug: slug)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a ad.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_ad(%{field: value})
|
||||
{:ok, %Ad{}}
|
||||
|
||||
iex> create_ad(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_ad(attrs \\ %{}) do
|
||||
%Ad{status: :active, verified: true}
|
||||
|> Ad.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a ad.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_ad(ad, %{field: new_value})
|
||||
{:ok, %Ad{}}
|
||||
|
||||
iex> update_ad(ad, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_ad(%Ad{} = ad, attrs) do
|
||||
ad
|
||||
|> Ad.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a ad.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_ad(ad)
|
||||
{:ok, %Ad{}}
|
||||
|
||||
iex> delete_ad(ad)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_ad(%Ad{} = ad) do
|
||||
Repo.delete(ad)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking ad changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_ad(ad)
|
||||
%Ecto.Changeset{data: %Ad{}}
|
||||
|
||||
"""
|
||||
def change_ad(%Ad{} = ad, attrs \\ %{}) do
|
||||
Ad.changeset(ad, attrs)
|
||||
end
|
||||
|
||||
def track_visit(attrs \\ %{}) do
|
||||
%Visit{}
|
||||
|> Visit.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def track_impressions(attrs \\ %{}) do
|
||||
%Impression{}
|
||||
|> Impression.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def get_current_index(ads) do
|
||||
:os.system_time(:millisecond)
|
||||
|> div(rotation_interval())
|
||||
|> rem(length(ads))
|
||||
end
|
||||
|
||||
def rotate_ads(ads, index \\ nil) do
|
||||
index = index || get_current_index(ads)
|
||||
Enum.concat(Enum.drop(ads, index), Enum.take(ads, index))
|
||||
end
|
||||
|
||||
def next_slot(time \\ DateTime.utc_now()) do
|
||||
time
|
||||
|> DateTime.truncate(:millisecond)
|
||||
|> DateTime.add(ms_until_next_slot(time), :millisecond)
|
||||
end
|
||||
|
||||
def time_until_next_slot(time \\ DateTime.utc_now()) do
|
||||
DateTime.diff(next_slot(time), time, :millisecond)
|
||||
end
|
||||
|
||||
defp ms_until_next_slot(time) do
|
||||
rotation_interval() - rem(DateTime.to_unix(time, :millisecond), rotation_interval())
|
||||
end
|
||||
|
||||
def list_content_metrics do
|
||||
Repo.all(ContentMetrics)
|
||||
|> Repo.preload(video: [:appearances, :product_reviews])
|
||||
end
|
||||
|
||||
def create_content_metrics(attrs \\ %{}) do
|
||||
%ContentMetrics{}
|
||||
|> ContentMetrics.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def change_content_metrics(%ContentMetrics{} = content_metrics, attrs \\ %{}) do
|
||||
ContentMetrics.changeset(content_metrics, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single content_metrics.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the ContentMetrics does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_content_metrics!(123)
|
||||
%ContentMetrics{}
|
||||
|
||||
iex> get_content_metrics!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_content_metrics!(id) do
|
||||
Repo.get!(ContentMetrics, id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a content_metrics.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_content_metrics(content_metrics, %{field: new_value})
|
||||
{:ok, %ContentMetrics{}}
|
||||
|
||||
iex> update_content_metrics(content_metrics, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_content_metrics(%ContentMetrics{} = content_metrics, attrs) do
|
||||
content_metrics
|
||||
|> ContentMetrics.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def create_appearance(attrs \\ %{}) do
|
||||
%Appearance{}
|
||||
|> Appearance.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def change_appearance(%Appearance{} = appearance, attrs \\ %{}) do
|
||||
Appearance.changeset(appearance, attrs)
|
||||
end
|
||||
|
||||
def create_product_review(attrs \\ %{}) do
|
||||
%ProductReview{}
|
||||
|> ProductReview.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def change_product_review(%ProductReview{} = product_review, attrs \\ %{}) do
|
||||
ProductReview.changeset(product_review, attrs)
|
||||
end
|
||||
|
||||
def list_appearances(ad) do
|
||||
Appearance
|
||||
|> where(ad_id: ^ad.id)
|
||||
|> preload(video: :user)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_product_reviews(ad) do
|
||||
ProductReview
|
||||
|> where(ad_id: ^ad.id)
|
||||
|> preload(video: :user)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_content_metrics(appearances) do
|
||||
video_ids = Enum.map(appearances, & &1.video_id)
|
||||
|
||||
ContentMetrics
|
||||
|> where([cm], cm.video_id in ^video_ids)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
60
lib/algora/ads/ad.ex
Normal file
60
lib/algora/ads/ad.ex
Normal file
@ -0,0 +1,60 @@
|
||||
defmodule Algora.Ads.Ad do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "ads" do
|
||||
field :slug, :string
|
||||
field :name, :string
|
||||
field :status, Ecto.Enum, values: [:inactive, :active]
|
||||
field :verified, :boolean, default: false
|
||||
field :website_url, :string
|
||||
field :composite_asset_url, :string
|
||||
field :asset_url, :string
|
||||
field :logo_url, :string
|
||||
field :qrcode_url, :string
|
||||
field :og_image_url, :string
|
||||
field :start_date, :naive_datetime
|
||||
field :end_date, :naive_datetime
|
||||
field :total_budget, :integer
|
||||
field :daily_budget, :integer
|
||||
field :tech_stack, {:array, :string}
|
||||
field :user_id, :id
|
||||
field :border_color, :string
|
||||
field :scheduled_for, :utc_datetime, virtual: true
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(ad, attrs) do
|
||||
ad
|
||||
|> cast(attrs, [
|
||||
:slug,
|
||||
:name,
|
||||
:verified,
|
||||
:website_url,
|
||||
:composite_asset_url,
|
||||
:asset_url,
|
||||
:logo_url,
|
||||
:qrcode_url,
|
||||
:og_image_url,
|
||||
:start_date,
|
||||
:end_date,
|
||||
:total_budget,
|
||||
:daily_budget,
|
||||
:tech_stack,
|
||||
:status,
|
||||
:border_color
|
||||
])
|
||||
|> validate_required([
|
||||
:slug,
|
||||
:website_url,
|
||||
:composite_asset_url,
|
||||
:border_color
|
||||
])
|
||||
|> validate_format(:border_color, ~r/^#([0-9A-F]{3}){1,2}$/i,
|
||||
message: "must be a valid hex color code"
|
||||
)
|
||||
|> unique_constraint(:slug)
|
||||
end
|
||||
end
|
23
lib/algora/ads/appearance.ex
Normal file
23
lib/algora/ads/appearance.ex
Normal file
@ -0,0 +1,23 @@
|
||||
defmodule Algora.Ads.Appearance do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Algora.Library.Video
|
||||
alias Algora.Ads.Ad
|
||||
|
||||
schema "ad_appearances" do
|
||||
field :airtime, :integer
|
||||
|
||||
belongs_to :ad, Ad
|
||||
belongs_to :video, Video
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(appearance, attrs) do
|
||||
appearance
|
||||
|> cast(attrs, [:airtime, :ad_id, :video_id])
|
||||
|> validate_required([:airtime, :ad_id, :video_id])
|
||||
|> foreign_key_constraint(:ad_id)
|
||||
|> foreign_key_constraint(:video_id)
|
||||
end
|
||||
end
|
37
lib/algora/ads/content_metrics.ex
Normal file
37
lib/algora/ads/content_metrics.ex
Normal file
@ -0,0 +1,37 @@
|
||||
defmodule Algora.Ads.ContentMetrics do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Algora.Library.Video
|
||||
|
||||
schema "content_metrics" do
|
||||
field :algora_stream_url, :string
|
||||
field :twitch_stream_url, :string
|
||||
field :youtube_video_url, :string
|
||||
field :twitter_video_url, :string
|
||||
field :twitch_avg_concurrent_viewers, :integer
|
||||
field :twitch_views, :integer
|
||||
field :youtube_views, :integer
|
||||
field :twitter_views, :integer
|
||||
|
||||
belongs_to :video, Video
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(content_metrics, attrs) do
|
||||
content_metrics
|
||||
|> cast(attrs, [
|
||||
:algora_stream_url,
|
||||
:twitch_stream_url,
|
||||
:youtube_video_url,
|
||||
:twitter_video_url,
|
||||
:twitch_avg_concurrent_viewers,
|
||||
:twitch_views,
|
||||
:youtube_views,
|
||||
:twitter_views,
|
||||
:video_id
|
||||
])
|
||||
|> validate_required([:video_id])
|
||||
|> foreign_key_constraint(:video_id)
|
||||
end
|
||||
end
|
13
lib/algora/ads/events.ex
Normal file
13
lib/algora/ads/events.ex
Normal file
@ -0,0 +1,13 @@
|
||||
defmodule Algora.Ads.Events do
|
||||
defmodule AdCreated do
|
||||
defstruct ad: nil
|
||||
end
|
||||
|
||||
defmodule AdUpdated do
|
||||
defstruct ad: nil
|
||||
end
|
||||
|
||||
defmodule AdDeleted do
|
||||
defstruct ad: nil
|
||||
end
|
||||
end
|
20
lib/algora/ads/impression.ex
Normal file
20
lib/algora/ads/impression.ex
Normal file
@ -0,0 +1,20 @@
|
||||
defmodule Algora.Ads.Impression do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "ad_impressions" do
|
||||
field :duration, :integer
|
||||
field :viewers_count, :integer
|
||||
field :ad_id, :id
|
||||
field :video_id, :id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(impression, attrs) do
|
||||
impression
|
||||
|> cast(attrs, [:duration, :viewers_count, :ad_id, :video_id])
|
||||
|> validate_required([:duration, :viewers_count, :ad_id])
|
||||
end
|
||||
end
|
25
lib/algora/ads/product_review.ex
Normal file
25
lib/algora/ads/product_review.ex
Normal file
@ -0,0 +1,25 @@
|
||||
defmodule Algora.Ads.ProductReview do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Algora.Library.Video
|
||||
alias Algora.Ads.Ad
|
||||
|
||||
schema "product_reviews" do
|
||||
field :clip_from, :integer
|
||||
field :clip_to, :integer
|
||||
field :thumbnail_url, :string
|
||||
|
||||
belongs_to :ad, Ad
|
||||
belongs_to :video, Video
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
def changeset(product_review, attrs) do
|
||||
product_review
|
||||
|> cast(attrs, [:clip_from, :clip_to, :thumbnail_url, :ad_id, :video_id])
|
||||
|> validate_required([:clip_from, :clip_to, :ad_id, :video_id])
|
||||
|> foreign_key_constraint(:ad_id)
|
||||
|> foreign_key_constraint(:video_id)
|
||||
end
|
||||
end
|
18
lib/algora/ads/visit.ex
Normal file
18
lib/algora/ads/visit.ex
Normal file
@ -0,0 +1,18 @@
|
||||
defmodule Algora.Ads.Visit do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "ad_visits" do
|
||||
field :ad_id, :id
|
||||
field :video_id, :id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(visit, attrs) do
|
||||
visit
|
||||
|> cast(attrs, [:ad_id, :video_id])
|
||||
|> validate_required([:ad_id])
|
||||
end
|
||||
end
|
@ -3,10 +3,10 @@ defmodule Algora.Clipper do
|
||||
|
||||
defp bucket(), do: Algora.config([:buckets, :media])
|
||||
|
||||
defp to_absolute(:video, uuid, uri),
|
||||
def to_absolute(:video, uuid, uri),
|
||||
do: "#{Storage.endpoint_url()}/#{bucket()}/#{uuid}/#{uri}"
|
||||
|
||||
defp to_absolute(:clip, uuid, uri),
|
||||
def to_absolute(:clip, uuid, uri),
|
||||
do: "#{Storage.endpoint_url()}/#{bucket()}/clips/#{uuid}/#{uri}"
|
||||
|
||||
def clip(video, from, to) do
|
||||
|
104
lib/algora/contact.ex
Normal file
104
lib/algora/contact.ex
Normal file
@ -0,0 +1,104 @@
|
||||
defmodule Algora.Contact do
|
||||
@moduledoc """
|
||||
The Contact context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Algora.Repo
|
||||
|
||||
alias Algora.Contact.Info
|
||||
|
||||
@doc """
|
||||
Returns the list of contact_info.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_contact_info()
|
||||
[%Info{}, ...]
|
||||
|
||||
"""
|
||||
def list_contact_info do
|
||||
Repo.all(Info)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single info.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Info does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_info!(123)
|
||||
%Info{}
|
||||
|
||||
iex> get_info!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_info!(id), do: Repo.get!(Info, id)
|
||||
|
||||
@doc """
|
||||
Creates a info.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_info(%{field: value})
|
||||
{:ok, %Info{}}
|
||||
|
||||
iex> create_info(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_info(attrs \\ %{}) do
|
||||
%Info{}
|
||||
|> Info.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a info.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_info(info, %{field: new_value})
|
||||
{:ok, %Info{}}
|
||||
|
||||
iex> update_info(info, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_info(%Info{} = info, attrs) do
|
||||
info
|
||||
|> Info.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a info.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_info(info)
|
||||
{:ok, %Info{}}
|
||||
|
||||
iex> delete_info(info)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_info(%Info{} = info) do
|
||||
Repo.delete(info)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking info changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_info(info)
|
||||
%Ecto.Changeset{data: %Info{}}
|
||||
|
||||
"""
|
||||
def change_info(%Info{} = info, attrs \\ %{}) do
|
||||
Info.changeset(info, attrs)
|
||||
end
|
||||
end
|
20
lib/algora/contact/info.ex
Normal file
20
lib/algora/contact/info.ex
Normal file
@ -0,0 +1,20 @@
|
||||
defmodule Algora.Contact.Info do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "contact_info" do
|
||||
field :email, :string
|
||||
field :website_url, :string
|
||||
field :revenue, :string
|
||||
field :company_location, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(info, attrs) do
|
||||
info
|
||||
|> cast(attrs, [:email, :website_url, :revenue, :company_location])
|
||||
|> validate_required([:email])
|
||||
end
|
||||
end
|
@ -355,6 +355,23 @@ defmodule Algora.Library do
|
||||
to_hhmmss(trunc(duration))
|
||||
end
|
||||
|
||||
def from_hhmmss(timestamp) do
|
||||
case String.split(timestamp, ":") do
|
||||
[hours, minutes, seconds] ->
|
||||
String.to_integer(hours) * 3600 + String.to_integer(minutes) * 60 +
|
||||
String.to_integer(seconds)
|
||||
|
||||
[minutes, seconds] ->
|
||||
String.to_integer(minutes) * 60 + String.to_integer(seconds)
|
||||
|
||||
[seconds] ->
|
||||
String.to_integer(seconds)
|
||||
|
||||
_ ->
|
||||
raise ArgumentError, "Invalid time format. Expected hh:mm:ss"
|
||||
end
|
||||
end
|
||||
|
||||
def unsubscribe_to_channel(%Channel{} = channel) do
|
||||
Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id))
|
||||
end
|
||||
@ -557,6 +574,21 @@ defmodule Algora.Library do
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_all_videos(limit \\ 100) do
|
||||
from(v in Video,
|
||||
join: u in User,
|
||||
on: v.user_id == u.id,
|
||||
limit: ^limit,
|
||||
select_merge: %{
|
||||
channel_handle: u.handle,
|
||||
channel_name: u.name,
|
||||
channel_avatar_url: u.avatar_url
|
||||
}
|
||||
)
|
||||
|> order_by_inserted(:desc)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_livestreams(limit \\ 100) do
|
||||
from(v in Video,
|
||||
join: u in User,
|
||||
|
@ -7,6 +7,7 @@ defmodule Algora.Library.Video do
|
||||
alias Algora.Library.Video
|
||||
alias Algora.Shows.Show
|
||||
alias Algora.Chat.Message
|
||||
alias Algora.Ads.{Appearance, ContentMetrics, ProductReview}
|
||||
|
||||
@type t() :: %__MODULE__{}
|
||||
|
||||
@ -37,6 +38,9 @@ defmodule Algora.Library.Video do
|
||||
belongs_to :transmuxed_from, Video
|
||||
|
||||
has_many :messages, Message
|
||||
has_many :appearances, Appearance
|
||||
has_many :content_metrics, ContentMetrics
|
||||
has_many :product_reviews, ProductReview
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
@ -54,6 +54,12 @@
|
||||
</script>
|
||||
<script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="fixed inset-0 bg-[radial-gradient(ellipse_at_top_left,_#1d1e3a_0%,_#050217_40%,_#050217_60%,_#1d1e3a_100%)] z-0">
|
||||
|
@ -54,6 +54,12 @@
|
||||
</script>
|
||||
<script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
|
||||
</script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<%= @inner_content %>
|
||||
|
40
lib/algora_web/components/tech_icon.ex
Normal file
40
lib/algora_web/components/tech_icon.ex
Normal file
@ -0,0 +1,40 @@
|
||||
defmodule AlgoraWeb.Components.TechIcon do
|
||||
use Phoenix.Component
|
||||
|
||||
def tech_icon(assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<%= case @tech do %>
|
||||
<% "TypeScript" -> %>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 17.5c.32 .32 .754 .5 1.207 .5h.543c.69 0 1.25 -.56 1.25 -1.25v-.25a1.5 1.5 0 0 0 -1.5 -1.5a1.5 1.5 0 0 1 -1.5 -1.5v-.25c0 -.69 .56 -1.25 1.25 -1.25h.543c.453 0 .887 .18 1.207 .5" />
|
||||
<path d="M9 12h4" />
|
||||
<path d="M11 12v6" />
|
||||
<path d="M21 19v-14a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2 -2z" />
|
||||
<% "PHP" -> %>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 12m-10 0a10 9 0 1 0 20 0a10 9 0 1 0 -20 0" />
|
||||
<path d="M5.5 15l.395 -1.974l.605 -3.026h1.32a1 1 0 0 1 .986 1.164l-.167 1a1 1 0 0 1 -.986 .836h-1.653" />
|
||||
<path d="M15.5 15l.395 -1.974l.605 -3.026h1.32a1 1 0 0 1 .986 1.164l-.167 1a1 1 0 0 1 -.986 .836h-1.653" />
|
||||
<path d="M12 7.5l-1 5.5" />
|
||||
<path d="M11.6 10h2.4l-.5 3" />
|
||||
<% _ -> %>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 8l-4 4l4 4" />
|
||||
<path d="M17 8l4 4l-4 4" />
|
||||
<path d="M14 4l-4 16" />
|
||||
<% end %>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
end
|
15
lib/algora_web/controllers/ad_redirect_controller.ex
Normal file
15
lib/algora_web/controllers/ad_redirect_controller.ex
Normal file
@ -0,0 +1,15 @@
|
||||
defmodule AlgoraWeb.AdRedirectController do
|
||||
use AlgoraWeb, :controller
|
||||
alias Algora.Ads
|
||||
|
||||
def go(conn, %{"slug" => slug}) do
|
||||
ad = Ads.get_ad_by_slug!(slug)
|
||||
|
||||
## TODO: log errors
|
||||
Ads.track_visit(%{ad_id: ad.id})
|
||||
|
||||
conn
|
||||
|> put_status(:found)
|
||||
|> redirect(external: ad.website_url)
|
||||
end
|
||||
end
|
174
lib/algora_web/live/ad_live/analytics.ex
Normal file
174
lib/algora_web/live/ad_live/analytics.ex
Normal file
@ -0,0 +1,174 @@
|
||||
defmodule AlgoraWeb.AdLive.Analytics do
|
||||
use AlgoraWeb, :live_view
|
||||
alias AlgoraWeb.Components.TechIcon
|
||||
alias AlgoraWeb.RTMPDestinationIconComponent
|
||||
|
||||
alias Algora.{Ads, Library}
|
||||
alias AlgoraWeb.PlayerComponent
|
||||
|
||||
@impl true
|
||||
def mount(%{"slug" => slug}, _session, socket) do
|
||||
ad = Ads.get_ad_by_slug!(slug)
|
||||
|
||||
%{
|
||||
stats: stats,
|
||||
appearances: appearances,
|
||||
product_reviews: product_reviews,
|
||||
product_review: product_review
|
||||
} = fetch_ad_stats(ad)
|
||||
|
||||
blurb =
|
||||
if product_review,
|
||||
do: %{
|
||||
video: Library.get_video!(product_review.video_id),
|
||||
current_time: product_review.clip_from
|
||||
}
|
||||
|
||||
if connected?(socket) do
|
||||
if blurb do
|
||||
send_update(PlayerComponent, %{
|
||||
id: "analytics-player",
|
||||
video: blurb.video,
|
||||
current_user: socket.assigns.current_user,
|
||||
current_time: blurb.current_time
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(ad: ad)
|
||||
|> assign(stats: stats)
|
||||
|> assign(appearances: appearances)
|
||||
|> assign(product_review: product_review)
|
||||
|> assign(product_reviews: product_reviews)
|
||||
|> assign(blurb: blurb)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(_arg, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
socket =
|
||||
cond do
|
||||
socket.assigns.ad.og_image_url ->
|
||||
assign(socket, :page_image, socket.assigns.ad.og_image_url)
|
||||
|
||||
socket.assigns.product_review ->
|
||||
assign(socket, :page_image, socket.assigns.product_review.thumbnail_url)
|
||||
|
||||
true ->
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, socket.assigns.ad.name)
|
||||
|> assign(
|
||||
:page_description,
|
||||
"Discover the appearances of #{socket.assigns.ad.name} in livestreams and videos"
|
||||
)}
|
||||
end
|
||||
|
||||
defp fetch_ad_stats(ad) do
|
||||
appearances = Ads.list_appearances(ad)
|
||||
content_metrics = Ads.list_content_metrics(appearances)
|
||||
|
||||
product_reviews =
|
||||
Ads.list_product_reviews(ad) |> Enum.sort_by(&(&1.clip_to - &1.clip_from), :desc)
|
||||
|
||||
twitch_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitch_views end)
|
||||
youtube_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.youtube_views end)
|
||||
twitter_views = Enum.reduce(content_metrics, 0, fn cm, acc -> acc + cm.twitter_views end)
|
||||
|
||||
tech_stack_data =
|
||||
appearances
|
||||
|> group_data_by_tech_stack(content_metrics)
|
||||
|> Enum.sort_by(fn {_, d} -> d.views end, :desc)
|
||||
|
||||
product_review = List.first(product_reviews)
|
||||
|
||||
views =
|
||||
%{
|
||||
"Twitch" => twitch_views,
|
||||
"YouTube" => youtube_views,
|
||||
"Twitter" => twitter_views
|
||||
}
|
||||
|> Enum.sort_by(fn {_, v} -> v end, :desc)
|
||||
|
||||
%{
|
||||
stats: %{
|
||||
views: views,
|
||||
total_views: twitch_views + youtube_views + twitter_views,
|
||||
airtime: calculate_total_airtime(appearances),
|
||||
streams: length(appearances),
|
||||
creators: length(Enum.uniq_by(appearances, & &1.video.user.id)),
|
||||
tech_stack_data: tech_stack_data
|
||||
},
|
||||
appearances: appearances,
|
||||
product_reviews: product_reviews,
|
||||
product_review: product_review
|
||||
}
|
||||
end
|
||||
|
||||
defp group_data_by_tech_stack(appearances, content_metrics) do
|
||||
appearances
|
||||
|> Enum.zip(content_metrics)
|
||||
|> Enum.reduce(%{}, fn {appearance, metrics}, acc ->
|
||||
tech_stack = get_tech_stack(appearance.video.user.id)
|
||||
total_views = metrics.twitch_views + metrics.youtube_views + metrics.twitter_views
|
||||
creator = appearance.video.user
|
||||
|
||||
Map.update(acc, tech_stack, %{views: total_views, creators: [creator]}, fn existing ->
|
||||
%{
|
||||
views: existing.views + total_views,
|
||||
creators: [creator | existing.creators] |> Enum.uniq()
|
||||
}
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
# TODO: This is a hack, we need to get the tech stack from the user's profile
|
||||
defp get_tech_stack(user_id) do
|
||||
case user_id do
|
||||
7 -> "TypeScript"
|
||||
109 -> "TypeScript"
|
||||
307 -> "PHP"
|
||||
_ -> "Other"
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_total_airtime(appearances) do
|
||||
appearances
|
||||
|> Enum.reduce(0, fn appearance, acc -> acc + appearance.airtime end)
|
||||
|> format_duration()
|
||||
end
|
||||
|
||||
defp format_duration(seconds) do
|
||||
hours = div(seconds, 3600)
|
||||
minutes = div(rem(seconds, 3600), 60)
|
||||
remaining_seconds = rem(seconds, 60)
|
||||
|
||||
cond do
|
||||
hours > 0 -> "#{hours}h #{minutes}m #{remaining_seconds}s"
|
||||
minutes > 0 -> "#{minutes}m #{remaining_seconds}s"
|
||||
true -> "#{remaining_seconds}s"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_number(number) when number >= 1_000_000 do
|
||||
:io_lib.format("~.1fM", [number / 1_000_000]) |> to_string()
|
||||
end
|
||||
|
||||
defp format_number(number) when number >= 1_000 do
|
||||
:io_lib.format("~.1fK", [number / 1_000]) |> to_string()
|
||||
end
|
||||
|
||||
defp format_number(number), do: to_string(number)
|
||||
|
||||
defp tech_icon(assigns), do: TechIcon.tech_icon(assigns)
|
||||
defp source_icon(assigns), do: RTMPDestinationIconComponent.icon(assigns)
|
||||
end
|
311
lib/algora_web/live/ad_live/analytics.html.heex
Normal file
311
lib/algora_web/live/ad_live/analytics.html.heex
Normal file
@ -0,0 +1,311 @@
|
||||
<div class="min-h-screen text-white max-w-7xl mx-auto font-display">
|
||||
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 px-4 gap-4">
|
||||
<div class="flex items-center gap-2 text-lg font-semibold">
|
||||
<img src={@ad.logo_url} class="h-12 w-12 rounded-full" />
|
||||
<a
|
||||
href={@ad.website_url |> String.replace(~r/[?#].*$/, "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="truncate max-w-[300px]"
|
||||
>
|
||||
<span>
|
||||
<%= @ad.website_url
|
||||
|> String.replace(~r/[?#].*$/, "")
|
||||
|> String.replace(~r/^https?:\/\//, "")
|
||||
|> String.replace(~r/\/$/, "") %>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<img
|
||||
src={@ad.composite_asset_url}
|
||||
alt={@ad.website_url}
|
||||
class="box-content max-h-16 max-w-screen aspect-[1092/135] object-cover border-[3px] rounded-md lg:rounded-xl"
|
||||
style={"border-color: #{@ad.border_color || "#fff"}"}
|
||||
/>
|
||||
<div class="shrink-0 hidden sm:flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
disabled
|
||||
class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 bg-white/5 ring-1 ring-white/10 text-white"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-controls="radix-:r7:"
|
||||
aria-expanded="false"
|
||||
aria-autocomplete="none"
|
||||
dir="ltr"
|
||||
data-state="closed"
|
||||
data-placeholder=""
|
||||
class="flex h-10 w-full items-center justify-between rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-white/5 ring-1 ring-white/10 text-white"
|
||||
>
|
||||
<span style="pointer-events: none;" class="mr-1">Last 30 days</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-down h-4 w-4 opacity-50"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="space-y-4 px-4">
|
||||
<div
|
||||
class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10"
|
||||
data-v0-t="card"
|
||||
>
|
||||
<div class="flex p-6 justify-center items-center">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Views</p>
|
||||
<p class="text-2xl font-bold"><%= format_number(@stats.total_views) %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Airtime</p>
|
||||
<p class="text-2xl font-bold"><%= @stats.airtime %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Streams</p>
|
||||
<p class="text-2xl font-bold"><%= @stats.streams %></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-300 tracking-tight font-bold uppercase">Creators</p>
|
||||
<p class="text-2xl font-bold"><%= @stats.creators %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div
|
||||
class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6"
|
||||
data-v0-t="card"
|
||||
>
|
||||
<h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3">
|
||||
Top Sources
|
||||
</h3>
|
||||
<div>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0">
|
||||
Source
|
||||
</th>
|
||||
<th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right">
|
||||
Views
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
<%= for {source, views} <- @stats.views do %>
|
||||
<%= if views > 0 do %>
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 flex items-center gap-2">
|
||||
<.source_icon icon={source |> String.downcase() |> String.to_atom()} />
|
||||
<%= source %>
|
||||
</td>
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right">
|
||||
<%= format_number(views) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6"
|
||||
data-v0-t="card"
|
||||
>
|
||||
<h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3">
|
||||
Top Languages
|
||||
</h3>
|
||||
<div>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0">
|
||||
Language
|
||||
</th>
|
||||
<th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-center">
|
||||
Creators
|
||||
</th>
|
||||
<th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right">
|
||||
Views
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
<%= for {tech, data} <- @stats.tech_stack_data do %>
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 flex items-center gap-2">
|
||||
<.tech_icon tech={tech} /> <%= tech %>
|
||||
</td>
|
||||
<td class="align-middle [&:has([role=checkbox])]:pr-0">
|
||||
<div class="flex items-center justify-center -space-x-1">
|
||||
<%= for creator <- Enum.take(data.creators, 5) do %>
|
||||
<img
|
||||
class="inline-block h-8 w-8 rounded-full ring-4 ring-[#16112f]"
|
||||
src={creator.avatar_url}
|
||||
alt={"Avatar of #{creator.name}"}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right">
|
||||
<%= format_number(data.views) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :if={@blurb.video} class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="w-full cursor-pointer">
|
||||
<.live_component module={PlayerComponent} id="analytics-player" />
|
||||
</div>
|
||||
<.link
|
||||
href={"/#{@blurb.video.channel_handle}/#{@blurb.video.id}"}
|
||||
class="hidden sm:block w-full max-w-sm p-6 bg-gray-800/40 hover:bg-gray-800/60 overflow-hidden rounded-lg lg:rounded-2xl shadow-inner shadow-white/[10%] lg:border border-white/[15%] hover:border/white/[20%]"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative h-20 w-20 shrink-0">
|
||||
<img
|
||||
src={@blurb.video.channel_avatar_url}
|
||||
alt={@blurb.video.channel_handle}
|
||||
class="w-full h-full p-1 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-3xl font-semibold"><%= @blurb.video.channel_name %></div>
|
||||
<div class="font-medium text-gray-300">@<%= @blurb.video.channel_handle %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4 font-medium text-gray-100"><%= @blurb.video.title %></div>
|
||||
<div class="pt-1 text-sm font-medium text-gray-300">
|
||||
Streamed on <%= @blurb.video.inserted_at |> Calendar.strftime("%b %d, %Y") %>
|
||||
</div>
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-lg lg:rounded-2xl border text-card-foreground shadow-inner bg-white/5 ring-1 ring-white/10 p-6"
|
||||
data-v0-t="card"
|
||||
>
|
||||
<h3 class="whitespace-nowrap text-xl font-semibold leading-none tracking-tight mb-3 ml-3">
|
||||
Appearances
|
||||
</h3>
|
||||
<div>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table class="w-full caption-bottom text-sm">
|
||||
<thead class="[&_tr]:border-b">
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<th class="h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0">
|
||||
Livestream
|
||||
</th>
|
||||
<th class="h-12 px-4 align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 text-right">
|
||||
Airtime
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="[&_tr:last-child]:border-0">
|
||||
<%= for product_review <- @product_reviews do %>
|
||||
<tr class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0">
|
||||
<.link
|
||||
navigate={"/#{product_review.video.user.handle}/#{product_review.video.id}?t=#{product_review.clip_from}"}
|
||||
class="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||
>
|
||||
<img
|
||||
src={product_review.thumbnail_url}
|
||||
class="h-16 sm:h-24 object-cover aspect-video rounded-lg"
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-gray-100">
|
||||
<%= product_review.video.user.name %> • <%= product_review.video.title %>
|
||||
</div>
|
||||
<div class="text-xs text-gray-300">
|
||||
Streamed on <%= product_review.video.inserted_at
|
||||
|> Calendar.strftime("%b %d, %Y") %>
|
||||
</div>
|
||||
<div class="text-xs text-purple-300">
|
||||
<%= Library.to_hhmmss(product_review.clip_from) %> - <%= Library.to_hhmmss(
|
||||
product_review.clip_to
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="p-4 align-middle [&:has([role=checkbox])]:pr-0 text-right">
|
||||
<%= format_duration(product_review.clip_to - product_review.clip_from) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="relative isolate overflow-hidden">
|
||||
<div class="px-6 py-12 sm:py-24 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl text-center">
|
||||
<h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-5xl">
|
||||
Your most successful <span class="text-green-300">ad campaign</span>
|
||||
<br class="hidden sm:inline" />is just a few livestreams away
|
||||
</h1>
|
||||
<p class="mt-6 text-xl leading-8 tracking-tight text-gray-300">
|
||||
Stand out in front of millions on Twitch, YouTube and X with in-video livestream ads
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<.link
|
||||
href="https://cal.com/ioannisflo"
|
||||
class="rounded-md bg-white px-3.5 py-2.5 text-2xl font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
|
||||
>
|
||||
Let's talk
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
class="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-x-1/2 [mask-image:radial-gradient(closest-side,white,transparent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="512"
|
||||
cy="512"
|
||||
r="512"
|
||||
fill="url(#8d958450-c69f-4251-94bc-4e091a323369)"
|
||||
fill-opacity="0.7"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient id="8d958450-c69f-4251-94bc-4e091a323369">
|
||||
<stop stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#3b82f6" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
625
lib/algora_web/live/ad_live/content_live.ex
Normal file
625
lib/algora_web/live/ad_live/content_live.ex
Normal file
@ -0,0 +1,625 @@
|
||||
defmodule AlgoraWeb.ContentLive do
|
||||
use AlgoraWeb, :live_view
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Algora.Ads
|
||||
alias Algora.Ads.{ContentMetrics, Appearance, ProductReview}
|
||||
alias Algora.Library
|
||||
alias AlgoraWeb.LayoutComponent
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
content_metrics = Ads.list_content_metrics()
|
||||
ads = Ads.list_ads()
|
||||
videos = Library.list_all_videos(1000)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:ads, ads)
|
||||
|> assign(:videos, videos)
|
||||
|> assign(:content_metrics, content_metrics)
|
||||
|> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{})))
|
||||
|> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{})))
|
||||
|> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{})))
|
||||
|> assign(:show_appearance_modal, false)
|
||||
|> assign(:show_product_review_modal, false)
|
||||
|> assign(:show_content_metrics_modal, false)
|
||||
|> assign(:editing_content_metrics, nil)
|
||||
|> assign(:uploaded_files, [])
|
||||
|> allow_upload(:product_review_thumbnail,
|
||||
accept: accept(),
|
||||
max_file_size: max_file_size() * 1_000_000,
|
||||
max_entries: 1,
|
||||
auto_upload: true,
|
||||
progress: &handle_progress/3
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="max-w-5xl mx-auto space-y-6 p-6">
|
||||
<%= for content_metric <- @content_metrics do %>
|
||||
<div class="bg-white/5 p-6 ring-1 ring-white/15 rounded-lg space-y-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="text-lg font-semibold"><%= content_metric.video.title %></div>
|
||||
<div class="text-sm text-gray-400">
|
||||
<%= Calendar.strftime(content_metric.video.inserted_at, "%b %d, %Y, %I:%M %p UTC") %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 text-sm font-display">
|
||||
<.link href={content_metric.twitch_stream_url} class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-twitch"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 5v11a1 1 0 0 0 1 1h2v4l4 -4h5.584c.266 0 .52 -.105 .707 -.293l2.415 -2.414c.187 -.188 .293 -.442 .293 -.708v-8.585a1 1 0 0 0 -1 -1h-14a1 1 0 0 0 -1 1z" /><path d="M16 8l0 4" /><path d="M12 8l0 4" />
|
||||
</svg>
|
||||
<%= content_metric.twitch_avg_concurrent_viewers || 0 %> CCV / <%= content_metric.twitch_views ||
|
||||
0 %> Views
|
||||
</.link>
|
||||
<.link href={content_metric.youtube_video_url} class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-youtube"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M2 8a4 4 0 0 1 4 -4h12a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4v-8z" /><path d="M10 9l5 3l-5 3z" />
|
||||
</svg>
|
||||
<%= content_metric.youtube_views || 0 %> Views
|
||||
</.link>
|
||||
<.link href={content_metric.twitter_video_url} class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="icon icon-tabler icons-tabler-outline icon-tabler-brand-x"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4l11.733 16h4.267l-11.733 -16z" /><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772" />
|
||||
</svg>
|
||||
<%= content_metric.twitter_views || 0 %> Views
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full ring-1 ring-white/5 bg-gray-950/40 rounded-lg">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-sm px-6 py-3 text-left">Ad</th>
|
||||
<th class="text-sm px-6 py-3 text-right">Airtime</th>
|
||||
<th class="text-sm px-6 py-3 text-right">Blurb</th>
|
||||
<th class="text-sm px-6 py-3 text-left">Thumbnail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for ad_id <- Enum.uniq(Enum.map(content_metric.video.appearances, & &1.ad_id) ++ Enum.map(content_metric.video.product_reviews, & &1.ad_id)) do %>
|
||||
<% ad = Ads.get_ad!(ad_id) %>
|
||||
<tr>
|
||||
<td class="text-sm px-6 py-3"><%= ad.slug %></td>
|
||||
<td class="text-sm px-6 py-3 text-right tabular-nums">
|
||||
<%= Enum.map_join(
|
||||
Enum.filter(content_metric.video.appearances, &(&1.ad_id == ad_id)),
|
||||
", ",
|
||||
&Library.to_hhmmss(&1.airtime)
|
||||
) %>
|
||||
</td>
|
||||
<td class="text-sm px-6 py-3 text-right tabular-nums">
|
||||
<%= Enum.map_join(
|
||||
Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)),
|
||||
", ",
|
||||
&"#{Library.to_hhmmss(&1.clip_from)} - #{Library.to_hhmmss(&1.clip_to)}"
|
||||
) %>
|
||||
</td>
|
||||
<td class="text-sm px-6 py-3">
|
||||
<%= Enum.map_join(
|
||||
Enum.filter(content_metric.video.product_reviews, &(&1.ad_id == ad_id)),
|
||||
", ",
|
||||
& &1.thumbnail_url
|
||||
) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<.button phx-click="open_appearance_modal" phx-value-video_id={content_metric.video_id}>
|
||||
Add airtime
|
||||
</.button>
|
||||
<.button
|
||||
phx-click="open_product_review_modal"
|
||||
phx-value-video_id={content_metric.video_id}
|
||||
>
|
||||
Add blurb
|
||||
</.button>
|
||||
<.button phx-click="edit_content_metrics" phx-value-id={content_metric.id}>
|
||||
Edit metrics
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white/5 p-6 ring-1 ring-white/15 rounded-lg">
|
||||
<.button phx-click="open_content_metrics_modal">Add content</.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.modal
|
||||
:if={@show_appearance_modal}
|
||||
id="appearance-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/admin/content")}
|
||||
>
|
||||
<.header>Add Airtime</.header>
|
||||
<.simple_form for={@new_appearance_form} phx-submit="save_appearance">
|
||||
<%= hidden_input(@new_appearance_form, :video_id) %>
|
||||
<.input
|
||||
field={@new_appearance_form[:ad_id]}
|
||||
type="select"
|
||||
label="Ad"
|
||||
prompt="Select an ad"
|
||||
options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)}
|
||||
/>
|
||||
<.input field={@new_appearance_form[:airtime]} label="Airtime" placeholder="hh:mm:ss" />
|
||||
<div />
|
||||
<div />
|
||||
<.button type="submit">Submit</.button>
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
|
||||
<.modal
|
||||
:if={@show_product_review_modal}
|
||||
id="product-review-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/admin/content")}
|
||||
>
|
||||
<.header>Add Blurb</.header>
|
||||
<.simple_form
|
||||
for={@new_product_review_form}
|
||||
phx-change="validate_product_review"
|
||||
phx-submit="save_product_review"
|
||||
>
|
||||
<%= hidden_input(@new_product_review_form, :video_id) %>
|
||||
<.input
|
||||
field={@new_product_review_form[:ad_id]}
|
||||
type="select"
|
||||
label="Ad"
|
||||
prompt="Select an ad"
|
||||
options={Enum.map(@ads, fn ad -> {ad.slug, ad.id} end)}
|
||||
/>
|
||||
<.input
|
||||
field={@new_product_review_form[:clip_from]}
|
||||
type="text"
|
||||
label="Clip From"
|
||||
placeholder="hh:mm:ss"
|
||||
/>
|
||||
<.input
|
||||
field={@new_product_review_form[:clip_to]}
|
||||
type="text"
|
||||
label="Clip To"
|
||||
placeholder="hh:mm:ss"
|
||||
/>
|
||||
<div class="shrink-0">
|
||||
<label for="show_title" class="block text-sm font-semibold leading-6 text-gray-100 mb-2">
|
||||
Thumbnail
|
||||
</label>
|
||||
<div
|
||||
id="show_image"
|
||||
phx-drop-target={@uploads.product_review_thumbnail.ref}
|
||||
class="relative"
|
||||
>
|
||||
<.live_file_input
|
||||
upload={@uploads.product_review_thumbnail}
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
/>
|
||||
<img
|
||||
:if={@new_product_review_form[:thumbnail_url].value}
|
||||
src={@new_product_review_form[:thumbnail_url].value}
|
||||
class="h-[180px] aspect-video object-cover rounded-lg"
|
||||
/>
|
||||
<div
|
||||
:if={!@new_product_review_form[:thumbnail_url].value}
|
||||
class="h-[180px] aspect-video bg-white/10 rounded-lg"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= hidden_input(@new_product_review_form, :thumbnail_url) %>
|
||||
<%= for err <- upload_errors(@uploads.product_review_thumbnail) do %>
|
||||
<p class="alert alert-danger"><%= error_to_string(err) %></p>
|
||||
<% end %>
|
||||
<.button type="submit">Submit</.button>
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
|
||||
<.modal
|
||||
:if={@show_content_metrics_modal}
|
||||
id="content-metrics-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/admin/content")}
|
||||
>
|
||||
<.header>Add content</.header>
|
||||
<.simple_form for={@new_content_metrics_form} phx-submit="save_content_metrics">
|
||||
<.input
|
||||
field={@new_content_metrics_form[:video_id]}
|
||||
type="select"
|
||||
label="Video"
|
||||
options={
|
||||
Enum.map(@videos, fn video ->
|
||||
{"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})",
|
||||
video.id}
|
||||
end)
|
||||
}
|
||||
prompt="Select a video"
|
||||
phx-change="video_selected"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:algora_stream_url]}
|
||||
type="text"
|
||||
label="Algora URL"
|
||||
phx-change="url_entered"
|
||||
phx-debounce="300"
|
||||
/>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitch_stream_url]}
|
||||
type="text"
|
||||
label="Twitch URL"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:youtube_video_url]}
|
||||
type="text"
|
||||
label="YouTube URL"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitter_video_url]}
|
||||
type="text"
|
||||
label="Twitter URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]}
|
||||
type="number"
|
||||
label="Twitch Average CCV"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" />
|
||||
<.input
|
||||
field={@new_content_metrics_form[:youtube_views]}
|
||||
type="number"
|
||||
label="YouTube Views"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitter_views]}
|
||||
type="number"
|
||||
label="Twitter Views"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.button type="submit">Submit</.button>
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
|
||||
<.modal
|
||||
:if={@editing_content_metrics}
|
||||
id="edit-content-metrics-modal"
|
||||
show
|
||||
on_cancel={JS.patch(~p"/admin/content")}
|
||||
>
|
||||
<.header>Edit metrics</.header>
|
||||
<.simple_form for={@new_content_metrics_form} phx-submit="update_content_metrics">
|
||||
<%= hidden_input(@new_content_metrics_form, :id) %>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:video_id]}
|
||||
type="select"
|
||||
label="Video"
|
||||
options={
|
||||
Enum.map(@videos, fn video ->
|
||||
{"#{video.title} (#{Calendar.strftime(video.inserted_at, "%b %d, %Y, %I:%M %p UTC")})",
|
||||
video.id}
|
||||
end)
|
||||
}
|
||||
prompt="Select a video"
|
||||
phx-change="video_selected"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:algora_stream_url]}
|
||||
type="text"
|
||||
label="Algora URL"
|
||||
phx-change="url_entered"
|
||||
phx-debounce="300"
|
||||
/>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitch_stream_url]}
|
||||
type="text"
|
||||
label="Twitch URL"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:youtube_video_url]}
|
||||
type="text"
|
||||
label="YouTube URL"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitter_video_url]}
|
||||
type="text"
|
||||
label="Twitter URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitch_avg_concurrent_viewers]}
|
||||
type="number"
|
||||
label="Twitch Average CCV"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<.input field={@new_content_metrics_form[:twitch_views]} type="number" label="Twitch Views" />
|
||||
<.input
|
||||
field={@new_content_metrics_form[:youtube_views]}
|
||||
type="number"
|
||||
label="YouTube Views"
|
||||
/>
|
||||
<.input
|
||||
field={@new_content_metrics_form[:twitter_views]}
|
||||
type="number"
|
||||
label="Twitter Views"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.button type="submit">Update</.button>
|
||||
</.simple_form>
|
||||
</.modal>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_content_metrics", %{"content_metrics" => params}, socket) do
|
||||
case Ads.create_content_metrics(params) do
|
||||
{:ok, _content_metrics} ->
|
||||
content_metrics = Ads.list_content_metrics()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:content_metrics, content_metrics)
|
||||
|> assign(
|
||||
:new_content_metrics_form,
|
||||
to_form(Ads.change_content_metrics(%ContentMetrics{}))
|
||||
)
|
||||
|> assign(:show_content_metrics_modal, false)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_appearance", %{"appearance" => params}, socket) do
|
||||
params = Map.update!(params, "airtime", &Library.from_hhmmss/1)
|
||||
|
||||
case Ads.create_appearance(params) do
|
||||
{:ok, _appearance} ->
|
||||
content_metrics = Ads.list_content_metrics()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:content_metrics, content_metrics)
|
||||
|> assign(:new_appearance_form, to_form(Ads.change_appearance(%Appearance{})))
|
||||
|> assign(:show_appearance_modal, false)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :new_appearance_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate_product_review", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_product_review", %{"product_review" => params}, socket) do
|
||||
params = Map.update!(params, "clip_from", &Library.from_hhmmss/1)
|
||||
params = Map.update!(params, "clip_to", &Library.from_hhmmss/1)
|
||||
|
||||
case Ads.create_product_review(params) do
|
||||
{:ok, _product_review} ->
|
||||
content_metrics = Ads.list_content_metrics()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:content_metrics, content_metrics)
|
||||
|> assign(:new_product_review_form, to_form(Ads.change_product_review(%ProductReview{})))
|
||||
|> assign(:show_product_review_modal, false)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :new_product_review_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_appearance_modal", %{"video_id" => video_id}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_appearance_modal, true)
|
||||
|> assign(
|
||||
:new_appearance_form,
|
||||
to_form(Ads.change_appearance(%Appearance{video_id: String.to_integer(video_id)}))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_product_review_modal", %{"video_id" => video_id}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_product_review_modal, true)
|
||||
|> assign(
|
||||
:new_product_review_form,
|
||||
to_form(Ads.change_product_review(%ProductReview{video_id: String.to_integer(video_id)}))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("open_content_metrics_modal", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_content_metrics_modal, true)
|
||||
|> assign(:new_content_metrics_form, to_form(Ads.change_content_metrics(%ContentMetrics{})))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("video_selected", %{"content_metrics" => %{"video_id" => video_id}}, socket) do
|
||||
video =
|
||||
if video_id != "",
|
||||
do: Enum.find(socket.assigns.videos, &(&1.id == String.to_integer(video_id)))
|
||||
|
||||
url =
|
||||
if video, do: "#{AlgoraWeb.Endpoint.url()}/#{video.channel_handle}/#{video_id}", else: ""
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:new_content_metrics_form,
|
||||
to_form(
|
||||
Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url})
|
||||
)
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("url_entered", %{"content_metrics" => %{"algora_stream_url" => url}}, socket) do
|
||||
video =
|
||||
Enum.find(
|
||||
socket.assigns.videos,
|
||||
&(url == "#{AlgoraWeb.Endpoint.url()}/#{&1.channel_handle}/#{&1.id}")
|
||||
)
|
||||
|
||||
video_id = if video, do: video.id, else: nil
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:new_content_metrics_form,
|
||||
to_form(
|
||||
Ads.change_content_metrics(%ContentMetrics{video_id: video_id, algora_stream_url: url})
|
||||
)
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("edit_content_metrics", %{"id" => id}, socket) do
|
||||
content_metrics = Ads.get_content_metrics!(id)
|
||||
changeset = Ads.change_content_metrics(content_metrics)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:editing_content_metrics, content_metrics)
|
||||
|> assign(:new_content_metrics_form, to_form(changeset))
|
||||
|> assign(:show_content_metrics_modal, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_content_metrics", %{"content_metrics" => params}, socket) do
|
||||
case Ads.update_content_metrics(socket.assigns.editing_content_metrics, params) do
|
||||
{:ok, _updated_content_metrics} ->
|
||||
content_metrics = Ads.list_content_metrics()
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:content_metrics, content_metrics)
|
||||
|> assign(:editing_content_metrics, nil)
|
||||
|> assign(:show_content_metrics_modal, false)}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :new_content_metrics_form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
|
||||
{:noreply, cancel_upload(socket, :product_review_thumbnail, ref)}
|
||||
end
|
||||
|
||||
defp handle_progress(:product_review_thumbnail, entry, socket) do
|
||||
if entry.done? do
|
||||
form =
|
||||
consume_uploaded_entry(socket, entry, fn %{path: path} = _meta ->
|
||||
uuid = Ecto.UUID.generate()
|
||||
remote_path = "blurbs/#{uuid}"
|
||||
|
||||
content_type = ExMarcel.MimeType.for({:path, path})
|
||||
|
||||
{:ok, _} =
|
||||
Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end,
|
||||
content_type: content_type
|
||||
)
|
||||
|
||||
bucket = Algora.config([:buckets, :media])
|
||||
%{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{})
|
||||
|
||||
# {:ok, form} =
|
||||
# Ecto.Changeset.apply_action(socket.assigns.new_product_review_form.source, :update)
|
||||
|
||||
form =
|
||||
socket.assigns.new_product_review_form.source
|
||||
|> put_change(
|
||||
:thumbnail_url,
|
||||
"#{scheme}#{host}/#{bucket}/#{remote_path}"
|
||||
)
|
||||
|> to_form()
|
||||
|
||||
{:ok, form}
|
||||
end)
|
||||
|
||||
{:noreply, socket |> assign(:new_product_review_form, form)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
LayoutComponent.hide_modal()
|
||||
{:noreply, socket |> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Content")
|
||||
|> assign(:page_description, "Content")
|
||||
end
|
||||
|
||||
defp error_to_string(:too_large) do
|
||||
"Only images up to #{max_file_size()} MB are allowed."
|
||||
end
|
||||
|
||||
defp error_to_string(:not_accepted) do
|
||||
"Uploaded file is not a valid image. Only #{accept() |> Enum.intersperse(", ") |> Enum.join()} files are allowed."
|
||||
end
|
||||
|
||||
defp max_file_size, do: 10
|
||||
defp accept, do: ~w(.png .jpg .jpeg .gif)
|
||||
end
|
113
lib/algora_web/live/ad_live/form_component.ex
Normal file
113
lib/algora_web/live/ad_live/form_component.ex
Normal file
@ -0,0 +1,113 @@
|
||||
defmodule AlgoraWeb.AdLive.FormComponent do
|
||||
use AlgoraWeb, :live_component
|
||||
|
||||
alias Algora.Ads
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<.header class="mb-8">
|
||||
<%= @title %>
|
||||
<:subtitle>Use this form to manage ad records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="ad-form"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="absolute text-sm start-0 flex items-center ps-3 top-10 mt-px pointer-events-none text-gray-400">
|
||||
tv.algora.io/go/
|
||||
</div>
|
||||
<.input field={@form[:slug]} type="text" label="QR Code URL" class="ps-[6.75rem]" />
|
||||
</div>
|
||||
<.input field={@form[:website_url]} type="text" label="Website URL" />
|
||||
<.input field={@form[:composite_asset_url]} type="text" label="Asset URL" />
|
||||
<.input
|
||||
field={@form[:border_color]}
|
||||
type="text"
|
||||
label="Border color"
|
||||
style={"color: #{valid_color(@form[:border_color].value)}"}
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Saving...">Save Ad</.button>
|
||||
</:actions>
|
||||
</.simple_form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(%{ad: ad} = assigns, socket) do
|
||||
changeset = Ads.change_ad(ad)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_form(changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"ad" => ad_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.ad
|
||||
|> Ads.change_ad(ad_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"ad" => ad_params}, socket) do
|
||||
save_ad(socket, socket.assigns.action, ad_params)
|
||||
end
|
||||
|
||||
defp save_ad(socket, :edit, ad_params) do
|
||||
case Ads.update_ad(socket.assigns.ad, ad_params) do
|
||||
{:ok, ad} ->
|
||||
notify_parent({:saved, ad})
|
||||
Ads.broadcast_ad_updated!(ad)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Ad updated successfully")
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_ad(socket, :new, ad_params) do
|
||||
case Ads.create_ad(ad_params) do
|
||||
{:ok, ad} ->
|
||||
notify_parent({:saved, ad})
|
||||
Ads.broadcast_ad_created!(ad)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Ad created successfully")
|
||||
|> push_patch(to: socket.assigns.patch)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign_form(socket, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
|
||||
assign(socket, :form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp valid_color(color) do
|
||||
if is_valid_hex_color?(color), do: color, else: "inherit"
|
||||
end
|
||||
|
||||
defp is_valid_hex_color?(nil), do: false
|
||||
|
||||
defp is_valid_hex_color?(color), do: color =~ ~r/^#([0-9A-F]{3}){1,2}$/i
|
||||
end
|
95
lib/algora_web/live/ad_live/index.ex
Normal file
95
lib/algora_web/live/ad_live/index.ex
Normal file
@ -0,0 +1,95 @@
|
||||
defmodule AlgoraWeb.AdLive.Index do
|
||||
use AlgoraWeb, :live_view
|
||||
|
||||
alias Algora.Ads
|
||||
alias Algora.Ads.Ad
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
schedule_next_rotation()
|
||||
end
|
||||
|
||||
next_slot = Ads.next_slot()
|
||||
|
||||
ads =
|
||||
Ads.list_active_ads()
|
||||
|> Ads.rotate_ads()
|
||||
|> Enum.with_index(-1)
|
||||
|> Enum.map(fn {ad, index} ->
|
||||
%{
|
||||
ad
|
||||
| scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
|
||||
}
|
||||
end)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> stream(:ads, ads)
|
||||
|> assign(:next_slot, Ads.next_slot())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Edit Ad")
|
||||
|> assign(:ad, Ads.get_ad!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Ad")
|
||||
|> assign(:ad, %Ad{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Ads")
|
||||
|> assign(:ad, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({AlgoraWeb.AdLive.FormComponent, {:saved, ad}}, socket) do
|
||||
{:noreply, stream_insert(socket, :ads, ad)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:rotate_ads, socket) do
|
||||
schedule_next_rotation()
|
||||
|
||||
next_slot = Ads.next_slot()
|
||||
|
||||
rotated_ads =
|
||||
socket.assigns.ads
|
||||
|> Ads.rotate_ads(1)
|
||||
|> Enum.with_index(-1)
|
||||
|> Enum.map(fn {ad, index} ->
|
||||
%{
|
||||
ad
|
||||
| scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
|
||||
}
|
||||
end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream(:ads, rotated_ads)
|
||||
|> assign(:next_slot, Ads.next_slot())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
ad = Ads.get_ad!(id)
|
||||
{:ok, _} = Ads.delete_ad(ad)
|
||||
Ads.broadcast_ad_deleted!(ad)
|
||||
|
||||
{:noreply, stream_delete(socket, :ads, ad)}
|
||||
end
|
||||
|
||||
defp schedule_next_rotation do
|
||||
Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot())
|
||||
end
|
||||
end
|
71
lib/algora_web/live/ad_live/index.html.heex
Normal file
71
lib/algora_web/live/ad_live/index.html.heex
Normal file
@ -0,0 +1,71 @@
|
||||
<.header class="pl-4 pr-6">
|
||||
Active Ads
|
||||
<:actions>
|
||||
<.link patch={~p"/ads/new"}>
|
||||
<.button>New Ad</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table id="ads" rows={@streams.ads} row_click={fn {_id, ad} -> JS.navigate(~p"/ads/#{ad}") end}>
|
||||
<:col :let={{_id, ad}} label="">
|
||||
<a href={ad.website_url} rel="noopener noreferrer" class="flex mb-3 font-medium">
|
||||
<div class="grid grid-cols-3 w-[1092px]">
|
||||
<div class="text-base text-gray-200">
|
||||
<%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %>
|
||||
</div>
|
||||
<div class="mx-auto font-mono text-gray-200">
|
||||
<%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1 text-base text-gray-300">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
|
||||
</svg>
|
||||
algora.tv/go/<%= ad.slug %>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<img
|
||||
src={ad.composite_asset_url}
|
||||
alt={ad.website_url}
|
||||
class="box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000"
|
||||
style={"border-color: #{ad.border_color || "#fff"}"}
|
||||
/>
|
||||
</:col>
|
||||
<:action :let={{_id, ad}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/ads/#{ad}"}>Show</.link>
|
||||
</div>
|
||||
<.link patch={~p"/ads/#{ad}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
<:action :let={{id, ad}}>
|
||||
<.link
|
||||
phx-click={JS.push("delete", value: %{id: ad.id}) |> hide("##{id}")}
|
||||
data-confirm="Are you sure?"
|
||||
>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.modal :if={@live_action in [:new, :edit]} id="ad-modal" show on_cancel={JS.patch(~p"/ads")}>
|
||||
<.live_component
|
||||
module={AlgoraWeb.AdLive.FormComponent}
|
||||
id={@ad.id || :new}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
ad={@ad}
|
||||
patch={~p"/ads"}
|
||||
/>
|
||||
</.modal>
|
68
lib/algora_web/live/ad_live/schedule.ex
Normal file
68
lib/algora_web/live/ad_live/schedule.ex
Normal file
@ -0,0 +1,68 @@
|
||||
defmodule AlgoraWeb.AdLive.Schedule do
|
||||
use AlgoraWeb, :live_view
|
||||
|
||||
alias Algora.Ads
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
schedule_next_rotation()
|
||||
end
|
||||
|
||||
next_slot = Ads.next_slot()
|
||||
|
||||
ads =
|
||||
Ads.list_active_ads()
|
||||
|> Ads.rotate_ads()
|
||||
|> Enum.with_index(-1)
|
||||
|> Enum.map(fn {ad, index} ->
|
||||
%{
|
||||
ad
|
||||
| scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
|
||||
}
|
||||
end)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> stream(:ads, ads)
|
||||
|> assign(:next_slot, Ads.next_slot())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :schedule, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Ads schedule")
|
||||
|> assign(:ad, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:rotate_ads, socket) do
|
||||
schedule_next_rotation()
|
||||
|
||||
next_slot = Ads.next_slot()
|
||||
|
||||
rotated_ads =
|
||||
socket.assigns.ads
|
||||
|> Ads.rotate_ads(1)
|
||||
|> Enum.with_index(-1)
|
||||
|> Enum.map(fn {ad, index} ->
|
||||
%{
|
||||
ad
|
||||
| scheduled_for: DateTime.add(next_slot, index * Ads.rotation_interval(), :millisecond)
|
||||
}
|
||||
end)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream(:ads, rotated_ads)
|
||||
|> assign(:next_slot, Ads.next_slot())}
|
||||
end
|
||||
|
||||
defp schedule_next_rotation do
|
||||
Process.send_after(self(), :rotate_ads, Ads.time_until_next_slot())
|
||||
end
|
||||
end
|
41
lib/algora_web/live/ad_live/schedule.html.heex
Normal file
41
lib/algora_web/live/ad_live/schedule.html.heex
Normal file
@ -0,0 +1,41 @@
|
||||
<.header class="pl-4 pr-6">
|
||||
Ads schedule
|
||||
</.header>
|
||||
|
||||
<.table id="ads" rows={@streams.ads}>
|
||||
<:col :let={{_id, ad}} label="">
|
||||
<a href={ad.website_url} rel="noopener noreferrer" class="flex mb-3 font-medium">
|
||||
<div class="grid grid-cols-3 w-[1092px]">
|
||||
<div class="text-base text-gray-200">
|
||||
<%= String.replace(ad.website_url, ~r/^https?:\/\//, "") %>
|
||||
</div>
|
||||
<div class="mx-auto font-mono text-gray-200">
|
||||
<%= Calendar.strftime(ad.scheduled_for, "%I:%M:%S %p UTC") %>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-1 text-base text-gray-300">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
|
||||
</svg>
|
||||
algora.tv/go/<%= ad.slug %>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<img
|
||||
src={ad.composite_asset_url}
|
||||
alt={ad.website_url}
|
||||
class="box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000"
|
||||
style={"border-color: #{ad.border_color || "#fff"}"}
|
||||
/>
|
||||
</:col>
|
||||
</.table>
|
21
lib/algora_web/live/ad_live/show.ex
Normal file
21
lib/algora_web/live/ad_live/show.ex
Normal file
@ -0,0 +1,21 @@
|
||||
defmodule AlgoraWeb.AdLive.Show do
|
||||
use AlgoraWeb, :live_view
|
||||
|
||||
alias Algora.Ads
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, page_title(socket.assigns.live_action))
|
||||
|> assign(:ad, Ads.get_ad!(id))}
|
||||
end
|
||||
|
||||
defp page_title(:show), do: "Show Ad"
|
||||
defp page_title(:edit), do: "Edit Ad"
|
||||
end
|
37
lib/algora_web/live/ad_live/show.html.heex
Normal file
37
lib/algora_web/live/ad_live/show.html.heex
Normal file
@ -0,0 +1,37 @@
|
||||
<.header>
|
||||
Ad <%= @ad.id %>
|
||||
<:subtitle>This is a ad record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.link patch={~p"/ads/#{@ad}/show/edit"} phx-click={JS.push_focus()}>
|
||||
<.button>Edit ad</.button>
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Verified"><%= @ad.verified %></:item>
|
||||
<:item title="Website url"><%= @ad.website_url %></:item>
|
||||
<:item title="Composite asset url"><%= @ad.composite_asset_url %></:item>
|
||||
<:item title="Asset url"><%= @ad.asset_url %></:item>
|
||||
<:item title="Logo url"><%= @ad.logo_url %></:item>
|
||||
<:item title="Qrcode url"><%= @ad.qrcode_url %></:item>
|
||||
<:item title="Start date"><%= @ad.start_date %></:item>
|
||||
<:item title="End date"><%= @ad.end_date %></:item>
|
||||
<:item title="Total budget"><%= @ad.total_budget %></:item>
|
||||
<:item title="Daily budget"><%= @ad.daily_budget %></:item>
|
||||
<:item title="Tech stack"><%= @ad.tech_stack %></:item>
|
||||
<:item title="Status"><%= @ad.status %></:item>
|
||||
</.list>
|
||||
|
||||
<.back navigate={~p"/ads"}>Back to ads</.back>
|
||||
|
||||
<.modal :if={@live_action == :edit} id="ad-modal" show on_cancel={JS.patch(~p"/ads/#{@ad}")}>
|
||||
<.live_component
|
||||
module={AlgoraWeb.AdLive.FormComponent}
|
||||
id={@ad.id}
|
||||
title={@page_title}
|
||||
action={@live_action}
|
||||
ad={@ad}
|
||||
patch={~p"/ads/#{@ad}"}
|
||||
/>
|
||||
</.modal>
|
146
lib/algora_web/live/ad_overlay_live.ex
Normal file
146
lib/algora_web/live/ad_overlay_live.ex
Normal file
@ -0,0 +1,146 @@
|
||||
defmodule AlgoraWeb.AdOverlayLive do
|
||||
use AlgoraWeb, :live_view
|
||||
require Logger
|
||||
|
||||
alias AlgoraWeb.{LayoutComponent, Presence}
|
||||
alias Algora.{Accounts, Library, Ads}
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<%= if @ads && length(@ads) > 0 do %>
|
||||
<div class="relative">
|
||||
<img
|
||||
src={@current_ad.composite_asset_url}
|
||||
alt={@current_ad.website_url}
|
||||
class={"box-content w-[1092px] h-[135px] object-cover border-[4px] rounded-xl transition-opacity duration-1000 #{if @show_ad, do: "opacity-100", else: "opacity-0"}"}
|
||||
style={"border-color: #{@current_ad.border_color || "#fff"}"}
|
||||
/>
|
||||
<img
|
||||
src={@next_ad.composite_asset_url}
|
||||
alt={@next_ad.website_url}
|
||||
class="absolute top-0 left-0 opacity-0 pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(%{"channel_handle" => channel_handle} = params, _session, socket) do
|
||||
channel =
|
||||
Accounts.get_user_by!(handle: channel_handle)
|
||||
|> Library.get_channel!()
|
||||
|
||||
ads = Ads.list_active_ads()
|
||||
current_ad_index = Ads.get_current_index(ads)
|
||||
current_ad = Enum.at(ads, current_ad_index)
|
||||
{next_ad, next_index} = get_next_ad(ads, current_ad_index)
|
||||
|
||||
if connected?(socket) do
|
||||
Ads.subscribe_to_ads()
|
||||
schedule_next_ad()
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:channel, channel)
|
||||
|> assign(:ads, ads)
|
||||
|> assign(:current_ad_index, current_ad_index)
|
||||
|> assign(:current_ad, current_ad)
|
||||
|> assign(:next_ad, next_ad)
|
||||
|> assign(:next_ad_index, next_index)
|
||||
|> assign(:show_ad, Map.has_key?(params, "test"))
|
||||
|> assign(:test_mode, Map.has_key?(params, "test"))}
|
||||
end
|
||||
|
||||
def handle_params(params, _url, socket) do
|
||||
LayoutComponent.hide_modal()
|
||||
{:noreply, socket |> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
def handle_info(:toggle_ad, %{assigns: %{test_mode: true}} = socket), do: {:noreply, socket}
|
||||
|
||||
def handle_info(:toggle_ad, socket) do
|
||||
case socket.assigns.show_ad do
|
||||
true ->
|
||||
schedule_next_ad()
|
||||
{:noreply, assign(socket, :show_ad, false)}
|
||||
|
||||
false ->
|
||||
track_impressions(socket.assigns.next_ad, socket.assigns.channel.handle)
|
||||
Process.send_after(self(), :toggle_ad, Ads.display_duration())
|
||||
|
||||
{new_next_ad, new_next_index} =
|
||||
get_next_ad(socket.assigns.ads, socket.assigns.next_ad_index)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_ad, true)
|
||||
|> assign(:current_ad, socket.assigns.next_ad)
|
||||
|> assign(:current_ad_index, socket.assigns.next_ad_index)
|
||||
|> assign(:next_ad, new_next_ad)
|
||||
|> assign(:next_ad_index, new_next_index)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({Ads, %Ads.Events.AdCreated{}}, socket) do
|
||||
update_ads_state(socket)
|
||||
end
|
||||
|
||||
def handle_info({Ads, %Ads.Events.AdDeleted{}}, socket) do
|
||||
update_ads_state(socket)
|
||||
end
|
||||
|
||||
def handle_info({Ads, %Ads.Events.AdUpdated{}}, socket) do
|
||||
update_ads_state(socket)
|
||||
end
|
||||
|
||||
def handle_info(_arg, socket), do: {:noreply, socket}
|
||||
|
||||
defp apply_action(socket, :show, params) do
|
||||
channel_name = params["channel_handle"]
|
||||
|
||||
socket
|
||||
|> assign(:page_title, channel_name)
|
||||
|> assign(:page_description, "Watch #{channel_name} on Algora TV")
|
||||
end
|
||||
|
||||
defp schedule_next_ad do
|
||||
Process.send_after(self(), :toggle_ad, Ads.time_until_next_slot())
|
||||
end
|
||||
|
||||
defp track_impressions(nil, _channel_handle), do: :ok
|
||||
|
||||
defp track_impressions(ad, channel_handle) do
|
||||
viewers_count =
|
||||
Presence.list_online_users(channel_handle)
|
||||
|> Enum.flat_map(fn %{metas: metas} -> metas end)
|
||||
|> Enum.filter(fn meta -> meta.id != channel_handle end)
|
||||
|> length()
|
||||
|
||||
Ads.track_impressions(%{
|
||||
ad_id: ad.id,
|
||||
duration: Ads.display_duration(),
|
||||
viewers_count: viewers_count
|
||||
})
|
||||
end
|
||||
|
||||
defp get_next_ad(ads, current_index) do
|
||||
next_index = rem(current_index + 1, length(ads))
|
||||
{Enum.at(ads, next_index), next_index}
|
||||
end
|
||||
|
||||
defp update_ads_state(socket) do
|
||||
ads = Ads.list_active_ads()
|
||||
current_ad_index = Ads.get_current_index(ads)
|
||||
current_ad = Enum.at(ads, current_ad_index)
|
||||
{next_ad, next_index} = get_next_ad(ads, current_ad_index)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:ads, ads)
|
||||
|> assign(:current_ad_index, current_ad_index)
|
||||
|> assign(:current_ad, current_ad)
|
||||
|> assign(:next_ad, next_ad)
|
||||
|> assign(:next_ad_index, next_index)}
|
||||
end
|
||||
end
|
381
lib/algora_web/live/partner_live.ex
Normal file
381
lib/algora_web/live/partner_live.ex
Normal file
@ -0,0 +1,381 @@
|
||||
defmodule AlgoraWeb.PartnerLive do
|
||||
use AlgoraWeb, :live_view
|
||||
require Logger
|
||||
|
||||
alias AlgoraWeb.LayoutComponent
|
||||
alias Algora.Contact
|
||||
alias Algora.Contact.Info
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="bg-gray-950 font-display">
|
||||
<main>
|
||||
<!-- Hero section -->
|
||||
<div class="relative isolate overflow-hidden">
|
||||
<svg
|
||||
class="absolute inset-0 -z-10 h-full w-full stroke-white/10 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="983e3e4c-de6d-4c3f-8d64-b9761d1534cc"
|
||||
width="200"
|
||||
height="200"
|
||||
x="50%"
|
||||
y="-1"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<svg x="50%" y="-1" class="overflow-visible fill-gray-800/20">
|
||||
<path
|
||||
d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
|
||||
stroke-width="0"
|
||||
/>
|
||||
</svg>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
stroke-width="0"
|
||||
fill="url(#983e3e4c-de6d-4c3f-8d64-b9761d1534cc)"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
class="absolute left-[calc(50%-4rem)] top-10 -z-10 transform blur-3xl sm:left-[calc(50%-18rem)] lg:left-48 lg:top-[calc(50%-30rem)] xl:left-[calc(50%-24rem)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="aspect-[1108/632] w-[69.25rem] bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20"
|
||||
style="clip-path: polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto px-6 pb-24 pt-10 sm:pb-40 lg:flex lg:px-20 lg:pt-40 gap-8">
|
||||
<div class="mx-auto max-w-3xl flex-shrink-0 lg:mx-0 lg:ml-auto lg:max-w-lg lg:pt-8">
|
||||
<.logo />
|
||||
|
||||
<h1 class="mt-10 text-4xl font-bold tracking-tight text-white sm:text-5xl">
|
||||
Your most successful<br class="hidden sm:inline" />
|
||||
<span class="text-green-300">ad campaign</span>
|
||||
is just<br class="hidden sm:inline" /> a few livestreams away
|
||||
</h1>
|
||||
<p class="mt-6 text-xl leading-8 tracking-tight text-gray-300">
|
||||
In-video livestream ads that help you stand out<br class="hidden sm:inline" />
|
||||
in front of millions on Twitch, YouTube and X.
|
||||
</p>
|
||||
<div class="hidden lg:mt-10 lg:flex items-center gap-x-6">
|
||||
<a
|
||||
href="#contact-form"
|
||||
class="rounded-md bg-indigo-500 px-3.5 py-2.5 text-lg font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-400"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto max-w-2xl xl:max-w-3xl aspect-[1456/756] w-full h-full flex-shrink-0 mt-12 flex lg:mt-6 xl:mt-0">
|
||||
<img
|
||||
src={~p"/images/partner-demo.png"}
|
||||
alt="Demo"
|
||||
class="w-full h-full shrink-0 rounded-xl shadow-2xl ring-1 ring-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Feature section -->
|
||||
<div class="mt-16 sm:mt-28">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-4xl sm:text-center">
|
||||
<h2 class="mt-2 text-4xl font-bold tracking-tight text-white sm:text-5xl">
|
||||
Influencer Marketing on Autopilot
|
||||
</h2>
|
||||
<p class="mt-6 text-xl sm:text-2xl leading-8 tracking-tight text-gray-300">
|
||||
Distribute your ad creatives to the most engaged tech audience in-video and measure success with our comprehensive analytics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative overflow-hidden pt-16">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<img
|
||||
src={~p"/images/analytics.png"}
|
||||
alt="Analytics"
|
||||
class="rounded-xl shadow-2xl ring-1 ring-white/10"
|
||||
width="1648"
|
||||
height="800"
|
||||
/>
|
||||
<div class="relative" aria-hidden="true">
|
||||
<div class="absolute -inset-x-20 bottom-0 bg-gradient-to-t from-gray-950 pt-[20%]">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-auto mt-16 max-w-7xl px-6 sm:mt-20 md:mt-24 lg:px-8">
|
||||
<dl class="mx-auto grid max-w-2xl grid-cols-1 gap-x-6 gap-y-10 text-base leading-7 text-gray-300 sm:grid-cols-2 lg:mx-0 lg:max-w-none lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16">
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" /><path d="M7 9l5 -5l5 5" /><path d="M12 4l0 12" />
|
||||
</svg>
|
||||
Easy Upload
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Upload your ad creatives quickly and easily through our intuitive interface.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12 7a5 5 0 1 0 5 5" /><path d="M13 3.055a9 9 0 1 0 7.941 7.945" /><path d="M15 6v3h3l3 -3h-3v-3z" /><path d="M15 9l-3 3" />
|
||||
</svg>
|
||||
Targeted Audience Reach
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Connect with a highly engaged, tech-focused audience across multiple streaming platforms.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M5 5m0 1a1 1 0 0 1 1 -1h12a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-12a1 1 0 0 1 -1 -1z" /><path d="M9 9h6v6h-6z" /><path d="M3 10h2" /><path d="M3 14h2" /><path d="M10 3v2" /><path d="M14 3v2" /><path d="M21 10h-2" /><path d="M21 14h-2" /><path d="M14 21v-2" /><path d="M10 21v-2" />
|
||||
</svg>
|
||||
Automated Placement
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Our system automatically places your ads in relevant tech content.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M10 13a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M8 21v-1a2 2 0 0 1 2 -2h4a2 2 0 0 1 2 2v1" /><path d="M15 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M17 10h2a2 2 0 0 1 2 2v1" /><path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" /><path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
|
||||
</svg>
|
||||
Wide Reach
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Access a vast network of tech-savvy viewers through our platform.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3v18h18" /><path d="M20 18v3" /><path d="M16 16v5" /><path d="M12 13v8" /><path d="M8 16v5" /><path d="M3 11c6 0 5 -5 9 -5s3 5 9 5" />
|
||||
</svg>
|
||||
Detailed Analytics
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Get comprehensive insights into your ad performance with our powerful analytics tools.
|
||||
</dd>
|
||||
</div>
|
||||
<div class="relative pl-9">
|
||||
<dt class="inline font-semibold text-white">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="absolute left-0 top-0 h-6 w-6 text-indigo-500"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M17 8v-3a1 1 0 0 0 -1 -1h-10a2 2 0 0 0 0 4h12a1 1 0 0 1 1 1v3m0 4v3a1 1 0 0 1 -1 1h-12a2 2 0 0 1 -2 -2v-12" /><path d="M20 12v4h-4a2 2 0 0 1 0 -4h4" />
|
||||
</svg>
|
||||
Smart Spending
|
||||
</dt>
|
||||
<dd class="inline">
|
||||
Get more bang for your buck with our clever, targeted ad approach.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Contact form -->
|
||||
<div class="isolate px-6 pt-24 sm:pt-32 pb-12 lg:px-8">
|
||||
<div
|
||||
id="contact-form"
|
||||
class="mx-auto relative px-12 py-12 ring-1 ring-purple-400 max-w-4xl rounded-xl shadow-lg overflow-hidden bg-white/5"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-x-0 -z-10 transform overflow-hidden blur-3xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="relative -z-10 aspect-[1155/678] w-[36.125rem] max-w-none rotate-[30deg] bg-gradient-to-tr from-[#80caff] to-[#4f46e5] opacity-10 sm:w-[72.1875rem]"
|
||||
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h2 class="text-4xl font-bold tracking-tight text-white sm:text-5xl">Work With Us</h2>
|
||||
<div class="w-96 h-0.5 mx-auto my-4 bg-gradient-to-r from-[#120f22] via-purple-400 to-[#120f22]">
|
||||
</div>
|
||||
<p class="mt-2 text-xl sm:text-2xl tracking-tight leading-8 text-gray-300">
|
||||
We only partner with 1-2 new clients per month. Your application reaches our CEO's inbox faster than the speed of light.
|
||||
</p>
|
||||
</div>
|
||||
<.form for={@form} phx-submit="save" action="#" class="pt-8">
|
||||
<div class="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<.input field={@form[:email]} type="email" label="What is your email address?" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<.input field={@form[:website_url]} type="text" label="What is your website?" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<.input field={@form[:revenue]} type="text" label="What is your revenue?" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<.input
|
||||
field={@form[:company_location]}
|
||||
type="text"
|
||||
label="Where is your company based?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-10">
|
||||
<.button
|
||||
phx-disable-with="Sending..."
|
||||
class=" w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-purple-600 hover:bg-purple-600 active:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-400"
|
||||
>
|
||||
Let's talk
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer aria-labelledby="footer-heading" class="relative">
|
||||
<h2 id="footer-heading" class="sr-only">Footer</h2>
|
||||
<div class="mx-auto max-w-7xl px-6 pb-12 pt-4 lg:px-8">
|
||||
<div class="border-t border-white/10 pt-12 md:flex md:items-center md:justify-between">
|
||||
<div class="flex space-x-6 md:order-2">
|
||||
<a href="https://twitter.com/algoraio" class="text-gray-500 hover:text-gray-400">
|
||||
<span class="sr-only">X</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M13.6823 10.6218L20.2391 3H18.6854L12.9921 9.61788L8.44486 3H3.2002L10.0765 13.0074L3.2002 21H4.75404L10.7663 14.0113L15.5685 21H20.8131L13.6819 10.6218H13.6823ZM11.5541 13.0956L10.8574 12.0991L5.31391 4.16971H7.70053L12.1742 10.5689L12.8709 11.5655L18.6861 19.8835H16.2995L11.5541 13.096V13.0956Z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/algora-io/tv" class="text-gray-500 hover:text-gray-400">
|
||||
<span class="sr-only">GitHub</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://www.youtube.com/@algora-io" class="text-gray-500 hover:text-gray-400">
|
||||
<span class="sr-only">YouTube</span>
|
||||
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="mt-8 md:mt-0 md:order-1">
|
||||
<.logo />
|
||||
<p class="mt-2 text-lg leading-5 text-gray-200 tracking-tight">
|
||||
We work with elite tech startups to provide elite advertising.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket |> assign(:form, to_form(Contact.change_info(%Info{})))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"info" => info_params}, socket) do
|
||||
case Contact.create_info(info_params) do
|
||||
{:ok, _info} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Thank you for your interest. We'll be in touch soon!")
|
||||
|> assign(:form, to_form(Contact.change_info(%Info{})))}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, form: to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_params(params, _url, socket) do
|
||||
LayoutComponent.hide_modal()
|
||||
{:noreply, socket |> apply_action(socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Partner")
|
||||
|> assign(
|
||||
:page_description,
|
||||
"In-video livestream ads that help you stand out in front of millions on Twitch, YouTube and X."
|
||||
)
|
||||
|> assign(:page_image, "#{AlgoraWeb.Endpoint.url()}/images/og/partner.png")
|
||||
end
|
||||
end
|
@ -110,9 +110,11 @@ defmodule AlgoraWeb.ShowLive.FormComponent do
|
||||
consume_uploaded_entry(socket, entry, fn %{path: path} = _meta ->
|
||||
remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}"
|
||||
|
||||
content_type = ExMarcel.MimeType.for({:path, path})
|
||||
|
||||
{:ok, _} =
|
||||
Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end,
|
||||
content_type: "image/jpeg"
|
||||
content_type: content_type
|
||||
)
|
||||
|
||||
bucket = Algora.config([:buckets, :media])
|
||||
|
@ -56,6 +56,13 @@ defmodule AlgoraWeb.Router do
|
||||
get "/:channel_handle/embed", EmbedPopoutController, :get
|
||||
get "/:channel_handle/:video_id/embed", EmbedPopoutController, :get_by_id
|
||||
|
||||
live_session :ads,
|
||||
layout: {AlgoraWeb.Layouts, :live_bare},
|
||||
root_layout: {AlgoraWeb.Layouts, :root_embed} do
|
||||
live "/partner", PartnerLive, :show
|
||||
live "/:channel_handle/ads", AdOverlayLive, :show
|
||||
end
|
||||
|
||||
live_session :chat,
|
||||
layout: {AlgoraWeb.Layouts, :live_chat},
|
||||
root_layout: {AlgoraWeb.Layouts, :root_embed} do
|
||||
@ -75,8 +82,16 @@ defmodule AlgoraWeb.Router do
|
||||
scope "/", AlgoraWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/go/:slug", AdRedirectController, :go
|
||||
|
||||
delete "/auth/logout", OAuthCallbackController, :sign_out
|
||||
|
||||
live_session :schedule,
|
||||
on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Nav] do
|
||||
live "/ads/schedule", AdLive.Schedule, :schedule
|
||||
live "/analytics/:slug", AdLive.Analytics, :show
|
||||
end
|
||||
|
||||
live_session :admin,
|
||||
on_mount: [
|
||||
{AlgoraWeb.UserAuth, :ensure_authenticated},
|
||||
@ -85,6 +100,14 @@ defmodule AlgoraWeb.Router do
|
||||
] do
|
||||
live "/shows", ShowLive.Index, :index
|
||||
live "/shows/new", ShowLive.Index, :new
|
||||
|
||||
live "/ads", AdLive.Index, :index
|
||||
live "/ads/new", AdLive.Index, :new
|
||||
live "/ads/:id/edit", AdLive.Index, :edit
|
||||
live "/ads/:id", AdLive.Show, :show
|
||||
live "/ads/:id/show/edit", AdLive.Show, :edit
|
||||
|
||||
live "/admin/content", ContentLive, :show
|
||||
end
|
||||
|
||||
live_session :authenticated,
|
||||
|
1
mix.exs
1
mix.exs
@ -44,7 +44,6 @@ defmodule Algora.MixProject do
|
||||
{:ex_m3u8, "~> 0.9.0"},
|
||||
{:ex_marcel, "~> 0.1.0"},
|
||||
{:exla, ">= 0.0.0"},
|
||||
{:exsync, "~> 0.2", only: :dev},
|
||||
{:ffmpex, "~> 0.10.0"},
|
||||
{:finch, "~> 0.18"},
|
||||
{:floki, ">= 0.30.0", only: :test},
|
||||
|
26
priv/repo/migrations/20240729183655_create_ads.exs
Normal file
26
priv/repo/migrations/20240729183655_create_ads.exs
Normal file
@ -0,0 +1,26 @@
|
||||
defmodule Algora.Repo.Migrations.CreateAds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:ads) do
|
||||
add :verified, :boolean, default: false, null: false
|
||||
add :website_url, :string
|
||||
add :composite_asset_url, :string
|
||||
add :asset_url, :string
|
||||
add :logo_url, :string
|
||||
add :qrcode_url, :string
|
||||
add :start_date, :naive_datetime
|
||||
add :end_date, :naive_datetime
|
||||
add :total_budget, :integer
|
||||
add :daily_budget, :integer
|
||||
add :tech_stack, {:array, :string}
|
||||
add :click_count, :integer
|
||||
add :status, :string
|
||||
add :user_id, references(:users, on_delete: :nothing)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:ads, [:user_id])
|
||||
end
|
||||
end
|
@ -0,0 +1,17 @@
|
||||
defmodule Algora.Repo.Migrations.CreateAdImpressions do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:ad_impressions) do
|
||||
add :duration, :integer
|
||||
add :viewers_count, :integer
|
||||
add :ad_id, references(:ads, on_delete: :nothing)
|
||||
add :video_id, references(:videos, on_delete: :nothing)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:ad_impressions, [:ad_id])
|
||||
create index(:ad_impressions, [:video_id])
|
||||
end
|
||||
end
|
19
priv/repo/migrations/20240730000856_create_ad_visits.exs
Normal file
19
priv/repo/migrations/20240730000856_create_ad_visits.exs
Normal file
@ -0,0 +1,19 @@
|
||||
defmodule Algora.Repo.Migrations.CreateAdVisits do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:ad_visits) do
|
||||
add :ad_id, references(:ads, on_delete: :nothing)
|
||||
add :video_id, references(:videos, on_delete: :nothing)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
alter table(:ads) do
|
||||
remove :click_count
|
||||
end
|
||||
|
||||
create index(:ad_visits, [:ad_id])
|
||||
create index(:ad_visits, [:video_id])
|
||||
end
|
||||
end
|
14
priv/repo/migrations/20240730160846_create_contact_info.exs
Normal file
14
priv/repo/migrations/20240730160846_create_contact_info.exs
Normal file
@ -0,0 +1,14 @@
|
||||
defmodule Algora.Repo.Migrations.CreateContactInfo do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:contact_info) do
|
||||
add :email, :string
|
||||
add :website_url, :string
|
||||
add :revenue, :string
|
||||
add :company_location, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
11
priv/repo/migrations/20240802162318_add_slug_to_ads.exs
Normal file
11
priv/repo/migrations/20240802162318_add_slug_to_ads.exs
Normal file
@ -0,0 +1,11 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddSlugToAds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:ads) do
|
||||
add :slug, :string
|
||||
end
|
||||
|
||||
create unique_index(:ads, [:slug])
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddBorderColorToAds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:ads) do
|
||||
add :border_color, :string
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,45 @@
|
||||
defmodule Algora.Repo.Migrations.CreateAdRelatedTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:product_reviews) do
|
||||
add :clip_from, :integer, null: false
|
||||
add :clip_to, :integer, null: false
|
||||
add :thumbnail_url, :string
|
||||
add :ad_id, references(:ads, on_delete: :nothing), null: false
|
||||
add :video_id, references(:videos, on_delete: :nothing), null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:product_reviews, [:ad_id])
|
||||
create index(:product_reviews, [:video_id])
|
||||
|
||||
create table(:ad_appearances) do
|
||||
add :airtime, :integer, null: false
|
||||
add :ad_id, references(:ads, on_delete: :nothing), null: false
|
||||
add :video_id, references(:videos, on_delete: :nothing), null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:ad_appearances, [:ad_id])
|
||||
create index(:ad_appearances, [:video_id])
|
||||
|
||||
create table(:content_metrics) do
|
||||
add :algora_stream_url, :string
|
||||
add :twitch_stream_url, :string
|
||||
add :youtube_video_url, :string
|
||||
add :twitter_video_url, :string
|
||||
add :twitch_avg_concurrent_viewers, :integer
|
||||
add :twitch_views, :integer
|
||||
add :youtube_views, :integer
|
||||
add :twitter_views, :integer
|
||||
add :video_id, references(:videos, on_delete: :nothing), null: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:content_metrics, [:video_id])
|
||||
end
|
||||
end
|
11
priv/repo/migrations/20240814021502_add_name_to_ads.exs
Normal file
11
priv/repo/migrations/20240814021502_add_name_to_ads.exs
Normal file
@ -0,0 +1,11 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddNameToAds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:ads) do
|
||||
add :name, :string
|
||||
end
|
||||
|
||||
execute "UPDATE ads SET name = INITCAP(slug)"
|
||||
end
|
||||
end
|
@ -0,0 +1,17 @@
|
||||
defmodule Algora.Repo.Local.Migrations.UpdateContentMetricsFields do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "UPDATE content_metrics SET twitch_avg_concurrent_viewers = COALESCE(twitch_avg_concurrent_viewers, 0)"
|
||||
execute "UPDATE content_metrics SET twitch_views = COALESCE(twitch_views, 0)"
|
||||
execute "UPDATE content_metrics SET youtube_views = COALESCE(youtube_views, 0)"
|
||||
execute "UPDATE content_metrics SET twitter_views = COALESCE(twitter_views, 0)"
|
||||
|
||||
alter table(:content_metrics) do
|
||||
modify :twitch_avg_concurrent_viewers, :integer, null: false, default: 0, from: :integer
|
||||
modify :twitch_views, :integer, null: false, default: 0, from: :integer
|
||||
modify :youtube_views, :integer, null: false, default: 0, from: :integer
|
||||
modify :twitter_views, :integer, null: false, default: 0, from: :integer
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,9 @@
|
||||
defmodule Algora.Repo.Local.Migrations.AddOgImageUrlToAds do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:ads) do
|
||||
add :og_image_url, :string
|
||||
end
|
||||
end
|
||||
end
|
BIN
priv/static/images/analytics.png
Normal file
BIN
priv/static/images/analytics.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 259 KiB |
BIN
priv/static/images/og/partner.png
Normal file
BIN
priv/static/images/og/partner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 329 KiB |
BIN
priv/static/images/partner-demo.png
Normal file
BIN
priv/static/images/partner-demo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 870 KiB |
@ -7,10 +7,6 @@ import Ecto.Changeset
|
||||
alias Algora.{Accounts, Library, Repo, Storage, Cache, ML}
|
||||
|
||||
IEx.configure(inspect: [charlists: :as_lists])
|
||||
|
||||
if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do
|
||||
ExSync.register_group_leader()
|
||||
end
|
||||
```
|
||||
|
||||
## Section
|
||||
|
33
test/algora/ads_test.exs
Normal file
33
test/algora/ads_test.exs
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule Algora.AdsTest do
|
||||
use Algora.DataCase
|
||||
|
||||
alias Algora.Ads
|
||||
|
||||
describe "next_slot/1" do
|
||||
test "returns the next 10-minute slot" do
|
||||
# Test case 1: Exactly at the start of a slot
|
||||
time = ~U[2024-08-03 10:00:00.000Z]
|
||||
assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z]
|
||||
|
||||
# Test case 2: In the middle of a slot
|
||||
time = ~U[2024-08-03 10:05:30.123Z]
|
||||
assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z]
|
||||
|
||||
# Test case 3: Just before the next slot
|
||||
time = ~U[2024-08-03 10:09:59.999Z]
|
||||
assert Ads.next_slot(time) == ~U[2024-08-03 10:10:00.000Z]
|
||||
|
||||
# Test case 4: Crossing an hour boundary
|
||||
time = ~U[2024-08-03 10:55:00.123Z]
|
||||
assert Ads.next_slot(time) == ~U[2024-08-03 11:00:00.000Z]
|
||||
|
||||
# Test case 5: Crossing a day boundary
|
||||
time = ~U[2024-08-03 23:55:00.123Z]
|
||||
assert Ads.next_slot(time) == ~U[2024-08-04 00:00:00.000Z]
|
||||
end
|
||||
|
||||
test "uses current time when no argument is provided" do
|
||||
assert Ads.next_slot() == Ads.next_slot(DateTime.utc_now())
|
||||
end
|
||||
end
|
||||
end
|
38
test/support/conn_case.ex
Normal file
38
test/support/conn_case.ex
Normal file
@ -0,0 +1,38 @@
|
||||
defmodule AlgoraWeb.ConnCase do
|
||||
@moduledoc """
|
||||
This module defines the test case to be used by
|
||||
tests that require setting up a connection.
|
||||
|
||||
Such tests rely on `Phoenix.ConnTest` and also
|
||||
import other functionality to make it easier
|
||||
to build common data structures and query the data layer.
|
||||
|
||||
Finally, if the test case interacts with the database,
|
||||
we enable the SQL sandbox, so changes done to the database
|
||||
are reverted at the end of every test. If you are using
|
||||
PostgreSQL, you can even run database tests asynchronously
|
||||
by setting `use AlgoraWeb.ConnCase, async: true`, although
|
||||
this option is not recommended for other databases.
|
||||
"""
|
||||
|
||||
use ExUnit.CaseTemplate
|
||||
|
||||
using do
|
||||
quote do
|
||||
# The default endpoint for testing
|
||||
@endpoint AlgoraWeb.Endpoint
|
||||
|
||||
use AlgoraWeb, :verified_routes
|
||||
|
||||
# Import conveniences for testing with connections
|
||||
import Plug.Conn
|
||||
import Phoenix.ConnTest
|
||||
import AlgoraWeb.ConnCase
|
||||
end
|
||||
end
|
||||
|
||||
setup tags do
|
||||
Algora.DataCase.setup_sandbox(tags)
|
||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||
end
|
||||
end
|
58
test/support/data_case.ex
Normal file
58
test/support/data_case.ex
Normal file
@ -0,0 +1,58 @@
|
||||
defmodule Algora.DataCase do
|
||||
@moduledoc """
|
||||
This module defines the setup for tests requiring
|
||||
access to the application's data layer.
|
||||
|
||||
You may define functions here to be used as helpers in
|
||||
your tests.
|
||||
|
||||
Finally, if the test case interacts with the database,
|
||||
we enable the SQL sandbox, so changes done to the database
|
||||
are reverted at the end of every test. If you are using
|
||||
PostgreSQL, you can even run database tests asynchronously
|
||||
by setting `use Algora.DataCase, async: true`, although
|
||||
this option is not recommended for other databases.
|
||||
"""
|
||||
|
||||
use ExUnit.CaseTemplate
|
||||
|
||||
using do
|
||||
quote do
|
||||
alias Algora.Repo
|
||||
|
||||
import Ecto
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
import Algora.DataCase
|
||||
end
|
||||
end
|
||||
|
||||
setup tags do
|
||||
Algora.DataCase.setup_sandbox(tags)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sets up the sandbox based on the test tags.
|
||||
"""
|
||||
def setup_sandbox(tags) do
|
||||
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Algora.Repo, shared: not tags[:async])
|
||||
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A helper that transforms changeset errors into a map of messages.
|
||||
|
||||
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
||||
assert "password is too short" in errors_on(changeset).password
|
||||
assert %{password: ["password is too short"]} = errors_on(changeset)
|
||||
|
||||
"""
|
||||
def errors_on(changeset) do
|
||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
|
||||
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
2
test/test_helper.exs
Normal file
2
test/test_helper.exs
Normal file
@ -0,0 +1,2 @@
|
||||
ExUnit.start()
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Algora.Repo, :manual)
|
Loading…
Reference in New Issue
Block a user