This commit is contained in:
Vincent CLAVIEN 2025-01-15 00:01:52 +01:00
parent b125ecbee6
commit 2a9df13fe3
41 changed files with 3932 additions and 3303 deletions

30
dev/public/js/init.js Normal file
View File

@ -0,0 +1,30 @@
drag.init("app");
document.addEventListener("alpine:init", () => {
// create and init stores
Alpine.store("files", filesStore);
Alpine.store("lightbox", lightboxStore);
Alpine.store("ui", uiStore);
Alpine.store("userPrefs", userPrefsStore);
const salutations = [
"Hi!",
"Hiya!",
"Hello there!",
"Good day!",
"Hullo!",
"Buongiorno!",
"Guten Tag!",
"Bonjour!",
"Oh hey!",
];
Alpine.store("ui").logMsg(`MARL loaded. ${salutations[Math.floor(Math.random() * salutations.length)]} 😊`);
resetStores();
});
document.addEventListener("alpine-i18n:ready", function () {
AlpineI18n.create("en", appStrings);
AlpineI18n.fallbackLocale = "en";
setLang();
});

36
dev/public/js/libs.js Normal file
View File

@ -0,0 +1,36 @@
const isFileProtocol = window.location.protocol === "file:";
const scripts = [
{
src: "js/jszip.min.js",
integrity: "sha512-XMVd28F1oH/O71fzwBnV7HucLxVwtxf26XV8P4wPk26EDxuGZ91N8bsOttmnomcCD3CS5ZMRL50H0GgOHvegtg==",
crossorigin: "anonymous",
defer: false,
},
// Note: Alpine plug-ins must be inserted BEFORE alpinejs
{
src: "js/alpinejs-i18n.min.js",
integrity: "sha256-o204NcFyHPFzboSC51fufMqFe2KJdQfSCl8AlvSZO/E=",
crossorigin: "anonymous",
defer: true,
},
{
src: "js/alpinejs.min.js",
integrity: "sha512-FUaEyIgi9bspXaH6hUadCwBLxKwdH7CW24riiOqA5p8hTNR/RCLv9UpAILKwqs2AN5WtKB52CqbiePBei3qjKg==",
crossorigin: "anonymous",
defer: true,
},
];
scripts.forEach(({ src, integrity, crossorigin, defer }) => {
const script = document.createElement("script");
script.src = src;
if (!isFileProtocol) {
script.integrity = integrity;
script.crossOrigin = crossorigin;
}
if (defer) {
script.defer = true;
}
document.head.appendChild(script);
});

File diff suppressed because it is too large Load Diff

888
dev/public/js/stores.js Normal file
View File

@ -0,0 +1,888 @@
// stores definitions
const userPrefsStore = {
prefix: "marl_",
save(pref, value) {
const msg = `Saving user preference <b>(${pref}: ${value})</b>`;
Alpine.store("ui").logMsg(msg, "info");
localStorage.setItem(this.prefix + pref, value);
},
load(pref) {
const value = localStorage.getItem(this.prefix + pref);
if (value !== null) {
this.set(pref, value);
} else if (pref === "lang") {
this.set(pref, value);
}
},
set(pref, value) {
switch (pref) {
case "sortAsc":
value = +value === 1 ? true : false;
if (value !== Alpine.store("files").sortAsc) {
Alpine.store("files").sortAsc = value;
}
break;
case "pageSize":
value = +value;
if (typeof value == "number" && !isNaN(value) && value > 0 && value !== Alpine.store("files").pageSize) {
Alpine.store("files").pageSize = value;
}
break;
case "lang":
if (!value) {
value = detectLangFromBrowser();
if (value) {
this.save("lang", value);
}
}
if (!value || !Alpine.store("ui").appLangs[value]) {
if (value) {
const msg = `<b>Unrecognized language</b> in user preferences: ${value}`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
}
value = "en";
this.save("lang", value);
}
Alpine.store("ui").lang = value;
break;
case "theme":
if (!(value === "dark" || value === "light")) {
value = "light";
this.save("theme", value);
}
Alpine.store("ui").theme = value;
setTheme(value);
break;
}
},
};
const filesStore = {
resetState() {
this.sources = [];
this.toots = [];
this.toc = [];
this.duplicates = false;
this.sortAsc = true; // -> userPrefs
this.pageSize = 10; // -> userPrefs
this.currentPage = 1;
this.loading = false;
this.someFilesLoaded = false;
this.languages = {};
this.boostsAuthors = [];
this.filters = {};
this.filtersDefault = {
fullText: "",
hashtagText: "",
mentionText: "",
externalLink: "",
summary: "",
isEdited: false,
isDuplicate: false,
noStartingAt: false,
hasExternalLink: false,
hasHashtags: false,
hasMentions: false,
hasSummary: false,
isSensitive: false,
visibilityPublic: true,
visibilityUnlisted: true,
visibilityFollowers: true,
visibilityMentioned: true,
typeOriginal: true,
typeBoost: true,
attachmentAny: false,
attachmentImage: false,
attachmentVideo: false,
attachmentSound: false,
attachmentNoAltText: false,
attachmentWithAltText: false,
// automatically generated (see loadJsonFile()):
// lang_en: true,
// lang_fr: true,
// lang_de: true,
// etc
// actor_0: true,
// actor_1: true,
// actor_2: true,
// etc
};
this.filtersActive = false;
this.tagsFilters = {
hashtags: "",
mentions: "",
boostsAuthors: "",
};
Alpine.store("userPrefs").load("sortAsc");
Alpine.store("userPrefs").load("pageSize");
},
setFilter() {
this.checkPagingValue();
scrollTootsToTop();
pagingUpdated();
if (JSON.stringify(this.filters) === JSON.stringify(this.filtersDefault)) {
this.filtersActive = false;
} else {
this.filtersActive = true;
}
const self = this;
setTimeout(() => {
self.checkPagingValue();
}, 50);
},
filterByTag(filter, value, id) {
if (value) {
if (value === this.filters[filter]) {
this.filters[filter] = "";
} else {
this.filters[filter] = value;
}
}
// "boosted users" group
// in this case let's also (un)check the 'boost type' filters
if (filter == "fullText") {
if (this.filters[filter] === "") {
this.filters.typeBoost = true;
this.filters.typeOriginal = true;
} else {
this.filters.typeBoost = true;
this.filters.typeOriginal = false;
}
}
this.setFilter();
// keyboard focus may be lost when tags list changes
setTimeout(() => {
document.getElementById(id).focus();
}, 100);
},
resetFilters(userAction) {
this.filters = JSON.parse(JSON.stringify(this.filtersDefault));
if (userAction) {
this.currentPage = 1;
this.filtersActive = false;
scrollTootsToTop();
pagingUpdated();
}
},
get filteredToots() {
const f = this.filters;
const fa = this.filtersActive;
return this.toots.filter((t) => {
if (!fa) {
return true;
}
if (f.fullText) {
let show = false;
if (t._marl.textContent) {
const filterValue = f.fullText.toLowerCase();
if (filterValue && t._marl.textContent && t._marl.textContent.indexOf(filterValue) >= 0) {
show = true;
}
}
if (!show) {
return show;
}
}
if (f.hashtagText) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
const filterValue = f.hashtagText.toLowerCase();
if (
!t.object.tag.some((t) => {
return t.type === "Hashtag" && t.name.toLowerCase().indexOf(filterValue) > -1;
})
) {
return false;
}
} else {
return false;
}
}
if (f.mentionText) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
const filterValue = f.mentionText.toLowerCase();
if (
!t.object.tag.some((t) => {
return t.type === "Mention" && t.name.toLowerCase().indexOf(filterValue) > -1;
})
) {
return false;
}
} else {
return false;
}
}
if (f.summary) {
if (t._marl.summary) {
const filterValue = f.summary.toLowerCase();
if (t._marl.summary.indexOf(filterValue) === -1) {
return false;
}
} else {
return false;
}
}
if (f.isEdited) {
if (!(typeof t.object === "object" && t.object !== null && t.object.updated)) {
return false;
}
}
if (f.isDuplicate) {
if (!t._marl.duplicate) {
return false;
}
}
if (f.noStartingAt) {
if (!t._marl.textContent || t._marl.textContent.indexOf("@") === 0) {
return false;
}
}
if (f.hasExternalLink) {
if (!t._marl.externalLinks || !t._marl.externalLinks.length) {
return false;
}
}
if (f.hasHashtags) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
if (
!t.object.tag.some((t) => {
return t.type === "Hashtag";
})
) {
return false;
}
} else {
return false;
}
}
if (f.hasMentions) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
if (
!t.object.tag.some((t) => {
return t.type === "Mention";
})
) {
return false;
}
} else {
return false;
}
}
if (f.hasSummary) {
if (typeof t.object === "object" && t.object !== null) {
if (!t.object.summary) {
return false;
}
} else {
return false;
}
}
if (f.isSensitive) {
if (typeof t.object === "object" && t.object !== null) {
if (!t.object.sensitive) {
return false;
}
} else {
return false;
}
}
if (f.externalLink) {
let show = false;
if (t._marl.externalLinks && t._marl.externalLinks.length) {
const filterValue = f.externalLink.toLowerCase();
show = t._marl.externalLinks.some((link) => {
return link.href.indexOf(filterValue) > -1 || link.text.indexOf(filterValue) > -1;
});
}
if (!show) {
return false;
}
}
if (!f.visibilityPublic && t._marl.visibility[0] === "public") {
return false;
}
if (!f.visibilityUnlisted && t._marl.visibility[0] === "unlisted") {
return false;
}
if (!f.visibilityFollowers && t._marl.visibility[0] === "followers") {
return false;
}
if (!f.visibilityMentioned && t._marl.visibility[0] === "mentioned") {
return false;
}
if (!f.typeOriginal && t.type === "Create") {
return false;
}
if (!f.typeBoost && t.type === "Announce") {
return false;
}
if (f.attachmentAny) {
if (!t._marl.hasAttachments) {
return false;
}
}
if (f.attachmentImage) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsImage(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentVideo) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsVideo(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentSound) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsSound(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentNoAltText) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return att.name === null;
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentWithAltText) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return att.name;
})
) {
return false;
}
} else {
return false;
}
}
for (const lang in this.languages) {
if (f.hasOwnProperty("lang_" + lang) && f["lang_" + lang] === false) {
if (t._marl.langs.includes(lang) || t._marl.langs.length === 0) {
return false;
}
}
}
for (const source of this.sources) {
const id = source.id;
if (f.hasOwnProperty("actor_" + id) && f["actor_" + id] === false) {
if (t._marl.source === id) {
return false;
}
}
}
return true;
});
},
get listHashtags() {
return this.listTags("Hashtag");
},
get listMentions() {
return this.listTags("Mention");
},
listTags(type) {
let filterSource = "";
switch (type) {
case "Mention":
filterSource = "mentions";
break;
case "Hashtag":
filterSource = "hashtags";
break;
}
let h = this.filteredToots.reduce((accu, toot) => {
if (tootHasTags(toot)) {
for (const key in toot.object.tag) {
const tag = toot.object.tag[key];
if (
tag.type &&
tag.type === type &&
tag.name &&
tag.name.toLowerCase().indexOf(this.tagsFilters[filterSource].toLowerCase()) >= 0
) {
if (
accu.some((item) => {
return item.name === tag.name;
})
) {
accu.map((item) => {
if (item.name === tag.name) {
item.nb++;
}
});
} else {
accu.push({
name: tag.name,
href: tag.href,
nb: 1,
});
}
}
}
}
return accu;
}, []);
h.sort((a, b) => {
if (a.nb === b.nb) {
return a.name.localeCompare(b.name);
} else {
return b.nb - a.nb;
}
});
return h;
},
get listBoostsAuthors() {
let r = this.boostsAuthors.reduce((accu, item) => {
if (item.name.toLowerCase().indexOf(this.tagsFilters.boostsAuthors.toLowerCase()) >= 0) {
accu.push(item);
}
return accu;
}, []);
r.sort((a, b) => {
if (a.nb === b.nb) {
let aHasNoName = a.name.indexOf("? ") === 0;
let bHasNoName = b.name.indexOf("? ") === 0;
if (aHasNoName && bHasNoName) {
return a.name.localeCompare(b.name);
} else if (aHasNoName) {
return 1;
} else if (bHasNoName) {
return -1;
} else {
return a.name.localeCompare(b.name);
}
} else {
if (a.nb === b.nb) {
return a.name.localeCompare(b.name);
} else {
return b.nb - a.nb;
}
}
});
return r;
},
get sortedLanguages() {
let langs = [];
for (const lang in this.languages) {
langs.push([lang, this.languages[lang]]);
}
langs.sort((a, b) => {
if (a[0] === "undefined") {
return 1;
}
if (b[0] === "undefined") {
return -1;
}
if (a[1] === b[1]) {
return a[0].localeCompare(b[0]);
}
return b[1] - a[1];
});
return langs;
},
get appReady() {
if (this.sources.length === 0) {
return false;
}
let r = true;
for (let i = 0; i < this.sources.length; i++) {
const source = this.sources[i];
if (
!source.loaded.actor ||
!source.loaded.avatar ||
!source.loaded.header ||
!source.loaded.outbox ||
!source.loaded.likes ||
!source.loaded.bookmarks
) {
r = false;
}
}
return r;
},
get totalPages() {
return Math.ceil(this.filteredToots.length / this.pageSize);
},
get pagedToots() {
if (this.filteredToots) {
return this.filteredToots.filter((_, index) => {
let start = (this.currentPage - 1) * this.pageSize;
let end = this.currentPage * this.pageSize;
if (index >= start && index < end) return true;
});
} else {
return [];
}
},
sortToots() {
this.toots.sort((a, b) => {
if (this.sortAsc) {
return a.published.localeCompare(b.published);
} else {
return b.published.localeCompare(a.published);
}
});
},
toggleTootsOrder() {
this.sortAsc = !this.sortAsc;
Alpine.store("userPrefs").save("sortAsc", this.sortAsc ? 1 : 0);
this.sortToots();
scrollTootsToTop();
pagingUpdated();
},
setPostsPerPage() {
this.checkPagingValue();
Alpine.store("userPrefs").save("pageSize", this.pageSize);
},
checkPagingValue() {
if (this.currentPage < 1) {
this.currentPage = 1;
} else if (this.currentPage > this.totalPages) {
this.currentPage = this.totalPages;
}
},
nextPage(setFocusTo) {
if (this.currentPage * this.pageSize < this.filteredToots.length) {
this.currentPage++;
scrollTootsToTop(setFocusTo);
pagingUpdated();
}
},
prevPage(setFocusTo) {
if (this.currentPage > 1) {
this.currentPage--;
scrollTootsToTop(setFocusTo);
pagingUpdated();
}
},
firstPage(setFocusTo) {
this.currentPage = 1;
scrollTootsToTop(setFocusTo);
pagingUpdated();
},
lastPage(setFocusTo) {
this.currentPage = this.totalPages;
scrollTootsToTop(setFocusTo);
pagingUpdated();
},
};
const lightboxStore = {
resetState() {
this.show = false;
this.data = [];
this.source = 0;
this.index = 0;
this.origin = "";
},
open(toot, index, origin) {
this.data = toot.object.attachment;
this.source = toot._marl.source;
this.show = true;
this.index = index;
this.origin = origin;
document.getElementById("main-section-inner").setAttribute("inert", true);
setTimeout(() => {
document.getElementById("lightbox").focus();
}, 50);
},
openProfileImg(name, origin, source) {
const data = {
object: {
attachment: [
{
name: name,
url: name,
mediaType: Alpine.store("files").sources[source][name].type,
},
],
},
_marl: {
source: source,
},
};
this.open(data, 0, origin);
},
close() {
const origin = this.origin;
this.data = [];
this.index = 0;
this.show = false;
this.origin = "";
document.getElementById("main-section-inner").removeAttribute("inert");
document.getElementById(origin).focus();
},
showNext() {
this.index++;
if (this.index >= this.data.length) {
this.index = 0;
}
if (!attachmentIsImage(this.data[this.index])) {
this.showNext();
}
},
showPrev() {
this.index--;
if (this.index < 0) {
this.index = this.data.length - 1;
}
if (!attachmentIsImage(this.data[this.index])) {
this.showPrev();
}
},
};
const uiStore = {
log: [],
resetState() {
this.pagingOptionsVisible = false;
this.openMenu = "";
this.actorPanel = 0;
this.menuIsActive = false;
this.lang = "en";
this.appLangs = appLangs ?? { en: "English" };
this.theme = "light";
this.log = this.log ?? [];
Alpine.store("userPrefs").load("lang");
Alpine.store("userPrefs").load("theme");
},
logMsg(msg, type) {
type = type ?? "info";
const dateOptions = {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const time = new Date().toLocaleTimeString(Alpine.store("ui").lang, dateOptions);
let m = {
msg: msg,
type: type,
time: time,
};
this.log.unshift(m);
},
toggleTheme() {
this.theme = this.theme === "light" ? "dark" : "light";
Alpine.store("userPrefs").save("theme", this.theme);
setTheme(this.theme);
},
togglePagingOptions() {
this.pagingOptionsVisible = !this.pagingOptionsVisible;
if (this.pagingOptionsVisible) {
setTimeout(() => {
document.getElementById("paging-options").focus();
}, 100);
}
},
get pagingOptionsClass() {
return this.pagingOptionsVisible ? "open" : "";
},
openActorPanel(id) {
this.actorPanel = id;
},
switchActorPanel(dir) {
let id = this.actorPanel;
if (dir === "up") {
id++;
if (id >= Alpine.store("files").sources.length) {
id = 0;
}
} else {
id--;
if (id < 0) {
id = Alpine.store("files").sources.length - 1;
}
}
this.actorPanel = id;
document.getElementById("actortab-" + id).focus();
},
menuClose() {
const name = this.openMenu;
this.openMenu = "";
this.setInert();
// bring focus back to where it was before the panel was opened
document.querySelector("#main-section-inner .mobile-menu .menu-" + name).focus();
},
menuOpen(name) {
this.openMenu = name;
this.resetPanels();
this.setInert();
setTimeout(() => {
document.getElementById("panel-" + name).focus();
}, 100);
},
menuToggle(name) {
switch (name) {
case "actor":
case "filters":
case "tags":
case "tools":
if (this.openMenu === name) {
this.menuClose();
} else {
this.menuOpen(name);
}
break;
}
},
resetPanels() {
const name = this.openMenu;
document.querySelectorAll(`#panel-${name} details[open]`).forEach((e) => {
e.removeAttribute("open");
});
setTimeout(() => {
document.getElementById("panel-" + name).scrollTop = 0;
}, 250);
},
checkMenuState() {
const menu = document.getElementById("mobile-menu");
if (window.getComputedStyle(menu, null).display === "none") {
this.menuIsActive = false;
} else {
this.menuIsActive = true;
}
this.setInert();
},
setInertMain() {
document
.querySelectorAll("#main-section-inner > *:not(.mobile-menu, .panel-backdrop, #panel-" + this.openMenu)
.forEach((e) => {
e.setAttribute("inert", true);
});
},
setInertPanels() {
document.querySelectorAll("#panel-actor, #panel-filters, #panel-tags, #panel-tools").forEach((e) => {
e.setAttribute("inert", true);
});
},
setInertTools() {
document.querySelectorAll("#panel-tools").forEach((e) => {
e.setAttribute("inert", true);
});
},
setInert() {
// set the 'inert' state on the side panels or the main part of the app
// depending on whether they are hidden or not, AND whether the mobile
// menu is active
document.querySelectorAll("#main-section-inner > *").forEach((e) => {
e.removeAttribute("inert");
});
if (this.menuIsActive) {
if (this.openMenu) {
this.setInertMain();
} else {
this.setInertPanels();
}
} else {
if (this.openMenu === "tools") {
this.setInertMain();
} else {
this.setInertTools();
}
}
},
get appClasses() {
let classes = [];
if (this.openMenu) {
classes.push("menu-open menu-open-" + this.openMenu);
} else {
classes.push("menu-closed");
}
return classes;
},
};

View File

@ -16,7 +16,6 @@ const appStrings = {
p3: `<strong>Start by opening your archive file with MARL.</strong><br />
You can drag and drop it anywhere on this page, or
{labelStart}click here to select it{labelEnd}.`,
projectPage: `Project page (github)`,
},
misc: {
loading: "Loading",
@ -27,8 +26,7 @@ const appStrings = {
filters: "Filters",
filtersActive: "some filters are active",
tags: "Tags",
newFile: "New File",
newFileConfirm: "Discard current data and load a new archive file?",
tools: "Tools",
},
lightbox: {
next: "Next image",
@ -50,8 +48,8 @@ const appStrings = {
cache. Posts that are not in your instance cache any more are not included in your
archive. This affects boosts, likes, and bookmarks.`,
rawData: "Raw data {fileName}",
favorites: "Favorites",
favoritesEmpty: "no favorites",
likes: "Favorites",
likesEmpty: "no favorites",
bookmarks: "Bookmarks",
bookmarksEmpty: "no bookmarks",
},
@ -140,6 +138,21 @@ const appStrings = {
mentionsFilter: "Filter mentions",
boostsFilter: "Filter boosted users",
},
tools: {
panelTitle: "Tools",
appSettings: "App settings",
selectLanguage: "Select language",
useDarkTheme: "Use dark theme",
useLightTheme: "Use light theme",
loadedFiles: "Loaded files",
addAnother: "Add another archive",
addAnotherTip:
"Tip: You can open multiple archives at once.<br>You can also drag and drop your archive files anywhere on this window.",
startOver: "Start over",
startOverConfirm: "Discard current data and load a new archive file?",
appLog: "App log",
projectPage: `Project page (github)`,
},
},
fr: {
@ -154,7 +167,6 @@ const appStrings = {
p3: `<strong>Commencez par ouvrir votre archive avec MARL.</strong><br />
Vous pouvez la glisser-déposer n'importe sur cette page, ou
{labelStart}cliquer ici pour la sélectionner{labelEnd}.`,
projectPage: `Page du project (github)`,
},
misc: {
loading: "Chargement",
@ -165,8 +177,7 @@ const appStrings = {
filters: "Filtres",
filtersActive: "certains filtres sont actifs",
tags: "Tags",
newFile: "Nouveau fichier",
newFileConfirm: "Repartir de zéro et charger un nouveau fichier ?",
tools: "Outils",
},
lightbox: {
next: "Image suivante",
@ -188,8 +199,8 @@ const appStrings = {
de ce cache. Les posts qui ne sont plus présents dans le cache de votre instance ne sont
pas inclus dans votre archive. Cela concerne les partages, les favoris et les marque-pages.`,
rawData: "Données brutes {fileName}",
favorites: "Favoris",
favoritesEmpty: "aucun favori",
likes: "Favoris",
likesEmpty: "aucun favori",
bookmarks: "Marque-pages",
bookmarksEmpty: "aucun marque-page",
},
@ -278,5 +289,20 @@ const appStrings = {
mentionsFilter: "Filtrer les mentions",
boostsFilter: "Filter utilisateurs partagés",
},
tools: {
panelTitle: "Outils",
appSettings: "Réglages de l'app",
selectLanguage: "Choisir la langue",
useDarkTheme: "Utiliser le thème sombre",
useLightTheme: "Utiliser le thème clair",
loadedFiles: "Fichiers chargés",
addAnother: "Ajouter une autre archive",
addAnotherTip:
"Astuce: Vous pouvez ouvrir plusieurs archives en même temps.<br>Vous pouvez aussi glisser-déposer vos fichiers d'archive n'importe où dans cette fenêtre.",
startOver: "Recommencer",
startOverConfirm: "Repartir de zéro et charger un nouveau fichier ?",
appLog: "Journal",
projectPage: `Page du project (github)`,
},
},
};

678
dev/public/js/utils.js Normal file
View File

@ -0,0 +1,678 @@
function resetStores() {
Alpine.store("files").resetState();
Alpine.store("lightbox").resetState();
Alpine.store("ui").resetState();
}
function unZip(files) {
const firstLoad = Alpine.store("files").sources.length === 0;
if (firstLoad) {
resetStores();
}
Alpine.store("files").loading = true;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (
Alpine.store("files").sources.some((source) => {
return (
source.fileInfos.name === file.name &&
source.fileInfos.size === file.size &&
source.fileInfos.lastModified === file.lastModified
);
})
) {
const msg = `File already loaded: <b>${file.name}</b>`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
continue;
}
Alpine.store("ui").logMsg(`Loading file: <b>${file.name}</b>`, "info");
JSZip.loadAsync(file).then(
(content) => {
const index = Alpine.store("files").sources.length;
const fileInfos = {
name: file.name,
size: file.size,
lastModified: file.lastModified,
};
Alpine.store("files").sources[index] = {
id: index,
fileInfos: fileInfos,
nbToots: 0,
actor: {},
outbox: {},
likes: [],
bookmarks: [],
avatar: {},
header: {},
loaded: {
actor: false,
avatar: false,
header: false,
outbox: false,
likes: false,
bookmarks: false,
},
};
Alpine.store("files").sources[index]._raw = content.files;
loadJsonFile("actor", index, fileInfos);
loadJsonFile("outbox", index, fileInfos);
loadJsonFile("likes", index, fileInfos);
loadJsonFile("bookmarks", index, fileInfos);
},
(error) => {
const msg = `Error loading <b>${file.name}</b>: ${error.message}`;
console.error(msg);
Alpine.store("ui").logMsg(msg, "error");
}
);
}
}
function loadJsonFile(name, index, fileInfos) {
const content = Alpine.store("files").sources[index]._raw;
if (content[name + ".json"] === undefined) {
if (name === "likes" || name === "bookmarks") {
// we can still run the app without those files
const msg = `<b>${fileInfos.name}</b>: File ${name}.json not found in archive.`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
Alpine.store("files").sources[index].loaded[name] = true;
} else {
// this should NOT happen and will prevent the app from running
const msg = `<b>Critical error - ${fileInfos.name}</b>: File ${name}.json not found in archive.`;
console.error(msg);
Alpine.store("ui").logMsg(msg, "error");
}
return;
}
content[name + ".json"].async("text").then(function (txt) {
if (name === "actor") {
Alpine.store("files").sources[index].actor = JSON.parse(txt);
loadActorImages(index);
Alpine.store("files").sources[index].loaded.actor = true;
} // actor.json
if (name === "outbox") {
let data = JSON.parse(txt);
let toots = data.orderedItems.reduce((accu, t) => {
let t2 = preprocessToots(t, index);
if (t2) {
accu.push(t2);
}
return accu;
}, []);
Alpine.store("files").toots = Alpine.store("files").toots.concat(toots);
Alpine.store("files").sources[index].nbToots = toots.length;
delete data.orderedItems;
Alpine.store("files").sources[index].outbox = data;
Alpine.store("files").sources[index].loaded.outbox = true;
} // outbox.json
if (name === "likes" || name === "bookmarks") {
const tmp = JSON.parse(txt);
Alpine.store("files").sources[index][name] = tmp.orderedItems;
Alpine.store("files").sources[index].loaded[name] = true;
} // likes.json || bookmarks.json
});
}
function buildTootsInfos() {
let langs = {};
let boosts = [];
if (Alpine.store("files").toots.length > 0) {
let infos = Alpine.store("files").toots.reduce(
(accu, toot) => {
for (let lang in toot._marl.langs) {
const l = toot._marl.langs[lang];
if (!accu.langs[l]) {
accu.langs[l] = 1;
} else {
accu.langs[l]++;
}
}
if (toot.type === "Announce") {
// since Mastodon doesn't allow (yet?) cross-origin requests to
// retrieve post data (for boosts), we try to at least extract the
// user names for all the boosts contained in the archive
// [ISSUE] "object" value is a string most of the times, but
// sometimes it's a complex object similar to type "Create"
if (typeof toot.object === "object" && toot.object !== null) {
// let's ignore this case for now...
// [TODO], but not clear how it should be handled
} else if (toot.object) {
// if it's not an object and it has a value, then it's simply a
// url (string) pointing to the original (boosted) post.
// [ISSUE] URL format not always consistent... (esp. in the case
// of non-Mastodon instances) - e.g:
// https://craftopi.art/objects/[...]
// https://firefish.city/notes/[...]
// https://bsky.brid.gy/convert/ap/at://did:plc:[...]/app.bsky.feed.post/[...]
// -> the user name is not always present in URL
const url = toot.object.split("/");
let name;
let user;
let domain;
if (url.length > 2) {
domain = url[2];
if (url[0] === "https:" && url[3] === "users" && url[5] === "statuses") {
// Mastodon URL format -> user name
name = url[4];
user = `https://${url[2]}/users/${url[4]}/`;
} else {
// other URL format -> domain name
name = `? ${url[2]}`;
user = `https://${url[2]}/`;
}
if (!accu.boosts[name]) {
accu.boosts[name] = {
nb: 1,
name: name,
url: user,
domain: domain,
};
} else {
accu.boosts[name].nb++;
}
}
}
}
return accu;
},
{ langs: {}, boosts: {} }
);
langs = infos.langs;
boosts = [];
for (var key in infos.boosts) {
boosts.push(infos.boosts[key]);
}
}
Alpine.store("files").languages = langs;
Alpine.store("files").boostsAuthors = boosts;
}
function buildDynamicFilters() {
for (const lang in Alpine.store("files").languages) {
Alpine.store("files").filtersDefault["lang_" + lang] = true;
}
for (const source of Alpine.store("files").sources) {
Alpine.store("files").filtersDefault["actor_" + source.id] = true;
}
Alpine.store("files").resetFilters(false);
}
function preprocessToots(t, index) {
// build the '_marl' prop for each toot
let marl = {
langs: [],
source: index,
};
// check for duplicates (in case of multiple archive files)
if (Alpine.store("files").toc.includes(t.id)) {
const alts = Alpine.store("files").toots.filter((t2) => t2.id === t.id);
let identical = false;
const flat1 = JSON.stringify(t);
alts.forEach((alt) => {
let alt2 = JSON.parse(JSON.stringify(alt));
delete alt2._marl;
const flat2 = JSON.stringify(alt2);
if (flat1 === flat2) {
identical = true;
} else {
alt._marl.duplicate = true;
marl.duplicate = true;
Alpine.store("files").duplicates = true;
}
});
if (identical) {
return false;
}
} else {
Alpine.store("files").toc.push(t.id);
}
if (t.type === "Create") {
if (typeof t.object === "object" && t.object !== null && t.object.contentMap) {
let langs = [];
for (let lang in t.object.contentMap) {
langs.push(lang);
}
marl.langs = langs;
} else {
marl.langs = ["undefined"];
}
}
if (typeof t.object === "object" && t.object !== null) {
if (t.object.content) {
const content = t.object.content.toLowerCase();
marl.textContent = stripHTML(content);
marl.externalLinks = extractExternalLinks(content);
}
if (t.object.summary) {
marl.summary = t.object.summary.toLowerCase();
}
if (t.object.attachment && t.object.attachment.length) {
marl.hasAttachments = true;
}
} else if (t.object) {
marl.textContent = t.object.toLowerCase();
}
marl.visibility = tootVisibility(t);
const id = t.id.split("/");
marl.id = id[id.length - 2];
t._marl = marl;
return t;
}
function loadActorImages(index) {
const actor = Alpine.store("files").sources[index].actor;
const content = Alpine.store("files").sources[index]._raw;
if (actor.icon && actor.icon.type === "Image" && actor.icon.url && content[actor.icon.url]) {
const image = actor.icon;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").sources[index].avatar = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").sources[index].loaded.avatar = true;
});
} else {
Alpine.store("files").sources[index].avatar = { noImg: true };
Alpine.store("files").sources[index].loaded.avatar = true;
}
if (actor.image && actor.image.type === "Image" && actor.image.url && content[actor.image.url]) {
const image = actor.image;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").sources[index].header = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").sources[index].loaded.header = true;
});
} else {
Alpine.store("files").sources[index].header = { noImg: true };
Alpine.store("files").sources[index].loaded.header = true;
}
}
function setHueForSources() {
const nbSources = Alpine.store("files").sources.length;
const hueStart = Math.round(Math.random() * 360); // MARL accent: 59.17
const hueSpacing = Math.round(360 / nbSources);
for (let i = 0; i < nbSources; i++) {
Alpine.store("files").sources[i].hue = hueStart + hueSpacing * i;
}
}
function checkAppReady(ok) {
if (ok) {
buildTootsInfos();
buildDynamicFilters();
cleanUpRaw();
setHueForSources();
document.getElementById("main-section").focus();
Alpine.store("ui").checkMenuState();
Alpine.store("files").sortToots();
Alpine.store("files").loading = false;
Alpine.store("files").someFilesLoaded = true;
}
}
function cleanUpRaw() {
for (let i = 0; i < Alpine.store("files").sources.length; i++) {
const content = Alpine.store("files").sources[i]._raw;
if (content.cleanedUp) {
continue;
}
const actor = Alpine.store("files").sources[i].actor;
if (actor.image && actor.image.url) {
delete content[actor.image.url];
}
if (actor.icon && actor.icon.url) {
delete content[actor.icon.url];
}
delete content["actor.json"];
delete content["outbox.json"];
delete content["likes.json"];
delete content["bookmarks.json"];
content.cleanedUp = true;
Alpine.store("files").sources[i]._raw = content;
}
}
function loadAttachedMedia(att, index) {
if (attachmentIsImage(att) || attachmentIsVideo(att) || attachmentIsSound(att)) {
const data = Alpine.store("files").sources[index]._raw;
let url = att.url;
// ?! some instances seem to add their own name in front of the path,
// resulting in an invalid path with relation to the archive
// structure (e.g. "/framapiaf/media_attachments/...", but in the
// archive there is only a folder "/media_attachments")
// => So we remove everything that comes before "media_attachments/",
// hoping it doesn't break something else... :/
const prefix = url.indexOf("media_attachments/");
if (prefix > 0) {
url = url.slice(prefix);
}
if (!data[url]) {
return;
}
data[url].async("base64").then((content) => {
Alpine.store("files").sources[index][att.url] = {
type: att.mediaType,
content: content,
};
});
}
}
function pagingUpdated() {
document.querySelectorAll(`#toots details[open]`).forEach((e) => {
e.removeAttribute("open");
});
}
function scrollTootsToTop(setFocusTo) {
setTimeout(() => {
document.getElementById("toots").scrollTop = 0;
if (setFocusTo) {
// for keyboard users: we transfer the focus to the corresponding button
// in the upper paging module; or, in the cases where said button is
// disabled, we set the focus on the list of posts.
document.getElementById(setFocusTo).focus();
}
}, 50);
}
function contentType(data) {
let r = "";
switch (data) {
case "Create":
r = "Post";
break;
case "Announce":
r = "Boost";
break;
}
return r;
}
function tootVisibility(data) {
if (data.to.includes("https://www.w3.org/ns/activitystreams#Public")) {
return ["public", AlpineI18n.t("filters.visibilityPublic")];
}
if (
data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["unlisted", AlpineI18n.t("filters.visibilityUnlisted")];
}
if (
data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
!data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["followers", AlpineI18n.t("filters.visibilityFollowers")];
}
if (
!data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
!data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["mentioned", AlpineI18n.t("filters.visibilityMentioned")];
}
}
function tootHasTags(toot) {
return typeof toot.object === "object" && toot.object !== null && toot.object.tag && toot.object.tag.length;
}
function formatJson(data) {
let r = data;
if (r._marl) {
// not a part of the source data; let's hide it to avoid confusion
r = JSON.parse(JSON.stringify(data));
delete r._marl;
}
return JSON.stringify(r, null, 4);
}
function formatAuthor(author, plainText) {
if (plainText) {
return author.split("/").pop();
} else {
return `<a href="${author}" target="_blank">${author.split("/").pop()}</a>`;
}
}
function formatDateTime(data) {
let date = new Date(data);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatFileDateTime(data) {
let date = new Date(data);
const dateOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatFileSize(size) {
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; // ### i18n
}
function formatDate(data) {
let date = new Date(data);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatNumber(nb) {
return nb.toLocaleString();
}
function formatLikesBookmarks(url) {
const u = url.split("/");
u.splice(0, 2);
// 0 [domain]
// 1 "users"
// 2 [username]
// 3 "statuses"
// 4 [post id]
let text = `<span class="url-instance">${u[0]}</span>`;
if (u[1] === "users" && u[3] === "statuses") {
text += `<span class="url-actor">${u[2]}</span><span class="url-post-id">${u[4]}</span>`;
} else {
u.splice(0, 1);
text += `<span class="url-post-id">${u.join("/")}</span>`;
}
return text;
}
function stripHTML(str) {
let doc = new DOMParser().parseFromString(str, "text/html");
return doc.body.textContent || "";
}
function extractExternalLinks(str) {
const doc = new DOMParser().parseFromString(str, "text/html");
const nodes = doc.querySelectorAll("a[href]:not(.mention)");
let links = [];
nodes.forEach((link) => {
links.push({
href: link.href,
text: link.textContent,
});
});
return links;
}
function attachmentIsImage(att) {
return att.mediaType === "image/jpeg" || att.mediaType === "image/png";
}
function attachmentIsVideo(att) {
return att.mediaType === "video/mp4";
}
function attachmentIsSound(att) {
return att.mediaType === "audio/mpeg";
}
function attachmentWrapperClass(att) {
let r = [];
if (attachmentIsImage(att)) {
r.push("att-img");
} else if (attachmentIsSound(att)) {
r.push("att-sound");
} else if (attachmentIsVideo(att)) {
r.push("att-video");
}
if (!att.name) {
r.push("no-alt-text");
}
return r;
}
function isFilterActive(name) {
return Alpine.store("files").filters[name] !== Alpine.store("files").filtersDefault[name];
}
function startOver() {
const txt = AlpineI18n.t("tools.startOverConfirm");
if (confirm(txt)) {
location.reload();
}
}
function detectLangFromBrowser() {
const langs = navigator.languages;
if (langs && langs.length) {
for (let i = 0; i < langs.length; i++) {
let lang = langs[i].split("-")[0];
if (Alpine.store("ui").appLangs[lang]) {
const msg = `Setting language based on browser preference: <b>'${lang}' (${
Alpine.store("ui").appLangs[lang]
})</b>`;
Alpine.store("ui").logMsg(msg, "info");
return lang;
}
}
}
return false;
}
function setLang() {
const lang = Alpine.store("ui").lang;
AlpineI18n.locale = lang;
Alpine.store("userPrefs").save("lang", lang);
const msg = `App language set to <b>'${lang}' (${Alpine.store("ui").appLangs[lang]})</b>`;
Alpine.store("ui").logMsg(msg);
}
function setTheme(theme) {
document.getElementsByTagName("html")[0].setAttribute("class", theme);
}
// drag'n'drop over entire page
const drag = {
el: null,
init(el) {
this.dropArea = document.getElementById(el);
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, (e) => this.preventDragDefaults(e), false);
});
["dragenter", "dragover"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, () => this.highlightDrag(), false);
});
["dragleave", "drop"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, () => this.unhighlightDrag(), false);
});
this.dropArea.addEventListener("drop", (e) => this.handleDrop(e), false);
},
preventDragDefaults(e) {
e.preventDefault();
e.stopPropagation();
},
highlightDrag() {
this.dropArea.classList.add("highlight-drag");
},
unhighlightDrag() {
this.dropArea.classList.remove("highlight-drag");
},
handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
unZip(files);
},
};

View File

@ -29,9 +29,9 @@
</button>
</li>
<li>
<button class="menu-new" @click="startOver">
<svg aria-hidden="true"><use href="#menu-new" /></svg>
<span x-text="$t('menu.newFile')"></span>
<button class="menu-tools" @click="$store.ui.menuToggle('tools')">
<svg aria-hidden="true"><use href="#tools" /></svg>
<span x-text="$t('menu.tools')"></span>
</button>
</li>
</ul>

View File

@ -6,8 +6,8 @@
>
<svg class="btn-icon" aria-hidden="true">
<use href="#nav-first" />
</svg>
<span class="btn-label" x-text="$t('paging.first')"></span>
</svg
><span class="btn-label" x-text="$t('paging.first')"></span>
</button>
<button
@click="$store.files.prevPage('paging-btn-prev')"
@ -15,8 +15,8 @@
>
<svg class="btn-icon" aria-hidden="true">
<use href="#nav-prev" />
</svg>
<span class="btn-label" x-text="$t('paging.prev')"></span>
</svg
><span class="btn-label" x-text="$t('paging.prev')"></span>
</button>
</div>
<div class="direction-fwd">
@ -24,8 +24,8 @@
@click="$store.files.nextPage('paging-btn-next')"
:disabled="$store.files.currentPage >= $store.files.totalPages"
>
<span class="btn-label" x-text="$t('paging.next')"></span>
<svg class="btn-icon" aria-hidden="true">
<span class="btn-label" x-text="$t('paging.next')"></span
><svg class="btn-icon" aria-hidden="true">
<use href="#nav-next" />
</svg>
</button>
@ -33,8 +33,8 @@
@click="$store.files.lastPage('toots')"
:disabled="$store.files.currentPage >= $store.files.totalPages"
>
<span class="btn-label" x-text="$t('paging.last')"></span>
<svg class="btn-icon" aria-hidden="true">
<span class="btn-label" x-text="$t('paging.last')"></span
><svg class="btn-icon" aria-hidden="true">
<use href="#nav-last" />
</svg>
</button>

View File

@ -8,11 +8,10 @@
min="1"
max="$store.files.totalPages"
x-model="$store.files.currentPage"
@keyup="$store.files.checkPagingValue()"
@change="$store.files.checkPagingValue()"
onclick="this.select()"
/>
/
/>
/
<span class="total-pages" x-text="formatNumber($store.files.totalPages)"></span>
(
<input
@ -21,10 +20,9 @@
type="number"
min="1"
x-model="$store.files.pageSize"
@keyup="$store.files.checkPagingValue()"
@change="$store.files.checkPagingValue()"
@change="$store.files.setPostsPerPage()"
onclick="this.select()"
/>
/>
<label for="paging-page-size" x-text="$t('paging.postsPerPage')"></label>)
</div>
<div class="paging-options-reverse">

View File

@ -11,8 +11,8 @@ import PagingOptions from "./PagingOptions.astro";
>
<svg class="btn-icon" aria-hidden="true">
<use href="#nav-first" />
</svg>
<span class="btn-label" x-text="$t('paging.first')"></span>
</svg
><span class="btn-label" x-text="$t('paging.first')"></span>
</button>
<button
id="paging-btn-prev"
@ -21,8 +21,8 @@ import PagingOptions from "./PagingOptions.astro";
>
<svg class="btn-icon" aria-hidden="true">
<use href="#nav-prev" />
</svg>
<span class="btn-label" x-text="$t('paging.prev')"></span>
</svg
><span class="btn-label" x-text="$t('paging.prev')"></span>
</button>
</div>
@ -30,8 +30,8 @@ import PagingOptions from "./PagingOptions.astro";
<button @click="$store.ui.togglePagingOptions()">
<svg class="btn-icon" aria-hidden="true">
<use href="#options" />
</svg>
<span class="btn-label" x-text="$t('paging.pagingOptions')"></span>
</svg
><span class="btn-label" x-text="$t('paging.pagingOptions')"></span>
</button>
</div>
@ -43,8 +43,8 @@ import PagingOptions from "./PagingOptions.astro";
@click="$store.files.nextPage()"
:disabled="$store.files.currentPage >= $store.files.totalPages"
>
<span class="btn-label" x-text="$t('paging.next')"></span>
<svg class="btn-icon" aria-hidden="true">
<span class="btn-label" x-text="$t('paging.next')"></span
><svg class="btn-icon" aria-hidden="true">
<use href="#nav-next" />
</svg>
</button>
@ -53,8 +53,8 @@ import PagingOptions from "./PagingOptions.astro";
@click="$store.files.lastPage()"
:disabled="$store.files.currentPage >= $store.files.totalPages"
>
<span class="btn-label" x-text="$t('paging.last')"></span>
<svg class="btn-icon" aria-hidden="true">
<span class="btn-label" x-text="$t('paging.last')"></span
><svg class="btn-icon" aria-hidden="true">
<use href="#nav-last" />
</svg>
</button>

View File

@ -89,14 +89,39 @@
d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="bookmark">
<symbol viewBox="0 -960 960 960" id="bookmarks">
<path
d="M200-120v-640q0-33 23.5-56.5T280-840h400q33 0 56.5 23.5T760-760v640L480-240 200-120Zm80-122 200-86 200 86v-518H280v518Zm0-518h400-400Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="favorite">
<symbol viewBox="0 -960 960 960" id="likes">
<path
d="m354-287 126-76 126 77-33-144 111-96-146-13-58-136-58 135-146 13 111 97-33 143ZM233-120l65-281L80-590l288-25 112-265 112 265 288 25-218 189 65 281-247-149-247 149Zm247-350Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="home">
<path
d="M240-200h120v-200q0-17 11.5-28.5T400-440h160q17 0 28.5 11.5T600-400v200h120v-360L480-740 240-560v360Zm-80 0v-360q0-19 8.5-36t23.5-28l240-180q21-16 48-16t48 16l240 180q15 11 23.5 28t8.5 36v360q0 33-23.5 56.5T720-120H560q-17 0-28.5-11.5T520-160v-200h-80v200q0 17-11.5 28.5T400-120H240q-33 0-56.5-23.5T160-200Zm320-270Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="dark">
<path
d="M480-120q-151 0-255.5-104.5T120-480q0-138 90-239.5T440-838q13-2 23 3.5t16 14.5q6 9 6.5 21t-7.5 23q-17 26-25.5 55t-8.5 61q0 90 63 153t153 63q31 0 61.5-9t54.5-25q11-7 22.5-6.5T819-479q10 5 15.5 15t3.5 24q-14 138-117.5 229T480-120Zm0-80q88 0 158-48.5T740-375q-20 5-40 8t-40 3q-123 0-209.5-86.5T364-660q0-20 3-40t8-40q-78 32-126.5 102T200-480q0 116 82 198t198 82Zm-10-270Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="light">
<path
d="M480-360q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm0 80q-83 0-141.5-58.5T280-480q0-83 58.5-141.5T480-680q83 0 141.5 58.5T680-480q0 83-58.5 141.5T480-280ZM80-440q-17 0-28.5-11.5T40-480q0-17 11.5-28.5T80-520h80q17 0 28.5 11.5T200-480q0 17-11.5 28.5T160-440H80Zm720 0q-17 0-28.5-11.5T760-480q0-17 11.5-28.5T800-520h80q17 0 28.5 11.5T920-480q0 17-11.5 28.5T880-440h-80ZM480-760q-17 0-28.5-11.5T440-800v-80q0-17 11.5-28.5T480-920q17 0 28.5 11.5T520-880v80q0 17-11.5 28.5T480-760Zm0 720q-17 0-28.5-11.5T440-80v-80q0-17 11.5-28.5T480-200q17 0 28.5 11.5T520-160v80q0 17-11.5 28.5T480-40ZM226-678l-43-42q-12-11-11.5-28t11.5-29q12-12 29-12t28 12l42 43q11 12 11 28t-11 28q-11 12-27.5 11.5T226-678Zm494 495-42-43q-11-12-11-28.5t11-27.5q11-12 27.5-11.5T734-282l43 42q12 11 11.5 28T777-183q-12 12-29 12t-28-12Zm-42-495q-12-11-11.5-27.5T678-734l42-43q11-12 28-11.5t29 11.5q12 12 12 29t-12 28l-43 42q-12 11-28 11t-28-11ZM183-183q-12-12-12-29t12-28l43-42q12-11 28.5-11t27.5 11q12 11 11.5 27.5T282-226l-42 43q-11 12-28 11.5T183-183Zm297-297Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="langs">
<path
d="m603-202-34 97q-4 11-14 18t-22 7q-20 0-32.5-16.5T496-133l152-402q5-11 15-18t22-7h30q12 0 22 7t15 18l152 403q8 19-4 35.5T868-80q-13 0-22.5-7T831-106l-34-96H603ZM362-401 188-228q-11 11-27.5 11.5T132-228q-11-11-11-28t11-28l174-174q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H80q-17 0-28.5-11.5T40-760q0-17 11.5-28.5T80-800h240v-40q0-17 11.5-28.5T360-880q17 0 28.5 11.5T400-840v40h240q17 0 28.5 11.5T680-760q0 17-11.5 28.5T640-720h-76q-21 72-63 148t-83 116l96 98-30 82-122-125Zm266 129h144l-72-204-72 204Z"
/>
</symbol>
<symbol viewBox="0 -960 960 960" id="tools">
<path
d="M360-360q-100 0-170-70t-70-170q0-20 3-40t11-38q5-10 12.5-15t16.5-7q9-2 18.5.5T199-689l105 105 72-72-105-105q-8-8-10.5-17.5T260-797q2-9 7-16.5t15-12.5q18-8 38-11t40-3q100 0 170 70t70 170q0 23-4 43.5T584-516l202 200q29 29 29 71t-29 71q-29 29-71 29t-71-30L444-376q-20 8-40.5 12t-43.5 4Zm0-80q26 0 52-8t47-25l243 243q5 5 13.5 4.5T729-231q5-5 5-13.5t-5-13.5L486-500q18-20 26-46.5t8-53.5q0-60-38.5-104.5T386-758l74 74q12 12 12 28t-12 28L332-500q-12 12-28 12t-28-12l-74-74q9 57 53.5 95.5T360-440Zm109-51Z"
/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -5,24 +5,24 @@ import PagingTop from '../PagingTop.astro';
<header class="toots-header" aria-labelledby="toots-header-title">
<h2 id="toots-header-title">
<span class="count">
<span x-text="await formatNumber($store.files.filteredToots.length)" class="nb"></span>
<span x-text="formatNumber($store.files.filteredToots.length)" class="nb"></span>
<span x-text="$t('header.countLabel')"></span>
</span>
<span class="order">
-
<span x-text="await $store.files.sortAsc ? $t('header.oldestFirst') : $t('header.latestFirst')"></span>
<span x-text="$store.files.sortAsc ? $t('header.oldestFirst') : $t('header.latestFirst')"></span>
<button @click="$store.files.toggleTootsOrder()" class="toggle-order">
<svg class="btn-icon" aria-hidden="true">
<use href="#toggle-order" />
</svg>
<span class="btn-label" x-text="$t('header.reverse')"></span>
</svg
><span class="btn-label" x-text="$t('header.reverse')"></span>
</button>
</span>
<button @click="startOver" class="load-new">
<button class="open-tools" @click="$store.ui.menuToggle('tools')">
<svg class="btn-icon" aria-hidden="true">
<use href="#load-file" /></svg
><span class="btn-label" x-text="$t('header.loadNewFile')"></span>
<use href="#tools" /></svg
><span class="btn-label" x-text="$t('menu.tools')"></span>
</button>
</h2>

View File

@ -1,59 +1,29 @@
---
import CloseBtn from './CloseBtn.astro'
import AppSettings from './tools/AppSettings.astro'
import ManageFiles from './tools/ManageFiles.astro'
import AppLog from './tools/AppLog.astro'
import About from './tools/About.astro'
---
<aside
class="panel-tools mobile-menu-panel"
id="panel-tools"
class="tools panel-tools mobile-menu-panel"
aria-labelledby="panel-tools-title"
tabindex="-1"
inert
>
<CloseBtn />
<h2 class="tools-title visually-hidden" id="panel-tools-title">Tools</h2>
<h2
id="panel-tools-title"
class="tools-section tools-title visually-hidden"
x-text="$t('tools.panelTitle')"
></h2>
<select
@change="setLang"
x-model="$store.ui.lang"
>
<template x-for="(lang, id) in $store.ui.appLangs">
<option
:value="id"
x-text="lang"
:selected="$store.ui.lang === id"
></option>
</template>
</select>
<AppSettings />
<ManageFiles />
<AppLog />
<About />
<select>
<option value="dark">Dark</option>
</select>
<div class="loaded-files">
<h3>Loaded files</h3>
<ul>
<li>File 1</li>
</ul>
</div>
<button @click="startOver" class="load-new">
<svg class="btn-icon" aria-hidden="true">
<use href="#load-file" /></svg
><span class="btn-label">Load new file</span>
</button>
<div class="app-log">
<ul>
<li>Message 4 amet consectetur adipisicing elit</li>
<li>Message 3 amet consectetur adipisicing elit</li>
<li>Message 2 Lorem ipsum dolor sit ipsum dolor sit amet consectetur adipisicing elit</li>
<li>Message 1 Lorem ipsum dolor sit amet consectetur adipisicing elit</li>
</ul>
</div>
<div class="about">
<a href="https://github.com/s427/MARL">
<span x-text="$t('welcome.projectPage')"></span>
</a>
</div>
</aside>

View File

@ -7,7 +7,7 @@ const { type } = Astro.props;
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#favorite" />
<use href={`#${type}`} />
</svg>
</span>
<h2 class="summary-label">

View File

@ -0,0 +1,7 @@
<div class="tools-section about">
<p>
MARL v. 2.1.0 &mdash; <a href="https://github.com/s427/MARL">
<span x-text="$t('tools.projectPage')"></span>
</a>
</p>
</div>

View File

@ -0,0 +1,13 @@
<div class="tools-section app-log">
<h3 x-text="$t('tools.appLog')"></h3>
<ul>
<template
x-for="m in $store.ui.log"
>
<li :class="m.type">
<span class="time"><time x-text="m.time"></time></span>
<span class="msg" x-html="m.msg"></span>
</li>
</template>
</ul>
</div>

View File

@ -0,0 +1,39 @@
<div class="tools-section app-settings">
<h3 x-text="$t('tools.appSettings')"></h3>
<div class="app-setting switch-lang">
<label for="switch-language">
<svg aria-hidden="true">
<use href="#langs" /></svg>
<span class="visually-hidden" x-text="$t('tools.selectLanguage')"></span>
</label>
<select
id="switch-language"
@change="setLang"
x-model="$store.ui.lang"
>
<template x-for="(lang, id) in $store.ui.appLangs">
<option
:value="id"
x-text="lang"
:selected="$store.ui.lang === id"
></option>
</template>
</select>
</div>
<div class="app-setting switch-theme">
<button @click="$store.ui.toggleTheme()"
class="switch-theme"
:class="'theme-' + $store.ui.theme"
>
<svg class="btn-icon" aria-hidden="true">
<use
:href="'#' + ($store.ui.theme === 'dark' ? 'light' : 'dark')"
/></svg
><span class="btn-label dark" x-text="$t('tools.useDarkTheme')"></span
><span class="btn-label light" x-text="$t('tools.useLightTheme')"></span>
</button>
</div>
</div>

View File

@ -0,0 +1,44 @@
<div class="tools-section manage-files">
<div
class="loaded-files"
:class="$store.files.sources.length > 1 ? 'multiple-files' : 'single-file'"
x-show="$store.files.sources.length"
>
<h3 x-text="$t('tools.loadedFiles')"></h3>
<ul class="loaded-files-list">
<template
x-for="s in $store.files.sources"
>
<li :style="'--actor-hue: '+ s.hue">
<span class="name" x-text="s.fileInfos.name"></span>
<span class="date" x-text="formatFileDateTime(s.fileInfos.lastModified)"></span>
<span class="size" x-text="formatFileSize(s.fileInfos.size)"></span>
</li>
</template>
</ul>
</div>
<div class="manage-files-actions">
<label for="file-loader2" class="btn load-more">
<svg class="btn-icon" aria-hidden="true">
<use href="#load-file" /></svg
><span class="btn-label" x-text="$t('tools.addAnother')"></span>
<input
id="file-loader2"
class="file-loader visually-hidden"
type="file"
accept=".zip"
multiple
@change="unZip(Object.values($event.target.files))"
/>
</label>
<button @click="startOver">
<svg class="btn-icon" aria-hidden="true">
<use href="#home" /></svg
><span class="btn-label" x-text="$t('tools.startOver')"></span>
</button>
<div class="tip" x-html="$t('tools.addAnotherTip')"></div>
</div>
</div>

View File

@ -1,5 +1,6 @@
---
import ToolsPanel from '../panels/ToolsPanel.astro';
import AppSettings from '../panels/tools/AppSettings.astro';
import About from '../panels/tools/About.astro';
---
<main class="welcome">
@ -19,6 +20,8 @@ import ToolsPanel from '../panels/ToolsPanel.astro';
/>
</div>
<ToolsPanel />
<div class="tools">
<AppSettings />
<About />
</div>
</main>

View File

@ -1,3 +1,5 @@
@use "mixins";
.actor {
position: relative;
&::before {
@ -144,13 +146,9 @@
color: var(--actor-fg0);
line-height: 1.3;
background: var(--actor-bg4);
background: radial-gradient(
circle at 200% 150%,
var(--actor-bg3),
var(--actor-bg4)
);
background: radial-gradient(circle at 200% 150%, var(--actor-bg3), var(--actor-bg4));
border-radius: 0.5rem;
box-shadow: 0 0.3rem 0.4rem -0.2rem rgba(0, 0, 0, 0.3);
@include mixins.box-shadow-post();
}
.actor-infos {

View File

@ -7,6 +7,8 @@
--bg2: #{colors.$dark-bg2};
--bg3: #{colors.$dark-bg3};
--bg4: #{colors.$dark-bg4};
--bg5: #{colors.$dark-bg5};
--bg6: #{colors.$dark-bg6};
--fg0: #{colors.$dark-fg0};
--fg1: #{colors.$dark-fg1};
@ -84,6 +86,10 @@
--actor-accent-ok: #{colors.$dark-actor-accent-ok};
--actor-accent2-ok: #{colors.$dark-actor-accent2-ok};
}
.loaded-files-list li {
--actor-accent-ok: #{colors.$dark-actor-accent-ok};
--actor-accent2-ok: #{colors.$dark-actor-accent2-ok};
}
} @else {
@ -92,6 +98,8 @@
--bg2: #{colors.$bg2};
--bg3: #{colors.$bg3};
--bg4: #{colors.$bg4};
--bg5: #{colors.$bg5};
--bg6: #{colors.$bg6};
--fg0: #{colors.$fg0};
--fg1: #{colors.$fg1};
@ -168,5 +176,9 @@
--actor-accent-ok: #{colors.$actor-accent-ok};
--actor-accent2-ok: #{colors.$actor-accent2-ok};
}
.loaded-files-list li {
--actor-accent-ok: #{colors.$actor-accent-ok};
--actor-accent2-ok: #{colors.$actor-accent2-ok};
}
}
}

View File

@ -6,10 +6,12 @@ $hue: 30; // === 59.17 in oklch
/*********************************/
$bg0: #fff; // post content
$bg1: hsl($hue, 10%, 95%); // post
$bg1: hsl($hue, 12%, 95%); // post
$bg2: hsl($hue, 12%, 90%); // post attachment/meta/raw
$bg3: hsl($hue, 15%, 87%); // posts header
$bg4: hsl($hue, 17.5%, 84%); // filters/tags panels
$bg5: hsl($hue, 25%, 95%); // settings
$bg6: #fff; // settings > ul
$fg0: #000; // post content
$fg1: hsl($hue, 10%, 20%); // body text; some SVG icons (panel close)
@ -22,6 +24,8 @@ $dark-bg1: hsl($hue, 8%, 13%);
$dark-bg2: hsl($hue, 10%, 10%);
$dark-bg3: hsl($hue, 12%, 8%);
$dark-bg4: hsl($hue, 12%, 8%);
$dark-bg5: $dark-bg2;
$dark-bg6: rgba(255, 255, 255, 0.07);
$dark-fg0: hsl($hue, 5%, 95%);
$dark-fg1: hsl($hue, 10%, 80%);
$dark-fg2: hsl($hue, 15%, 50%);

