feat: datasheet upload and auto-retrieve
- store datasheet PDFs on the server - download PDF automatically when given a link
This commit is contained in:
137
lib/components_elixir/datasheet_downloader.ex
Normal file
137
lib/components_elixir/datasheet_downloader.ex
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
defmodule ComponentsElixir.DatasheetDownloader do
|
||||||
|
@moduledoc """
|
||||||
|
Module for downloading datasheet PDFs from URLs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Downloads a PDF from the given URL and saves it to the datasheets folder.
|
||||||
|
Returns {:ok, filename} on success or {:error, reason} on failure.
|
||||||
|
"""
|
||||||
|
def download_pdf_from_url(url) when is_binary(url) do
|
||||||
|
with {:ok, %URI{scheme: scheme}} when scheme in ["http", "https"] <- validate_url(url),
|
||||||
|
{:ok, filename} <- generate_filename(url),
|
||||||
|
{:ok, response} <- fetch_pdf(url),
|
||||||
|
:ok <- validate_pdf_content(response.body),
|
||||||
|
:ok <- save_file(filename, response.body) do
|
||||||
|
{:ok, filename}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
error -> {:error, "Unexpected error: #{inspect(error)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_pdf_from_url(_), do: {:error, "Invalid URL"}
|
||||||
|
|
||||||
|
defp validate_url(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: scheme} when scheme in ["http", "https"] ->
|
||||||
|
{:ok, URI.parse(url)}
|
||||||
|
_ ->
|
||||||
|
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_filename(url) do
|
||||||
|
# Try to extract a meaningful filename from the URL
|
||||||
|
uri = URI.parse(url)
|
||||||
|
original_filename =
|
||||||
|
case Path.basename(uri.path || "") do
|
||||||
|
"" -> "datasheet"
|
||||||
|
basename ->
|
||||||
|
# Remove extension and sanitize
|
||||||
|
basename
|
||||||
|
|> Path.rootname()
|
||||||
|
|> sanitize_filename()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a unique filename with timestamp
|
||||||
|
timestamp = System.unique_integer([:positive])
|
||||||
|
filename = "#{timestamp}_#{original_filename}.pdf"
|
||||||
|
{:ok, filename}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_filename(filename) do
|
||||||
|
filename
|
||||||
|
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores
|
||||||
|
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single
|
||||||
|
|> String.trim("_") # Remove leading/trailing underscores
|
||||||
|
|> String.slice(0, 50) # Limit length
|
||||||
|
|> case do
|
||||||
|
"" -> "datasheet"
|
||||||
|
name -> name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_pdf(url) do
|
||||||
|
case Req.get(url,
|
||||||
|
redirect: true,
|
||||||
|
max_redirects: 5,
|
||||||
|
receive_timeout: 30_000,
|
||||||
|
headers: [
|
||||||
|
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
|
||||||
|
]
|
||||||
|
) do
|
||||||
|
{:ok, %Req.Response{status: 200} = response} ->
|
||||||
|
{:ok, response}
|
||||||
|
{:ok, %Req.Response{status: status}} ->
|
||||||
|
{:error, "HTTP error: #{status}"}
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
|
||||||
|
{:error, "Download failed: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_pdf_content(body) do
|
||||||
|
# Check if the response body looks like a PDF (starts with %PDF)
|
||||||
|
case body do
|
||||||
|
<<"%PDF", _rest::binary>> ->
|
||||||
|
:ok
|
||||||
|
_ ->
|
||||||
|
{:error, "Downloaded content is not a valid PDF file"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp save_file(filename, content) do
|
||||||
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
|
datasheets_dir = Path.join([uploads_dir, "datasheets"])
|
||||||
|
file_path = Path.join(datasheets_dir, filename)
|
||||||
|
|
||||||
|
# Ensure the datasheets directory exists
|
||||||
|
case File.mkdir_p(datasheets_dir) do
|
||||||
|
:ok ->
|
||||||
|
case File.write(file_path, content) do
|
||||||
|
:ok ->
|
||||||
|
Logger.info("Successfully saved datasheet: #{filename}")
|
||||||
|
:ok
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to save datasheet file: #{inspect(reason)}")
|
||||||
|
{:error, "Failed to save file: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
|
||||||
|
{:error, "Failed to create directory: #{inspect(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a datasheet file from the filesystem.
|
||||||
|
"""
|
||||||
|
def delete_datasheet_file(nil), do: :ok
|
||||||
|
def delete_datasheet_file(""), do: :ok
|
||||||
|
|
||||||
|
def delete_datasheet_file(filename) do
|
||||||
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
|
file_path = Path.join([uploads_dir, "datasheets", filename])
|
||||||
|
|
||||||
|
case File.rm(file_path) do
|
||||||
|
:ok ->
|
||||||
|
Logger.info("Deleted datasheet file: #{filename}")
|
||||||
|
:ok
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -320,6 +320,30 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a component and downloads datasheet from URL if provided.
|
||||||
|
"""
|
||||||
|
def create_component_with_datasheet(attrs \\ %{}) do
|
||||||
|
# If a datasheet_url is provided, download it
|
||||||
|
updated_attrs =
|
||||||
|
case Map.get(attrs, "datasheet_url") do
|
||||||
|
url when is_binary(url) and url != "" ->
|
||||||
|
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||||
|
{:ok, filename} ->
|
||||||
|
Map.put(attrs, "datasheet_filename", filename)
|
||||||
|
{:error, _reason} ->
|
||||||
|
# Continue without datasheet file if download fails
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
_ ->
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
%Component{}
|
||||||
|
|> Component.changeset(updated_attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates a component.
|
Updates a component.
|
||||||
"""
|
"""
|
||||||
@@ -329,6 +353,34 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Updates a component and downloads datasheet from URL if provided.
|
||||||
|
"""
|
||||||
|
def update_component_with_datasheet(%Component{} = component, attrs) do
|
||||||
|
# If a datasheet_url is provided and changed, download it
|
||||||
|
updated_attrs =
|
||||||
|
case Map.get(attrs, "datasheet_url") do
|
||||||
|
url when is_binary(url) and url != "" and url != component.datasheet_url ->
|
||||||
|
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||||
|
{:ok, filename} ->
|
||||||
|
# Delete old datasheet file if it exists
|
||||||
|
if component.datasheet_filename do
|
||||||
|
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename)
|
||||||
|
end
|
||||||
|
Map.put(attrs, "datasheet_filename", filename)
|
||||||
|
{:error, _reason} ->
|
||||||
|
# Keep existing filename if download fails
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
_ ->
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
component
|
||||||
|
|> Component.changeset(updated_attrs)
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes a component.
|
Deletes a component.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
field :legacy_position, :string
|
field :legacy_position, :string
|
||||||
field :count, :integer, default: 0
|
field :count, :integer, default: 0
|
||||||
field :datasheet_url, :string
|
field :datasheet_url, :string
|
||||||
|
field :datasheet_filename, :string
|
||||||
field :image_filename, :string
|
field :image_filename, :string
|
||||||
|
|
||||||
belongs_to :category, Category
|
belongs_to :category, Category
|
||||||
@@ -29,7 +30,7 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
@doc false
|
@doc false
|
||||||
def changeset(component, attrs) do
|
def changeset(component, attrs) do
|
||||||
component
|
component
|
||||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :image_filename, :category_id, :storage_location_id])
|
||||||
|> validate_required([:name, :category_id])
|
|> validate_required([:name, :category_id])
|
||||||
|> validate_length(:name, min: 1, max: 255)
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|> validate_length(:description, max: 2000)
|
|> validate_length(:description, max: 2000)
|
||||||
@@ -59,6 +60,14 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
|> cast(attrs, [:image_filename])
|
|> cast(attrs, [:image_filename])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating component datasheet.
|
||||||
|
"""
|
||||||
|
def datasheet_changeset(component, attrs) do
|
||||||
|
component
|
||||||
|
|> cast(attrs, [:datasheet_filename])
|
||||||
|
end
|
||||||
|
|
||||||
defp validate_url(changeset, field) do
|
defp validate_url(changeset, field) do
|
||||||
validate_change(changeset, field, fn ^field, url ->
|
validate_change(changeset, field, fn ^field, url ->
|
||||||
if url && url != "" do
|
if url && url != "" do
|
||||||
@@ -78,6 +87,12 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
||||||
def has_image?(_), do: false
|
def has_image?(_), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns true if the component has a datasheet file.
|
||||||
|
"""
|
||||||
|
def has_datasheet?(%__MODULE__{datasheet_filename: filename}) when is_binary(filename), do: true
|
||||||
|
def has_datasheet?(_), do: false
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the search text for this component.
|
Returns the search text for this component.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
defmodule ComponentsElixirWeb.FileController do
|
defmodule ComponentsElixirWeb.FileController do
|
||||||
use ComponentsElixirWeb, :controller
|
use ComponentsElixirWeb, :controller
|
||||||
|
|
||||||
def show(conn, %{"filename" => filename}) do
|
def show(conn, %{"filename" => encoded_filename}) do
|
||||||
# Security: only allow alphanumeric, dashes, underscores, and dots
|
case decode_and_validate_filename(encoded_filename) do
|
||||||
if String.match?(filename, ~r/^[a-zA-Z0-9_\-\.]+$/) do
|
{:ok, filename} ->
|
||||||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
file_path = Path.join([uploads_dir, "images", filename])
|
file_path = Path.join([uploads_dir, "images", filename])
|
||||||
|
|
||||||
@@ -20,10 +20,60 @@ defmodule ComponentsElixirWeb.FileController do
|
|||||||
|> put_status(:not_found)
|
|> put_status(:not_found)
|
||||||
|> text("File not found")
|
|> text("File not found")
|
||||||
end
|
end
|
||||||
else
|
|
||||||
|
{:error, reason} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(:bad_request)
|
|> put_status(:bad_request)
|
||||||
|> text("Invalid filename")
|
|> text(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def show_datasheet(conn, %{"filename" => encoded_filename}) do
|
||||||
|
case decode_and_validate_filename(encoded_filename) do
|
||||||
|
{:ok, filename} ->
|
||||||
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
|
file_path = Path.join([uploads_dir, "datasheets", filename])
|
||||||
|
|
||||||
|
if File.exists?(file_path) do
|
||||||
|
# Get the file's MIME type
|
||||||
|
mime_type = get_mime_type(filename)
|
||||||
|
|
||||||
|
conn
|
||||||
|
|> put_resp_content_type(mime_type)
|
||||||
|
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
|
||||||
|
|> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|
||||||
|
|> send_file(200, file_path)
|
||||||
|
else
|
||||||
|
conn
|
||||||
|
|> put_status(:not_found)
|
||||||
|
|> text("File not found")
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn
|
||||||
|
|> put_status(:bad_request)
|
||||||
|
|> text(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_and_validate_filename(encoded_filename) do
|
||||||
|
try do
|
||||||
|
# URL decode the filename
|
||||||
|
decoded_filename = URI.decode(encoded_filename)
|
||||||
|
|
||||||
|
# Security validation: prevent directory traversal and only allow safe characters
|
||||||
|
# Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation
|
||||||
|
if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and
|
||||||
|
not String.contains?(decoded_filename, "..") and
|
||||||
|
not String.starts_with?(decoded_filename, "/") and
|
||||||
|
not String.contains?(decoded_filename, "\\") do
|
||||||
|
{:ok, decoded_filename}
|
||||||
|
else
|
||||||
|
{:error, "Invalid filename: contains unsafe characters"}
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
_ ->
|
||||||
|
{:error, "Invalid filename: cannot decode"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -34,6 +84,7 @@ defmodule ComponentsElixirWeb.FileController do
|
|||||||
".png" -> "image/png"
|
".png" -> "image/png"
|
||||||
".gif" -> "image/gif"
|
".gif" -> "image/gif"
|
||||||
".webp" -> "image/webp"
|
".webp" -> "image/webp"
|
||||||
|
".pdf" -> "application/pdf"
|
||||||
_ -> "application/octet-stream"
|
_ -> "application/octet-stream"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
max_entries: 1,
|
max_entries: 1,
|
||||||
max_file_size: 5_000_000
|
max_file_size: 5_000_000
|
||||||
)
|
)
|
||||||
|
|> allow_upload(:datasheet,
|
||||||
|
accept: ~w(.pdf),
|
||||||
|
max_entries: 1,
|
||||||
|
max_file_size: 10_000_000
|
||||||
|
)
|
||||||
|> load_components()}
|
|> load_components()}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -350,11 +355,18 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
{:noreply, cancel_upload(socket, :image, ref)}
|
{:noreply, cancel_upload(socket, :image, ref)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save_component", %{"component" => component_params}, socket) do
|
def handle_event("cancel-datasheet-upload", %{"ref" => ref}, socket) do
|
||||||
# Handle any uploaded images
|
{:noreply, cancel_upload(socket, :datasheet, ref)}
|
||||||
updated_params = save_uploaded_image(socket, component_params)
|
end
|
||||||
|
|
||||||
case Inventory.create_component(updated_params) do
|
def handle_event("save_component", %{"component" => component_params}, socket) do
|
||||||
|
# Handle any uploaded files
|
||||||
|
updated_params =
|
||||||
|
socket
|
||||||
|
|> save_uploaded_image(component_params)
|
||||||
|
|> save_uploaded_datasheet(socket)
|
||||||
|
|
||||||
|
case Inventory.create_component_with_datasheet(updated_params) do
|
||||||
{:ok, _component} ->
|
{:ok, _component} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
@@ -369,10 +381,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save_edit", %{"component" => component_params}, socket) do
|
def handle_event("save_edit", %{"component" => component_params}, socket) do
|
||||||
# Handle any uploaded images
|
# Handle any uploaded files
|
||||||
updated_params = save_uploaded_image(socket, component_params)
|
updated_params =
|
||||||
|
socket
|
||||||
|
|> save_uploaded_image(component_params)
|
||||||
|
|> save_uploaded_datasheet(socket)
|
||||||
|
|
||||||
case Inventory.update_component(socket.assigns.editing_component, updated_params) do
|
case Inventory.update_component_with_datasheet(socket.assigns.editing_component, updated_params) do
|
||||||
{:ok, _component} ->
|
{:ok, _component} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
@@ -804,6 +819,51 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content">Datasheet Upload</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<.live_file_input
|
||||||
|
upload={@uploads.datasheet}
|
||||||
|
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
PDF files up to 10MB (or enter URL above to auto-download)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= for err <- upload_errors(@uploads.datasheet) do %>
|
||||||
|
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for entry <- @uploads.datasheet.entries do %>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||||
|
<.icon name="hero-document-text" class="w-6 h-6 text-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
|
||||||
|
<p class="text-sm text-base-content/60">{entry.progress}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="cancel-datasheet-upload"
|
||||||
|
phx-value-ref={entry.ref}
|
||||||
|
aria-label="cancel"
|
||||||
|
class="text-error hover:text-error/80"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= for err <- upload_errors(@uploads.datasheet) do %>
|
||||||
|
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -948,6 +1008,64 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-base-content">Datasheet</label>
|
||||||
|
<%= if @editing_component && @editing_component.datasheet_filename do %>
|
||||||
|
<div class="mt-1 mb-2">
|
||||||
|
<p class="text-sm text-base-content/70">Current datasheet:</p>
|
||||||
|
<a
|
||||||
|
href={"/uploads/datasheets/#{@editing_component.datasheet_filename}"}
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
<.icon name="hero-document-text" class="w-4 h-4 mr-1" />
|
||||||
|
View PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<div class="mt-1">
|
||||||
|
<.live_file_input
|
||||||
|
upload={@uploads.datasheet}
|
||||||
|
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-base-content/60">
|
||||||
|
PDF files up to 10MB (leave empty to keep current, or enter URL above to auto-download)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<%= for err <- upload_errors(@uploads.datasheet) do %>
|
||||||
|
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for entry <- @uploads.datasheet.entries do %>
|
||||||
|
<div class="mt-2 flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="h-10 w-10 rounded-lg bg-secondary/10 flex items-center justify-center">
|
||||||
|
<.icon name="hero-document-text" class="w-6 h-6 text-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
|
||||||
|
<p class="text-sm text-base-content/60">{entry.progress}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="cancel-datasheet-upload"
|
||||||
|
phx-value-ref={entry.ref}
|
||||||
|
aria-label="cancel"
|
||||||
|
class="text-error hover:text-error/80"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= for err <- upload_errors(@uploads.datasheet) do %>
|
||||||
|
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -997,7 +1115,16 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-center min-w-0 flex-1">
|
<div class="flex items-center min-w-0 flex-1">
|
||||||
<h3 class="text-lg font-semibold text-primary select-text">
|
<h3 class="text-lg font-semibold text-primary select-text">
|
||||||
<%= if component.datasheet_url do %>
|
<%= cond do %>
|
||||||
|
<% component.datasheet_filename -> %>
|
||||||
|
<a
|
||||||
|
href={"/uploads/datasheets/#{component.datasheet_filename}"}
|
||||||
|
target="_blank"
|
||||||
|
class="hover:text-primary/80"
|
||||||
|
>
|
||||||
|
{component.name}
|
||||||
|
</a>
|
||||||
|
<% component.datasheet_url -> %>
|
||||||
<a
|
<a
|
||||||
href={component.datasheet_url}
|
href={component.datasheet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1005,11 +1132,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
>
|
>
|
||||||
{component.name}
|
{component.name}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% true -> %>
|
||||||
{component.name}
|
{component.name}
|
||||||
<% end %>
|
<% end %>
|
||||||
</h3>
|
</h3>
|
||||||
<%= if component.datasheet_url do %>
|
<%= if component.datasheet_url || component.datasheet_filename do %>
|
||||||
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -1119,6 +1246,41 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<%= if component.datasheet_filename || component.datasheet_url do %>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<.icon name="hero-document-text" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-base-content">Datasheet:</span>
|
||||||
|
<div class="space-y-1 mt-1">
|
||||||
|
<%= if component.datasheet_filename do %>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={"/uploads/datasheets/#{component.datasheet_filename}"}
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center text-primary hover:text-primary/80 text-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-document-arrow-down" class="w-4 h-4 mr-1" />
|
||||||
|
View PDF (Downloaded)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<%= if component.datasheet_url do %>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href={component.datasheet_url}
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center text-base-content/70 hover:text-primary text-sm"
|
||||||
|
>
|
||||||
|
<.icon name="hero-link" class="w-4 h-4 mr-1" />
|
||||||
|
Original URL
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1185,7 +1347,16 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-center min-w-0 flex-1">
|
<div class="flex items-center min-w-0 flex-1">
|
||||||
<p class="text-sm font-medium text-primary truncate">
|
<p class="text-sm font-medium text-primary truncate">
|
||||||
<%= if component.datasheet_url do %>
|
<%= cond do %>
|
||||||
|
<% component.datasheet_filename -> %>
|
||||||
|
<a
|
||||||
|
href={"/uploads/datasheets/#{component.datasheet_filename}"}
|
||||||
|
target="_blank"
|
||||||
|
class="hover:text-primary/80"
|
||||||
|
>
|
||||||
|
{component.name}
|
||||||
|
</a>
|
||||||
|
<% component.datasheet_url -> %>
|
||||||
<a
|
<a
|
||||||
href={component.datasheet_url}
|
href={component.datasheet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1193,11 +1364,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
>
|
>
|
||||||
{component.name}
|
{component.name}
|
||||||
</a>
|
</a>
|
||||||
<% else %>
|
<% true -> %>
|
||||||
{component.name}
|
{component.name}
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<%= if component.datasheet_url do %>
|
<%= if component.datasheet_url || component.datasheet_filename do %>
|
||||||
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -1421,6 +1592,40 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
result
|
result
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Helper function for datasheet upload handling
|
||||||
|
defp save_uploaded_datasheet(component_params, socket) do
|
||||||
|
uploaded_files =
|
||||||
|
consume_uploaded_entries(socket, :datasheet, fn %{path: path}, entry ->
|
||||||
|
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
|
||||||
|
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||||
|
upload_dir = Path.join([uploads_dir, "datasheets"])
|
||||||
|
dest = Path.join(upload_dir, filename)
|
||||||
|
|
||||||
|
# Ensure the upload directory exists
|
||||||
|
File.mkdir_p!(upload_dir)
|
||||||
|
|
||||||
|
# Copy the file
|
||||||
|
case File.cp(path, dest) do
|
||||||
|
:ok ->
|
||||||
|
{:ok, filename}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:postpone, {:error, reason}}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
case uploaded_files do
|
||||||
|
[filename] when is_binary(filename) ->
|
||||||
|
Map.put(component_params, "datasheet_filename", filename)
|
||||||
|
|
||||||
|
[] ->
|
||||||
|
component_params
|
||||||
|
|
||||||
|
_error ->
|
||||||
|
component_params
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp delete_image_file(nil), do: :ok
|
defp delete_image_file(nil), do: :ok
|
||||||
defp delete_image_file(""), do: :ok
|
defp delete_image_file(""), do: :ok
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ defmodule ComponentsElixirWeb.Router do
|
|||||||
get "/login/authenticate", AuthController, :authenticate
|
get "/login/authenticate", AuthController, :authenticate
|
||||||
post "/logout", AuthController, :logout
|
post "/logout", AuthController, :logout
|
||||||
|
|
||||||
# File serving endpoint
|
# File serving endpoints
|
||||||
get "/uploads/images/:filename", FileController, :show
|
get "/uploads/images/:filename", FileController, :show
|
||||||
|
get "/uploads/datasheets/:filename", FileController, :show_datasheet
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", ComponentsElixirWeb do
|
scope "/", ComponentsElixirWeb do
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
defmodule ComponentsElixir.Repo.Migrations.AddDatasheetFilenameToComponents do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:components) do
|
||||||
|
add :datasheet_filename, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user