├── test ├── test_helper.exs ├── circle_web │ └── controllers │ │ ├── page_controller_test.exs │ │ ├── error_json_test.exs │ │ └── error_html_test.exs └── support │ ├── conn_case.ex │ └── data_case.ex ├── assets ├── package.json ├── css │ └── app.css ├── js │ ├── uploaders.js │ └── app.js ├── tailwind.config.js ├── vendor │ └── topbar.js └── package-lock.json ├── lib ├── circle │ ├── mailer.ex │ ├── repo.ex │ ├── release.ex │ ├── videos │ │ ├── video.ex │ │ ├── ffmpeg │ │ │ └── progress_collector.ex │ │ └── ffmpeg.ex │ ├── application.ex │ ├── video_store.ex │ ├── simple_s3_upload.ex │ └── videos.ex ├── circle.ex ├── circle_web │ ├── controllers │ │ ├── page_controller.ex │ │ ├── page_html.ex │ │ ├── video_html.ex │ │ ├── error_json.ex │ │ ├── error_html.ex │ │ ├── video_html │ │ │ └── show.html.heex │ │ ├── video_controller.ex │ │ └── page_html │ │ │ └── home.html.heex │ ├── components │ │ ├── layouts.ex │ │ ├── layouts │ │ │ ├── app.html.heex │ │ │ └── root.html.heex │ │ ├── upload_components.ex │ │ └── core_components.ex │ ├── gettext.ex │ ├── endpoint.ex │ ├── router.ex │ ├── live │ │ └── video_live │ │ │ ├── upload.html.heex │ │ │ └── upload.ex │ └── telemetry.ex └── circle_web.ex ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20240907184934_create_videos.exs │ └── seeds.exs ├── static │ ├── favicon.ico │ ├── robots.txt │ └── images │ │ └── logo.svg └── gettext │ ├── errors.pot │ └── en │ └── LC_MESSAGES │ └── errors.po ├── guides └── images │ ├── 4_cors.png │ ├── uploaded_video.jpg │ ├── 3_fly_api_token.png │ ├── 2_fly_terminal_config.png │ └── 1_fly_tigris_object_storage.png ├── .formatter.exs ├── config ├── prod.exs ├── test.exs ├── config.exs ├── dev.exs └── runtime.exs ├── fly.toml ├── .gitignore ├── .dockerignore ├── README.md ├── mix.exs ├── Dockerfile └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Circle.Repo, :manual) 3 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "video.js": "^8.17.3" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/circle/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Mailer do 2 | use Swoosh.Mailer, otp_app: :circle 3 | end 4 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /guides/images/4_cors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/guides/images/4_cors.png -------------------------------------------------------------------------------- /priv/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/priv/static/favicon.ico -------------------------------------------------------------------------------- /guides/images/uploaded_video.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/guides/images/uploaded_video.jpg -------------------------------------------------------------------------------- /guides/images/3_fly_api_token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/guides/images/3_fly_api_token.png -------------------------------------------------------------------------------- /guides/images/2_fly_terminal_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/guides/images/2_fly_terminal_config.png -------------------------------------------------------------------------------- /lib/circle/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Repo do 2 | use Ecto.Repo, 3 | otp_app: :circle, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /guides/images/1_fly_tigris_object_storage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/poeticoding/circle-demo/HEAD/guides/images/1_fly_tigris_object_storage.png -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /lib/circle.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle do 2 | @moduledoc """ 3 | Circle keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /test/circle_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.PageControllerTest do 2 | use CircleWeb.ConnCase 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, ~p"/") 6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.PageController do 2 | use CircleWeb, :controller 3 | 4 | def home(conn, _params) do 5 | # The home page is often custom made, 6 | # so skip the default app layout. 7 | redirect(conn, to: ~p"/videos/new") 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/page_html.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.PageHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use CircleWeb, :html 8 | 9 | embed_templates "page_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/video_html.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.VideoHTML do 2 | @moduledoc """ 3 | This module contains pages rendered by PageController. 4 | 5 | See the `page_html` directory for all templates available. 6 | """ 7 | use CircleWeb, :html 8 | 9 | embed_templates "video_html/*" 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # Circle.Repo.insert!(%Circle.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /test/circle_web/controllers/error_json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.ErrorJSONTest do 2 | use CircleWeb.ConnCase, async: true 3 | 4 | test "renders 404" do 5 | assert CircleWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} 6 | end 7 | 8 | test "renders 500" do 9 | assert CircleWeb.ErrorJSON.render("500.json", %{}) == 10 | %{errors: %{detail: "Internal Server Error"}} 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @import "video.js/dist/video-js.css"; 6 | 7 | /* This file is for your main application CSS */ 8 | .drop-zone { 9 | border: 2px dashed #ccc; 10 | border-radius: 20px; 11 | width: 100%; 12 | padding: 20px; 13 | text-align: center; 14 | cursor: pointer; 15 | } 16 | 17 | .drop-zone:hover { 18 | background-color: #f8f8f8; 19 | } -------------------------------------------------------------------------------- /test/circle_web/controllers/error_html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.ErrorHTMLTest do 2 | use CircleWeb.ConnCase, async: true 3 | 4 | # Bring render_to_string/4 for testing custom views 5 | import Phoenix.Template 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(CircleWeb.ErrorHTML, "404", "html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(CircleWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/circle_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.Layouts do 2 | @moduledoc """ 3 | This module holds different layouts used by your application. 4 | 5 | See the `layouts` directory for all templates available. 6 | The "root" layout is a skeleton rendered as part of the 7 | application router. The "app" layout is set as the default 8 | layout on both `use CircleWeb, :controller` and 9 | `use CircleWeb, :live_view`. 10 | """ 11 | use CircleWeb, :html 12 | import CircleWeb.UploadComponents 13 | 14 | embed_templates "layouts/*" 15 | end 16 | -------------------------------------------------------------------------------- /lib/circle_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | New 8 | 9 |
10 |
11 |
12 |
13 | <.flash_group flash={@flash} /> 14 | <%= @inner_content %> 15 |
16 |
17 | -------------------------------------------------------------------------------- /lib/circle/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Release do 2 | @moduledoc """ 3 | Used for executing DB release tasks when run in production without Mix 4 | installed. 5 | """ 6 | @app :circle 7 | 8 | def migrate do 9 | load_app() 10 | 11 | for repo <- repos() do 12 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 13 | end 14 | end 15 | 16 | def rollback(repo, version) do 17 | load_app() 18 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 19 | end 20 | 21 | defp repos do 22 | Application.fetch_env!(@app, :ecto_repos) 23 | end 24 | 25 | defp load_app do 26 | Application.load(@app) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.ErrorJSON do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on JSON requests. 4 | 5 | See config/config.exs. 6 | """ 7 | 8 | # If you want to customize a particular status code, 9 | # you may add your own clauses, such as: 10 | # 11 | # def render("500.json", _assigns) do 12 | # %{errors: %{detail: "Internal Server Error"}} 13 | # end 14 | 15 | # By default, Phoenix returns the status message from 16 | # the template name. For example, "404.json" becomes 17 | # "Not Found". 18 | def render(template, _assigns) do 19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Note we also include the path to a cache manifest 4 | # containing the digested version of static files. This 5 | # manifest is generated by the `mix assets.deploy` task, 6 | # which you should run after static files are built and 7 | # before starting your production server. 8 | config :circle, CircleWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 9 | 10 | # Configures Swoosh API Client 11 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: Circle.Finch 12 | 13 | # Disable Swoosh Local Memory Storage 14 | config :swoosh, local: false 15 | 16 | # Do not print debug messages in production 17 | config :logger, level: :info 18 | 19 | # Runtime production configuration, including reading 20 | # of environment variables, is done on config/runtime.exs. 21 | -------------------------------------------------------------------------------- /lib/circle_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import CircleWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :circle 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/migrations/20240907184934_create_videos.exs: -------------------------------------------------------------------------------- 1 | defmodule Circle.Repo.Migrations.CreateVideos do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:videos, primary_key: false) do 6 | add :id, :uuid, primary_key: true 7 | add :title, :string 8 | 9 | add :original_filename, :text, null: false 10 | add :original_extension, :string, null: false 11 | add :original_size, :bigint 12 | 13 | add :views_count, :bigint, default: 0, null: false 14 | 15 | add :original_uploaded_at, :utc_datetime 16 | add :web_uploaded_at, :utc_datetime 17 | add :preview_image_uploaded_at, :utc_datetime 18 | 19 | timestamps(type: :utc_datetime) 20 | end 21 | 22 | create index(:videos, :original_uploaded_at) 23 | create index(:videos, :web_uploaded_at) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.ErrorHTML do 2 | @moduledoc """ 3 | This module is invoked by your endpoint in case of errors on HTML requests. 4 | 5 | See config/config.exs. 6 | """ 7 | use CircleWeb, :html 8 | 9 | # If you want to customize your error pages, 10 | # uncomment the embed_templates/1 call below 11 | # and add pages to the error directory: 12 | # 13 | # * lib/circle_web/controllers/error_html/404.html.heex 14 | # * lib/circle_web/controllers/error_html/500.html.heex 15 | # 16 | # embed_templates "error_html/*" 17 | 18 | # The default is to render a plain text page based on 19 | # the template name. For example, "404.html" becomes 20 | # "Not Found". 21 | def render(template, _assigns) do 22 | Phoenix.Controller.status_message_from_template(template) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/circle_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <.live_title suffix=" · Phoenix Framework"> 8 | <%= assigns[:page_title] || "Circle" %> 9 | 10 | 11 | <%= if not is_nil(assigns[:video]) do %> 12 | <.video_meta title={@video.title} video_id={@video.id} /> 13 | <% end %> 14 | 15 | 16 | 18 | 19 | 20 | <%= @inner_content %> 21 | 22 | 23 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for circlevideos-prod on 2024-09-17T01:32:38+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = 'circlevideos-prod' 7 | primary_region = 'ams' 8 | kill_signal = 'SIGTERM' 9 | 10 | [build] 11 | 12 | [deploy] 13 | release_command = '/app/bin/migrate' 14 | 15 | [env] 16 | AWS_ENDPOINT_URL_S3 = 'fly.storage.tigris.dev' 17 | PHX_HOST = 'circle.poeticoding.com' 18 | PORT = '8080' 19 | 20 | [http_service] 21 | internal_port = 8080 22 | force_https = true 23 | auto_stop_machines = 'stop' 24 | auto_start_machines = true 25 | min_machines_running = 0 26 | processes = ['app'] 27 | 28 | [http_service.concurrency] 29 | type = 'connections' 30 | hard_limit = 1000 31 | soft_limit = 1000 32 | 33 | [[vm]] 34 | memory = '1gb' 35 | cpu_kind = 'shared' 36 | cpus = 1 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | # Ignore package tarball (built via "mix hex.build"). 26 | circle-*.tar 27 | 28 | # Ignore assets that are produced by build tools. 29 | /priv/static/assets/ 30 | 31 | # Ignore digested assets cache. 32 | /priv/static/cache_manifest.json 33 | 34 | # In case you use Node.js/npm, you want to ignore these. 35 | npm-debug.log 36 | /assets/node_modules/ 37 | 38 | .env -------------------------------------------------------------------------------- /assets/js/uploaders.js: -------------------------------------------------------------------------------- 1 | let Uploaders = {} 2 | 3 | Uploaders.Tigris = function(entries, onViewError){ 4 | entries.forEach(entry => { 5 | let formData = new FormData() 6 | let {url, fields} = entry.meta 7 | Object.entries(fields).forEach(([key, val]) => formData.append(key, val)) 8 | formData.append("file", entry.file) 9 | let xhr = new XMLHttpRequest() 10 | onViewError(() => xhr.abort()) 11 | xhr.onload = () => { 12 | console.log("Uploaders.Tigris onload.status", xhr.status) 13 | console.log(entry) 14 | xhr.status === 200 ? entry.progress(100) : entry.error() 15 | } 16 | 17 | xhr.onerror = () => { 18 | console.log("Uploaders.Tigris XHR Error") 19 | entry.error() 20 | } 21 | xhr.upload.addEventListener("progress", (event) => { 22 | if(event.lengthComputable){ 23 | let percent = Math.round((event.loaded / event.total) * 100) 24 | if(percent < 100){ 25 | entry.progress(percent) 26 | } 27 | } 28 | }) 29 | 30 | xhr.open("POST", url, true) 31 | xhr.send(formData) 32 | }) 33 | } 34 | 35 | export default Uploaders; -------------------------------------------------------------------------------- /lib/circle/videos/video.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Videos.Video do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | import Ecto.Query 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | schema "videos" do 10 | field :title, :string 11 | 12 | field :original_extension, :string 13 | field :original_filename, :string 14 | field :original_size, :integer 15 | field :original_uploaded_at, :utc_datetime 16 | 17 | field :web_uploaded_at, :utc_datetime 18 | field :preview_image_uploaded_at, :utc_datetime 19 | 20 | field :views_count, :integer 21 | timestamps(type: :utc_datetime) 22 | end 23 | 24 | @doc false 25 | def changeset(video, attrs) do 26 | video 27 | |> cast(attrs, [ 28 | :title, 29 | :original_filename, 30 | :original_extension, 31 | :original_size, 32 | :original_uploaded_at, 33 | :web_uploaded_at, 34 | :preview_image_uploaded_at 35 | ]) 36 | end 37 | 38 | def increment_views_count_query(video) do 39 | from(v in __MODULE__, where: v.id == ^video.id, update: [inc: [views_count: 1]]) 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/video_html/show.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 9 |
10 |
11 | 17 |

<%= @video.title %>

18 |
19 |
20 | <%= if @video.views_count do %> 21 | <%= ngettext("1 view", "%{count} views", @video.views_count) %> 22 | <% else %> 23 | No views yet 24 | <% end %> 25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use CircleWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # The default endpoint for testing 23 | @endpoint CircleWeb.Endpoint 24 | 25 | use CircleWeb, :verified_routes 26 | 27 | # Import conveniences for testing with connections 28 | import Plug.Conn 29 | import Phoenix.ConnTest 30 | import CircleWeb.ConnCase 31 | end 32 | end 33 | 34 | setup tags do 35 | Circle.DataCase.setup_sandbox(tags) 36 | {:ok, conn: Phoenix.ConnTest.build_conn()} 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/video_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.VideoController do 2 | use CircleWeb, :controller 3 | 4 | alias Circle.VideoStore 5 | alias Circle.Videos 6 | alias Circle.Videos.Video 7 | 8 | plug :fetch_video 9 | 10 | def show(conn, _params) do 11 | Videos.increment_views_count(conn.assigns.video) 12 | {:ok, url} = VideoStore.presigned_download_url(conn.assigns.video, :web) 13 | render(conn, "show.html", video_url: url) 14 | end 15 | 16 | def download(conn, _params) do 17 | {:ok, url} = VideoStore.presigned_download_url(conn.assigns.video, :web) 18 | redirect(conn, external: url) 19 | end 20 | 21 | def preview_image(conn, _params) do 22 | case VideoStore.get_preview_image(conn.assigns.video) do 23 | {:ok, image_data} -> 24 | send_download(conn, {:binary, image_data}, 25 | filename: "preview.png", 26 | content_type: "image/png" 27 | ) 28 | 29 | _ -> 30 | conn 31 | |> put_status(404) 32 | |> halt() 33 | end 34 | end 35 | 36 | defp fetch_video(conn, _opts) do 37 | case Videos.get_video(conn.params["id"]) do 38 | %Video{} = video -> 39 | assign(conn, :video, video) 40 | 41 | _ -> 42 | conn 43 | |> put_status(:not_found) 44 | |> halt() 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :circle, Circle.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "circle_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: System.schedulers_online() * 2 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :circle, CircleWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "xo5uAdjOABu8GvgPqVhJr5Xy6NBK1tpnWs7lgP9XAv9clASWw7dj+DxIBAMzGj5j", 21 | server: false 22 | 23 | # In test we don't send emails 24 | config :circle, Circle.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | # Disable swoosh api client as it is only required for production adapters 27 | config :swoosh, :api_client, false 28 | 29 | # Print only warnings and errors during test 30 | config :logger, level: :warning 31 | 32 | # Initialize plugs at runtime for faster test compilation 33 | config :phoenix, :plug_init_mode, :runtime 34 | 35 | # Enable helpful, but potentially expensive runtime checks 36 | config :phoenix_live_view, 37 | enable_expensive_runtime_checks: true 38 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # This file excludes paths from the Docker build context. 2 | # 3 | # By default, Docker's build context includes all files (and folders) in the 4 | # current directory. Even if a file isn't copied into the container it is still sent to 5 | # the Docker daemon. 6 | # 7 | # There are multiple reasons to exclude files from the build context: 8 | # 9 | # 1. Prevent nested folders from being copied into the container (ex: exclude 10 | # /assets/node_modules when copying /assets) 11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc) 12 | # 3. Avoid sending files containing sensitive information 13 | # 14 | # More information on using .dockerignore is available here: 15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file 16 | 17 | .dockerignore 18 | 19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed: 20 | # 21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat 22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc 23 | .git 24 | !.git/HEAD 25 | !.git/refs 26 | 27 | # Common development/test artifacts 28 | /cover/ 29 | /doc/ 30 | /test/ 31 | /tmp/ 32 | .elixir_ls 33 | 34 | # Mix artifacts 35 | /_build/ 36 | /deps/ 37 | *.ez 38 | 39 | # Generated on crash by the VM 40 | erl_crash.dump 41 | 42 | # Static artifacts - These should be fetched and built inside the Docker image 43 | /assets/node_modules/ 44 | /priv/static/assets/ 45 | /priv/static/cache_manifest.json 46 | -------------------------------------------------------------------------------- /lib/circle/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @flame_timeout 10 * 60_000 9 | 10 | @impl true 11 | def start(_type, _args) do 12 | flame_parent = FLAME.Parent.get() 13 | 14 | children = 15 | [ 16 | CircleWeb.Telemetry, 17 | Circle.Repo, 18 | {DNSCluster, query: Application.get_env(:circle, :dns_cluster_query) || :ignore}, 19 | {Phoenix.PubSub, name: Circle.PubSub}, 20 | # Start the Finch HTTP client for sending emails 21 | {Finch, name: Circle.Finch}, 22 | { 23 | FLAME.Pool, 24 | # 10 mins timeout 25 | name: Circle.FFMpegRunner, 26 | min: 0, 27 | max: 10, 28 | max_concurrency: 2, 29 | timeout: @flame_timeout, 30 | idle_shutdown_after: 30_000 31 | }, 32 | !flame_parent && CircleWeb.Endpoint 33 | ] 34 | |> Enum.filter(& &1) 35 | 36 | # See https://hexdocs.pm/elixir/Supervisor.html 37 | # for other strategies and supported options 38 | opts = [strategy: :one_for_one, name: Circle.Supervisor] 39 | Supervisor.start_link(children, opts) 40 | end 41 | 42 | # Tell Phoenix to update the endpoint configuration 43 | # whenever the application is updated. 44 | @impl true 45 | def config_change(changed, _new, removed) do 46 | CircleWeb.Endpoint.config_change(changed, removed) 47 | :ok 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/circle_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :circle 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_circle_key", 10 | signing_salt: "85338qOi", 11 | same_site: "Lax" 12 | ] 13 | 14 | socket "/live", Phoenix.LiveView.Socket, 15 | websocket: [connect_info: [session: @session_options]], 16 | longpoll: [connect_info: [session: @session_options]] 17 | 18 | # Serve at "/" the static files from "priv/static" directory. 19 | # 20 | # You should set gzip to true if you are running phx.digest 21 | # when deploying your static files in production. 22 | plug Plug.Static, 23 | at: "/", 24 | from: :circle, 25 | gzip: false, 26 | only: CircleWeb.static_paths() 27 | 28 | # Code reloading can be explicitly enabled under the 29 | # :code_reloader configuration of your endpoint. 30 | if code_reloading? do 31 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 32 | plug Phoenix.LiveReloader 33 | plug Phoenix.CodeReloader 34 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :circle 35 | end 36 | 37 | plug Phoenix.LiveDashboard.RequestLogger, 38 | param_key: "request_logger", 39 | cookie_key: "request_logger" 40 | 41 | plug Plug.RequestId 42 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 43 | 44 | plug Plug.Parsers, 45 | parsers: [:urlencoded, :multipart, :json], 46 | pass: ["*/*"], 47 | json_decoder: Phoenix.json_library() 48 | 49 | plug Plug.MethodOverride 50 | plug Plug.Head 51 | plug Plug.Session, @session_options 52 | plug CircleWeb.Router 53 | end 54 | -------------------------------------------------------------------------------- /lib/circle/videos/ffmpeg/progress_collector.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Videos.FFMpeg.ProgressCollector do 2 | @moduledoc """ 3 | Module which implements the `Collectable` protocol 4 | to parse the progress from stdout when running FFMpeg with `System.cmd`. 5 | It then broadcasts via pubsub the progress. 6 | """ 7 | require Logger 8 | alias Circle.Videos 9 | 10 | defstruct [:video_id, :total_frames] 11 | 12 | def new(video_id, total_frames), do: %__MODULE__{video_id: video_id, total_frames: total_frames} 13 | 14 | defimpl Collectable, for: __MODULE__ do 15 | def into(coll) do 16 | Logger.metadata(video_id: coll.video_id) 17 | 18 | # Initial state (empty buffer) 19 | {:ok, 20 | fn 21 | _, {:cont, output} when is_binary(output) -> 22 | # Send output to the pid whenever there's new output 23 | 24 | case Regex.run(~r/frame\=(\d+)/, output) do 25 | [_, frame_str] -> 26 | Videos.pubsub_broadcast( 27 | coll.video_id, 28 | {:postprocessing, coll.video_id, {:progress, progress(coll, frame_str)}} 29 | ) 30 | 31 | :ok 32 | 33 | error -> 34 | Logger.error("ProgressCollector.into/1 error: #{inspect(error)}") 35 | error 36 | end 37 | 38 | _, :done -> 39 | # When done, we can also send a :done message to the pid 40 | Videos.pubsub_broadcast( 41 | coll.video_id, 42 | {:postprocessing, coll.video_id, {:progress, :done}} 43 | ) 44 | 45 | :ok 46 | 47 | _, :halt -> 48 | :ok 49 | 50 | _, _ -> 51 | :ok 52 | end} 53 | end 54 | 55 | defp progress(coll, frame_str) do 56 | round(String.to_integer(frame_str) * 100 / coll.total_frames) 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | import Uploaders from "./uploaders" 26 | import videojs from 'video.js'; 27 | 28 | 29 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 30 | let liveSocket = new LiveSocket("/live", Socket, { 31 | longPollFallbackMs: 2500, 32 | params: {_csrf_token: csrfToken}, 33 | uploaders: Uploaders 34 | }) 35 | 36 | // Show progress bar on live navigation and form submits 37 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 38 | window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) 39 | window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) 40 | 41 | // connect if there are any LiveViews on the page 42 | liveSocket.connect() 43 | 44 | // expose liveSocket on window for web console debug logs and latency simulation: 45 | // >> liveSocket.enableDebug() 46 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 47 | // >> liveSocket.disableLatencySim() 48 | window.liveSocket = liveSocket 49 | 50 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use Circle.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Circle.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Circle.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | Circle.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Circle.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :circle, 11 | ecto_repos: [Circle.Repo], 12 | generators: [timestamp_type: :utc_datetime] 13 | 14 | # Configures the endpoint 15 | config :circle, CircleWeb.Endpoint, 16 | url: [host: "localhost"], 17 | adapter: Bandit.PhoenixAdapter, 18 | render_errors: [ 19 | formats: [html: CircleWeb.ErrorHTML, json: CircleWeb.ErrorJSON], 20 | layout: false 21 | ], 22 | pubsub_server: Circle.PubSub, 23 | live_view: [signing_salt: "1ByHOGAQ"] 24 | 25 | # Configures the mailer 26 | # 27 | # By default it uses the "Local" adapter which stores the emails 28 | # locally. You can see the emails in your browser, at "/dev/mailbox". 29 | # 30 | # For production it's recommended to configure a different adapter 31 | # at the `config/runtime.exs`. 32 | config :circle, Circle.Mailer, adapter: Swoosh.Adapters.Local 33 | 34 | # Configure esbuild (the version is required) 35 | config :esbuild, 36 | version: "0.17.11", 37 | circle: [ 38 | args: 39 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 40 | cd: Path.expand("../assets", __DIR__), 41 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 42 | ] 43 | 44 | # Configure tailwind (the version is required) 45 | config :tailwind, 46 | version: "3.4.3", 47 | circle: [ 48 | args: ~w( 49 | --config=tailwind.config.js 50 | --input=css/app.css 51 | --output=../priv/static/assets/app.css 52 | ), 53 | cd: Path.expand("../assets", __DIR__) 54 | ] 55 | 56 | # Configures Elixir's Logger 57 | config :logger, :console, 58 | format: "$time $metadata[$level] $message\n", 59 | metadata: [:request_id, :video_id] 60 | 61 | # Use Jason for JSON parsing in Phoenix 62 | config :phoenix, :json_library, Jason 63 | 64 | # Import environment specific config. This must remain at the bottom 65 | # of this file so it overrides the configuration defined above. 66 | import_config "#{config_env()}.exs" 67 | -------------------------------------------------------------------------------- /lib/circle_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.Router do 2 | use CircleWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, html: {CircleWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | pipeline :protected do 18 | plug :auth 19 | end 20 | 21 | scope "/", CircleWeb do 22 | pipe_through [:browser, :protected] 23 | 24 | live "/videos/new", VideoLive.Upload, :new 25 | end 26 | 27 | scope "/", CircleWeb do 28 | pipe_through :browser 29 | 30 | get "/", PageController, :home 31 | 32 | get "/videos/:id", VideoController, :show 33 | get "/videos/:id/preview.jpg", VideoController, :preview_image 34 | get "/videos/:id/download", VideoController, :download 35 | end 36 | 37 | # Other scopes may use custom stacks. 38 | # scope "/api", CircleWeb do 39 | # pipe_through :api 40 | # end 41 | 42 | # Enable LiveDashboard and Swoosh mailbox preview in development 43 | if Application.compile_env(:circle, :dev_routes) do 44 | # If you want to use the LiveDashboard in production, you should put 45 | # it behind authentication and allow only admins to access it. 46 | # If your application does not have an admins-only section yet, 47 | # you can use Plug.BasicAuth to set up some basic authentication 48 | # as long as you are also using SSL (which you should anyway). 49 | import Phoenix.LiveDashboard.Router 50 | 51 | scope "/dev" do 52 | pipe_through :browser 53 | 54 | live_dashboard "/dashboard", metrics: CircleWeb.Telemetry 55 | forward "/mailbox", Plug.Swoosh.MailboxPreview 56 | end 57 | end 58 | 59 | # if user and password are defined, you get a basic auth to create a new video 60 | defp auth(conn, _opts) do 61 | user = Application.fetch_env!(:circle, :basic_auth) |> Keyword.get(:username) 62 | pass = Application.fetch_env!(:circle, :basic_auth) |> Keyword.get(:password) 63 | 64 | if is_nil(user) or is_nil(pass) do 65 | conn 66 | else 67 | Plug.BasicAuth.basic_auth(conn, Application.fetch_env!(:circle, :basic_auth)) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/circle_web/components/upload_components.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.UploadComponents do 2 | use Phoenix.Component 3 | 4 | @doc """ 5 | Shows the upload progress bar 6 | 7 | ## Examples 8 | 9 | <.progress_bar color="blue" label={entry.client_name} progress={entry.progress} /> 10 | """ 11 | attr :label, :string, required: true 12 | attr :progress, :integer, required: true 13 | attr :color, :string 14 | 15 | def progress_bar(assigns) do 16 | ~H""" 17 |
18 |
19 | 20 | <%= @label %> 21 | 22 |
23 | 24 |
25 | 26 | <%= @progress %>% 27 | 28 |
29 |
30 | 31 |
32 |
33 |
34 | """ 35 | end 36 | 37 | attr :video_id, :string 38 | attr :title, :string 39 | 40 | def video_meta(assigns) do 41 | ~H""" 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | """ 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Circle 2 | ![](guides/images/uploaded_video.jpg) 3 | 4 | Related Article: **[Effortless Video Sharing with Phoenix LiveView and FLAME](https://www.poeticoding.com/effortless-video-sharing-with-phoenix-liveview-and-flame/)** 5 | 6 | 7 | > Transparency Notice: The article is sponsored by [Tigris](https://www.tigrisdata.com). They've provided resources and compensation, enabling me to explore and share this solution. Tigris is featured for its excellent fit in this use case, chosen based on its merits. I'm really glad for their support in creating content about innovative technologies. I encourage readers to explore all services mentioned to find the best fit for their projects. 8 | 9 | ## Running it yourself 10 | 11 | You first need a [fly.io](http://fly.io) account, with which you can also access to [Tigrisdata.com](https://www.tigrisdata.com). 12 | 13 | 1. To create the [fly.io](http://fly.io) app and the Tigris bucket is quite easy, you simply need to run the `fly launch` command in the root directory of the circle app. 14 | 2. During the app creation you need to set the Tigris bucket you want to create, and fly will do the rest for you (like setting all secrets and most of the environment variables you need). 15 | 16 | ![Fly.io - Tigris Object Storage](guides/images/1_fly_tigris_object_storage.png) 17 | 18 | ![](guides/images/2_fly_terminal_config.png) 19 | 20 | 3. The Fly deployment will likely fail because you need to set the `FLY_API_TOKEN`, which is the token the app requires to spawn new machines for FFmpeg processing. 21 | 22 | ![`FLY_API_TOKEN`](guides/images/3_fly_api_token.png) 23 | 24 | To resolve this, run the following command to generate a new token and set it in the app's secrets: 25 | 26 | `fly secrets set FLY_API_TOKEN="$(fly auth token)"` 27 | 28 | After setting the token, execute `fly deploy` to rebuild and launch the app. 29 | 30 | 4. Before uploading videos, you need to set the correct CORS settings for the bucket. In the Fly app dashboard, navigate to the *Tigris Object Storage menu* and click on your bucket. This will open a TigrisData web page where you can configure the bucket's settings. 31 | 32 | ![`CORS`](guides/images/4_cors.png) 33 | 34 | Under Regions choose “Global”. I’ve also set a TTL to autodelete the files after a certain time. At the moment the app doesn’t support files auto-expire/deletion out of the box. 35 | 36 | 37 | --- 38 | 39 | **Remember that, by default, anyone can access any page!** 40 | If you want to add basic protection to the upload page, you can set the `AUTH_USERNAME` and `AUTH_PASSWORD` env variables (set them as `fly secrets` ) to add basic authentication to the /videos/new route. It's a simple solution. While in production I'd prefer using accounts and LiveView sessions, this basic authentication should suffice for testing the app. -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | const fs = require("fs") 6 | const path = require("path") 7 | 8 | module.exports = { 9 | content: [ 10 | "./js/**/*.js", 11 | "../lib/circle_web.ex", 12 | "../lib/circle_web/**/*.*ex" 13 | ], 14 | theme: { 15 | extend: { 16 | colors: { 17 | brand: "#FD4F00", 18 | } 19 | }, 20 | }, 21 | plugins: [ 22 | require("@tailwindcss/forms"), 23 | // Allows prefixing tailwind classes with LiveView classes to add rules 24 | // only when LiveView classes are applied, for example: 25 | // 26 | //
27 | // 28 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 29 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 30 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])), 31 | 32 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle 33 | // See your `CoreComponents.icon/1` for more information. 34 | // 35 | plugin(function({matchComponents, theme}) { 36 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized") 37 | let values = {} 38 | let icons = [ 39 | ["", "/24/outline"], 40 | ["-solid", "/24/solid"], 41 | ["-mini", "/20/solid"], 42 | ["-micro", "/16/solid"] 43 | ] 44 | icons.forEach(([suffix, dir]) => { 45 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => { 46 | let name = path.basename(file, ".svg") + suffix 47 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)} 48 | }) 49 | }) 50 | matchComponents({ 51 | "hero": ({name, fullPath}) => { 52 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "") 53 | let size = theme("spacing.6") 54 | if (name.endsWith("-mini")) { 55 | size = theme("spacing.5") 56 | } else if (name.endsWith("-micro")) { 57 | size = theme("spacing.4") 58 | } 59 | return { 60 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`, 61 | "-webkit-mask": `var(--hero-${name})`, 62 | "mask": `var(--hero-${name})`, 63 | "mask-repeat": "no-repeat", 64 | "background-color": "currentColor", 65 | "vertical-align": "middle", 66 | "display": "inline-block", 67 | "width": size, 68 | "height": size 69 | } 70 | } 71 | }, {values}) 72 | }) 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /lib/circle/video_store.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.VideoStore do 2 | alias Circle.SimpleS3Upload 3 | 4 | @tigris_host "fly.storage.tigris.dev" 5 | 6 | @presigned_url_default_options [ 7 | expires_in: 300 8 | ] 9 | 10 | def bucket, do: System.fetch_env!("BUCKET_NAME") 11 | 12 | @doc """ 13 | `:preview_image` videos' thumbnail 14 | `:original` original uploaded video 15 | `:web` video used by the app player 16 | """ 17 | def key(video, :preview_image) do 18 | Path.join(["videos", video.id, "preview.jpg"]) 19 | end 20 | 21 | def key(video, :original) do 22 | Path.join(["videos", video.id, "original#{video.original_extension}"]) 23 | end 24 | 25 | def key(video, :web) do 26 | Path.join(["videos", video.id, "web.mp4"]) 27 | end 28 | 29 | def put(key, content, options \\ []) do 30 | ExAws.S3.put_object(bucket(), key, content, options) 31 | |> ExAws.request() 32 | end 33 | 34 | def put_preview_image(video, data) do 35 | put(key(video, :preview_image), data, content_type: "image/jpg") 36 | end 37 | 38 | def get(key) do 39 | ExAws.S3.get_object(bucket(), key) 40 | |> ExAws.request() 41 | |> then(fn 42 | {:ok, %{body: data}} -> {:ok, data} 43 | error -> error 44 | end) 45 | end 46 | 47 | def get_preview_image(video) do 48 | video 49 | |> key(:preview_image) 50 | |> get() 51 | end 52 | 53 | def save_video_file(video, local_video_path, version) do 54 | local_video_path 55 | |> ExAws.S3.Upload.stream_file() 56 | |> ExAws.S3.upload(bucket(), key(video, version), content_type: "video/mp4") 57 | |> ExAws.request() 58 | end 59 | 60 | ## PRESIGNED URL 61 | def presigned_download_url(video, version \\ :original, opts \\ []) do 62 | opts = Keyword.merge(@presigned_url_default_options, opts) 63 | 64 | ExAws.Config.new(:s3) 65 | |> ExAws.S3.presigned_url(:get, bucket(), key(video, version), opts) 66 | end 67 | 68 | @spec presigned_upload_form_url(Video.t(), Phoenix.LiveView.UploadEntry.t(), integer()) :: map() 69 | def presigned_upload_form_url(video, entry, max_file_size) do 70 | bucket = bucket() 71 | key = key(video, :original) 72 | 73 | config = %{ 74 | region: System.get_env("AWS_REGION", "auto"), 75 | access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), 76 | secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") 77 | } 78 | 79 | {:ok, fields} = 80 | SimpleS3Upload.sign_form_upload(config, bucket, 81 | key: key, 82 | content_type: entry.client_type, 83 | max_file_size: max_file_size, 84 | expires_in: :timer.hours(1) 85 | ) 86 | 87 | host = Application.get_env(:ex_aws, :s3) |> Keyword.fetch!(:host) 88 | url = "https://#{bucket}.#{host}" 89 | 90 | %{ 91 | uploader: "Tigris", 92 | key: key, 93 | url: url, 94 | fields: fields 95 | } 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /priv/static/images/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /lib/circle_web.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use CircleWeb, :controller 9 | use CircleWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: CircleWeb.Layouts] 44 | 45 | import Plug.Conn 46 | import CircleWeb.Gettext 47 | 48 | unquote(verified_routes()) 49 | end 50 | end 51 | 52 | def live_view do 53 | quote do 54 | use Phoenix.LiveView, 55 | layout: {CircleWeb.Layouts, :app} 56 | 57 | unquote(html_helpers()) 58 | end 59 | end 60 | 61 | def live_component do 62 | quote do 63 | use Phoenix.LiveComponent 64 | 65 | unquote(html_helpers()) 66 | end 67 | end 68 | 69 | def html do 70 | quote do 71 | use Phoenix.Component 72 | 73 | # Import convenience functions from controllers 74 | import Phoenix.Controller, 75 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 76 | 77 | # Include general helpers for rendering HTML 78 | unquote(html_helpers()) 79 | end 80 | end 81 | 82 | defp html_helpers do 83 | quote do 84 | # HTML escaping functionality 85 | import Phoenix.HTML 86 | # Core UI components and translation 87 | import CircleWeb.CoreComponents 88 | import CircleWeb.Gettext 89 | 90 | # Shortcut for generating JS commands 91 | alias Phoenix.LiveView.JS 92 | 93 | # Routes generation with the ~p sigil 94 | unquote(verified_routes()) 95 | end 96 | end 97 | 98 | def verified_routes do 99 | quote do 100 | use Phoenix.VerifiedRoutes, 101 | endpoint: CircleWeb.Endpoint, 102 | router: CircleWeb.Router, 103 | statics: CircleWeb.static_paths() 104 | end 105 | end 106 | 107 | @doc """ 108 | When used, dispatch to the appropriate controller/live_view/etc. 109 | """ 110 | defmacro __using__(which) when is_atom(which) do 111 | apply(__MODULE__, which, []) 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/circle_web/live/video_live/upload.html.heex: -------------------------------------------------------------------------------- 1 |
2 | <.header class="mb-4 text-xl font-bold"> 3 | <%= (assigns[:video] && @video.title) || @page_title %> 4 | 5 | 6 |
12 |
13 | <.live_file_input upload={@uploads.video} class="hidden" /> 14 |
19 | 23 |
24 |
25 | 26 | <%= if assigns[:preview_image] do %> 27 |
28 | Video preview 29 |
30 | <% end %> 31 | 32 |
33 | <%= for entry <- @uploads.video.entries do %> 34 | <.progress_bar color="blue" label={entry.client_name} progress={entry.progress} /> 35 | 36 |
37 | 44 | 45 | 54 |
55 | <% end %> 56 |
57 | <.progress_bar 58 | :if={not is_nil(@processing_progress)} 59 | color="green" 60 | label="processing" 61 | progress={@processing_progress} 62 | /> 63 | <%= if not is_nil(@processing_progress) do %> 64 |

65 | Please do not close this tab 66 |

67 | <% end %> 68 |
69 |
70 | 71 | <%= for err <- upload_errors(@uploads.video) do %> 72 |

<%= inspect(err) %>

73 | <% end %> 74 |
75 |
76 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | ## From Ecto.Changeset.cast/4 11 | msgid "can't be blank" 12 | msgstr "" 13 | 14 | ## From Ecto.Changeset.unique_constraint/3 15 | msgid "has already been taken" 16 | msgstr "" 17 | 18 | ## From Ecto.Changeset.put_change/3 19 | msgid "is invalid" 20 | msgstr "" 21 | 22 | ## From Ecto.Changeset.validate_acceptance/3 23 | msgid "must be accepted" 24 | msgstr "" 25 | 26 | ## From Ecto.Changeset.validate_format/3 27 | msgid "has invalid format" 28 | msgstr "" 29 | 30 | ## From Ecto.Changeset.validate_subset/3 31 | msgid "has an invalid entry" 32 | msgstr "" 33 | 34 | ## From Ecto.Changeset.validate_exclusion/3 35 | msgid "is reserved" 36 | msgstr "" 37 | 38 | ## From Ecto.Changeset.validate_confirmation/3 39 | msgid "does not match confirmation" 40 | msgstr "" 41 | 42 | ## From Ecto.Changeset.no_assoc_constraint/3 43 | msgid "is still associated with this entry" 44 | msgstr "" 45 | 46 | msgid "are still associated with this entry" 47 | msgstr "" 48 | 49 | ## From Ecto.Changeset.validate_length/3 50 | msgid "should have %{count} item(s)" 51 | msgid_plural "should have %{count} item(s)" 52 | msgstr[0] "" 53 | msgstr[1] "" 54 | 55 | msgid "should be %{count} character(s)" 56 | msgid_plural "should be %{count} character(s)" 57 | msgstr[0] "" 58 | msgstr[1] "" 59 | 60 | msgid "should be %{count} byte(s)" 61 | msgid_plural "should be %{count} byte(s)" 62 | msgstr[0] "" 63 | msgstr[1] "" 64 | 65 | msgid "should have at least %{count} item(s)" 66 | msgid_plural "should have at least %{count} item(s)" 67 | msgstr[0] "" 68 | msgstr[1] "" 69 | 70 | msgid "should be at least %{count} character(s)" 71 | msgid_plural "should be at least %{count} character(s)" 72 | msgstr[0] "" 73 | msgstr[1] "" 74 | 75 | msgid "should be at least %{count} byte(s)" 76 | msgid_plural "should be at least %{count} byte(s)" 77 | msgstr[0] "" 78 | msgstr[1] "" 79 | 80 | msgid "should have at most %{count} item(s)" 81 | msgid_plural "should have at most %{count} item(s)" 82 | msgstr[0] "" 83 | msgstr[1] "" 84 | 85 | msgid "should be at most %{count} character(s)" 86 | msgid_plural "should be at most %{count} character(s)" 87 | msgstr[0] "" 88 | msgstr[1] "" 89 | 90 | msgid "should be at most %{count} byte(s)" 91 | msgid_plural "should be at most %{count} byte(s)" 92 | msgstr[0] "" 93 | msgstr[1] "" 94 | 95 | ## From Ecto.Changeset.validate_number/3 96 | msgid "must be less than %{number}" 97 | msgstr "" 98 | 99 | msgid "must be greater than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be less than or equal to %{number}" 103 | msgstr "" 104 | 105 | msgid "must be greater than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be equal to %{number}" 109 | msgstr "" 110 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should have %{count} item(s)" 54 | msgid_plural "should have %{count} item(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should be %{count} character(s)" 59 | msgid_plural "should be %{count} character(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be %{count} byte(s)" 64 | msgid_plural "should be %{count} byte(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at least %{count} character(s)" 74 | msgid_plural "should be at least %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should be at least %{count} byte(s)" 79 | msgid_plural "should be at least %{count} byte(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | msgid "should have at most %{count} item(s)" 84 | msgid_plural "should have at most %{count} item(s)" 85 | msgstr[0] "" 86 | msgstr[1] "" 87 | 88 | msgid "should be at most %{count} character(s)" 89 | msgid_plural "should be at most %{count} character(s)" 90 | msgstr[0] "" 91 | msgstr[1] "" 92 | 93 | msgid "should be at most %{count} byte(s)" 94 | msgid_plural "should be at most %{count} byte(s)" 95 | msgstr[0] "" 96 | msgstr[1] "" 97 | 98 | ## From Ecto.Changeset.validate_number/3 99 | msgid "must be less than %{number}" 100 | msgstr "" 101 | 102 | msgid "must be greater than %{number}" 103 | msgstr "" 104 | 105 | msgid "must be less than or equal to %{number}" 106 | msgstr "" 107 | 108 | msgid "must be greater than or equal to %{number}" 109 | msgstr "" 110 | 111 | msgid "must be equal to %{number}" 112 | msgstr "" 113 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Circle.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :circle, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | aliases: aliases(), 12 | deps: deps() 13 | ] 14 | end 15 | 16 | # Configuration for the OTP application. 17 | # 18 | # Type `mix help compile.app` for more information. 19 | def application do 20 | [ 21 | mod: {Circle.Application, []}, 22 | extra_applications: [:logger, :runtime_tools] 23 | ] 24 | end 25 | 26 | # Specifies which paths to compile per environment. 27 | defp elixirc_paths(:test), do: ["lib", "test/support"] 28 | defp elixirc_paths(_), do: ["lib"] 29 | 30 | # Specifies your project dependencies. 31 | # 32 | # Type `mix help deps` for examples and options. 33 | defp deps do 34 | [ 35 | {:phoenix, "~> 1.7.14"}, 36 | {:phoenix_ecto, "~> 4.5"}, 37 | {:ecto_sql, "~> 3.10"}, 38 | {:postgrex, ">= 0.0.0"}, 39 | {:phoenix_html, "~> 4.1"}, 40 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 41 | # TODO bump on release to {:phoenix_live_view, "~> 1.0.0"}, 42 | {:phoenix_live_view, "~> 1.0.0-rc.1", override: true}, 43 | {:floki, ">= 0.30.0", only: :test}, 44 | {:phoenix_live_dashboard, "~> 0.8.3"}, 45 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, 46 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, 47 | {:heroicons, 48 | github: "tailwindlabs/heroicons", 49 | tag: "v2.1.1", 50 | sparse: "optimized", 51 | app: false, 52 | compile: false, 53 | depth: 1}, 54 | {:swoosh, "~> 1.5"}, 55 | {:finch, "~> 0.13"}, 56 | {:telemetry_metrics, "~> 1.0"}, 57 | {:telemetry_poller, "~> 1.0"}, 58 | {:gettext, "~> 0.20"}, 59 | {:jason, "~> 1.2"}, 60 | {:dns_cluster, "~> 0.1.1"}, 61 | {:bandit, "~> 1.5"}, 62 | 63 | # AWS S3 64 | {:ex_aws, "~> 2.5"}, 65 | {:ex_aws_s3, "~> 2.5"}, 66 | {:poison, "~> 6.0"}, 67 | {:hackney, "~> 1.20"}, 68 | {:sweet_xml, "~> 0.7.4"}, 69 | 70 | # FLAME 71 | {:flame, "~> 0.4.4"} 72 | ] 73 | end 74 | 75 | # Aliases are shortcuts or tasks specific to the current project. 76 | # For example, to install project dependencies and perform other setup tasks, run: 77 | # 78 | # $ mix setup 79 | # 80 | # See the documentation for `Mix` for more info on aliases. 81 | defp aliases do 82 | [ 83 | setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], 84 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 85 | "ecto.reset": ["ecto.drop", "ecto.setup"], 86 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], 87 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], 88 | "assets.build": ["tailwind circle", "esbuild circle"], 89 | "assets.deploy": [ 90 | "tailwind circle --minify", 91 | "esbuild circle --minify", 92 | "phx.digest" 93 | ] 94 | ] 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian 2 | # instead of Alpine to avoid DNS resolution issues in production. 3 | # 4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu 5 | # https://hub.docker.com/_/ubuntu?tab=tags 6 | # 7 | # This file is based on these images: 8 | # 9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image 10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20240513-slim - for the release image 11 | # - https://pkgs.org/ - resource for finding needed packages 12 | # - Ex: hexpm/elixir:1.16.2-erlang-26.2.5-debian-bullseye-20240513-slim 13 | # 14 | ARG ELIXIR_VERSION=1.16.2 15 | ARG OTP_VERSION=26.2.5 16 | ARG DEBIAN_VERSION=bullseye-20240513-slim 17 | 18 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}" 19 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}" 20 | 21 | FROM ${BUILDER_IMAGE} as builder 22 | 23 | # install build dependencies 24 | RUN apt-get update -y && apt-get install -y build-essential git nodejs npm\ 25 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 26 | 27 | # prepare build dir 28 | WORKDIR /app 29 | 30 | # install hex + rebar 31 | RUN mix local.hex --force && \ 32 | mix local.rebar --force 33 | 34 | # set build ENV 35 | ENV MIX_ENV="prod" 36 | 37 | # install mix dependencies 38 | COPY mix.exs mix.lock ./ 39 | RUN mix deps.get --only $MIX_ENV 40 | RUN mkdir config 41 | 42 | # copy compile-time config files before we compile dependencies 43 | # to ensure any relevant config change will trigger the dependencies 44 | # to be re-compiled. 45 | COPY config/config.exs config/${MIX_ENV}.exs config/ 46 | RUN mix deps.compile 47 | 48 | COPY priv priv 49 | 50 | COPY lib lib 51 | 52 | COPY assets assets 53 | 54 | RUN npm install --prefix assets 55 | # compile assets 56 | RUN mix assets.deploy 57 | 58 | # Compile the release 59 | RUN mix compile 60 | 61 | # Changes to config/runtime.exs don't require recompiling the code 62 | COPY config/runtime.exs config/ 63 | 64 | COPY rel rel 65 | RUN mix release 66 | 67 | # start a new build stage so that the final image will only contain 68 | # the compiled release and other runtime necessities 69 | FROM ${RUNNER_IMAGE} 70 | 71 | RUN apt-get update -y && \ 72 | apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates ffmpeg\ 73 | && apt-get clean && rm -f /var/lib/apt/lists/*_* 74 | 75 | # Set the locale 76 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 77 | 78 | ENV LANG en_US.UTF-8 79 | ENV LANGUAGE en_US:en 80 | ENV LC_ALL en_US.UTF-8 81 | 82 | WORKDIR "/app" 83 | RUN chown nobody /app 84 | 85 | # set runner ENV 86 | ENV MIX_ENV="prod" 87 | 88 | # Only copy the final release from the build stage 89 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/circle ./ 90 | 91 | USER nobody 92 | 93 | # If using an environment that doesn't automatically reap zombie processes, it is 94 | # advised to add an init process such as tini via `apt-get install` 95 | # above and adding an entrypoint. See https://github.com/krallin/tini for details 96 | # ENTRYPOINT ["/tini", "--"] 97 | 98 | CMD ["/app/bin/server"] 99 | -------------------------------------------------------------------------------- /lib/circle_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.start.system_time", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.endpoint.stop.duration", 29 | unit: {:native, :millisecond} 30 | ), 31 | summary("phoenix.router_dispatch.start.system_time", 32 | tags: [:route], 33 | unit: {:native, :millisecond} 34 | ), 35 | summary("phoenix.router_dispatch.exception.duration", 36 | tags: [:route], 37 | unit: {:native, :millisecond} 38 | ), 39 | summary("phoenix.router_dispatch.stop.duration", 40 | tags: [:route], 41 | unit: {:native, :millisecond} 42 | ), 43 | summary("phoenix.socket_connected.duration", 44 | unit: {:native, :millisecond} 45 | ), 46 | summary("phoenix.channel_joined.duration", 47 | unit: {:native, :millisecond} 48 | ), 49 | summary("phoenix.channel_handled_in.duration", 50 | tags: [:event], 51 | unit: {:native, :millisecond} 52 | ), 53 | 54 | # Database Metrics 55 | summary("circle.repo.query.total_time", 56 | unit: {:native, :millisecond}, 57 | description: "The sum of the other measurements" 58 | ), 59 | summary("circle.repo.query.decode_time", 60 | unit: {:native, :millisecond}, 61 | description: "The time spent decoding the data received from the database" 62 | ), 63 | summary("circle.repo.query.query_time", 64 | unit: {:native, :millisecond}, 65 | description: "The time spent executing the query" 66 | ), 67 | summary("circle.repo.query.queue_time", 68 | unit: {:native, :millisecond}, 69 | description: "The time spent waiting for a database connection" 70 | ), 71 | summary("circle.repo.query.idle_time", 72 | unit: {:native, :millisecond}, 73 | description: 74 | "The time the connection spent waiting before being checked out for the query" 75 | ), 76 | 77 | # VM Metrics 78 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 79 | summary("vm.total_run_queue_lengths.total"), 80 | summary("vm.total_run_queue_lengths.cpu"), 81 | summary("vm.total_run_queue_lengths.io") 82 | ] 83 | end 84 | 85 | defp periodic_measurements do 86 | [ 87 | # A module, function and arguments to be invoked periodically. 88 | # This function must call :telemetry.execute/3 and a metric must be added above. 89 | # {CircleWeb, :count_users, []} 90 | ] 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :circle, Circle.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "circle_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we can use it 18 | # to bundle .js and .css sources. 19 | config :circle, CircleWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "RJ/OY16YM427elPzvf5ITnQqMxFC6ZmkQyOWjTHhATg2tmupoefnFLcmR/lXvCEG", 27 | watchers: [ 28 | esbuild: {Esbuild, :install_and_run, [:circle, ~w(--sourcemap=inline --watch)]}, 29 | tailwind: {Tailwind, :install_and_run, [:circle, ~w(--watch)]} 30 | ] 31 | 32 | # Configure S3 client for access to Tigris 33 | config :ex_aws, 34 | debug_requests: true, 35 | json_codec: Jason, 36 | access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, 37 | secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} 38 | 39 | config :ex_aws, :s3, 40 | scheme: "https://", 41 | host: "fly.storage.tigris.dev", 42 | region: "auto" 43 | 44 | config :circle, :basic_auth, 45 | username: System.get_env("AUTH_USERNAME"), 46 | password: System.get_env("AUTH_PASSWORD") 47 | 48 | # ## SSL Support 49 | # 50 | # In order to use HTTPS in development, a self-signed 51 | # certificate can be generated by running the following 52 | # Mix task: 53 | # 54 | # mix phx.gen.cert 55 | # 56 | # Run `mix help phx.gen.cert` for more information. 57 | # 58 | # The `http:` config above can be replaced with: 59 | # 60 | # https: [ 61 | # port: 4001, 62 | # cipher_suite: :strong, 63 | # keyfile: "priv/cert/selfsigned_key.pem", 64 | # certfile: "priv/cert/selfsigned.pem" 65 | # ], 66 | # 67 | # If desired, both `http:` and `https:` keys can be 68 | # configured to run both http and https servers on 69 | # different ports. 70 | 71 | # Watch static and templates for browser reloading. 72 | config :circle, CircleWeb.Endpoint, 73 | live_reload: [ 74 | patterns: [ 75 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", 76 | ~r"priv/gettext/.*(po)$", 77 | ~r"lib/circle_web/(controllers|live|components)/.*(ex|heex)$" 78 | ] 79 | ] 80 | 81 | # Enable dev routes for dashboard and mailbox 82 | config :circle, dev_routes: true 83 | 84 | # Do not include metadata nor timestamps in development logs 85 | config :logger, :console, format: "[$level] $message\n" 86 | 87 | # Set a higher stacktrace during development. Avoid configuring such 88 | # in production as building large stacktraces may be expensive. 89 | config :phoenix, :stacktrace_depth, 20 90 | 91 | # Initialize plugs at runtime for faster development compilation 92 | config :phoenix, :plug_init_mode, :runtime 93 | 94 | config :phoenix_live_view, 95 | # Include HEEx debug annotations as HTML comments in rendered markup 96 | debug_heex_annotations: true, 97 | # Enable helpful, but potentially expensive runtime checks 98 | enable_expensive_runtime_checks: true 99 | 100 | # Disable swoosh api client as it is only required for production adapters. 101 | config :swoosh, :api_client, false 102 | -------------------------------------------------------------------------------- /lib/circle/videos/ffmpeg.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Videos.FFMpeg do 2 | @moduledoc """ 3 | Functions to generate thumbnail, resize video 4 | """ 5 | alias Circle.Videos.FFMpeg.ProgressCollector 6 | 7 | require Logger 8 | 9 | @doc """ 10 | Runs `ffmpeg` to resize the original video 11 | `url`: original video presigned url 12 | `resized_video_path`: output video path 13 | `version`: atom (at the moment only :web), to get the right ffmpeg args 14 | `video_id`: DB video id 15 | `total_frames`: total number of frames (you can get with `get_total_frames`). 16 | Needed to calculate the progress. 17 | 18 | Returns {output, exit_code} 19 | """ 20 | def resize(url, resized_video_path, version, video_id, total_frames) do 21 | progress_collector = ProgressCollector.new(video_id, total_frames) 22 | 23 | System.cmd("ffmpeg", resize_args(url, resized_video_path, version), into: progress_collector) 24 | end 25 | 26 | @doc """ 27 | Runs `ffprobe` to get the number of frames of the video. 28 | Returns the number of frames. 29 | """ 30 | @spec get_total_frames(String.t()) :: {:ok, integer()} | {:error, String.t()} 31 | def get_total_frames(original_url) do 32 | {output, 0} = 33 | System.cmd("ffprobe", [ 34 | "-v", 35 | "error", 36 | "-select_streams", 37 | "v:0", 38 | "-show_entries", 39 | "stream=avg_frame_rate,duration", 40 | "-of", 41 | "csv=p=0", 42 | original_url 43 | ]) 44 | 45 | regex = ~r/(?\d+\/\d+|\d+\.\d+),\s*(?\d+\.\d+)/ 46 | 47 | case Regex.run(regex, output) do 48 | [_, frame_rate_str, duration_str] -> 49 | # Parse frame rate and duration 50 | frame_rate = parse_frame_rate(frame_rate_str) 51 | duration = String.to_float(duration_str) 52 | 53 | # Calculate total number of frames 54 | total_frames = Float.round(frame_rate * duration) |> trunc() 55 | Logger.info("Total frames: #{total_frames}") 56 | {:ok, total_frames} 57 | 58 | _ -> 59 | {:error, "error parsing ffprobe"} 60 | end 61 | end 62 | 63 | defp parse_frame_rate(frame_rate_str) do 64 | case String.split(frame_rate_str, "/") do 65 | [numerator, denominator] -> 66 | String.to_integer(numerator) / String.to_integer(denominator) 67 | 68 | [frame_rate] -> 69 | String.to_integer(frame_rate) 70 | end 71 | end 72 | 73 | @doc """ 74 | Runs FFMpeg to generate the preview image. 75 | """ 76 | def generate_preview_image(url, image_path) do 77 | System.cmd("ffmpeg", [ 78 | "-i", 79 | url, 80 | "-ss", 81 | "00:00:01", 82 | "-frames:v", 83 | "1", 84 | "-vf", 85 | "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2", 86 | image_path 87 | ]) 88 | end 89 | 90 | defp resize_args(original_url, local_tmp_path, :web) do 91 | [ 92 | "-i", 93 | original_url, 94 | "-aspect", 95 | "16:9", 96 | "-vf", 97 | "scale=1920:1280:force_original_aspect_ratio=decrease,pad=1920:1280:(ow-iw)/2:(oh-ih)/2:black", 98 | "-c:v", 99 | "h264", 100 | "-level", 101 | "4.0", 102 | "-profile:v", 103 | "main", 104 | "-pix_fmt", 105 | # 4:2:0 chroma subsampling 106 | "yuv420p", 107 | "-preset", 108 | "medium", 109 | # Ensure the frame rate does not exceed 60fps 110 | "-r", 111 | "30", 112 | "-maxrate", 113 | "2M", 114 | "-bufsize", 115 | "4M", 116 | # "slow", 117 | # "ultrafast", 118 | # "-crf", 119 | # "23", 120 | "-c:a", 121 | "aac", 122 | "-b:a", 123 | "96k", 124 | "-movflags", 125 | "+faststart", 126 | "-progress", 127 | "pipe:1", 128 | "-hide_banner", 129 | "-loglevel", 130 | "error", 131 | local_tmp_path 132 | ] 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/circle_web/live/video_live/upload.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.VideoLive.Upload do 2 | use CircleWeb, :live_view 3 | 4 | alias Circle.Videos 5 | alias Circle.Videos.Video 6 | alias Circle.VideoStore 7 | 8 | import CircleWeb.UploadComponents 9 | 10 | require Logger 11 | 12 | @impl true 13 | @max_file_size 5_000_000_000 14 | def mount(_params, _session, socket) do 15 | socket = 16 | socket 17 | |> assign(:form, to_form(Videos.change_video(%Video{}, %{}))) 18 | |> assign(:processing_progress, nil) 19 | |> allow_upload(:video, 20 | accept: ~w(.mov .mp4 .avi), 21 | max_entries: 1, 22 | external: &presign_upload/2, 23 | max_file_size: @max_file_size 24 | ) 25 | 26 | {:ok, socket} 27 | end 28 | 29 | @impl true 30 | def handle_params(params, _url, socket) do 31 | {:noreply, apply_action(socket, socket.assigns.live_action, params)} 32 | end 33 | 34 | defp apply_action(socket, :new, _params) do 35 | socket 36 | |> assign(:page_title, "Upload a new video") 37 | end 38 | 39 | @impl true 40 | def handle_event("validate-upload", _params, socket) do 41 | {:noreply, socket} 42 | end 43 | 44 | @impl true 45 | def handle_event("save-upload", _params, socket) do 46 | Logger.info("VideoLive.Upload: save-upload") 47 | 48 | video = socket.assigns.video 49 | key = video && VideoStore.key(video, :original) 50 | 51 | uploaded_video = 52 | consume_uploaded_entries(socket, :video, fn 53 | %{key: ^key}, %{cancelled?: false} = _entry -> 54 | {:ok, video} = 55 | Videos.update_video(video, %{ 56 | original_uploaded_at: DateTime.utc_now() 57 | }) 58 | 59 | Videos.process(video) 60 | 61 | {:ok, video} 62 | 63 | _, _entry -> 64 | {:error, :cancelled} 65 | end) 66 | |> List.first() 67 | 68 | socket = if uploaded_video, do: assign(socket, :processing_progress, 0), else: socket 69 | {:noreply, socket} 70 | end 71 | 72 | @impl true 73 | def handle_event("cancel-upload", %{"ref" => ref}, socket) do 74 | Logger.info("VideoLive.Upload: cancel-upload") 75 | 76 | {:noreply, 77 | socket 78 | |> cancel_upload(:video, ref) 79 | |> assign(:video, nil)} 80 | end 81 | 82 | @impl true 83 | 84 | def handle_info({:postprocessing, _video_id, :started}, socket) do 85 | {:noreply, socket} 86 | end 87 | 88 | def handle_info({:preview_image, _video_id, :ready}, socket) do 89 | {:noreply, assign(socket, :preview_image, ~p"/videos/#{socket.assigns.video}/preview.jpg")} 90 | end 91 | 92 | def handle_info({:postprocessing, _video_id, {:progress, :done}}, socket) do 93 | {:noreply, socket} 94 | end 95 | 96 | def handle_info({:postprocessing, _video_id, {:progress, progress}}, socket) do 97 | {:noreply, assign(socket, :processing_progress, progress)} 98 | end 99 | 100 | def handle_info({:postprocessing, _video_id, :done}, socket) do 101 | socket = 102 | socket 103 | |> assign(:processing_progress, nil) 104 | |> redirect(to: ~p"/videos/#{socket.assigns.video}") 105 | 106 | {:noreply, socket} 107 | end 108 | 109 | def handle_info({:ffmpeg, _video_id, {:progress, :done}}, socket) do 110 | {:noreply, socket} 111 | end 112 | 113 | defp presign_upload(entry, socket) do 114 | uploads = socket.assigns.uploads 115 | 116 | case Videos.create_video(entry.client_name, entry.client_size) do 117 | {:ok, video} -> 118 | meta = 119 | VideoStore.presigned_upload_form_url( 120 | video, 121 | entry, 122 | uploads[entry.upload_config].max_file_size 123 | ) 124 | 125 | # subscribing to get upload progress 126 | Videos.pubsub_subscribe(video.id) 127 | 128 | {:ok, meta, assign(socket, :video, video)} 129 | 130 | {:error, _changeset} = error -> 131 | error 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/circle/simple_s3_upload.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.SimpleS3Upload do 2 | @moduledoc """ 3 | Dependency-free S3 Form Upload using HTTP POST sigv4 4 | https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html 5 | """ 6 | 7 | @doc """ 8 | Signs a form upload. 9 | The configuration is a map which must contain the following keys: 10 | * `:region` - The AWS region, such as "us-east-1" 11 | * `:access_key_id` - The AWS access key id 12 | * `:secret_access_key` - The AWS secret access key 13 | Returns a map of form fields to be used on the client via the JavaScript `FormData` API. 14 | 15 | ## Options 16 | * `:key` - The required key of the object to be uploaded. 17 | * `:max_file_size` - The required maximum allowed file size in bytes. 18 | * `:content_type` - The required MIME type of the file to be uploaded. 19 | * `:expires_in` - The required expiration time in milliseconds from now 20 | before the signed upload expires. 21 | ## Examples 22 | config = %{ 23 | region: "us-east-1", 24 | access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"), 25 | secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY") 26 | } 27 | {:ok, fields} = 28 | SimpleS3Upload.sign_form_upload(config, "my-bucket", 29 | key: "public/my-file-name", 30 | content_type: "image/png", 31 | max_file_size: 10_000, 32 | expires_in: :timer.hours(1) 33 | ) 34 | """ 35 | def sign_form_upload(config, bucket, opts) do 36 | key = Keyword.fetch!(opts, :key) 37 | # max_file_size = Keyword.fetch!(opts, :max_file_size) 38 | content_type = Keyword.fetch!(opts, :content_type) 39 | expires_in = Keyword.fetch!(opts, :expires_in) 40 | 41 | expires_at = DateTime.add(DateTime.utc_now(), expires_in, :millisecond) 42 | amz_date = amz_date(expires_at) 43 | credential = credential(config, expires_at) 44 | 45 | encoded_policy = 46 | Base.encode64(""" 47 | { 48 | "expiration": "#{DateTime.to_iso8601(expires_at)}", 49 | "conditions": [ 50 | {"bucket": "#{bucket}"}, 51 | ["eq", "$key", "#{key}"], 52 | ["eq", "$Content-Type", "#{content_type}"], 53 | {"x-amz-server-side-encryption": "AES256"}, 54 | {"x-amz-credential": "#{credential}"}, 55 | {"x-amz-algorithm": "AWS4-HMAC-SHA256"}, 56 | {"x-amz-date": "#{amz_date}"} 57 | ] 58 | } 59 | """) 60 | 61 | fields = %{ 62 | "key" => key, 63 | "content-type" => content_type, 64 | "x-amz-server-side-encryption" => "AES256", 65 | "x-amz-credential" => credential, 66 | "x-amz-algorithm" => "AWS4-HMAC-SHA256", 67 | "x-amz-date" => amz_date, 68 | "policy" => encoded_policy, 69 | "x-amz-signature" => signature(config, expires_at, encoded_policy) 70 | } 71 | 72 | {:ok, fields} 73 | end 74 | 75 | defp amz_date(time) do 76 | time 77 | |> NaiveDateTime.to_iso8601() 78 | |> String.split(".") 79 | |> List.first() 80 | |> String.replace("-", "") 81 | |> String.replace(":", "") 82 | |> Kernel.<>("Z") 83 | end 84 | 85 | defp credential(%{} = config, %DateTime{} = expires_at) do 86 | "#{config.access_key_id}/#{short_date(expires_at)}/#{config.region}/s3/aws4_request" 87 | end 88 | 89 | defp signature(config, %DateTime{} = expires_at, encoded_policy) do 90 | config 91 | |> signing_key(expires_at, "s3") 92 | |> sha256(encoded_policy) 93 | |> Base.encode16(case: :lower) 94 | end 95 | 96 | defp signing_key(%{} = config, %DateTime{} = expires_at, service) when service in ["s3"] do 97 | amz_date = short_date(expires_at) 98 | %{secret_access_key: secret, region: region} = config 99 | 100 | ("AWS4" <> secret) 101 | |> sha256(amz_date) 102 | |> sha256(region) 103 | |> sha256(service) 104 | |> sha256("aws4_request") 105 | end 106 | 107 | defp short_date(%DateTime{} = expires_at) do 108 | expires_at 109 | |> amz_date() 110 | |> String.slice(0..7) 111 | end 112 | 113 | defp sha256(secret, msg), do: :crypto.mac(:hmac, :sha256, secret, msg) 114 | end 115 | -------------------------------------------------------------------------------- /lib/circle/videos.ex: -------------------------------------------------------------------------------- 1 | defmodule Circle.Videos do 2 | @moduledoc """ 3 | The Videos context. 4 | """ 5 | 6 | import Ecto.Query, warn: false 7 | alias Circle.Repo 8 | alias Circle.Videos.FFMpeg 9 | alias Circle.Videos.Video 10 | alias Circle.VideoStore 11 | 12 | require Logger 13 | 14 | @flame_timeout 10 * 60_000 15 | 16 | def get_video(id) do 17 | Repo.get(Video, id) 18 | end 19 | 20 | @doc """ 21 | Creates a video with the given `filename` and `size`. 22 | """ 23 | def create_video(filename, size) do 24 | %Video{} 25 | |> Video.changeset(%{ 26 | title: filename, 27 | original_filename: filename, 28 | original_extension: Path.extname(String.downcase(filename)), 29 | original_size: size 30 | }) 31 | |> Repo.insert() 32 | end 33 | 34 | def update_video(%Video{} = video, attrs) do 35 | video 36 | |> Video.changeset(attrs) 37 | |> Repo.update() 38 | end 39 | 40 | def change_video(%Video{} = video, attrs \\ %{}) do 41 | Video.changeset(video, attrs) 42 | end 43 | 44 | def increment_views_count(video) do 45 | video 46 | |> Video.increment_views_count_query() 47 | |> Repo.update_all([]) 48 | end 49 | 50 | @doc """ 51 | Runs a FLAME job to: 52 | 1. Extract the preview image and uploads it to the cloud store. 53 | 2. Resize the video and make it compatible to be played by the web player. 54 | """ 55 | def process(video) do 56 | Logger.metadata(video_id: video.id) 57 | 58 | FLAME.place_child( 59 | Circle.FFMpegRunner, 60 | Task.child_spec(fn -> 61 | pubsub_broadcast(video.id, {:postprocessing, video.id, :started}) 62 | 63 | with {:ok, video} <- generate_preview_image(video), 64 | {:ok, video} <- resize(video, :web) do 65 | {:ok, video} 66 | else 67 | error -> 68 | Logger.error("FFMpegRunner resize error: #{inspect(error)}") 69 | error 70 | end 71 | |> tap(fn _ -> pubsub_broadcast(video.id, {:postprocessing, video.id, :done}) end) 72 | end), 73 | timeout: @flame_timeout 74 | ) 75 | end 76 | 77 | defp resize(video, version) do 78 | resized_video_path = 79 | Path.join([System.tmp_dir!(), "#{Atom.to_string(version)}_#{video.id}.mp4"]) 80 | 81 | with {:download_url, {:ok, url}} <- 82 | {:download_url, VideoStore.presigned_download_url(video, :original)}, 83 | {:total_frames, {:ok, total_frames}} <- {:total_frames, FFMpeg.get_total_frames(url)}, 84 | {:ffmpeg_resize, {_output, 0}} <- 85 | {:ffmpeg_resize, 86 | FFMpeg.resize(url, resized_video_path, version, video.id, total_frames)}, 87 | {:upload, {:ok, _}} <- 88 | {:upload, VideoStore.save_video_file(video, resized_video_path, version)}, 89 | {:update, {:ok, video}} <- 90 | {:update, update_video(video, %{web_uploaded_at: DateTime.utc_now()})} do 91 | # removing local temporary file 92 | Logger.info("Resized to #{version} and saved to Tigris") 93 | File.rm!(resized_video_path) 94 | {:ok, video} 95 | end 96 | end 97 | 98 | def generate_preview_image(video) do 99 | local_preview_image_path = Path.join([System.tmp_dir!(), Ecto.UUID.generate() <> ".jpg"]) 100 | 101 | with {:presigned_url, {:ok, original_url}} <- 102 | {:presigned_url, VideoStore.presigned_download_url(video, :original)}, 103 | {:ffmpeg, {_, 0}} <- 104 | {:ffmpeg, FFMpeg.generate_preview_image(original_url, local_preview_image_path)}, 105 | {:read_image, {:ok, image_data}} <- {:read_image, File.read(local_preview_image_path)}, 106 | {:upload, {:ok, _}} <- 107 | {:upload, VideoStore.put_preview_image(video, image_data)}, 108 | {:update, {:ok, video}} <- 109 | {:update, update_video(video, %{preview_image_uploaded_at: DateTime.utc_now()})} do 110 | File.rm!(local_preview_image_path) 111 | pubsub_broadcast(video.id, {:preview_image, video.id, :ready}) 112 | {:ok, video} 113 | else 114 | error -> 115 | Logger.error("Videos.resize/2 ERROR: #{inspect(error)}") 116 | error 117 | end 118 | end 119 | 120 | ### PubSub 121 | def pubsub_subscribe(video_id) do 122 | Phoenix.PubSub.subscribe(Circle.PubSub, pubsub_topic(video_id)) 123 | end 124 | 125 | def pubsub_broadcast(video_id, message) do 126 | Phoenix.PubSub.broadcast(Circle.PubSub, pubsub_topic(video_id), message) 127 | end 128 | 129 | defp pubsub_topic(video_id) do 130 | "video:#{video_id}" 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/circle start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :circle, CircleWeb.Endpoint, server: true 21 | end 22 | 23 | if config_env() == :prod do 24 | database_url = 25 | System.get_env("DATABASE_URL") || 26 | raise """ 27 | environment variable DATABASE_URL is missing. 28 | For example: ecto://USER:PASS@HOST/DATABASE 29 | """ 30 | 31 | maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: [] 32 | 33 | config :circle, Circle.Repo, 34 | # ssl: true, 35 | url: database_url, 36 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 37 | socket_options: maybe_ipv6 38 | 39 | # The secret key base is used to sign/encrypt cookies and other secrets. 40 | # A default value is used in config/dev.exs and config/test.exs but you 41 | # want to use a different value for prod and you most likely don't want 42 | # to check this value into version control, so we use an environment 43 | # variable instead. 44 | secret_key_base = 45 | System.get_env("SECRET_KEY_BASE") || 46 | raise """ 47 | environment variable SECRET_KEY_BASE is missing. 48 | You can generate one by calling: mix phx.gen.secret 49 | """ 50 | 51 | host = System.get_env("PHX_HOST") || "example.com" 52 | port = String.to_integer(System.get_env("PORT") || "4000") 53 | 54 | config :circle, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") 55 | 56 | config :circle, CircleWeb.Endpoint, 57 | url: [host: host, port: 443, scheme: "https"], 58 | http: [ 59 | # Enable IPv6 and bind on all interfaces. 60 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 61 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0 62 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 63 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 64 | port: port 65 | ], 66 | secret_key_base: secret_key_base 67 | 68 | config :ex_aws, 69 | debug_requests: true, 70 | json_codec: Jason, 71 | access_key_id: {:system, "AWS_ACCESS_KEY_ID"}, 72 | secret_access_key: {:system, "AWS_SECRET_ACCESS_KEY"} 73 | 74 | config :ex_aws, :s3, 75 | scheme: "https://", 76 | host: "fly.storage.tigris.dev", 77 | region: System.get_env("AWS_REGION", "auto") 78 | 79 | config :flame, :backend, FLAME.FlyBackend 80 | 81 | config :flame, FLAME.FlyBackend, 82 | token: System.fetch_env!("FLY_API_TOKEN"), 83 | cpus: 16, 84 | memory_mb: 4096 * 8 85 | 86 | config :flame, :terminator, shutdown_timeout: :timer.seconds(10) 87 | 88 | config :circle, :basic_auth, 89 | username: System.get_env("AUTH_USERNAME"), 90 | password: System.get_env("AUTH_PASSWORD") 91 | 92 | # ## SSL Support 93 | # 94 | # To get SSL working, you will need to add the `https` key 95 | # to your endpoint configuration: 96 | # 97 | # config :circle, CircleWeb.Endpoint, 98 | # https: [ 99 | # ..., 100 | # port: 443, 101 | # cipher_suite: :strong, 102 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 103 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 104 | # ] 105 | # 106 | # The `cipher_suite` is set to `:strong` to support only the 107 | # latest and more secure SSL ciphers. This means old browsers 108 | # and clients may not be supported. You can set it to 109 | # `:compatible` for wider support. 110 | # 111 | # `:keyfile` and `:certfile` expect an absolute path to the key 112 | # and cert in disk or a relative path inside priv, for example 113 | # "priv/ssl/server.key". For all supported SSL configuration 114 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 115 | # 116 | # We also recommend setting `force_ssl` in your config/prod.exs, 117 | # ensuring no data is ever sent via http, always redirecting to https: 118 | # 119 | # config :circle, CircleWeb.Endpoint, 120 | # force_ssl: [hsts: true] 121 | # 122 | # Check `Plug.SSL` for all available options in `force_ssl`. 123 | 124 | # ## Configuring the mailer 125 | # 126 | # In production you need to configure the mailer to use a different adapter. 127 | # Also, you may need to configure the Swoosh API client of your choice if you 128 | # are not using SMTP. Here is an example of the configuration: 129 | # 130 | # config :circle, Circle.Mailer, 131 | # adapter: Swoosh.Adapters.Mailgun, 132 | # api_key: System.get_env("MAILGUN_API_KEY"), 133 | # domain: System.get_env("MAILGUN_DOMAIN") 134 | # 135 | # For this example you need include a HTTP client required by Swoosh API client. 136 | # Swoosh supports Hackney and Finch out of the box: 137 | # 138 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 139 | # 140 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 141 | end 142 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 2.0.0, 2023-02-04 4 | * https://buunguyen.github.io/topbar 5 | * Copyright (c) 2021 Buu Nguyen 6 | */ 7 | (function (window, document) { 8 | "use strict"; 9 | 10 | // https://gist.github.com/paulirish/1579671 11 | (function () { 12 | var lastTime = 0; 13 | var vendors = ["ms", "moz", "webkit", "o"]; 14 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 15 | window.requestAnimationFrame = 16 | window[vendors[x] + "RequestAnimationFrame"]; 17 | window.cancelAnimationFrame = 18 | window[vendors[x] + "CancelAnimationFrame"] || 19 | window[vendors[x] + "CancelRequestAnimationFrame"]; 20 | } 21 | if (!window.requestAnimationFrame) 22 | window.requestAnimationFrame = function (callback, element) { 23 | var currTime = new Date().getTime(); 24 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 25 | var id = window.setTimeout(function () { 26 | callback(currTime + timeToCall); 27 | }, timeToCall); 28 | lastTime = currTime + timeToCall; 29 | return id; 30 | }; 31 | if (!window.cancelAnimationFrame) 32 | window.cancelAnimationFrame = function (id) { 33 | clearTimeout(id); 34 | }; 35 | })(); 36 | 37 | var canvas, 38 | currentProgress, 39 | showing, 40 | progressTimerId = null, 41 | fadeTimerId = null, 42 | delayTimerId = null, 43 | addEvent = function (elem, type, handler) { 44 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 45 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 46 | else elem["on" + type] = handler; 47 | }, 48 | options = { 49 | autoRun: true, 50 | barThickness: 3, 51 | barColors: { 52 | 0: "rgba(26, 188, 156, .9)", 53 | ".25": "rgba(52, 152, 219, .9)", 54 | ".50": "rgba(241, 196, 15, .9)", 55 | ".75": "rgba(230, 126, 34, .9)", 56 | "1.0": "rgba(211, 84, 0, .9)", 57 | }, 58 | shadowBlur: 10, 59 | shadowColor: "rgba(0, 0, 0, .6)", 60 | className: null, 61 | }, 62 | repaint = function () { 63 | canvas.width = window.innerWidth; 64 | canvas.height = options.barThickness * 5; // need space for shadow 65 | 66 | var ctx = canvas.getContext("2d"); 67 | ctx.shadowBlur = options.shadowBlur; 68 | ctx.shadowColor = options.shadowColor; 69 | 70 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 71 | for (var stop in options.barColors) 72 | lineGradient.addColorStop(stop, options.barColors[stop]); 73 | ctx.lineWidth = options.barThickness; 74 | ctx.beginPath(); 75 | ctx.moveTo(0, options.barThickness / 2); 76 | ctx.lineTo( 77 | Math.ceil(currentProgress * canvas.width), 78 | options.barThickness / 2 79 | ); 80 | ctx.strokeStyle = lineGradient; 81 | ctx.stroke(); 82 | }, 83 | createCanvas = function () { 84 | canvas = document.createElement("canvas"); 85 | var style = canvas.style; 86 | style.position = "fixed"; 87 | style.top = style.left = style.right = style.margin = style.padding = 0; 88 | style.zIndex = 100001; 89 | style.display = "none"; 90 | if (options.className) canvas.classList.add(options.className); 91 | document.body.appendChild(canvas); 92 | addEvent(window, "resize", repaint); 93 | }, 94 | topbar = { 95 | config: function (opts) { 96 | for (var key in opts) 97 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 98 | }, 99 | show: function (delay) { 100 | if (showing) return; 101 | if (delay) { 102 | if (delayTimerId) return; 103 | delayTimerId = setTimeout(() => topbar.show(), delay); 104 | } else { 105 | showing = true; 106 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 107 | if (!canvas) createCanvas(); 108 | canvas.style.opacity = 1; 109 | canvas.style.display = "block"; 110 | topbar.progress(0); 111 | if (options.autoRun) { 112 | (function loop() { 113 | progressTimerId = window.requestAnimationFrame(loop); 114 | topbar.progress( 115 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 116 | ); 117 | })(); 118 | } 119 | } 120 | }, 121 | progress: function (to) { 122 | if (typeof to === "undefined") return currentProgress; 123 | if (typeof to === "string") { 124 | to = 125 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 126 | ? currentProgress 127 | : 0) + parseFloat(to); 128 | } 129 | currentProgress = to > 1 ? 1 : to; 130 | repaint(); 131 | return currentProgress; 132 | }, 133 | hide: function () { 134 | clearTimeout(delayTimerId); 135 | delayTimerId = null; 136 | if (!showing) return; 137 | showing = false; 138 | if (progressTimerId != null) { 139 | window.cancelAnimationFrame(progressTimerId); 140 | progressTimerId = null; 141 | } 142 | (function loop() { 143 | if (topbar.progress("+.1") >= 1) { 144 | canvas.style.opacity -= 0.05; 145 | if (canvas.style.opacity <= 0.05) { 146 | canvas.style.display = "none"; 147 | fadeTimerId = null; 148 | return; 149 | } 150 | } 151 | fadeTimerId = window.requestAnimationFrame(loop); 152 | })(); 153 | }, 154 | }; 155 | 156 | if (typeof module === "object" && typeof module.exports === "object") { 157 | module.exports = topbar; 158 | } else if (typeof define === "function" && define.amd) { 159 | define(function () { 160 | return topbar; 161 | }); 162 | } else { 163 | this.topbar = topbar; 164 | } 165 | }.call(this, window, document)); 166 | -------------------------------------------------------------------------------- /assets/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assets", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "video.js": "^8.17.3" 9 | } 10 | }, 11 | "node_modules/@babel/runtime": { 12 | "version": "7.25.6", 13 | "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.6.tgz", 14 | "integrity": "sha512-VBj9MYyDb9tuLq7yzqjgzt6Q+IBQLrGZfdjOekyEirZPHxXWoTSGUTMrpsfi58Up73d13NfYLv8HT9vmznjzhQ==", 15 | "dependencies": { 16 | "regenerator-runtime": "^0.14.0" 17 | }, 18 | "engines": { 19 | "node": ">=6.9.0" 20 | } 21 | }, 22 | "node_modules/@videojs/http-streaming": { 23 | "version": "3.13.3", 24 | "resolved": "https://registry.npmjs.org/@videojs/http-streaming/-/http-streaming-3.13.3.tgz", 25 | "integrity": "sha512-L7H+iTeqHeZ5PylzOx+pT3CVyzn4TALWYTJKkIc1pDaV/cTVfNGtG+9/vXPAydD+wR/xH1M9/t2JH8tn/DCT4w==", 26 | "dependencies": { 27 | "@babel/runtime": "^7.12.5", 28 | "@videojs/vhs-utils": "4.0.0", 29 | "aes-decrypter": "4.0.1", 30 | "global": "^4.4.0", 31 | "m3u8-parser": "^7.1.0", 32 | "mpd-parser": "^1.3.0", 33 | "mux.js": "7.0.3", 34 | "video.js": "^7 || ^8" 35 | }, 36 | "engines": { 37 | "node": ">=8", 38 | "npm": ">=5" 39 | }, 40 | "peerDependencies": { 41 | "video.js": "^8.14.0" 42 | } 43 | }, 44 | "node_modules/@videojs/http-streaming/node_modules/aes-decrypter": { 45 | "version": "4.0.1", 46 | "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.1.tgz", 47 | "integrity": "sha512-H1nh/P9VZXUf17AA5NQfJML88CFjVBDuGkp5zDHa7oEhYN9TTpNLJknRY1ie0iSKWlDf6JRnJKaZVDSQdPy6Cg==", 48 | "dependencies": { 49 | "@babel/runtime": "^7.12.5", 50 | "@videojs/vhs-utils": "^3.0.5", 51 | "global": "^4.4.0", 52 | "pkcs7": "^1.0.4" 53 | } 54 | }, 55 | "node_modules/@videojs/http-streaming/node_modules/aes-decrypter/node_modules/@videojs/vhs-utils": { 56 | "version": "3.0.5", 57 | "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz", 58 | "integrity": "sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw==", 59 | "dependencies": { 60 | "@babel/runtime": "^7.12.5", 61 | "global": "^4.4.0", 62 | "url-toolkit": "^2.2.1" 63 | }, 64 | "engines": { 65 | "node": ">=8", 66 | "npm": ">=5" 67 | } 68 | }, 69 | "node_modules/@videojs/vhs-utils": { 70 | "version": "4.0.0", 71 | "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.0.0.tgz", 72 | "integrity": "sha512-xJp7Yd4jMLwje2vHCUmi8MOUU76nxiwII3z4Eg3Ucb+6rrkFVGosrXlMgGnaLjq724j3wzNElRZ71D/CKrTtxg==", 73 | "dependencies": { 74 | "@babel/runtime": "^7.12.5", 75 | "global": "^4.4.0", 76 | "url-toolkit": "^2.2.1" 77 | }, 78 | "engines": { 79 | "node": ">=8", 80 | "npm": ">=5" 81 | } 82 | }, 83 | "node_modules/@videojs/xhr": { 84 | "version": "2.7.0", 85 | "resolved": "https://registry.npmjs.org/@videojs/xhr/-/xhr-2.7.0.tgz", 86 | "integrity": "sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==", 87 | "dependencies": { 88 | "@babel/runtime": "^7.5.5", 89 | "global": "~4.4.0", 90 | "is-function": "^1.0.1" 91 | } 92 | }, 93 | "node_modules/@xmldom/xmldom": { 94 | "version": "0.8.10", 95 | "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", 96 | "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", 97 | "engines": { 98 | "node": ">=10.0.0" 99 | } 100 | }, 101 | "node_modules/aes-decrypter": { 102 | "version": "4.0.2", 103 | "resolved": "https://registry.npmjs.org/aes-decrypter/-/aes-decrypter-4.0.2.tgz", 104 | "integrity": "sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==", 105 | "dependencies": { 106 | "@babel/runtime": "^7.12.5", 107 | "@videojs/vhs-utils": "^4.1.1", 108 | "global": "^4.4.0", 109 | "pkcs7": "^1.0.4" 110 | } 111 | }, 112 | "node_modules/aes-decrypter/node_modules/@videojs/vhs-utils": { 113 | "version": "4.1.1", 114 | "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz", 115 | "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==", 116 | "dependencies": { 117 | "@babel/runtime": "^7.12.5", 118 | "global": "^4.4.0" 119 | }, 120 | "engines": { 121 | "node": ">=8", 122 | "npm": ">=5" 123 | } 124 | }, 125 | "node_modules/dom-walk": { 126 | "version": "0.1.2", 127 | "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", 128 | "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" 129 | }, 130 | "node_modules/global": { 131 | "version": "4.4.0", 132 | "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", 133 | "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", 134 | "dependencies": { 135 | "min-document": "^2.19.0", 136 | "process": "^0.11.10" 137 | } 138 | }, 139 | "node_modules/is-function": { 140 | "version": "1.0.2", 141 | "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", 142 | "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==" 143 | }, 144 | "node_modules/m3u8-parser": { 145 | "version": "7.2.0", 146 | "resolved": "https://registry.npmjs.org/m3u8-parser/-/m3u8-parser-7.2.0.tgz", 147 | "integrity": "sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==", 148 | "dependencies": { 149 | "@babel/runtime": "^7.12.5", 150 | "@videojs/vhs-utils": "^4.1.1", 151 | "global": "^4.4.0" 152 | } 153 | }, 154 | "node_modules/m3u8-parser/node_modules/@videojs/vhs-utils": { 155 | "version": "4.1.1", 156 | "resolved": "https://registry.npmjs.org/@videojs/vhs-utils/-/vhs-utils-4.1.1.tgz", 157 | "integrity": "sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==", 158 | "dependencies": { 159 | "@babel/runtime": "^7.12.5", 160 | "global": "^4.4.0" 161 | }, 162 | "engines": { 163 | "node": ">=8", 164 | "npm": ">=5" 165 | } 166 | }, 167 | "node_modules/min-document": { 168 | "version": "2.19.0", 169 | "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", 170 | "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", 171 | "dependencies": { 172 | "dom-walk": "^0.1.0" 173 | } 174 | }, 175 | "node_modules/mpd-parser": { 176 | "version": "1.3.0", 177 | "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-1.3.0.tgz", 178 | "integrity": "sha512-WgeIwxAqkmb9uTn4ClicXpEQYCEduDqRKfmUdp4X8vmghKfBNXZLYpREn9eqrDx/Tf5LhzRcJLSpi4ohfV742Q==", 179 | "dependencies": { 180 | "@babel/runtime": "^7.12.5", 181 | "@videojs/vhs-utils": "^4.0.0", 182 | "@xmldom/xmldom": "^0.8.3", 183 | "global": "^4.4.0" 184 | }, 185 | "bin": { 186 | "mpd-to-m3u8-json": "bin/parse.js" 187 | } 188 | }, 189 | "node_modules/mux.js": { 190 | "version": "7.0.3", 191 | "resolved": "https://registry.npmjs.org/mux.js/-/mux.js-7.0.3.tgz", 192 | "integrity": "sha512-gzlzJVEGFYPtl2vvEiJneSWAWD4nfYRHD5XgxmB2gWvXraMPOYk+sxfvexmNfjQUFpmk6hwLR5C6iSFmuwCHdQ==", 193 | "dependencies": { 194 | "@babel/runtime": "^7.11.2", 195 | "global": "^4.4.0" 196 | }, 197 | "bin": { 198 | "muxjs-transmux": "bin/transmux.js" 199 | }, 200 | "engines": { 201 | "node": ">=8", 202 | "npm": ">=5" 203 | } 204 | }, 205 | "node_modules/pkcs7": { 206 | "version": "1.0.4", 207 | "resolved": "https://registry.npmjs.org/pkcs7/-/pkcs7-1.0.4.tgz", 208 | "integrity": "sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==", 209 | "dependencies": { 210 | "@babel/runtime": "^7.5.5" 211 | }, 212 | "bin": { 213 | "pkcs7": "bin/cli.js" 214 | } 215 | }, 216 | "node_modules/process": { 217 | "version": "0.11.10", 218 | "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", 219 | "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", 220 | "engines": { 221 | "node": ">= 0.6.0" 222 | } 223 | }, 224 | "node_modules/regenerator-runtime": { 225 | "version": "0.14.1", 226 | "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", 227 | "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" 228 | }, 229 | "node_modules/url-toolkit": { 230 | "version": "2.2.5", 231 | "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", 232 | "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" 233 | }, 234 | "node_modules/video.js": { 235 | "version": "8.17.4", 236 | "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.17.4.tgz", 237 | "integrity": "sha512-AECieAxKMKB/QgYK36ci50phfpWys6bFT6+pGMpSafeFYSoZaQ2Vpl83T9Qqcesv4TO7oNtiycnVeaBnrva2oA==", 238 | "dependencies": { 239 | "@babel/runtime": "^7.12.5", 240 | "@videojs/http-streaming": "3.13.3", 241 | "@videojs/vhs-utils": "^4.0.0", 242 | "@videojs/xhr": "2.7.0", 243 | "aes-decrypter": "^4.0.1", 244 | "global": "4.4.0", 245 | "m3u8-parser": "^7.1.0", 246 | "mpd-parser": "^1.2.2", 247 | "mux.js": "^7.0.1", 248 | "videojs-contrib-quality-levels": "4.1.0", 249 | "videojs-font": "4.2.0", 250 | "videojs-vtt.js": "0.15.5" 251 | } 252 | }, 253 | "node_modules/videojs-contrib-quality-levels": { 254 | "version": "4.1.0", 255 | "resolved": "https://registry.npmjs.org/videojs-contrib-quality-levels/-/videojs-contrib-quality-levels-4.1.0.tgz", 256 | "integrity": "sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==", 257 | "dependencies": { 258 | "global": "^4.4.0" 259 | }, 260 | "engines": { 261 | "node": ">=16", 262 | "npm": ">=8" 263 | }, 264 | "peerDependencies": { 265 | "video.js": "^8" 266 | } 267 | }, 268 | "node_modules/videojs-font": { 269 | "version": "4.2.0", 270 | "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-4.2.0.tgz", 271 | "integrity": "sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==" 272 | }, 273 | "node_modules/videojs-vtt.js": { 274 | "version": "0.15.5", 275 | "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", 276 | "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", 277 | "dependencies": { 278 | "global": "^4.3.1" 279 | } 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /lib/circle_web/controllers/page_html/home.html.heex: -------------------------------------------------------------------------------- 1 | <.flash_group flash={@flash} /> 2 | 41 |
42 |
43 | 49 |

