Skip to content

Instantly share code, notes, and snippets.

@Neophen
Last active June 8, 2023 15:31
Show Gist options
  • Save Neophen/f90176a17fad823d1957c7245d70beec to your computer and use it in GitHub Desktop.
Save Neophen/f90176a17fad823d1957c7245d70beec to your computer and use it in GitHub Desktop.
defmodule PentoWeb.UploadLive do
use PentoWeb, :live_view
# @bytes_for_5MB 5_242_880
@bytes_for_5MB 1_000_000
@impl Phoenix.LiveView
def mount(_params, _session, socket) do
socket =
socket
|> assign(:uploaded_files, [])
|> assign(:hidden_inputs, [])
|> allow_upload(:avatar,
accept: ~w(image/*),
max_entries: 1,
max_file_size: @bytes_for_5MB,
auto_upload: true,
external: &presign_upload/2
)
{:ok, socket}
end
@impl Phoenix.LiveView
def render(assigns) do
~H"""
<form id="upload-form" phx-submit="save" phx-change="validate">
<input :for={{id, value} <- @hidden_inputs} type="hidden" id={id} name={id} value={value} />
<div class="relative max-w-xl">
<.live_file_input upload={@uploads.avatar} class="hidden" />
<div class="min-h-max w-full">
<label
:if={@uploads.avatar.entries == [] && @uploaded_files == []}
for={@uploads.avatar.ref}
phx-drop-target={@uploads.avatar.ref}
class="relative flex aspect-square w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-600 bg-gray-100 px-6 transition-colors hover:bg-gray-200"
>
<div class="flex gap-1 text-gray-600">
<.icon name="hero-camera" class="h-6 w-6" />
<div class="grid gap-1">
<p class="text-body-1">Click to add a photo</p>
<p class="text-caption-2 text-gray-500">JPEG, PNG or TIFF (max. 5 MB)</p>
</div>
</div>
</label>
<.image_preview :for={entry <- @uploads.avatar.entries} entry={entry} live? />
<.image_preview :for={entry <- @uploaded_files} entry={entry} />
</div>
<div class="text-caption-2 mt-2 text-red-400 ">
<p :for={err <- upload_errors(@uploads.avatar)}><%= error_to_string(err) %></p>
<%= for entry <- @uploads.avatar.entries do %>
<p :for={err <- upload_errors(@uploads.avatar, entry)}><%= error_to_string(err) %></p>
<% end %>
</div>
</div>
<button type="submit" class="block p-4 border hover:bg-slate-200">Upload</button>
</form>
<.image_preview_modal :for={entry <- @uploads.avatar.entries} entry={entry} live? />
<.image_preview_modal :for={entry <- @uploaded_files} entry={entry} />
"""
end
@impl Phoenix.LiveView
def handle_event("validate", params, socket) do
hidden_inputs =
socket.assigns.hidden_inputs
|> Enum.map(fn {id, value} -> {id, Map.get(params, id) || value} end)
socket = assign(socket, :hidden_inputs, hidden_inputs)
{:noreply, socket}
end
def handle_event("remove-entry", %{"value" => ref, "live?" => true}, socket) do
socket = cancel_upload(socket, :avatar, ref)
{:noreply, socket}
end
def handle_event("remove-entry", %{"value" => ref}, socket) do
# remove from the service?
socket = assign(socket, :uploaded_files, [])
{:noreply, socket}
end
def handle_event("save", params, socket) do
uploaded_files =
consume_uploaded_entries(socket, :avatar, fn meta, entry ->
meta = params |> Map.get(meta.input_id) |> Jason.decode!()
uuid = Map.get(meta, "uuid")
entry = %{
url: Map.get(meta, "cdnUrl"),
ref: uuid,
uuid: uuid,
progress: 100
}
{:ok, entry}
end)
socket = assign(socket, :uploaded_files, uploaded_files)
{:noreply, socket}
end
# Helpers __________________________________________________________________________________________________________
defp presign_upload(entry, socket) do
input_id = "input-" <> entry.uuid
meta = %{uploader: "Uploadcare", input_id: input_id}
hidden_inputs = (socket.assigns.hidden_inputs ++ [{input_id, ""}]) |> Enum.uniq()
socket = assign(socket, :hidden_inputs, hidden_inputs)
{:ok, meta, socket}
end
def error_to_string(:too_large), do: "Too large"
def error_to_string(:not_accepted), do: "You have selected an unacceptable file type"
def error_to_string(:too_many_files), do: "You have selected too many files"
# Components
attr :entry, :map, required: true
attr :live?, :boolean, default: false
defp image_preview(assigns) do
~H"""
<div class="w-full aspect-square relative">
<.live_img_preview :if={@live?} entry={@entry} class="h-full w-full rounded-md object-cover" />
<img
:if={not @live?}
src={@entry.url <> "-/smart_resize/300x300/"}
key={"preview-#{@entry.uuid}"}
class="h-full w-full rounded-md object-cover"
/>
<button
type="button"
onclick={"document.getElementById('#{@entry.uuid}').showModal()"}
class="absolute inset-0 w-full h-full bg-gradient-to-b from-black/50 hover:from-indigo-400 rounded-md transition-colors"
>
</button>
<div :if={@entry.progress < 100} class="absolute inset-0 w-full h-full bg-black/50">
<div
class="h-full bg-teal-400/50 transition-transform scale-x-0 will-change-transform origin-left"
style={"--tw-scale-x: #{@entry.progress * 0.01}"}
>
</div>
</div>
<button
type="button"
phx-click={JS.push("remove-entry", value: %{value: @entry.ref, live?: @live?})}
aria-label="delete"
class="absolute right-0 top-0 m-2"
>
<div class="text-geraldine-400 flex h-12 w-12 rounded bg-white items-center justify-center hover:bg-gray-100">
<.icon name="hero-trash" class="h-6 w-6" />
</div>
</button>
</div>
"""
end
attr :entry, :map, required: true
attr :live?, :boolean, default: false
defp image_preview_modal(assigns) do
~H"""
<dialog
id={@entry.uuid}
class="hidden open:block backdrop:bg-[#150A2DCC] bg-transparent max-w-2xl w-full p-4 sm:p-6"
>
<.live_img_preview
:if={@live?}
id={"modal-#{@entry.uuid}"}
entry={@entry}
class="w-full rounded-md"
/>
<img :if={not @live?} src={@entry.url <> "-/smart_resize/1200x1200/"} class="w-full rounded-md" />
<button
type="button"
onclick={"document.getElementById('#{@entry.uuid}').close()"}
class="fixed inset-0 w-full h-full"
>
</button>
</dialog>
"""
end
end
import { UploadClient } from '@uploadcare/upload-client'
const client = new UploadClient({ publicKey: "somekey" })
const markCompleted = (uploadedEntry) => {
uploadedEntry.progress(100)
}
const Uploadcare = (entries, onViewError) => {
const uploadEntry = makeEntryUploader(onViewError)
entries.forEach(uploadEntry)
}
const makeEntryUploader = (onViewError) => async (entry) => {
const abortController = new AbortController()
onViewError(() => abortController.abort())
const { file, meta } = entry
const onProgress = ({ isComputable, value }) => {
if (isComputable) {
let percent = Math.round(value * 100)
// It's important to not set progress to 100 as that completes the upload
if (percent < 100) { entry.progress(percent) }
}
}
const result = await client.uploadFile(file, {
onProgress,
signal: abortController.signal,
})
markCompleted(entry)
// Fire off event to update the hidden input so
// that we can grab the meta data server side
meta_input = document.getElementById(meta.input_id)
const meta_data = JSON.stringify(result)
meta_input.value = meta_data
meta_input.dispatchEvent(new Event('input', { bubbles: true }));
}
const uploaders = {
Uploadcare,
}
export default uploaders
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment