From 9628a1868165e7687f9fce9a736ef6be168b2108 Mon Sep 17 00:00:00 2001 From: Jerod Santo Date: Fri, 3 May 2024 09:50:37 -0500 Subject: [PATCH] Add dynamic share images for episodes --- assets/app/img.css | 326 ++++++++++++++++++ assets/webpack.config.js | 7 + lib/changelog/regexp.ex | 4 + .../controllers/episode_controller.ex | 24 ++ lib/changelog_web/router.ex | 3 +- .../templates/episode/img.html.heex | 49 +++ .../templates/episode/img_news.html.heex | 30 ++ lib/changelog_web/views/episode_view.ex | 42 +++ lib/changelog_web/views/news_view.ex | 36 ++ lib/changelog_web/views/podcast_view.ex | 25 ++ test/changelog/regexp_test.exs | 25 +- .../controllers/episode_controller_test.exs | 39 ++- 12 files changed, 604 insertions(+), 6 deletions(-) create mode 100644 assets/app/img.css create mode 100644 lib/changelog_web/templates/episode/img.html.heex create mode 100644 lib/changelog_web/templates/episode/img_news.html.heex create mode 100644 lib/changelog_web/views/news_view.ex diff --git a/assets/app/img.css b/assets/app/img.css new file mode 100644 index 0000000000..d92889689e --- /dev/null +++ b/assets/app/img.css @@ -0,0 +1,326 @@ +/* Reset */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; + padding: 0; +} + +ul[role="list"], +ol[role="list"] { + list-style: none; +} + +html:focus-within { + scroll-behavior: smooth; +} + +a:not([class]) { + text-decoration-skip-ink: auto; +} + +img, +picture, +svg, +video, +canvas { + max-width: 100%; + height: auto; + vertical-align: middle; + font-style: italic; + background-repeat: no-repeat; + background-size: cover; +} + +input, +button, +textarea, +select { + font: inherit; +} + +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + transition: none; + } +} + +body, +html { + height: 100%; + scroll-behavior: smooth; +} + +/* Variables */ +:root { + --color-black: #000; + --color-dark: #101820; + --color-white: #fff; + --color-green: #59b287; + --color-grey: #878b8f; + --font-display: "Sana Sans", sans-serif; + --font-sans: "Sana Sans", sans-serif; + --font-mono: "Roboto Mono", monospace; + --base-border: 1px solid var(--color-dark); +} + +/* Typography */ +h1, +h2, +h3, +h4, +h5 { + font-weight: normal; + text-wrap: balance; +} + +/* Display */ +.display-sm { + font-family: var(--font-display); + font-size: clamp(0.875rem, 2vw, 1rem); + font-weight: 800; + line-height: 1.5; +} +.display-md { + font-family: var(--font-display); + font-size: clamp(1rem, 3vw, 1.25rem); + font-weight: 800; + line-height: 1.5; +} +.display-lg { + font-family: var(--font-display); + font-size: clamp(1.25rem, 4vw, 1.5rem); + font-weight: 800; + line-height: 1.5; +} +.display-xl { + font-family: var(--font-display); + font-size: clamp(1.5rem, 4.5vw, 2rem); + font-weight: 800; + line-height: 1.25; +} +.display-2xl { + font-family: var(--font-display); + font-size: 2.75rem; + font-weight: 700; + line-height: 1.25; +} +.display-3xl { + font-family: var(--font-display); + font-size: 4.25rem; + font-weight: 700; + line-height: 1.125; +} + +/* Sans */ +.sans-sm { + font-family: var(--font-sans); + font-size: clamp(0.875rem, 3vw, 1rem); + line-height: 1.5; +} +.sans-md { + font-family: var(--font-sans); + font-size: clamp(1rem, 3vw, 1.25rem); + font-weight: 400; + line-height: 1.5; +} +.sans-lg { + font-family: var(--font-sans); + font-size: clamp(1.25rem, 3vw, 1.5rem); + lin-height: 1.5; +} +.sans-xl { + font-family: var(--font-sans); + font-size: clamp(2rem, 5vw, 2.25rem); + font-weight: 500; + line-height: 1.5; +} + +/* Mono */ +.mono-sm { + font-family: var(--font-mono); + font-size: clamp(0.75rem, 2vw, 0.875rem); +} +.mono-md { + font-family: var(--font-mono); + font-size: clamp(0.875rem, 2.25vw, 1rem); + font-weight: 400; +} +.mono-lg { + font-family: var(--font-mono); + font-size: clamp(1rem, 2.5vw, 1.25rem); +} + +/* Misc. */ + +.italic { + font-style: italic; +} + +.color-grey { + color: var(--color-grey); +} + +.uppercase { + text-transform: uppercase; +} + +/* Primary Layout */ +html, +body { + background: var(--color-white); + font-family: var(--font-sans); + color: var(--color-white); + font-size: 16px; + height: 100%; + margin: 0; + padding: 0; +} + +.container { + background: var(--color-dark); + background: linear-gradient( + to right, + var(--color-dark) 40%, + var(--primary) 100% + ); + height: 630px; + margin: 0 auto 1rem; + width: 1200px; +} + +.inner { + display: flex; + flex-direction: column; + justify-content: space-between; + margin: 0 auto; + overflow: hidden; + padding: 3.5rem 0; + width: 800px; + height: 100%; +} + +/* Header */ + +.header { + align-items: center; + display: flex; + gap: 2rem; +} + +/* Details */ + +.details { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 2rem; +} + +.details-face_pile { + display: flex; + gap: 1rem; + + img { + border-radius: 1rem; + } +} + +/* Audio */ + +.audio { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.audio-timestamps { + display: flex; + justify-content: space-between; + gap: 1rem; +} + +.audio-waveform { + align-items: center; + display: flex; + justify-content: space-between; + height: 60px; +} + +.audio-waveform div { + width: 3px; + background-color: white; + border-radius: 3px; + opacity: 0.6; +} + +.audio-waveform div.has-played { + opacity: 1; +} + +/* + +Changelog News + +*/ + +.news-container { + background: var(--color-dark); + height: 630px; + margin: 0 auto 1rem; + position: relative; + width: 1200px; +} + +.news-fade { + background: linear-gradient(to top, var(--color-dark) 10%, transparent 100%); + position: absolute; + bottom: 0; + right: 0; + left: 0; + height: 12rem; +} + +.news-inner { + display: flex; + flex-direction: column; + gap: 3rem; + margin: 0 auto; + overflow: hidden; + padding: 3.5rem 0; + width: 800px; + height: 100%; +} + +.news-header { + display: flex; + justify-content: space-between; +} + +.news-heading { + text-align: center; +} + +.news-articles { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.news-articles li { + display: flex; + gap: 1rem; +} diff --git a/assets/webpack.config.js b/assets/webpack.config.js index 3079530855..a65ad44d62 100644 --- a/assets/webpack.config.js +++ b/assets/webpack.config.js @@ -96,6 +96,13 @@ module.exports = [ }, plugins: [new MiniCssExtractPlugin({ filename: "css/news.css" })] }), + merge(common, { + entry: [__dirname + "/app/fonts.css", __dirname + "/app/img.css"], + output: { + path: __dirname + "/../priv/static" + }, + plugins: [new MiniCssExtractPlugin({ filename: "css/img.css" })] + }), merge(common, { entry: [ "normalize.css", diff --git a/lib/changelog/regexp.ex b/lib/changelog/regexp.ex index 48ace6aa0a..fbedd59e8d 100644 --- a/lib/changelog/regexp.ex +++ b/lib/changelog/regexp.ex @@ -22,4 +22,8 @@ defmodule Changelog.Regexp do def slug_message, do: "valid chars: a-z, 0-9, -, _" def timestamp, do: ~r/(\d\d:)?(\d\d?:)(\d\d)(\.\d\d?)?/ + + def top_story, do: ~r/\A###?\s(\X+)\s\[/ + + def new_line, do: ~r/\r?\n/ end diff --git a/lib/changelog_web/controllers/episode_controller.ex b/lib/changelog_web/controllers/episode_controller.ex index ff27be812c..8fce10d3ab 100644 --- a/lib/changelog_web/controllers/episode_controller.ex +++ b/lib/changelog_web/controllers/episode_controller.ex @@ -27,6 +27,30 @@ defmodule ChangelogWeb.EpisodeController do |> render(:show) end + def img(conn, %{"slug" => slug}, podcast = %{slug: "news"}) do + episode = + assoc(podcast, :episodes) + |> Episode.published() + |> Episode.preload_all() + |> Repo.get_by!(slug: slug) + + conn + |> assign(:episode, episode) + |> render(:img_news, layout: false) + end + + def img(conn, %{"slug" => slug}, podcast) do + episode = + assoc(podcast, :episodes) + |> Episode.published() + |> Episode.preload_all() + |> Repo.get_by!(slug: slug) + + conn + |> assign(:episode, episode) + |> render(:img, layout: false) + end + def embed(conn, params = %{"slug" => slug}, podcast) do episode = assoc(podcast, :episodes) diff --git a/lib/changelog_web/router.ex b/lib/changelog_web/router.ex index dfc6233b9a..d4b6d940ce 100644 --- a/lib/changelog_web/router.ex +++ b/lib/changelog_web/router.ex @@ -283,7 +283,8 @@ defmodule ChangelogWeb.Router do post "/:podcast/:slug/subscribe", EpisodeController, :subscribe, as: :episode post "/:podcast/:slug/unsubscribe", EpisodeController, :unsubscribe, as: :episode - for subpage <- ~w(embed preview play share discuss transcript live time chapters psc email)a do + for subpage <- + ~w(embed preview play share img discuss transcript live time chapters psc email)a do get "/:podcast/:slug/#{subpage}", EpisodeController, subpage, as: :episode end diff --git a/lib/changelog_web/templates/episode/img.html.heex b/lib/changelog_web/templates/episode/img.html.heex new file mode 100644 index 0000000000..d13c190b1a --- /dev/null +++ b/lib/changelog_web/templates/episode/img.html.heex @@ -0,0 +1,49 @@ + + + + + + + <%= title_with_guest_focused_subtitle_and_podcast_aside(@episode) %> + + + +
+
+
+ Podcast Artwork +

<%= @episode.title %>

+
+ +
+ <% participants = participants(@episode) %> +
+ <%= for participant <- Enum.take(participants, 10) do %> + + <% end %> +
+ + <%= if @episode.subtitle do %> +

<%= @episode.subtitle %>

+ <% end %> +
+ + <%= if @episode.audio_duration do %> +
+
+ <% percent_complete = Enum.random(20..80) %> + <%= for i <- 1..80 do %> +
+ <% end %> +
+ +
+ <%= TimeView.duration(@episode.audio_duration * (percent_complete / 100)) %> + <%= TimeView.duration(@episode.audio_duration) %> +
+
+ <% end %> +
+
+ + diff --git a/lib/changelog_web/templates/episode/img_news.html.heex b/lib/changelog_web/templates/episode/img_news.html.heex new file mode 100644 index 0000000000..a5976bb05f --- /dev/null +++ b/lib/changelog_web/templates/episode/img_news.html.heex @@ -0,0 +1,30 @@ + + + + + + + <%= title_with_guest_focused_subtitle_and_podcast_aside(@episode) %> + + + +
+
+
+ Changelog News #<%= @episode.slug %> + <%= NewsView.read_duration(@episode) %> Min Read / <%= NewsView.listen_duration(@episode) %> Min Listen +
+ +

<%= NewsView.title(@episode) %>

+ +
    + <%= for headline <- NewsView.headlines(@episode) do %> +
  • <%= headline %>
  • + <% end %> +
+
+ +
+
+ + diff --git a/lib/changelog_web/views/episode_view.ex b/lib/changelog_web/views/episode_view.ex index bd78c32374..52766db769 100644 --- a/lib/changelog_web/views/episode_view.ex +++ b/lib/changelog_web/views/episode_view.ex @@ -19,6 +19,7 @@ defmodule ChangelogWeb.EpisodeView do Endpoint, LayoutView, Meta, + NewsView, PersonView, PodcastView, SponsorView, @@ -217,6 +218,47 @@ defmodule ChangelogWeb.EpisodeView do Github.Source.new("show-notes", episode).html_url end + def subtitle_img_class(episode, participants) do + subtitle_length = String.length(episode.subtitle) + + case length(participants) do + 1 -> + cond do + subtitle_length < 50 -> "sans-xl" + subtitle_length < 70 -> "sans-lg" + subtitle_length < 80 -> "sans-md" + true -> "sans-sm" + end + + 2 -> + cond do + subtitle_length < 40 -> "sans-xl" + subtitle_length < 60 -> "sans-lg" + subtitle_length < 70 -> "sans-md" + true -> "sans-sm" + end + + 3 -> + cond do + subtitle_length < 30 -> "sans-xl" + subtitle_length < 50 -> "sans-lg" + subtitle_length < 60 -> "sans-md" + true -> "sans-sm" + end + + 4 -> + cond do + subtitle_length < 28 -> "sans-xl" + subtitle_length < 48 -> "sans-lg" + subtitle_length < 58 -> "sans-md" + true -> "sans-sm" + end + + _else -> + "sans-sm" + end + end + def title_with_podcast_aside(episode) do [ episode.title, diff --git a/lib/changelog_web/views/news_view.ex b/lib/changelog_web/views/news_view.ex new file mode 100644 index 0000000000..8d1734cdae --- /dev/null +++ b/lib/changelog_web/views/news_view.ex @@ -0,0 +1,36 @@ +defmodule ChangelogWeb.NewsView do + import ChangelogWeb.Helpers.SharedHelpers, only: [word_count: 1, md_to_text: 1, truncate: 2] + + alias Changelog.Regexp + + def headlines(episode) do + content = episode.email_content || "" + subject = episode.email_subject || "" + first = String.first(subject) || "-1" + + content + |> String.split(Regexp.new_line()) + |> Enum.filter(&String.match?(&1, Regexp.top_story())) + |> Enum.map(&md_to_text(&1)) + |> Enum.reject(fn s -> String.contains?(s, first) end) + |> Enum.map(&truncate(&1, 40)) + end + + def title(%{email_subject: subject, title: title}) do + subject || title + end + + def read_duration(%{email_content: nil}), do: 0 + + # assumes 250 words per minute + def read_duration(%{email_content: content}) do + minutes_to_read = word_count(content) / 250 * 60 + round(minutes_to_read / 60) + end + + def listen_duration(%{audio_duration: nil}), do: 0 + + def listen_duration(%{audio_duration: duration}) do + round(duration / 60) + end +end diff --git a/lib/changelog_web/views/podcast_view.ex b/lib/changelog_web/views/podcast_view.ex index 2807d319de..3765e0d225 100644 --- a/lib/changelog_web/views/podcast_view.ex +++ b/lib/changelog_web/views/podcast_view.ex @@ -27,6 +27,31 @@ defmodule ChangelogWeb.PodcastView do end end + def color_hex_code(podcast) do + case podcast.slug do + "brainscience" -> + "F9423A" + + "founderstalk" -> + "5FC4B4" + + "gotime" -> + "58CAF5" + + "jsparty" -> + "FDCF0F" + + "practicalai" -> + "4A5FAA" + + "shipit" -> + "97CC69" + + _else -> + "59B287" + end + end + def cover_url(podcast, version \\ :original) # Special cases for Master, The Changelog & ++ diff --git a/test/changelog/regexp_test.exs b/test/changelog/regexp_test.exs index e155b532a4..e265769af4 100644 --- a/test/changelog/regexp_test.exs +++ b/test/changelog/regexp_test.exs @@ -17,7 +17,13 @@ defmodule Changelog.RegexpTest do end test "email cannot have a spaces in them" do - no = ["tester@yandex.ru ", " tester@yandex.ru", "test er@yandex.ru", "tester@yandex .ru", "tester@yandex. ru"] + no = [ + "tester@yandex.ru ", + " tester@yandex.ru", + "test er@yandex.ru", + "tester@yandex .ru", + "tester@yandex. ru" + ] for email <- no do refute String.match?(email, Regexp.email()) @@ -36,4 +42,21 @@ defmodule Changelog.RegexpTest do refute String.match?(social, Regexp.social()) end end + + test "top_story" do + yes = [ + "## ✨ [Cool thing](http://cool.com)", + "### 👌 [Slightly less cool thing](http://cool.com)" + ] + + no = ["## ✨ Missing the link", "### [No emoji](http://cool.com)"] + + for top_story <- yes do + assert String.match?(top_story, Regexp.top_story()) + end + + for top_story <- no do + refute String.match?(top_story, Regexp.top_story()) + end + end end diff --git a/test/changelog_web/controllers/episode_controller_test.exs b/test/changelog_web/controllers/episode_controller_test.exs index 29dde85cbd..0dcfbcc342 100644 --- a/test/changelog_web/controllers/episode_controller_test.exs +++ b/test/changelog_web/controllers/episode_controller_test.exs @@ -253,13 +253,44 @@ defmodule ChangelogWeb.EpisodeControllerTest do end end + describe "img" do + test "renders for a regular episode", %{conn: conn} do + p = insert(:podcast) + e = insert(:published_episode, podcast: p) + + conn = get(conn, ~p"/#{p.slug}/#{e.slug}/img") + + assert conn.status == 200 + end + + test "renders for a news episode that lacks data", %{conn: conn} do + p = insert(:podcast, slug: "news") + e = insert(:published_episode, podcast: p) + + conn = get(conn, ~p"/#{p.slug}/#{e.slug}/img") + + assert conn.status == 200 + end + + test "renders for a news episode that has data", %{conn: conn} do + p = insert(:podcast, slug: "news") + e = insert(:published_episode, podcast: p, email_content: "## 🙌 [hello]()") + + conn = get(conn, ~p"/#{p.slug}/#{e.slug}/img") + + assert conn.status == 200 + assert conn.resp_body =~ "hello" + assert conn.resp_body =~ e.title + end + end + describe "live" do test "404 when episode is not recorded live", %{conn: conn} do p = insert(:podcast) e = insert(:episode, podcast: p, recorded_live: false) assert_raise Ecto.NoResultsError, fn -> - get(conn, Routes.episode_path(conn, :live, p.slug, e.slug)) + get(conn, ~p"/#{p.slug}/#{e.slug}/live") end end @@ -268,7 +299,7 @@ defmodule ChangelogWeb.EpisodeControllerTest do e = insert(:episode, podcast: p, recorded_live: true) assert_raise Ecto.NoResultsError, fn -> - get(conn, Routes.episode_path(conn, :live, p.slug, e.slug)) + get(conn, ~p"/#{p.slug}/#{e.slug}/live") end end @@ -276,7 +307,7 @@ defmodule ChangelogWeb.EpisodeControllerTest do p = insert(:podcast) e = insert(:episode, podcast: p, recorded_live: true, youtube_id: "8675309") - conn = get(conn, Routes.episode_path(conn, :live, p.slug, e.slug)) + conn = get(conn, ~p"/#{p.slug}/#{e.slug}/live") assert_redirected_to(conn, "https://youtu.be/8675309") end @@ -297,7 +328,7 @@ defmodule ChangelogWeb.EpisodeControllerTest do e = insert(:episode, podcast: p, recorded_at: nil) assert_raise Ecto.NoResultsError, fn -> - get(conn, Routes.episode_path(conn, :live, p.slug, e.slug)) + get(conn, ~p"/#{p.slug}/#{e.slug}/live") end end