50 | Phoenix Framework 51 | 52 | v<%= Application.spec(:phoenix, :vsn) %> 53 | 54 |

55 |

56 | Peace of mind from prototype to production. 57 |

58 |

59 | Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. 60 |

61 | 221 |
222 |
223 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"}, 3 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 4 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 5 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 6 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 7 | "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, 8 | "ecto": {:hex, :ecto, "3.12.3", "1a9111560731f6c3606924c81c870a68a34c819f6d4f03822f370ea31a582208", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9efd91506ae722f95e48dc49e70d0cb632ede3b7a23896252a60a14ac6d59165"}, 9 | "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, 10 | "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, 11 | "ex_aws": {:hex, :ex_aws, "2.5.5", "5dc378eff99c3c46c917b7a96a75ad0d4c300ab7250df668d0819bcd18c0213d", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ed7ee39c56012c56600e021953c6487ecce9c49320ec3b4655a15d785f221ca6"}, 12 | "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.4", "87aaf4a2f24a48f516d7f5aaced9d128dd5d0f655c4431f9037a11a85c71109c", [: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", "c06e7f68b33f7c0acba1361dbd951c79661a28f85aa2e0582990fccca4425355"}, 13 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 14 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 15 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, 16 | "flame": {:hex, :flame, "0.4.4", "422c28d61a57b8a3d1b4a5f03a84ceac8c809cdfb65eaf1751e67d3beda8c170", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1f89f7b8ff24799998dc11228e3fa52adb2a67eb39074c992ebb1e2c054a34be"}, 17 | "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, 18 | "gettext": {:hex, :gettext, "0.26.1", "38e14ea5dcf962d1fc9f361b63ea07c0ce715a8ef1f9e82d3dfb8e67e0416715", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "01ce56f188b9dc28780a52783d6529ad2bc7124f9744e571e1ee4ea88bf08734"}, 19 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 20 | "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, 21 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 22 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 23 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 24 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 25 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 26 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 27 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 28 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 29 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 30 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 31 | "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, 32 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, 33 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 34 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, 35 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, 36 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0-rc.6", "47d2669995ea326e5c71f5c1bc9177109cebf211385c638faa7b5862a401e516", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e56e4f1642a0b20edc2488cab30e5439595e0d8b5b259f76ef98b1c4e2e5b527"}, 37 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 38 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 39 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 40 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 41 | "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, 42 | "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, 43 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 44 | "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, 45 | "swoosh": {:hex, :swoosh, "1.17.0", "4a082a6ce4d60b1f48ffa725c8da0e2304504569ff550f4ed2d088c923039cb0", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "659b8bc25f7483b872d051a7f0731fb8d5312165be0d0302a3c783b566b0a290"}, 46 | "tailwind": {:hex, :tailwind, "0.2.3", "277f08145d407de49650d0a4685dc062174bdd1ae7731c5f1da86163a24dfcdb", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "8e45e7a34a676a7747d04f7913a96c770c85e6be810a1d7f91e713d3a3655b5d"}, 47 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 48 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"}, 49 | "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"}, 50 | "thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"}, 51 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 52 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 53 | "websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"}, 54 | } 55 | -------------------------------------------------------------------------------- /lib/circle_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule CircleWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | At first glance, this module may seem daunting, but its goal is to provide 6 | core building blocks for your application, such as modals, tables, and 7 | forms. The components consist mostly of markup and are well-documented 8 | with doc strings and declarative assigns. You may customize and style 9 | them in any way you want, based on your application growth and needs. 10 | 11 | The default components use Tailwind CSS, a utility-first CSS framework. 12 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn 13 | how to customize them or feel free to swap in another framework altogether. 14 | 15 | Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage. 16 | """ 17 | use Phoenix.Component 18 | 19 | alias Phoenix.LiveView.JS 20 | import CircleWeb.Gettext 21 | 22 | @doc """ 23 | Renders a modal. 24 | 25 | ## Examples 26 | 27 | <.modal id="confirm-modal"> 28 | This is a modal. 29 | 30 | 31 | JS commands may be passed to the `:on_cancel` to configure 32 | the closing/cancel event, for example: 33 | 34 | <.modal id="confirm" on_cancel={JS.navigate(~p"/posts")}> 35 | This is another modal. 36 | 37 | 38 | """ 39 | attr :id, :string, required: true 40 | attr :show, :boolean, default: false 41 | attr :on_cancel, JS, default: %JS{} 42 | slot :inner_block, required: true 43 | 44 | def modal(assigns) do 45 | ~H""" 46 | 328 | """ 329 | end 330 | 331 | def input(%{type: "select"} = assigns) do 332 | ~H""" 333 |
334 | <.label for={@id}><%= @label %> 335 | 345 | <.error :for={msg <- @errors}><%= msg %> 346 |
347 | """ 348 | end 349 | 350 | def input(%{type: "textarea"} = assigns) do 351 | ~H""" 352 |
353 | <.label for={@id}><%= @label %> 354 | 364 | <.error :for={msg <- @errors}><%= msg %> 365 |
366 | """ 367 | end 368 | 369 | # All other inputs text, datetime-local, url, password, etc. are handled here... 370 | def input(assigns) do 371 | ~H""" 372 |
373 | <.label for={@id}><%= @label %> 374 | 386 | <.error :for={msg <- @errors}><%= msg %> 387 |
388 | """ 389 | end 390 | 391 | @doc """ 392 | Renders a label. 393 | """ 394 | attr :for, :string, default: nil 395 | slot :inner_block, required: true 396 | 397 | def label(assigns) do 398 | ~H""" 399 | 402 | """ 403 | end 404 | 405 | @doc """ 406 | Generates a generic error message. 407 | """ 408 | slot :inner_block, required: true 409 | 410 | def error(assigns) do 411 | ~H""" 412 |

