From c8acd8873ec2619079ab0d7f23d750f063b8b323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lui=CC=81s=20Arteiro?= Date: Mon, 30 Oct 2023 16:18:06 +0000 Subject: [PATCH] chore: Adding rest of initial setup and README. #1 --- .formatter.exs | 5 + .gitignore | 41 +- README.md | 259 ++++++- assets/css/app.css | 5 + assets/js/app.js | 41 ++ assets/tailwind.config.js | 26 + assets/vendor/topbar.js | 165 +++++ lib/app_web/components/core_components.ex | 650 ++++++++++++++++++ lib/app_web/components/layouts.ex | 5 + lib/app_web/components/layouts/app.html.heex | 43 ++ lib/app_web/components/layouts/root.html.heex | 17 + lib/app_web/controllers/error_html.ex | 19 + lib/app_web/controllers/page_controller.ex | 9 + lib/app_web/controllers/page_html.ex | 5 + .../controllers/page_html/home.html.heex | 237 +++++++ mix.lock | 28 + priv/static/favicon.ico | Bin 0 -> 1258 bytes priv/static/robots.txt | 5 + test/app_web/controllers/error_html_test.exs | 14 + test/app_web/controllers/error_json_test.exs | 12 + .../controllers/page_controller_test.exs | 8 + test/support/conn_case.ex | 37 + test/test_helper.exs | 1 + 23 files changed, 1623 insertions(+), 9 deletions(-) create mode 100644 .formatter.exs create mode 100644 assets/css/app.css create mode 100644 assets/js/app.js create mode 100644 assets/tailwind.config.js create mode 100644 assets/vendor/topbar.js create mode 100644 lib/app_web/components/core_components.ex create mode 100644 lib/app_web/components/layouts.ex create mode 100644 lib/app_web/components/layouts/app.html.heex create mode 100644 lib/app_web/components/layouts/root.html.heex create mode 100644 lib/app_web/controllers/error_html.ex create mode 100644 lib/app_web/controllers/page_controller.ex create mode 100644 lib/app_web/controllers/page_html.ex create mode 100644 lib/app_web/controllers/page_html/home.html.heex create mode 100644 mix.lock create mode 100644 priv/static/favicon.ico create mode 100644 priv/static/robots.txt create mode 100644 test/app_web/controllers/error_html_test.exs create mode 100644 test/app_web/controllers/error_json_test.exs create mode 100644 test/app_web/controllers/page_controller_test.exs create mode 100644 test/support/conn_case.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/.gitignore b/.gitignore index b263cd1..8073c0c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,37 @@ -/_build -/cover -/deps -/doc +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. /.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). *.ez -*.beam -/config/*.secret.exs -.elixir_ls/ + +# Temporary files, for example, from tests. +/tmp/ + +# Ignore package tarball (built via "mix hex.build"). +app-*.tar + +# Ignore assets that are produced by build tools. +/priv/static/assets/ + +# Ignore digested assets cache. +/priv/static/cache_manifest.json + +# In case you use Node.js/npm, you want to ignore these. +npm-debug.log +/assets/node_modules/ + diff --git a/README.md b/README.md index f103a53..7e437e0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,257 @@ -# image-classifier -Classify images and attempt to extract data from or describe their contents +
+ +# Image classifier in `Elixir` + +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/dwyl/image-classifier/ci.yml?label=build&style=flat-square&branch=main) +[![codecov.io](https://img.shields.io/codecov/c/github/dwyl/image-classifier/main.svg?style=flat-square)](https://codecov.io/github/dwyl/image-classifier?branch=main) +[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat-square)](https://github.com/dwyl/image-classifier/issues) +[![HitCount](https://hits.dwyl.com/dwyl/image-classifier.svg?style=flat-square&show=unique)](https://hits.dwyl.com/dwyl/image-classifier) + +Classify your images using +machine learning models +within `Phoenix + Liveview`! + +
+ +
+ +- [Image classifier in `Elixir`](#image-classifier-in-elixir) +- [Why? 🤷](#why-) +- [What? 💭](#what-) +- [Who? 👤](#who-) +- [How? 💻](#how-) + - [Prerequisites](#prerequisites) + - [0. Creating a fresh `Phoenix` project](#0-creating-a-fresh-phoenix-project) + - [1. Adding `LiveView` capabilities to our project](#1-adding-liveview-capabilities-to-our-project) +- [_Please_ Star the repo! ⭐️](#please-star-the-repo-️) + + +
+ +# Why? 🤷 + +Building our +[app](https://github.com/dwyl/app), +we consider `images` an _essential_ +medium of communication. + +By adding a way of classifying images, +we make it *easy* for people +to suggest meta tags to describe images +so they become **searchable**. + + +# What? 💭 + +This run-through will create a simple +`Phoenix LiveView` web application +that will allow you to choose/drag an image +and classify the image. + + +# Who? 👤 + +This tutorial is aimed at `LiveView` beginners +that want to grasp how to do image classifying +within a `Phoenix` application. + +If you are completely new to `Phoenix` and `LiveView`, +we recommend you follow the **`LiveView` _Counter_ Tutorial**: +[dwyl/phoenix-liveview-counter-tutorial](https://github.com/dwyl/phoenix-liveview-counter-tutorial) + + +# How? 💻 + +In this chapter, we'll go over the development process +of this small application. +You'll learn how to do this *yourself*, +so grab some coffee and let's get cracking! + + +## Prerequisites + +This tutorial requires you have `Elixir` and `Phoenix` installed. +If you you don't, please see +[how to install Elixir](https://github.com/dwyl/learn-elixir#installation) +and +[Phoenix](https://hexdocs.pm/phoenix/installation.html#phoenix). + +We assume you know the basics of `Phoenix` +and have *some* knowledge of how it works. +If you don't, +we *highly suggest* you follow our other tutorials first. +e.g: +[github.com/dwyl/**phoenix-chat-example**](https://github.com/dwyl/phoenix-chat-example) + +In addition to this, +**_some_ knowledge of `AWS`** - +what it is, what an `S3` bucket is/does - +**is assumed**. + +> **Note**: if you have questions or get stuck, +> please open an issue! +> [/dwyl/image-classifier/issues](https://github.com/dwyl/image-classifier/issues) + + +## 0. Creating a fresh `Phoenix` project + +Let's create a fresh `Phoenix` project. +Run the following command in a given folder: + +```sh +mix phx.new . --app app --no-dashboard --no-ecto --no-gettext --no-mailer +``` + +We're running [`mix phx.new`](https://hexdocs.pm/phoenix/Mix.Tasks.Phx.New.html) +to generate a new project without a dashboard +and mailer (email) service, +since we don't need those in our project. + +After this, +if you run `mix phx.server` to run your server, +you should be able to see the following page. + +

+ +

+ +We're ready to start implementing! + + +## 1. Adding `LiveView` capabilities to our project + +As it stands, +our project is not using `LiveView`. +Let's fix this. + +In `lib/app_web/router.ex`, +change the `scope "/"` to the following. + +```elixir + scope "/", AppWeb do + pipe_through :browser + + live "/", ImgupLive + end +``` + +Instead of using the `PageController`, +we are going to be creating `ImgupLive`, +a `LiveView` file. + +Let's create our `LiveView` files. +Inside `lib/app_web`, +create a folder called `live` +and create the following file +`imgup_live.ex`. + +```elixir +defmodule AppWeb.ImgupLive do + use AppWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:image_list, accept: ~w(image/*), max_entries: 6, chunk_size: 64_000)} + end +end +``` + +This is a simple `LiveView` controller +with the `mount/3` function +where we use the +[`allow_upload/3`](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#allow_upload/3) +function, +which is needed to allow file uploads in `LiveView`. + +In the same `live` folder, +create a file called `imgup_live.html.heex` +and use the following code. + +```html +<.flash_group flash={@flash} /> +
+
+
+
+
+

Image Upload

+

Drag your images and they'll be uploaded to the cloud! ☁️

+ +
+ +
+
+
+ +
+ +

or drag and drop

+
+

PNG, JPG, GIF up to 10MB

+
+
+
+
+
+
+ +
+ + +
+
+
+
+``` + +This is a simple HTML form that uses +[`Tailwind CSS`](https://github.com/dwyl/learn-tailwind) +to enhance the presentation of the upload form. +We'll also remove the unused header of the page layout, +while we're at it. + +Locate the file `lib/app_web/components/layouts/app.html.heex` +and remove the `
` class. +The file should only have the following code: + +```html +
+
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
+
+``` + +Now you can safely delete the `lib/app_web/controllers` folder, +which is no longer used. + +If you run `mix phx.server`, +you should see the following screen: + +

+ +

+ +This means we've successfully added `LiveView` +and changed our view! +We can now start implementing file uploads! 🗳️ + +> If you want to see the changes made to the project, +> check [b414b11](https://github.com/dwyl/imgup/pull/55/commits). + + +# _Please_ Star the repo! ⭐️ + +If you find this package/repo useful, +please star on GitHub, so that we know! ⭐ + +Thank you! 🙏 \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..378c8f9 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1,5 @@ +@import "tailwindcss/base"; +@import "tailwindcss/components"; +@import "tailwindcss/utilities"; + +/* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..df0cdd9 --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,41 @@ +// If you want to use Phoenix channels, run `mix help phx.gen.channel` +// to get started and then uncomment the line below. +// import "./user_socket.js" + +// You can include dependencies in two ways. +// +// The simplest option is to put them in assets/vendor and +// import them using relative paths: +// +// import "../vendor/some-package.js" +// +// Alternatively, you can `npm install some-package --prefix assets` and import +// them using a path starting with the package name: +// +// import "some-package" +// + +// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. +import "phoenix_html" +// Establish Phoenix Socket and LiveView configuration. +import {Socket} from "phoenix" +import {LiveSocket} from "phoenix_live_view" +import topbar from "../vendor/topbar" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Show progress bar on live navigation and form submits +topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// connect if there are any LiveViews on the page +liveSocket.connect() + +// expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket + diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..e3bf241 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,26 @@ +// See the Tailwind configuration guide for advanced usage +// https://tailwindcss.com/docs/configuration + +const plugin = require("tailwindcss/plugin") + +module.exports = { + content: [ + "./js/**/*.js", + "../lib/*_web.ex", + "../lib/*_web/**/*.*ex" + ], + theme: { + extend: { + colors: { + brand: "#FD4F00", + } + }, + }, + plugins: [ + require("@tailwindcss/forms"), + plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), + plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), + plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), + plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) + ] +} diff --git a/assets/vendor/topbar.js b/assets/vendor/topbar.js new file mode 100644 index 0000000..4195727 --- /dev/null +++ b/assets/vendor/topbar.js @@ -0,0 +1,165 @@ +/** + * @license MIT + * topbar 2.0.0, 2023-02-04 + * https://buunguyen.github.io/topbar + * Copyright (c) 2021 Buu Nguyen + */ +(function (window, document) { + "use strict"; + + // https://gist.github.com/paulirish/1579671 + (function () { + var lastTime = 0; + var vendors = ["ms", "moz", "webkit", "o"]; + for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { + window.requestAnimationFrame = + window[vendors[x] + "RequestAnimationFrame"]; + window.cancelAnimationFrame = + window[vendors[x] + "CancelAnimationFrame"] || + window[vendors[x] + "CancelRequestAnimationFrame"]; + } + if (!window.requestAnimationFrame) + window.requestAnimationFrame = function (callback, element) { + var currTime = new Date().getTime(); + var timeToCall = Math.max(0, 16 - (currTime - lastTime)); + var id = window.setTimeout(function () { + callback(currTime + timeToCall); + }, timeToCall); + lastTime = currTime + timeToCall; + return id; + }; + if (!window.cancelAnimationFrame) + window.cancelAnimationFrame = function (id) { + clearTimeout(id); + }; + })(); + + var canvas, + currentProgress, + showing, + progressTimerId = null, + fadeTimerId = null, + delayTimerId = null, + addEvent = function (elem, type, handler) { + if (elem.addEventListener) elem.addEventListener(type, handler, false); + else if (elem.attachEvent) elem.attachEvent("on" + type, handler); + else elem["on" + type] = handler; + }, + options = { + autoRun: true, + barThickness: 3, + barColors: { + 0: "rgba(26, 188, 156, .9)", + ".25": "rgba(52, 152, 219, .9)", + ".50": "rgba(241, 196, 15, .9)", + ".75": "rgba(230, 126, 34, .9)", + "1.0": "rgba(211, 84, 0, .9)", + }, + shadowBlur: 10, + shadowColor: "rgba(0, 0, 0, .6)", + className: null, + }, + repaint = function () { + canvas.width = window.innerWidth; + canvas.height = options.barThickness * 5; // need space for shadow + + var ctx = canvas.getContext("2d"); + ctx.shadowBlur = options.shadowBlur; + ctx.shadowColor = options.shadowColor; + + var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); + for (var stop in options.barColors) + lineGradient.addColorStop(stop, options.barColors[stop]); + ctx.lineWidth = options.barThickness; + ctx.beginPath(); + ctx.moveTo(0, options.barThickness / 2); + ctx.lineTo( + Math.ceil(currentProgress * canvas.width), + options.barThickness / 2 + ); + ctx.strokeStyle = lineGradient; + ctx.stroke(); + }, + createCanvas = function () { + canvas = document.createElement("canvas"); + var style = canvas.style; + style.position = "fixed"; + style.top = style.left = style.right = style.margin = style.padding = 0; + style.zIndex = 100001; + style.display = "none"; + if (options.className) canvas.classList.add(options.className); + document.body.appendChild(canvas); + addEvent(window, "resize", repaint); + }, + topbar = { + config: function (opts) { + for (var key in opts) + if (options.hasOwnProperty(key)) options[key] = opts[key]; + }, + show: function (delay) { + if (showing) return; + if (delay) { + if (delayTimerId) return; + delayTimerId = setTimeout(() => topbar.show(), delay); + } else { + showing = true; + if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); + if (!canvas) createCanvas(); + canvas.style.opacity = 1; + canvas.style.display = "block"; + topbar.progress(0); + if (options.autoRun) { + (function loop() { + progressTimerId = window.requestAnimationFrame(loop); + topbar.progress( + "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) + ); + })(); + } + } + }, + progress: function (to) { + if (typeof to === "undefined") return currentProgress; + if (typeof to === "string") { + to = + (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 + ? currentProgress + : 0) + parseFloat(to); + } + currentProgress = to > 1 ? 1 : to; + repaint(); + return currentProgress; + }, + hide: function () { + clearTimeout(delayTimerId); + delayTimerId = null; + if (!showing) return; + showing = false; + if (progressTimerId != null) { + window.cancelAnimationFrame(progressTimerId); + progressTimerId = null; + } + (function loop() { + if (topbar.progress("+.1") >= 1) { + canvas.style.opacity -= 0.05; + if (canvas.style.opacity <= 0.05) { + canvas.style.display = "none"; + fadeTimerId = null; + return; + } + } + fadeTimerId = window.requestAnimationFrame(loop); + })(); + }, + }; + + if (typeof module === "object" && typeof module.exports === "object") { + module.exports = topbar; + } else if (typeof define === "function" && define.amd) { + define(function () { + return topbar; + }); + } else { + this.topbar = topbar; + } +}.call(this, window, document)); diff --git a/lib/app_web/components/core_components.ex b/lib/app_web/components/core_components.ex new file mode 100644 index 0000000..dad6364 --- /dev/null +++ b/lib/app_web/components/core_components.ex @@ -0,0 +1,650 @@ +defmodule AppWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + The components in this module use Tailwind CSS, a utility-first CSS framework. + See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to + customize the generated components in this module. + + Icons are provided by [heroicons](https://heroicons.com), using the + [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. + """ + use Phoenix.Component + + alias Phoenix.LiveView.JS + + @doc """ + Renders a modal. + + ## Examples + + <.modal id="confirm-modal"> + Are you sure? + <:confirm>OK + <:cancel>Cancel + + + JS commands may be passed to the `:on_cancel` and `on_confirm` attributes + for the caller to react to each button press, for example: + + <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> + Are you sure you? + <:confirm>OK + <:cancel>Cancel + + """ + attr :id, :string, required: true + attr :show, :boolean, default: false + attr :on_cancel, JS, default: %JS{} + attr :on_confirm, JS, default: %JS{} + + slot :inner_block, required: true + slot :title + slot :subtitle + slot :confirm + slot :cancel + + def modal(assigns) do + ~H""" +