View File

@ -1,5 +1,5 @@
@use 'mixins';
@use 'colors-mixins';
@use "mixins";
@use "colors-mixins";
/* mini-reset */
*,
@ -36,7 +36,7 @@ html {
@media (prefers-color-scheme: dark) {
color-scheme: dark;
@include colors-mixins.theme-vars('dark');
@include colors-mixins.theme-vars("dark");
}
}
@ -47,7 +47,7 @@ html.light {
html.dark {
color-scheme: dark;
@include colors-mixins.theme-vars('dark');
@include colors-mixins.theme-vars("dark");
}
/* global rules */
@ -81,21 +81,33 @@ a,
color: var(--accent);
}
button {
position: relative;
top: 0;
input,
button,
label.btn,
select {
padding: 0.3em 0.5em;
line-height: 24px;
font-family: inherit;
vertical-align: middle;
background: var(--bg-button);
border: none;
border-radius: 5px;
box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.35);
@media (forced-colors: active) {
border: 1px solid ButtonText;
}
}
button,
label.btn,
select {
border-radius: 5px;
}
button,
label.btn {
position: relative;
top: 0;
line-height: 24px;
vertical-align: middle;
background: var(--bg-button);
border-radius: 5px;
box-shadow: 0 0.15em 0 0 rgba(0, 0, 0, 0.35);
&:not(:has(svg)) {
padding: 0.7em 1em;
@ -163,16 +175,11 @@ button {
}
}
input {
padding: 0.3em 0.5em;
input,
select {
color: inherit;
border: none;
background-color: var(--bg-input);
@media (forced-colors: active) {
border: 1px solid ButtonText;
}
&:hover {
background-color: var(--bg-input-hover);
}
@ -182,14 +189,14 @@ input {
}
}
input[type='number'] {
input[type="number"] {
width: 6ch;
text-align: center;
-moz-appearance: textfield;
appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
@ -243,19 +250,28 @@ details {
// focusable elements
*:focus-visible,
.tags-group button:focus-visible div,
.toots-filter:has([type='checkbox']:focus-visible) {
.toots-filter:has([type="checkbox"]:focus-visible) {
text-decoration: none;
outline: 2px solid var(--accent-light);
outline-offset: 2px;
border-radius: 2px;
position: relative;
z-index: 1;
&.mobile-menu-panel {
outline-offset: -4px;
border-radius: 6px;
}
}
.visually-hidden:not(:focus):not(:active) {
@include mixins.visually-hidden();
}
[inert] > * {
opacity: 0.5;
}
.nojs {
margin: 5dvh 10dvw;
padding: 2em;

View File

@ -11,11 +11,12 @@ html {
z-index: 1;
height: 100dvh;
max-width: 100dvw;
overflow: hidden;
display: grid;
grid-template-areas:
"tools actor filters header tags"
"tools actor filters toots tags";
grid-template-columns: 150px min(25%, 600px) min(20%, 400px) 1fr min(20%, 400px);
"actor filters header tags"
"actor filters toots tags";
grid-template-columns: min(25%, 600px) min(20%, 400px) 1fr min(20%, 400px);
grid-template-rows: min-content 1fr;
}
@ -34,9 +35,6 @@ html {
.toots-tags {
grid-area: tags;
}
.panel-tools {
grid-area: tools;
}
.mobile-menu {
display: none;
}
@ -57,7 +55,7 @@ html {
.toggle-order {
margin-bottom: 5px;
}
.load-new {
.open-tools {
float: right;
margin-left: 5px;
}

View File

@ -116,6 +116,36 @@ $panel-width: 400px;
bottom: 0;
z-index: 3;
background-color: var(--menu-backdrop);
.main-page.menu-open & {
display: block;
}
}
.panel-close {
position: absolute;
right: 6px;
top: 6px;
z-index: 1;
color: var(--panel-close);
background: transparent;
box-shadow: none;
.btn-icon {
fill: currentColor;
}
&:hover,
&:focus,
&:focus-visible {
color: var(--panel-close-hover);
}
@media (forced-colors: active) {
color: buttonText;
}
.actor & {
color: #fff;
}
}
@media screen and (width < 1200px) {
@ -124,7 +154,7 @@ $panel-width: 400px;
.toots-header {
z-index: 2;
box-shadow: 0 -10px 10px 10px rgba(0, 0, 0, 0.5);
.load-new {
.open-tools {
display: none;
}
}
@ -147,6 +177,7 @@ $panel-width: 400px;
.mobile-menu-panel {
position: fixed;
left: 0 - ($menu-width + $panel-width);
right: auto;
top: 0;
bottom: 0;
z-index: 3;
@ -163,31 +194,6 @@ $panel-width: 400px;
scroll-behavior: smooth;
}
}
.panel-close {
position: absolute;
right: 6px;
top: 6px;
z-index: 1;
color: var(--panel-close);
background: transparent;
box-shadow: none;
.btn-icon {
fill: currentColor;
}
&:hover,
&:focus,
&:focus-visible {
color: var(--panel-close-hover);
}
@media (forced-colors: active) {
color: buttonText;
}
.actor & {
color: #fff;
}
}
.mobile-menu {
display: block;
@ -196,13 +202,10 @@ $panel-width: 400px;
}
.main-page.menu-open {
.panel-backdrop {
display: block;
}
&.menu-open-actor .actor,
&.menu-open-filters .toots-filters,
&.menu-open-tags .toots-tags {
&.menu-open-tags .toots-tags,
&.menu-open-tools .tools {
left: $menu-width;
box-shadow: -1rem 0 1rem 1rem rgba(0, 0, 0, 0.5);
@media (prefers-reduced-motion: no-preference) {
@ -212,7 +215,8 @@ $panel-width: 400px;
&.menu-open-actor .menu-actor,
&.menu-open-filters .menu-filters,
&.menu-open-tags .menu-tags {
&.menu-open-tags .menu-tags,
&.menu-open-tools .menu-tools {
color: var(--menu-fg-active);
background-color: rgba(255, 255, 255, 0.2);
}
@ -258,7 +262,8 @@ $panel-width: 400px;
.main-page.menu-open {
&.menu-open-actor .actor,
&.menu-open-filters .toots-filters,
&.menu-open-tags .toots-tags {
&.menu-open-tags .toots-tags,
&.menu-open-tools .tools {
left: 0;
}
}

View File

@ -9,5 +9,8 @@
}
@mixin box-shadow-inner() {
box-shadow: 0 0.1rem 0.2rem -0.2rem rgba(0, 0, 0, 0.5) inset;
box-shadow: 0 0.15rem 0.3rem -0.2rem rgba(0, 0, 0, 0.35) inset;
}
@mixin box-shadow-post() {
box-shadow: 0 0.3rem 0.4rem -0.2rem rgba(0, 0, 0, 0.3);
}

View File

@ -18,7 +18,7 @@ $display-wide: 340px;
height: 100%;
overflow: hidden;
display: grid;
padding: 1rem 1rem 0;
padding: 0.5rem 1rem;
grid-template-rows: min-content 1fr;
ul {
@ -92,15 +92,14 @@ $display-wide: 340px;
}
.tags-group-header {
white-space: nowrap;
overflow: hidden;
@container (width >= #{$display-wide}) {
display: flex;
justify-content: space-between;
gap: 0.25rem 1rem;
}
h3 {
white-space: nowrap;
overflow: hidden;
.count {
color: var(--accent-dark);
font-weight: normal;
@ -121,7 +120,7 @@ $display-wide: 340px;
.tags-group-scroll {
overflow: auto;
padding-top: 0.5rem;
margin-top: 0.5rem;
padding-bottom: 2rem;
}

View File

@ -1,3 +1,199 @@
.panel-tools {
padding: 1rem;
@use "mixins";
.tools-section {
h3 {
margin: 0 0 1rem;
}
ul {
list-style: none;
font-size: 0.85em;
margin: 1rem 0;
padding: 0.5rem 1rem;
background-color: var(--bg6);
border-radius: 0.5rem;
word-break: break-all;
}
li {
list-style: none;
margin: 0.5rem 0;
}
}
.panel-tools {
position: absolute;
left: auto;
right: calc(0px - min(25%, 600px));
top: 0;
bottom: 0;
z-index: 4;
width: min(25%, 600px);
height: 100dvh;
overflow-y: scroll;
padding: 0;
box-shadow: 0.75rem 0 1rem 0.75rem rgba(0, 0, 0, 0.35);
background-color: var(--bg5);
backdrop-filter: blur(20px);
display: flex;
flex-direction: column;
justify-content: space-between;
@media (prefers-reduced-motion: no-preference) {
transition: right 0.2s ease-out;
transition-property: right, box-shadow;
scroll-behavior: smooth;
}
.menu-open-tools & {
right: 0;
}
.panel-close {
display: block !important;
}
.tools-section {
padding: 1rem 1rem 2rem;
&.app-log {
padding-bottom: 0;
}
}
}
.app-settings {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
h3 {
margin: 0;
flex: 0 0 100%;
user-select: none;
}
}
.switch-lang {
display: flex;
gap: 1rem;
line-height: 24px;
label {
display: block;
width: 24px;
height: 24px;
padding: 0.3rem 0.5rem;
svg {
width: 24px;
height: 24px;
fill: var(--button-svg);
@media (forced-colors: active) {
fill: ButtonText;
}
}
}
}
.switch-theme {
&.theme-dark {
.btn-label.dark {
display: none;
}
}
&.theme-light {
.btn-label.light {
display: none;
}
}
}
.loaded-files-list {
@include mixins.box-shadow-inner();
li {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
li + li {
margin-top: 1rem;
}
.name {
flex: 0 0 100%;
color: var(--accent);
}
@media (forced-colors: none) {
.multiple-files & li {
position: relative;
padding-left: 1.25rem;
&::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 1;
width: 0.25rem;
border-radius: 0.25rem;
background-color: var(--actor-accent-ok, transparent);
}
}
}
}
.manage-files-actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
.file-loader {
@include mixins.visually-hidden();
}
.tip {
flex: 0 0 100%;
font-size: 0.8em;
text-align: center;
font-style: italic;
opacity: 0.85;
}
}
.app-log {
ul {
height: 20dvh;
overflow-y: auto;
@include mixins.box-shadow-inner();
}
.msg {
b {
font-weight: normal;
color: var(--accent);
}
}
.time {
opacity: 0.65;
}
}
.about {
font-size: 0.85em;
text-align: center;
}
.welcome {
.tools {
margin-top: auto;
h3,
.app-log {
display: none;
}
}
.manage-files {
display: none;
}
.about {
margin-top: 4rem;
}
}

View File

@ -62,7 +62,7 @@ $meta-visible: 100ch;
overflow-wrap: break-word;
background-color: var(--bg0);
border-radius: 0.5rem;
box-shadow: 0 0.3rem 0.4rem -0.2rem rgba(0, 0, 0, 0.3);
@include mixins.box-shadow-post();
@media (forced-colors: active) {
border: 1px solid CanvasText;
@ -113,7 +113,6 @@ $meta-visible: 100ch;
z-index: 1;
width: 0.25rem;
border-radius: 0.25rem;
background-color: red;
background-color: var(--actor-accent-ok, transparent);
}
}
@ -193,7 +192,7 @@ $meta-visible: 100ch;
object-fit: cover;
font-size: 0.6em;
border-radius: 0.5rem;
box-shadow: 0 0.3rem 0.4rem -0.2rem rgba(0, 0, 0, 0.3);
@include mixins.box-shadow-post();
mix-blend-mode: multiply;
@media (prefers-reduced-motion: no-preference) {
transition: filter 0.2s ease-out;
@ -253,7 +252,7 @@ $meta-visible: 100ch;
max-width: 100%;
max-height: 75dvh;
border-radius: 0.5rem;
box-shadow: 0 0.3rem 0.4rem -0.2rem rgba(0, 0, 0, 0.3);
@include mixins.box-shadow-post();
}
.att-description {
padding: 1rem;

View File

@ -18,10 +18,6 @@
line-height: 1.4;
position: relative;
z-index: 1;
.load-new {
display: none;
}
}
.intro {

View File

@ -22,6 +22,9 @@ import SvgSprites from '../components/SvgSprites.astro';
<SvgSprites />
<script src="/js/strings.js" is:inline></script>
<script src="/js/main.js" is:inline></script>
<script src="/js/stores.js" is:inline></script>
<script src="/js/utils.js" is:inline></script>
<script src="/js/libs.js" is:inline></script>
<script src="/js/init.js" is:inline></script>
</body>
</html>

1
dist/_astro/index.33CKCK6j.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
dist/index.html vendored

File diff suppressed because one or more lines are too long

30
dist/js/init.js vendored Normal file
View File

@ -0,0 +1,30 @@
drag.init("app");
document.addEventListener("alpine:init", () => {
// create and init stores
Alpine.store("files", filesStore);
Alpine.store("lightbox", lightboxStore);
Alpine.store("ui", uiStore);
Alpine.store("userPrefs", userPrefsStore);
const salutations = [
"Hi!",
"Hiya!",
"Hello there!",
"Good day!",
"Hullo!",
"Buongiorno!",
"Guten Tag!",
"Bonjour!",
"Oh hey!",
];
Alpine.store("ui").logMsg(`MARL loaded. ${salutations[Math.floor(Math.random() * salutations.length)]} 😊`);
resetStores();
});
document.addEventListener("alpine-i18n:ready", function () {
AlpineI18n.create("en", appStrings);
AlpineI18n.fallbackLocale = "en";
setLang();
});

36
dist/js/libs.js vendored Normal file
View File

@ -0,0 +1,36 @@
const isFileProtocol = window.location.protocol === "file:";
const scripts = [
{
src: "js/jszip.min.js",
integrity: "sha512-XMVd28F1oH/O71fzwBnV7HucLxVwtxf26XV8P4wPk26EDxuGZ91N8bsOttmnomcCD3CS5ZMRL50H0GgOHvegtg==",
crossorigin: "anonymous",
defer: false,
},
// Note: Alpine plug-ins must be inserted BEFORE alpinejs
{
src: "js/alpinejs-i18n.min.js",
integrity: "sha256-o204NcFyHPFzboSC51fufMqFe2KJdQfSCl8AlvSZO/E=",
crossorigin: "anonymous",
defer: true,
},
{
src: "js/alpinejs.min.js",
integrity: "sha512-FUaEyIgi9bspXaH6hUadCwBLxKwdH7CW24riiOqA5p8hTNR/RCLv9UpAILKwqs2AN5WtKB52CqbiePBei3qjKg==",
crossorigin: "anonymous",
defer: true,
},
];
scripts.forEach(({ src, integrity, crossorigin, defer }) => {
const script = document.createElement("script");
script.src = src;
if (!isFileProtocol) {
script.integrity = integrity;
script.crossOrigin = crossorigin;
}
if (defer) {
script.defer = true;
}
document.head.appendChild(script);
});

1507
dist/js/main.js vendored

File diff suppressed because it is too large Load Diff

888
dist/js/stores.js vendored Normal file
View File

@ -0,0 +1,888 @@
// stores definitions
const userPrefsStore = {
prefix: "marl_",
save(pref, value) {
const msg = `Saving user preference <b>(${pref}: ${value})</b>`;
Alpine.store("ui").logMsg(msg, "info");
localStorage.setItem(this.prefix + pref, value);
},
load(pref) {
const value = localStorage.getItem(this.prefix + pref);
if (value !== null) {
this.set(pref, value);
} else if (pref === "lang") {
this.set(pref, value);
}
},
set(pref, value) {
switch (pref) {
case "sortAsc":
value = +value === 1 ? true : false;
if (value !== Alpine.store("files").sortAsc) {
Alpine.store("files").sortAsc = value;
}
break;
case "pageSize":
value = +value;
if (typeof value == "number" && !isNaN(value) && value > 0 && value !== Alpine.store("files").pageSize) {
Alpine.store("files").pageSize = value;
}
break;
case "lang":
if (!value) {
value = detectLangFromBrowser();
if (value) {
this.save("lang", value);
}
}
if (!value || !Alpine.store("ui").appLangs[value]) {
if (value) {
const msg = `<b>Unrecognized language</b> in user preferences: ${value}`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
}
value = "en";
this.save("lang", value);
}
Alpine.store("ui").lang = value;
break;
case "theme":
if (!(value === "dark" || value === "light")) {
value = "light";
this.save("theme", value);
}
Alpine.store("ui").theme = value;
setTheme(value);
break;
}
},
};
const filesStore = {
resetState() {
this.sources = [];
this.toots = [];
this.toc = [];
this.duplicates = false;
this.sortAsc = true; // -> userPrefs
this.pageSize = 10; // -> userPrefs
this.currentPage = 1;
this.loading = false;
this.someFilesLoaded = false;
this.languages = {};
this.boostsAuthors = [];
this.filters = {};
this.filtersDefault = {
fullText: "",
hashtagText: "",
mentionText: "",
externalLink: "",
summary: "",
isEdited: false,
isDuplicate: false,
noStartingAt: false,
hasExternalLink: false,
hasHashtags: false,
hasMentions: false,
hasSummary: false,
isSensitive: false,
visibilityPublic: true,
visibilityUnlisted: true,
visibilityFollowers: true,
visibilityMentioned: true,
typeOriginal: true,
typeBoost: true,
attachmentAny: false,
attachmentImage: false,
attachmentVideo: false,
attachmentSound: false,
attachmentNoAltText: false,
attachmentWithAltText: false,
// automatically generated (see loadJsonFile()):
// lang_en: true,
// lang_fr: true,
// lang_de: true,
// etc
// actor_0: true,
// actor_1: true,
// actor_2: true,
// etc
};
this.filtersActive = false;
this.tagsFilters = {
hashtags: "",
mentions: "",
boostsAuthors: "",
};
Alpine.store("userPrefs").load("sortAsc");
Alpine.store("userPrefs").load("pageSize");
},
setFilter() {
this.checkPagingValue();
scrollTootsToTop();
pagingUpdated();
if (JSON.stringify(this.filters) === JSON.stringify(this.filtersDefault)) {
this.filtersActive = false;
} else {
this.filtersActive = true;
}
const self = this;
setTimeout(() => {
self.checkPagingValue();
}, 50);
},
filterByTag(filter, value, id) {
if (value) {
if (value === this.filters[filter]) {
this.filters[filter] = "";
} else {
this.filters[filter] = value;
}
}
// "boosted users" group
// in this case let's also (un)check the 'boost type' filters
if (filter == "fullText") {
if (this.filters[filter] === "") {
this.filters.typeBoost = true;
this.filters.typeOriginal = true;
} else {
this.filters.typeBoost = true;
this.filters.typeOriginal = false;
}
}
this.setFilter();
// keyboard focus may be lost when tags list changes
setTimeout(() => {
document.getElementById(id).focus();
}, 100);
},
resetFilters(userAction) {
this.filters = JSON.parse(JSON.stringify(this.filtersDefault));
if (userAction) {
this.currentPage = 1;
this.filtersActive = false;
scrollTootsToTop();
pagingUpdated();
}
},
get filteredToots() {
const f = this.filters;
const fa = this.filtersActive;
return this.toots.filter((t) => {
if (!fa) {
return true;
}
if (f.fullText) {
let show = false;
if (t._marl.textContent) {
const filterValue = f.fullText.toLowerCase();
if (filterValue && t._marl.textContent && t._marl.textContent.indexOf(filterValue) >= 0) {
show = true;
}
}
if (!show) {
return show;
}
}
if (f.hashtagText) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
const filterValue = f.hashtagText.toLowerCase();
if (
!t.object.tag.some((t) => {
return t.type === "Hashtag" && t.name.toLowerCase().indexOf(filterValue) > -1;
})
) {
return false;
}
} else {
return false;
}
}
if (f.mentionText) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
const filterValue = f.mentionText.toLowerCase();
if (
!t.object.tag.some((t) => {
return t.type === "Mention" && t.name.toLowerCase().indexOf(filterValue) > -1;
})
) {
return false;
}
} else {
return false;
}
}
if (f.summary) {
if (t._marl.summary) {
const filterValue = f.summary.toLowerCase();
if (t._marl.summary.indexOf(filterValue) === -1) {
return false;
}
} else {
return false;
}
}
if (f.isEdited) {
if (!(typeof t.object === "object" && t.object !== null && t.object.updated)) {
return false;
}
}
if (f.isDuplicate) {
if (!t._marl.duplicate) {
return false;
}
}
if (f.noStartingAt) {
if (!t._marl.textContent || t._marl.textContent.indexOf("@") === 0) {
return false;
}
}
if (f.hasExternalLink) {
if (!t._marl.externalLinks || !t._marl.externalLinks.length) {
return false;
}
}
if (f.hasHashtags) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
if (
!t.object.tag.some((t) => {
return t.type === "Hashtag";
})
) {
return false;
}
} else {
return false;
}
}
if (f.hasMentions) {
if (typeof t.object === "object" && t.object !== null && t.object.tag) {
if (
!t.object.tag.some((t) => {
return t.type === "Mention";
})
) {
return false;
}
} else {
return false;
}
}
if (f.hasSummary) {
if (typeof t.object === "object" && t.object !== null) {
if (!t.object.summary) {
return false;
}
} else {
return false;
}
}
if (f.isSensitive) {
if (typeof t.object === "object" && t.object !== null) {
if (!t.object.sensitive) {
return false;
}
} else {
return false;
}
}
if (f.externalLink) {
let show = false;
if (t._marl.externalLinks && t._marl.externalLinks.length) {
const filterValue = f.externalLink.toLowerCase();
show = t._marl.externalLinks.some((link) => {
return link.href.indexOf(filterValue) > -1 || link.text.indexOf(filterValue) > -1;
});
}
if (!show) {
return false;
}
}
if (!f.visibilityPublic && t._marl.visibility[0] === "public") {
return false;
}
if (!f.visibilityUnlisted && t._marl.visibility[0] === "unlisted") {
return false;
}
if (!f.visibilityFollowers && t._marl.visibility[0] === "followers") {
return false;
}
if (!f.visibilityMentioned && t._marl.visibility[0] === "mentioned") {
return false;
}
if (!f.typeOriginal && t.type === "Create") {
return false;
}
if (!f.typeBoost && t.type === "Announce") {
return false;
}
if (f.attachmentAny) {
if (!t._marl.hasAttachments) {
return false;
}
}
if (f.attachmentImage) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsImage(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentVideo) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsVideo(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentSound) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return attachmentIsSound(att);
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentNoAltText) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return att.name === null;
})
) {
return false;
}
} else {
return false;
}
}
if (f.attachmentWithAltText) {
if (t._marl.hasAttachments) {
if (
!t.object.attachment.some((att) => {
return att.name;
})
) {
return false;
}
} else {
return false;
}
}
for (const lang in this.languages) {
if (f.hasOwnProperty("lang_" + lang) && f["lang_" + lang] === false) {
if (t._marl.langs.includes(lang) || t._marl.langs.length === 0) {
return false;
}
}
}
for (const source of this.sources) {
const id = source.id;
if (f.hasOwnProperty("actor_" + id) && f["actor_" + id] === false) {
if (t._marl.source === id) {
return false;
}
}
}
return true;
});
},
get listHashtags() {
return this.listTags("Hashtag");
},
get listMentions() {
return this.listTags("Mention");
},
listTags(type) {
let filterSource = "";
switch (type) {
case "Mention":
filterSource = "mentions";
break;
case "Hashtag":
filterSource = "hashtags";
break;
}
let h = this.filteredToots.reduce((accu, toot) => {
if (tootHasTags(toot)) {
for (const key in toot.object.tag) {
const tag = toot.object.tag[key];
if (
tag.type &&
tag.type === type &&
tag.name &&
tag.name.toLowerCase().indexOf(this.tagsFilters[filterSource].toLowerCase()) >= 0
) {
if (
accu.some((item) => {
return item.name === tag.name;
})
) {
accu.map((item) => {
if (item.name === tag.name) {
item.nb++;
}
});
} else {
accu.push({
name: tag.name,
href: tag.href,
nb: 1,
});
}
}
}
}
return accu;
}, []);
h.sort((a, b) => {
if (a.nb === b.nb) {
return a.name.localeCompare(b.name);
} else {
return b.nb - a.nb;
}
});
return h;
},
get listBoostsAuthors() {
let r = this.boostsAuthors.reduce((accu, item) => {
if (item.name.toLowerCase().indexOf(this.tagsFilters.boostsAuthors.toLowerCase()) >= 0) {
accu.push(item);
}
return accu;
}, []);
r.sort((a, b) => {
if (a.nb === b.nb) {
let aHasNoName = a.name.indexOf("? ") === 0;
let bHasNoName = b.name.indexOf("? ") === 0;
if (aHasNoName && bHasNoName) {
return a.name.localeCompare(b.name);
} else if (aHasNoName) {
return 1;
} else if (bHasNoName) {
return -1;
} else {
return a.name.localeCompare(b.name);
}
} else {
if (a.nb === b.nb) {
return a.name.localeCompare(b.name);
} else {
return b.nb - a.nb;
}
}
});
return r;
},
get sortedLanguages() {
let langs = [];
for (const lang in this.languages) {
langs.push([lang, this.languages[lang]]);
}
langs.sort((a, b) => {
if (a[0] === "undefined") {
return 1;
}
if (b[0] === "undefined") {
return -1;
}
if (a[1] === b[1]) {
return a[0].localeCompare(b[0]);
}
return b[1] - a[1];
});
return langs;
},
get appReady() {
if (this.sources.length === 0) {
return false;
}
let r = true;
for (let i = 0; i < this.sources.length; i++) {
const source = this.sources[i];
if (
!source.loaded.actor ||
!source.loaded.avatar ||
!source.loaded.header ||
!source.loaded.outbox ||
!source.loaded.likes ||
!source.loaded.bookmarks
) {
r = false;
}
}
return r;
},
get totalPages() {
return Math.ceil(this.filteredToots.length / this.pageSize);
},
get pagedToots() {
if (this.filteredToots) {
return this.filteredToots.filter((_, index) => {
let start = (this.currentPage - 1) * this.pageSize;
let end = this.currentPage * this.pageSize;
if (index >= start && index < end) return true;
});
} else {
return [];
}
},
sortToots() {
this.toots.sort((a, b) => {
if (this.sortAsc) {
return a.published.localeCompare(b.published);
} else {
return b.published.localeCompare(a.published);
}
});
},
toggleTootsOrder() {
this.sortAsc = !this.sortAsc;
Alpine.store("userPrefs").save("sortAsc", this.sortAsc ? 1 : 0);
this.sortToots();
scrollTootsToTop();
pagingUpdated();
},
setPostsPerPage() {
this.checkPagingValue();
Alpine.store("userPrefs").save("pageSize", this.pageSize);
},
checkPagingValue() {
if (this.currentPage < 1) {
this.currentPage = 1;
} else if (this.currentPage > this.totalPages) {
this.currentPage = this.totalPages;
}
},
nextPage(setFocusTo) {
if (this.currentPage * this.pageSize < this.filteredToots.length) {
this.currentPage++;
scrollTootsToTop(setFocusTo);
pagingUpdated();
}
},
prevPage(setFocusTo) {
if (this.currentPage > 1) {
this.currentPage--;
scrollTootsToTop(setFocusTo);
pagingUpdated();
}
},
firstPage(setFocusTo) {
this.currentPage = 1;
scrollTootsToTop(setFocusTo);
pagingUpdated();
},
lastPage(setFocusTo) {
this.currentPage = this.totalPages;
scrollTootsToTop(setFocusTo);
pagingUpdated();
},
};
const lightboxStore = {
resetState() {
this.show = false;
this.data = [];
this.source = 0;
this.index = 0;
this.origin = "";
},
open(toot, index, origin) {
this.data = toot.object.attachment;
this.source = toot._marl.source;
this.show = true;
this.index = index;
this.origin = origin;
document.getElementById("main-section-inner").setAttribute("inert", true);
setTimeout(() => {
document.getElementById("lightbox").focus();
}, 50);
},
openProfileImg(name, origin, source) {
const data = {
object: {
attachment: [
{
name: name,
url: name,
mediaType: Alpine.store("files").sources[source][name].type,
},
],
},
_marl: {
source: source,
},
};
this.open(data, 0, origin);
},
close() {
const origin = this.origin;
this.data = [];
this.index = 0;
this.show = false;
this.origin = "";
document.getElementById("main-section-inner").removeAttribute("inert");
document.getElementById(origin).focus();
},
showNext() {
this.index++;
if (this.index >= this.data.length) {
this.index = 0;
}
if (!attachmentIsImage(this.data[this.index])) {
this.showNext();
}
},
showPrev() {
this.index--;
if (this.index < 0) {
this.index = this.data.length - 1;
}
if (!attachmentIsImage(this.data[this.index])) {
this.showPrev();
}
},
};
const uiStore = {
log: [],
resetState() {
this.pagingOptionsVisible = false;
this.openMenu = "";
this.actorPanel = 0;
this.menuIsActive = false;
this.lang = "en";
this.appLangs = appLangs ?? { en: "English" };
this.theme = "light";
this.log = this.log ?? [];
Alpine.store("userPrefs").load("lang");
Alpine.store("userPrefs").load("theme");
},
logMsg(msg, type) {
type = type ?? "info";
const dateOptions = {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const time = new Date().toLocaleTimeString(Alpine.store("ui").lang, dateOptions);
let m = {
msg: msg,
type: type,
time: time,
};
this.log.unshift(m);
},
toggleTheme() {
this.theme = this.theme === "light" ? "dark" : "light";
Alpine.store("userPrefs").save("theme", this.theme);
setTheme(this.theme);
},
togglePagingOptions() {
this.pagingOptionsVisible = !this.pagingOptionsVisible;
if (this.pagingOptionsVisible) {
setTimeout(() => {
document.getElementById("paging-options").focus();
}, 100);
}
},
get pagingOptionsClass() {
return this.pagingOptionsVisible ? "open" : "";
},
openActorPanel(id) {
this.actorPanel = id;
},
switchActorPanel(dir) {
let id = this.actorPanel;
if (dir === "up") {
id++;
if (id >= Alpine.store("files").sources.length) {
id = 0;
}
} else {
id--;
if (id < 0) {
id = Alpine.store("files").sources.length - 1;
}
}
this.actorPanel = id;
document.getElementById("actortab-" + id).focus();
},
menuClose() {
const name = this.openMenu;
this.openMenu = "";
this.setInert();
// bring focus back to where it was before the panel was opened
document.querySelector("#main-section-inner .mobile-menu .menu-" + name).focus();
},
menuOpen(name) {
this.openMenu = name;
this.resetPanels();
this.setInert();
setTimeout(() => {
document.getElementById("panel-" + name).focus();
}, 100);
},
menuToggle(name) {
switch (name) {
case "actor":
case "filters":
case "tags":
case "tools":
if (this.openMenu === name) {
this.menuClose();
} else {
this.menuOpen(name);
}
break;
}
},
resetPanels() {
const name = this.openMenu;
document.querySelectorAll(`#panel-${name} details[open]`).forEach((e) => {
e.removeAttribute("open");
});
setTimeout(() => {
document.getElementById("panel-" + name).scrollTop = 0;
}, 250);
},
checkMenuState() {
const menu = document.getElementById("mobile-menu");
if (window.getComputedStyle(menu, null).display === "none") {
this.menuIsActive = false;
} else {
this.menuIsActive = true;
}
this.setInert();
},
setInertMain() {
document
.querySelectorAll("#main-section-inner > *:not(.mobile-menu, .panel-backdrop, #panel-" + this.openMenu)
.forEach((e) => {
e.setAttribute("inert", true);
});
},
setInertPanels() {
document.querySelectorAll("#panel-actor, #panel-filters, #panel-tags, #panel-tools").forEach((e) => {
e.setAttribute("inert", true);
});
},
setInertTools() {
document.querySelectorAll("#panel-tools").forEach((e) => {
e.setAttribute("inert", true);
});
},
setInert() {
// set the 'inert' state on the side panels or the main part of the app
// depending on whether they are hidden or not, AND whether the mobile
// menu is active
document.querySelectorAll("#main-section-inner > *").forEach((e) => {
e.removeAttribute("inert");
});
if (this.menuIsActive) {
if (this.openMenu) {
this.setInertMain();
} else {
this.setInertPanels();
}
} else {
if (this.openMenu === "tools") {
this.setInertMain();
} else {
this.setInertTools();
}
}
},
get appClasses() {
let classes = [];
if (this.openMenu) {
classes.push("menu-open menu-open-" + this.openMenu);
} else {
classes.push("menu-closed");
}
return classes;
},
};

208
dist/js/strings.js vendored
View File

@ -1,7 +1,7 @@
const appLangs = [
["en", "English"],
["fr", "Français"],
];
const appLangs = {
en: "English",
fr: "Français",
};
const appStrings = {
en: {
@ -16,7 +16,6 @@ const appStrings = {
p3: `<strong>Start by opening your archive file with MARL.</strong><br />
You can drag and drop it anywhere on this page, or
{labelStart}click here to select it{labelEnd}.`,
projectPage: `Project page (github)`,
},
misc: {
loading: "Loading",
@ -27,8 +26,7 @@ const appStrings = {
filters: "Filters",
filtersActive: "some filters are active",
tags: "Tags",
newFile: "New File",
newFileConfirm: "Discard current data and load a new archive file?",
tools: "Tools",
},
lightbox: {
next: "Next image",
@ -50,8 +48,8 @@ const appStrings = {
cache. Posts that are not in your instance cache any more are not included in your
archive. This affects boosts, likes, and bookmarks.`,
rawData: "Raw data {fileName}",
favorites: "Favorites",
favoritesEmpty: "no favorites",
likes: "Favorites",
likesEmpty: "no favorites",
bookmarks: "Bookmarks",
bookmarksEmpty: "no bookmarks",
},
@ -118,7 +116,7 @@ const appStrings = {
posts: {
panelTitle: "Posts",
noResults: "No results for the specified filters",
noPostsError: "No posts found in archive (?!)",
noPostsError: "No posts found in archive",
},
post: {
by: "by",
@ -140,6 +138,21 @@ const appStrings = {
mentionsFilter: "Filter mentions",
boostsFilter: "Filter boosted users",
},
tools: {
panelTitle: "Tools",
appSettings: "App settings",
selectLanguage: "Select language",
useDarkTheme: "Use dark theme",
useLightTheme: "Use light theme",
loadedFiles: "Loaded files",
addAnother: "Add another archive",
addAnotherTip:
"Tip: You can open multiple archives at once.<br>You can also drag and drop your archive files anywhere on this window.",
startOver: "Start over",
startOverConfirm: "Discard current data and load a new archive file?",
appLog: "App log",
projectPage: `Project page (github)`,
},
},
fr: {
@ -154,129 +167,142 @@ const appStrings = {
p3: `<strong>Commencez par ouvrir votre archive avec MARL.</strong><br />
Vous pouvez la glisser-déposer n'importe sur cette page, ou
{labelStart}cliquer ici pour la sélectionner{labelEnd}.`,
projectPage: `Page du project (github)`,
},
misc: {
loading: "Loading",
closePanelBtn: "Close panel",
loading: "Chargement",
closePanelBtn: "Fermer le panneau",
},
menu: {
profile: "Profile",
filters: "Filters",
filtersActive: "some filters are active",
profile: "Profil",
filters: "Filtres",
filtersActive: "certains filtres sont actifs",
tags: "Tags",
newFile: "New File",
newFileConfirm: "Discard current data and load a new archive file?",
tools: "Outils",
},
lightbox: {
next: "Next image",
prev: "Previous image",
close: "Close image",
next: "Image suivante",
prev: "Image précédente",
close: "Fermer l'image",
},
actor: {
accountInfo: "Account info",
accounts: "Accounts",
noAvatarImage: "No avatar image",
noHeaderImage: "No header image",
headerImage: "Header",
memberSince: "Member since",
accountInfo: "Infos du compte",
accounts: "Comptes",
noAvatarImage: "Pas d'avatar",
noHeaderImage: "pas d'image d'en-tête",
headerImage: "En-tête",
memberSince: "Membre depuis",
countPosts: "posts",
countInArchive: "in archive",
countDiffWhy: "Why are those two numbers different?",
countDiffExplanation: `Posts that are not directly hosted on your instance are kept
in a cache by your instance for a given time, after what they are deleted from that
cache. Posts that are not in your instance cache any more are not included in your
archive. This affects boosts, likes, and bookmarks.`,
rawData: "Raw data {fileName}",
favorites: "Favorites",
favoritesEmpty: "no favorites",
bookmarks: "Bookmarks",
bookmarksEmpty: "no bookmarks",
countInArchive: "dans l'archive",
countDiffWhy: "Pourquoi ces deux nombres sont-ils différents ?",
countDiffExplanation: `Les posts qui ne sont pas hébergés directement sur votre instance
sont gardés en cache par celle-ci pour une durée limitée, après quoi ils sont supprimés
de ce cache. Les posts qui ne sont plus présents dans le cache de votre instance ne sont
pas inclus dans votre archive. Cela concerne les partages, les favoris et les marque-pages.`,
rawData: "Données brutes {fileName}",
likes: "Favoris",
likesEmpty: "aucun favori",
bookmarks: "Marque-pages",
bookmarksEmpty: "aucun marque-page",
},
filters: {
panelTitle: "Filter posts",
panelNotice: `The list of posts will be automatically updated based on the active
filters below.`,
fullText: "Full text",
panelTitle: "Filtrer les posts",
panelNotice: `La liste des posts sera automatiquement mise à jour en fonction des filtres
activés ci-dessous.`,
fullText: "Partout",
hashtagText: "Hashtags",
mentionText: "Mentions",
externalLink: "External links",
summary: "Summary (CW)",
isEdited: "Has been edited",
isDuplicate: "Non-exact duplicates",
externalLink: "Liens externes",
summary: "Avertissement de contenu",
isEdited: "A été modifié",
isDuplicate: "Doublons imparfaits",
mustContain: "Must contain",
mustContain: "Doit contenir",
hasHashtags: "Hashtag(s)",
hasMentions: "Mention(s)",
hasExternalLink: "External link(s)",
hasSummary: "Summary (CW)",
hasExternalLink: "Lien(s) externe(s)",
hasSummary: "Avertissement de contenu",
type: "Type",
typeOriginal: "Original posts (incl. replies)",
typeBoost: "Boosts",
noStartingAt: 'Does not start with "@"',
isSensitive: "Marked as sensitive",
typeOriginal: "Posts originaux (y.c. réponses)",
typeBoost: "Partages",
noStartingAt: 'Ne commence pas par "@"',
isSensitive: "Marqué comme sensible",
mustHaveAttachement: "Must have attachment",
attachmentAny: "Any type",
mustHaveAttachement: "Doit avoir un fichier joint",
attachmentAny: "N'importe quel type",
attachmentImage: "Image(s)",
attachmentVideo: "Video(s)",
attachmentSound: "Sound(s)",
attachmentNoAltText: "Without alt text",
attachmentWithAltText: "With alt text",
attachmentVideo: "Vidéo(s)",
attachmentSound: "Son(s)",
attachmentNoAltText: "Sans description alternative",
attachmentWithAltText: "Avec description alternative",
visibility: "Visibility",
visibility: "Confidentialité",
visibilityPublic: "Public",
visibilityUnlisted: "Unlisted",
visibilityFollowers: "Followers only",
visibilityMentioned: "Mentioned people only",
visibilityUnlisted: "Public discret",
visibilityFollowers: "Abonnés",
visibilityMentioned: "Personnes spécifiques",
language: "Language",
author: "Author",
language: "Langue",
author: "Auteur",
resetFilters: "Reset filters",
resetFilters: "Réinitialiser les filtres",
},
header: {
countLabel: "posts",
oldestFirst: "oldest first",
latestFirst: "latest first",
reverse: "Reverse",
loadNewFile: "Load new file",
oldestFirst: "les plus anciens d'abord",
latestFirst: "les plus récents d'abord",
reverse: "Inverser",
loadNewFile: "Charger un nouveau fichier",
},
paging: {
first: "First",
prev: "Prev",
next: "Next",
last: "Last",
pagingOptions: "Paging options",
first: "Première",
prev: "Précédente",
next: "Suivante",
last: "Dernière",
pagingOptions: "Options de pagination",
page: "Page",
postsPerPage: "posts per page",
reverseOrder: "Reverse order",
postsPerPage: "posts par page",
reverseOrder: "Inverser l'ordre",
},
posts: {
panelTitle: "Posts",
noResults: "No results for the specified filters",
noPostsError: "No posts found in archive (?!)",
noResults: "Pas de résultats pour les filtres spécifiés",
noPostsError: "Aucun post trouvé dans l'archive",
},
post: {
by: "by",
lastUpdated: "Last updated",
linkToPost: "link",
attachmentNoAlt: "No description provided",
attachmentInArchive: "In archive:",
people: "People",
by: "par",
lastUpdated: "Dernière modification",
linkToPost: "lien",
attachmentNoAlt: "Aucune description fournie",
attachmentInArchive: "Dans l'archive :",
people: "Personnes",
hashtags: "Hashtags",
extLinks: "External links",
rawData: "Raw data",
extLinks: "Liens externes",
rawData: "Données brutes",
},
tags: {
panelTitle: "Tags",
hashtags: "Hashtags",
mentions: "Mentions",
boosts: "Boosted users",
hashtagsFilter: "Filter hashtags",
mentionsFilter: "Filter mentions",
boostsFilter: "Filter boosted users",
boosts: "Utilisateurs partagés",
hashtagsFilter: "Filtrer les hashtags",
mentionsFilter: "Filtrer les mentions",
boostsFilter: "Filter utilisateurs partagés",
},
tools: {
panelTitle: "Outils",
appSettings: "Réglages de l'app",
selectLanguage: "Choisir la langue",
useDarkTheme: "Utiliser le thème sombre",
useLightTheme: "Utiliser le thème clair",
loadedFiles: "Fichiers chargés",
addAnother: "Ajouter une autre archive",
addAnotherTip:
"Astuce: Vous pouvez ouvrir plusieurs archives en même temps.<br>Vous pouvez aussi glisser-déposer vos fichiers d'archive n'importe où dans cette fenêtre.",
startOver: "Recommencer",
startOverConfirm: "Repartir de zéro et charger un nouveau fichier ?",
appLog: "Journal",
projectPage: `Page du project (github)`,
},
},
};

678
dist/js/utils.js vendored Normal file
View File

@ -0,0 +1,678 @@
function resetStores() {
Alpine.store("files").resetState();
Alpine.store("lightbox").resetState();
Alpine.store("ui").resetState();
}
function unZip(files) {
const firstLoad = Alpine.store("files").sources.length === 0;
if (firstLoad) {
resetStores();
}
Alpine.store("files").loading = true;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (
Alpine.store("files").sources.some((source) => {
return (
source.fileInfos.name === file.name &&
source.fileInfos.size === file.size &&
source.fileInfos.lastModified === file.lastModified
);
})
) {
const msg = `File already loaded: <b>${file.name}</b>`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
continue;
}
Alpine.store("ui").logMsg(`Loading file: <b>${file.name}</b>`, "info");
JSZip.loadAsync(file).then(
(content) => {
const index = Alpine.store("files").sources.length;
const fileInfos = {
name: file.name,
size: file.size,
lastModified: file.lastModified,
};
Alpine.store("files").sources[index] = {
id: index,
fileInfos: fileInfos,
nbToots: 0,
actor: {},
outbox: {},
likes: [],
bookmarks: [],
avatar: {},
header: {},
loaded: {
actor: false,
avatar: false,
header: false,
outbox: false,
likes: false,
bookmarks: false,
},
};
Alpine.store("files").sources[index]._raw = content.files;
loadJsonFile("actor", index, fileInfos);
loadJsonFile("outbox", index, fileInfos);
loadJsonFile("likes", index, fileInfos);
loadJsonFile("bookmarks", index, fileInfos);
},
(error) => {
const msg = `Error loading <b>${file.name}</b>: ${error.message}`;
console.error(msg);
Alpine.store("ui").logMsg(msg, "error");
}
);
}
}
function loadJsonFile(name, index, fileInfos) {
const content = Alpine.store("files").sources[index]._raw;
if (content[name + ".json"] === undefined) {
if (name === "likes" || name === "bookmarks") {
// we can still run the app without those files
const msg = `<b>${fileInfos.name}</b>: File ${name}.json not found in archive.`;
console.warn(msg);
Alpine.store("ui").logMsg(msg, "warn");
Alpine.store("files").sources[index].loaded[name] = true;
} else {
// this should NOT happen and will prevent the app from running
const msg = `<b>Critical error - ${fileInfos.name}</b>: File ${name}.json not found in archive.`;
console.error(msg);
Alpine.store("ui").logMsg(msg, "error");
}
return;
}
content[name + ".json"].async("text").then(function (txt) {
if (name === "actor") {
Alpine.store("files").sources[index].actor = JSON.parse(txt);
loadActorImages(index);
Alpine.store("files").sources[index].loaded.actor = true;
} // actor.json
if (name === "outbox") {
let data = JSON.parse(txt);
let toots = data.orderedItems.reduce((accu, t) => {
let t2 = preprocessToots(t, index);
if (t2) {
accu.push(t2);
}
return accu;
}, []);
Alpine.store("files").toots = Alpine.store("files").toots.concat(toots);
Alpine.store("files").sources[index].nbToots = toots.length;
delete data.orderedItems;
Alpine.store("files").sources[index].outbox = data;
Alpine.store("files").sources[index].loaded.outbox = true;
} // outbox.json
if (name === "likes" || name === "bookmarks") {
const tmp = JSON.parse(txt);
Alpine.store("files").sources[index][name] = tmp.orderedItems;
Alpine.store("files").sources[index].loaded[name] = true;
} // likes.json || bookmarks.json
});
}
function buildTootsInfos() {
let langs = {};
let boosts = [];
if (Alpine.store("files").toots.length > 0) {
let infos = Alpine.store("files").toots.reduce(
(accu, toot) => {
for (let lang in toot._marl.langs) {
const l = toot._marl.langs[lang];
if (!accu.langs[l]) {
accu.langs[l] = 1;
} else {
accu.langs[l]++;
}
}
if (toot.type === "Announce") {
// since Mastodon doesn't allow (yet?) cross-origin requests to
// retrieve post data (for boosts), we try to at least extract the
// user names for all the boosts contained in the archive
// [ISSUE] "object" value is a string most of the times, but
// sometimes it's a complex object similar to type "Create"
if (typeof toot.object === "object" && toot.object !== null) {
// let's ignore this case for now...
// [TODO], but not clear how it should be handled
} else if (toot.object) {
// if it's not an object and it has a value, then it's simply a
// url (string) pointing to the original (boosted) post.
// [ISSUE] URL format not always consistent... (esp. in the case
// of non-Mastodon instances) - e.g:
// https://craftopi.art/objects/[...]
// https://firefish.city/notes/[...]
// https://bsky.brid.gy/convert/ap/at://did:plc:[...]/app.bsky.feed.post/[...]
// -> the user name is not always present in URL
const url = toot.object.split("/");
let name;
let user;
let domain;
if (url.length > 2) {
domain = url[2];
if (url[0] === "https:" && url[3] === "users" && url[5] === "statuses") {
// Mastodon URL format -> user name
name = url[4];
user = `https://${url[2]}/users/${url[4]}/`;
} else {
// other URL format -> domain name
name = `? ${url[2]}`;
user = `https://${url[2]}/`;
}
if (!accu.boosts[name]) {
accu.boosts[name] = {
nb: 1,
name: name,
url: user,
domain: domain,
};
} else {
accu.boosts[name].nb++;
}
}
}
}
return accu;
},
{ langs: {}, boosts: {} }
);
langs = infos.langs;
boosts = [];
for (var key in infos.boosts) {
boosts.push(infos.boosts[key]);
}
}
Alpine.store("files").languages = langs;
Alpine.store("files").boostsAuthors = boosts;
}
function buildDynamicFilters() {
for (const lang in Alpine.store("files").languages) {
Alpine.store("files").filtersDefault["lang_" + lang] = true;
}
for (const source of Alpine.store("files").sources) {
Alpine.store("files").filtersDefault["actor_" + source.id] = true;
}
Alpine.store("files").resetFilters(false);
}
function preprocessToots(t, index) {
// build the '_marl' prop for each toot
let marl = {
langs: [],
source: index,
};
// check for duplicates (in case of multiple archive files)
if (Alpine.store("files").toc.includes(t.id)) {
const alts = Alpine.store("files").toots.filter((t2) => t2.id === t.id);
let identical = false;
const flat1 = JSON.stringify(t);
alts.forEach((alt) => {
let alt2 = JSON.parse(JSON.stringify(alt));
delete alt2._marl;
const flat2 = JSON.stringify(alt2);
if (flat1 === flat2) {
identical = true;
} else {
alt._marl.duplicate = true;
marl.duplicate = true;
Alpine.store("files").duplicates = true;
}
});
if (identical) {
return false;
}
} else {
Alpine.store("files").toc.push(t.id);
}
if (t.type === "Create") {
if (typeof t.object === "object" && t.object !== null && t.object.contentMap) {
let langs = [];
for (let lang in t.object.contentMap) {
langs.push(lang);
}
marl.langs = langs;
} else {
marl.langs = ["undefined"];
}
}
if (typeof t.object === "object" && t.object !== null) {
if (t.object.content) {
const content = t.object.content.toLowerCase();
marl.textContent = stripHTML(content);
marl.externalLinks = extractExternalLinks(content);
}
if (t.object.summary) {
marl.summary = t.object.summary.toLowerCase();
}
if (t.object.attachment && t.object.attachment.length) {
marl.hasAttachments = true;
}
} else if (t.object) {
marl.textContent = t.object.toLowerCase();
}
marl.visibility = tootVisibility(t);
const id = t.id.split("/");
marl.id = id[id.length - 2];
t._marl = marl;
return t;
}
function loadActorImages(index) {
const actor = Alpine.store("files").sources[index].actor;
const content = Alpine.store("files").sources[index]._raw;
if (actor.icon && actor.icon.type === "Image" && actor.icon.url && content[actor.icon.url]) {
const image = actor.icon;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").sources[index].avatar = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").sources[index].loaded.avatar = true;
});
} else {
Alpine.store("files").sources[index].avatar = { noImg: true };
Alpine.store("files").sources[index].loaded.avatar = true;
}
if (actor.image && actor.image.type === "Image" && actor.image.url && content[actor.image.url]) {
const image = actor.image;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").sources[index].header = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").sources[index].loaded.header = true;
});
} else {
Alpine.store("files").sources[index].header = { noImg: true };
Alpine.store("files").sources[index].loaded.header = true;
}
}
function setHueForSources() {
const nbSources = Alpine.store("files").sources.length;
const hueStart = Math.round(Math.random() * 360); // MARL accent: 59.17
const hueSpacing = Math.round(360 / nbSources);
for (let i = 0; i < nbSources; i++) {
Alpine.store("files").sources[i].hue = hueStart + hueSpacing * i;
}
}
function checkAppReady(ok) {
if (ok) {
buildTootsInfos();
buildDynamicFilters();
cleanUpRaw();
setHueForSources();
document.getElementById("main-section").focus();
Alpine.store("ui").checkMenuState();
Alpine.store("files").sortToots();
Alpine.store("files").loading = false;
Alpine.store("files").someFilesLoaded = true;
}
}
function cleanUpRaw() {
for (let i = 0; i < Alpine.store("files").sources.length; i++) {
const content = Alpine.store("files").sources[i]._raw;
if (content.cleanedUp) {
continue;
}
const actor = Alpine.store("files").sources[i].actor;
if (actor.image && actor.image.url) {
delete content[actor.image.url];
}
if (actor.icon && actor.icon.url) {
delete content[actor.icon.url];
}
delete content["actor.json"];
delete content["outbox.json"];
delete content["likes.json"];
delete content["bookmarks.json"];
content.cleanedUp = true;
Alpine.store("files").sources[i]._raw = content;
}
}
function loadAttachedMedia(att, index) {
if (attachmentIsImage(att) || attachmentIsVideo(att) || attachmentIsSound(att)) {
const data = Alpine.store("files").sources[index]._raw;
let url = att.url;
// ?! some instances seem to add their own name in front of the path,
// resulting in an invalid path with relation to the archive
// structure (e.g. "/framapiaf/media_attachments/...", but in the
// archive there is only a folder "/media_attachments")
// => So we remove everything that comes before "media_attachments/",
// hoping it doesn't break something else... :/
const prefix = url.indexOf("media_attachments/");
if (prefix > 0) {
url = url.slice(prefix);
}
if (!data[url]) {
return;
}
data[url].async("base64").then((content) => {
Alpine.store("files").sources[index][att.url] = {
type: att.mediaType,
content: content,
};
});
}
}
function pagingUpdated() {
document.querySelectorAll(`#toots details[open]`).forEach((e) => {
e.removeAttribute("open");
});
}
function scrollTootsToTop(setFocusTo) {
setTimeout(() => {
document.getElementById("toots").scrollTop = 0;
if (setFocusTo) {
// for keyboard users: we transfer the focus to the corresponding button
// in the upper paging module; or, in the cases where said button is
// disabled, we set the focus on the list of posts.
document.getElementById(setFocusTo).focus();
}
}, 50);
}
function contentType(data) {
let r = "";
switch (data) {
case "Create":
r = "Post";
break;
case "Announce":
r = "Boost";
break;
}
return r;
}
function tootVisibility(data) {
if (data.to.includes("https://www.w3.org/ns/activitystreams#Public")) {
return ["public", AlpineI18n.t("filters.visibilityPublic")];
}
if (
data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["unlisted", AlpineI18n.t("filters.visibilityUnlisted")];
}
if (
data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
!data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["followers", AlpineI18n.t("filters.visibilityFollowers")];
}
if (
!data.to.some((x) => x.indexOf("/followers") > -1) &&
!data.to.includes("https://www.w3.org/ns/activitystreams#Public") &&
!data.cc.includes("https://www.w3.org/ns/activitystreams#Public")
) {
return ["mentioned", AlpineI18n.t("filters.visibilityMentioned")];
}
}
function tootHasTags(toot) {
return typeof toot.object === "object" && toot.object !== null && toot.object.tag && toot.object.tag.length;
}
function formatJson(data) {
let r = data;
if (r._marl) {
// not a part of the source data; let's hide it to avoid confusion
r = JSON.parse(JSON.stringify(data));
delete r._marl;
}
return JSON.stringify(r, null, 4);
}
function formatAuthor(author, plainText) {
if (plainText) {
return author.split("/").pop();
} else {
return `<a href="${author}" target="_blank">${author.split("/").pop()}</a>`;
}
}
function formatDateTime(data) {
let date = new Date(data);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatFileDateTime(data) {
let date = new Date(data);
const dateOptions = {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatFileSize(size) {
var i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return +(size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; // ### i18n
}
function formatDate(data) {
let date = new Date(data);
const dateOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
return date.toLocaleDateString(Alpine.store("ui").lang, dateOptions);
}
function formatNumber(nb) {
return nb.toLocaleString();
}
function formatLikesBookmarks(url) {
const u = url.split("/");
u.splice(0, 2);
// 0 [domain]
// 1 "users"
// 2 [username]
// 3 "statuses"
// 4 [post id]
let text = `<span class="url-instance">${u[0]}</span>`;
if (u[1] === "users" && u[3] === "statuses") {
text += `<span class="url-actor">${u[2]}</span><span class="url-post-id">${u[4]}</span>`;
} else {
u.splice(0, 1);
text += `<span class="url-post-id">${u.join("/")}</span>`;
}
return text;
}
function stripHTML(str) {
let doc = new DOMParser().parseFromString(str, "text/html");
return doc.body.textContent || "";
}
function extractExternalLinks(str) {
const doc = new DOMParser().parseFromString(str, "text/html");
const nodes = doc.querySelectorAll("a[href]:not(.mention)");
let links = [];
nodes.forEach((link) => {
links.push({
href: link.href,
text: link.textContent,
});
});
return links;
}
function attachmentIsImage(att) {
return att.mediaType === "image/jpeg" || att.mediaType === "image/png";
}
function attachmentIsVideo(att) {
return att.mediaType === "video/mp4";
}
function attachmentIsSound(att) {
return att.mediaType === "audio/mpeg";
}
function attachmentWrapperClass(att) {
let r = [];
if (attachmentIsImage(att)) {
r.push("att-img");
} else if (attachmentIsSound(att)) {
r.push("att-sound");
} else if (attachmentIsVideo(att)) {
r.push("att-video");
}
if (!att.name) {
r.push("no-alt-text");
}
return r;
}
function isFilterActive(name) {
return Alpine.store("files").filters[name] !== Alpine.store("files").filtersDefault[name];
}
function startOver() {
const txt = AlpineI18n.t("tools.startOverConfirm");
if (confirm(txt)) {
location.reload();
}
}
function detectLangFromBrowser() {
const langs = navigator.languages;
if (langs && langs.length) {
for (let i = 0; i < langs.length; i++) {
let lang = langs[i].split("-")[0];
if (Alpine.store("ui").appLangs[lang]) {
const msg = `Setting language based on browser preference: <b>'${lang}' (${
Alpine.store("ui").appLangs[lang]
})</b>`;
Alpine.store("ui").logMsg(msg, "info");
return lang;
}
}
}
return false;
}
function setLang() {
const lang = Alpine.store("ui").lang;
AlpineI18n.locale = lang;
Alpine.store("userPrefs").save("lang", lang);
const msg = `App language set to <b>'${lang}' (${Alpine.store("ui").appLangs[lang]})</b>`;
Alpine.store("ui").logMsg(msg);
}
function setTheme(theme) {
document.getElementsByTagName("html")[0].setAttribute("class", theme);
}
// drag'n'drop over entire page
const drag = {
el: null,
init(el) {
this.dropArea = document.getElementById(el);
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, (e) => this.preventDragDefaults(e), false);
});
["dragenter", "dragover"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, () => this.highlightDrag(), false);
});
["dragleave", "drop"].forEach((eventName) => {
this.dropArea.addEventListener(eventName, () => this.unhighlightDrag(), false);
});
this.dropArea.addEventListener("drop", (e) => this.handleDrop(e), false);
},
preventDragDefaults(e) {
e.preventDefault();
e.stopPropagation();
},
highlightDrag() {
this.dropArea.classList.add("highlight-drag");
},
unhighlightDrag() {
this.dropArea.classList.remove("highlight-drag");
},
handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
unZip(files);
},
};

View File

@ -121,6 +121,9 @@ Mastodon: https://lou.lt/@s427
## Version history
- v. 2.1
- Internationalization: MARL is now usable in two languages (English and French), with support for more languages. Get in touch if you want to help translating MARL in your language!
- Tools panel: a new "Tools" button will a new panel, where you can change various settings for the app (language, theme) as well as see information about your loaded files, messages from the app, and an "about" section.
- v. 2.0
- code refactoring: MARL now uses Astro to build its HTML code as well as optimize and bundle its assets (CSS, SVG, images).
- all assets used to build MARL are now stored in the `dev` folder.