413 | <.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" /> 414 | <%= render_slot(@inner_block) %> 415 |

416 | """ 417 | end 418 | 419 | @doc """ 420 | Renders a header with title. 421 | """ 422 | attr :class, :string, default: nil 423 | 424 | slot :inner_block, required: true 425 | slot :subtitle 426 | slot :actions 427 | 428 | def header(assigns) do 429 | ~H""" 430 |
431 |
432 |

433 | <%= render_slot(@inner_block) %> 434 |

435 |

436 | <%= render_slot(@subtitle) %> 437 |

438 |
439 |
<%= render_slot(@actions) %>
440 |
441 | """ 442 | end 443 | 444 | @doc ~S""" 445 | Renders a table with generic styling. 446 | 447 | ## Examples 448 | 449 | <.table id="users" rows={@users}> 450 | <:col :let={user} label="id"><%= user.id %> 451 | <:col :let={user} label="username"><%= user.username %> 452 | 453 | """ 454 | attr :id, :string, required: true 455 | attr :rows, :list, required: true 456 | attr :row_id, :any, default: nil, doc: "the function for generating the row id" 457 | attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row" 458 | 459 | attr :row_item, :any, 460 | default: &Function.identity/1, 461 | doc: "the function for mapping each row before calling the :col and :action slots" 462 | 463 | slot :col, required: true do 464 | attr :label, :string 465 | end 466 | 467 | slot :action, doc: "the slot for showing user actions in the last table column" 468 | 469 | def table(assigns) do 470 | assigns = 471 | with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do 472 | assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) 473 | end 474 | 475 | ~H""" 476 |
477 | 478 | 479 | 480 | 481 | 484 | 485 | 486 | 491 | 492 | 504 | 515 | 516 | 517 |
<%= col[:label] %> 482 | <%= gettext("Actions") %> 483 |
497 |
498 | 499 | 500 | <%= render_slot(col, @row_item.(row)) %> 501 | 502 |
503 |
505 |
506 | 507 | 511 | <%= render_slot(action, @row_item.(row)) %> 512 | 513 |
514 |
518 |
519 | """ 520 | end 521 | 522 | @doc """ 523 | Renders a data list. 524 | 525 | ## Examples 526 | 527 | <.list> 528 | <:item title="Title"><%= @post.title %> 529 | <:item title="Views"><%= @post.views %> 530 | 531 | """ 532 | slot :item, required: true do 533 | attr :title, :string, required: true 534 | end 535 | 536 | def list(assigns) do 537 | ~H""" 538 |
539 |
540 |
541 |
<%= item.title %>
542 |
<%= render_slot(item) %>
543 |
544 |
545 |
546 | """ 547 | end 548 | 549 | @doc """ 550 | Renders a back navigation link. 551 | 552 | ## Examples 553 | 554 | <.back navigate={~p"/posts"}>Back to posts 555 | """ 556 | attr :navigate, :any, required: true 557 | slot :inner_block, required: true 558 | 559 | def back(assigns) do 560 | ~H""" 561 |
562 | <.link 563 | navigate={@navigate} 564 | class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" 565 | > 566 | <.icon name="hero-arrow-left-solid" class="h-3 w-3" /> 567 | <%= render_slot(@inner_block) %> 568 | 569 |
570 | """ 571 | end 572 | 573 | @doc """ 574 | Renders a [Heroicon](https://heroicons.com). 575 | 576 | Heroicons come in three styles – outline, solid, and mini. 577 | By default, the outline style is used, but solid and mini may 578 | be applied by using the `-solid` and `-mini` suffix. 579 | 580 | You can customize the size and colors of the icons by setting 581 | width, height, and background color classes. 582 | 583 | Icons are extracted from the `deps/heroicons` directory and bundled within 584 | your compiled app.css by the plugin in your `assets/tailwind.config.js`. 585 | 586 | ## Examples 587 | 588 | <.icon name="hero-x-mark-solid" /> 589 | <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" /> 590 | """ 591 | attr :name, :string, required: true 592 | attr :class, :string, default: nil 593 | 594 | def icon(%{name: "hero-" <> _} = assigns) do 595 | ~H""" 596 | 597 | """ 598 | end 599 | 600 | ## JS Commands 601 | 602 | def show(js \\ %JS{}, selector) do 603 | JS.show(js, 604 | to: selector, 605 | time: 300, 606 | transition: 607 | {"transition-all transform ease-out duration-300", 608 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", 609 | "opacity-100 translate-y-0 sm:scale-100"} 610 | ) 611 | end 612 | 613 | def hide(js \\ %JS{}, selector) do 614 | JS.hide(js, 615 | to: selector, 616 | time: 200, 617 | transition: 618 | {"transition-all transform ease-in duration-200", 619 | "opacity-100 translate-y-0 sm:scale-100", 620 | "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} 621 | ) 622 | end 623 | 624 | def show_modal(js \\ %JS{}, id) when is_binary(id) do 625 | js 626 | |> JS.show(to: "##{id}") 627 | |> JS.show( 628 | to: "##{id}-bg", 629 | time: 300, 630 | transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} 631 | ) 632 | |> show("##{id}-container") 633 | |> JS.add_class("overflow-hidden", to: "body") 634 | |> JS.focus_first(to: "##{id}-content") 635 | end 636 | 637 | def hide_modal(js \\ %JS{}, id) do 638 | js 639 | |> JS.hide( 640 | to: "##{id}-bg", 641 | transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} 642 | ) 643 | |> hide("##{id}-container") 644 | |> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) 645 | |> JS.remove_class("overflow-hidden", to: "body") 646 | |> JS.pop_focus() 647 | end 648 | 649 | @doc """ 650 | Translates an error message using gettext. 651 | """ 652 | def translate_error({msg, opts}) do 653 | # When using gettext, we typically pass the strings we want 654 | # to translate as a static argument: 655 | # 656 | # # Translate the number of files with plural rules 657 | # dngettext("errors", "1 file", "%{count} files", count) 658 | # 659 | # However the error messages in our forms and APIs are generated 660 | # dynamically, so we need to translate them by calling Gettext 661 | # with our gettext backend as first argument. Translations are 662 | # available in the errors.po file (as we use the "errors" domain). 663 | if count = opts[:count] do 664 | Gettext.dngettext(CircleWeb.Gettext, "errors", msg, msg, count, opts) 665 | else 666 | Gettext.dgettext(CircleWeb.Gettext, "errors", msg, opts) 667 | end 668 | end 669 | 670 | @doc """ 671 | Translates the errors for a field from a keyword list of errors. 672 | """ 673 | def translate_errors(errors, field) when is_list(errors) do 674 | for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts}) 675 | end 676 | end 677 | --------------------------------------------------------------------------------