defmodule DiogramosWeb.CanvasLive.Index do
use DiogramosWeb, :live_view
alias Diogramos.Diagrams
alias Diogramos.Diagrams.{Canvas, Folder}
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
{:ok,
socket
|> assign(:page_title, "Your canvases")
|> assign(:selected_folder_id, nil)
|> assign(:new_canvas_form, build_canvas_form())
|> assign(:new_folder_form, build_folder_form())
|> assign(:show_canvas_modal, false)
|> assign(:show_folder_modal, false)
|> assign(:show_import_modal, false)
|> assign(:import_error, nil)
|> assign(:manage_canvas, nil)
|> assign(:manage_grants, [])
|> assign(:manage_error, nil)
|> assign(:delete_confirm, "")
|> assign(:manage_folder, nil)
|> assign(:folder_error, nil)
|> allow_upload(:canvas_import,
accept: ~w(.json application/json),
max_entries: 1,
max_file_size: 5_000_000
)
|> stream(:canvases, Diagrams.list_canvases(scope))
|> stream(:folders, Diagrams.list_folders(scope))}
end
@impl true
def handle_event("filter_folder", %{"folder_id" => "all"}, socket) do
scope = socket.assigns.current_scope
{:noreply,
socket
|> assign(:selected_folder_id, nil)
|> stream(:canvases, Diagrams.list_canvases(scope), reset: true)}
end
def handle_event("filter_folder", %{"folder_id" => folder_id}, socket) do
scope = socket.assigns.current_scope
{id, _} = Integer.parse(folder_id)
{:noreply,
socket
|> assign(:selected_folder_id, id)
|> stream(:canvases, Diagrams.list_canvases_in_folder(scope, id), reset: true)}
end
def handle_event("toggle_canvas_modal", _, socket) do
{:noreply, update(socket, :show_canvas_modal, &(!&1))}
end
def handle_event("toggle_folder_modal", _, socket) do
{:noreply, update(socket, :show_folder_modal, &(!&1))}
end
def handle_event("create_canvas", %{"canvas" => params}, socket) do
scope = socket.assigns.current_scope
params =
params
|> Map.put_new("theme", "afterdark")
|> with_selected_folder(socket.assigns.selected_folder_id)
case Diagrams.create_canvas(scope, params) do
{:ok, canvas} ->
{:noreply,
socket
|> assign(:show_canvas_modal, false)
|> assign(:new_canvas_form, build_canvas_form())
|> stream_insert(:canvases, canvas, at: 0)
|> push_navigate(to: ~p"/c/#{canvas.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :new_canvas_form, to_form(changeset, as: :canvas))}
end
end
def handle_event("toggle_import_modal", _, socket) do
{:noreply, socket |> update(:show_import_modal, &(!&1)) |> assign(:import_error, nil)}
end
def handle_event("validate_import", _params, socket), do: {:noreply, socket}
def handle_event("submit_import", _, socket) do
scope = socket.assigns.current_scope
case consume_uploaded_entries(socket, :canvas_import, fn %{path: path}, _entry ->
with {:ok, raw} <- File.read(path),
{:ok, data} <- Jason.decode(raw),
{:ok, canvas} <- Diagrams.import_canvas(scope, data) do
{:ok, canvas}
else
{:error, _} = err -> {:postpone, err}
end
end) do
[%Diogramos.Diagrams.Canvas{} = canvas] ->
{:noreply,
socket
|> assign(:show_import_modal, false)
|> stream_insert(:canvases, canvas, at: 0)
|> push_navigate(to: ~p"/c/#{canvas.slug}")}
[{:error, %Ecto.Changeset{} = changeset}] ->
{:noreply,
assign(socket, :import_error, "Could not import: #{inspect(changeset.errors)}")}
[{:error, :invalid_format}] ->
{:noreply,
assign(
socket,
:import_error,
"That file isn't a Diogramos canvas export."
)}
[{:error, %Jason.DecodeError{}}] ->
{:noreply, assign(socket, :import_error, "That file isn't valid JSON.")}
_ ->
{:noreply, assign(socket, :import_error, "Could not read upload.")}
end
end
def handle_event("open_manage_modal", %{"id" => id}, socket) do
scope = socket.assigns.current_scope
{canvas_id, _} = Integer.parse(id)
canvas = Diagrams.get_canvas!(scope, canvas_id)
if canvas.owner_id == scope.user.id do
{:noreply,
socket
|> assign(:manage_canvas, canvas)
|> assign(:manage_grants, Diagrams.list_user_grants("canvas", canvas.id))
|> assign(:manage_error, nil)
|> assign(:delete_confirm, "")}
else
{:noreply, put_flash(socket, :error, "Only the owner can manage collaborators.")}
end
end
def handle_event("close_manage_modal", _, socket) do
{:noreply,
socket
|> assign(:manage_canvas, nil)
|> assign(:manage_grants, [])
|> assign(:manage_error, nil)
|> assign(:delete_confirm, "")}
end
def handle_event("validate_delete", %{"confirm" => confirm}, socket) do
{:noreply, assign(socket, :delete_confirm, confirm)}
end
# The typed confirmation is checked here, not only by the disabled
# button: the button is a convenience, and the event can be sent
# without it.
def handle_event("delete_canvas", %{"confirm" => confirm}, socket) do
scope = socket.assigns.current_scope
case socket.assigns.manage_canvas do
nil ->
# No canvas open: nothing to delete, and nothing to say about it.
{:noreply, socket}
canvas ->
if String.trim(confirm) == canvas.slug do
delete_managed_canvas(socket, scope, canvas)
else
{:noreply,
assign(socket, :manage_error, "Type #{canvas.slug} exactly to confirm the delete.")}
end
end
end
def handle_event("grant_user", %{"email" => email, "role" => role}, socket)
when role in ["viewer", "editor"] do
canvas = socket.assigns.manage_canvas
scope = socket.assigns.current_scope
email = email |> to_string() |> String.trim()
cond do
canvas == nil ->
{:noreply, socket}
email == "" ->
{:noreply, assign(socket, :manage_error, "Enter an email address.")}
true ->
case Diogramos.Accounts.get_user_by_email(email) do
nil ->
{:noreply, assign(socket, :manage_error, "No registered user with that email.")}
user when user.id == canvas.owner_id ->
{:noreply, assign(socket, :manage_error, "The owner already has access.")}
user ->
{:ok, _} =
Diagrams.grant_permission(
"canvas",
canvas.id,
"user",
user.id,
role,
granted_by_id: scope.user.id
)
{:noreply,
socket
|> assign(:manage_grants, Diagrams.list_user_grants("canvas", canvas.id))
|> assign(:manage_error, nil)}
end
end
end
def handle_event("update_canvas_theme", %{"theme" => theme}, socket) do
canvas = socket.assigns.manage_canvas
scope = socket.assigns.current_scope
if canvas == nil do
{:noreply, socket}
else
case Diagrams.update_canvas_metadata(scope, canvas, %{
"title" => canvas.title,
"theme" => theme
}) do
{:ok, updated} ->
{:noreply,
socket
|> assign(:manage_canvas, updated)
|> stream_insert(:canvases, updated)
|> put_flash(:info, "Theme updated.")}
{:error, _} ->
{:noreply, assign(socket, :manage_error, "Could not update theme.")}
end
end
end
def handle_event("revoke_user", %{"user_id" => user_id}, socket) do
canvas = socket.assigns.manage_canvas
{uid, _} = Integer.parse(user_id)
if canvas do
Diagrams.revoke_permission("canvas", canvas.id, "user", uid)
{:noreply, assign(socket, :manage_grants, Diagrams.list_user_grants("canvas", canvas.id))}
else
{:noreply, socket}
end
end
def handle_event("open_folder_manage", %{"id" => id}, socket) do
scope = socket.assigns.current_scope
{folder_id, _} = Integer.parse(id)
folder = Diagrams.get_folder!(scope, folder_id)
{:noreply,
socket
|> assign(:manage_folder, folder)
|> assign(:folder_error, nil)}
end
def handle_event("close_folder_manage", _, socket) do
{:noreply,
socket
|> assign(:manage_folder, nil)
|> assign(:folder_error, nil)}
end
def handle_event("rename_folder", %{"name" => name}, socket) do
folder = socket.assigns.manage_folder
scope = socket.assigns.current_scope
case Diagrams.rename_folder(scope, folder, String.trim(name)) do
{:ok, renamed} ->
{:noreply,
socket
|> stream_insert(:folders, renamed)
|> assign(:manage_folder, nil)
|> assign(:folder_error, nil)
|> put_flash(:info, "Renamed to #{renamed.name}.")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :folder_error, changeset_message(changeset))}
{:error, :forbidden} ->
{:noreply, assign(socket, :folder_error, "Only the owner can rename this folder.")}
end
end
def handle_event("delete_folder", _params, socket) do
scope = socket.assigns.current_scope
case socket.assigns.manage_folder do
nil ->
{:noreply, socket}
folder ->
case Diagrams.delete_folder(scope, folder) do
{:ok, _deleted} ->
{:noreply,
socket
|> stream_delete(:folders, folder)
|> stream(:folders, Diagrams.list_folders(scope), reset: true)
|> assign(:manage_folder, nil)
|> assign(:folder_error, nil)
# The canvases that were inside now belong to no folder, and
# the filter they were being viewed under is gone with it.
|> assign(:selected_folder_id, nil)
|> stream(:canvases, Diagrams.list_canvases(scope), reset: true)
|> put_flash(
:info,
"Deleted #{folder.name}. Its canvases are in All canvases."
)}
{:error, :forbidden} ->
{:noreply, assign(socket, :folder_error, "Only the owner can delete this folder.")}
{:error, _reason} ->
{:noreply, assign(socket, :folder_error, "Couldn't delete that folder.")}
end
end
end
def handle_event("create_folder", %{"folder" => params}, socket) do
scope = socket.assigns.current_scope
case Diagrams.create_folder(scope, params) do
{:ok, folder} ->
{:noreply,
socket
|> assign(:show_folder_modal, false)
|> assign(:new_folder_form, build_folder_form())
|> stream_insert(:folders, folder, at: 0)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :new_folder_form, to_form(changeset, as: :folder))}
end
end
defp build_canvas_form do
%Canvas{}
|> Canvas.create_changeset(%{})
|> to_form(as: :canvas)
end
defp build_folder_form do
%Folder{}
|> Folder.changeset(%{})
|> to_form(as: :folder)
end
defp with_selected_folder(params, nil), do: params
defp with_selected_folder(params, id), do: Map.put_new(params, "folder_id", id)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} wide>
<div
class="grid grid-cols-[16rem_1fr] gap-8 px-6 lg:px-10 py-6 w-full"
id="canvases-page"
>
<aside class="border border-base-300 rounded-box p-3 bg-base-200 h-fit">
<div class="flex items-center justify-between mb-2">
<h2 class="font-mono text-xs uppercase tracking-wide text-accent">Folders</h2>
<button
type="button"
phx-click="toggle_folder_modal"
class="btn btn-ghost btn-xs"
aria-label="New folder"
id="btn-new-folder"
>
<.icon name="hero-folder-plus-micro" class="size-4" />
</button>
</div>
<ul class="menu menu-sm w-full p-0">
<li>
<button
type="button"
phx-click="filter_folder"
phx-value-folder_id="all"
class={[@selected_folder_id == nil && "menu-active"]}
>
<.icon name="hero-inbox-micro" class="size-4" /> All canvases
</button>
</li>
</ul>
<%!-- Its own list: a phx-update="stream" container may hold
nothing but stream items, and without the attribute the
comprehension renders only what is in the stream buffer
-- which is empty after the first diff, so the folders
vanished on the next update until a reload. --%>
<ul class="menu menu-sm w-full p-0" id="folder-list" phx-update="stream">
<li
:for={{dom_id, folder} <- @streams.folders}
id={dom_id}
class="flex flex-row items-center"
>
<button
type="button"
phx-click="filter_folder"
phx-value-folder_id={folder.id}
class={["flex-1 min-w-0", @selected_folder_id == folder.id && "menu-active"]}
>
<.icon name="hero-folder-micro" class="size-4" />
<span class="truncate">{folder.name}</span>
</button>
<button
type="button"
phx-click="open_folder_manage"
phx-value-id={folder.id}
class="btn btn-ghost btn-xs"
aria-label={"Manage #{folder.name}"}
>
<.icon name="hero-cog-6-tooth-micro" class="size-4" />
</button>
</li>
</ul>
</aside>
<section>
<header class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">{@page_title}</h1>
<div class="flex items-center gap-2">
<button
type="button"
phx-click="toggle_import_modal"
class="btn btn-ghost btn-sm"
id="btn-import-canvas"
>
<.icon name="hero-arrow-up-tray-micro" class="size-4" /> Import
</button>
<button
type="button"
phx-click="toggle_canvas_modal"
class="btn btn-primary btn-sm"
id="btn-new-canvas"
>
<.icon name="hero-plus-micro" class="size-4" /> New canvas
</button>
</div>
</header>
<ul
id="canvases"
phx-update="stream"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-5"
>
<li
id="canvases-empty"
class="hidden only:block col-span-full text-center text-base-content/60 py-8"
>
No canvases yet. Click <em class="not-italic font-semibold">New canvas</em>
to create your first one.
</li>
<li
:for={{dom_id, canvas} <- @streams.canvases}
id={dom_id}
data-theme={canvas.theme}
class="border border-base-300 rounded-box bg-base-200 text-base-content overflow-hidden flex flex-col hover:shadow-xl transition min-h-[12rem]"
>
<.link
navigate={~p"/c/#{canvas.slug}"}
class="flex-1 flex flex-col p-5 gap-1 bg-base-100 hover:bg-base-300 transition"
>
<span class="text-lg font-semibold truncate">{canvas.title}</span>
<span class="text-sm font-mono text-accent truncate">{canvas.slug}</span>
<span class="mt-auto text-xs text-base-content/60 pt-2">
v{canvas.version} · {Calendar.strftime(canvas.updated_at, "%Y-%m-%d")}
</span>
</.link>
<div class="px-4 py-2 flex items-center justify-end gap-1 bg-base-200 border-t border-base-300">
<%= if canvas.owner_id == @current_scope.user.id do %>
<button
type="button"
phx-click="open_manage_modal"
phx-value-id={canvas.id}
class="btn btn-ghost btn-xs"
title="Manage collaborators"
>
<.icon name="hero-user-group-micro" class="size-4" /> Manage
</button>
<% end %>
</div>
</li>
</ul>
</section>
</div>
<%= if @show_canvas_modal do %>
<div
class="modal modal-open"
id="new-canvas-modal"
phx-window-keydown="toggle_canvas_modal"
phx-key="escape"
>
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">New canvas</h3>
<.form
for={@new_canvas_form}
id="new-canvas-form"
phx-submit="create_canvas"
class="flex flex-col gap-3"
>
<.input field={@new_canvas_form[:title]} type="text" label="Title" required />
<.input
field={@new_canvas_form[:slug]}
type="text"
label="Slug"
placeholder="my-canvas"
required
/>
<.input
field={@new_canvas_form[:theme]}
type="select"
label="Theme"
options={Diogramos.Themes.all()}
/>
<div class="modal-action">
<button type="button" phx-click="toggle_canvas_modal" class="btn btn-ghost">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</.form>
</div>
</div>
<% end %>
<%= if @show_folder_modal do %>
<div
class="modal modal-open"
id="new-folder-modal"
phx-window-keydown="toggle_folder_modal"
phx-key="escape"
>
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">New folder</h3>
<.form
for={@new_folder_form}
id="new-folder-form"
phx-submit="create_folder"
class="flex flex-col gap-3"
>
<.input field={@new_folder_form[:name]} type="text" label="Name" required />
<div class="modal-action">
<button type="button" phx-click="toggle_folder_modal" class="btn btn-ghost">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</.form>
</div>
</div>
<% end %>
<%= if @show_import_modal do %>
<div
class="modal modal-open"
id="import-canvas-modal"
phx-window-keydown="toggle_import_modal"
phx-key="escape"
>
<div class="modal-box">
<h3 class="font-bold text-lg mb-2">Import canvas</h3>
<p class="text-sm opacity-70 mb-3">
Upload a JSON file previously exported from another canvas.
</p>
<form id="import-canvas-form" phx-change="validate_import" phx-submit="submit_import">
<.live_file_input
upload={@uploads.canvas_import}
class="file-input file-input-bordered w-full"
/>
<div :for={entry <- @uploads.canvas_import.entries} class="text-xs mt-2">
<span class="font-mono">{entry.client_name}</span>
<progress value={entry.progress} max="100" class="w-full" />
<p :for={err <- upload_errors(@uploads.canvas_import, entry)} class="text-error">
{error_to_string(err)}
</p>
</div>
<p :if={@import_error} class="text-error text-sm mt-2">{@import_error}</p>
<div class="modal-action">
<button type="button" phx-click="toggle_import_modal" class="btn btn-ghost">
Cancel
</button>
<button
type="submit"
class="btn btn-primary"
disabled={@uploads.canvas_import.entries == []}
>
Import
</button>
</div>
</form>
</div>
</div>
<% end %>
<%= if @manage_folder do %>
<div
class="modal modal-open"
id="manage-folder-modal"
phx-window-keydown="close_folder_manage"
phx-key="escape"
>
<div class="modal-box max-w-md">
<h3 class="font-bold text-lg mb-1">Manage folder</h3>
<p class="text-sm opacity-70 mb-4">{@manage_folder.name}</p>
<form phx-submit="rename_folder" id="folder-rename-form" class="flex flex-col gap-2">
<label class="font-mono text-xs uppercase tracking-wider text-accent">Name</label>
<div class="flex gap-2">
<input
type="text"
name="name"
value={@manage_folder.name}
class="input input-bordered input-sm flex-1"
required
/>
<button type="submit" class="btn btn-primary btn-sm">Rename</button>
</div>
<p :if={@folder_error} class="text-error text-xs">{@folder_error}</p>
</form>
<div class="divider mt-6 mb-2" />
<h4 class="font-mono text-xs uppercase tracking-wider text-error mb-2">
Danger zone
</h4>
<p class="text-sm opacity-80 mb-2">
Deleting this folder keeps everything in it: its canvases move to
All canvases, and any folders nested inside move up a level.
</p>
<button
type="button"
phx-click="delete_folder"
data-confirm={"Delete #{@manage_folder.name}? Its canvases move to All canvases."}
class="btn btn-error btn-sm"
>
Delete folder
</button>
<div class="modal-action">
<button type="button" phx-click="close_folder_manage" class="btn btn-ghost">
Done
</button>
</div>
</div>
</div>
<% end %>
<%= if @manage_canvas do %>
<div
class="modal modal-open"
id="manage-canvas-modal"
phx-window-keydown="close_manage_modal"
phx-key="escape"
>
<div class="modal-box max-w-lg">
<h3 class="font-bold text-lg mb-1">Manage collaborators</h3>
<p class="text-sm opacity-70 mb-4">
<span class="font-semibold">{@manage_canvas.title}</span>
<span class="font-mono text-xs text-accent ml-2">{@manage_canvas.slug}</span>
</p>
<h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">Theme</h4>
<form phx-change="update_canvas_theme" class="mb-4">
<div class="grid grid-cols-3 gap-2">
<label
:for={theme <- Diogramos.Themes.all()}
data-theme={theme}
class={[
"border rounded-box p-3 cursor-pointer flex flex-col items-center gap-1 bg-base-100 text-base-content transition",
@manage_canvas.theme == theme && "ring-2 ring-accent border-accent",
@manage_canvas.theme != theme && "border-base-300 hover:border-base-content/40"
]}
>
<input
type="radio"
name="theme"
value={theme}
checked={@manage_canvas.theme == theme}
class="sr-only"
/>
<div class="flex gap-1">
<span class="size-3 rounded-full bg-primary" />
<span class="size-3 rounded-full bg-secondary" />
<span class="size-3 rounded-full bg-accent" />
</div>
<span class="text-[10px] font-mono uppercase tracking-wider opacity-70">
{theme}
</span>
</label>
</div>
</form>
<h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">
People with access
</h4>
<ul class="flex flex-col gap-1 mb-4">
<li class="flex items-center justify-between text-sm border border-base-300 rounded-box px-3 py-2 bg-base-100">
<div class="min-w-0">
<span class="font-mono truncate">{@current_scope.user.email}</span>
<span class="text-xs opacity-60 block">you (owner)</span>
</div>
<span class="badge badge-accent">owner</span>
</li>
<li
:for={grant <- @manage_grants}
class="flex items-center justify-between text-sm border border-base-300 rounded-box px-3 py-2"
>
<div class="min-w-0">
<span class="font-mono truncate">
{(grant.user && grant.user.email) || "Anonymous"}
</span>
<span class="text-xs opacity-60 block">{grant.role}</span>
</div>
<button
type="button"
phx-click="revoke_user"
phx-value-user_id={grant.principal_id}
class="btn btn-ghost btn-xs text-error"
title="Revoke access"
>
<.icon name="hero-x-mark-micro" class="size-4" />
</button>
</li>
</ul>
<h4 class="font-mono text-xs uppercase tracking-wider text-accent mb-2">
Add collaborator
</h4>
<form phx-submit="grant_user" class="flex flex-col gap-2" id="manage-grant-form">
<div class="flex gap-2">
<input
type="email"
name="email"
placeholder="someone@example.com"
class="input input-bordered input-sm flex-1"
required
/>
<select
name="role"
class="select select-bordered select-sm"
>
<option value="viewer">Viewer</option>
<option value="editor" selected>Editor</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</div>
<%= if @manage_error do %>
<p class="text-error text-xs">{@manage_error}</p>
<% end %>
</form>
<div class="divider mt-6 mb-2" />
<h4 class="font-mono text-xs uppercase tracking-wider text-error mb-2">
Danger zone
</h4>
<form
phx-submit="delete_canvas"
phx-change="validate_delete"
id="manage-delete-form"
class="flex flex-col gap-2"
>
<p class="text-sm opacity-80">
Deleting removes this canvas, everything drawn on it, and everyone else's
access to it. It cannot be undone.
</p>
<p class="text-sm">
Type <span class="font-mono text-error">{@manage_canvas.slug}</span> to confirm.
</p>
<div class="flex gap-2">
<input
type="text"
name="confirm"
value={@delete_confirm}
placeholder={@manage_canvas.slug}
autocomplete="off"
spellcheck="false"
class="input input-bordered input-sm flex-1 font-mono"
/>
<button
type="submit"
class="btn btn-error btn-sm"
disabled={String.trim(@delete_confirm) != @manage_canvas.slug}
>
Delete canvas
</button>
</div>
</form>
<div class="modal-action">
<button type="button" phx-click="close_manage_modal" class="btn btn-ghost">Done</button>
</div>
</div>
</div>
<% end %>
</Layouts.app>
"""
end
defp delete_managed_canvas(socket, scope, canvas) do
case Diagrams.delete_canvas(scope, canvas) do
{:ok, _deleted} ->
{:noreply,
socket
|> stream_delete(:canvases, canvas)
|> assign(:manage_canvas, nil)
|> assign(:manage_grants, [])
|> assign(:manage_error, nil)
|> assign(:delete_confirm, "")
|> put_flash(:info, "Deleted #{canvas.title}.")}
{:error, :forbidden} ->
{:noreply, assign(socket, :manage_error, "Only the owner can delete this canvas.")}
{:error, _reason} ->
{:noreply, assign(socket, :manage_error, "Couldn't delete that canvas.")}
end
end
defp changeset_message(changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end)
|> Enum.map(fn {field, messages} -> "#{field} #{Enum.join(messages, ", ")}" end)
|> Enum.join("; ")
end
defp error_to_string(:too_large), do: "File is too large."
defp error_to_string(:not_accepted), do: "File must be JSON."
defp error_to_string(:too_many_files), do: "Only one file at a time."
defp error_to_string(other), do: to_string(other)
end
neiam
/diogramos
diogramos
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
29.6 KiB · text
History
5ff2909