Julian Noble
2 years ago
commit
c1491da68a
46 changed files with 2355 additions and 0 deletions
@ -0,0 +1,6 @@
|
||||
[ |
||||
import_deps: [:ecto, :phoenix], |
||||
subdirectories: ["priv/*/migrations"], |
||||
plugins: [Phoenix.LiveView.HTMLFormatter], |
||||
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] |
||||
] |
@ -0,0 +1,34 @@
|
||||
# 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 |
||||
|
||||
# Ignore package tarball (built via "mix hex.build"). |
||||
chirp-*.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/ |
||||
|
@ -0,0 +1,19 @@
|
||||
# Chirp |
||||
|
||||
To start your Phoenix server: |
||||
|
||||
* Install dependencies with `mix deps.get` |
||||
* Create and migrate your database with `mix ecto.setup` |
||||
* 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 |
@ -0,0 +1,5 @@
|
||||
@import "tailwindcss/base"; |
||||
@import "tailwindcss/components"; |
||||
@import "tailwindcss/utilities"; |
||||
|
||||
/* This file is for your main application CSS */ |
File diff suppressed because one or more lines are too long
@ -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.delayedShow(200)) |
||||
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 |
||||
|
@ -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 &"])) |
||||
] |
||||
} |
@ -0,0 +1,167 @@
|
||||
/** |
||||
* @license MIT |
||||
* topbar 1.0.0, 2021-01-06 |
||||
* Modifications: |
||||
* - add delayedShow(time) (2022-09-21) |
||||
* http://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]; |
||||
}, |
||||
delayedShow: function(time) { |
||||
if (showing) return; |
||||
if (delayTimerId) return; |
||||
delayTimerId = setTimeout(() => topbar.show(), time); |
||||
}, |
||||
show: function () { |
||||
if (showing) return; |
||||
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)); |
@ -0,0 +1,61 @@
|
||||
# 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 :chirp, |
||||
ecto_repos: [Chirp.Repo] |
||||
|
||||
# Configures the endpoint |
||||
config :chirp, ChirpWeb.Endpoint, |
||||
url: [host: "localhost"], |
||||
render_errors: [view: ChirpWeb.ErrorView, accepts: ~w(html json), layout: false], |
||||
pubsub_server: Chirp.PubSub, |
||||
live_view: [signing_salt: "hHZZMpp8"] |
||||
|
||||
# Configures the mailer |
||||
# |
||||
# By default it uses the "Local" adapter which stores the emails |
||||
# locally. You can see the emails in your browser, at "/dev/mailbox". |
||||
# |
||||
# For production it's recommended to configure a different adapter |
||||
# at the `config/runtime.exs`. |
||||
config :chirp, Chirp.Mailer, adapter: Swoosh.Adapters.Local |
||||
|
||||
# Configure esbuild (the version is required) |
||||
config :esbuild, |
||||
version: "0.14.41", |
||||
default: [ |
||||
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.1.0", |
||||
default: [ |
||||
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" |
@ -0,0 +1,82 @@
|
||||
import Config |
||||
|
||||
# Configure your database |
||||
config :chirp, Chirp.Repo, |
||||
username: "postgres", |
||||
password: "7a5ff6dba25f438a89f4427f956000e0", |
||||
hostname: "localhost", |
||||
database: "chirp_dev", |
||||
stacktrace: true, |
||||
show_sensitive_data_on_connection_error: true, |
||||
pool_size: 10 |
||||
|
||||
# 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 use it |
||||
# with esbuild to bundle .js and .css sources. |
||||
config :chirp, ChirpWeb.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: "cFcP9udlcMLbpgEshTX+j2J0qSbz2nnsO/sKmaBR/UMWgIbGKOYIV3R6nQ4H5prV", |
||||
watchers: [ |
||||
# Start the esbuild watcher by calling Esbuild.install_and_run(:default, args) |
||||
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, |
||||
tailwind: {Tailwind, :install_and_run, [:default, ~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 |
||||
# |
||||
# Note that this task requires Erlang/OTP 20 or later. |
||||
# 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 :chirp, ChirpWeb.Endpoint, |
||||
live_reload: [ |
||||
patterns: [ |
||||
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", |
||||
~r"priv/gettext/.*(po)$", |
||||
~r"lib/chirp_web/(live|views)/.*(ex)$", |
||||
~r"lib/chirp_web/templates/.*(eex)$" |
||||
] |
||||
] |
||||
|
||||
# Enable dev routes for dashboard and mailbox |
||||
config :chirp, 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 |
||||
|
||||
# Disable swoosh api client as it is only required for production adapters. |
||||
config :swoosh, :api_client, false |
@ -0,0 +1,49 @@
|
||||
import Config |
||||
|
||||
# For production, don't forget to configure the url host |
||||
# to something meaningful, Phoenix uses this information |
||||
# when generating URLs. |
||||
# |
||||
# Note we also include the path to a cache manifest |
||||
# containing the digested version of static files. This |
||||
# manifest is generated by the `mix phx.digest` task, |
||||
# which you should run after static files are built and |
||||
# before starting your production server. |
||||
config :chirp, ChirpWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" |
||||
|
||||
# Do not print debug messages in production |
||||
config :logger, level: :info |
||||
|
||||
# ## SSL Support |
||||
# |
||||
# To get SSL working, you will need to add the `https` key |
||||
# to the previous section and set your `:url` port to 443: |
||||
# |
||||
# config :chirp, ChirpWeb.Endpoint, |
||||
# ..., |
||||
# url: [host: "example.com", port: 443], |
||||
# 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 endpoint, ensuring |
||||
# no data is ever sent via http, always redirecting to https: |
||||
# |
||||
# config :chirp, ChirpWeb.Endpoint, |
||||
# force_ssl: [hsts: true] |
||||
# |
||||
# Check `Plug.SSL` for all available options in `force_ssl`. |
@ -0,0 +1,83 @@
|
||||
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/chirp 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 :chirp, ChirpWeb.Endpoint, server: true |
||||
end |
||||
|
||||
if config_env() == :prod do |
||||
database_url = |
||||
System.get_env("DATABASE_URL") || |
||||
raise """ |
||||
environment variable DATABASE_URL is missing. |
||||
For example: ecto://USER:PASS@HOST/DATABASE |
||||
""" |
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] |
||||
|
||||
config :chirp, Chirp.Repo, |
||||
# ssl: true, |
||||
url: database_url, |
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), |
||||
socket_options: maybe_ipv6 |
||||
|
||||
# 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 :chirp, ChirpWeb.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/plug_cowboy/Plug.Cowboy.html |
||||
# 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 |
||||
|
||||
# ## Configuring the mailer |
||||
# |
||||
# In production you need to configure the mailer to use a different adapter. |
||||
# Also, you may need to configure the Swoosh API client of your choice if you |
||||
# are not using SMTP. Here is an example of the configuration: |
||||
# |
||||
# config :chirp, Chirp.Mailer, |
||||
# adapter: Swoosh.Adapters.Mailgun, |
||||
# api_key: System.get_env("MAILGUN_API_KEY"), |
||||
# domain: System.get_env("MAILGUN_DOMAIN") |
||||
# |
||||
# For this example you need include a HTTP client required by Swoosh API client. |
||||
# Swoosh supports Hackney and Finch out of the box: |
||||
# |
||||
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney |
||||
# |
||||
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. |
||||
end |
@ -0,0 +1,33 @@
|
||||
import Config |
||||
|
||||
# Configure your database |
||||
# |
||||
# The MIX_TEST_PARTITION environment variable can be used |
||||
# to provide built-in test partitioning in CI environment. |
||||
# Run `mix help test` for more information. |
||||
config :chirp, Chirp.Repo, |
||||
username: "postgres", |
||||
password: "7a5ff6dba25f438a89f4427f956000e0", |
||||
hostname: "localhost", |
||||
database: "chirp_test#{System.get_env("MIX_TEST_PARTITION")}", |
||||
pool: Ecto.Adapters.SQL.Sandbox, |
||||
pool_size: 10 |
||||
|
||||
# We don't run a server during test. If one is required, |
||||
# you can enable the server option below. |
||||
config :chirp, ChirpWeb.Endpoint, |
||||
http: [ip: {127, 0, 0, 1}, port: 4002], |
||||
secret_key_base: "9CEgcgXMtcBtdcMAGv08+nHuAg7SmImLzRTeMvqStzi5+Xn2wtfuVzbEq2dwDMob", |
||||
server: false |
||||
|
||||
# In test we don't send emails. |
||||
config :chirp, Chirp.Mailer, adapter: Swoosh.Adapters.Test |
||||
|
||||
# Disable swoosh api client as it is only required for production adapters. |
||||
config :swoosh, :api_client, 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 |
@ -0,0 +1,9 @@
|
||||
defmodule Chirp do |
||||
@moduledoc """ |
||||
Chirp 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 |
@ -0,0 +1,36 @@
|
||||
defmodule Chirp.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 = [ |
||||
# Start the Telemetry supervisor |
||||
ChirpWeb.Telemetry, |
||||
# Start the Ecto repository |
||||
Chirp.Repo, |
||||
# Start the PubSub system |
||||
{Phoenix.PubSub, name: Chirp.PubSub}, |
||||
# Start the Endpoint (http/https) |
||||
ChirpWeb.Endpoint |
||||
# Start a worker by calling: Chirp.Worker.start_link(arg) |
||||
# {Chirp.Worker, arg} |
||||
] |
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html |
||||
# for other strategies and supported options |
||||
opts = [strategy: :one_for_one, name: Chirp.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 |
||||
ChirpWeb.Endpoint.config_change(changed, removed) |
||||
:ok |
||||
end |
||||
end |
@ -0,0 +1,3 @@
|
||||
defmodule Chirp.Mailer do |
||||
use Swoosh.Mailer, otp_app: :chirp |
||||
end |
@ -0,0 +1,5 @@
|
||||
defmodule Chirp.Repo do |
||||
use Ecto.Repo, |
||||
otp_app: :chirp, |
||||
adapter: Ecto.Adapters.Postgres |
||||
end |
@ -0,0 +1,115 @@
|
||||
defmodule ChirpWeb do |
||||
@moduledoc """ |
||||
The entrypoint for defining your web interface, such |
||||
as controllers, views, channels and so on. |
||||
|
||||
This can be used in your application as: |
||||
|
||||
use ChirpWeb, :controller |
||||
use ChirpWeb, :view |
||||
|
||||
The definitions below will be executed for every view, |
||||
controller, etc, so keep them short and clean, focused |
||||
on imports, uses and aliases. |
||||
|
||||
Do NOT define functions inside the quoted expressions |
||||
below. Instead, define any helper function in modules |
||||
and import those modules here. |
||||
""" |
||||
|
||||
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) |
||||
|
||||
def controller do |
||||
quote do |
||||
use Phoenix.Controller, namespace: ChirpWeb |
||||
|
||||
import Plug.Conn |
||||
import ChirpWeb.Gettext |
||||
|
||||
unquote(verified_routes()) |
||||
end |
||||
end |
||||
|
||||
def view do |
||||
quote do |
||||
use Phoenix.View, |
||||
root: "lib/chirp_web/templates", |
||||
namespace: ChirpWeb |
||||
|
||||
use Phoenix.Component |
||||
|
||||
# Import convenience functions from controllers |
||||
import Phoenix.Controller, |
||||
only: [get_csrf_token: 0, view_module: 1, view_template: 1] |
||||
|
||||
# Include shared imports and aliases for views |
||||
unquote(view_helpers()) |
||||
end |
||||
end |
||||
|
||||
def live_view do |
||||
quote do |
||||
use Phoenix.LiveView, |
||||
layout: {ChirpWeb.LayoutView, "app.html"} |
||||
|
||||
unquote(view_helpers()) |
||||
end |
||||
end |
||||
|
||||
def live_component do |
||||
quote do |
||||
use Phoenix.LiveComponent |
||||
|
||||
unquote(view_helpers()) |
||||
end |
||||
end |
||||
|
||||
def router do |
||||
quote do |
||||
use Phoenix.Router, helpers: false |
||||
|
||||
import Plug.Conn |
||||
import Phoenix.Controller |
||||
import Phoenix.LiveView.Router |
||||
end |
||||
end |
||||
|
||||
def channel do |
||||
quote do |
||||
use Phoenix.Channel |
||||
import ChirpWeb.Gettext |
||||
end |
||||
end |
||||
|
||||
def verified_routes do |
||||
quote do |
||||
use Phoenix.VerifiedRoutes, |
||||
endpoint: ChirpWeb.Endpoint, |
||||
router: ChirpWeb.Router, |
||||
statics: ChirpWeb.static_paths() |
||||
end |
||||
end |
||||
|
||||
defp view_helpers do |
||||
quote do |
||||
import Phoenix.HTML |
||||
import Phoenix.HTML.Form |
||||
import ChirpWeb.Components |
||||
|
||||
alias Phoenix.LiveView.JS |
||||
|
||||
# Import basic rendering functionality (render, render_layout, etc) |
||||
import Phoenix.View |
||||
|
||||
import ChirpWeb.Gettext |
||||
unquote(verified_routes()) |
||||
end |
||||
end |
||||
|
||||
@doc """ |
||||
When used, dispatch to the appropriate controller/view/etc. |
||||
""" |
||||
defmacro __using__(which) when is_atom(which) do |
||||
apply(__MODULE__, which, []) |
||||
end |
||||
end |
@ -0,0 +1,594 @@
|
||||
defmodule ChirpWeb.Components 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 |
||||
|
||||
import ChirpWeb.Gettext, warn: false |
||||
|
||||
alias Phoenix.LiveView.JS |
||||
|
||||
@doc """ |
||||
Renders a modal. |
||||
|
||||
## Examples |
||||
|
||||
<.modal id="confirm-modal"> |
||||
Are you sure? |
||||
<:confirm>OK</:confirm> |
||||
<:cancel>Cancel</:cancel> |
||||
<.modal> |
||||
|
||||
JS commands may be passed to the `:on_cancel` and `on_confirm` attributes |
||||
for the caller to reactor 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</:confirm> |
||||
<:cancel>Cancel</:confirm> |
||||
<.modal> |
||||
""" |
||||
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""" |
||||
<div id={@id} phx-mounted={@show && show_modal(@id)} class="relative z-50 hidden"> |
||||
<div id={"#{@id}-bg"} class="fixed inset-0 bg-zinc-50/90 transition-opacity" aria-hidden="true" /> |
||||
<div |
||||
class="fixed inset-0 overflow-y-auto" |
||||
aria-labelledby={"#{@id}-title"} |
||||
aria-describedby={"#{@id}-description"} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
tabindex="0" |
||||
> |
||||
<div class="flex min-h-full items-center justify-center"> |
||||
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8"> |
||||
<.focus_wrap |
||||
id={"#{@id}-container"} |
||||
phx-mounted={@show && show_modal(@id)} |
||||
phx-window-keydown={hide_modal(@on_cancel, @id)} |
||||
phx-key="escape" |
||||
phx-click-away={hide_modal(@on_cancel, @id)} |
||||
class="hidden relative rounded-2xl bg-white p-14 shadow-lg shadow-zinc-700/10 ring-1 ring-zinc-700/10 transition" |
||||
> |
||||
<div class="absolute top-6 right-5"> |
||||
<button |
||||
phx-click={hide_modal(@on_cancel, @id)} |
||||
type="button" |
||||
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40" |
||||
aria-label="Close" |
||||
> |
||||
<Heroicons.x_mark solid class="h-5 w-5 stroke-current" /> |
||||
</button> |
||||
</div> |
||||
<div id={"#{@id}-content"}> |
||||
<header :if={@title != []}> |
||||
<h1 id={"#{@id}-title"} class="text-lg font-semibold leading-8 text-zinc-800"> |
||||
<%= render_slot(@title) %> |
||||
</h1> |
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> |
||||
<%= render_slot(@subtitle) %> |
||||
</p> |
||||
</header> |
||||
<%= render_slot(@inner_block) %> |
||||
<div :if={@confirm != [] or @cancel != []} class="ml-6 mb-4 flex items-center gap-5"> |
||||
<.button |
||||
:for={confirm <- @confirm} |
||||
id={"#{@id}-confirm"} |
||||
phx-click={@on_confirm} |
||||
phx-disable-with |
||||
class="py-2 px-3" |
||||
> |
||||
<%= render_slot(confirm) %> |
||||
</.button> |
||||
<.link |
||||
:for={cancel <- @cancel} |
||||
phx-click={hide_modal(@on_cancel, @id)} |
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" |
||||
> |
||||
<%= render_slot(cancel) %> |
||||
</.link> |
||||
</div> |
||||
</div> |
||||
</.focus_wrap> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders flash notices. |
||||
|
||||
## Examples |
||||
|
||||
<.flash kind={:info} flash={@flash} /> |
||||
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> |
||||
""" |
||||
attr :id, :string, default: "flash", 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 :rest, :global |
||||
attr :kind, :atom, doc: "one of :info, :error used for styling and flash lookup" |
||||
attr :autoshow, :boolean, default: true, doc: "whether to auto show the flash on mount" |
||||
attr :close, :boolean, default: true, doc: "whether the flash can be closed" |
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message" |
||||
|
||||
def flash(assigns) do |
||||
~H""" |
||||
<div |
||||
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} |
||||
id={@id} |
||||
phx-mounted={@autoshow && show("##{@id}")} |
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("#flash")} |
||||
role="alert" |
||||
class={[ |
||||
"fixed hidden top-2 right-2 w-96 z-50 rounded-lg p-3 shadow-md shadow-zinc-900/5 ring-1", |
||||
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900", |
||||
@kind == :error && "bg-rose-50 p-3 text-rose-900 shadow-md ring-rose-500 fill-rose-900" |
||||
]} |
||||
{@rest} |
||||
> |
||||
<p :if={@title} class="flex items-center gap-1.5 text-[0.8125rem] font-semibold leading-6"> |
||||
<Heroicons.information_circle :if={@kind == :info} mini class="h-4 w-4" /> |
||||
<Heroicons.exclamation_circle :if={@kind == :error} mini class="h-4 w-4" /> |
||||
<%= @title %> |
||||
</p> |
||||
<p class="mt-2 text-[0.8125rem] leading-5"><%= msg %></p> |
||||
<button :if={@close} type="button" class="group absolute top-2 right-1 p-2" aria-label="Close"> |
||||
<Heroicons.x_mark solid class="h-5 w-5 stroke-current opacity-40 group-hover:opacity-70" /> |
||||
</button> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders a simple form. |
||||
|
||||
## Examples |
||||
|
||||
<.simple_form :let={f} for={:user} phx-change="validate" phx-submit="save"> |
||||
<.input field={{f, :email}} label="Email"/> |
||||
<.input field={{f, :username}} label="Username" /> |
||||
<:actions> |
||||
<.button>Save</.button> |
||||
<:actions> |
||||
</.simple_form> |
||||
""" |
||||
attr :for, :any, default: nil, doc: "the datastructure for the form" |
||||
attr :as, :any, default: nil, doc: "the server side parameter to collect all input under" |
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to apply to the form tag" |
||||
|
||||
slot :inner_block, required: true |
||||
slot :actions, doc: "the slot for form actions, such as a submit button" |
||||
|
||||
def simple_form(assigns) do |
||||
~H""" |
||||
<.form :let={f} for={@for} as={@as} {@rest}> |
||||
<div class="space-y-8 bg-white mt-10"> |
||||
<%= render_slot(@inner_block, f) %> |
||||
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6"> |
||||
<%= render_slot(action, f) %> |
||||
</div> |
||||
</div> |
||||
</.form> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders a button. |
||||
|
||||
## Examples |
||||
|
||||
<.button>Send!</.button> |
||||
<.button phx-click="go" class="ml-2">Send!</.button> |
||||
""" |
||||
attr :type, :string, default: nil |
||||
attr :class, :string, default: nil |
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to apply to the button tag" |
||||
|
||||
slot :inner_block, required: true |
||||
|
||||
def button(assigns) do |
||||
~H""" |
||||
<button |
||||
type={@type} |
||||
class={[ |
||||
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3", |
||||
"text-sm font-semibold leading-6 text-white active:text-white/80", |
||||
@class |
||||
]} |
||||
{@rest} |
||||
> |
||||
<%= render_slot(@inner_block) %> |
||||
</button> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders an input with label and error messages. |
||||
|
||||
A `%Phoenix.HTML.Form{}` and field name may be passed to the input |
||||
to build input names and error messages, or all the attributes and |
||||
errors may be passed explicitly. |
||||
|
||||
## Examples |
||||
|
||||
<.input field={{f, :email}} type="email" /> |
||||
<.input name="my-input" errors={["oh no!"]} /> |
||||
""" |
||||
attr :id, :any |
||||
attr :name, :any |
||||
attr :label, :string, default: nil |
||||
|
||||
attr :type, :string, |
||||
default: "text", |
||||
doc: ~s|one of "text", "textarea", "number" "email", "date", "time", "datetime", "select"| |
||||
|
||||
attr :value, :any |
||||
attr :field, :any, doc: "a %Phoenix.HTML.Form{}/field name tuple, for example: {f, :email}" |
||||
attr :errors, :list |
||||
attr :rest, :global, doc: "the arbitrary HTML attributes for the input tag" |
||||
|
||||
slot :inner_block |
||||
slot :option, doc: "the slot for select input options" |
||||
|
||||
def input(%{field: {f, field}} = assigns) do |
||||
assigns |
||||
|> assign(field: nil) |
||||
|> assign_new(:name, fn -> Phoenix.HTML.Form.input_name(f, field) end) |
||||
|> assign_new(:id, fn -> Phoenix.HTML.Form.input_id(f, field) end) |
||||
|> assign_new(:value, fn -> Phoenix.HTML.Form.input_value(f, field) end) |
||||
|> assign_new(:errors, fn -> translate_errors(f.errors || [], field) end) |
||||
|> input() |
||||
end |
||||
|
||||
def input(%{type: "checkbox"} = assigns) do |
||||
~H""" |
||||
<label phx-feedback-for={@name} class="flex items-center gap-4 text-sm leading-6 text-zinc-600"> |
||||
<input |
||||
type="checkbox" |
||||
id={@id || @name} |
||||
name={@name} |
||||
class="rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900" |
||||
/> |
||||
<%= @label %> |
||||
</label> |
||||
""" |
||||
end |
||||
|
||||
def input(%{type: "select"} = assigns) do |
||||
~H""" |
||||
<div phx-feedback-for={@name}> |
||||
<.label for={@id}><%= @label %></.label> |
||||
<select |
||||
id={@id} |
||||
name={@name} |
||||
autocomplete={@name} |
||||
class="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-zinc-500 focus:border-zinc-500 sm:text-sm" |
||||
{@rest} |
||||
> |
||||
<option :for={opt <- @option} {assigns_to_attributes(opt)}><%= render_slot(opt) %></option> |
||||
</select> |
||||
<.error :for={msg <- @errors} message={msg} /> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
def input(%{type: "textarea"} = assigns) do |
||||
~H""" |
||||
<div phx-feedback-for={@name}> |
||||
<.label for={@id}><%= @label %></.label> |
||||
<textarea |
||||
id={@id || @name} |
||||
name={@name} |
||||
class={[ |
||||
input_border(@errors), |
||||
"mt-2 block min-h-[6rem] w-full rounded-lg border-zinc-300 py-[calc(theme(spacing.2)-1px)] px-[calc(theme(spacing.3)-1px)]", |
||||
"text-zinc-900 focus:border-zinc-400 focus:outline-none focus:ring-4 focus:ring-zinc-800/5 sm:text-sm sm:leading-6", |
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5" |
||||
]} |
||||
{@rest} |
||||
><%= @value %></textarea> |
||||
<.error :for={msg <- @errors} message={msg} /> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
def input(assigns) do |
||||
~H""" |
||||
<div phx-feedback-for={@name}> |
||||
<.label for={@id}><%= @label %></.label> |
||||
<input |
||||
type={@type} |
||||
name={@name} |
||||
id={@id || @name} |
||||
value={@value} |
||||
class={[ |
||||
input_border(@errors), |
||||
"mt-2 block w-full rounded-lg border-zinc-300 py-[calc(theme(spacing.2)-1px)] px-[calc(theme(spacing.3)-1px)]", |
||||
"text-zinc-900 focus:outline-none focus:ring-4 sm:text-sm sm:leading-6", |
||||
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400 phx-no-feedback:focus:ring-zinc-800/5" |
||||
]} |
||||
{@rest} |
||||
/> |
||||
<.error :for={msg <- @errors} message={msg} /> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
defp input_border([] = _errors), |
||||
do: "border-zinc-300 focus:border-zinc-400 focus:ring-zinc-800/5" |
||||
|
||||
defp input_border([_ | _] = _errors), |
||||
do: "border-rose-400 focus:border-rose-400 focus:ring-rose-400/10" |
||||
|
||||
@doc """ |
||||
Renders a label. |
||||
""" |
||||
attr :for, :string, default: nil |
||||
slot :inner_block, required: true |
||||
|
||||
def label(assigns) do |
||||
~H""" |
||||
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800"> |
||||
<%= render_slot(@inner_block) %> |
||||
</label> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Generates a generic error message. |
||||
""" |
||||
attr :message, :string, required: true |
||||
|
||||
def error(assigns) do |
||||
~H""" |
||||
<p class="phx-no-feedback:hidden mt-3 flex gap-3 text-sm leading-6 text-rose-600"> |
||||
<Heroicons.exclamation_circle mini class="mt-0.5 h-5 w-5 flex-none fill-rose-500" /> |
||||
<%= @message %> |
||||
</p> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders a header with title. |
||||
""" |
||||
attr :class, :string, default: nil |
||||
|
||||
slot :inner_block, required: true |
||||
slot :subtitle |
||||
slot :actions |
||||
|
||||
def header(assigns) do |
||||
~H""" |
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}> |
||||
<div> |
||||
<h1 class="text-lg font-semibold leading-8 text-zinc-800"> |
||||
<%= render_slot(@inner_block) %> |
||||
</h1> |
||||
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600"> |
||||
<%= render_slot(@subtitle) %> |
||||
</p> |
||||
</div> |
||||
<div class="flex-none"><%= render_slot(@actions) %></div> |
||||
</header> |
||||
""" |
||||
end |
||||
|
||||
@doc ~S""" |
||||
Renders a table with generic styling. |
||||
|
||||
## Examples |
||||
|
||||
<.table rows={@users}> |
||||
<:col :let={user} label="id"><%= user.id %></:col> |
||||
<:col :let={user} label="username"><%= user.username %></:col> |
||||
</.table> |
||||
""" |
||||
attr :id, :string, required: true |
||||
attr :row_click, JS, default: nil |
||||
attr :rows, :list, required: true |
||||
|
||||
slot :col, required: true do |
||||
attr :label, :string |
||||
end |
||||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column" |
||||
|
||||
def table(assigns) do |
||||
~H""" |
||||
<div id={@id} class="overflow-y-auto px-4 sm:overflow-visible sm:px-0"> |
||||
<table class="mt-11 w-[40rem] sm:w-full"> |
||||
<thead class="text-left text-[0.8125rem] leading-6 text-zinc-500"> |
||||
<tr> |
||||
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th> |
||||
<th class="relative p-0 pb-4"><span class="sr-only">Actions</span></th> |
||||
</tr> |
||||
</thead> |
||||
<tbody class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"> |
||||
<tr |
||||
:for={row <- @rows} |
||||
id={"#{@id}-#{Phoenix.Param.to_param(row)}"} |
||||
class="group hover:bg-zinc-50" |
||||
> |
||||
<td |
||||
:for={{col, i} <- Enum.with_index(@col)} |
||||
phx-click={@row_click && @row_click.(row)} |
||||
class={["relative p-0", @row_click && "hover:cursor-pointer"]} |
||||
> |
||||
<div class="block py-4 pr-6"> |
||||
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" /> |
||||
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}> |
||||
<%= render_slot(col, row) %> |
||||
</span> |
||||
</div> |
||||
</td> |
||||
<td :if={@action != []} class="relative p-0 w-14"> |
||||
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium"> |
||||
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" /> |
||||
<span |
||||
:for={action <- @action} |
||||
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700" |
||||
> |
||||
<%= render_slot(action, row) %> |
||||
</span> |
||||
</div> |
||||
</td> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders a data list. |
||||
|
||||
## Examples |
||||
|
||||
<.list> |
||||
<:item title="Title"><%= @post.title %></:item> |
||||
<:item title="Views"><%= @post.views %></:item> |
||||
</.list> |
||||
""" |
||||
slot :item, required: true do |
||||
attr :title, :string, required: true |
||||
end |
||||
|
||||
def list(assigns) do |
||||
~H""" |
||||
<div class="mt-14"> |
||||
<dl class="-my-4 divide-y divide-zinc-100"> |
||||
<div :for={item <- @item} class="flex gap-4 py-4 sm:gap-8"> |
||||
<dt class="w-1/4 flex-none text-[0.8125rem] leading-6 text-zinc-500"><%= item.title %></dt> |
||||
<dd class="text-sm leading-6 text-zinc-700"><%= render_slot(item) %></dd> |
||||
</div> |
||||
</dl> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
@doc """ |
||||
Renders a back navigation link. |
||||
|
||||
## Examples |
||||
|
||||
<.back navigate={~p"/posts"}>Back to posts</.back> |
||||
""" |
||||
attr :navigate, :any, required: true |
||||
slot :inner_block, required: true |
||||
|
||||
def back(assigns) do |
||||
~H""" |
||||
<div class="mt-16"> |
||||
<.link |
||||
navigate={@navigate} |
||||
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700" |
||||
> |
||||
<Heroicons.arrow_left solid class="w-3 h-3 stroke-current inline" /> |
||||
<%= render_slot(@inner_block) %> |
||||
</.link> |
||||
</div> |
||||
""" |
||||
end |
||||
|
||||
## JS Commands |
||||
|
||||
def show(js \\ %JS{}, selector) do |
||||
JS.show(js, |
||||
to: selector, |
||||
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 |
||||
|
||||
def show_modal(js \\ %JS{}, id) when is_binary(id) do |
||||
js |
||||
|> JS.show(to: "##{id}") |
||||
|> JS.show( |
||||
to: "##{id}-bg", |
||||
transition: {"transition-all transform ease-out duration-300", "opacity-0", "opacity-100"} |
||||
) |
||||
|> show("##{id}-container") |
||||
|> JS.focus_first(to: "##{id}-content") |
||||
end |
||||
|
||||
def hide_modal(js \\ %JS{}, id) do |
||||
js |
||||
|> JS.hide( |
||||
to: "##{id}-bg", |
||||
transition: {"transition-all transform ease-in duration-200", "opacity-100", "opacity-0"} |
||||
) |
||||
|> hide("##{id}-container") |
||||
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"}) |
||||
|> JS.pop_focus() |
||||
end |
||||
|
||||
@doc """ |
||||
Translates an error message using gettext. |
||||
""" |
||||
def translate_error({msg, opts}) do |
||||
# When using gettext, we typically pass the strings we want |
||||
# to translate as a static argument: |
||||
# |
||||
# # Translate "is invalid" in the "errors" domain |
||||
# dgettext("errors", "is invalid") |
||||
# |
||||
# # Translate the number of files with plural rules |
||||
# dngettext("errors", "1 file", "%{count} files", count) |
||||
# |
||||
# Because the error messages we show in our forms and APIs |
||||
# are defined inside Ecto, we need to translate them dynamically. |
||||
# This requires us to call the Gettext module passing our gettext |
||||
# backend as first argument. |
||||
# |
||||
# Note we use the "errors" domain, which means translations |
||||
# should be written to the errors.po file. The :count option is |
||||
# set by Ecto and indicates we should also apply plural rules. |
||||
if count = opts[:count] do |
||||
Gettext.dngettext(ChirpWeb.Gettext, "errors", msg, msg, count, opts) |
||||
else |
||||
Gettext.dgettext(ChirpWeb.Gettext, "errors", msg, opts) |
||||
end |
||||
end |
||||
|
||||
@doc """ |
||||
Translates the errors for a field from a keyword list of errors. |
||||
""" |
||||
def translate_errors(errors, field) |