From 9f2e4c5d218ed3789f24f21ee4b75bc4cd9957f1 Mon Sep 17 00:00:00 2001
From: sloane <git@sloanelybutsurely.com>
Date: Thu, 5 Dec 2024 09:17:50 -0500
Subject: [PATCH] initial commit

---
 .formatter.exs                                |   5 +
 .gitignore                                    |  37 ++++
 .mise.toml                                    |   3 +
 README.md                                     |  18 ++
 assets/css/app.css                            |   5 +
 assets/js/app.js                              |  44 +++++
 assets/tailwind.config.js                     |  74 +++++++
 assets/vendor/topbar.js                       | 165 ++++++++++++++++
 config/config.exs                             |  56 ++++++
 config/dev.exs                                |  71 +++++++
 config/prod.exs                               |  14 ++
 config/runtime.exs                            |  84 ++++++++
 config/test.exs                               |  18 ++
 lib/groupdle.ex                               |   9 +
 lib/groupdle/application.ex                   |  33 ++++
 lib/groupdle_web.ex                           | 111 +++++++++++
 .../components/core_components.ex             | 182 ++++++++++++++++++
 lib/groupdle_web/components/layouts.ex        |  14 ++
 .../components/layouts/app.html.heex          |   7 +
 .../components/layouts/root.html.heex         |  17 ++
 lib/groupdle_web/controllers/error_html.ex    |  24 +++
 lib/groupdle_web/controllers/error_json.ex    |  21 ++
 .../controllers/page_controller.ex            |   9 +
 lib/groupdle_web/controllers/page_html.ex     |  10 +
 .../controllers/page_html/home.html.heex      |   1 +
 lib/groupdle_web/endpoint.ex                  |  52 +++++
 lib/groupdle_web/router.ex                    |  43 +++++
 lib/groupdle_web/telemetry.ex                 |  69 +++++++
 mix.exs                                       |  76 ++++++++
 mix.lock                                      |  28 +++
 priv/static/favicon.ico                       | Bin 0 -> 152 bytes
 priv/static/images/logo.svg                   |   6 +
 priv/static/robots.txt                        |   5 +
 .../controllers/error_html_test.exs           |  14 ++
 .../controllers/error_json_test.exs           |  12 ++
 .../controllers/page_controller_test.exs      |   3 +
 test/support/conn_case.ex                     |  37 ++++
 test/test_helper.exs                          |   1 +
 38 files changed, 1378 insertions(+)
 create mode 100644 .formatter.exs
 create mode 100644 .gitignore
 create mode 100644 .mise.toml
 create mode 100644 README.md
 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 config/config.exs
 create mode 100644 config/dev.exs
 create mode 100644 config/prod.exs
 create mode 100644 config/runtime.exs
 create mode 100644 config/test.exs
 create mode 100644 lib/groupdle.ex
 create mode 100644 lib/groupdle/application.ex
 create mode 100644 lib/groupdle_web.ex
 create mode 100644 lib/groupdle_web/components/core_components.ex
 create mode 100644 lib/groupdle_web/components/layouts.ex
 create mode 100644 lib/groupdle_web/components/layouts/app.html.heex
 create mode 100644 lib/groupdle_web/components/layouts/root.html.heex
 create mode 100644 lib/groupdle_web/controllers/error_html.ex
 create mode 100644 lib/groupdle_web/controllers/error_json.ex
 create mode 100644 lib/groupdle_web/controllers/page_controller.ex
 create mode 100644 lib/groupdle_web/controllers/page_html.ex
 create mode 100644 lib/groupdle_web/controllers/page_html/home.html.heex
 create mode 100644 lib/groupdle_web/endpoint.ex
 create mode 100644 lib/groupdle_web/router.ex
 create mode 100644 lib/groupdle_web/telemetry.ex
 create mode 100644 mix.exs
 create mode 100644 mix.lock
 create mode 100644 priv/static/favicon.ico
 create mode 100644 priv/static/images/logo.svg
 create mode 100644 priv/static/robots.txt
 create mode 100644 test/groupdle_web/controllers/error_html_test.exs
 create mode 100644 test/groupdle_web/controllers/error_json_test.exs
 create mode 100644 test/groupdle_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
new file mode 100644
index 0000000..1aa5221
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+# 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
+
+# Temporary files, for example, from tests.
+/tmp/
+
+# Ignore package tarball (built via "mix hex.build").
+groupdle-*.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/.mise.toml b/.mise.toml
new file mode 100644
index 0000000..d74b12d
--- /dev/null
+++ b/.mise.toml
@@ -0,0 +1,3 @@
+[tools]
+erlang = "27.1.2"
+elixir = "1.17.3-otp-27"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..62344a2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,18 @@
+# Groupdle
+
+To start your Phoenix server:
+
+  * Run `mix setup` to install and setup dependencies
+  * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
+
+Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
+
+Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
+
+## Learn more
+
+  * Official website: https://www.phoenixframework.org/
+  * Guides: https://hexdocs.pm/phoenix/overview.html
+  * Docs: https://hexdocs.pm/phoenix
+  * Forum: https://elixirforum.com/c/phoenix-forum
+  * Source: https://github.com/phoenixframework/phoenix
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..d5e278a
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,44 @@
+// 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, {
+  longPollFallbackMs: 2500,
+  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..62eafef
--- /dev/null
+++ b/assets/tailwind.config.js
@@ -0,0 +1,74 @@
+// See the Tailwind configuration guide for advanced usage
+// https://tailwindcss.com/docs/configuration
+
+const plugin = require("tailwindcss/plugin")
+const fs = require("fs")
+const path = require("path")
+
+module.exports = {
+  content: [
+    "./js/**/*.js",
+    "../lib/groupdle_web.ex",
+    "../lib/groupdle_web/**/*.*ex"
+  ],
+  theme: {
+    extend: {
+      colors: {
+        brand: "#FD4F00",
+      }
+    },
+  },
+  plugins: [
+    require("@tailwindcss/forms"),
+    // Allows prefixing tailwind classes with LiveView classes to add rules
+    // only when LiveView classes are applied, for example:
+    //
+    //     <div class="phx-click-loading:animate-ping">
+    //
+    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 &"])),
+
+    // Embeds Heroicons (https://heroicons.com) into your app.css bundle
+    // See your `CoreComponents.icon/1` for more information.
+    //
+    plugin(function({matchComponents, theme}) {
+      let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
+      let values = {}
+      let icons = [
+        ["", "/24/outline"],
+        ["-solid", "/24/solid"],
+        ["-mini", "/20/solid"],
+        ["-micro", "/16/solid"]
+      ]
+      icons.forEach(([suffix, dir]) => {
+        fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+          let name = path.basename(file, ".svg") + suffix
+          values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+        })
+      })
+      matchComponents({
+        "hero": ({name, fullPath}) => {
+          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+          let size = theme("spacing.6")
+          if (name.endsWith("-mini")) {
+            size = theme("spacing.5")
+          } else if (name.endsWith("-micro")) {
+            size = theme("spacing.4")
+          }
+          return {
+            [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+            "-webkit-mask": `var(--hero-${name})`,
+            "mask": `var(--hero-${name})`,
+            "mask-repeat": "no-repeat",
+            "background-color": "currentColor",
+            "vertical-align": "middle",
+            "display": "inline-block",
+            "width": size,
+            "height": size
+          }
+        }
+      }, {values})
+    })
+  ]
+}
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/config/config.exs b/config/config.exs
new file mode 100644
index 0000000..a2847c5
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,56 @@
+# This file is responsible for configuring your application
+# and its dependencies with the aid of the Config module.
+#
+# This configuration file is loaded before any dependency and
+# is restricted to this project.
+
+# General application configuration
+import Config
+
+config :groupdle,
+  generators: [timestamp_type: :utc_datetime, binary_id: true]
+
+# Configures the endpoint
+config :groupdle, GroupdleWeb.Endpoint,
+  url: [host: "localhost"],
+  adapter: Bandit.PhoenixAdapter,
+  render_errors: [
+    formats: [html: GroupdleWeb.ErrorHTML, json: GroupdleWeb.ErrorJSON],
+    layout: false
+  ],
+  pubsub_server: Groupdle.PubSub,
+  live_view: [signing_salt: "smOBm6Oy"]
+
+# Configure esbuild (the version is required)
+config :esbuild,
+  version: "0.17.11",
+  groupdle: [
+    args:
+      ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
+    cd: Path.expand("../assets", __DIR__),
+    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
+  ]
+
+# Configure tailwind (the version is required)
+config :tailwind,
+  version: "3.4.3",
+  groupdle: [
+    args: ~w(
+      --config=tailwind.config.js
+      --input=css/app.css
+      --output=../priv/static/assets/app.css
+    ),
+    cd: Path.expand("../assets", __DIR__)
+  ]
+
+# Configures Elixir's Logger
+config :logger, :console,
+  format: "$time $metadata[$level] $message\n",
+  metadata: [:request_id]
+
+# Use Jason for JSON parsing in Phoenix
+config :phoenix, :json_library, Jason
+
+# Import environment specific config. This must remain at the bottom
+# of this file so it overrides the configuration defined above.
+import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
new file mode 100644
index 0000000..ed4a0b8
--- /dev/null
+++ b/config/dev.exs
@@ -0,0 +1,71 @@
+import Config
+
+# For development, we disable any cache and enable
+# debugging and code reloading.
+#
+# The watchers configuration can be used to run external
+# watchers to your application. For example, we can use it
+# to bundle .js and .css sources.
+config :groupdle, GroupdleWeb.Endpoint,
+  # Binding to loopback ipv4 address prevents access from other machines.
+  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
+  http: [ip: {127, 0, 0, 1}, port: 4000],
+  check_origin: false,
+  code_reloader: true,
+  debug_errors: true,
+  secret_key_base: "29QxkLzZguJEYEEkU8GdVAIO9KVVTxm7qNmywffKfKHD9bjinGWQBJhaT0RfQu7Z",
+  watchers: [
+    esbuild: {Esbuild, :install_and_run, [:groupdle, ~w(--sourcemap=inline --watch)]},
+    tailwind: {Tailwind, :install_and_run, [:groupdle, ~w(--watch)]}
+  ]
+
+# ## SSL Support
+#
+# In order to use HTTPS in development, a self-signed
+# certificate can be generated by running the following
+# Mix task:
+#
+#     mix phx.gen.cert
+#
+# Run `mix help phx.gen.cert` for more information.
+#
+# The `http:` config above can be replaced with:
+#
+#     https: [
+#       port: 4001,
+#       cipher_suite: :strong,
+#       keyfile: "priv/cert/selfsigned_key.pem",
+#       certfile: "priv/cert/selfsigned.pem"
+#     ],
+#
+# If desired, both `http:` and `https:` keys can be
+# configured to run both http and https servers on
+# different ports.
+
+# Watch static and templates for browser reloading.
+config :groupdle, GroupdleWeb.Endpoint,
+  live_reload: [
+    patterns: [
+      ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
+      ~r"lib/groupdle_web/(controllers|live|components)/.*(ex|heex)$"
+    ]
+  ]
+
+# Enable dev routes for dashboard and mailbox
+config :groupdle, dev_routes: true
+
+# Do not include metadata nor timestamps in development logs
+config :logger, :console, format: "[$level] $message\n"
+
+# Set a higher stacktrace during development. Avoid configuring such
+# in production as building large stacktraces may be expensive.
+config :phoenix, :stacktrace_depth, 20
+
+# Initialize plugs at runtime for faster development compilation
+config :phoenix, :plug_init_mode, :runtime
+
+config :phoenix_live_view,
+  # Include HEEx debug annotations as HTML comments in rendered markup
+  debug_heex_annotations: true,
+  # Enable helpful, but potentially expensive runtime checks
+  enable_expensive_runtime_checks: true
diff --git a/config/prod.exs b/config/prod.exs
new file mode 100644
index 0000000..ee1e5ee
--- /dev/null
+++ b/config/prod.exs
@@ -0,0 +1,14 @@
+import Config
+
+# Note we also include the path to a cache manifest
+# containing the digested version of static files. This
+# manifest is generated by the `mix assets.deploy` task,
+# which you should run after static files are built and
+# before starting your production server.
+config :groupdle, GroupdleWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
+
+# Do not print debug messages in production
+config :logger, level: :info
+
+# Runtime production configuration, including reading
+# of environment variables, is done on config/runtime.exs.
diff --git a/config/runtime.exs b/config/runtime.exs
new file mode 100644
index 0000000..4d027d1
--- /dev/null
+++ b/config/runtime.exs
@@ -0,0 +1,84 @@
+import Config
+
+# config/runtime.exs is executed for all environments, including
+# during releases. It is executed after compilation and before the
+# system starts, so it is typically used to load production configuration
+# and secrets from environment variables or elsewhere. Do not define
+# any compile-time configuration in here, as it won't be applied.
+# The block below contains prod specific runtime configuration.
+
+# ## Using releases
+#
+# If you use `mix release`, you need to explicitly enable the server
+# by passing the PHX_SERVER=true when you start it:
+#
+#     PHX_SERVER=true bin/groupdle start
+#
+# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
+# script that automatically sets the env var above.
+if System.get_env("PHX_SERVER") do
+  config :groupdle, GroupdleWeb.Endpoint, server: true
+end
+
+if config_env() == :prod do
+  # The secret key base is used to sign/encrypt cookies and other secrets.
+  # A default value is used in config/dev.exs and config/test.exs but you
+  # want to use a different value for prod and you most likely don't want
+  # to check this value into version control, so we use an environment
+  # variable instead.
+  secret_key_base =
+    System.get_env("SECRET_KEY_BASE") ||
+      raise """
+      environment variable SECRET_KEY_BASE is missing.
+      You can generate one by calling: mix phx.gen.secret
+      """
+
+  host = System.get_env("PHX_HOST") || "example.com"
+  port = String.to_integer(System.get_env("PORT") || "4000")
+
+  config :groupdle, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
+
+  config :groupdle, GroupdleWeb.Endpoint,
+    url: [host: host, port: 443, scheme: "https"],
+    http: [
+      # Enable IPv6 and bind on all interfaces.
+      # Set it to  {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
+      # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
+      # for details about using IPv6 vs IPv4 and loopback vs public addresses.
+      ip: {0, 0, 0, 0, 0, 0, 0, 0},
+      port: port
+    ],
+    secret_key_base: secret_key_base
+
+  # ## SSL Support
+  #
+  # To get SSL working, you will need to add the `https` key
+  # to your endpoint configuration:
+  #
+  #     config :groupdle, GroupdleWeb.Endpoint,
+  #       https: [
+  #         ...,
+  #         port: 443,
+  #         cipher_suite: :strong,
+  #         keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
+  #         certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
+  #       ]
+  #
+  # The `cipher_suite` is set to `:strong` to support only the
+  # latest and more secure SSL ciphers. This means old browsers
+  # and clients may not be supported. You can set it to
+  # `:compatible` for wider support.
+  #
+  # `:keyfile` and `:certfile` expect an absolute path to the key
+  # and cert in disk or a relative path inside priv, for example
+  # "priv/ssl/server.key". For all supported SSL configuration
+  # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
+  #
+  # We also recommend setting `force_ssl` in your config/prod.exs,
+  # ensuring no data is ever sent via http, always redirecting to https:
+  #
+  #     config :groupdle, GroupdleWeb.Endpoint,
+  #       force_ssl: [hsts: true]
+  #
+  # Check `Plug.SSL` for all available options in `force_ssl`.
+end
diff --git a/config/test.exs b/config/test.exs
new file mode 100644
index 0000000..4234111
--- /dev/null
+++ b/config/test.exs
@@ -0,0 +1,18 @@
+import Config
+
+# We don't run a server during test. If one is required,
+# you can enable the server option below.
+config :groupdle, GroupdleWeb.Endpoint,
+  http: [ip: {127, 0, 0, 1}, port: 4002],
+  secret_key_base: "h/cgku8mPf86IFURx70/Eb2LoHtA3PY3BhReczJxWXmLfbh3v+Scy3S+hWNLO+9+",
+  server: false
+
+# Print only warnings and errors during test
+config :logger, level: :warning
+
+# Initialize plugs at runtime for faster test compilation
+config :phoenix, :plug_init_mode, :runtime
+
+# Enable helpful, but potentially expensive runtime checks
+config :phoenix_live_view,
+  enable_expensive_runtime_checks: true
diff --git a/lib/groupdle.ex b/lib/groupdle.ex
new file mode 100644
index 0000000..65b1702
--- /dev/null
+++ b/lib/groupdle.ex
@@ -0,0 +1,9 @@
+defmodule Groupdle do
+  @moduledoc """
+  Groupdle keeps the contexts that define your domain
+  and business logic.
+
+  Contexts are also responsible for managing your data, regardless
+  if it comes from the database, an external API or others.
+  """
+end
diff --git a/lib/groupdle/application.ex b/lib/groupdle/application.ex
new file mode 100644
index 0000000..660fbe2
--- /dev/null
+++ b/lib/groupdle/application.ex
@@ -0,0 +1,33 @@
+defmodule Groupdle.Application do
+  # See https://hexdocs.pm/elixir/Application.html
+  # for more information on OTP Applications
+  @moduledoc false
+
+  use Application
+
+  @impl true
+  def start(_type, _args) do
+    children = [
+      GroupdleWeb.Telemetry,
+      {DNSCluster, query: Application.get_env(:groupdle, :dns_cluster_query) || :ignore},
+      {Phoenix.PubSub, name: Groupdle.PubSub},
+      # Start a worker by calling: Groupdle.Worker.start_link(arg)
+      # {Groupdle.Worker, arg},
+      # Start to serve requests, typically the last entry
+      GroupdleWeb.Endpoint
+    ]
+
+    # See https://hexdocs.pm/elixir/Supervisor.html
+    # for other strategies and supported options
+    opts = [strategy: :one_for_one, name: Groupdle.Supervisor]
+    Supervisor.start_link(children, opts)
+  end
+
+  # Tell Phoenix to update the endpoint configuration
+  # whenever the application is updated.
+  @impl true
+  def config_change(changed, _new, removed) do
+    GroupdleWeb.Endpoint.config_change(changed, removed)
+    :ok
+  end
+end
diff --git a/lib/groupdle_web.ex b/lib/groupdle_web.ex
new file mode 100644
index 0000000..fd7ea7e
--- /dev/null
+++ b/lib/groupdle_web.ex
@@ -0,0 +1,111 @@
+defmodule GroupdleWeb do
+  @moduledoc """
+  The entrypoint for defining your web interface, such
+  as controllers, components, channels, and so on.
+
+  This can be used in your application as:
+
+      use GroupdleWeb, :controller
+      use GroupdleWeb, :html
+
+  The definitions below will be executed for every controller,
+  component, etc, so keep them short and clean, focused
+  on imports, uses and aliases.
+
+  Do NOT define functions inside the quoted expressions
+  below. Instead, define additional modules and import
+  those modules here.
+  """
+
+  def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
+
+  def router do
+    quote do
+      use Phoenix.Router, helpers: false
+
+      # Import common connection and controller functions to use in pipelines
+      import Plug.Conn
+      import Phoenix.Controller
+      import Phoenix.LiveView.Router
+    end
+  end
+
+  def channel do
+    quote do
+      use Phoenix.Channel
+    end
+  end
+
+  def controller do
+    quote do
+      use Phoenix.Controller,
+        formats: [:html, :json],
+        layouts: [html: GroupdleWeb.Layouts]
+
+      import Plug.Conn
+
+      unquote(verified_routes())
+    end
+  end
+
+  def live_view do
+    quote do
+      use Phoenix.LiveView,
+        layout: {GroupdleWeb.Layouts, :app}
+
+      unquote(html_helpers())
+    end
+  end
+
+  def live_component do
+    quote do
+      use Phoenix.LiveComponent
+
+      unquote(html_helpers())
+    end
+  end
+
+  def html do
+    quote do
+      use Phoenix.Component
+
+      # Import convenience functions from controllers
+      import Phoenix.Controller,
+        only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+      # Include general helpers for rendering HTML
+      unquote(html_helpers())
+    end
+  end
+
+  defp html_helpers do
+    quote do
+      # HTML escaping functionality
+      import Phoenix.HTML
+      # Core UI components and translation
+      import GroupdleWeb.CoreComponents
+
+      # Shortcut for generating JS commands
+      alias Phoenix.LiveView.JS
+
+      # Routes generation with the ~p sigil
+      unquote(verified_routes())
+    end
+  end
+
+  def verified_routes do
+    quote do
+      use Phoenix.VerifiedRoutes,
+        endpoint: GroupdleWeb.Endpoint,
+        router: GroupdleWeb.Router,
+        statics: GroupdleWeb.static_paths()
+    end
+  end
+
+  @doc """
+  When used, dispatch to the appropriate controller/live_view/etc.
+  """
+  defmacro __using__(which) when is_atom(which) do
+    apply(__MODULE__, which, [])
+  end
+end
diff --git a/lib/groupdle_web/components/core_components.ex b/lib/groupdle_web/components/core_components.ex
new file mode 100644
index 0000000..bc7de11
--- /dev/null
+++ b/lib/groupdle_web/components/core_components.ex
@@ -0,0 +1,182 @@
+defmodule GroupdleWeb.CoreComponents do
+  @moduledoc """
+  Provides core UI components.
+
+  At first glance, this module may seem daunting, but its goal is to provide
+  core building blocks for your application, such as modals, tables, and
+  forms. The components consist mostly of markup and are well-documented
+  with doc strings and declarative assigns. You may customize and style
+  them in any way you want, based on your application growth and needs.
+
+  The default components use Tailwind CSS, a utility-first CSS framework.
+  See the [Tailwind CSS documentation](https://tailwindcss.com) to learn
+  how to customize them or feel free to swap in another framework altogether.
+
+  Icons are provided by [heroicons](https://heroicons.com). See `icon/1` for usage.
+  """
+  use Phoenix.Component
+
+  alias Phoenix.LiveView.JS
+
+  @doc """
+  Renders flash notices.
+
+  ## Examples
+
+      <.flash kind={:info} flash={@flash} />
+      <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
+  """
+  attr :id, :string, doc: "the optional id of flash container"
+  attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
+  attr :title, :string, default: nil
+  attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
+  attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
+
+  slot :inner_block, doc: "the optional inner block that renders the flash message"
+
+  def flash(assigns) do
+    assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
+
+    ~H"""
+    <div
+      :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
+      id={@id}
+      phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
+      role="alert"
+      class={[
+        "fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
+        @kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
+        @kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
+      ]}
+      {@rest}
+    >
+      <p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
+        <.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
+        <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
+        {@title}
+      </p>
+      <p class="mt-2 text-sm leading-5">{msg}</p>
+      <button type="button" class="group absolute top-1 right-1 p-2" aria-label="close">
+        <.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
+      </button>
+    </div>
+    """
+  end
+
+  @doc """
+  Shows the flash group with standard titles and content.
+
+  ## Examples
+
+      <.flash_group flash={@flash} />
+  """
+  attr :flash, :map, required: true, doc: "the map of flash messages"
+  attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
+
+  def flash_group(assigns) do
+    ~H"""
+    <div id={@id}>
+      <.flash kind={:info} title="Success!" flash={@flash} />
+      <.flash kind={:error} title="Error!" flash={@flash} />
+      <.flash
+        id="client-error"
+        kind={:error}
+        title="We can't find the internet"
+        phx-disconnected={show(".phx-client-error #client-error")}
+        phx-connected={hide("#client-error")}
+        hidden
+      >
+        Attempting to reconnect <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+      </.flash>
+
+      <.flash
+        id="server-error"
+        kind={:error}
+        title="Something went wrong!"
+        phx-disconnected={show(".phx-server-error #server-error")}
+        phx-connected={hide("#server-error")}
+        hidden
+      >
+        Hang in there while we get back on track
+        <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
+      </.flash>
+    </div>
+    """
+  end
+
+  @doc """
+  Renders a [Heroicon](https://heroicons.com).
+
+  Heroicons come in three styles – outline, solid, and mini.
+  By default, the outline style is used, but solid and mini may
+  be applied by using the `-solid` and `-mini` suffix.
+
+  You can customize the size and colors of the icons by setting
+  width, height, and background color classes.
+
+  Icons are extracted from the `deps/heroicons` directory and bundled within
+  your compiled app.css by the plugin in your `assets/tailwind.config.js`.
+
+  ## Examples
+
+      <.icon name="hero-x-mark-solid" />
+      <.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
+  """
+  attr :name, :string, required: true
+  attr :class, :string, default: nil
+
+  def icon(%{name: "hero-" <> _} = assigns) do
+    ~H"""
+    <span class={[@name, @class]} />
+    """
+  end
+
+  ## JS Commands
+
+  def show(js \\ %JS{}, selector) do
+    JS.show(js,
+      to: selector,
+      time: 300,
+      transition:
+        {"transition-all transform ease-out duration-300",
+         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
+         "opacity-100 translate-y-0 sm:scale-100"}
+    )
+  end
+
+  def hide(js \\ %JS{}, selector) do
+    JS.hide(js,
+      to: selector,
+      time: 200,
+      transition:
+        {"transition-all transform ease-in duration-200",
+         "opacity-100 translate-y-0 sm:scale-100",
+         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
+    )
+  end
+
+  @doc """
+  Translates an error message using gettext.
+  """
+  def translate_error({msg, opts}) do
+    # You can make use of gettext to translate error messages by
+    # uncommenting and adjusting the following code:
+
+    # if count = opts[:count] do
+    #   Gettext.dngettext(GroupdleWeb.Gettext, "errors", msg, msg, count, opts)
+    # else
+    #   Gettext.dgettext(GroupdleWeb.Gettext, "errors", msg, opts)
+    # end
+
+    Enum.reduce(opts, msg, fn {key, value}, acc ->
+      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
+    end)
+  end
+
+  @doc """
+  Translates the errors for a field from a keyword list of errors.
+  """
+  def translate_errors(errors, field) when is_list(errors) do
+    for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
+  end
+end
diff --git a/lib/groupdle_web/components/layouts.ex b/lib/groupdle_web/components/layouts.ex
new file mode 100644
index 0000000..84b7535
--- /dev/null
+++ b/lib/groupdle_web/components/layouts.ex
@@ -0,0 +1,14 @@
+defmodule GroupdleWeb.Layouts do
+  @moduledoc """
+  This module holds different layouts used by your application.
+
+  See the `layouts` directory for all templates available.
+  The "root" layout is a skeleton rendered as part of the
+  application router. The "app" layout is set as the default
+  layout on both `use GroupdleWeb, :controller` and
+  `use GroupdleWeb, :live_view`.
+  """
+  use GroupdleWeb, :html
+
+  embed_templates "layouts/*"
+end
diff --git a/lib/groupdle_web/components/layouts/app.html.heex b/lib/groupdle_web/components/layouts/app.html.heex
new file mode 100644
index 0000000..c352cde
--- /dev/null
+++ b/lib/groupdle_web/components/layouts/app.html.heex
@@ -0,0 +1,7 @@
+<header></header>
+<main>
+  <div>
+    <.flash_group flash={@flash} />
+    {@inner_content}
+  </div>
+</main>
diff --git a/lib/groupdle_web/components/layouts/root.html.heex b/lib/groupdle_web/components/layouts/root.html.heex
new file mode 100644
index 0000000..5cd8d7b
--- /dev/null
+++ b/lib/groupdle_web/components/layouts/root.html.heex
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en" class="[scrollbar-gutter:stable]">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="csrf-token" content={get_csrf_token()} />
+    <.live_title prefix="Groupdle | " default="Welcome">
+      {assigns[:page_title]}
+    </.live_title>
+    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
+    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
+    </script>
+  </head>
+  <body class="bg-white">
+    {@inner_content}
+  </body>
+</html>
diff --git a/lib/groupdle_web/controllers/error_html.ex b/lib/groupdle_web/controllers/error_html.ex
new file mode 100644
index 0000000..3b1e99f
--- /dev/null
+++ b/lib/groupdle_web/controllers/error_html.ex
@@ -0,0 +1,24 @@
+defmodule GroupdleWeb.ErrorHTML do
+  @moduledoc """
+  This module is invoked by your endpoint in case of errors on HTML requests.
+
+  See config/config.exs.
+  """
+  use GroupdleWeb, :html
+
+  # If you want to customize your error pages,
+  # uncomment the embed_templates/1 call below
+  # and add pages to the error directory:
+  #
+  #   * lib/groupdle_web/controllers/error_html/404.html.heex
+  #   * lib/groupdle_web/controllers/error_html/500.html.heex
+  #
+  # embed_templates "error_html/*"
+
+  # The default is to render a plain text page based on
+  # the template name. For example, "404.html" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    Phoenix.Controller.status_message_from_template(template)
+  end
+end
diff --git a/lib/groupdle_web/controllers/error_json.ex b/lib/groupdle_web/controllers/error_json.ex
new file mode 100644
index 0000000..6d8aca7
--- /dev/null
+++ b/lib/groupdle_web/controllers/error_json.ex
@@ -0,0 +1,21 @@
+defmodule GroupdleWeb.ErrorJSON do
+  @moduledoc """
+  This module is invoked by your endpoint in case of errors on JSON requests.
+
+  See config/config.exs.
+  """
+
+  # If you want to customize a particular status code,
+  # you may add your own clauses, such as:
+  #
+  # def render("500.json", _assigns) do
+  #   %{errors: %{detail: "Internal Server Error"}}
+  # end
+
+  # By default, Phoenix returns the status message from
+  # the template name. For example, "404.json" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+  end
+end
diff --git a/lib/groupdle_web/controllers/page_controller.ex b/lib/groupdle_web/controllers/page_controller.ex
new file mode 100644
index 0000000..2d053ec
--- /dev/null
+++ b/lib/groupdle_web/controllers/page_controller.ex
@@ -0,0 +1,9 @@
+defmodule GroupdleWeb.PageController do
+  use GroupdleWeb, :controller
+
+  def home(conn, _params) do
+    # The home page is often custom made,
+    # so skip the default app layout.
+    render(conn, :home, layout: false)
+  end
+end
diff --git a/lib/groupdle_web/controllers/page_html.ex b/lib/groupdle_web/controllers/page_html.ex
new file mode 100644
index 0000000..5bfc040
--- /dev/null
+++ b/lib/groupdle_web/controllers/page_html.ex
@@ -0,0 +1,10 @@
+defmodule GroupdleWeb.PageHTML do
+  @moduledoc """
+  This module contains pages rendered by PageController.
+
+  See the `page_html` directory for all templates available.
+  """
+  use GroupdleWeb, :html
+
+  embed_templates "page_html/*"
+end
diff --git a/lib/groupdle_web/controllers/page_html/home.html.heex b/lib/groupdle_web/controllers/page_html/home.html.heex
new file mode 100644
index 0000000..637ed6e
--- /dev/null
+++ b/lib/groupdle_web/controllers/page_html/home.html.heex
@@ -0,0 +1 @@
+<.flash_group flash={@flash} />
diff --git a/lib/groupdle_web/endpoint.ex b/lib/groupdle_web/endpoint.ex
new file mode 100644
index 0000000..155c23b
--- /dev/null
+++ b/lib/groupdle_web/endpoint.ex
@@ -0,0 +1,52 @@
+defmodule GroupdleWeb.Endpoint do
+  use Phoenix.Endpoint, otp_app: :groupdle
+
+  # The session will be stored in the cookie and signed,
+  # this means its contents can be read but not tampered with.
+  # Set :encryption_salt if you would also like to encrypt it.
+  @session_options [
+    store: :cookie,
+    key: "_groupdle_key",
+    signing_salt: "bnbFReqk",
+    same_site: "Lax"
+  ]
+
+  socket "/live", Phoenix.LiveView.Socket,
+    websocket: [connect_info: [session: @session_options]],
+    longpoll: [connect_info: [session: @session_options]]
+
+  # Serve at "/" the static files from "priv/static" directory.
+  #
+  # You should set gzip to true if you are running phx.digest
+  # when deploying your static files in production.
+  plug Plug.Static,
+    at: "/",
+    from: :groupdle,
+    gzip: false,
+    only: GroupdleWeb.static_paths()
+
+  # Code reloading can be explicitly enabled under the
+  # :code_reloader configuration of your endpoint.
+  if code_reloading? do
+    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
+    plug Phoenix.LiveReloader
+    plug Phoenix.CodeReloader
+  end
+
+  plug Phoenix.LiveDashboard.RequestLogger,
+    param_key: "request_logger",
+    cookie_key: "request_logger"
+
+  plug Plug.RequestId
+  plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
+
+  plug Plug.Parsers,
+    parsers: [:urlencoded, :multipart, :json],
+    pass: ["*/*"],
+    json_decoder: Phoenix.json_library()
+
+  plug Plug.MethodOverride
+  plug Plug.Head
+  plug Plug.Session, @session_options
+  plug GroupdleWeb.Router
+end
diff --git a/lib/groupdle_web/router.ex b/lib/groupdle_web/router.ex
new file mode 100644
index 0000000..66798e3
--- /dev/null
+++ b/lib/groupdle_web/router.ex
@@ -0,0 +1,43 @@
+defmodule GroupdleWeb.Router do
+  use GroupdleWeb, :router
+
+  pipeline :browser do
+    plug :accepts, ["html"]
+    plug :fetch_session
+    plug :fetch_live_flash
+    plug :put_root_layout, html: {GroupdleWeb.Layouts, :root}
+    plug :protect_from_forgery
+    plug :put_secure_browser_headers
+  end
+
+  pipeline :api do
+    plug :accepts, ["json"]
+  end
+
+  scope "/", GroupdleWeb do
+    pipe_through :browser
+
+    get "/", PageController, :home
+  end
+
+  # Other scopes may use custom stacks.
+  # scope "/api", GroupdleWeb do
+  #   pipe_through :api
+  # end
+
+  # Enable LiveDashboard in development
+  if Application.compile_env(:groupdle, :dev_routes) do
+    # If you want to use the LiveDashboard in production, you should put
+    # it behind authentication and allow only admins to access it.
+    # If your application does not have an admins-only section yet,
+    # you can use Plug.BasicAuth to set up some basic authentication
+    # as long as you are also using SSL (which you should anyway).
+    import Phoenix.LiveDashboard.Router
+
+    scope "/dev" do
+      pipe_through :browser
+
+      live_dashboard "/dashboard", metrics: GroupdleWeb.Telemetry
+    end
+  end
+end
diff --git a/lib/groupdle_web/telemetry.ex b/lib/groupdle_web/telemetry.ex
new file mode 100644
index 0000000..a5b9535
--- /dev/null
+++ b/lib/groupdle_web/telemetry.ex
@@ -0,0 +1,69 @@
+defmodule GroupdleWeb.Telemetry do
+  use Supervisor
+  import Telemetry.Metrics
+
+  def start_link(arg) do
+    Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
+  end
+
+  @impl true
+  def init(_arg) do
+    children = [
+      # Telemetry poller will execute the given period measurements
+      # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
+      {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
+      # Add reporters as children of your supervision tree.
+      # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
+    ]
+
+    Supervisor.init(children, strategy: :one_for_one)
+  end
+
+  def metrics do
+    [
+      # Phoenix Metrics
+      summary("phoenix.endpoint.start.system_time",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.endpoint.stop.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.start.system_time",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.exception.duration",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.router_dispatch.stop.duration",
+        tags: [:route],
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.socket_connected.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.channel_joined.duration",
+        unit: {:native, :millisecond}
+      ),
+      summary("phoenix.channel_handled_in.duration",
+        tags: [:event],
+        unit: {:native, :millisecond}
+      ),
+
+      # VM Metrics
+      summary("vm.memory.total", unit: {:byte, :kilobyte}),
+      summary("vm.total_run_queue_lengths.total"),
+      summary("vm.total_run_queue_lengths.cpu"),
+      summary("vm.total_run_queue_lengths.io")
+    ]
+  end
+
+  defp periodic_measurements do
+    [
+      # A module, function and arguments to be invoked periodically.
+      # This function must call :telemetry.execute/3 and a metric must be added above.
+      # {GroupdleWeb, :count_users, []}
+    ]
+  end
+end
diff --git a/mix.exs b/mix.exs
new file mode 100644
index 0000000..96041ea
--- /dev/null
+++ b/mix.exs
@@ -0,0 +1,76 @@
+defmodule Groupdle.MixProject do
+  use Mix.Project
+
+  def project do
+    [
+      app: :groupdle,
+      version: "0.1.0",
+      elixir: "~> 1.14",
+      elixirc_paths: elixirc_paths(Mix.env()),
+      start_permanent: Mix.env() == :prod,
+      aliases: aliases(),
+      deps: deps()
+    ]
+  end
+
+  # Configuration for the OTP application.
+  #
+  # Type `mix help compile.app` for more information.
+  def application do
+    [
+      mod: {Groupdle.Application, []},
+      extra_applications: [:logger, :runtime_tools]
+    ]
+  end
+
+  # Specifies which paths to compile per environment.
+  defp elixirc_paths(:test), do: ["lib", "test/support"]
+  defp elixirc_paths(_), do: ["lib"]
+
+  # Specifies your project dependencies.
+  #
+  # Type `mix help deps` for examples and options.
+  defp deps do
+    [
+      {:phoenix, "~> 1.7.17"},
+      {:phoenix_html, "~> 4.1"},
+      {:phoenix_live_reload, "~> 1.2", only: :dev},
+      {:phoenix_live_view, "~> 1.0.0"},
+      {:floki, ">= 0.30.0", only: :test},
+      {:phoenix_live_dashboard, "~> 0.8.3"},
+      {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
+      {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
+      {:heroicons,
+       github: "tailwindlabs/heroicons",
+       tag: "v2.1.1",
+       sparse: "optimized",
+       app: false,
+       compile: false,
+       depth: 1},
+      {:telemetry_metrics, "~> 1.0"},
+      {:telemetry_poller, "~> 1.0"},
+      {:jason, "~> 1.2"},
+      {:dns_cluster, "~> 0.1.1"},
+      {:bandit, "~> 1.5"}
+    ]
+  end
+
+  # Aliases are shortcuts or tasks specific to the current project.
+  # For example, to install project dependencies and perform other setup tasks, run:
+  #
+  #     $ mix setup
+  #
+  # See the documentation for `Mix` for more info on aliases.
+  defp aliases do
+    [
+      setup: ["deps.get", "assets.setup", "assets.build"],
+      "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+      "assets.build": ["tailwind groupdle", "esbuild groupdle"],
+      "assets.deploy": [
+        "tailwind groupdle --minify",
+        "esbuild groupdle --minify",
+        "phx.digest"
+      ]
+    ]
+  end
+end
diff --git a/mix.lock b/mix.lock
new file mode 100644
index 0000000..3b9d154
--- /dev/null
+++ b/mix.lock
@@ -0,0 +1,28 @@
+%{
+  "bandit": {:hex, :bandit, "1.6.0", "9cb6c67c27cecab2d0c93968cb957fa8decccb7275193c8bf33f97397b3ac25d", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "fd2491e564a7c5e11ff8496ebf530c342c742452c59de17ac0fb1f814a0ab01a"},
+  "castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
+  "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
+  "esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
+  "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
+  "floki": {:hex, :floki, "0.36.3", "1102f93b16a55bc5383b85ae3ec470f82dee056eaeff9195e8afdf0ef2a43c30", [:mix], [], "hexpm", "fe0158bff509e407735f6d40b3ee0d7deb47f3f3ee7c6c182ad28599f9f6b27a"},
+  "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]},
+  "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
+  "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
+  "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
+  "phoenix": {:hex, :phoenix, "1.7.17", "2fcdceecc6fb90bec26fab008f96abbd0fd93bc9956ec7985e5892cf545152ca", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "50e8ad537f3f7b0efb1509b2f75b5c918f697be6a45d48e49a30d3b7c0e464c9"},
+  "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"},
+  "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.5", "d5f44d7dbd7cfacaa617b70c5a14b2b598d6f93b9caa8e350c51d56cd4350a9b", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "1d73920515554d7d6c548aee0bf10a4780568b029d042eccb336db29ea0dad70"},
+  "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
+  "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.0", "3a10dfce8f87b2ad4dc65de0732fc2a11e670b2779a19e8d3281f4619a85bce4", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "254caef0028765965ca6bd104cc7d68dcc7d57cc42912bef92f6b03047251d99"},
+  "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
+  "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
+  "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
+  "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
+  "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"},
+  "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
+  "telemetry_metrics": {:hex, :telemetry_metrics, "1.0.0", "29f5f84991ca98b8eb02fc208b2e6de7c95f8bb2294ef244a176675adc7775df", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f23713b3847286a534e005126d4c959ebcca68ae9582118ce436b521d1d47d5d"},
+  "telemetry_poller": {:hex, :telemetry_poller, "1.1.0", "58fa7c216257291caaf8d05678c8d01bd45f4bdbc1286838a28c4bb62ef32999", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9eb9d9cbfd81cbd7cdd24682f8711b6e2b691289a0de6826e58452f28c103c8f"},
+  "thousand_island": {:hex, :thousand_island, "1.3.7", "1da7598c0f4f5f50562c097a3f8af308ded48cd35139f0e6f17d9443e4d0c9c5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0139335079953de41d381a6134d8b618d53d084f558c734f2662d1a72818dd12"},
+  "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
+  "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
+}
diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..7f372bfc21cdd8cb47585339d5fa4d9dd424402f
GIT binary patch
literal 152
zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=@t!V@Ar*{oFEH`~d50E!_s``s
q?{G*w(7?#d#v@^nKnY_HKaYb01EZMZjMqTJ89ZJ6T-G@yGywoKK_h|y

literal 0
HcmV?d00001

diff --git a/priv/static/images/logo.svg b/priv/static/images/logo.svg
new file mode 100644
index 0000000..9f26bab
--- /dev/null
+++ b/priv/static/images/logo.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
+  <path
+    d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
+    fill="#FD4F00"
+  />
+</svg>
diff --git a/priv/static/robots.txt b/priv/static/robots.txt
new file mode 100644
index 0000000..26e06b5
--- /dev/null
+++ b/priv/static/robots.txt
@@ -0,0 +1,5 @@
+# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
+#
+# To ban all spiders from the entire site uncomment the next two lines:
+# User-agent: *
+# Disallow: /
diff --git a/test/groupdle_web/controllers/error_html_test.exs b/test/groupdle_web/controllers/error_html_test.exs
new file mode 100644
index 0000000..0e777fa
--- /dev/null
+++ b/test/groupdle_web/controllers/error_html_test.exs
@@ -0,0 +1,14 @@
+defmodule GroupdleWeb.ErrorHTMLTest do
+  use GroupdleWeb.ConnCase, async: true
+
+  # Bring render_to_string/4 for testing custom views
+  import Phoenix.Template
+
+  test "renders 404.html" do
+    assert render_to_string(GroupdleWeb.ErrorHTML, "404", "html", []) == "Not Found"
+  end
+
+  test "renders 500.html" do
+    assert render_to_string(GroupdleWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
+  end
+end
diff --git a/test/groupdle_web/controllers/error_json_test.exs b/test/groupdle_web/controllers/error_json_test.exs
new file mode 100644
index 0000000..6d7ac5b
--- /dev/null
+++ b/test/groupdle_web/controllers/error_json_test.exs
@@ -0,0 +1,12 @@
+defmodule GroupdleWeb.ErrorJSONTest do
+  use GroupdleWeb.ConnCase, async: true
+
+  test "renders 404" do
+    assert GroupdleWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+  end
+
+  test "renders 500" do
+    assert GroupdleWeb.ErrorJSON.render("500.json", %{}) ==
+             %{errors: %{detail: "Internal Server Error"}}
+  end
+end
diff --git a/test/groupdle_web/controllers/page_controller_test.exs b/test/groupdle_web/controllers/page_controller_test.exs
new file mode 100644
index 0000000..8722501
--- /dev/null
+++ b/test/groupdle_web/controllers/page_controller_test.exs
@@ -0,0 +1,3 @@
+defmodule GroupdleWeb.PageControllerTest do
+  use GroupdleWeb.ConnCase
+end
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
new file mode 100644
index 0000000..85648cf
--- /dev/null
+++ b/test/support/conn_case.ex
@@ -0,0 +1,37 @@
+defmodule GroupdleWeb.ConnCase do
+  @moduledoc """
+  This module defines the test case to be used by
+  tests that require setting up a connection.
+
+  Such tests rely on `Phoenix.ConnTest` and also
+  import other functionality to make it easier
+  to build common data structures and query the data layer.
+
+  Finally, if the test case interacts with the database,
+  we enable the SQL sandbox, so changes done to the database
+  are reverted at the end of every test. If you are using
+  PostgreSQL, you can even run database tests asynchronously
+  by setting `use GroupdleWeb.ConnCase, async: true`, although
+  this option is not recommended for other databases.
+  """
+
+  use ExUnit.CaseTemplate
+
+  using do
+    quote do
+      # The default endpoint for testing
+      @endpoint GroupdleWeb.Endpoint
+
+      use GroupdleWeb, :verified_routes
+
+      # Import conveniences for testing with connections
+      import Plug.Conn
+      import Phoenix.ConnTest
+      import GroupdleWeb.ConnCase
+    end
+  end
+
+  setup _tags do
+    {:ok, conn: Phoenix.ConnTest.build_conn()}
+  end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()