add: littleweb full

This commit is contained in:
trisua 2025-07-08 13:35:23 -04:00
parent 3fc0872867
commit d67e7c9c33
32 changed files with 1699 additions and 71 deletions

View file

@ -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

View file

@ -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

View 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 %}")

View 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 %}")

View 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 %}")

View 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(\"&lt;/script&gt;\", \"</script\" + \">\"),
language: MIME_MODES[\"{{ file.mime }}\"],
theme: \"vs-dark\",
});
});"))
(text "{%- endif %}")
(text "{% endblock %}")

View file

@ -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

View file

@ -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")))

View file

@ -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 }}"))

View file

@ -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();
});
})();

View 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();