svg preview renderer
This commit is contained in:
parent
43a8412f06
commit
faa9599849
29 changed files with 1027 additions and 254 deletions
|
|
@ -1,10 +1,16 @@
|
|||
defmodule Zoeyscomputer.Gists.Gist do
|
||||
alias Zoeyscomputer.IdGenerator
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Zoeyscomputer.Users.User
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
schema "gists" do
|
||||
field :code, :string
|
||||
field :lang, :string
|
||||
field :title, :string
|
||||
field :desc, :string
|
||||
belongs_to :author, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
|
@ -12,7 +18,15 @@ defmodule Zoeyscomputer.Gists.Gist do
|
|||
@doc false
|
||||
def changeset(gist, attrs) do
|
||||
gist
|
||||
|> cast(attrs, [:code, :lang])
|
||||
|> validate_required([:code, :lang])
|
||||
|> cast(attrs, [:code, :lang, :title, :desc])
|
||||
|> validate_required([:code, :lang, :title])
|
||||
|> put_new_id()
|
||||
end
|
||||
|
||||
defp put_new_id(changeset) do
|
||||
case get_field(changeset, :id) do
|
||||
nil -> put_change(changeset, :id, IdGenerator.generate(7))
|
||||
_id -> changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule ZoeyscomputerWeb.CodeBlock do
|
||||
use Phoenix.Component
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
import ZoeyscomputerWeb.CoreComponents
|
||||
|
||||
@moduledoc """
|
||||
A code block component with syntax highlighting using Shiki.
|
||||
|
|
@ -37,6 +38,7 @@ defmodule ZoeyscomputerWeb.CodeBlock do
|
|||
attr :code, :string, required: true
|
||||
attr :language, :string, required: true
|
||||
attr :title, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
attr :line_numbers, :boolean, default: false
|
||||
attr :highlighted_lines, :list, default: []
|
||||
|
||||
|
|
@ -44,48 +46,46 @@ defmodule ZoeyscomputerWeb.CodeBlock do
|
|||
# Calculate the number of lines for line numbers
|
||||
assigns = assign(assigns, :num_lines, String.split(assigns.code, "\n") |> length())
|
||||
|
||||
id = System.unique_integer()
|
||||
|
||||
~H"""
|
||||
<div class="relative ctp-bg-base rounded-lg overflow-hidden">
|
||||
<div class={"relative rounded-lg overflow-hidden w-full bg-ctp-mantle border border-ctp-surface0 #{@class} "}>
|
||||
<%= if @title do %>
|
||||
<div class="ctp-bg-mantle px-4 py-2 border-b ctp-border-surface0">
|
||||
<h3 class="ctp-text-text text-sm font-medium"><%= @title %></h3>
|
||||
<div class="bg-ctp-crust px-4 py-2 border-b border-ctp-surface0">
|
||||
<h3 class="ctp-text-text text-[0.75rem] font-medium"><%= @title %></h3>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="relative">
|
||||
<%= if @line_numbers do %>
|
||||
<div class="absolute left-0 top-0 bottom-0 ctp-bg-crust w-12 flex flex-col items-end pr-2 py-4 ctp-text-surface2 select-none">
|
||||
<%= for line_num <- 1..@num_lines do %>
|
||||
<span class={[
|
||||
"text-sm leading-6",
|
||||
line_num in @highlighted_lines && "ctp-text-mauve font-medium"
|
||||
]}>
|
||||
<%= line_num %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="overflow-x-scroll bg-ctp-mantle">
|
||||
<div class="relative bg-ctp-crust">
|
||||
<%= if @line_numbers do %>
|
||||
<div class="absolute bg-ctp-crust left-0 top-0 bottom-0 ctp-bg-crust min-w-12 flex flex-col items-end py-4 ctp-text-surface2 select-none">
|
||||
<%= for line_num <- 1..@num_lines do %>
|
||||
<span class={[
|
||||
"text-sm leading-6 pr-2",
|
||||
line_num in @highlighted_lines &&
|
||||
"text-ctp-mauve bg-ctp-mauve/15 w-full text-right font-bold"
|
||||
]}>
|
||||
<%= line_num %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class={["overflow-x-auto", @line_numbers && "pl-12"]}>
|
||||
<pre
|
||||
class="p-4"
|
||||
id={"code-block-#{System.unique_integer()}"}
|
||||
phx-hook="CodeBlockHook"
|
||||
data-code={@code}
|
||||
data-language={@language}
|
||||
data-highlighted-lines={Jason.encode!(@highlighted_lines)}
|
||||
><code class="text-sm"><%= @code %></code></pre>
|
||||
<div class={["bg-ctp-mantle overflow-x-scroll pb-0.5", @line_numbers && "pl-12"]}>
|
||||
<pre
|
||||
class="p-4 leading-6 text-sm mb-4"
|
||||
id={"code-block-#{id}"}
|
||||
phx-hook="CodeBlockHook"
|
||||
data-code={@code}
|
||||
data-language={@language}
|
||||
data-highlighted-lines={Jason.encode!(@highlighted_lines)}
|
||||
><code class="text-sm"><%= @code %></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ctp-bg-mantle px-4 py-2 border-t ctp-border-surface0 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="ctp-text-subtext0 hover:ctp-text-text text-sm"
|
||||
phx-click={JS.dispatch("clipcopy", detail: %{text: @code})}
|
||||
>
|
||||
Copy code
|
||||
</button>
|
||||
<div class="bg-ctp-mantle px-4 py-2 border-t border-ctp-surface0 flex justify-end">
|
||||
<.copy_button id="code-copy-btn" content={"code-block-#{id}"} />
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -235,8 +235,9 @@ defmodule ZoeyscomputerWeb.CoreComponents do
|
|||
<button
|
||||
type={@type}
|
||||
class={[
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
|
||||
"text-sm font-semibold leading-6 text-white active:text-white/80",
|
||||
"phx-submit-loading:opacity-75 rounded-lg bg-ctp-mauve text-ctp-crust hover:brightness-125 py-2 px-3",
|
||||
"text-sm font-semibold leading-6",
|
||||
"transition-all duration-150 ease-in",
|
||||
@class
|
||||
]}
|
||||
{@rest}
|
||||
|
|
@ -359,7 +360,7 @@ defmodule ZoeyscomputerWeb.CoreComponents do
|
|||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
"mt-2 block w-full rounded-lg text-ctp-overlay2 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
"mt-2 block w-full bg-ctp-base rounded-lg text-ctp-text focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
|
||||
@errors == [] && "border-ctp-surface2 focus:border-zinc-400",
|
||||
@errors != [] && "border-rose-400 focus:border-rose-400"
|
||||
]}
|
||||
|
|
@ -400,7 +401,7 @@ defmodule ZoeyscomputerWeb.CoreComponents do
|
|||
|
||||
def label(assigns) do
|
||||
~H"""
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-ctp-text">
|
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-ctp-overlay1">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</label>
|
||||
"""
|
||||
|
|
@ -458,7 +459,7 @@ defmodule ZoeyscomputerWeb.CoreComponents do
|
|||
content={@content}
|
||||
phx-click={JS.dispatch("phx:copy", to: "##{@content}")}
|
||||
type="button"
|
||||
class="rounded-md inline-flex items-center bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
||||
class="rounded-md inline-flex items-center bg-ctp-blue px-2.5 py-1.5 text-sm font-semibold text-ctp-crust shadow-sm shadow-ctp-blue/25 ring-ctp-blue ring-1 ring-inset transition-all duration-150 ease-in hover:brightness-125"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<main class="bg-ctp-base px-4 py-20 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="mx-auto lg:max-w-7xl">
|
||||
<.flash_group flash={@flash} />
|
||||
<%= @inner_content %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
defmodule ZoeyscomputerWeb.SearchableDropdown do
|
||||
use Phoenix.Component
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :options, :list, required: true
|
||||
attr :selected, :string, default: nil
|
||||
attr :class, :string, default: nil
|
||||
attr :name, :string, required: true
|
||||
attr :form, :any, required: true
|
||||
|
||||
def searchable_dropdown(assigns) do
|
||||
~H"""
|
||||
<div class={["relative w-full", @class]} id={"#{@id}-container"}>
|
||||
<div class="relative" phx-click-away={JS.hide(to: "##{@id}-dropdown", transition: "fade-out")}>
|
||||
<input type="hidden" name={@name} value={@selected} id={"#{@id}-input"} />
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-md border border-ctp-surface0 bg-ctp-base px-3 py-2 text-sm text-ctp-text shadow-sm hover:bg-ctp-surface0 focus:outline-none focus:ring-2 focus:ring-ctp-lavender transition-colors duration-200"
|
||||
phx-click={
|
||||
JS.toggle(to: "##{@id}-dropdown", in: "fade-in", out: "fade-out")
|
||||
|> JS.focus(to: "##{@id}-search")
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="block truncate">
|
||||
<%= @selected || "Select an option..." %>
|
||||
</span>
|
||||
<svg
|
||||
class="h-5 w-5 text-ctp-overlay0 transform transition-transform duration-200"
|
||||
class={"#{if @selected, do: "rotate-180", else: ""}"}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
id={"#{@id}-dropdown"}
|
||||
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-ctp-surface0 bg-ctp-base shadow-lg hidden transition-all duration-200 ease-in-out"
|
||||
phx-hook="DropdownAnimation"
|
||||
>
|
||||
<div class="p-2">
|
||||
<input
|
||||
type="text"
|
||||
id={"#{@id}-search"}
|
||||
placeholder="Search..."
|
||||
class="w-full rounded-md border border-ctp-surface0 bg-ctp-mantle px-3 py-2 text-sm text-ctp-text placeholder-ctp-overlay0 focus:outline-none focus:ring-2 focus:ring-ctp-lavender transition-colors duration-200"
|
||||
phx-hook="SearchableDropdown"
|
||||
data-dropdown-id={@id}
|
||||
data-form-id={@form.id}
|
||||
/>
|
||||
</div>
|
||||
<ul class="max-h-48 overflow-y-auto py-1" role="listbox" id={"#{@id}-options"}>
|
||||
<li :for={option <- @options} class="transition-colors duration-150 ease-in-out">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-ctp-surface0 focus:bg-ctp-surface0 focus:outline-none cursor-pointer transition-colors duration-150 ease-in-out"
|
||||
phx-click={
|
||||
JS.push("select_language", value: %{language: option}, target: "##{@form.id}")
|
||||
|> JS.hide(to: "##{@id}-dropdown", transition: "fade-out")
|
||||
}
|
||||
phx-target={"##{@form.id}"}
|
||||
role="option"
|
||||
data-option={option}
|
||||
>
|
||||
<%= option %>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
30
lib/zoeyscomputer_web/controllers/gist_preview_controller.ex
Normal file
30
lib/zoeyscomputer_web/controllers/gist_preview_controller.ex
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
defmodule ZoeyscomputerWeb.GistPreviewController do
|
||||
use ZoeyscomputerWeb, :controller
|
||||
|
||||
require Logger
|
||||
alias Zoeyscomputer.Gists
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
gist = Gists.get_gist!(id)
|
||||
|
||||
{:ok, webp} = ZoeyscomputerWeb.GistLive.OgImage.get_webp(gist)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("image/webp")
|
||||
|> put_resp_header("cache-control", "public, max-age=300")
|
||||
|> send_resp(200, webp)
|
||||
|> halt()
|
||||
end
|
||||
|
||||
def raw(conn, %{"id" => id}) do
|
||||
gist = Gists.get_gist!(id)
|
||||
|
||||
webp = ZoeyscomputerWeb.GistLive.OgImage.generate_preview(gist)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("image/svg+xml")
|
||||
|> put_resp_header("cache-control", "public, max-age=300")
|
||||
|> send_resp(200, webp)
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
defmodule ZoeyscomputerWeb.GistLive.FormComponent do
|
||||
use ZoeyscomputerWeb, :live_component
|
||||
|
||||
import ZoeyscomputerWeb.GistLive.Select
|
||||
|
||||
alias Zoeyscomputer.Gists
|
||||
|
||||
@impl true
|
||||
|
|
@ -11,7 +13,6 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
|
|||
<%= @title %>
|
||||
<:subtitle>Use this form to manage gist records in your database.</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.simple_form
|
||||
for={@form}
|
||||
id="gist-form"
|
||||
|
|
@ -19,8 +20,22 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
|
|||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<.input field={@form[:code]} type="text" label="Code" />
|
||||
<.input field={@form[:lang]} type="text" label="Lang" />
|
||||
<.input field={@form[:title]} type="text" label="Title" />
|
||||
<.input field={@form[:desc]} type="textarea" label="Description" />
|
||||
<.input field={@form[:code]} type="textarea" label="Code" />
|
||||
<.select
|
||||
id="lang-select"
|
||||
form={@form}
|
||||
field={:lang}
|
||||
label="Language"
|
||||
options={[
|
||||
{"JavaScript", "javascript"},
|
||||
{"Python", "python"},
|
||||
{"Elixir", "elixir"},
|
||||
{"Ruby", "ruby"},
|
||||
{"Rust", "rust"}
|
||||
]}
|
||||
/>
|
||||
<:actions>
|
||||
<.button phx-disable-with="Saving...">Save Gist</.button>
|
||||
</:actions>
|
||||
|
|
@ -49,6 +64,14 @@ defmodule ZoeyscomputerWeb.GistLive.FormComponent do
|
|||
save_gist(socket, socket.assigns.action, gist_params)
|
||||
end
|
||||
|
||||
# Updated to use lang instead of language
|
||||
def handle_event("change", %{"value" => value}, socket) do
|
||||
current_params = socket.assigns.form.params || %{}
|
||||
updated_params = Map.put(current_params, "lang", value)
|
||||
changeset = Gists.change_gist(socket.assigns.gist, updated_params)
|
||||
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
defp save_gist(socket, :edit, gist_params) do
|
||||
case Gists.update_gist(socket.assigns.gist, gist_params) do
|
||||
{:ok, gist} ->
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
live_action = socket.assigns.live_action || :index
|
||||
{:noreply, apply_action(socket, live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
|
|
@ -23,7 +24,16 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
|
|||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Gist")
|
||||
|> assign(:gist, %Gist{})
|
||||
|> assign(:gist, %Gist{
|
||||
code: "",
|
||||
lang: nil
|
||||
})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :show, %{"id" => id}) do
|
||||
socket
|
||||
|> assign(:page_title, "Show Gist")
|
||||
|> assign(:gist, Gists.get_gist!(id))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
|
|
@ -32,6 +42,13 @@ defmodule ZoeyscomputerWeb.GistLive.Index do
|
|||
|> assign(:gist, nil)
|
||||
end
|
||||
|
||||
# Catch-all clause for when live_action is nil
|
||||
defp apply_action(socket, nil, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Listing Gists")
|
||||
|> assign(:gist, nil)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({ZoeyscomputerWeb.GistLive.FormComponent, {:saved, gist}}, socket) do
|
||||
{:noreply, stream_insert(socket, :gists, gist)}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
rows={@streams.gists}
|
||||
row_click={fn {_id, gist} -> JS.navigate(~p"/gists/#{gist}") end}
|
||||
>
|
||||
<:col :let={{_id, gist}} label="Code"><%= gist.code %></:col>
|
||||
<:col :let={{_id, gist}} label="Code">
|
||||
<p class="truncate max-w-72"><%= gist.title || gist.code %></p>
|
||||
</:col>
|
||||
<:col :let={{_id, gist}} label="Lang"><%= gist.lang %></:col>
|
||||
<:action :let={{_id, gist}}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
15
lib/zoeyscomputer_web/live/gist_live/languages.ex
Normal file
15
lib/zoeyscomputer_web/live/gist_live/languages.ex
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
defmodule ZoeyscomputerWeb.GistLive.Languages do
|
||||
@languages [
|
||||
%{name: "JavaScript", val: "javascript"},
|
||||
%{name: "TypeScript", val: "typescript"},
|
||||
%{name: "Rust", val: "rust"},
|
||||
%{name: "Elixir", val: "elixir"}
|
||||
]
|
||||
|
||||
def search_languages(name) do
|
||||
@languages
|
||||
|> Enum.filter(fn language ->
|
||||
String.contains?(String.downcase(language.name), String.downcase(name))
|
||||
end)
|
||||
end
|
||||
end
|
||||
243
lib/zoeyscomputer_web/live/gist_live/og_image.ex
Normal file
243
lib/zoeyscomputer_web/live/gist_live/og_image.ex
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
defmodule ZoeyscomputerWeb.GistLive.OgImage do
|
||||
@moduledoc """
|
||||
Generates OpenGraph preview images for Gists using SVG with syntax highlighting.
|
||||
"""
|
||||
require Logger
|
||||
|
||||
@external_resource "priv/static/fonts/WOFF2/Iosevka-Bold.woff2"
|
||||
@external_resource "priv/static/fonts/WOFF2/Iosevka-Regular.woff2"
|
||||
@font_bold File.read!("priv/static/fonts/WOFF2/Iosevka-Bold.woff2")
|
||||
@font_regular File.read!("priv/static/fonts/WOFF2/Iosevka-Regular.woff2")
|
||||
|
||||
@line_height 36
|
||||
@code_start_y 210
|
||||
@code_start_x 80
|
||||
@font_size 24
|
||||
@char_width 12
|
||||
|
||||
defp font_face_styles do
|
||||
"""
|
||||
@font-face {
|
||||
font-family: 'Iosevka';
|
||||
src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_regular)}) format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Iosevka';
|
||||
src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_bold)}) format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
"""
|
||||
end
|
||||
|
||||
def generate_preview(gist) do
|
||||
code_lines =
|
||||
gist.code
|
||||
|> preview_code()
|
||||
|> highlight_code(gist.lang)
|
||||
|> render_code_lines()
|
||||
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
#{font_face_styles()}
|
||||
|
||||
* {
|
||||
font-family: 'Iosevka' !important;
|
||||
}
|
||||
|
||||
.background { fill: #0D1117; }
|
||||
.title { fill: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
|
||||
.description { fill: #8B949E; font-family: system-ui, -apple-system, sans-serif; }
|
||||
.code-background { fill: #161B22; }
|
||||
|
||||
/* Syntax Highlighting Colors */
|
||||
.c, .cm, .cp, .c1, .cs { fill: #768390; } /* Comment */
|
||||
.k, .kc, .kd, .kn, .kp, .kr, .kt { fill: #f47067; } /* Keyword */
|
||||
.s, .sb, .sc, .sd, .s2, .se, .sh, .si, .sx, .sr, .s1, .ss { fill: #96d0ff; } /* String */
|
||||
.n, .na, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nx, .py, .nt, .nv, .vc, .vg, .vi { fill: #adbac7; } /* Name */
|
||||
.nb, .bp { fill: #f69d50; } /* Built-in */
|
||||
.m, .mf, .mh, .mi, .mo, .il { fill: #6cb6ff; } /* Number */
|
||||
.o, .ow { fill: #f47067; } /* Operator */
|
||||
.p { fill: #adbac7; } /* Punctuation */
|
||||
.w { fill: #E6EDF3; } /* Whitespace */
|
||||
.l { fill: #6cb6ff; } /* Literal */
|
||||
.default { fill: #E6EDF3; } /* Default text color */
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect class="background" width="1200" height="630" />
|
||||
|
||||
<!-- Content -->
|
||||
<text x="60" y="80" class="title" font-size="40" font-weight="bold">
|
||||
#{escape_text(gist.title || "Gist: #{gist.id}")}
|
||||
</text>
|
||||
|
||||
<text x="60" y="130" class="description" font-size="24">
|
||||
#{escape_text(gist.desc || "No description")}
|
||||
</text>
|
||||
|
||||
<!-- Code Preview Background -->
|
||||
<rect x="60" y="160" width="1080" height="420" class="code-background" rx="8"/>
|
||||
|
||||
<!-- Code Content -->
|
||||
<g font-family="Iosevka" font-size="#{@font_size}">
|
||||
#{code_lines}
|
||||
</g>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_code_lines(highlighted_html) do
|
||||
highlighted_html
|
||||
|> String.split("\n")
|
||||
|> Enum.with_index()
|
||||
|> Enum.map_join("\n", fn {line, index} ->
|
||||
y_position = @code_start_y + index * @line_height
|
||||
render_line(String.trim(line), y_position)
|
||||
end)
|
||||
end
|
||||
|
||||
defp render_line("", _y_position), do: ""
|
||||
|
||||
defp render_line(line, y_position) do
|
||||
spans = parse_spans(line)
|
||||
|
||||
spans
|
||||
|> Enum.reduce({[], 0}, fn {content, class}, {spans, x_offset} ->
|
||||
span = render_span(content, class, x_offset, y_position)
|
||||
{[span | spans], x_offset + String.length(content)}
|
||||
end)
|
||||
|> elem(0)
|
||||
|> Enum.reverse()
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp parse_spans(""), do: []
|
||||
|
||||
defp parse_spans(line) do
|
||||
# Handle non-span text first
|
||||
line
|
||||
|> String.split(~r{(<span.*?>|</span>)}, include_captures: true)
|
||||
|> Enum.reduce({[], nil}, fn
|
||||
# Opening span tag
|
||||
<<"<span class=\"", rest::binary>>, {acc, nil} ->
|
||||
class = rest |> String.replace_suffix("\">", "")
|
||||
{acc, class}
|
||||
|
||||
# Closing span tag
|
||||
"</span>", {acc, _class} ->
|
||||
{acc, nil}
|
||||
|
||||
# Content within or outside spans
|
||||
content, {acc, class} when byte_size(content) > 0 ->
|
||||
{[{content, class || "default"} | acc], class}
|
||||
|
||||
_, acc_class ->
|
||||
acc_class
|
||||
end)
|
||||
|> elem(0)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp render_span(content, class, x_offset, y_position) when byte_size(content) > 0 do
|
||||
x = @code_start_x + x_offset * @char_width
|
||||
"<text x=\"#{x}\" y=\"#{y_position}\" class=\"#{class}\">#{escape_text(content)}</text>"
|
||||
end
|
||||
|
||||
defp render_span(_, _, _, _), do: ""
|
||||
|
||||
defp preview_code(content) do
|
||||
content
|
||||
|> String.split("\n")
|
||||
|> Enum.take(15)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp escape_text(text) do
|
||||
text
|
||||
|> String.replace("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
end
|
||||
|
||||
defp highlight_code(code, language) when is_binary(language) do
|
||||
lexer = get_lexer_for_language(language)
|
||||
|
||||
String.trim(
|
||||
code
|
||||
|> Makeup.highlight_inner_html(
|
||||
lexer: lexer,
|
||||
formatter_opts: [
|
||||
highlight_tag: "span",
|
||||
css_class: "makeup",
|
||||
highlight_css_class: "hll"
|
||||
]
|
||||
)
|
||||
|> to_string()
|
||||
)
|
||||
end
|
||||
|
||||
defp highlight_code(code, nil) do
|
||||
escape_text(code)
|
||||
end
|
||||
|
||||
defp get_lexer_for_language(language) do
|
||||
case language do
|
||||
"elixir" -> Makeup.Lexers.ElixirLexer
|
||||
"erlang" -> Makeup.Lexers.ErlangLexer
|
||||
"html" -> Makeup.Lexers.HtmlLexer
|
||||
"javascript" -> Makeup.Lexers.JsLexer
|
||||
"python" -> Makeup.Lexers.PythonLexer
|
||||
"ruby" -> Makeup.Lexers.RubyLexer
|
||||
"rust" -> Makeup.Lexers.RustLexer
|
||||
end
|
||||
end
|
||||
|
||||
def get_webp(gist) do
|
||||
svg = generate_preview(gist)
|
||||
tmp_svg_path = Path.join(System.tmp_dir(), "temp_#{:rand.uniform(999_999)}.svg")
|
||||
|
||||
Logger.debug(tmp_svg_path)
|
||||
|
||||
try do
|
||||
File.write!(tmp_svg_path, svg)
|
||||
|
||||
Logger.debug("wrote svg")
|
||||
|
||||
try do
|
||||
image =
|
||||
tmp_svg_path
|
||||
|> Mogrify.open()
|
||||
|> Mogrify.custom("define", "svg:include-fonts=true")
|
||||
|> Mogrify.format("webp")
|
||||
|> Mogrify.save()
|
||||
|
||||
Logger.debug("wrote image #{image.path}")
|
||||
|
||||
{:ok, image_binary} = File.read(image.path)
|
||||
|
||||
Logger.debug("returning binary")
|
||||
|
||||
{:ok, image_binary}
|
||||
rescue
|
||||
e in Mogrify.Error ->
|
||||
{
|
||||
:error,
|
||||
"Conversion failed: #{Exception.message(e)}"
|
||||
}
|
||||
end
|
||||
rescue
|
||||
e -> {:error, "File operation failed: #{Exception.message(e)}"}
|
||||
after
|
||||
File.rm(tmp_svg_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
125
lib/zoeyscomputer_web/live/gist_live/select.ex
Normal file
125
lib/zoeyscomputer_web/live/gist_live/select.ex
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
defmodule ZoeyscomputerWeb.GistLive.Select do
|
||||
use Phoenix.Component
|
||||
|
||||
import Phoenix.HTML.Form, only: [input_value: 2]
|
||||
|
||||
attr :id, :string, required: true
|
||||
attr :form, :any, required: true
|
||||
attr :field, :atom, required: true
|
||||
attr :options, :list, required: true
|
||||
attr :label, :string, default: nil
|
||||
attr :prompt, :string, default: "Select an option..."
|
||||
attr :class, :string, default: nil
|
||||
attr :disabled, :boolean, default: false
|
||||
|
||||
def select(assigns) do
|
||||
selected_value = input_value(assigns.form, assigns.field)
|
||||
|
||||
assigns = assign(assigns, :selected_value, selected_value)
|
||||
|
||||
~H"""
|
||||
<div class="relative" id={@id <> "-container"} phx-hook="SelectHook">
|
||||
<label :if={@label} for={@id} class="block text-sm font-bold mb-2 text-ctp-overlay1">
|
||||
<%= @label %>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id={@id <> "-trigger"}
|
||||
phx-update="ignore"
|
||||
class={[
|
||||
"relative w-full cursor-default rounded-md py-1.5 pl-3 pr-10 text-left",
|
||||
"bg-ctp-base text-ctp-text border-ctp-surface0 border",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ctp-ring-lavender",
|
||||
"transition-colors duration-200",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50"
|
||||
]}
|
||||
disabled={@disabled}
|
||||
>
|
||||
<span class="block truncate" id={@id <> "-selected"}>
|
||||
<%= selected_option(@options, @selected_value) || @prompt %>
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<svg class="h-5 w-5 ctp-text-overlay0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id={@id <> "-dropdown"}
|
||||
phx-update="ignore"
|
||||
class={
|
||||
[
|
||||
"absolute z-10 mt-1 w-full rounded-md py-1 shadow-lg",
|
||||
"bg-ctp-base border-ctp-surface0 border",
|
||||
# Initially hidden, toggled by JS
|
||||
"hidden"
|
||||
]
|
||||
}
|
||||
>
|
||||
<div class="px-3 py-2">
|
||||
<input
|
||||
type="text"
|
||||
id={@id <> "-search"}
|
||||
placeholder="Search..."
|
||||
class={[
|
||||
"w-full rounded-md py-1.5 px-3",
|
||||
"bg-ctp-mantle text-ctp-text placeholder-ctp-overlay0",
|
||||
"focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-ctp-lavender",
|
||||
"transition-colors duration-200"
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="max-h-60 overflow-auto" id={@id <> "-options"} role="listbox" tabindex="-1">
|
||||
<%= for {label, value} <- @options do %>
|
||||
<li
|
||||
class={[
|
||||
"relative cursor-default select-none py-2 pl-3 pr-9",
|
||||
"text-ctp-text hover:bg-ctp-surface0",
|
||||
"transition-colors duration-200"
|
||||
]}
|
||||
role="option"
|
||||
data-value={value}
|
||||
aria-selected={to_string(value) == to_string(@selected_value)}
|
||||
>
|
||||
<span class="block truncate"><%= label %></span>
|
||||
<%= if to_string(value) == to_string(@selected_value) do %>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-4 ctp-text-lavender">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="hidden"
|
||||
name={input_name(@form, @field)}
|
||||
id={input_id(@form, @field)}
|
||||
value={@selected_value}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp selected_option(_options, value) when is_nil(value), do: nil
|
||||
|
||||
defp selected_option(options, value) do
|
||||
case Enum.find(options, fn {_label, val} -> to_string(val) == to_string(value) end) do
|
||||
{label, _value} -> label
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp input_id(%{id: id}, field) when is_atom(field), do: "#{id}_#{field}"
|
||||
defp input_name(%{name: name}, field), do: "#{name}[#{field}]"
|
||||
end
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule ZoeyscomputerWeb.GistLive.Show do
|
||||
use ZoeyscomputerWeb, :live_view
|
||||
import ZoeyscomputerWeb.CodeBlock
|
||||
|
||||
alias Zoeyscomputer.Gists
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<.header>
|
||||
Gist <%= @gist.id %>
|
||||
<%= @gist.title || "Gist: #{@gist.id}" %>
|
||||
<:subtitle>This is a gist record from your database.</:subtitle>
|
||||
<:actions>
|
||||
<.link patch={~p"/gists/#{@gist}/show/edit"} phx-click={JS.push_focus()}>
|
||||
|
|
@ -8,10 +8,21 @@
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Code"><%= @gist.code %></:item>
|
||||
<:item title="Lang"><%= @gist.lang %></:item>
|
||||
</.list>
|
||||
<meta property="og:title" content={@gist.title || "Gist: #{@gist.id}"} />
|
||||
<meta property="og:description" content={@gist.desc} />
|
||||
<meta property="og:image" content={url(~p"/gists/#{@gist.id}/preview")} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
|
||||
<.code_block
|
||||
class="mt-4"
|
||||
code={@gist.code}
|
||||
language={@gist.lang}
|
||||
title={@gist.title}
|
||||
line_numbers={true}
|
||||
highlighted_lines={[118, 119, 120]}
|
||||
/>
|
||||
|
||||
<.back navigate={~p"/gists"}>Back to gists</.back>
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ defmodule ZoeyscomputerWeb.Router do
|
|||
live "/users/confirm", UserConfirmationInstructionsLive, :new
|
||||
live "/", HomeLive, :index
|
||||
|
||||
get "/gists/:id/preview", GistPreviewController, :show
|
||||
get "/gists/:id/preview/raw", GistPreviewController, :raw
|
||||
|
||||
live "/images/:id", ImageLive.Show, :show
|
||||
live "/gists/:id", GistLive.Show, :show
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue