obliviously.., duh!

~^▼^~

ikwilvanmijnleraaraf.nl

include "std"
include "libweb"

use LibWeb
use TypedStruct

import Structo

typedstruct module: Quote do
  field :id, String.t()
  field :author, String.t()
  field :content, String.t()
  field :dt, DateTime.t()
  field :upvotes, integer(), default: 0
  field :downvotes, integer(), default: 0
end

def call(conn, params, _) do
  route = Map.get(params, "p", "/")

  if conn.host == "vik.dupunkto.org" do
    Process.put(:use_params, true)
  end

  case conn.method do
    "GET" -> handle_route(conn, route, params)
    "POST" -> handle_action(conn, route, params)
    _ -> handle_unsupported_method(conn)
  end
end

expose call: 3

def sigil_u(route, _) do
  if Process.get(:use_params), 
    do: "?p=#{route}", else: route
end

# Router

defp handle_route(conn, "/", _) do
  conn
  |> assign(:page_title, "Kwoouhts")
  |> assign(:quotes, KV.list(:quotes))
  |> send_template(&home/1)
end

defp handle_route(conn, "/main.css", _) do
  conn 
  |> put_resp_content_type("text/css")
  |> send_resp(200, stylesheet())
end

defp handle_route(conn, "/votes.js", _) do
  conn 
  |> put_resp_content_type("text/javascript")
  |> send_resp(200, javascript())
end

defp handle_route(conn, "/new", _) do
  conn
  |> assign(:page_title, "Submit new quote")
  |> send_template(&new/1)
end

defp handle_route(conn, _, _) do
  conn
  |> assign(:page_title, "Oops, that's a 404 :0")
  |> put_status(:not_found)
  |> send_template(404, &not_found/1)
end

# Controllers

def handle_action(conn, "/upvote", ~m{id}s) do
  if q = KV.get(:quotes, id) do
    q = %Quote{q | upvotes: q.upvotes + 1}
    :ok = KV.put(:quotes, q.id, q)

    send_json(conn, 201, %{"message" => "Upvoted.", "count" => q.upvotes})
  else
    send_json(conn, 400, %{"error" => "Missing 'id'."})
  end
end

def handle_action(conn, "/downvote", ~m{id}s) do
  if q = KV.get(:quotes, id) do
    q = %Quote{q | downvotes: q.downvotes + 1}
    :ok = KV.put(:quotes, q.id, q)

    send_json(conn, 201, %{"message" => "Downvoted.", "count" => q.downvotes})
  else
    send_json(conn, 400, %{"error" => "Missing 'id'."})
  end
end

def handle_action(conn, "/new", ~m{content, author}s) do
  id = generate_id()
  author = String.trim(author)
  content = String.trim(content)
  dt = DateTime.utc_now()

  KV.put(:quotes, id, ~m{:Quote, id, author, content, dt})

  conn
  |> put_resp_header("Location", ~u"/")
  |> send_json(302, %{"message" => "Redirected to #{~u"/"}"})
end

def handle_action(conn, _, params) do
  send_json(conn, 400, %{"error" => "Bad request. Got: #{inspect(params)}"})
end

defp generate_id do
  to_string(:erlang.ref_to_list(:erlang.make_ref()))
end

defp handle_unsupported_method(conn) do
  send_resp(conn, 405, %{"error" => "Method not supported."})
end

# Layouts

attr :page_title, :string, required: true
slot :inner_block, required: true

def root(assigns) do
  ~H"""
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <meta http-equiv="X-UA-Compatible" content="IE=edge" />
      <title>{@page_title} &mdash; ikwilvanmijnleraaraf.nl</title>
      <link rel="stylesheet" href={~u"/main.css"}>
      <script src={~u"/votes.js"} defer></script>
      <script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js"></script>
    </head>
    <body>
      <h1>{@page_title}</h1>
      {render_slot(@inner_block)}
    </body>
  </html>
  """
end

attr :page_title, :string, required: true
attr :quotes, :list, required: true

defp home(assigns) do
  ~H"""
  <.root {assigns}>
    <.button href={~u"/new"}>
      <.icon name="plus" />
      Submit new quote
    </.button>
    <section :for={{_, q} <- sort_by_hotness(@quotes)} class="quote">
      <.voting id={q.id} up={q.upvotes} down={q.downvotes} />
      <q class="content">{q.content}</q>
      <cite class="author">
        &mdash; {q.author},
        <time dt={q.dt}>{q.dt.year}</time>
      </cite>
    </section>
  </.root>
  """
end

attr :page_title, :string, required: true

defp new(assigns) do
  ~H"""
  <.root {assigns}>
    <.button href={~u"/"}>
      <.icon name="arrow-left" />
      Back to the homepage
    </.button>
    <form action={~u"/new"} method="post">
      <label for="author">Author</label>
      <input type="text" name="author" required />
      <label for="content">Quote</label>
      <textarea name="content" required></textarea>
      <button>
        Submit&nbsp;
        <.icon name="paper-airplane" />
      </button>
    </form>
  </.root>
  """
end

attr :page_title, :string, required: true

defp not_found(assigns) do
  ~H"""
  <.root {assigns}>
    <.button href={~u"/"}>Go to the homepage</.button>
  </.root>
  """
end

# Components

attr :id, :string, required: true
attr :up, :integer, required: true
attr :down, :integer, required: true

def voting(assigns) do
  ~H"""
  <div id={@id} class="votes">
    <button onclick={"upvote(event, '#{@id}')"}>
      <.icon name="arrow-trending-up" />
      <span id={"#{@id}-u"} class="upvotes">{@up}</span>
    </button>
    <button onclick={"downvote(event, '#{@id}')"}>
      <span id={"#{@id}-d"} class="downvotes">{@down}</span>
      <.icon name="arrow-trending-down" />
    </button>
  </div>
  """
end

attr :href, :string, required: true
slot :inner_block, required: true

def button(assigns) do
  ~H"""
  <div class="button-wrapper">
    <a href={@href}>{render_slot(@inner_block)}</a>
  </div>
  """
end

attr :name, :string, required: true

def icon(%{name: "arrow-left"} = assigns) do
  ~H"""
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
    <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
  </svg>
  """
end

def icon(%{name: "plus"} = assigns) do
  ~H"""
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
    <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
  </svg>
  """
end

def icon(%{name: "paper-airplane"} = assigns) do
  ~H"""
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
    <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
  </svg>
  """
end

def icon(%{name: "arrow-trending-up"} = assigns) do
  ~H"""
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
    <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941" />
  </svg>
  """
end

def icon(%{name: "arrow-trending-down"} = assigns) do
  ~H"""
  <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="icon">
    <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6 9 12.75l4.286-4.286a11.948 11.948 0 0 1 4.306 6.43l.776 2.898m0 0 3.182-5.511m-3.182 5.51-5.511-3.181" />
  </svg>
  """
end

# Styling

defp stylesheet do
  """
  @import url("https://cdn.geheimesite.nl/reset.css");

  :root {
    --bright-pink: #f3a8a8;
    --deep-purple: #071632;
    --bright-blue: #7194f0;
    --light-blue: #abc9f1;
    --teal: #008080;
    --dark-teal: #024848;
    --paper: #e6ecef;
    --light-grey: #ced4da;
    --rounded: 0.4125em;
  }
  
  body {
    color-scheme: light;
    font-family: sans-serif;
    background: var(--bright-pink);
    color: var(--deep-purple);
    padding: 25px;
    max-width: 80ch;
    margin: 0.5em auto;
  }
  
  /* Header */
  
  h1 {
    font-size: 2.5em;
  }

  @media (min-width: 510px) {
    h1 + .button-wrapper {
      float: right;
      margin-top: -4.3em;
    }
  }

  @media (max-width: 510px) {
    h1 + .button-wrapper {
      margin-top: -0.5em;
      margin-bottom: 3em;
    }
  }
  
  /* Forms */
  
  label {
    margin-top: 1em;
  }
  
  form {
    display: flex;
    flex-direction: column;
    width: fit-content;
    gap: 0.3em;
    width: 100%;
  }
  
  input,
  textarea {
    border: 1px solid var(--deep-purple);
    border-bottom: 4px solid var(--deep-purple);
    border-radius: var(--rounded);
    outline: none;
    padding: 0.5em;
    resize: none;
  }
  
  input:focus,
  textarea:focus {
    border-color: var(--deep-purple);
    border-bottom-color: var(--bright-blue)
  }
  
  /* Buttons */
  
  a,
  button,
  input[type="button"],
  input[type="submit"] {
    --border: 4px;
    --outset: 4px;
  
    display: block;
    cursor: default;
    text-decoration: none;
    background: var(--light-blue);
    color: var(--deep-purple);
    font-weight: 600;
    padding: 0.5em 1em;
    border: 1px solid var(--bright-blue);
    border-bottom-width: var(--border);
    border-radius: var(--rounded);
    transition: all 0.12s ease-out 0s;
  
    &:hover {
      filter: brightness(1.05);
      border-bottom-width: calc(var(--border) + var(--outset));
      margin-top: calc(-1 * var(--outset));
    }
  
    &:active {
      filter: brightness(0.98);
      border-bottom-width: 1px;
      margin-top: var(--border);
      animation: active-press 0.25s;
    }
  }
  
  @keyframes active-press {
    0% {
      -webkit-transform: translateY(-4px);
      transform: translateY(-4px);
    }
    50% {
      -webkit-transform: translateY(4px);
      transform: translateY(4px);
    }
    75% {
      -webkit-transform: translateY(2px);
      transform: translateY(2px);
    }
    100% {
      -webkit-transform: translateY(0);
      transform: translateY(0);
    }
  }
  
  /* Prevents elements below a button from jumping around */
  .button-wrapper {
    height: 40px;
    display: flex;
    align-items: flex-end;
  }
  
  /* Icons */
  
  .icon {
    width: 1em;
    vertical-align: sub;
  }
  
  /* Alignment inside of buttons */
  .button-wrapper .icon {
    margin-right: 0.35em;
    margin-left: -0.15em;
  }
  
  /* Quotes */
  
  .quote {
    background: var(--paper);
    color: var(--deep-purple);
    max-width: 780px;
    padding: 1.2em;
    margin-bottom: 20px;
    box-shadow: 18px 10px 0 0 var(--deep-purple);
    border-radius: var(--rounded);
  }
  
  q {
    font-size: 1.5em;
    margin-bottom: 0.3em;
  }
  
  q, cite {
    display: block;
  }
  
  .quote:nth-child(10n) { margin-left: -18px }
  .quote:nth-child(10n + 1) { margin-left: 18px }
  .quote:nth-child(10n + 2) { margin-left: -32px }
  .quote:nth-child(10n + 3) { margin-left: -8px }
  .quote:nth-child(10n + 4) { margin-left: -38px }
  .quote:nth-child(10n + 5) { margin-left: 28px }
  .quote:nth-child(10n + 6) { margin-left: -18px }
  .quote:nth-child(10n + 7) { margin-left: 18px }
  .quote:nth-child(10n + 8) { margin-left: -8px }
  .quote:nth-child(10n + 9) { margin-left: 32px }

  /* Votes */
  .votes {
    display: flex;
    margin-bottom: .6em;
    margin-left: 0.5em;
    width: fit-content;
    height: 40px;
    float: right;
  }

  .votes button {
    background: var(--light-grey);
    border-color: var(--deep-purple);
    border-radius: 0;
  }

  .votes.upvoted button:first-child {
    background: #20c997;
    color: #021912;
  }

  .votes.downvoted button:last-child {
    background:#ff6b6b;
    color: #3d0c0c;
  }
  
  .votes button:first-child {
    border-right: 0.5px solid var(--deep-purple);
    border-radius: 1000em 0 0 1000em;
  }
  
  .votes button:last-child {
    border-left: 0.5px solid var(--deep-purple);
    border-radius: 0 1000em 1000em 0;
  }
  """
end

defp javascript do 
  ~s"""
  function markVoted(id, state) {
    localStorage.setItem(id, state);
    document.getElementById(id).classList.add(state);
  }

  function hasVoted(id) {
    return !!localStorage.getItem(id);
  }
  
  function buildRequest(id) {
    return {
      method: "POST",
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ id: id })
    };
  }

  function recordVote(endpoint, id, selector) {
    const el = document.getElementById(selector);

    return fetch(endpoint, buildRequest(id))
      .then(r => r.json())
      .then(json => el.innerText = json.count)
  }
  
  function upvote(e, id) {
    const x = e.clientX / window.innerWidth;
    const y = e.clientY / window.innerHeight;

    if(hasVoted(id)) return;

    recordVote('#{~u"/upvote"}', id, id + '-u').then(() => {  
      confetti({ origin: { x, y } });
      markVoted(id, "upvoted");
    });
  }

  function downvote(e, id) {
    if(hasVoted(id)) return;

    recordVote('#{~u"/downvote"}', id, id + '-d').then(() => {
      markVoted(id, "downvoted");
    });
  }

  window.onload = () => {
    for (const [id, state] of Object.entries({...localStorage})) {
      markVoted(id, state);
    }
  }
  """
end

# The Algorithm(TM)

def sort_by_hotness(quotes) do
  now = DateTime.utc_now()

  quotes
  |> Enum.map(&{hotness(&1, now), &1})
  |> Enum.sort_by(fn {score, _} -> -score end)
end

defp hotness(~m{:Quote, upvotes, downvotes, dt}, now) do
  hotness(upvotes, downvotes, DateTime.diff(now, dt, :second) / 3600)
end

@negativity 40 # higher means downvotes weigh more
@coolness 1.2 # higher means quotes cool faster
@fossilness 20 # quotes older than 20 hours cool

def hotness(upvotes, downvotes, age) do
  # The +0.001 is to make sure the score or engagement is never
  # zero, which would cause a random ordering instead of
  # decayful ordering.
  # engagement = 0.001 + upvotes + (1/@negativity) * downvotes
  score = 0.001 + upvotes - @negativity * downvotes

  # Decay: the older the post, the smaller the effective score.
  decay = :math.pow(max(age - @fossilness, 1), @coolness)

  score - decay
end

Source code for ikwilvanmijnleraaraf.nl