1
0
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:
Zafer Cesur 2024-08-15 18:02:56 +03:00 committed by GitHub
parent 199e568a67
commit c79d0e8f2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 3274 additions and 21 deletions

View File

@ -29,4 +29,5 @@ node_modules
/.local /.local
/priv/cache /priv/cache
*.patch *.patch
/.fly /.fly
/xref*

3
.gitignore vendored
View File

@ -41,4 +41,5 @@ npm-debug.log
/.local /.local
/priv/cache /priv/cache
*.patch *.patch
/.fly /.fly
/xref*

View File

@ -4,7 +4,3 @@ import Ecto.Changeset
alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows} alias Algora.{Admin, Accounts, Library, Repo, Storage, Cache, ML, Shows}
IEx.configure(inspect: [charlists: :as_lists]) IEx.configure(inspect: [charlists: :as_lists])
if Code.ensure_loaded?(ExSync) && function_exported?(ExSync, :register_group_leader, 0) do
ExSync.register_group_leader()
end

View File

@ -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 { .scrollbar-thin::-webkit-scrollbar {
width: 0.25em; width: 0.25em;
height: 0.25em; height: 0.25em;

View File

@ -152,8 +152,11 @@ const Hooks = {
this.playerId = this.el.id; 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, { this.player = videojs(this.el, {
autoplay: "any", autoplay: autoplay,
liveui: true, liveui: true,
html5: { html5: {
vhs: { vhs: {

View File

@ -6,16 +6,15 @@ import Config
# to provide built-in test partitioning in CI environment. # to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information. # Run `mix help test` for more information.
config :algora, Algora.Repo, config :algora, Algora.Repo,
username: "postgres", url: System.get_env("TEST_DATABASE_URL"),
password: "postgres", show_sensitive_data_on_connection_error: true,
database: "algora_test#{System.get_env("MIX_TEST_PARTITION")}",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10 pool_size: 10
config :algora, Algora.Repo.Local, 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, show_sensitive_data_on_connection_error: true,
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 10, pool_size: 10,
priv: "priv/repo" priv: "priv/repo"
@ -27,7 +26,7 @@ config :algora, AlgoraWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002], http: [ip: {127, 0, 0, 1}, port: 4002],
server: false server: false
config :algora, AlgoraWeb.Embed.Endpoint, config :algora, AlgoraWeb.Embed.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4003], http: [ip: {127, 0, 0, 1}, port: 4003],
server: false server: false

View 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
View 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
View 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

View 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

View 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
View 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

View 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

View 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
View 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

View File

@ -3,10 +3,10 @@ defmodule Algora.Clipper do
defp bucket(), do: Algora.config([:buckets, :media]) 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}" 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}" do: "#{Storage.endpoint_url()}/#{bucket()}/clips/#{uuid}/#{uri}"
def clip(video, from, to) do def clip(video, from, to) do

104
lib/algora/contact.ex Normal file
View 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

View 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

View File

@ -355,6 +355,23 @@ defmodule Algora.Library do
to_hhmmss(trunc(duration)) to_hhmmss(trunc(duration))
end 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 def unsubscribe_to_channel(%Channel{} = channel) do
Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id)) Phoenix.PubSub.unsubscribe(@pubsub, topic(channel.user_id))
end end
@ -557,6 +574,21 @@ defmodule Algora.Library do
|> Repo.all() |> Repo.all()
end 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 def list_livestreams(limit \\ 100) do
from(v in Video, from(v in Video,
join: u in User, join: u in User,

View File

@ -7,6 +7,7 @@ defmodule Algora.Library.Video do
alias Algora.Library.Video alias Algora.Library.Video
alias Algora.Shows.Show alias Algora.Shows.Show
alias Algora.Chat.Message alias Algora.Chat.Message
alias Algora.Ads.{Appearance, ContentMetrics, ProductReview}
@type t() :: %__MODULE__{} @type t() :: %__MODULE__{}
@ -37,6 +38,9 @@ defmodule Algora.Library.Video do
belongs_to :transmuxed_from, Video belongs_to :transmuxed_from, Video
has_many :messages, Message has_many :messages, Message
has_many :appearances, Appearance
has_many :content_metrics, ContentMetrics
has_many :product_reviews, ProductReview
timestamps() timestamps()
end end

View File

@ -54,6 +54,12 @@
</script> </script>
<script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js"> <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
</script> </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> </head>
<body> <body>
<div class="fixed inset-0 bg-[radial-gradient(ellipse_at_top_left,_#1d1e3a_0%,_#050217_40%,_#050217_60%,_#1d1e3a_100%)] z-0"> <div class="fixed inset-0 bg-[radial-gradient(ellipse_at_top_left,_#1d1e3a_0%,_#050217_40%,_#050217_60%,_#1d1e3a_100%)] z-0">

View File

@ -54,6 +54,12 @@
</script> </script>
<script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js"> <script defer data-domain="tv.algora.io" src="https://plausible.io/js/script.js">
</script> </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> </head>
<body> <body>
<%= @inner_content %> <%= @inner_content %>

View 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

View 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

View 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

View 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 %> &bull; <%= 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>

View 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

View 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

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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

View File

@ -110,9 +110,11 @@ defmodule AlgoraWeb.ShowLive.FormComponent do
consume_uploaded_entry(socket, entry, fn %{path: path} = _meta -> consume_uploaded_entry(socket, entry, fn %{path: path} = _meta ->
remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}" remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}"
content_type = ExMarcel.MimeType.for({:path, path})
{:ok, _} = {:ok, _} =
Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end, Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end,
content_type: "image/jpeg" content_type: content_type
) )
bucket = Algora.config([:buckets, :media]) bucket = Algora.config([:buckets, :media])

View File

@ -56,6 +56,13 @@ defmodule AlgoraWeb.Router do
get "/:channel_handle/embed", EmbedPopoutController, :get get "/:channel_handle/embed", EmbedPopoutController, :get
get "/:channel_handle/:video_id/embed", EmbedPopoutController, :get_by_id 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, live_session :chat,
layout: {AlgoraWeb.Layouts, :live_chat}, layout: {AlgoraWeb.Layouts, :live_chat},
root_layout: {AlgoraWeb.Layouts, :root_embed} do root_layout: {AlgoraWeb.Layouts, :root_embed} do
@ -75,8 +82,16 @@ defmodule AlgoraWeb.Router do
scope "/", AlgoraWeb do scope "/", AlgoraWeb do
pipe_through :browser pipe_through :browser
get "/go/:slug", AdRedirectController, :go
delete "/auth/logout", OAuthCallbackController, :sign_out 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, live_session :admin,
on_mount: [ on_mount: [
{AlgoraWeb.UserAuth, :ensure_authenticated}, {AlgoraWeb.UserAuth, :ensure_authenticated},
@ -85,6 +100,14 @@ defmodule AlgoraWeb.Router do
] do ] do
live "/shows", ShowLive.Index, :index live "/shows", ShowLive.Index, :index
live "/shows/new", ShowLive.Index, :new 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 end
live_session :authenticated, live_session :authenticated,

View File

@ -44,7 +44,6 @@ defmodule Algora.MixProject do
{:ex_m3u8, "~> 0.9.0"}, {:ex_m3u8, "~> 0.9.0"},
{:ex_marcel, "~> 0.1.0"}, {:ex_marcel, "~> 0.1.0"},
{:exla, ">= 0.0.0"}, {:exla, ">= 0.0.0"},
{:exsync, "~> 0.2", only: :dev},
{:ffmpex, "~> 0.10.0"}, {:ffmpex, "~> 0.10.0"},
{:finch, "~> 0.18"}, {:finch, "~> 0.18"},
{:floki, ">= 0.30.0", only: :test}, {:floki, ">= 0.30.0", only: :test},

View 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

View File

@ -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

View 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

View 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

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 KiB

View File

@ -7,10 +7,6 @@ import Ecto.Changeset
alias Algora.{Accounts, Library, Repo, Storage, Cache, ML} alias Algora.{Accounts, Library, Repo, Storage, Cache, ML}
IEx.configure(inspect: [charlists: :as_lists]) 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 ## Section

33
test/algora/ads_test.exs Normal file
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(Algora.Repo, :manual)