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, ¬_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} — 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">
— {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
<.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