1
0
mirror of https://github.com/s427/MARL.git synced 2025-01-31 03:24:48 +01:00

Merge branch 'localdev' - support for multiple archives (issue #3)

This commit is contained in:
Vincent CLAVIEN 2024-12-09 21:28:47 +01:00
commit 2d6b8d09ba
13 changed files with 934 additions and 519 deletions

View File

@ -1,16 +1,18 @@
.actor {
container-type: inline-size;
height: 100dvh;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
color: var(--fg-inv1);
background: var(--bg-inv3);
position: relative;
&::before {
content: "";
position: absolute;
right: 0;
top: 0;
bottom: 0;
z-index: 1;
width: 2rem;
box-shadow: -1.5rem 0 2rem -2rem #000 inset;
}
a {
color: var(--fg-inv0);
color: var(--actor-fg0);
}
h1,
h2,
@ -22,13 +24,13 @@
summary {
padding: 1rem;
svg {
fill: var(--fg-inv1);
fill: var(--actor-fg1);
}
&:hover,
&:focus {
color: var(--fg-inv0);
color: var(--actor-fg0);
svg {
fill: var(--fg-inv0);
fill: var(--actor-fg0);
}
}
}
@ -39,7 +41,28 @@
}
}
.actors-wrapper {
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
}
.actor-panel {
flex-grow: 1;
height: 100%;
container-type: inline-size;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-y: auto;
overflow-x: hidden;
color: var(--actor-fg1);
background: var(--actor-bg3);
}
.actor-pretty {
flex-grow: 1;
flex-shrink: 0;
padding: 1rem 1rem 2rem;
}
@ -118,13 +141,13 @@
.actor-summary {
margin: 2rem 0;
padding: 1rem;
color: var(--fg-inv0);
color: var(--actor-fg0);
line-height: 1.3;
background: var(--bg-inv4);
background: var(--actor-bg4);
background: radial-gradient(
circle at 200% 150%,
var(--bg-inv3),
var(--bg-inv4)
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);
@ -146,8 +169,8 @@
}
dd {
padding: 0.5rem 1rem;
color: var(--fg-inv0);
border-left: 1px solid var(--bg-inv4);
color: var(--actor-fg0);
border-left: 1px solid var(--actor-bg4);
}
@container (width < 340px) {
@ -156,7 +179,7 @@
dl {
display: block;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--bg-inv4);
border-bottom: 1px solid var(--actor-bg4);
&:last-child {
border-bottom: none;
}
@ -180,7 +203,7 @@
gap: 1rem;
.count {
display: block;
color: var(--fg-inv0);
color: var(--actor-fg0);
font-size: 1.6em;
font-weight: bold;
}
@ -200,11 +223,11 @@
font-weight: bold;
&:hover,
&:focus-visible {
color: var(--fg-inv0);
color: var(--actor-fg0);
}
}
&[open] summary {
color: var(--fg-inv0);
color: var(--actor-fg0);
}
p {
margin-top: 0.5rem;
@ -214,7 +237,7 @@
}
.actor-raw {
background: var(--bg-inv2);
background: var(--actor-bg2);
box-shadow: 0 0.75rem 0.75rem -1rem #000 inset;
.details-content {
padding: 1rem 1rem 2rem;
@ -224,8 +247,7 @@
}
.actor-likes-bookmarks {
flex-grow: 1;
background: var(--bg-inv0);
background: var(--actor-bg0);
overflow-wrap: break-word;
h2 {
@ -260,10 +282,100 @@
}
.actor-likes {
background: var(--bg-inv1);
background: var(--actor-bg1);
box-shadow: 0 0.75rem 0.75rem -1rem #000 inset;
}
.actor-bookmarks {
padding-bottom: 1rem;
box-shadow: 0 0.75rem 0.75rem -1rem #000 inset;
}
.multiple-actors {
.actors-tabs {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1rem;
background-color: var(--actor-tabs-bg);
overflow: hidden;
user-select: none;
button {
all: unset;
padding: 0.35rem 0.7rem 0.4rem;
font-size: 0.85em;
font-family: inherit;
border-radius: 0.25rem;
color: #fff;
background: var(--actor-accent2-ok, transparent);
cursor: pointer;
span {
display: inline-block;
padding: 0 0 0.3rem;
border-bottom: 0.1rem solid transparent;
}
&:hover {
span {
border-bottom-color: rgba(255, 255, 255, 0.5);
}
}
&[aria-selected="true"] {
box-shadow: none;
span {
border-bottom-color: #fff;
}
}
&:focus-visible {
outline: 2px solid hsl(var(--actor-hue), 50%, 80%);
outline: 2px solid var(--actor-accent-ok, var(--actor-accent));
outline-offset: 2px;
}
}
}
// color variations
.actor {
*:focus-visible {
outline-color: var(--actor-accent-ok, var(--accent-light));
}
}
.actor summary svg {
fill: var(--actor-fg1-ok, var(--actor-fg1));
}
.actor-panel {
color: var(--actor-fg1-ok, var(--actor-fg1));
background: var(--actor-bg3-ok, var(--actor-bg3));
}
.actor-summary {
background: var(--actor-bg4-ok, var(--actor-bg4));
background: radial-gradient(
circle at 200% 150%,
var(--actor-bg3-ok, var(--actor-bg3)),
var(--actor-bg4-ok, var(--actor-bg4))
);
}
.actor-infos {
dd {
border-left-color: var(--actor-bg4-ok, var(--actor-bg4));
}
@container (width < 340px) {
dl {
border-bottom-color: var(--actor-bg4-ok, var(--actor-bg4));
}
}
}
.actor-raw {
background: var(--actor-bg2-ok, var(--actor-bg2));
}
.actor-likes-bookmarks {
background: var(--actor-bg0-ok, var(--actor-bg0));
}
.actor-likes {
background: var(--actor-bg1-ok, var(--actor-bg1));
}
}

View File

@ -1,64 +1,80 @@
// tint and saturation factor for the app color scheme
$hue: 30;
$sat: 0.5;
$hue: 30; // === 59.17 in oklch
/*********************************/
/* global background/text colors */
/*********************************/
$bg0: hsl(var(--hue), calc(var(--sat) * 0%), 100%);
// -> post content
$bg1: hsl(var(--hue), calc(var(--sat) * 20%), 95%);
// -> post
$bg2: hsl(var(--hue), calc(var(--sat) * 30%), 90%);
// -> post attachment/meta/raw
$bg3: hsl(var(--hue), calc(var(--sat) * 35%), 87%);
// -> posts header
$bg4: hsl(var(--hue), calc(var(--sat) * 40%), 84%);
// -> filters/tags panels
$bg0: #fff; // post content
$bg1: hsl($hue, 10%, 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
$fg0: hsl(var(--hue), calc(var(--sat) * 0%), 0%);
// -> post content
$fg1: hsl(var(--hue), calc(var(--sat) * 20%), 20%);
// -> body text; some SVG icons (panel close)
$fg2: hsl(var(--hue), calc(var(--sat) * 20%), 30%);
// -> headings (h1, h2...)
$fg0: #000; // post content
$fg1: hsl($hue, 10%, 20%); // body text; some SVG icons (panel close)
$fg2: hsl($hue, 10%, 30%); // headings (h1, h2...)
/********************************************/
/* inverted light scheme (e.g. actor panel) */
/********************************************/
$fg-inv: #fff; // misc highlighted text
$bg-inv0: hsl(var(--hue), calc(var(--sat) * 75%), 11%);
// -> bookmarks
$bg-inv1: hsl(var(--hue), calc(var(--sat) * 65%), 15%);
// -> favorites
$bg-inv2: hsl(var(--hue), calc(var(--sat) * 50%), 20%);
// -> actor raw data, mobile menu
$bg-inv3: hsl(var(--hue), calc(var(--sat) * 50%), 25%);
// -> actor panel
$bg-inv4: hsl(var(--hue), calc(var(--sat) * 45%), 40%);
// -> actor summary
/***************/
/* actor panel */
/***************/
$fg-inv0: hsl(var(--hue), calc(var(--sat) * 0%), 100%);
// -> misc highlighted text; mobile menu icons
$fg-inv1: hsl(var(--hue), calc(var(--sat) * 80%), 80%);
// -> actor, mobile menu
$actor-bg0: hsl($hue, 37.5%, 11%); // bookmarks
$actor-bg1: hsl($hue, 32.5%, 15%); // favorites
$actor-bg2: hsl($hue, 25%, 20%); // actor raw data; tabs bg
$actor-bg3: hsl($hue, 25%, 25%); // actor panel
$actor-bg4: hsl($hue, 22.5%, 40%); // actor summary
$actor-fg0: #fff;
$actor-fg1: hsl($hue, 40%, 80%); // actor texts
$actor-tabs-bg: hsl($hue, 12%, 17%);
// OKLCH variants (multiple archives opened)
$actor-bg0-ok: oklch(21% 18% var(--actor-hue)); // bookmarks; tabs bg
$actor-bg1-ok: oklch(24% 16% var(--actor-hue)); // favorites
$actor-bg2-ok: oklch(27% 14% var(--actor-hue)); // actor raw data
$actor-bg3-ok: oklch(30% 12% var(--actor-hue)); // actor panel
$actor-bg4-ok: oklch(40% 10% var(--actor-hue)); // actor summary
$actor-fg1-ok: oklch(92% 8% var(--actor-hue)); // actor texts
$actor-accent: hsl($hue, 75%, 50%);
$actor-accent-ok: oklch(80% 25% var(--actor-hue));
$actor-accent2-ok: oklch(60% 20% var(--actor-hue));
// -> outline; color code on post
/***************/
/* mobile menu */
/***************/
$menu-bg: hsl($hue, 25%, 20%); // actor-bg2
$menu-fg: hsl($hue, 40%, 80%); // actor-fg1
$menu-fg-active: #fff;
$menu-icon: #fff;
$menu-filter-active: hsl($hue, 75%, 50%); // accent-light2
$menu-backdrop: rgba(0, 0, 0, 0.5);
$panel-close: hsl($hue, 10%, 20%); // fg1
$panel-close-hover: hsl($hue, 75%, 38%); // accent-light
/**********/
/* accent */
/**********/
$accent: hsl(var(--hue), calc(var(--sat) * 200%), 30%);
$accent: hsl($hue, 100%, 30%);
// -> links, active elements, counters, misc background stripes (color 1)
$accent-dark: hsl(var(--hue), calc(var(--sat) * 250%), 26%);
$accent-dark: hsl($hue, 100%, 26%);
// -> misc background stripes (color 2)
$accent-light: hsl(var(--hue), calc(var(--sat) * 150%), 38%);
$accent-light: hsl($hue, 75%, 38%);
// -> focusable elements outline; main header counter
$accent-light2: hsl(var(--hue), calc(var(--sat) * 150%), 50%);
$accent-light2: hsl($hue, 75%, 50%);
// -> overlay hover/focus icons (SVG); private posts border; active filter indicator (mobile menu)
$accent-light3: hsl(var(--hue), calc(var(--sat) * 150%), 75%);
// === oklch(56.48% 0.124 59.17)
$accent-light3: hsl($hue, 75%, 75%);
// -> hover effect on images
$accent-light-bg: hsl(var(--hue), calc(var(--sat) * 100%), 96%);
$accent-light-bg: hsl($hue, 50%, 96%);
// -> private posts background
/***********/
@ -68,7 +84,6 @@ $accent-light-bg: hsl(var(--hue), calc(var(--sat) * 100%), 96%);
$overlay-icon: #fff;
$overlay-icon-hover: #fff;
$overlay-backdrop: rgba(0, 0, 0, 0.75);
$menu-backdrop: rgba(0, 0, 0, 0.5);
/*****************/
/* form elements */
@ -79,11 +94,11 @@ $bg-input-hover: rgba(255, 255, 255, 0.65);
$bg-input-focus: rgba(255, 255, 255, 1);
$bg-button: rgba(255, 255, 255, 1);
$bg-button-hover: hsl(var(--hue), calc(var(--sat) * 100%), 95%);
$bg-button-hover: hsl($hue, 50%, 95%);
$button-svg: $fg1;
$button-svg-hover: hsl(var(--hue), calc(var(--sat) * 150%), 50%);
$button-svg-focus: hsl(var(--hue), calc(var(--sat) * 150%), 50%);
$button-svg-active: hsl(var(--hue), calc(var(--sat) * 150%), 50%);
$button-svg-hover: hsl($hue, 75%, 50%);
$button-svg-focus: hsl($hue, 75%, 50%);
$button-svg-active: hsl($hue, 75%, 50%);
$fg-button-focus: $accent;
$fg-button-active: $accent;
@ -91,9 +106,6 @@ $fg-button-active: $accent;
// Note: some rgba values hardcoded, used mostly for box-shadow or backdrops
html {
--hue: #{$hue};
--sat: #{$sat};
--bg0: #{$bg0};
--bg1: #{$bg1};
--bg2: #{$bg2};
@ -103,15 +115,15 @@ html {
--fg0: #{$fg0};
--fg1: #{$fg1};
--fg2: #{$fg2};
--fg-inv: #{$fg-inv};
--bg-inv0: #{$bg-inv0};
--bg-inv1: #{$bg-inv1};
--bg-inv2: #{$bg-inv2};
--bg-inv3: #{$bg-inv3};
--bg-inv4: #{$bg-inv4};
--fg-inv0: #{$fg-inv0};
--fg-inv1: #{$fg-inv1};
--menu-bg: #{$menu-bg};
--menu-fg: #{$menu-fg};
--menu-fg-active: #{$menu-fg-active};
--menu-icon: #{$menu-icon};
--menu-filter-active: #{$menu-filter-active};
--panel-close: #{$panel-close};
--panel-close-hover: #{$panel-close-hover};
--accent: #{$accent};
--accent-dark: #{$accent-dark};
@ -139,3 +151,33 @@ html {
--fg-button-focus: #{$fg-button-focus};
--fg-button-active: #{$fg-button-active};
}
.actors-wrapper {
--actor-hue: 0;
--actor-bg0: #{$actor-bg0};
--actor-bg0-ok: #{$actor-bg0-ok};
--actor-bg1: #{$actor-bg1};
--actor-bg1-ok: #{$actor-bg1-ok};
--actor-bg2: #{$actor-bg2};
--actor-bg2-ok: #{$actor-bg2-ok};
--actor-bg3: #{$actor-bg3};
--actor-bg3-ok: #{$actor-bg3-ok};
--actor-bg4: #{$actor-bg4};
--actor-bg4-ok: #{$actor-bg4-ok};
--actor-fg0: #{$actor-fg0};
--actor-fg1: #{$actor-fg1};
--actor-fg1-ok: #{$actor-fg1-ok};
--actor-tabs-bg: #{$actor-tabs-bg};
--actor-accent: #{$actor-accent};
--actor-accent-ok: #{$actor-accent-ok};
--actor-accent2-ok: #{$actor-accent2-ok};
}
.actors-tabs button {
--actor-accent-ok: #{$actor-accent-ok};
--actor-accent2-ok: #{$actor-accent2-ok};
}
.toot-content {
--actor-accent-ok: #{$actor-accent-ok};
--actor-accent2-ok: #{$actor-accent2-ok};
}

View File

@ -96,7 +96,7 @@
}
&.active label {
color: var(--fg-inv0);
color: var(--fg-inv);
opacity: 1;
background-color: var(--accent);
@media (forced-colors: active) {

View File

@ -211,7 +211,6 @@ details {
// focusable elements
*:focus-visible,
// .att-img-wrapper:hover,
.tags-group button:focus-visible div,
.toots-filter:has([type="checkbox"]:focus-visible) {
text-decoration: none;

View File

@ -6,8 +6,8 @@ $panel-width: 400px;
display: none;
position: relative;
z-index: 2;
color: var(--fg-inv1);
background-color: var(--bg-inv2);
color: var(--menu-fg);
background-color: var(--menu-bg);
@media (forced-colors: active) {
color: buttonText;
@ -53,7 +53,7 @@ $panel-width: 400px;
width: $icon-width;
height: auto;
margin: 5px auto 0;
fill: var(--fg-inv0);
fill: var(--menu-icon);
@media (forced-colors: active) {
fill: buttonText;
@ -79,7 +79,7 @@ $panel-width: 400px;
width: 10px;
height: 10px;
border-radius: 10px;
background-color: var(--accent-light2);
background-color: var(--menu-filter-active);
@media (forced-colors: active) {
background-color: Highlight;
}
@ -153,6 +153,10 @@ $panel-width: 400px;
width: min(#{$panel-width}, calc(100dvw - #{$menu-width}));
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
&.actor::before {
content: none;
}
@media (prefers-reduced-motion: no-preference) {
transition: left 0.2s ease-out;
transition-property: left, box-shadow;
@ -164,7 +168,7 @@ $panel-width: 400px;
right: 6px;
top: 6px;
z-index: 1;
color: var(--fg1);
color: var(--panel-close);
background: transparent;
box-shadow: none;
.btn-icon {
@ -173,12 +177,16 @@ $panel-width: 400px;
&:hover,
&:focus,
&:focus-visible {
color: var(--accent-light);
color: var(--panel-close-hover);
}
@media (forced-colors: active) {
color: buttonText;
}
.actor & {
color: #fff;
}
}
.mobile-menu {
@ -205,7 +213,7 @@ $panel-width: 400px;
&.menu-open-actor .menu-actor,
&.menu-open-filters .menu-filters,
&.menu-open-tags .menu-tags {
color: var(--fg-inv0);
color: var(--menu-fg-active);
background-color: rgba(255, 255, 255, 0.2);
}
}

View File

@ -82,10 +82,10 @@ $display-wide: 340px;
}
}
.active button div {
color: var(--fg-inv0);
color: var(--fg-inv);
background-color: var(--accent);
.count {
color: var(--fg-inv0);
color: var(--fg-inv);
}
}
}

View File

@ -37,7 +37,7 @@ $meta-visible: 100ch;
.toot-summary {
padding: 0.5rem 1rem;
margin-bottom: 1rem;
color: var(--fg-inv0);
color: var(--fg-inv);
background: repeating-linear-gradient(
45deg,
var(--accent-dark),
@ -98,6 +98,23 @@ $meta-visible: 100ch;
}
}
.multiple-actors .toot-content {
position: relative;
padding-left: 1.6rem;
&::before {
content: "";
position: absolute;
left: 0.35rem;
top: 0.35rem;
bottom: 0.35rem;
z-index: 1;
width: 0.25rem;
border-radius: 0.25rem;
background-color: red;
background-color: var(--actor-accent-ok, transparent);
}
}
.toot-attachments {
margin: 1rem 0 0;
ul,

View File

@ -66,7 +66,7 @@
align-items: center;
font-size: 2em;
background-color: var(--accent);
color: var(--fg-inv0);
color: var(--fg-inv);
background: repeating-linear-gradient(
45deg,
var(--accent-dark),
@ -128,3 +128,10 @@
background: var(--bg2);
}
}
.loading-more {
justify-content: center;
.file-loader {
margin: 0;
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,7 +7,7 @@
<link rel="icon" href="data:," />
<base target="_blank" />
<link rel="stylesheet" href="css/main.css?20241201" />
<link rel="stylesheet" href="css/main.css?20241209" />
<script>
const isFileProtocol = window.location.protocol === "file:";
const scripts = [
@ -52,9 +52,9 @@
id="app"
x-data
:class="$store.ui.appClasses"
x-init="$watch('$store.files.allLoaded', value => checkAllLoaded(value))"
x-init="$watch('$store.files.appReady', value => checkAppReady(value))"
>
<template x-if="! $store.files.allLoaded">
<template x-if="!$store.files.appReady && !$store.files.someFilesLoaded">
<main
class="welcome"
:class="$store.files.loading ? 'file-loading' : ''"
@ -91,6 +91,7 @@
class="file-loader"
type="file"
accept=".zip"
multiple
x-show="!$store.files.loading"
@change="unZip(Object.values($event.target.files))"
/>
@ -105,14 +106,28 @@
</main>
</template>
<template x-if="$store.files.allLoaded">
<template x-if="!$store.files.appReady && $store.files.someFilesLoaded">
<main class="welcome loading-more">
<div class="intro">
<div class="file-loader loading">
<span>... Loading ...</span>
</div>
</div>
</main>
</template>
<template x-if="$store.files.appReady">
<main
class="main-section"
id="main-section"
tabindex="-1"
x-on:resize.window="$store.ui.checkMenuState()"
>
<div class="main-section-inner" id="main-section-inner">
<div
class="main-section-inner"
id="main-section-inner"
:class="$store.files.sources.length > 1 ? 'multiple-actors' : ''"
>
<div class="mobile-menu" id="mobile-menu">
<nav>
<ul>
@ -185,209 +200,250 @@
</button>
<h2 class="visually-hidden" id="actor-title">Account info</h2>
<h3 class="visually-hidden" id="actor-tabs-title">Accounts</h3>
<div class="actor-pretty">
<div class="actor-banner">
<template x-if="$store.files.header.noImg">
<img alt="header" src="img/no-header.png" />
</template>
<template x-if="! $store.files.header.noImg">
<button
id="actor-header"
@click="$store.lightbox.openProfileImg('header', 'actor-header')"
>
<img
alt="header"
:src="`data:${$store.files.header.type}; base64,${$store.files.header.content}`"
/>
</button>
</template>
</div>
<div class="actor-id">
<template x-if="$store.files.avatar.noImg">
<div class="actor-avatar no-avatar">
<img
:alt="$store.files.actor.name"
src="img/no-avatar.png"
/>
</div>
</template>
<template x-if="! $store.files.avatar.noImg">
<button
id="actor-avatar"
class="actor-avatar"
@click="$store.lightbox.openProfileImg('avatar', 'actor-avatar')"
>
<img
:alt="$store.files.actor.name"
class="actor-avatar"
:src="`data:${$store.files.avatar.type}; base64,${$store.files.avatar.content}`"
/>
</button>
</template>
<h1
class="actor-name"
x-text="await $store.files.actor.name"
></h1>
<div class="actor-url">
<a
:href="await $store.files.actor.url"
x-text="await $store.files.actor.url"
></a>
</div>
</div>
<div
class="actor-summary"
x-show="$store.files.actor.summary"
x-html="await $store.files.actor.summary"
></div>
<div class="actor-infos">
<dl>
<dt>Member since</dt>
<dd
x-text="await formatDate($store.files.actor.published)"
></dd>
</dl>
<template x-for="item in await $store.files.actor.attachment">
<dl>
<dt x-text="item.name"></dt>
<dd x-html="item.value"></dd>
</dl>
</template>
</div>
<div class="actor-posts-count">
<div class="total">
<span
class="count"
x-text="formatNumber($store.files.outbox.totalItems)"
></span>
<span class="label">posts</span>
</div>
<div class="archive">
<span
class="count"
x-text="formatNumber($store.files.toots.length)"
></span>
<span class="label">in archive</span>
</div>
<details
class="comment"
x-show="$store.files.toots.length != $store.files.outbox.totalItems"
<div
class="actors-wrapper"
:style="'--actor-hue: '+ $store.files.sources[$store.ui.actorPanel].hue"
>
<template x-if="$store.files.sources.length > 1">
<div
class="actors-tabs"
role="tablist"
aria-labelledby="actor-tabs-title"
>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#question" />
</svg>
</span>
<span class="summary-label"
>Why are those two numbers different?</span
<template x-for="(_, source) in $store.files.sources">
<button
type="button"
role="tab"
:id="'actortab-' + source"
:aria-selected="source === $store.ui.actorPanel ? 'true': 'false'"
:aria-controls="'actorpanel-' + source"
:style="'--actor-hue: '+ $store.files.sources[source].hue"
@click="$store.ui.openActorPanel(source)"
@keyup.right="$store.ui.switchActorPanel('up')"
@keyup.left="$store.ui.switchActorPanel('down')"
>
</summary>
<div class="details-content">
<p>
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.
</p>
</div>
</details>
</div>
</div>
<span
x-text="$store.files.sources[source].actor.name"
></span>
</button>
</template>
</div>
</template>
<template x-if="$store.files.sources.length === 1">
<span
class="visually-hidden"
id="actortab-0"
x-text="$store.files.sources[0].actor.name"
></span>
</template>
<div class="actor-raw">
<div class="actor-raw-inner">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#json" />
</svg>
</span>
<span class="summary-label"
>Raw data <em>(actor.json)</em></span
>
</summary>
<div class="details-content">
<pre x-text="formatJson(await $store.files.actor)"></pre>
</div>
</details>
</div>
</div>
<template x-for="source in $store.files.sources">
<div
class="actor-panel"
:id="'actorpanel-' + source.id"
role="tabpanel"
tabindex="0"
:aria-labelledby="'actortab-' + source.id"
x-data="{a: source.actor}"
x-show="$store.ui.actorPanel === source.id"
>
<div class="actor-pretty">
<div class="actor-banner">
<template x-if="source.header.noImg">
<img alt="header" src="img/no-header.png" />
</template>
<template x-if="! source.header.noImg">
<button
id="actor-header"
@click="$store.lightbox.openProfileImg('header', 'actor-header', source.id)"
>
<img
alt="header"
:src="`data:${source.header.type}; base64,${source.header.content}`"
/>
</button>
</template>
</div>
<div class="actor-likes-bookmarks">
<div class="actor-likes">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#favorite" />
</svg>
</span>
<h2 class="summary-label">
Favorites (<span
class="count"
x-text="$store.files.likes.length"
></span
>)
</h2>
</summary>
<div class="details-content">
<template x-if="$store.files.likes.length">
<ul>
<template x-for="url in $store.files.likes">
<li>
<a :href="url" x-text="url"></a>
</li>
</template>
</ul>
</template>
<template x-if="! $store.files.likes.length">
<p class="no-content">... no favorites ...</p>
</template>
<div class="actor-id">
<template x-if="source.avatar.noImg">
<div class="actor-avatar no-avatar">
<img :alt="a.name" src="img/no-avatar.png" />
</div>
</template>
<template x-if="! source.avatar.noImg">
<button
id="actor-avatar"
class="actor-avatar"
@click="$store.lightbox.openProfileImg('avatar', 'actor-avatar', source.id)"
>
<img
:alt="a.name"
class="actor-avatar"
:src="`data:${source.avatar.type}; base64,${source.avatar.content}`"
/>
</button>
</template>
<h1 class="actor-name" x-text="await a.name"></h1>
<div class="actor-url">
<a :href="await a.url" x-text="await a.url"></a>
</div>
</div>
<div
class="actor-summary"
x-show="a.summary"
x-html="await a.summary"
></div>
<div class="actor-infos">
<dl>
<dt>Member since</dt>
<dd x-text="await formatDate(a.published)"></dd>
</dl>
<template x-for="item in await a.attachment">
<dl>
<dt x-text="item.name"></dt>
<dd x-html="item.value"></dd>
</dl>
</template>
</div>
<div class="actor-posts-count">
<div class="total">
<span
class="count"
x-text="formatNumber(source.outbox.totalItems)"
></span>
<span class="label">posts</span>
</div>
<div class="archive">
<span
class="count"
x-text="formatNumber(source.nbToots)"
></span>
<span class="label">in archive</span>
</div>
<details
class="comment"
x-show="source.nbToots != source.outbox.totalItems"
>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#question" />
</svg>
</span>
<span class="summary-label"
>Why are those two numbers different?</span
>
</summary>
<div class="details-content">
<p>
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.
</p>
</div>
</details>
</div>
</div>
</details>
</div>
<div class="actor-bookmarks">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#bookmark" />
</svg>
</span>
<h2 class="summary-label">
Bookmarks (<span
class="count"
x-text="$store.files.bookmarks.length"
></span
>)
</h2>
</summary>
<div class="details-content">
<template x-if="$store.files.bookmarks.length">
<ul>
<template x-for="url in $store.files.bookmarks">
<li>
<a :href="url" x-text="url"></a>
</li>
</template>
</ul>
</template>
<template x-if="! $store.files.bookmarks.length">
<p class="no-content">... no bookmarks ...</p>
</template>
<div class="actor-raw">
<div class="actor-raw-inner">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#json" />
</svg>
</span>
<span class="summary-label"
>Raw data <em>(actor.json)</em></span
>
</summary>
<div class="details-content">
<pre x-text="formatJson(await a)"></pre>
</div>
</details>
</div>
</div>
</details>
</div>
<div class="actor-likes-bookmarks">
<div class="actor-likes">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#favorite" />
</svg>
</span>
<h2 class="summary-label">
Favorites (<span
class="count"
x-text="source.likes.length"
></span
>)
</h2>
</summary>
<div class="details-content">
<template x-if="source.likes.length">
<ul>
<template x-for="url in source.likes">
<li>
<a :href="url" x-text="url"></a>
</li>
</template>
</ul>
</template>
<template x-if="! source.likes.length">
<p class="no-content">... no favorites ...</p>
</template>
</div>
</details>
</div>
<div class="actor-bookmarks">
<details>
<summary>
<span class="summary-icon">
<svg aria-hidden="true">
<use href="#bookmark" />
</svg>
</span>
<h2 class="summary-label">
Bookmarks (<span
class="count"
x-text="source.bookmarks.length"
></span
>)
</h2>
</summary>
<div class="details-content">
<template x-if="source.bookmarks.length">
<ul>
<template x-for="url in source.bookmarks">
<li>
<a :href="url" x-text="url"></a>
</li>
</template>
</ul>
</template>
<template x-if="! source.bookmarks.length">
<p class="no-content">... no bookmarks ...</p>
</template>
</div>
</details>
</div>
</div>
</div>
</template>
</div>
</div>
@ -775,18 +831,21 @@
</div>
</div>
<div class="toots-filters-group">
<div
class="toots-filters-group"
x-show="$store.files.toots.length"
>
<h3 class="toots-filters-group-title">Language</h3>
<template x-for="(nb, lang) in $store.files.languages">
<div
class="toots-filter checkbox"
:class="isFilterActive('langs_' + lang) ? 'active' : ''"
:class="isFilterActive('lang_' + lang) ? 'active' : ''"
>
<label :for="'filter-lang-' + lang">
<input
:id="'filter-lang-' + lang"
type="checkbox"
x-model="$store.files.filters['langs_' + lang]"
x-model="$store.files.filters['lang_' + lang]"
@change="$store.files.setFilter()"
/>
<span>
@ -798,6 +857,30 @@
</template>
</div>
<template x-if="$store.files.sources.length > 1">
<div class="toots-filters-group">
<h3 class="toots-filters-group-title">Author</h3>
<template x-for="source in $store.files.sources">
<div
class="toots-filter checkbox"
:class="isFilterActive('actor_' + source.id) ? 'active' : ''"
>
<label :for="'filter-actor-' + source.id">
<input
:id="'filter-actor-' + source.id"
type="checkbox"
x-model="$store.files.filters['actor_' + source.id]"
@change="$store.files.setFilter()"
/>
<span>
<span x-text="source.actor.name"></span>
</span>
</label>
</div>
</template>
</div>
</template>
<div class="toots-filters-reset">
<button
@click="$store.files.resetFilters(true)"
@ -958,6 +1041,7 @@
<div
class="toot"
:class="['toot-type-' + contentType(toot.type).toLowerCase(), 'toot-visibility-' + toot._marl.visibility[0]]"
:style="'--actor-hue: '+ $store.files.sources[toot._marl.source].hue"
>
<h3 class="toot-header visually-hidden">
<span x-text="contentType(toot.type)"></span>
@ -986,21 +1070,21 @@
>
<li
:class="attachmentWrapperClass(att)"
:data-trigger="loadAttachedMedia(att)"
:data-trigger="loadAttachedMedia(att, toot._marl.source)"
>
<!-- image -->
<template
x-if="attachmentIsImage(att) && $store.files[att.url]"
x-if="attachmentIsImage(att) && $store.files.sources[toot._marl.source][att.url]"
>
<div class="att-wrapper">
<button
class="att-img-wrapper"
@click="$store.lightbox.open(toot.object.attachment, index, toot._marl.id + '-' + index)"
@click="$store.lightbox.open(toot, index, toot._marl.id + '-' + index)"
:id="toot._marl.id + '-' + index"
>
<img
alt=""
:src="`data:${att.mediaType}; base64,${await $store.files[att.url].content}`"
:src="`data:${att.mediaType}; base64,${await $store.files.sources[toot._marl.source][att.url].content}`"
:aria-labelledby="toot._marl.id + '-' + index + '-desc'"
/>
</button>
@ -1023,12 +1107,12 @@
<!-- sound -->
<template
x-if="attachmentIsSound(att) && $store.files[att.url]"
x-if="attachmentIsSound(att) && $store.files.sources[toot._marl.source][att.url]"
>
<div class="att-wrapper">
<audio
controls
:src="`data:${att.mediaType}; base64,${await $store.files[att.url].content}`"
:src="`data:${att.mediaType}; base64,${await $store.files.sources[toot._marl.source][att.url].content}`"
:aria-labelledby="toot._marl.id + '-' + index + '-desc'"
></audio>
<div
@ -1049,14 +1133,14 @@
<!-- video -->
<template
x-if="attachmentIsVideo(att) && $store.files[att.url]"
x-if="attachmentIsVideo(att) && $store.files.sources[toot._marl.source][att.url]"
>
<div class="att-wrapper">
<video
controls
:width="att.width"
:height="att.height"
:src="`data:${att.mediaType}; base64,${await $store.files[att.url].content}`"
:src="`data:${att.mediaType}; base64,${await $store.files.sources[toot._marl.source][att.url].content}`"
:aria-labelledby="toot._marl.id + '-' + index + '-desc'"
></video>
<div
@ -1195,10 +1279,16 @@
<use href="#json" />
</svg>
</span>
<span class="summary-label">Raw data</span>
<span class="summary-label" :id="'raw-' + toot._marl.id"
>Raw data</span
>
</summary>
<div class="details-content">
<textarea x-text="formatJson(toot)" readonly></textarea>
<textarea
x-text="formatJson(toot)"
:aria-labelledby="'raw-' + toot._marl.id"
readonly
></textarea>
</div>
</details>
</div>
@ -1454,7 +1544,7 @@
<div class="overlay-content">
<img
:alt="$store.lightbox.data[$store.lightbox.index].name"
:src="`data:${$store.lightbox.data[$store.lightbox.index].mediaType}; base64,${$store.files[$store.lightbox.data[$store.lightbox.index].url].content}`"
:src="`data:${$store.lightbox.data[$store.lightbox.index].mediaType}; base64,${$store.files.sources[$store.lightbox.source][$store.lightbox.data[$store.lightbox.index].url].content}`"
@click="$store.lightbox.showNext()"
/>
</div>
@ -1596,6 +1686,6 @@
</symbol>
</svg>
<script src="js/main.js?20241202"></script>
<script src="js/main.js?20241209"></script>
</body>
</html>

View File

@ -37,31 +37,18 @@ const userPrefsStore = {
const filesStore = {
resetState() {
this.raw = {};
this.actor = {};
this.outbox = {};
this.sources = [];
this.toots = [];
this.likes = [];
this.bookmarks = [];
this.avatar = {};
this.header = {};
this.sortAsc = true; // -> userPrefs
this.pageSize = 10; // -> userPrefs
this.currentPage = 1;
this.loading = false;
this.loaded = {
actor: false,
avatar: false,
banner: false,
outbox: false,
likes: false,
bookmarks: false,
};
this.someFilesLoaded = false;
this.languages = {};
this.boostsAuthors = {};
this.boostsAuthors = [];
this.filters = {};
this.filtersDefault = {
@ -91,9 +78,13 @@ const filesStore = {
attachmentWithAltText: false,
// automatically generated (see loadJsonFile()):
// langs_en: true,
// langs_fr: true,
// langs_de: true,
// lang_en: true,
// lang_fr: true,
// lang_de: true,
// etc
// actor_0: true,
// actor_1: true,
// actor_2: true,
// etc
};
this.filtersActive = false;
@ -412,8 +403,8 @@ const filesStore = {
}
}
for (let lang in this.languages) {
if (f.hasOwnProperty("langs_" + lang) && f["langs_" + lang] === false) {
for (const lang in this.languages) {
if (f.hasOwnProperty("lang_" + lang) && f["lang_" + lang] === false) {
if (t.type === "Create") {
if (t.object.contentMap.hasOwnProperty(lang)) {
return false;
@ -424,6 +415,15 @@ const filesStore = {
}
}
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;
});
},
@ -524,15 +524,26 @@ const filesStore = {
return r;
},
get allLoaded() {
return (
this.loaded.actor &&
this.loaded.avatar &&
this.loaded.banner &&
this.loaded.outbox &&
this.loaded.likes &&
this.loaded.bookmarks
);
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() {
@ -540,7 +551,7 @@ const filesStore = {
},
get pagedToots() {
if (this.filteredToots) {
return this.filteredToots.filter((toot, index) => {
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;
@ -559,7 +570,6 @@ const filesStore = {
}
});
},
toggleTootsOrder() {
this.sortAsc = !this.sortAsc;
this.sortToots();
@ -570,10 +580,7 @@ const filesStore = {
checkPagingValue() {
if (this.currentPage < 1) {
this.currentPage = 1;
} else if (
(this.currentPage - 1) * this.pageSize >
this.filteredToots.length
) {
} else if (this.currentPage > this.totalPages) {
this.currentPage = this.totalPages;
}
},
@ -607,38 +614,47 @@ const lightboxStore = {
resetState() {
this.show = false;
this.data = [];
this.source = 0;
this.index = 0;
this.source = "";
this.origin = "";
},
open(att, index, source) {
this.data = att;
open(toot, index, origin) {
this.data = toot.object.attachment;
this.source = toot._marl.source;
this.show = true;
this.index = index;
this.source = source;
this.origin = origin;
document.getElementById("main-section-inner").setAttribute("inert", true);
setTimeout(() => {
document.getElementById("lightbox").focus();
}, 50);
},
openProfileImg(name, source) {
const data = [
{
name: name,
url: name,
mediaType: Alpine.store("files")[name].type,
openProfileImg(name, origin, source) {
const data = {
object: {
attachment: [
{
name: name,
url: name,
mediaType: Alpine.store("files").sources[source][name].type,
},
],
},
];
this.open(data, 0, source);
_marl: {
source: source,
},
};
this.open(data, 0, origin);
},
close() {
const source = this.source;
const origin = this.origin;
this.data = [];
this.index = 0;
this.show = false;
this.source = "";
this.origin = "";
document.getElementById("main-section-inner").removeAttribute("inert");
document.getElementById(source).focus();
document.getElementById(origin).focus();
},
showNext() {
this.index++;
@ -664,6 +680,7 @@ const uiStore = {
resetState() {
this.pagingOptionsVisible = false;
this.openMenu = "";
this.actorPanel = 0;
this.menuIsActive = false;
},
@ -680,6 +697,26 @@ const uiStore = {
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 = "";
@ -773,19 +810,6 @@ const uiStore = {
// utils
function unZip(file) {
resetStores();
Alpine.store("files").loading = true;
JSZip.loadAsync(file[0]).then(function (content) {
Alpine.store("files").raw = content.files;
loadJsonFile("actor");
loadJsonFile("outbox");
loadJsonFile("likes");
loadJsonFile("bookmarks");
});
}
function resetStores() {
Alpine.store("files").resetState();
Alpine.store("lightbox").resetState();
@ -795,14 +819,78 @@ function resetStores() {
Alpine.store("userPrefs").load("pageSize");
}
function loadJsonFile(name) {
const content = Alpine.store("files").raw;
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
);
})
) {
console.warn("File already loaded:", file.name);
continue;
}
let index = Alpine.store("files").sources.length;
Alpine.store("files").sources[index] = {
id: index,
fileInfos: {
name: file.name,
size: file.size,
lastModified: file.lastModified,
},
nbToots: 0,
actor: {},
outbox: {},
likes: [],
bookmarks: [],
avatar: {},
header: {},
loaded: {
actor: false,
avatar: false,
header: false,
outbox: false,
likes: false,
bookmarks: false,
},
};
JSZip.loadAsync(file).then(function (content) {
Alpine.store("files").sources[index]._raw = content.files;
loadJsonFile("actor", index);
loadJsonFile("outbox", index);
loadJsonFile("likes", index);
loadJsonFile("bookmarks", index);
});
}
setHueForSources();
}
function loadJsonFile(name, index) {
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
console.warn(`File ${name}.json not found in archive.`);
Alpine.store("files").loaded[name] = true;
Alpine.store("files").sources[index].loaded[name] = true;
} else {
// this should NOT happen and will prevent the app from running
console.error(`File ${name}.json not found in archive.`);
@ -812,114 +900,131 @@ function loadJsonFile(name) {
content[name + ".json"].async("text").then(function (txt) {
if (name === "actor") {
Alpine.store("files").actor = JSON.parse(txt);
loadActorImages();
Alpine.store("files").loaded.actor = true;
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.map(preprocessToots);
Alpine.store("files").toots = toots;
let infos = toots.reduce(
(accu, toot) => {
if (toot.type === "Create") {
const map = toot.object.contentMap;
for (let lang in map) {
if (!accu.langs[lang]) {
accu.langs[lang] = 1;
} else {
accu.langs[lang]++;
}
}
} else 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: {} }
);
let boosts = [];
for (var key in infos.boosts) {
boosts.push(infos.boosts[key]);
}
Alpine.store("files").languages = infos.langs;
Alpine.store("files").boostsAuthors = boosts;
for (let lang in infos.langs) {
Alpine.store("files").filtersDefault["langs_" + lang] = true;
}
let toots = data.orderedItems.map((t) => preprocessToots(t, index));
Alpine.store("files").toots = Alpine.store("files").toots.concat(toots); // ### duplicate toots?
Alpine.store("files").sources[index].nbToots = toots.length;
delete data.orderedItems;
Alpine.store("files").outbox = data;
Alpine.store("files").resetFilters(false);
Alpine.store("files").loaded.outbox = true;
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")[name] = tmp.orderedItems;
Alpine.store("files").loaded[name] = true;
Alpine.store("files").sources[index][name] = tmp.orderedItems;
Alpine.store("files").sources[index].loaded[name] = true;
} // likes.json || bookmarks.json
});
}
function preprocessToots(t) {
let marl = {};
function buildTootsInfos() {
let langs = {};
let boosts = [];
if (Alpine.store("files").toots.length > 0) {
let infos = Alpine.store("files").toots.reduce(
(accu, toot) => {
if (toot.type === "Create") {
const map = toot.object.contentMap;
for (let lang in map) {
if (!accu.langs[lang]) {
accu.langs[lang] = 1;
} else {
accu.langs[lang]++;
}
}
} else 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) {
let marl = {
source: index,
};
if (typeof t.object === "object" && t.object !== null) {
if (t.object.contentMap) {
@ -955,87 +1060,106 @@ function preprocessToots(t) {
return t;
}
function loadActorImages() {
const actor = Alpine.store("files").actor;
const content = Alpine.store("files").raw;
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) {
const image = actor.icon;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").avatar = {
Alpine.store("files").sources[index].avatar = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").loaded.avatar = true;
Alpine.store("files").sources[index].loaded.avatar = true;
});
} else {
Alpine.store("files").avatar = { noImg: true };
Alpine.store("files").loaded.avatar = true;
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) {
const image = actor.image;
content[image.url].async("base64").then(function (content) {
Alpine.store("files").header = {
Alpine.store("files").sources[index].header = {
type: image.mediaType,
content: content,
noImg: false,
};
Alpine.store("files").loaded.banner = true;
Alpine.store("files").sources[index].loaded.header = true;
});
} else {
Alpine.store("files").header = { noImg: true };
Alpine.store("files").loaded.banner = true;
Alpine.store("files").sources[index].header = { noImg: true };
Alpine.store("files").sources[index].loaded.header = true;
}
}
function loadAttachedMedia(att) {
if (
attachmentIsImage(att) ||
attachmentIsVideo(att) ||
attachmentIsSound(att)
) {
const data = Alpine.store("files").raw;
let url = att.url;
if (url.indexOf("/") === 0) {
url = url.slice(1);
}
data[url].async("base64").then((content) => {
Alpine.store("files")[att.url] = {
type: att.mediaType,
content: content,
};
});
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 checkAllLoaded(ok) {
function checkAppReady(ok) {
if (ok) {
buildTootsInfos();
buildDynamicFilters();
cleanUpRaw();
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() {
const content = Alpine.store("files").raw;
const actor = Alpine.store("files").actor;
for (let i = 0; i < Alpine.store("files").sources.length; i++) {
const content = Alpine.store("files").sources[i]._raw;
if (content.cleanedUp) {
continue;
}
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"];
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").raw = content;
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;
if (url.indexOf("/") === 0) {
url = url.slice(1);
}
data[url].async("base64").then((content) => {
Alpine.store("files").sources[index][att.url] = {
type: att.mediaType,
content: content,
};
});
}
}
function pagingUpdated() {

View File

@ -45,6 +45,10 @@ No need to unpack your archive. Just drag'n'drop your file in the MARL window, a
Everything takes place in the browser, with no communication with any server (once the page has been loaded). Your archive data is _not_ sent to any server. No analytics are included either.
### Support for multiple archives
You can open multiple archive files at once (or add some more after via drag'n'drop) and MARL will show you all their posts in a single chronological list, as well as all the profiles in a tabbed interface. Each profile is automatically color-coded. A new group of filters allow you to filter posts by author.
### Filters
Many filters allow you to quickly find a subset of posts in your archive:
@ -112,3 +116,15 @@ This is a personal, non-official project. I am not associated with the Mastodon
You can reach me via github or on Mastodon:
Github: https://github.com/s427
Mastodon: https://lou.lt/@s427
## Version history
- v. 1.2
- [NEW] support for multiple archive files. Notes:
- you can select multiple files from the open dialog;
- if one or several files are already loaded, you can drag and drop more files anywhere on the app window in order for MARL to load those new files and add them to the ones already loaded;
- for now the "Load new file" button still assumes you want to start over (blank slate); this will probably be changed in a future version.
- v. 1.1
- [NEW] paging preferences (page size and posts order) are automatically saved to the browser and restored on app load if present
- various bug fixes and improvements
- v. 1.0 - Initial release