add: postgres support
chore: restructure
This commit is contained in:
parent
cda879f6df
commit
b6fe2fba37
58 changed files with 3403 additions and 603 deletions
1117
crates/app/src/public/css/style.css
Normal file
1117
crates/app/src/public/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
9
crates/app/src/public/html/auth/base.html
Normal file
9
crates/app/src/public/html/auth/base.html
Normal file
|
@ -0,0 +1,9 @@
|
|||
{% extends "root.html" %} {% block body %}
|
||||
<main class="flex flex-col gap-2" style="max-width: 25rem">
|
||||
<h2 class="w-full text-center">{% block title %}{% endblock %}</h2>
|
||||
<div class="card w-full flex flex-col gap-4 justify-center align-center">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% block footer %}{% endblock %}
|
||||
</main>
|
||||
{% endblock %}
|
33
crates/app/src/public/html/auth/login.html
Normal file
33
crates/app/src/public/html/auth/login.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{% extends "auth/base.html" %} {% block head %}
|
||||
<title>🐐 Login</title>
|
||||
{% endblock %} {% block title %}Login{% endblock %} {% block content %}
|
||||
<form class="w-full flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="username"><b>Username</b></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
required
|
||||
name="username"
|
||||
id="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="username"><b>Password</b></label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="password"
|
||||
required
|
||||
name="password"
|
||||
id="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
{% endblock %} {% block footer %}
|
||||
<span class="small w-full text-center"
|
||||
>Or, <a href="/auth/register">register</a></span
|
||||
>
|
||||
{% endblock %}
|
62
crates/app/src/public/html/auth/register.html
Normal file
62
crates/app/src/public/html/auth/register.html
Normal file
|
@ -0,0 +1,62 @@
|
|||
{% extends "auth/base.html" %} {% block head %}
|
||||
<title>🐐 Register</title>
|
||||
{% endblock %} {% block title %}Register{% endblock %} {% block content %}
|
||||
<form class="w-full flex flex-col gap-4" onsubmit="register(event)">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="username"><b>Username</b></label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="username"
|
||||
required
|
||||
name="username"
|
||||
id="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="username"><b>Password</b></label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="password"
|
||||
required
|
||||
name="password"
|
||||
id="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function register(e) {
|
||||
e.preventDefault();
|
||||
fetch("/api/v1/auth/register", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: e.target.username.value,
|
||||
password: e.target.password.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger("atto::toast", [
|
||||
res.ok ? "sucesss" : "error",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %} {% block footer %}
|
||||
<span class="small w-full text-center"
|
||||
>Or, <a href="/auth/login">login</a></span
|
||||
>
|
||||
{% endblock %}
|
45
crates/app/src/public/html/macros.html
Normal file
45
crates/app/src/public/html/macros.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{% macro nav(selected="", show_lhs=true) -%}
|
||||
<nav>
|
||||
<div class="content_container">
|
||||
<div class="flex nav_side">
|
||||
<a href="/" class="button desktop title">
|
||||
<b>{{ config.name }}</b>
|
||||
</a>
|
||||
|
||||
{% if show_lhs %}
|
||||
<a
|
||||
href="/"
|
||||
class="button {% if selected == 'home' %}active{% endif %}"
|
||||
>Home</a
|
||||
>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex nav_side">
|
||||
{% if user %}
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="flex-row title"
|
||||
onclick="trigger('atto::hooks::dropdown', [event])"
|
||||
exclude="dropdown"
|
||||
style="gap: 0.25rem !important"
|
||||
>
|
||||
{{ macros::avatar(username=user.username, size="24px") }}
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/auth/login" class="button">Login</a>
|
||||
<a href="/auth/register" class="button">Register</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{%- endmacro %} {% macro avatar(username, size="24px") -%}
|
||||
<img
|
||||
title="{{ username }}'s avatar"
|
||||
src="/api/v1/auth/profile/{{ username }}/avatar"
|
||||
alt="@{{ username }}"
|
||||
class="avatar shadow"
|
||||
style="--size: {{ size }}"
|
||||
/>
|
||||
{%- endmacro %}
|
23
crates/app/src/public/html/misc/index.html
Normal file
23
crates/app/src/public/html/misc/index.html
Normal file
|
@ -0,0 +1,23 @@
|
|||
{% import "macros.html" as macros %} {% extends "root.html" %} {% block body %}
|
||||
{{ macros::nav(selected="home") }}
|
||||
|
||||
<main class="flex flex-col gap-2">
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
<div class="pillmenu">
|
||||
<a class="active" href="#">A</a>
|
||||
<a href="#">B</a>
|
||||
<a href="#">C</a>
|
||||
</div>
|
||||
|
||||
<div class="card w-full flex flex-col gap-2">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button>Hello, world!</button>
|
||||
<button class="secondary">Hello, world!</button>
|
||||
<button class="camo">Hello, world!</button>
|
||||
</div>
|
||||
|
||||
<input type="text" placeholder="abcd" />
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
73
crates/app/src/public/html/root.html
Normal file
73
crates/app/src/public/html/root.html
Normal file
|
@ -0,0 +1,73 @@
|
|||
<!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" />
|
||||
|
||||
<link rel="stylesheet" href="/css/style.css" />
|
||||
|
||||
<script src="/js/loader.js"></script>
|
||||
<script defer async src="/js/atto.js"></script>
|
||||
|
||||
<script>
|
||||
globalThis.ns_verbose = false;
|
||||
globalThis.ns_config = {
|
||||
root: "/js/",
|
||||
verbose: globalThis.ns_verbose,
|
||||
};
|
||||
|
||||
globalThis._app_base = {
|
||||
name: "tetratto",
|
||||
ns_store: {},
|
||||
classes: {},
|
||||
};
|
||||
|
||||
globalThis.no_policy = false;
|
||||
</script>
|
||||
|
||||
<meta name="theme-color" content="{{ config.color }}" />
|
||||
<meta name="description" content="{{ config.description }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="{{ config.name }}" />
|
||||
|
||||
<meta name="turbo-prefetch" content="false" />
|
||||
<meta name="turbo-refresh-method" content="morph" />
|
||||
<meta name="turbo-refresh-scroll" content="preserve" />
|
||||
|
||||
<script
|
||||
src="https://unpkg.com/@hotwired/turbo@8.0.5/dist/turbo.es2017-esm.js"
|
||||
type="module"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="toast_zone"></div>
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<script data-turbo-permanent="true" id="init-script">
|
||||
document.documentElement.addEventListener("turbo:load", () => {
|
||||
const atto = ns("atto");
|
||||
|
||||
atto.disconnect_observers();
|
||||
atto.clean_date_codes();
|
||||
atto.link_filter();
|
||||
|
||||
atto["hooks::scroll"](document.body, document.documentElement);
|
||||
atto["hooks::dropdown.init"](window);
|
||||
atto["hooks::character_counter.init"]();
|
||||
atto["hooks::long_text.init"]();
|
||||
atto["hooks::alt"]();
|
||||
// atto["hooks::ips"]();
|
||||
atto["hooks::check_reactions"]();
|
||||
atto["hooks::tabs"]();
|
||||
atto["hooks::partial_embeds"]();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
3
crates/app/src/public/images/default-avatar.svg
Normal file
3
crates/app/src/public/images/default-avatar.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="460" height="460" fill="#C9B1BC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 159 B |
3
crates/app/src/public/images/default-banner.svg
Normal file
3
crates/app/src/public/images/default-banner.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg width="460" height="460" viewBox="0 0 460 460" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="460" height="460" fill="#C9B1BC"/>
|
||||
</svg>
|
After Width: | Height: | Size: 159 B |
618
crates/app/src/public/js/atto.js
Normal file
618
crates/app/src/public/js/atto.js
Normal file
|
@ -0,0 +1,618 @@
|
|||
console.log("🐐 tetratto - https://github.com/trisuaso/tetratto");
|
||||
|
||||
// theme preference
|
||||
function media_theme_pref() {
|
||||
document.documentElement.removeAttribute("class");
|
||||
|
||||
if (
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches &&
|
||||
!window.localStorage.getItem("tetratto:theme")
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
// window.localStorage.setItem("theme", "dark");
|
||||
} else if (
|
||||
window.matchMedia("(prefers-color-scheme: light)").matches &&
|
||||
!window.localStorage.getItem("tetratto:theme")
|
||||
) {
|
||||
document.documentElement.classList.remove("dark");
|
||||
// window.localStorage.setItem("theme", "light");
|
||||
} else if (window.localStorage.getItem("tetratto:theme")) {
|
||||
/* restore theme */
|
||||
const current = window.localStorage.getItem("tetratto:theme");
|
||||
document.documentElement.className = current;
|
||||
}
|
||||
}
|
||||
|
||||
function set_theme(theme) {
|
||||
window.localStorage.setItem("tetratto:theme", theme);
|
||||
document.documentElement.className = theme;
|
||||
}
|
||||
|
||||
media_theme_pref();
|
||||
|
||||
// atto ns
|
||||
(() => {
|
||||
const self = reg_ns("atto");
|
||||
|
||||
// env
|
||||
self.DEBOUNCE = [];
|
||||
self.OBSERVERS = [];
|
||||
|
||||
// ...
|
||||
self.define("try_use", (_, ns_name, callback) => {
|
||||
// attempt to get existing namespace
|
||||
if (globalThis._app_base.ns_store[`$${ns_name}`]) {
|
||||
return callback(globalThis._app_base.ns_store[`$${ns_name}`]);
|
||||
}
|
||||
|
||||
// otherwise, call normal use
|
||||
use(ns_name, callback);
|
||||
});
|
||||
|
||||
self.define("debounce", ({ $ }, name) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ($.DEBOUNCE.includes(name)) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
$.DEBOUNCE.push(name);
|
||||
|
||||
setTimeout(() => {
|
||||
delete $.DEBOUNCE[$.DEBOUNCE.indexOf(name)];
|
||||
}, 1000);
|
||||
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
|
||||
self.define("rel_date", (_, date) => {
|
||||
// stolen and slightly modified because js dates suck
|
||||
const diff = (new Date().getTime() - date.getTime()) / 1000;
|
||||
const day_diff = Math.floor(diff / 86400);
|
||||
|
||||
if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
(day_diff === 0 &&
|
||||
((diff < 60 && "just now") ||
|
||||
(diff < 120 && "1 minute ago") ||
|
||||
// biome-ignore lint/style/useTemplate: ok
|
||||
(diff < 3600 && Math.floor(diff / 60) + " minutes ago") ||
|
||||
(diff < 7200 && "1 hour ago") ||
|
||||
(diff < 86400 &&
|
||||
// biome-ignore lint/style/useTemplate: ok
|
||||
Math.floor(diff / 3600) + " hours ago"))) ||
|
||||
(day_diff === 1 && "Yesterday") ||
|
||||
// biome-ignore lint/style/useTemplate: ok
|
||||
(day_diff < 7 && day_diff + " days ago") ||
|
||||
// biome-ignore lint/style/useTemplate: ok
|
||||
(day_diff < 31 && Math.ceil(day_diff / 7) + " weeks ago")
|
||||
);
|
||||
});
|
||||
|
||||
self.define("clean_date_codes", ({ $ }) => {
|
||||
for (const element of Array.from(document.querySelectorAll(".date"))) {
|
||||
if (element.getAttribute("data-unix")) {
|
||||
// this allows us to run the function twice on the same page
|
||||
// without errors from already rendered dates
|
||||
element.innerText = element.getAttribute("data-unix");
|
||||
}
|
||||
|
||||
element.setAttribute("data-unix", element.innerText);
|
||||
const then = new Date(Number.parseInt(element.innerText));
|
||||
|
||||
if (Number.isNaN(element.innerText)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.setAttribute("title", then.toLocaleString());
|
||||
|
||||
let pretty = $.rel_date(then);
|
||||
|
||||
if (screen.width < 900 && pretty !== undefined) {
|
||||
// shorten dates even more for mobile
|
||||
pretty = pretty
|
||||
.replaceAll(" minutes ago", "m")
|
||||
.replaceAll(" minute ago", "m")
|
||||
.replaceAll(" hours ago", "h")
|
||||
.replaceAll(" hour ago", "h")
|
||||
.replaceAll(" days ago", "d")
|
||||
.replaceAll(" day ago", "d")
|
||||
.replaceAll(" weeks ago", "w")
|
||||
.replaceAll(" week ago", "w")
|
||||
.replaceAll(" months ago", "m")
|
||||
.replaceAll(" month ago", "m")
|
||||
.replaceAll(" years ago", "y")
|
||||
.replaceAll(" year ago", "y");
|
||||
}
|
||||
|
||||
element.innerText =
|
||||
pretty === undefined ? then.toLocaleDateString() : pretty;
|
||||
|
||||
element.style.display = "inline-block";
|
||||
}
|
||||
});
|
||||
|
||||
self.define("copy_text", ({ $ }, text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
$.toast("success", "Copied!");
|
||||
});
|
||||
|
||||
self.define("smooth_remove", (_, element, ms) => {
|
||||
// run animation
|
||||
element.style.animation = `fadeout ease-in-out 1 ${ms}ms forwards running`;
|
||||
|
||||
// remove
|
||||
setTimeout(() => {
|
||||
element.remove();
|
||||
}, ms);
|
||||
});
|
||||
|
||||
self.define("disconnect_observers", ({ $ }) => {
|
||||
for (const observer of $.OBSERVERS) {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
$.OBSERVERS = [];
|
||||
});
|
||||
|
||||
self.define("offload_work_to_client_when_in_view", (_, entry_callback) => {
|
||||
// instead of spending the time on the server loading everything before
|
||||
// returning the page, we can instead of just create an IntersectionObserver
|
||||
// and send individual requests as we see the element it's needed for
|
||||
const seen = [];
|
||||
return new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const element = entry.target;
|
||||
if (!entry.isIntersecting || seen.includes(element)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.push(element);
|
||||
entry_callback(element);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: document.body,
|
||||
rootMargin: "0px",
|
||||
threshold: 1.0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
self.define("toggle_flex", (_, element) => {
|
||||
if (element.style.display === "none") {
|
||||
element.style.display = "flex";
|
||||
} else {
|
||||
element.style.display = "none";
|
||||
}
|
||||
});
|
||||
|
||||
// hooks
|
||||
self.define("hooks::scroll", (_, scroll_element, track_element) => {
|
||||
const goals = [150, 250, 500, 1000];
|
||||
|
||||
track_element.setAttribute("data-scroll", "0");
|
||||
scroll_element.addEventListener("scroll", (e) => {
|
||||
track_element.setAttribute("data-scroll", scroll_element.scrollTop);
|
||||
|
||||
for (const goal of goals) {
|
||||
const name = `data-scroll-${goal}`;
|
||||
if (scroll_element.scrollTop >= goal) {
|
||||
track_element.setAttribute(name, "true");
|
||||
} else {
|
||||
track_element.removeAttribute(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.define("hooks::dropdown.close", (_) => {
|
||||
for (const dropdown of Array.from(
|
||||
document.querySelectorAll(".inner.open"),
|
||||
)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
self.define("hooks::dropdown", ({ $ }, event) => {
|
||||
event.stopImmediatePropagation();
|
||||
let target = event.target;
|
||||
|
||||
while (!target.matches(".dropdown")) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
// close all others
|
||||
$["hooks::dropdown.close"]();
|
||||
|
||||
// open
|
||||
setTimeout(() => {
|
||||
for (const dropdown of Array.from(
|
||||
target.querySelectorAll(".inner"),
|
||||
)) {
|
||||
// check y
|
||||
const box = target.getBoundingClientRect();
|
||||
|
||||
let parent = dropdown.parentElement;
|
||||
|
||||
while (!parent.matches("html, .window")) {
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
let parent_height = parent.getBoundingClientRect().y;
|
||||
|
||||
if (parent.nodeName === "HTML") {
|
||||
parent_height = window.screen.height;
|
||||
}
|
||||
|
||||
const scroll = window.scrollY;
|
||||
const height = parent_height;
|
||||
const y = box.y + scroll;
|
||||
|
||||
if (y > height - scroll - 300) {
|
||||
dropdown.classList.add("top");
|
||||
} else {
|
||||
dropdown.classList.remove("top");
|
||||
}
|
||||
|
||||
// open
|
||||
dropdown.classList.add("open");
|
||||
|
||||
if (dropdown.classList.contains("open")) {
|
||||
dropdown.removeAttribute("aria-hidden");
|
||||
} else {
|
||||
dropdown.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
}, 5);
|
||||
});
|
||||
|
||||
self.define("hooks::dropdown.init", (_, bind_to) => {
|
||||
for (const dropdown of Array.from(
|
||||
document.querySelectorAll(".inner"),
|
||||
)) {
|
||||
dropdown.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
bind_to.addEventListener("click", (event) => {
|
||||
if (
|
||||
event.target.matches(".dropdown") ||
|
||||
event.target.matches("[exclude=dropdown]")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const dropdown of Array.from(
|
||||
document.querySelectorAll(".inner.open"),
|
||||
)) {
|
||||
dropdown.classList.remove("open");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.define("hooks::character_counter", (_, event) => {
|
||||
let target = event.target;
|
||||
|
||||
while (!target.matches("textarea, input")) {
|
||||
target = target.parentElement;
|
||||
}
|
||||
|
||||
const counter = document.getElementById(`${target.id}:counter`);
|
||||
counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`;
|
||||
});
|
||||
|
||||
self.define("hooks::character_counter.init", (_, event) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=counter]") || [],
|
||||
)) {
|
||||
const counter = document.getElementById(`${element.id}:counter`);
|
||||
counter.innerText = `0/${element.getAttribute("maxlength")}`;
|
||||
element.addEventListener("keyup", (e) =>
|
||||
app["hooks::character_counter"](e),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.define("hooks::long", (_, element, full_text) => {
|
||||
element.classList.remove("hook:long.hidden_text");
|
||||
element.innerHTML = full_text;
|
||||
});
|
||||
|
||||
self.define("hooks::long_text.init", (_, event) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=long]") || [],
|
||||
)) {
|
||||
const is_long = element.innerText.length >= 64 * 16;
|
||||
|
||||
if (!is_long) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.classList.add("hook:long.hidden_text");
|
||||
|
||||
if (element.getAttribute("hook-arg") === "lowered") {
|
||||
element.classList.add("hook:long.hidden_text+lowered");
|
||||
}
|
||||
|
||||
const html = element.innerHTML;
|
||||
const short = html.slice(0, 64 * 16);
|
||||
element.innerHTML = `${short}...`;
|
||||
|
||||
// event
|
||||
const listener = () => {
|
||||
app["hooks::long"](element, html);
|
||||
element.removeEventListener("click", listener);
|
||||
};
|
||||
|
||||
element.addEventListener("click", listener);
|
||||
}
|
||||
});
|
||||
|
||||
self.define("hooks::alt", (_) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("img") || [],
|
||||
)) {
|
||||
if (element.getAttribute("alt") && !element.getAttribute("title")) {
|
||||
element.setAttribute("title", element.getAttribute("alt"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
self.define(
|
||||
"hooks::attach_to_partial",
|
||||
({ $ }, partial, full, attach, wrapper, page, run_on_load) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
async function load_partial() {
|
||||
const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`;
|
||||
history.replaceState(
|
||||
history.state,
|
||||
"",
|
||||
url.replace(partial, full),
|
||||
);
|
||||
|
||||
fetch(url)
|
||||
.then(async (res) => {
|
||||
const text = await res.text();
|
||||
|
||||
if (
|
||||
text.length < 100 ||
|
||||
text.includes('data-marker="no-results"')
|
||||
) {
|
||||
// pretty much blank content, no more pages
|
||||
wrapper.removeEventListener("scroll", event);
|
||||
|
||||
return resolve();
|
||||
}
|
||||
|
||||
attach.innerHTML += text;
|
||||
|
||||
$.clean_date_codes();
|
||||
$.link_filter();
|
||||
$["hooks::alt"]();
|
||||
})
|
||||
.catch(() => {
|
||||
// done scrolling, no more pages (http error)
|
||||
wrapper.removeEventListener("scroll", event);
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const event = async () => {
|
||||
if (
|
||||
wrapper.scrollTop + wrapper.offsetHeight + 100 >
|
||||
attach.offsetHeight
|
||||
) {
|
||||
self.debounce("app::partials")
|
||||
.then(async () => {
|
||||
if (document.getElementById("initial_loader")) {
|
||||
console.log("partial blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
// biome-ignore lint/style/noParameterAssign: no it isn't
|
||||
page += 1;
|
||||
await load_partial();
|
||||
await $["hooks::partial_embeds"]();
|
||||
})
|
||||
.catch(() => {
|
||||
console.log("partial stuck");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener("scroll", event);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
self.define("hooks::partial_embeds", (_) => {
|
||||
for (const paragraph of Array.from(
|
||||
document.querySelectorAll("span[class] p"),
|
||||
)) {
|
||||
const groups = /(\/\+r\/)([\w]+)/.exec(paragraph.innerText);
|
||||
|
||||
if (groups === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// add embed
|
||||
paragraph.innerText = paragraph.innerText.replace(groups[0], "");
|
||||
paragraph.parentElement.innerHTML += `<include-partial
|
||||
src="/_app/components/response.html?id=${groups[2]}&do_render_nested=false"
|
||||
uses="app::clean_date_codes,app::link_filter,app::hooks::alt"
|
||||
></include-partial>`;
|
||||
}
|
||||
});
|
||||
|
||||
self.define("hooks::check_reactions", async ({ $ }) => {
|
||||
const observer = $.offload_work_to_client_when_in_view(
|
||||
async (element) => {
|
||||
const reaction = await (
|
||||
await fetch(
|
||||
`/api/v1/reactions/${element.getAttribute("hook-arg:id")}`,
|
||||
)
|
||||
).json();
|
||||
|
||||
if (reaction.success) {
|
||||
element.classList.add("green");
|
||||
element.querySelector("svg").classList.add("filled");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[hook=check_reaction]") || [],
|
||||
)) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
$.OBSERVERS.push(observer);
|
||||
});
|
||||
|
||||
self.define("hooks::tabs:switch", (_, tab) => {
|
||||
// tab
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[data-tab]"),
|
||||
)) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(`[data-tab="${tab}"]`)
|
||||
.classList.remove("hidden");
|
||||
|
||||
// button
|
||||
if (document.querySelector(`[data-tab-button="${tab}"]`)) {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll("[data-tab-button]"),
|
||||
)) {
|
||||
element.classList.remove("active");
|
||||
}
|
||||
|
||||
document
|
||||
.querySelector(`[data-tab-button="${tab}"]`)
|
||||
.classList.add("active");
|
||||
}
|
||||
});
|
||||
|
||||
self.define("hooks::tabs:check", ({ $ }, hash) => {
|
||||
if (!hash || !hash.startsWith("#/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
$["hooks::tabs:switch"](hash.replace("#/", ""));
|
||||
});
|
||||
|
||||
self.define("hooks::tabs", ({ $ }) => {
|
||||
$["hooks::tabs:check"](window.location.hash); // initial check
|
||||
window.addEventListener("hashchange", (event) =>
|
||||
$["hooks::tabs:check"](new URL(event.newURL).hash),
|
||||
);
|
||||
});
|
||||
|
||||
// web api replacements
|
||||
self.define("prompt", (_, msg) => {
|
||||
const dialog = document.getElementById("web_api_prompt");
|
||||
document.getElementById("web_api_prompt:msg").innerText = msg;
|
||||
|
||||
return new Promise((resolve, _) => {
|
||||
globalThis.web_api_prompt_submit = (value) => {
|
||||
dialog.close();
|
||||
return resolve(value);
|
||||
};
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
self.define("prompt_long", (_, msg) => {
|
||||
const dialog = document.getElementById("web_api_prompt_long");
|
||||
document.getElementById("web_api_prompt_long:msg").innerText = msg;
|
||||
|
||||
return new Promise((resolve, _) => {
|
||||
globalThis.web_api_prompt_long_submit = (value) => {
|
||||
dialog.close();
|
||||
return resolve(value);
|
||||
};
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
self.define("confirm", (_, msg) => {
|
||||
const dialog = document.getElementById("web_api_confirm");
|
||||
document.getElementById("web_api_confirm:msg").innerText = msg;
|
||||
|
||||
return new Promise((resolve, _) => {
|
||||
globalThis.web_api_confirm_submit = (value) => {
|
||||
dialog.close();
|
||||
return resolve(value);
|
||||
};
|
||||
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
|
||||
// toast
|
||||
self.define("toast", ({ $ }, type, content, time_until_remove = 5) => {
|
||||
const element = document.createElement("div");
|
||||
element.id = "toast";
|
||||
element.classList.add(type);
|
||||
element.classList.add("toast");
|
||||
element.innerHTML = `<span>${content
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")}</span>`;
|
||||
|
||||
document.getElementById("toast_zone").prepend(element);
|
||||
|
||||
const timer = document.createElement("span");
|
||||
element.appendChild(timer);
|
||||
|
||||
timer.innerText = time_until_remove;
|
||||
timer.classList.add("timer");
|
||||
|
||||
// start timer
|
||||
setTimeout(() => {
|
||||
clearInterval(count_interval);
|
||||
$.smooth_remove(element, 500);
|
||||
}, time_until_remove * 1000);
|
||||
|
||||
const count_interval = setInterval(() => {
|
||||
// biome-ignore lint/style/noParameterAssign: no it isn't
|
||||
time_until_remove -= 1;
|
||||
timer.innerText = time_until_remove;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// link filter
|
||||
self.define("link_filter", (_) => {
|
||||
for (const anchor of Array.from(document.querySelectorAll("a"))) {
|
||||
if (anchor.href.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = new URL(anchor.href);
|
||||
if (
|
||||
anchor.href.startsWith("/") ||
|
||||
anchor.href.startsWith("javascript:") ||
|
||||
url.origin === window.location.origin
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById("link_filter_url").innerText =
|
||||
anchor.href;
|
||||
document.getElementById("link_filter_continue").href =
|
||||
anchor.href;
|
||||
document.getElementById("link_filter").showModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
205
crates/app/src/public/js/loader.js
Normal file
205
crates/app/src/public/js/loader.js
Normal file
|
@ -0,0 +1,205 @@
|
|||
//! https://github.com/trisuaso/tetratto
|
||||
globalThis.ns_config = globalThis.ns_config || {
|
||||
root: "/static/js/",
|
||||
version: 0,
|
||||
verbose: true,
|
||||
};
|
||||
|
||||
globalThis._app_base = globalThis._app_base || { ns_store: {}, classes: {} };
|
||||
|
||||
function regns_log(level, ...args) {
|
||||
if (globalThis.ns_config.verbose) {
|
||||
console[level](...args);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Query an existing namespace
|
||||
globalThis.ns = (ns) => {
|
||||
regns_log("info", "namespace query:", ns);
|
||||
|
||||
// get namespace from app base
|
||||
const res = globalThis._app_base.ns_store[`$${ns}`];
|
||||
|
||||
if (!res) {
|
||||
return console.error(
|
||||
"namespace does not exist, please use one of the following:",
|
||||
Object.keys(globalThis._app_base.ns_store),
|
||||
);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
/// Register a new namespace
|
||||
globalThis.reg_ns = (ns, deps) => {
|
||||
if (typeof ns !== "string") {
|
||||
return console.error("type check failed on namespace:", ns);
|
||||
}
|
||||
|
||||
if (!ns) {
|
||||
return console.error("cannot register invalid namespace!");
|
||||
}
|
||||
|
||||
if (globalThis._app_base.ns_store[`$${ns}`]) {
|
||||
regns_log("warn", "overwriting existing namespace:", ns);
|
||||
}
|
||||
|
||||
// register new blank namespace
|
||||
globalThis._app_base.ns_store[`$${ns}`] = {
|
||||
_ident: ns,
|
||||
_deps: deps || [],
|
||||
/// Pull dependencies (other namespaces) as listed in the given `deps` argument
|
||||
_get_deps: () => {
|
||||
const self = globalThis._app_base.ns_store[`$${ns}`];
|
||||
const deps = {};
|
||||
|
||||
for (const dep of self._deps) {
|
||||
const res = globalThis.ns(dep);
|
||||
|
||||
if (!res) {
|
||||
regns_log("warn", "failed to pull dependency:", dep);
|
||||
continue;
|
||||
}
|
||||
|
||||
deps[dep] = res;
|
||||
}
|
||||
|
||||
deps.$ = self; // give access to self through $
|
||||
return deps;
|
||||
},
|
||||
/// Store the real versions of functions
|
||||
_fn_store: {},
|
||||
/// Call a function in a namespace and load namespace dependencies
|
||||
define: (name, func, types) => {
|
||||
const self = globalThis.ns(ns);
|
||||
self._fn_store[name] = func; // store real function
|
||||
self[name] = function (...args) {
|
||||
regns_log("info", "namespace call:", ns, name);
|
||||
|
||||
// js doesn't provide type checking, we do
|
||||
if (types) {
|
||||
for (const i in args) {
|
||||
// biome-ignore lint: this is incorrect, you do not need a string literal to use typeof
|
||||
if (types[i] && typeof args[i] !== types[i]) {
|
||||
return console.error(
|
||||
"argument does not pass type check:",
|
||||
i,
|
||||
args[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
// we MUST return here, otherwise nothing will work in workers
|
||||
return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
regns_log("log", "registered namespace:", ns);
|
||||
return globalThis._app_base.ns_store[`$${ns}`];
|
||||
};
|
||||
|
||||
/// Call a namespace function quickly
|
||||
globalThis.trigger = (id, args) => {
|
||||
// get namespace
|
||||
const s = id.split("::");
|
||||
const [namespace, func] = [s[0], s.slice(1, s.length).join("::")];
|
||||
const self = ns(namespace);
|
||||
|
||||
if (!self) {
|
||||
return console.error("namespace does not exist:", namespace);
|
||||
}
|
||||
|
||||
if (!self[func]) {
|
||||
return console.error("namespace function does not exist:", id);
|
||||
}
|
||||
|
||||
return self[func](...(args || []));
|
||||
};
|
||||
|
||||
/// Import a namespace from path (relative to ns_config.root)
|
||||
globalThis.use = (id, callback) => {
|
||||
let file = id;
|
||||
|
||||
if (id.includes(".h.")) {
|
||||
const split = id.split(".h.");
|
||||
file = split[1];
|
||||
}
|
||||
|
||||
// check if namespace already exists
|
||||
const res = globalThis._app_base.ns_store[`$${file}`];
|
||||
|
||||
if (res) {
|
||||
return callback(res);
|
||||
}
|
||||
|
||||
// create script to load
|
||||
const script = document.createElement("script");
|
||||
script.src = `${globalThis.ns_config.root}${id}.js?v=${globalThis.ns_config.version}`;
|
||||
script.id = `${globalThis.ns_config.version}-${file}.js`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.setAttribute("data-turbo-permanent", "true");
|
||||
script.setAttribute("data-registered", new Date().toISOString());
|
||||
script.setAttribute("data-version", globalThis.ns_config.version);
|
||||
|
||||
// run callback once the script loads
|
||||
script.addEventListener("load", () => {
|
||||
const res = globalThis._app_base.ns_store[`$${file}`];
|
||||
|
||||
if (!res) {
|
||||
return console.error("imported namespace failed to register:", id);
|
||||
}
|
||||
|
||||
callback(res);
|
||||
});
|
||||
};
|
||||
|
||||
// classes
|
||||
|
||||
/// Import a class from path (relative to ns_config.root/classes)
|
||||
globalThis.require = (id, callback) => {
|
||||
let file = id;
|
||||
|
||||
if (id.includes(".h.")) {
|
||||
const split = id.split(".h.");
|
||||
file = split[1];
|
||||
}
|
||||
|
||||
// check if class already exists
|
||||
const res = globalThis._app_base.classes[file];
|
||||
|
||||
if (res) {
|
||||
return callback(res);
|
||||
}
|
||||
|
||||
// create script to load
|
||||
const script = document.createElement("script");
|
||||
script.src = `${globalThis.ns_config.root}classes/${id}.js?v=${globalThis.ns_config.version}`;
|
||||
script.id = `${globalThis.ns_config.version}-${file}.class.js`;
|
||||
document.head.appendChild(script);
|
||||
|
||||
script.setAttribute("data-turbo-permanent", "true");
|
||||
script.setAttribute("data-registered", new Date().toISOString());
|
||||
script.setAttribute("data-version", globalThis.ns_config.version);
|
||||
|
||||
// run callback once the script loads
|
||||
script.addEventListener("load", () => {
|
||||
const res = globalThis._app_base.classes[file];
|
||||
|
||||
if (!res) {
|
||||
return console.error("imported class failed to register:", id);
|
||||
}
|
||||
|
||||
callback(res);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.define = (class_name, class_) => {
|
||||
globalThis._app_base.classes[class_name] = class_;
|
||||
regns_log("log", "registered class:", class_name);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue