add: littleweb full
This commit is contained in:
parent
3fc0872867
commit
d67e7c9c33
32 changed files with 1699 additions and 71 deletions
|
@ -94,6 +94,8 @@
|
|||
atto[\"hooks::spotify_time_text\"](); // spotify durations
|
||||
atto[\"hooks::verify_emoji\"]();
|
||||
|
||||
fix_atto_links();
|
||||
|
||||
if (document.getElementById(\"tokens\")) {
|
||||
trigger(\"me::render_token_picker\", [
|
||||
document.getElementById(\"tokens\"),
|
||||
|
@ -163,6 +165,40 @@
|
|||
(icon (text "x"))
|
||||
(str (text "dialog:action.cancel"))))))
|
||||
|
||||
(dialog
|
||||
("id" "littleweb")
|
||||
(div
|
||||
("class" "inner flex flex-col gap-2")
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/net")
|
||||
(icon (text "globe"))
|
||||
(str (text "littleweb:label.browser")))
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/services")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_services")))
|
||||
|
||||
(a
|
||||
("class" "button w-full lowered justify-start")
|
||||
("href" "/domains")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_domains")))
|
||||
|
||||
(hr ("class" "margin"))
|
||||
(div
|
||||
("class" "flex gap-2 justify-between")
|
||||
(div null?)
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("type" "button")
|
||||
("onclick", "document.getElementById('littleweb').close()")
|
||||
(icon (text "x"))
|
||||
(str (text "dialog:action.cancel"))))))
|
||||
|
||||
(dialog
|
||||
("id" "web_api_prompt")
|
||||
(div
|
||||
|
|
|
@ -1112,6 +1112,12 @@
|
|||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
(str (text "general:link.journals")))
|
||||
(text "{% if config.lw_host -%}")
|
||||
(button
|
||||
("onclick" "document.getElementById('littleweb').showModal()")
|
||||
(icon (text "globe"))
|
||||
(str (text "general:link.little_web")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if not user.settings.disable_achievements -%}")
|
||||
(a
|
||||
("href" "/achievements")
|
||||
|
@ -2333,6 +2339,10 @@
|
|||
(text "Create infinite notes in each journal"))
|
||||
(li
|
||||
(text "Publish up to 50 notes"))
|
||||
(li
|
||||
(text "Create infinite Littleweb sites"))
|
||||
(li
|
||||
(text "Create infinite Littleweb domains"))
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(li
|
||||
|
|
211
crates/app/src/public/html/littleweb/browser.lisp
Normal file
211
crates/app/src/public/html/littleweb/browser.lisp
Normal file
|
@ -0,0 +1,211 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "{{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %}")
|
||||
(div
|
||||
("id" "panel")
|
||||
("class" "flex flex-row gap-2")
|
||||
(a
|
||||
("class" "button camo")
|
||||
("href" "/")
|
||||
(icon (text "house")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "back()")
|
||||
(icon (text "arrow-left")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "forward()")
|
||||
(icon (text "arrow-right")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "reload()")
|
||||
(icon (text "rotate-cw")))
|
||||
|
||||
(form
|
||||
("class" "w-full flex gap-1 flex-row")
|
||||
("onsubmit" "event.preventDefault(); littleweb_navigate(event.target.uri.getAttribute('true_value'))")
|
||||
(input
|
||||
("type" "uri")
|
||||
("class" "w-full")
|
||||
("true_value" "{{ path }}")
|
||||
("name" "uri")
|
||||
("id" "uri"))
|
||||
|
||||
(button ("class" "lowered small square") (icon (text "arrow-right"))))
|
||||
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "flex-row camo")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
("style" "gap: var(--pad-1) !important")
|
||||
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow")))
|
||||
|
||||
(text "{{ components::user_menu() }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(iframe
|
||||
("id" "browser_iframe")
|
||||
("frameborder" "0")
|
||||
("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}"))
|
||||
|
||||
(style
|
||||
("data-turbo-temporary" "true")
|
||||
(text ":root {
|
||||
--panel-height: 45px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#panel {
|
||||
width: 100dvw;
|
||||
height: var(--panel-height);
|
||||
padding: var(--pad-2);
|
||||
}
|
||||
|
||||
#panel input {
|
||||
border: none;
|
||||
background: var(--color-lowered);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
#panel input:focus {
|
||||
background: var(--color-super-lowered);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
#panel input:focus {
|
||||
position: fixed;
|
||||
width: calc(100dvw - (62px + var(--pad-2) * 2)) !important;
|
||||
left: var(--pad-2);
|
||||
}
|
||||
}
|
||||
|
||||
#panel button:not(.inner *),
|
||||
#panel a.button:not(.inner *),
|
||||
#panel input {
|
||||
--h: 28.2px;
|
||||
height: var(--h);
|
||||
min-height: var(--h);
|
||||
max-height: var(--h);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#panel button:not(.inner *),
|
||||
#panel a.button:not(.inner *) {
|
||||
padding: var(--pad-1) var(--pad-2);
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100dvw;
|
||||
height: calc(100dvh - var(--panel-height));
|
||||
}"))
|
||||
|
||||
(script
|
||||
(text "function littleweb_navigate(uri) {
|
||||
if (!uri.includes(\".html\")) {
|
||||
uri = `${uri}/index.html`;
|
||||
}
|
||||
|
||||
if (!uri.startsWith(\"atto://\")) {
|
||||
uri = `atto://${uri}`;
|
||||
}
|
||||
|
||||
// ...
|
||||
console.log(\"navigate\", uri);
|
||||
document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`;
|
||||
}
|
||||
|
||||
document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => {
|
||||
console.log(\"web content loaded\");
|
||||
});
|
||||
|
||||
window.addEventListener(\"message\", (e) => {
|
||||
if (typeof e.data !== \"string\") {
|
||||
console.log(\"refuse message (bad type)\");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (!data.t) {
|
||||
console.log(\"refuse message (not for tetratto)\");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(\"received message\");
|
||||
|
||||
if (data.event === \"change_url\") {
|
||||
const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length);
|
||||
window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`);
|
||||
document.getElementById(\"uri\").setAttribute(\"true_value\", uri);
|
||||
}
|
||||
});
|
||||
|
||||
function back() {
|
||||
post_message({ t: true, event: \"back\" });
|
||||
}
|
||||
|
||||
function forward() {
|
||||
post_message({ t: true, event: \"forward\" });
|
||||
}
|
||||
|
||||
function reload() {
|
||||
post_message({ t: true, event: \"reload\" });
|
||||
}
|
||||
|
||||
function post_message(data) {
|
||||
const origin = new URL(document.getElementById(\"browser_iframe\").src).origin;
|
||||
document.getElementById(\"browser_iframe\").contentWindow.postMessage(JSON.stringify(data), origin);
|
||||
}
|
||||
|
||||
// handle dropdowns
|
||||
window.addEventListener(\"blur\", () => {
|
||||
trigger(\"atto::hooks::dropdown.close\");
|
||||
});
|
||||
|
||||
// url bar focus
|
||||
document.getElementById(\"uri\").addEventListener(\"input\", (e) => {
|
||||
e.target.setAttribute(\"true_value\", e.target.value);
|
||||
});
|
||||
|
||||
let is_focused = false;
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"mouseenter\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\");
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"mouseleave\", (e) => {
|
||||
if (is_focused) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"focus\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\");
|
||||
is_focused = true;
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").addEventListener(\"blur\", (e) => {
|
||||
e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0];
|
||||
is_focused = false;
|
||||
});
|
||||
|
||||
document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]"))
|
||||
|
||||
(text "{% endblock %}")
|
274
crates/app/src/public/html/littleweb/domain.lisp
Normal file
274
crates/app/src/public/html/littleweb/domain.lisp
Normal file
|
@ -0,0 +1,274 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "{{ domain.name }}.{{ domain.tld|lower }}")))
|
||||
(div
|
||||
("class" "flex flex-col gap-2 card")
|
||||
(code
|
||||
("class" "w-content")
|
||||
(a
|
||||
("href" "atto://{{ domain.name }}.{{ domain.tld|lower }}")
|
||||
(text "atto://{{ domain.name }}.{{ domain.tld|lower }}")))
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap")
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_domain()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete"))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.domain_data"))))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(button
|
||||
("class" "small lowered")
|
||||
("title" "Help")
|
||||
("onclick" "document.getElementById('domain_help').classList.toggle('hidden')")
|
||||
(icon (text "circle-question-mark")))
|
||||
|
||||
(button
|
||||
("class" "small")
|
||||
("onclick" "document.getElementById('add_data').classList.toggle('hidden')")
|
||||
(icon (text "plus"))
|
||||
(str (text "littleweb:action.add")))))
|
||||
|
||||
(div
|
||||
("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin")
|
||||
("id" "domain_help")
|
||||
(p (text "To link your domain to a site, go to the site and press \"Copy ID\"."))
|
||||
(p (text "After you have the site's ID, click \"Add\" on this page, then paste the ID into the \"value\" field."))
|
||||
(p (text "If you've ever managed a real domain's DNS, this should be familiar."))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
; add data
|
||||
(form
|
||||
("id" "add_data")
|
||||
("class" "card hidden w-full lowered flex flex-col gap-2")
|
||||
("onsubmit" "add_data_from_form(event)")
|
||||
(div
|
||||
("class" "flex gap-2")
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(str (text "littleweb:label.type")))
|
||||
(select
|
||||
("type" "text")
|
||||
("name" "type")
|
||||
("id" "type")
|
||||
("placeholder" "type")
|
||||
("required" "")
|
||||
(option ("value" "Service") (text "Site ID"))
|
||||
(option ("value" "Text") (text "Text"))))
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(str (text "littleweb:label.name")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("minlength" "1")
|
||||
("maxlength" "32"))
|
||||
(span ("class" "fade") (text "Use \"@\" for root.")))
|
||||
(div
|
||||
("class" "flex w-full flex-col gap-1")
|
||||
(label
|
||||
("for" "value")
|
||||
(str (text "littleweb:label.value")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "value")
|
||||
("id" "value")
|
||||
("placeholder" "value")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "256"))))
|
||||
(div
|
||||
("class" "flex w-full justify-between")
|
||||
(div)
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))))
|
||||
; data
|
||||
(table
|
||||
(thead
|
||||
(tr
|
||||
(th (text "Name"))
|
||||
(th (text "Type"))
|
||||
(th (text "Value"))
|
||||
(th (text "Actions"))))
|
||||
|
||||
(tbody
|
||||
(text "{% for item in domain.data -%}")
|
||||
(tr
|
||||
(td (text "{{ item[0] }}"))
|
||||
(text "{% for k,v in item[1] -%}")
|
||||
(td (text "{{ k }}"))
|
||||
(td (text "{{ v }}"))
|
||||
(text "{%- endfor %}")
|
||||
(td
|
||||
("style" "overflow: auto")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "ellipsis")))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "rename_data('{{ item[0] }}')")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.rename")))
|
||||
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "remove_data('{{ item[0] }}')")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))))
|
||||
(text "{%- endfor %}"))))))
|
||||
|
||||
(script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}"))
|
||||
(script
|
||||
(text "globalThis.DOMAIN_DATA = JSON.parse(document.getElementById(\"domain_data\").innerText);
|
||||
async function save_data() {
|
||||
await trigger(\"atto::debounce\", [\"domains::update_data\"]);
|
||||
fetch(\"/api/v1/domains/{{ domain.id }}/data\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: DOMAIN_DATA,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function add_data_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::add_data\"]);
|
||||
|
||||
const x = {};
|
||||
x[e.target.type.selectedOptions[0].value] = e.target.value.value;
|
||||
|
||||
if (e.target.name.value === \"\") {
|
||||
e.target.name.value = \"@\";
|
||||
}
|
||||
|
||||
const name = e.target.name.value.replace(\" \", \"_\");
|
||||
if (DOMAIN_DATA.find((x) => x[0] === name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
DOMAIN_DATA.push([name, x]);
|
||||
await save_data();
|
||||
e.target.reset();
|
||||
}
|
||||
|
||||
async function delete_data(name) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::delete_data\"]);
|
||||
|
||||
delete DOMAIN_DATA.find((x) => x[0] === name);
|
||||
await save_data();
|
||||
}
|
||||
|
||||
async function delete_domain() {
|
||||
await trigger(\"atto::debounce\", [\"domains::delete\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/domains/{{ domain.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function rename_data(selector) {
|
||||
await trigger(\"atto::debounce\", [\"domains::rename_data\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
DOMAIN_DATA.find((x) => x[0] === selector)[0] = name.replaceAll(\" \", \"_\");
|
||||
await save_data();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function remove_data(name) {
|
||||
await trigger(\"atto::debounce\", [\"domains::remove_data\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
DOMAIN_DATA.find((x) => {
|
||||
i += 1;
|
||||
return x[0] === name;
|
||||
});
|
||||
|
||||
DOMAIN_DATA.splice(i - 1, 1);
|
||||
await save_data();
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
124
crates/app/src/public/html/littleweb/domains.lisp
Normal file
124
crates/app/src/public/html/littleweb/domains.lisp
Normal file
|
@ -0,0 +1,124 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My domains - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "littleweb:label.create_new_domain"))))
|
||||
(form
|
||||
("class" "card flex flex-col gap-2")
|
||||
("onsubmit" "create_domain_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "name")
|
||||
(text "{{ text \"communities:label.name\" }}"))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "name")
|
||||
("id" "name")
|
||||
("placeholder" "name")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(div
|
||||
("class" "flex flex-col gap-1")
|
||||
(label
|
||||
("for" "tld")
|
||||
(str (text "littleweb:label.tld")))
|
||||
(select
|
||||
("type" "text")
|
||||
("name" "tld")
|
||||
("id" "tld")
|
||||
("placeholder" "tld")
|
||||
("required" "")
|
||||
(text "{% for tld in tlds -%}")
|
||||
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
|
||||
(text "{%- endfor %}")))
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
|
||||
(details
|
||||
(summary
|
||||
(icon (text "circle-alert"))
|
||||
(text "Disclaimer"))
|
||||
|
||||
(div
|
||||
("class" "card lowered no_p_margin")
|
||||
(p (text "Domains are registered into {{ config.name }}'s closed web."))
|
||||
(p (text "This means that domains are only accessible through {{ config.name }}, as well as other supporting sites."))
|
||||
(p (text "If you would prefer a public-facing domain, those cost money and cannot be bought from {{ config.name }}."))
|
||||
(p (text "All domains have first-class support on {{ config.name }}, meaning all links to them will work properly on this site."))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "panel-top"))
|
||||
(span
|
||||
(str (text "littleweb:label.my_domains")))))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% for item in list %}")
|
||||
(a
|
||||
("href" "/domains/{{ item.id }}")
|
||||
("class" "card secondary flex flex-col gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "globe"))
|
||||
(b
|
||||
(text "{{ item.name }}.{{ item.tld|lower }}")))
|
||||
(span
|
||||
(text "Created ")
|
||||
(span
|
||||
("class" "date")
|
||||
(text "{{ item.created }}"))
|
||||
(text "; {{ item.data|length }} entries")))
|
||||
(text "{% endfor %}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_domain_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"domains::create\"]);
|
||||
|
||||
fetch(\"/api/v1/domains\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
tld: e.target.tld.selectedOptions[0].value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.href = `/domains/${res.payload}`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}"))
|
||||
|
||||
(text "{% endblock %}")
|
347
crates/app/src/public/html/littleweb/service.lisp
Normal file
347
crates/app/src/public/html/littleweb/service.lisp
Normal file
|
@ -0,0 +1,347 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") ("class" "active") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(text "{{ service.name }}")))
|
||||
|
||||
(div
|
||||
("class" "flex gap-2 flex-wrap card")
|
||||
(text "{% if file and file.children|length == 0 -%}")
|
||||
(button
|
||||
("onclick" "update_content()")
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "update_name()")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.edit_site_name")))
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "trigger('atto::copy_text', ['{{ service.id }}'])")
|
||||
(icon (text "copy"))
|
||||
(str (text "general:action.copy_id")))
|
||||
|
||||
(button
|
||||
("class" "red lowered")
|
||||
("onclick" "delete_service()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card-nest w-full")
|
||||
(div
|
||||
("class" "card small flex items-center justify-between gap-2")
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(icon (text "folder-open"))
|
||||
(span (text "{% if path -%} {{ path }} {%- else -%} / {%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "flex items-center gap-2")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
("onclick" "go_up()")
|
||||
(icon (text "arrow-up")))
|
||||
|
||||
(text "{% if not file or file.content|length == 0 -%}")
|
||||
(button
|
||||
("class" "lowered small")
|
||||
("onclick" "create_file()")
|
||||
(icon (text "plus"))
|
||||
(str (text "communities:action.create")))
|
||||
(text "{%- endif %}")))
|
||||
(div
|
||||
("class" "card flex flex-col gap-2")
|
||||
(text "{% if not file or file.children|length > 0 -%}")
|
||||
; directory browser
|
||||
(table
|
||||
(thead
|
||||
(tr
|
||||
(th (text "Name"))
|
||||
(th (text "Type"))
|
||||
(th (text "Children"))
|
||||
(th (text "Actions"))))
|
||||
|
||||
(tbody
|
||||
(text "{% for item in files %}")
|
||||
(tr
|
||||
(td
|
||||
("class" "flex gap-2 items-center")
|
||||
(text "{% if item.children|length > 0 -%}")
|
||||
(icon (text "folder"))
|
||||
(text "{% else %}")
|
||||
(icon (text "file"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(a
|
||||
("href" "?path={{ path }}/{{ item.name }}")
|
||||
("data-turbo" "false")
|
||||
(text "{{ item.name }}")))
|
||||
(td (text "{{ item.mime }}"))
|
||||
(td (text "{{ item.children|length }}"))
|
||||
(td
|
||||
("style" "overflow: auto")
|
||||
(div
|
||||
("class" "dropdown")
|
||||
(button
|
||||
("class" "camo small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "ellipsis")))
|
||||
(div
|
||||
("class" "inner")
|
||||
(button
|
||||
("onclick" "rename_file('{{ item.id }}')")
|
||||
(icon (text "pencil"))
|
||||
(str (text "littleweb:action.rename")))
|
||||
|
||||
(button
|
||||
("class" "red")
|
||||
("onclick" "remove_file('{{ item.id }}')")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))))
|
||||
(text "{% endfor %}")))
|
||||
(text "{% else %}")
|
||||
; file editor
|
||||
(div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px"))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script ("id" "all_service_files") ("type" "application/json") (text "{{ service.files|json_encode()|remove_script_tags|safe }}"))
|
||||
(script ("id" "service_files") ("type" "application/json") (text "{{ files|json_encode()|remove_script_tags|safe }}"))
|
||||
(script ("id" "id_path") ("type" "application/json") (text "{{ id_path|json_encode()|remove_script_tags|safe }}"))
|
||||
|
||||
(script
|
||||
(text "globalThis.SERVICE_FILES = JSON.parse(document.getElementById(\"service_files\").innerText);
|
||||
globalThis.EXTENSION_MIMES = {
|
||||
\"html\": \"text/html\",
|
||||
\"js\": \"text/javascript\",
|
||||
\"css\": \"text/css\",
|
||||
\"json\": \"application/json\",
|
||||
\"txt\": \"text/plain\",
|
||||
}
|
||||
|
||||
globalThis.MIME_MODES = {
|
||||
\"Html\": \"html\",
|
||||
\"Js\": \"javascript\",
|
||||
\"Css\": \"css\",
|
||||
\"Json\": \"json\",
|
||||
\"Plain\": \"txt\",
|
||||
}
|
||||
|
||||
function go_up() {
|
||||
const x = JSON.parse(document.getElementById(\"id_path\").innerText);
|
||||
const y = JSON.parse(document.getElementById(\"all_service_files\").innerText);
|
||||
|
||||
x.pop();
|
||||
let path = \"\";
|
||||
|
||||
for (id of x) {
|
||||
path += `/${y.find((x) => x.id == id).name}`;
|
||||
}
|
||||
|
||||
window.location.href = `?path=${path}`;
|
||||
}
|
||||
|
||||
async function update_name() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_name\"]);
|
||||
|
||||
const name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/services/{{ service.id }}/name\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: e.target.name.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_service() {
|
||||
await trigger(\"atto::debounce\", [\"services::delete\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/services/{{ service.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_content() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_content\"]);
|
||||
const content = globalThis.editor.getValue();
|
||||
fetch(\"/api/v1/services/{{ service.id }}/content\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
id_path: JSON.parse(document.getElementById(\"id_path\").innerText),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_files() {
|
||||
await trigger(\"atto::debounce\", [\"services::update_files\"]);
|
||||
fetch(\"/api/v1/services/{{ service.id }}/files\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
files: SERVICE_FILES,
|
||||
id_path: JSON.parse(document.getElementById(\"id_path\").innerText),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function create_file() {
|
||||
await trigger(\"atto::debounce\", [\"services::create_file\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"Name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s = name.split(\".\");
|
||||
SERVICE_FILES.push({
|
||||
id: window.crypto.randomUUID(),
|
||||
name,
|
||||
mime: EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"],
|
||||
children: [],
|
||||
content: \"\",
|
||||
});
|
||||
|
||||
await update_files();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
async function rename_file(id) {
|
||||
await trigger(\"atto::debounce\", [\"services::rename_file\"]);
|
||||
|
||||
let name = await trigger(\"atto::prompt\", [\"New name:\"]);
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file_ref = SERVICE_FILES.find((x) => x.id === id);
|
||||
file_ref.name = name;
|
||||
|
||||
const s = name.split(\".\");
|
||||
file_ref.mime = EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"];
|
||||
|
||||
await update_files();
|
||||
}
|
||||
|
||||
async function remove_file(id) {
|
||||
await trigger(\"atto::debounce\", [\"services::remove_file\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
SERVICE_FILES.find((x) => {
|
||||
i += 1;
|
||||
return x.id === id;
|
||||
});
|
||||
|
||||
SERVICE_FILES.splice(i - 1, 1);
|
||||
await update_files();
|
||||
}"))
|
||||
|
||||
(text "{% if file and file.mime != 'Plain' -%}")
|
||||
(script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js"))
|
||||
(script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}"))
|
||||
(script
|
||||
(text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } });
|
||||
|
||||
require([\"vs/editor/editor.main\"], () => {
|
||||
const shadow = document.getElementById(\"editor_container\").attachShadow({
|
||||
mode: \"closed\",
|
||||
});
|
||||
|
||||
const inner = document.createElement(\"div\");
|
||||
inner.style.width = window.getComputedStyle(document.getElementById(\"editor_container\")).width;
|
||||
inner.style.height = window.getComputedStyle(document.getElementById(\"editor_container\")).height;
|
||||
shadow.appendChild(inner);
|
||||
|
||||
const style = document.createElement(\"style\");
|
||||
style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";';
|
||||
shadow.appendChild(style);
|
||||
|
||||
globalThis.editor = monaco.editor.create(inner, {
|
||||
value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"</script\" + \">\"),
|
||||
language: MIME_MODES[\"{{ file.mime }}\"],
|
||||
theme: \"vs-dark\",
|
||||
});
|
||||
});"))
|
||||
(text "{%- endif %}")
|
||||
(text "{% endblock %}")
|
|
@ -1,11 +1,16 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My stacks - {{ config.name }}"))
|
||||
(text "My services - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex-col gap-2")
|
||||
(text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}")
|
||||
(text "{% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
(a ("href" "/services") ("class" "active") (str (text "littleweb:label.services")))
|
||||
(a ("href" "/domains") (str (text "littleweb:label.domains"))))
|
||||
|
||||
(div
|
||||
("class" "card-nest")
|
||||
(div
|
||||
|
|
|
@ -71,7 +71,7 @@
|
|||
(button
|
||||
("class" "flex-row title")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exlude" "dropdown")
|
||||
("exclude" "dropdown")
|
||||
("style" "gap: var(--pad-1) !important")
|
||||
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
|
||||
(icon_class (text "chevron-down") (text "dropdown-arrow")))
|
||||
|
|
|
@ -35,10 +35,12 @@
|
|||
|
||||
globalThis.no_policy = false;
|
||||
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
|
||||
globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\";
|
||||
</script>")
|
||||
|
||||
(script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
(script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
(script ("defer" "true") ("src" "/js/proto_links.js?v=tetratto-{{ random_cache_breaker }}" ))
|
||||
|
||||
(meta ("name" "theme-color") ("content" "{{ config.color }}"))
|
||||
(meta ("name" "description") ("content" "{{ config.description }}"))
|
||||
|
|
|
@ -855,7 +855,8 @@ media_theme_pref();
|
|||
anchor.href.startsWith("https://tetratto.com") ||
|
||||
anchor.href.startsWith("https://buy.stripe.com") ||
|
||||
anchor.href.startsWith("https://billing.stripe.com") ||
|
||||
anchor.href.startsWith("https://last.fm")
|
||||
anchor.href.startsWith("https://last.fm") ||
|
||||
anchor.href.startsWith("atto://")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
@ -1333,6 +1334,8 @@ ${option.input_element_type === "textarea" ? `${option.value}</textarea>` : ""}
|
|||
atto["hooks::online_indicator"]();
|
||||
atto["hooks::verify_emoji"]();
|
||||
atto["hooks::check_reactions"]();
|
||||
|
||||
fix_atto_links();
|
||||
});
|
||||
})();
|
||||
|
||||
|
|
136
crates/app/src/public/js/proto_links.js
Normal file
136
crates/app/src/public/js/proto_links.js
Normal file
|
@ -0,0 +1,136 @@
|
|||
if (!globalThis.TETRATTO_LINK_HANDLER_CTX) {
|
||||
globalThis.TETRATTO_LINK_HANDLER_CTX = "embed";
|
||||
}
|
||||
|
||||
// create little link preview box
|
||||
function create_link_preview() {
|
||||
globalThis.TETRATTO_LINK_PREVIEW = document.createElement("div");
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.position = "fixed";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.bottom = "0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.left = "0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.background = "#323232";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.color = "#ffffff";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.borderRadius = "4px";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.padding = "0.25rem 0.5rem";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.style.display = "none";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.id = "tetratto_link_preview";
|
||||
globalThis.TETRATTO_LINK_PREVIEW.setAttribute(
|
||||
"data-turbo-permanent",
|
||||
"true",
|
||||
);
|
||||
document.body.appendChild(globalThis.TETRATTO_LINK_PREVIEW);
|
||||
}
|
||||
|
||||
/// Clean up all "atto://" links on the page.
|
||||
function fix_atto_links() {
|
||||
setTimeout(() => {
|
||||
if (!document.getElementById("tetratto_link_preview")) {
|
||||
create_link_preview();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
if (TETRATTO_LINK_HANDLER_CTX === "embed") {
|
||||
// relative links for embeds
|
||||
const path = window.location.pathname.slice("/api/v1/net/".length);
|
||||
|
||||
function fix_element(
|
||||
selector = "a",
|
||||
property = "href",
|
||||
relative = true,
|
||||
) {
|
||||
for (const y of Array.from(document.querySelectorAll(selector))) {
|
||||
if (!y[property].startsWith(window.location.origin)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let x = new URL(y[property]).pathname;
|
||||
|
||||
if (!x.includes(".html")) {
|
||||
x = `${x}/index.html`;
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
y[property] =
|
||||
`atto://${path.replace("atto://", "").split("/")[0]}${x}`;
|
||||
} else {
|
||||
y[property] =
|
||||
`/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fix_element("a", "href", true);
|
||||
fix_element("link", "href", false);
|
||||
fix_element("script", "src", false);
|
||||
|
||||
// send message
|
||||
window.top.postMessage(
|
||||
JSON.stringify({
|
||||
t: true,
|
||||
event: "change_url",
|
||||
target: window.location.href,
|
||||
}),
|
||||
"*",
|
||||
);
|
||||
|
||||
// handle messages
|
||||
window.addEventListener("message", (e) => {
|
||||
if (typeof e.data !== "string") {
|
||||
console.log("refuse message (bad type)");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(e.data);
|
||||
|
||||
if (!data.t) {
|
||||
console.log("refuse message (not for tetratto)");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("received message");
|
||||
|
||||
if (data.event === "back") {
|
||||
window.history.back();
|
||||
} else if (data.event === "forward") {
|
||||
window.history.forward();
|
||||
} else if (data.event === "reload") {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const anchor of Array.from(document.querySelectorAll("a"))) {
|
||||
if (
|
||||
!anchor.href.startsWith("atto://") ||
|
||||
anchor.getAttribute("data-checked") === "true"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const href = structuredClone(anchor.href);
|
||||
|
||||
anchor.addEventListener("click", () => {
|
||||
if (TETRATTO_LINK_HANDLER_CTX === "net") {
|
||||
window.location.href = `/net/${href.replace("atto://", "")}`;
|
||||
} else {
|
||||
window.location.href = `/api/v1/net/${href}`;
|
||||
}
|
||||
});
|
||||
|
||||
anchor.addEventListener("mouseenter", () => {
|
||||
TETRATTO_LINK_PREVIEW.innerText = href;
|
||||
TETRATTO_LINK_PREVIEW.style.display = "block";
|
||||
});
|
||||
|
||||
anchor.addEventListener("mouseleave", () => {
|
||||
TETRATTO_LINK_PREVIEW.style.display = "none";
|
||||
});
|
||||
|
||||
anchor.removeAttribute("href");
|
||||
anchor.style.cursor = "pointer";
|
||||
anchor.setAttribute("data-checked", "true");
|
||||
}
|
||||
}
|
||||
|
||||
fix_atto_links();
|
||||
create_link_preview();
|
Loading…
Add table
Add a link
Reference in a new issue