add: postgres support
chore: restructure
This commit is contained in:
parent
cda879f6df
commit
b6fe2fba37
58 changed files with 3403 additions and 603 deletions
48
crates/app/src/assets.rs
Normal file
48
crates/app/src/assets.rs
Normal file
|
@ -0,0 +1,48 @@
|
|||
use pathbufd::PathBufD;
|
||||
use tera::Context;
|
||||
use tetratto_core::{config::Config, model::auth::User};
|
||||
|
||||
use crate::write_template;
|
||||
|
||||
// images
|
||||
pub const DEFAULT_AVATAR: &str = include_str!("./public/images/default-avatar.svg");
|
||||
pub const DEFAULT_BANNER: &str = include_str!("./public/images/default-banner.svg");
|
||||
|
||||
// css
|
||||
pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
|
||||
|
||||
// js
|
||||
pub const ATTO_JS: &str = include_str!("./public/js/atto.js");
|
||||
pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
|
||||
|
||||
// html
|
||||
pub const ROOT: &str = include_str!("./public/html/root.html");
|
||||
pub const MACROS: &str = include_str!("./public/html/macros.html");
|
||||
|
||||
pub const MISC_INDEX: &str = include_str!("./public/html/misc/index.html");
|
||||
|
||||
pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.html");
|
||||
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.html");
|
||||
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.html");
|
||||
|
||||
// ...
|
||||
|
||||
/// Set up public directories.
|
||||
pub(crate) fn write_assets(html_path: &PathBufD) {
|
||||
write_template!(html_path->"root.html"(crate::assets::ROOT));
|
||||
write_template!(html_path->"macros.html"(crate::assets::MACROS));
|
||||
|
||||
write_template!(html_path->"misc/index.html"(crate::assets::MISC_INDEX) -d "misc");
|
||||
|
||||
write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth");
|
||||
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN));
|
||||
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER));
|
||||
}
|
||||
|
||||
/// Create the initial template context.
|
||||
pub(crate) fn initial_context(config: &Config, user: &Option<User>) -> Context {
|
||||
let mut ctx = Context::new();
|
||||
ctx.insert("config", &config);
|
||||
ctx.insert("user", &user);
|
||||
ctx
|
||||
}
|
82
crates/app/src/avif.rs
Normal file
82
crates/app/src/avif.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{FromRequest, Request},
|
||||
http::{StatusCode, header::CONTENT_TYPE},
|
||||
};
|
||||
use axum_extra::extract::Multipart;
|
||||
use std::{fs::File, io::BufWriter};
|
||||
|
||||
/// An image extractor accepting:
|
||||
/// * `multipart/form-data`
|
||||
/// * `image/png`
|
||||
/// * `image/jpeg`
|
||||
/// * `image/avif`
|
||||
/// * `image/webp`
|
||||
pub struct Image(pub Bytes);
|
||||
|
||||
impl<S> FromRequest<S> for Image
|
||||
where
|
||||
Bytes: FromRequest<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = StatusCode;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Some(content_type) = req.headers().get(CONTENT_TYPE) else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
let body = if content_type
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("multipart/form-data")
|
||||
{
|
||||
let mut multipart = Multipart::from_request(req, state)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let Ok(Some(field)) = multipart.next_field().await else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
} else if (content_type == "image/avif")
|
||||
| (content_type == "image/jpeg")
|
||||
| (content_type == "image/png")
|
||||
| (content_type == "image/webp")
|
||||
{
|
||||
Bytes::from_request(req, state)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?
|
||||
} else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
Ok(Self(body))
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an AVIF buffer given an input of `bytes`
|
||||
pub fn save_avif_buffer(path: &str, bytes: Vec<u8>) -> std::io::Result<()> {
|
||||
let pre_img_buffer = match image::load_from_memory(&bytes) {
|
||||
Ok(i) => i,
|
||||
Err(_) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Image failed",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
if let Err(_) = pre_img_buffer.write_to(&mut writer, image::ImageFormat::Avif) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"Image conversion failed",
|
||||
));
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
54
crates/app/src/macros.rs
Normal file
54
crates/app/src/macros.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
#[macro_export]
|
||||
macro_rules! write_template {
|
||||
($html_path:ident->$path:literal($as:expr)) => {
|
||||
std::fs::write($html_path.join($path), $as).unwrap();
|
||||
};
|
||||
|
||||
($html_path:ident->$path:literal($as:expr) -d $dir_path:literal) => {
|
||||
let dir = $html_path.join($dir_path);
|
||||
if !std::fs::exists(&dir).unwrap() {
|
||||
std::fs::create_dir(dir).unwrap();
|
||||
}
|
||||
|
||||
std::fs::write($html_path.join($path), $as).unwrap();
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! create_dir_if_not_exists {
|
||||
($dir_path:expr) => {
|
||||
if !std::fs::exists(&$dir_path).unwrap() {
|
||||
std::fs::create_dir($dir_path).unwrap();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! get_user_from_token {
|
||||
(($jar:ident, $db:expr) <optional>) => {{
|
||||
if let Some(token) = $jar.get("__Secure-atto-token") {
|
||||
match $db
|
||||
.get_user_by_token(&rainbeam_shared::hash::hash(
|
||||
token.to_string().replace("__Secure-atto-token=", ""),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(ua) => Some(ua),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}};
|
||||
|
||||
($jar:ident, $db:ident) => {{
|
||||
if let Some(token) = $jar.get("__Secure-Atto-Token") {
|
||||
match $db.get_user_by_token(token) {
|
||||
Ok(ua) => ua,
|
||||
Err(_) => return axum::response::Html(crate::data::assets::REDIRECT_TO_AUTH),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}};
|
||||
}
|
68
crates/app/src/main.rs
Normal file
68
crates/app/src/main.rs
Normal file
|
@ -0,0 +1,68 @@
|
|||
mod assets;
|
||||
mod avif;
|
||||
mod macros;
|
||||
mod routes;
|
||||
|
||||
use assets::write_assets;
|
||||
pub use tetratto_core::*;
|
||||
|
||||
use axum::{Extension, Router};
|
||||
use pathbufd::PathBufD;
|
||||
use tera::Tera;
|
||||
use tower_http::trace::{self, TraceLayer};
|
||||
use tracing::{Level, info};
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub(crate) type State = Arc<RwLock<(DataManager, Tera)>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
.compact()
|
||||
.init();
|
||||
|
||||
let config = config::Config::get_config();
|
||||
|
||||
// ...
|
||||
create_dir_if_not_exists!(&config.dirs.media);
|
||||
let images_path =
|
||||
PathBufD::current().extend(&[config.dirs.media.clone(), "images".to_string()]);
|
||||
create_dir_if_not_exists!(&images_path);
|
||||
create_dir_if_not_exists!(
|
||||
&PathBufD::current().extend(&[config.dirs.media.clone(), "avatars".to_string()])
|
||||
);
|
||||
create_dir_if_not_exists!(
|
||||
&PathBufD::current().extend(&[config.dirs.media.clone(), "banners".to_string()])
|
||||
);
|
||||
|
||||
write_template!(images_path->"default-avatar.svg"(assets::DEFAULT_AVATAR));
|
||||
write_template!(images_path->"default-banner.svg"(assets::DEFAULT_BANNER));
|
||||
|
||||
// create templates
|
||||
let html_path = PathBufD::current().join(&config.dirs.templates);
|
||||
write_assets(&html_path);
|
||||
|
||||
// ...
|
||||
let app = Router::new()
|
||||
.merge(routes::routes(&config))
|
||||
.layer(Extension(Arc::new(RwLock::new((
|
||||
DataManager::new(config.clone()).await.unwrap(),
|
||||
Tera::new(&format!("{html_path}/**/*")).unwrap(),
|
||||
)))))
|
||||
.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
|
||||
.on_response(trace::DefaultOnResponse::new().level(Level::INFO)),
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
info!("🐐 tetratto.");
|
||||
info!("listening on http://0.0.0.0:{}", config.port);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
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);
|
||||
};
|
1
crates/app/src/routes/api/mod.rs
Normal file
1
crates/app/src/routes/api/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod v1;
|
102
crates/app/src/routes/api/v1/auth/images.rs
Normal file
102
crates/app/src/routes/api/v1/auth/images.rs
Normal file
|
@ -0,0 +1,102 @@
|
|||
use axum::{Extension, body::Body, extract::Path, response::IntoResponse};
|
||||
use pathbufd::PathBufD;
|
||||
use std::{
|
||||
fs::{File, exists},
|
||||
io::Read,
|
||||
};
|
||||
|
||||
use crate::State;
|
||||
|
||||
pub fn read_image(path: PathBufD) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
for byte in File::open(path).unwrap().bytes() {
|
||||
bytes.push(byte.unwrap())
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Get a profile's avatar image
|
||||
/// `/api/v1/auth/profile/{id}/avatar`
|
||||
pub async fn avatar_request(
|
||||
Path(username): Path<String>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
let user = match data.get_user_by_username(&username).await {
|
||||
Ok(ua) => ua,
|
||||
Err(_) => {
|
||||
return (
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
]))),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let path =
|
||||
PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
|
||||
|
||||
if !exists(&path).unwrap() {
|
||||
return (
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
]))),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
[("Content-Type", "image/avif")],
|
||||
Body::from(read_image(path)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get a profile's banner image
|
||||
/// `/api/v1/auth/profile/{id}/banner`
|
||||
pub async fn banner_request(
|
||||
Path(username): Path<String>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
||||
let user = match data.get_user_by_username(&username).await {
|
||||
Ok(ua) => ua,
|
||||
Err(_) => {
|
||||
return (
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let path =
|
||||
PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
|
||||
|
||||
if !exists(&path).unwrap() {
|
||||
return (
|
||||
[("Content-Type", "image/svg+xml")],
|
||||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
);
|
||||
}
|
||||
|
||||
(
|
||||
[("Content-Type", "image/avif")],
|
||||
Body::from(read_image(path)),
|
||||
)
|
||||
}
|
127
crates/app/src/routes/api/v1/auth/mod.rs
Normal file
127
crates/app/src/routes/api/v1/auth/mod.rs
Normal file
|
@ -0,0 +1,127 @@
|
|||
pub mod images;
|
||||
|
||||
use super::AuthProps;
|
||||
use crate::{
|
||||
State, get_user_from_token,
|
||||
model::{ApiReturn, Error, auth::User},
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json,
|
||||
http::{HeaderMap, HeaderValue},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
/// `/api/v1/auth/register`
|
||||
pub async fn register_request(
|
||||
headers: HeaderMap,
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<AuthProps>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = get_user_from_token!((jar, data) <optional>);
|
||||
|
||||
if user.is_some() {
|
||||
return (
|
||||
None,
|
||||
Json(ApiReturn {
|
||||
ok: false,
|
||||
message: Error::AlreadyAuthenticated.to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// get real ip
|
||||
let real_ip = headers
|
||||
.get(data.0.security.real_ip_header.to_owned())
|
||||
.unwrap_or(&HeaderValue::from_static(""))
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// ...
|
||||
let mut user = User::new(props.username, props.password);
|
||||
let (initial_token, t) = User::create_token(&real_ip);
|
||||
user.tokens.push(t);
|
||||
|
||||
// return
|
||||
match data.create_user(user).await {
|
||||
Ok(_) => (
|
||||
Some([(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
|
||||
initial_token,
|
||||
60 * 60 * 24 * 365
|
||||
),
|
||||
)]),
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User created".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
),
|
||||
Err(e) => (None, Json(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// `/api/v1/auth/login`
|
||||
pub async fn login_request(
|
||||
headers: HeaderMap,
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<AuthProps>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = get_user_from_token!((jar, data) <optional>);
|
||||
|
||||
if user.is_some() {
|
||||
return (None, Json(Error::AlreadyAuthenticated.into()));
|
||||
}
|
||||
|
||||
// get real ip
|
||||
let real_ip = headers
|
||||
.get(data.0.security.real_ip_header.to_owned())
|
||||
.unwrap_or(&HeaderValue::from_static(""))
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// verify password
|
||||
let user = match data.get_user_by_username(&props.username).await {
|
||||
Ok(ua) => ua,
|
||||
Err(_) => return (None, Json(Error::IncorrectPassword.into())),
|
||||
};
|
||||
|
||||
if !user.check_password(props.password) {
|
||||
return (None, Json(Error::IncorrectPassword.into()));
|
||||
}
|
||||
|
||||
// update tokens
|
||||
let mut new_tokens = user.tokens.clone();
|
||||
let (unhashed_token_id, token) = User::create_token(&real_ip);
|
||||
new_tokens.push(token);
|
||||
|
||||
if let Err(e) = data.update_user_tokens(user.id, new_tokens).await {
|
||||
return (None, Json(e.into()));
|
||||
}
|
||||
|
||||
// ...
|
||||
(
|
||||
Some([(
|
||||
"Set-Cookie",
|
||||
format!(
|
||||
"__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
|
||||
unhashed_token_id,
|
||||
60 * 60 * 24 * 365
|
||||
),
|
||||
)]),
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: unhashed_token_id,
|
||||
payload: (),
|
||||
}),
|
||||
)
|
||||
}
|
28
crates/app/src/routes/api/v1/mod.rs
Normal file
28
crates/app/src/routes/api/v1/mod.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
pub mod auth;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub fn routes() -> Router {
|
||||
Router::new()
|
||||
// global
|
||||
.route("/auth/register", post(auth::register_request))
|
||||
.route("/auth/login", post(auth::login_request))
|
||||
// profile
|
||||
.route(
|
||||
"/auth/profile/{id}/avatar",
|
||||
get(auth::images::avatar_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/profile/{id}/banner",
|
||||
get(auth::images::banner_request),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AuthProps {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
22
crates/app/src/routes/assets.rs
Normal file
22
crates/app/src/routes/assets.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use axum::response::IntoResponse;
|
||||
|
||||
/// `/css/style.css`
|
||||
pub async fn style_css_request() -> impl IntoResponse {
|
||||
([("Content-Type", "text/css")], crate::assets::STYLE_CSS)
|
||||
}
|
||||
|
||||
/// `/js/atto.js`
|
||||
pub async fn atto_js_request() -> impl IntoResponse {
|
||||
(
|
||||
[("Content-Type", "text/javascript")],
|
||||
crate::assets::ATTO_JS,
|
||||
)
|
||||
}
|
||||
|
||||
/// `/js/atto.js`
|
||||
pub async fn loader_js_request() -> impl IntoResponse {
|
||||
(
|
||||
[("Content-Type", "text/javascript")],
|
||||
crate::assets::LOADER_JS,
|
||||
)
|
||||
}
|
25
crates/app/src/routes/mod.rs
Normal file
25
crates/app/src/routes/mod.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
pub mod api;
|
||||
pub mod assets;
|
||||
pub mod pages;
|
||||
|
||||
use crate::config::Config;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, get_service},
|
||||
};
|
||||
|
||||
pub fn routes(config: &Config) -> Router {
|
||||
Router::new()
|
||||
// assets
|
||||
.route("/css/style.css", get(assets::style_css_request))
|
||||
.route("/js/atto.js", get(assets::atto_js_request))
|
||||
.route("/js/loader.js", get(assets::loader_js_request))
|
||||
.nest_service(
|
||||
"/static",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||
)
|
||||
// api
|
||||
.nest("/api/v1", api::v1::routes())
|
||||
// pages
|
||||
.merge(pages::routes())
|
||||
}
|
39
crates/app/src/routes/pages/auth.rs
Normal file
39
crates/app/src/routes/pages/auth.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
use crate::{State, assets::initial_context, get_user_from_token};
|
||||
use axum::{
|
||||
Extension,
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
/// `/auth/login`
|
||||
pub async fn login_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!((jar, data.0) <optional>);
|
||||
|
||||
if user.is_some() {
|
||||
return Err(Redirect::to("/"));
|
||||
}
|
||||
|
||||
let mut context = initial_context(&data.0.0, &user);
|
||||
Ok(Html(
|
||||
data.1.render("auth/login.html", &mut context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/auth/register`
|
||||
pub async fn register_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!((jar, data.0) <optional>);
|
||||
|
||||
if user.is_some() {
|
||||
return Err(Redirect::to("/"));
|
||||
}
|
||||
|
||||
let mut context = initial_context(&data.0.0, &user);
|
||||
Ok(Html(
|
||||
data.1.render("auth/register.html", &mut context).unwrap(),
|
||||
))
|
||||
}
|
15
crates/app/src/routes/pages/misc.rs
Normal file
15
crates/app/src/routes/pages/misc.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
use crate::{State, assets::initial_context, get_user_from_token};
|
||||
use axum::{
|
||||
Extension,
|
||||
response::{Html, IntoResponse},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
/// `/`
|
||||
pub async fn index_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!((jar, data.0) <optional>);
|
||||
|
||||
let mut context = initial_context(&data.0.0, &user);
|
||||
Html(data.1.render("misc/index.html", &mut context).unwrap())
|
||||
}
|
13
crates/app/src/routes/pages/mod.rs
Normal file
13
crates/app/src/routes/pages/mod.rs
Normal file
|
@ -0,0 +1,13 @@
|
|||
pub mod auth;
|
||||
pub mod misc;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
pub fn routes() -> Router {
|
||||
Router::new()
|
||||
// misc
|
||||
.route("/", get(misc::index_request))
|
||||
// auth
|
||||
.route("/auth/register", get(auth::register_request))
|
||||
.route("/auth/login", get(auth::login_request))
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue