1
0
mirror of https://github.com/algora-io/tv.git synced 2025-02-14 01:59:50 +02:00

add shows (#40)

This commit is contained in:
Zafer Cesur 2024-05-25 12:30:01 +03:00 committed by GitHub
parent 5fa6e1805c
commit 72acdbe1f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 948 additions and 83 deletions

View File

@ -11,6 +11,7 @@ defmodule Algora.Accounts.User do
field :channel_tagline, :string
field :avatar_url, :string
field :external_homepage_url, :string
field :twitter_url, :string
field :videos_count, :integer
field :is_live, :boolean, default: false
field :stream_key, :string

View File

@ -53,7 +53,8 @@ defmodule Algora.Application do
id: Membrane.RTMP.Source.TcpServer,
start: {Membrane.RTMP.Source.TcpServer, :start_link, [tcp_server_options]}
},
Algora.Stargazer
Algora.Stargazer,
ExMarcel.TableWrapper
# Start a worker by calling: Algora.Worker.start_link(arg)
# {Algora.Worker, arg}
]

164
lib/algora/events.ex Normal file
View File

@ -0,0 +1,164 @@
defmodule Algora.Events do
import Ecto.Query
alias Algora.{Repo}
alias Algora.Library.Video
alias Algora.Events.Event
alias Algora.Accounts.{User, Identity}
def toggle_subscription_event(user, show) do
name = if subscribed?(user, show), do: :unsubscribed, else: :subscribed
%Event{
actor_id: "user_#{user.id}",
user_id: user.id,
show_id: show.id,
channel_id: show.user_id,
name: name
}
|> Event.changeset(%{})
|> Repo.insert()
end
def toggle_rsvp_event(user, show) do
name = if rsvpd?(user, show), do: :unrsvpd, else: :rsvpd
%Event{
actor_id: "user_#{user.id}",
user_id: user.id,
show_id: show.id,
channel_id: show.user_id,
name: name
}
|> Event.changeset(%{})
|> Repo.insert()
end
def subscribed?(nil, _show), do: false
def subscribed?(user, show) do
event =
from(
e in Event,
where:
e.channel_id == ^show.user_id and
e.user_id == ^user.id and
(e.name == :subscribed or
e.name == :unsubscribed),
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
event && event.name == :subscribed
end
def rsvpd?(nil, _show), do: false
def rsvpd?(user, show) do
event =
from(
e in Event,
where:
e.channel_id == ^show.user_id and
e.user_id == ^user.id and
(e.name == :rsvpd or
e.name == :unrsvpd),
order_by: [desc: e.inserted_at],
limit: 1
)
|> Repo.one()
event && event.name == :rsvpd
end
def fetch_attendees(show) do
# Get the latest relevant events (:rsvpd and :unrsvpd) for each user
latest_events_query =
from(e in Event,
where: e.channel_id == ^show.user_id and e.name in [:rsvpd, :unrsvpd],
order_by: [desc: e.inserted_at],
distinct: e.user_id
)
# Join user data and filter for :rsvpd events
from(e in subquery(latest_events_query),
join: u in User,
on: e.user_id == u.id,
join: i in Identity,
on: i.user_id == u.id and i.provider == "github",
select_merge: %{
user_handle: u.handle,
user_display_name: coalesce(u.name, u.handle),
user_email: u.email,
user_avatar_url: u.avatar_url,
user_github_handle: i.provider_login
},
where: e.name == :rsvpd,
order_by: [desc: e.inserted_at, desc: e.id]
)
|> Repo.all()
end
def fetch_unique_viewers(user) do
subquery_first_watched =
from(e in Event,
where: e.channel_id == ^user.id and e.name in [:watched, :subscribed],
order_by: [asc: e.inserted_at],
distinct: e.user_id
)
from(e in subquery(subquery_first_watched),
join: u in User,
on: e.user_id == u.id,
join: i in Identity,
on: i.user_id == u.id and i.provider == "github",
left_join: v in Video,
on: e.video_id == v.id,
select_merge: %{
user_handle: u.handle,
user_display_name: coalesce(u.name, u.handle),
user_email: u.email,
user_avatar_url: u.avatar_url,
user_github_handle: i.provider_login,
first_video_id: e.video_id,
first_video_title: v.title
},
distinct: e.user_id,
order_by: [desc: e.inserted_at, desc: e.id]
)
|> Repo.all()
end
def fetch_unique_subscribers(user) do
# Get the latest relevant events (:subscribed and :unsubscribed) for each user
latest_events_query =
from(e in Event,
where: e.channel_id == ^user.id and e.name in [:subscribed, :unsubscribed],
order_by: [desc: e.inserted_at],
distinct: e.user_id
)
# Join user data and filter for :subscribed events
from(e in subquery(latest_events_query),
join: u in User,
on: e.user_id == u.id,
join: i in Identity,
on: i.user_id == u.id and i.provider == "github",
left_join: v in Video,
on: e.video_id == v.id,
select_merge: %{
user_handle: u.handle,
user_display_name: coalesce(u.name, u.handle),
user_email: u.email,
user_avatar_url: u.avatar_url,
user_github_handle: i.provider_login,
first_video_id: e.video_id,
first_video_title: v.title
},
where: e.name == :subscribed,
order_by: [desc: e.inserted_at, desc: e.id]
)
|> Repo.all()
end
end

View File

@ -7,6 +7,7 @@ defmodule Algora.Events.Event do
field :user_id, :integer
field :video_id, :integer
field :channel_id, :integer
field :show_id, :integer
field :user_handle, :string, virtual: true
field :user_display_name, :string, virtual: true
field :user_email, :string, virtual: true
@ -14,7 +15,7 @@ defmodule Algora.Events.Event do
field :user_github_handle, :string, virtual: true
field :first_video_id, :integer, virtual: true
field :first_video_title, :string, virtual: true
field :name, Ecto.Enum, values: [:subscribed, :unsubscribed, :watched]
field :name, Ecto.Enum, values: [:subscribed, :unsubscribed, :watched, :rsvpd, :unrsvpd]
timestamps()
end

View File

@ -658,6 +658,7 @@ defmodule Algora.Library do
tagline: user.channel_tagline,
avatar_url: user.avatar_url,
external_homepage_url: user.external_homepage_url,
twitter_url: user.twitter_url,
is_live: user.is_live,
bounties_count: user.bounties_count,
orgs_contributed: user.orgs_contributed,

View File

@ -5,6 +5,7 @@ defmodule Algora.Library.Channel do
tagline: nil,
avatar_url: nil,
external_homepage_url: nil,
twitter_url: nil,
is_live: nil,
bounties_count: nil,
orgs_contributed: nil,

View File

@ -5,6 +5,7 @@ defmodule Algora.Library.Video do
alias Algora.Accounts.User
alias Algora.Library.Video
alias Algora.Shows.Show
alias Algora.Chat.Message
@type t() :: %__MODULE__{}
@ -32,6 +33,7 @@ defmodule Algora.Library.Video do
field :local_path, :string
belongs_to :user, User
belongs_to :show, Show
belongs_to :transmuxed_from, Video
has_many :messages, Message

34
lib/algora/shows.ex Normal file
View File

@ -0,0 +1,34 @@
defmodule Algora.Shows do
import Ecto.Query, warn: false
alias Algora.Repo
alias Algora.Shows.Show
def list_shows do
Repo.all(Show)
end
def get_show!(id), do: Repo.get!(Show, id)
def get_show_by_fields!(fields), do: Repo.get_by!(Show, fields)
def create_show(attrs \\ %{}) do
%Show{}
|> Show.changeset(attrs)
|> Repo.insert()
end
def update_show(%Show{} = show, attrs) do
show
|> Show.changeset(attrs)
|> Repo.update()
end
def delete_show(%Show{} = show) do
Repo.delete(show)
end
def change_show(%Show{} = show, attrs \\ %{}) do
Show.changeset(show, attrs)
end
end

27
lib/algora/shows/show.ex Normal file
View File

@ -0,0 +1,27 @@
defmodule Algora.Shows.Show do
use Ecto.Schema
import Ecto.Changeset
alias Algora.Accounts.User
schema "shows" do
field :title, :string
field :description, :string
field :slug, :string
field :scheduled_for, :naive_datetime
field :image_url, :string
field :og_image_url, :string
field :url, :string
belongs_to :user, User
timestamps()
end
@doc false
def changeset(show, attrs) do
show
|> cast(attrs, [:title, :description, :slug, :scheduled_for, :image_url, :url])
|> validate_required([:title, :slug])
end
end

View File

@ -166,16 +166,14 @@ defmodule AlgoraWeb.CoreComponents do
def playlist(assigns) do
~H"""
<div>
<div class="align-middle inline-block min-w-full">
<div id={@id} class="px-4 min-w-full">
<div
id={"#{@id}-body"}
class="mt-3 gap-8 grid sm:grid-cols-2 lg:grid-cols-3"
phx-update="stream"
>
<.video_entry :for={{_id, video} <- @videos} video={video} />
</div>
<div class="align-middle inline-block min-w-full">
<div id={@id} class="px-4 min-w-full">
<div
id={"#{@id}-body"}
class="mt-3 gap-8 grid sm:grid-cols-2 lg:grid-cols-3"
phx-update="stream"
>
<.video_entry :for={{_id, video} <- @videos} video={video} />
</div>
</div>
</div>
@ -498,7 +496,7 @@ defmodule AlgoraWeb.CoreComponents do
<button
phx-click={hide_modal(@on_cancel, @id)}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40 focus:outline-none focus:opacity-40"
aria-label={gettext("close")}
>
<Heroicons.x_mark solid class="w-5 h-5" />

View File

@ -35,6 +35,24 @@ defmodule AlgoraWeb.UserAuth do
Ecto.NoResultsError -> {:halt, redirect_require_login(socket)}
end
def on_mount(:ensure_admin, _params, session, socket) do
case session do
%{"user_id" => user_id} ->
user = Accounts.get_user!(user_id)
if Accounts.admin?(user) do
{:cont, socket}
else
{:halt, LiveView.redirect(socket, to: ~p"/status/404")}
end
%{} ->
{:halt, redirect_require_login(socket)}
end
rescue
Ecto.NoResultsError -> {:halt, redirect_require_login(socket)}
end
defp redirect_require_login(socket) do
socket
|> LiveView.put_flash(:error, "Please sign in")

View File

@ -1,11 +1,7 @@
defmodule AlgoraWeb.AudienceLive do
use AlgoraWeb, :live_view
import Ecto.Query, warn: false
alias Algora.Accounts.{User, Identity}
alias Algora.Events.Event
alias Algora.Library.Video
alias Algora.Repo
alias Algora.Events
def render(assigns) do
~H"""
@ -60,6 +56,7 @@ defmodule AlgoraWeb.AudienceLive do
</:col>
<:col :let={subscriber}>
<.link
:if={subscriber.first_video_id}
navigate={~p"/#{@current_user.handle}/#{subscriber.first_video_id}"}
class="truncate ml-auto flex w-[200px]"
>
@ -100,6 +97,7 @@ defmodule AlgoraWeb.AudienceLive do
</:col>
<:col :let={viewer}>
<.link
:if={viewer.first_video_id}
navigate={~p"/#{@current_user.handle}/#{viewer.first_video_id}"}
class="truncate ml-auto flex w-[200px]"
>
@ -115,71 +113,12 @@ defmodule AlgoraWeb.AudienceLive do
def mount(_params, _session, socket) do
user = socket.assigns.current_user
viewers = fetch_unique_viewers(user)
subscribers = fetch_unique_subscribers(user)
viewers = Events.fetch_unique_viewers(user)
subscribers = Events.fetch_unique_subscribers(user)
{:ok, assign(socket, viewers: viewers, subscribers: subscribers)}
end
defp fetch_unique_viewers(user) do
subquery_first_watched =
from(e in Event,
where: e.channel_id == ^user.id and e.name in [:watched, :subscribed],
order_by: [asc: e.inserted_at],
distinct: e.user_id
)
from(e in subquery(subquery_first_watched),
join: u in User,
on: e.user_id == u.id,
join: i in Identity,
on: i.user_id == u.id and i.provider == "github",
join: v in Video,
on: e.video_id == v.id,
select_merge: %{
user_handle: u.handle,
user_display_name: coalesce(u.name, u.handle),
user_email: u.email,
user_avatar_url: u.avatar_url,
user_github_handle: i.provider_login,
first_video_id: e.video_id,
first_video_title: v.title
},
distinct: e.user_id,
order_by: [desc: e.inserted_at, desc: e.id]
)
|> Repo.all()
end
defp fetch_unique_subscribers(user) do
# Get the latest relevant events (:subscribed and :unsubscribed) for each user
latest_events_query =
from(e in Event,
where: e.channel_id == ^user.id and e.name in [:subscribed, :unsubscribed],
order_by: [desc: e.inserted_at],
distinct: e.user_id
)
# Join user data and filter for :subscribed events
from(e in subquery(latest_events_query),
join: u in User,
on: e.user_id == u.id,
join: i in Identity,
on: i.user_id == u.id and i.provider == "github",
join: v in Video,
on: e.video_id == v.id,
select_merge: %{
user_handle: u.handle,
user_display_name: coalesce(u.name, u.handle),
user_email: u.email,
user_avatar_url: u.avatar_url,
user_github_handle: i.provider_login,
first_video_id: e.video_id,
first_video_title: v.title
},
where: e.name == :subscribed,
order_by: [desc: e.inserted_at, desc: e.id]
)
|> Repo.all()
{:ok,
socket
|> assign(:viewers, viewers)
|> assign(:subscribers, subscribers)}
end
end

View File

@ -0,0 +1,180 @@
defmodule AlgoraWeb.ShowLive.FormComponent do
use AlgoraWeb, :live_component
alias Algora.Shows
@impl true
def render(assigns) do
~H"""
<div>
<.header class="pb-6">
<%= @title %>
</.header>
<.simple_form
for={@form}
id="show-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<div class="flex flex-col md:flex-row gap-8 justify-between">
<div class="w-full space-y-8">
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:description]} type="textarea" label="Description" rows={3} />
</div>
<div class="shrink-0">
<label for="show_title" class="block text-sm font-semibold leading-6 text-gray-100 mb-2">
Cover image
</label>
<div id="show_image" phx-drop-target={@uploads.cover_image.ref} class="relative">
<.live_file_input
upload={@uploads.cover_image}
class="absolute inset-0 opacity-0 cursor-pointer"
/>
<img :if={@show.image_url} src={@show.image_url} class="max-w-[200px] rounded-lg" />
<div :if={!@show.image_url} class="w-[200px] h-[200px] bg-white/10 rounded-lg"></div>
</div>
</div>
</div>
<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/shows/
</div>
<.input
field={@form[:slug]}
type="text"
label="URL"
placeholder="showname"
class="ps-[8.25rem]"
/>
</div>
<.input field={@form[:scheduled_for]} type="datetime-local" label="Date (UTC)" />
<%!-- <.input field={@form[:image_url]} type="text" label="Image URL" /> --%>
<%= for err <- upload_errors(@uploads.cover_image) do %>
<p class="alert alert-danger"><%= error_to_string(err) %></p>
<% end %>
<:actions>
<.button phx-disable-with="Saving...">Save</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:uploaded_files, [])
|> allow_upload(:cover_image,
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 update(%{show: show} = assigns, socket) do
changeset = Shows.change_show(show)
{:ok,
socket
|> assign(assigns)
|> assign_form(changeset)}
end
@impl true
def handle_event("validate", %{"show" => show_params}, socket) do
changeset =
socket.assigns.show
|> Shows.change_show(show_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"show" => show_params}, socket) do
save_show(socket, socket.assigns.action, show_params)
end
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :cover_image, ref)}
end
defp handle_progress(:cover_image, entry, socket) do
if entry.done? do
show =
consume_uploaded_entry(socket, entry, fn %{path: path} = _meta ->
remote_path = "shows/#{socket.assigns.show.id}/cover/#{System.os_time(:second)}"
{:ok, _} =
Algora.Storage.upload_from_filename(path, remote_path, fn _ -> nil end,
content_type: "image/jpeg"
)
bucket = Algora.config([:buckets, :media])
%{scheme: scheme, host: host} = Application.fetch_env!(:ex_aws, :s3) |> Enum.into(%{})
Shows.update_show(socket.assigns.show, %{
image_url: "#{scheme}#{host}/#{bucket}/#{remote_path}"
})
end)
notify_parent({:saved, show})
{:noreply, socket |> assign(:show, show)}
else
{:noreply, socket}
end
end
defp save_show(socket, :edit, show_params) do
case Shows.update_show(socket.assigns.show, show_params) do
{:ok, show} ->
notify_parent({:saved, show})
{:noreply,
socket
|> put_flash(:info, "Show updated successfully")
|> push_patch(to: ~p"/shows/#{show.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, changeset)}
end
end
defp save_show(socket, :new, show_params) do
case Shows.create_show(show_params) do
{:ok, show} ->
notify_parent({:saved, show})
{:noreply,
socket
|> put_flash(:info, "Show created successfully")
|> push_patch(to: ~p"/shows/#{show.slug}")}
{: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 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)
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@ -0,0 +1,41 @@
defmodule AlgoraWeb.ShowLive.Index do
use AlgoraWeb, :live_view
alias Algora.Shows
alias Algora.Shows.Show
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :shows, Shows.list_shows())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_info({AlgoraWeb.ShowLive.FormComponent, {:saved, show}}, socket) do
{:noreply, socket |> assign(:show, show)}
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Show")
|> assign(:show, %Show{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Shows")
|> assign(:show, nil)
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
show = Shows.get_show!(id)
{:ok, _} = Shows.delete_show(show)
{:noreply, stream_delete(socket, :shows, show)}
end
end

View File

@ -0,0 +1,49 @@
<.header>
Listing Shows
<:actions>
<.link patch={~p"/admin/shows/new"}>
<.button>New Show</.button>
</.link>
</:actions>
</.header>
<.table
id="shows"
rows={@streams.shows}
row_click={fn {_id, show} -> JS.navigate(~p"/shows/#{show.slug}") end}
>
<:col :let={{_id, show}} label="Title"><%= show.title %></:col>
<:col :let={{_id, show}} label="Slug"><%= show.slug %></:col>
<:col :let={{_id, show}} label="Scheduled for"><%= show.scheduled_for %></:col>
<:col :let={{_id, show}} label="Image url"><%= show.image_url %></:col>
<:action :let={{_id, show}}>
<div class="sr-only">
<.link navigate={~p"/shows/#{show.slug}"}>Show</.link>
</div>
<.link patch={~p"/shows/#{show.slug}/edit"}>Edit</.link>
</:action>
<:action :let={{id, show}}>
<.link
phx-click={JS.push("delete", value: %{id: show.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal
:if={@live_action in [:new, :edit]}
id="show-modal"
show
on_cancel={JS.patch(~p"/admin/shows")}
>
<.live_component
module={AlgoraWeb.ShowLive.FormComponent}
id={@show.id || :new}
title={@page_title}
action={@live_action}
show={@show}
patch={~p"/admin/shows"}
/>
</.modal>

View File

@ -0,0 +1,355 @@
defmodule AlgoraWeb.ShowLive.Show do
use AlgoraWeb, :live_view
alias Algora.{Shows, Library, Events}
alias Algora.Accounts
alias AlgoraWeb.LayoutComponent
@impl true
def render(assigns) do
~H"""
<div class="text-white min-h-screen p-8">
<div class="flex flex-col md:grid md:grid-cols-3 gap-6">
<div class="md:col-span-1 bg-white/5 ring-1 ring-white/15 rounded-lg p-6 space-y-6">
<img src={@show.image_url} class="max-h-[250px] rounded-lg" />
<div class="space-y-2">
<div class="flex items-center space-x-2">
<span class="relative ring-4 ring-[#15122c] flex h-10 w-10 shrink-0 overflow-hidden rounded-full">
<img
class="aspect-square h-full w-full"
alt={@channel.name}
src={@channel.avatar_url}
/>
</span>
<span class="font-bold"><%= @channel.name %></span>
<.link
:if={@channel.twitter_url}
target="_blank"
rel="noopener noreferrer"
href={@channel.twitter_url}
>
<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="text-white"
>
<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>
</.link>
</div>
<div :if={!@owns_show?} class="pt-2">
<.button :if={@current_user} phx-click="toggle_subscription">
<%= if @subscribed? do %>
Unsubscribe
<% else %>
Subscribe
<% end %>
</.button>
<.button :if={!@current_user && @authorize_url}>
<.link navigate={@authorize_url}>
Subscribe
</.link>
</.button>
</div>
<div :if={@owns_show?} class="pt-2">
<.button>
<.link patch={~p"/shows/#{@show.slug}/edit"}>
Edit show
</.link>
</.button>
</div>
</div>
<div :if={@attendees_count > 0} class="border-t border-white/15 pt-6 space-y-4">
<div>
<span class="font-medium"><%= @attendees_count %> Attending</span>
</div>
<div>
<div class="flex -space-x-1">
<span
:for={attendee <- @attendees |> Enum.take(@max_attendee_avatars_count)}
class="relative ring-4 ring-[#15122c] flex h-10 w-10 shrink-0 overflow-hidden rounded-full"
>
<img
class="aspect-square h-full w-full"
alt={attendee.user_display_name}
src={attendee.user_avatar_url}
/>
</span>
</div>
<div class="mt-2">
<span
:for={
{attendee, i} <-
Enum.with_index(@attendees) |> Enum.take(@max_attendee_names_count)
}
class="font-medium"
>
<span :if={i != 0} class="-ml-1">, </span><span><%= attendee.user_display_name %></span>
</span>
<span :if={@attendees_count > @max_attendee_names_count} class="font-medium">
and
<span :if={@attendees_count == @max_attendee_names_count + 1}>
<%= @attendees |> Enum.at(@max_attendee_names_count) %>
</span>
<span :if={@attendees_count != @max_attendee_names_count + 1}>
<%= @attendees_count - @max_attendee_names_count %> others
</span>
</span>
</div>
</div>
</div>
</div>
<div class="md:col-span-2 space-y-6">
<div class="bg-white/5 ring-1 ring-white/15 rounded-lg p-6 space-y-6">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-6">
<div>
<h1 class="text-4xl font-bold"><%= @show.title %></h1>
<div :if={@show.description} class="mt-4 space-y-4">
<div>
<h2 class="text-2xl font-bold">About</h2>
<p class="typography whitespace-pre-wrap"><%= @show.description %></p>
</div>
</div>
</div>
<div class="space-y-2 w-full max-w-xs md:max-w-sm">
<div :if={@show.scheduled_for} class="bg-gray-950/75 p-4 rounded-lg">
<div class="flex flex-col md:flex-wrap md:flex-row gap-2 md:items-center md:justify-between">
<div class="flex items-center space-x-4">
<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-6 w-6 text-green-300 shrink-0"
>
<path d="M8 2v4"></path>
<path d="M16 2v4"></path>
<rect width="18" height="18" x="3" y="4" rx="2"></rect>
<path d="M3 10h18"></path>
</svg>
<div class="shrink-0">
<div class="text-sm font-semibold">
<%= @show.scheduled_for
|> Timex.to_datetime("Etc/UTC")
|> Timex.Timezone.convert("America/New_York")
|> Timex.format!("{WDfull}, {Mshort} {D}") %>
</div>
<div class="text-sm">
<%= @show.scheduled_for
|> Timex.to_datetime("Etc/UTC")
|> Timex.Timezone.convert("America/New_York")
|> Timex.format!("{h12}:{m} {am}, Eastern Time") %>
</div>
</div>
</div>
<.button :if={@current_user && !@rsvpd?} phx-click="toggle_rsvp">
Attend
</.button>
<.button
:if={@rsvpd?}
disabled
class="bg-green-400 hover:bg-green-400 disabled:opacity-100 text-green-950 flex items-center active:text-green-950"
>
<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-5 w-5 -ml-0.5"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M5 12l5 5l10 -10" />
</svg>
<span class="ml-1">Attending</span>
</.button>
<.button :if={!@current_user && @authorize_url}>
<.link navigate={@authorize_url}>
Attend
</.link>
</.button>
</div>
</div>
<.link
href={@show.url || ~p"/#{@channel.handle}/latest"}
target="_blank"
rel="noopener"
class="block bg-gray-950/75 p-4 rounded-lg"
>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4 shrink-0">
<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-6 w-6 text-red-400"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M18.364 19.364a9 9 0 1 0 -12.728 0" /><path d="M15.536 16.536a5 5 0 1 0 -7.072 0" /><path d="M12 13m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
<div>
<div class="text-sm font-semibold">Watch live</div>
<div class="text-sm">
<%= @show.url || "tv.algora.io/#{@channel.handle}/latest" %>
</div>
</div>
</div>
</div>
</.link>
</div>
</div>
</div>
<div class="bg-white/5 ring-1 ring-white/15 rounded-lg p-6 pb-2 space-y-6">
<h2 class="pt-4 text-2xl font-bold">Past sessions</h2>
<div
id="past-sessions"
class="flex gap-8 overflow-x-scroll pb-4 scrollbar-thin"
phx-update="stream"
>
<div :for={{_id, video} <- @streams.videos} class="max-w-xs shrink-0 w-full">
<.link class="cursor-pointer truncate" href={~p"/#{video.channel_handle}/#{video.id}"}>
<.video_thumbnail video={video} class="rounded-2xl" />
<div class="pt-2 text-base font-semibold truncate"><%= video.title %></div>
<div class="text-gray-300 text-sm"><%= Timex.from_now(video.inserted_at) %></div>
</.link>
</div>
</div>
</div>
</div>
</div>
</div>
<.modal
:if={@live_action in [:new, :edit]}
id="show-modal"
show
on_cancel={JS.patch(~p"/shows/#{@show.slug}")}
>
<.live_component
module={AlgoraWeb.ShowLive.FormComponent}
id={@show.id || :new}
title={@page_title}
action={@live_action}
show={@show}
/>
</.modal>
"""
end
@impl true
def mount(%{"slug" => slug}, _session, socket) do
%{current_user: current_user} = socket.assigns
show = Shows.get_show_by_fields!(slug: slug)
channel = Accounts.get_user(show.user_id) |> Library.get_channel!()
videos = Library.list_channel_videos(channel, 50)
{:ok,
socket
|> assign_attendees(show)
|> assign(:show, show)
|> assign(:owns_show?, current_user && show.user_id == current_user.id)
|> assign(:channel, channel)
|> assign(:max_attendee_avatars_count, 5)
|> assign(:max_attendee_names_count, 2)
|> assign(:subscribed?, Events.subscribed?(current_user, channel))
|> assign(:rsvpd?, Events.rsvpd?(current_user, channel))
|> stream(:videos, videos)}
end
@impl true
def handle_params(params, url, socket) do
%{path: path} = URI.parse(url)
LayoutComponent.hide_modal()
{:noreply,
socket
|> assign(:authorize_url, Algora.Github.authorize_url(path))
|> apply_action(socket.assigns.live_action, params)}
end
@impl true
def handle_event("toggle_subscription", _params, socket) do
Events.toggle_subscription_event(socket.assigns.current_user, socket.assigns.show)
{:noreply,
socket
|> assign(:subscribed?, !socket.assigns.subscribed?)}
end
def handle_event("toggle_rsvp", _params, socket) do
Events.toggle_rsvp_event(socket.assigns.current_user, socket.assigns.show)
{:noreply,
socket
|> assign_attendees(socket.assigns.show)
|> assign(:rsvpd?, !socket.assigns.rsvpd?)}
end
@impl true
def handle_info({AlgoraWeb.ShowLive.FormComponent, {:saved, show}}, socket) do
{:noreply, socket |> assign(:show, show)}
end
defp apply_action(socket, :show, %{"slug" => slug}) do
show = Shows.get_show_by_fields!(slug: slug)
socket
|> assign(:page_title, show.title)
|> assign(:page_description, show.description)
|> assign(:page_image, show.og_image_url)
end
defp apply_action(socket, :edit, %{"slug" => slug}) do
%{current_user: current_user} = socket.assigns
show = Shows.get_show_by_fields!(slug: slug)
cond do
current_user == nil ->
socket
|> redirect(to: ~p"/auth/login")
current_user.id != show.user_id && !Accounts.admin?(current_user) ->
socket
|> put_flash(:error, "You don't have permission to edit this show")
|> redirect(to: ~p"/shows/#{show.slug}")
true ->
socket
|> assign(:page_title, show.title)
|> assign(:page_description, show.description)
|> assign(:page_image, show.og_image_url)
end
end
defp assign_attendees(socket, show) do
attendees = Events.fetch_attendees(show)
socket
|> assign(:attendees, attendees)
|> assign(:attendees_count, length(attendees))
end
end

View File

@ -767,6 +767,7 @@ defmodule AlgoraWeb.VideoLive do
{:noreply, socket |> assign(subscribed?: !socket.assigns.subscribed?)}
end
# TODO: move into events context
defp toggle_subscription_event(user, video) do
name = if subscribed?(user, video), do: :unsubscribed, else: :subscribed
@ -781,6 +782,7 @@ defmodule AlgoraWeb.VideoLive do
|> Repo.insert()
end
# TODO: move into events context
defp subscribed?(nil, _video), do: false
defp subscribed?(user, video) do

View File

@ -65,6 +65,16 @@ defmodule AlgoraWeb.Router do
delete "/auth/logout", OAuthCallbackController, :sign_out
live_session :admin,
on_mount: [
{AlgoraWeb.UserAuth, :ensure_authenticated},
{AlgoraWeb.UserAuth, :ensure_admin},
AlgoraWeb.Nav
] do
live "/shows", ShowLive.Index, :index
live "/shows/new", ShowLive.Index, :new
end
live_session :authenticated,
on_mount: [{AlgoraWeb.UserAuth, :ensure_authenticated}, AlgoraWeb.Nav] do
live "/channel/settings", SettingsLive, :edit
@ -85,6 +95,10 @@ defmodule AlgoraWeb.Router do
live "/auth/login", SignInLive, :index
live "/cossgpt", COSSGPTLive, :index
live "/og/cossgpt", COSSGPTOGLive, :index
live "/shows/:slug", ShowLive.Show, :show
live "/shows/:slug/edit", ShowLive.Show, :edit
live "/:channel_handle", ChannelLive, :show
live "/:channel_handle/:video_id", VideoLive, :show

View File

@ -42,6 +42,7 @@ defmodule Algora.MixProject do
{:elixir_make, "~> 0.7.0", runtime: false},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
{:ex_m3u8, "~> 0.9.0"},
{:ex_marcel, "~> 0.1.0"},
{:exla, ">= 0.0.0"},
{:exsync, "~> 0.2", only: :dev},
{:ffmpex, "~> 0.10.0"},

View File

@ -33,6 +33,7 @@
"ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"},
"ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"},
"ex_m3u8": {:hex, :ex_m3u8, "0.9.0", "54a12463320236aab09402bc69676f665e692636235a2b186a22df507ebc5643", [:mix], [{:nimble_parsec, "~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:typed_struct, "~> 0.3.0", [hex: :typed_struct, repo: "hexpm", optional: false]}], "hexpm", "d57939a90d8da5956264d27a516c5e2ac80b09c8adbe4e3199d7d14c79549b5c"},
"ex_marcel": {:hex, :ex_marcel, "0.1.0", "61116a255ed51e8d9ec65cb0018442bf37cadb2228d3bb90e3bbb6d3d11dcd34", [:mix], [], "hexpm", "48dfc497435a9c52c0e90c1e07d8ce7316a095dcec0e04d182e8250e493b72fb"},
"exla": {:hex, :exla, "0.7.0", "27fac40a580f0d3816fe3bf35c50dfc2f99597d26ac7e2aca4a3c62b89bb427f", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:nx, "~> 0.7.0", [hex: :nx, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:xla, "~> 0.6.0", [hex: :xla, repo: "hexpm", optional: false]}], "hexpm", "d3bfc622deb52cec95efc9d76063891afc7cd33e38eddbb01f3385c53e043c40"},
"expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"},
"exsync": {:hex, :exsync, "0.3.0", "39ab8b3d4e5fe779a34ad930135145283ebf56069513dfdfaad4e30a04b158c7", [:mix], [{:file_system, "~> 0.2", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "2030d085a14fa5f685d53d97171a21345dddaf2b67a0927263efc6b2cd2bb09f"},

View File

@ -0,0 +1,35 @@
defmodule Algora.Repo.Migrations.CreateShows do
use Ecto.Migration
def change do
create table(:shows) do
add :title, :string, null: false
add :description, :string
add :slug, :citext, null: false
add :scheduled_for, :naive_datetime
add :image_url, :string
add :og_image_url, :string
add :url, :string
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
alter table(:events) do
add :show_id, references(:shows)
end
alter table(:videos) do
add :show_id, references(:shows)
end
alter table(:users) do
add :twitter_url, :string
end
create unique_index(:shows, [:slug])
create index(:shows, [:user_id])
create index(:events, [:show_id])
create index(:videos, [:show_id])
end
end