2024-01-01 14:42:50 +01:00
<!DOCTYPE html>
2024-01-03 19:11:14 +01:00
< meta charset = "utf-8" / >
2024-01-02 19:23:14 +01:00
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
2024-01-03 19:11:14 +01:00
< title > 🃏️ [Matrix] Sticker Helper< / title >
2024-01-02 19:23:14 +01:00
<!--
< script src = "../../../SpaccDotWeb/SpaccDotWeb.js" module = "SpaccDotWeb" > < / script >
2024-01-01 14:42:50 +01:00
< script src = "//SpaccInc.gitlab.io/SpaccDotWeb/SpaccDotWeb.js" module = "SpaccDotWeb" > < / script >
2024-01-02 19:23:14 +01:00
-->
< script src = "../../../SpaccDotWeb/SpaccDotWeb.Alt.js" module = "SpaccDotWeb" > < / script >
< script src = "//SpaccInc.gitlab.io/SpaccDotWeb/SpaccDotWeb.Alt.js" module = "SpaccDotWeb" > < / script >
< link rel = "stylesheet" href = "./paper.min.css" / >
2024-01-01 14:42:50 +01:00
2024-01-03 19:11:14 +01:00
<!--
< script module = "Meta" > ( {
Name: "🃏️ [Matrix] Sticker Helper",
})< / script >
-->
<!--
TODO:
* more error handling
* add a way to delete a sticker or a pack
* allow reordering stickers and packs
-->
2024-01-03 00:31:15 +01:00
< div id = "Main" hidden = "true" >
< div id = "LayoutAccountSelect" > < / div >
< div id = "LayoutCollectionActions" >
< button name = "back" > 🔙️ Go Back< / button >
< button name = "commit" disabled = "true" > 📝️ Commit Changes< / button >
< / div >
< div id = "LayoutPacksList" > < / div >
< div id = "LayoutPackGrid" > < / div >
< div id = "LayoutInfo" > < / div >
< details class = "col border margin" id = "LayoutCollectionOptions" >
< summary >
< p > Options< / p >
< / summary >
< p >
Reinitializing sticker data for your account will simply override
<!-- the <code>stickerpicker</code> subfield in --> its < code > m.widgets< / code > field.
< / p >
< button name = "reinit" > 💡️ Reinitialize sticker data as new< / button >
< details class = "col border margin" >
< summary >
< p > Advanced< / p >
< / summary >
< label >
Sticker selector app URL
< input name = "pickerUrl" type = "text" / >
< / label >
< / details >
< / details >
< / div >
2024-01-03 19:11:14 +01:00
< hr class = "margin-large" / >
2024-01-03 00:31:15 +01:00
< p >
2024-01-03 19:11:14 +01:00
🃏️ [Matrix] Sticker Helper < a name = "version" href = "javascript:;" > Early Version< / a > ,
2024-01-03 00:31:15 +01:00
created with ☕️ by < a href = "https://hub.octt.eu.org" > OctoSpacc< / a > .
< br / >
2024-01-03 19:11:14 +01:00
Made possible by < a href = "https://github.com/maunium/stickerpicker" target = "_blank" > Maunium sticker picker< / a > ,
brought to paper thanks to < a href = "https://www.getpapercss.com" target = "_blank" > PaperCSS< / a > .
2024-01-03 00:31:15 +01:00
< / p >
< details class = "col border margin" >
< summary >
< p > Help< / p >
< / summary >
< p >
2024-01-03 19:11:14 +01:00
There is no one around to help... yet.
Maybe join my Matrix space if you need some: < a href = "https://matrix.to/#/#Spacc:matrix.org" > https://matrix.to/#/#Spacc:matrix.org< / a > .
2024-01-03 00:31:15 +01:00
< / p >
< / details >
2024-01-01 14:42:50 +01:00
< script module = "Main" type = "module" >
2024-01-02 19:23:14 +01:00
const Spacc = SpaccDotWeb.AppInit();
2024-01-01 14:42:50 +01:00
const State = {};
const Defaults = {
2024-01-01 19:11:18 +01:00
stickerSelectorUrl: "https://maunium.net/stickers-demo/",
2024-01-01 14:42:50 +01:00
appIdentity: "org.eu.octt.MatrixStickerHelper",
appInterface: "v1",
2024-01-02 19:23:14 +01:00
Strings: {
2024-01-03 00:31:15 +01:00
mConfirmCommit: "Confirm committing account data?",
mMustInit: "Your account must first be initialized to handle stickers.",
mNotManaged: `
2024-01-02 19:23:14 +01:00
Your account is set-up with a sticker picker, but it's not marked as being managed by this app.
This could mean that you are currently using an incompatible sticker picker.
2024-01-03 19:11:14 +01:00
< br / >
2024-01-02 19:23:14 +01:00
You can try to continue anyway if you think it should work, otherwise you should reinitialize sticker data.
`,
2024-01-03 19:11:14 +01:00
mCreatePackHint: `
<!-- Optionally include --> Include the URL of a sticker pack in JSON format to import it.
<!-- Otherwise, leave the field empty --> The option to create a brand-new pack will soon be available.
`,
mLoginHint: `
Please login with your [Matrix] account.
< br / >
(Your login details are processed locally and only sent to the homeserver you specified.)
`,
2024-01-02 19:23:14 +01:00
}
2024-01-01 14:42:50 +01:00
};
let Config = localStorage.getItem('SpaccInc-Matrix-Config');
2024-01-03 19:11:14 +01:00
if (Config) {
Config = JSON.parse(Config);
} else {
Config = {
accounts: [],
2024-01-01 14:42:50 +01:00
};
}
2024-01-03 19:11:14 +01:00
Config.Save = () => localStorage.setItem('SpaccInc-Matrix-Config', JSON.stringify(Config));
2024-01-01 14:42:50 +01:00
2024-01-02 19:23:14 +01:00
const $ = (query, ...params) => (query
? document.querySelector(Array.isArray(query)
? (params.length > 0
? params.map((a, i) => `${query[i]}${a}`).join('')
: query.join(''))
: query)
: document);
2024-01-01 14:42:50 +01:00
2024-01-02 19:23:14 +01:00
const GetMatrixUserTag = (account) => `@${account.username}:${account.homeserver.split('://')[1]}`;
2024-01-01 19:11:18 +01:00
2024-01-03 19:11:14 +01:00
const GetMatrixMediaUrl = (mxcId, props) => (mxcId ? `${props?.homeserver || `https://${mxcId.split('mxc://')[1].split('/')[0]}`}/_matrix/media/r0/${props?.type || 'download'}/${mxcId.split('mxc://')[1]}` : undefined);
2024-01-01 19:11:18 +01:00
2024-01-02 19:23:14 +01:00
function ResetLayouts () {
$`#Main`.hidden = false;
for (const id of ['LayoutInfo', 'LayoutAccountSelect', 'LayoutPacksList', 'LayoutPackGrid']) {
$`#${id}`.innerHTML = '';
}
2024-01-03 19:11:14 +01:00
for (const id of ['LayoutCollectionActions', 'LayoutCollectionOptions']) {
2024-01-02 19:23:14 +01:00
$`#${id}`.hidden = true;
}
for (const id of ['LayoutCollectionOptions']) {
$`#${id}`.open = false;
}
}
2024-01-01 19:11:18 +01:00
2024-01-03 19:11:14 +01:00
function InitializeState () {
$`#LayoutCollectionActions button[name="back"]`.onclick = () => DisplayAccountSelect();
2024-01-02 19:23:14 +01:00
$`#LayoutCollectionOptions input[name="pickerUrl"]`.value = Defaults.stickerSelectorUrl;
}
2024-01-01 19:11:18 +01:00
2024-01-03 19:11:14 +01:00
async function RequestAccountWidgetsData (postData) {
const request = await fetch(`${State.account.homeserver}/_matrix/client/v3/user/${GetMatrixUserTag(State.account)}/account_data/m.widgets`, {
2024-01-02 19:23:14 +01:00
method: (postData ? "PUT" : "GET"),
2024-01-03 19:11:14 +01:00
headers: { Authorization: `Bearer ${State.account.token}` },
2024-01-02 19:23:14 +01:00
body: JSON.stringify(postData),
});
const result = await request.json();
if (request.status === 200 || result.errcode === 'M_NOT_FOUND') {
return result;
} else {
Spacc.ShowModal(`Error: ${JSON.stringify(result)}`);
}
2024-01-01 14:42:50 +01:00
}
2024-01-03 19:11:14 +01:00
async function RequestUploadFile (fileData, fileMime) {
const request = await fetch(`${State.account.homeserver}/_matrix/media/v3/upload`, {
2024-01-02 19:23:14 +01:00
method: "POST",
2024-01-03 19:11:14 +01:00
headers: { Authorization: `Bearer ${State.account.token}`, ...(fileMime & & { "Content-Type": fileMime }) },
2024-01-02 19:23:14 +01:00
body: fileData,
});
const result = await request.json();
if (request.status === 200) {
2024-01-03 19:11:14 +01:00
return await result;
2024-01-02 19:23:14 +01:00
} else {
Spacc.ShowModal(`Error: ${JSON.stringify(result)}`);
}
}
2024-01-01 19:11:18 +01:00
2024-01-02 19:23:14 +01:00
async function PreparePacksEditor (account) {
2024-01-03 19:11:14 +01:00
if (account) {
State.account = account;
}
2024-01-02 19:23:14 +01:00
ResetLayouts();
2024-01-03 19:11:14 +01:00
State.packsData = { homeserver_url: State.account.homeserver, packs: [] };
State.stickersData = [];
2024-01-02 19:23:14 +01:00
$`#LayoutCollectionActions`.hidden = false;
$`#LayoutCollectionOptions`.hidden = false;
2024-01-03 19:11:14 +01:00
$`#LayoutCollectionOptions button[name="reinit"]`.onclick = () => Spacc.ShowModal({
label: Defaults.Strings.mConfirmCommit,
action: () => ReinitStickersAccountData(),
});
2024-01-02 19:23:14 +01:00
$`#LayoutInfo`.innerHTML = `< p >
Fetching account data...
< / p > `;
2024-01-03 19:11:14 +01:00
State.widgetsData = await RequestAccountWidgetsData();
if (State.widgetsData) {
const isManaged = State.widgetsData?.stickerpicker?.content?.managedBy?.includes(`${Defaults.appIdentity}/${Defaults.appInterface}`);
const packsUrl = (new URLSearchParams(State.widgetsData?.stickerpicker?.content?.url?.split('?')[1])).get('config');
if (!isManaged || !State.widgetsData?.stickerpicker) {
2024-01-02 19:23:14 +01:00
$`#LayoutCollectionOptions`.open = true;
2024-01-03 19:11:14 +01:00
if (!State.widgetsData?.stickerpicker) {
2024-01-03 00:31:15 +01:00
$`#LayoutInfo`.innerHTML = `< p > ${Defaults.Strings.mMustInit}< / p > `;
2024-01-02 19:23:14 +01:00
} else
if (!isManaged) {
$`#LayoutInfo`.innerHTML = `
2024-01-03 00:31:15 +01:00
< p > ${Defaults.Strings.mNotManaged}< / p >
2024-01-02 19:23:14 +01:00
< button name = "continue" > ⏭️ Continue< / button >
`;
$`#LayoutInfo > button[name="continue"]`.onclick = () => {
$`#LayoutCollectionOptions`.open = false;
2024-01-03 19:11:14 +01:00
DisplayPacksEditor(packsUrl);
2024-01-02 19:23:14 +01:00
};
}
} else {
2024-01-03 19:11:14 +01:00
DisplayPacksEditor(packsUrl);
2024-01-01 19:11:18 +01:00
}
}
2024-01-02 19:23:14 +01:00
}
2024-01-03 00:31:15 +01:00
2024-01-03 19:11:14 +01:00
async function DisplayPacksEditor (packsUrl) {
2024-01-02 19:23:14 +01:00
ResetLayouts();
$`#LayoutCollectionActions`.hidden = false;
$`#LayoutCollectionOptions`.hidden = false;
2024-01-03 00:31:15 +01:00
$`#LayoutCollectionActions button[name="commit"]`.onclick = () => Spacc.ShowModal({
label: Defaults.Strings.mConfirmCommit,
2024-01-03 19:11:14 +01:00
action: () => CommitNewAccountStickersAndData(),
2024-01-03 00:31:15 +01:00
});
2024-01-02 19:23:14 +01:00
if (packsUrl) {
2024-01-03 00:31:15 +01:00
try {
const request = await fetch(packsUrl);
if (request.status === 200) {
2024-01-03 19:11:14 +01:00
State.packsData = await request.json();
2024-01-03 00:31:15 +01:00
}
} catch(err) {
2024-01-03 19:11:14 +01:00
Spacc.ShowModal(`${err} ${packsUrl}`);
2024-01-03 00:31:15 +01:00
}
2024-01-02 19:23:14 +01:00
}
const addButton = $().createElement('button');
addButton.name = 'add';
2024-01-03 19:11:14 +01:00
addButton.innerHTML = '➕ ️ Create/Import New Pack';
addButton.onclick = (event) => Spacc.ShowModal({ label: Defaults.Strings.mCreatePackHint, extraHTML: `
< input name = "packUrl" type = "text" style = "width: 100%;" / >
`, action: (event, modalButton) => CreateNewPack(event, modalButton) });
$`#LayoutPacksList`.appendChild(addButton);
LoadStickerPacksList();
}
async function LoadStickerPacksList () {
for (const pack of State.packsData?.packs) {
try {
const request = await fetch(pack);
const packData = await request.json();
State.stickersData.push({ edited: false, data: packData });
AddNewPackButton(packData);
} catch(err) {
Spacc.ShowModal(`${err} ${pack}`);
}
}
}
async function CreateNewPack (event, modalButton) {
let packData = { stickers: [] };
// if the user specified an URL, try downloading data from there
const packUrl = modalButton.parentElement.querySelector('input[name="packUrl"]').value;
// TODO: warn if an existing pack is imported from that URL and let the user choose if to continue or cancel
//if (packUrl & & IsAnyPackImportedFrom(packUrl) & & await Spacc.ShowModal({ label: Defaults.Strings.mAlreadyImported, action: () => 'continue', actionCancel: () => 'cancel' }) === 'continue') {
// return;
//};
if (packUrl) {
try {
2024-01-03 00:31:15 +01:00
const request = await fetch(packUrl);
packData = await request.json();
2024-01-03 19:11:14 +01:00
$`#LayoutCollectionActions button[name="commit"]`.disabled = false;
} catch(err) {
Spacc.ShowModal(`${err} ${packUrl}`);
return;
2024-01-03 00:31:15 +01:00
}
2024-01-03 19:11:14 +01:00
}
packData[Defaults.appIdentity] = {
...(packUrl & & { importedFrom: packUrl }),
};
State.packsData.packs.push(null);
State.stickersData.push({ edited: true, data: packData });
AddNewPackButton(packData, event, modalButton);
}
function IsAnyPackImportedFrom (packUrl) {
for (const pack of State.stickersData) {
if (pack.data[Defaults.appIdentity].importedFrom === packUrl) {
return true;
2024-01-02 19:23:14 +01:00
}
}
2024-01-03 19:11:14 +01:00
return false;
2024-01-02 19:23:14 +01:00
}
2024-01-03 19:11:14 +01:00
function AddNewPackButton (packData, event, modalButton) {
for (const elem of $`#LayoutPacksList`.querySelectorAll('button')) {
elem.disabled = false;
}
const packButton = MakeStickerPackButton(packData);
$`#LayoutPacksList`.insertBefore(packButton, $`#LayoutPacksList > button[name="add"]`.nextElementSibling);
packButton.click();
}
function MakeStickerPackButton (packData) {
2024-01-02 19:23:14 +01:00
const packButton = $().createElement('button');
2024-01-03 19:11:14 +01:00
packButton.innerHTML = `< img src = "${GetMatrixMediaUrl(packData.stickers[0]?.info?.thumbnail_url, { homeserver: State.packsData?.homeserver_url, type: 'thumbnail' }) || ''}?&height=64&width=64&method=scale" / > `;
packButton.onclick = (event) => ShowStickerPack(event, packData);
return packButton;
}
function ShowStickerPack (event, packData) {
const thisElem = (event.srcElement.tagName.toLowerCase() === 'button' ? event.srcElement : event.srcElement.parentElement);
for (const elem of thisElem.parentElement.querySelectorAll('button')) {
elem.disabled = false;
}
thisElem.disabled = true;
$`#LayoutPackGrid`.innerHTML = '';
for (const sticker of (packData.stickers || [])) {
const stickerElem = $().createElement('button');
stickerElem.innerHTML = `< img src = "${GetMatrixMediaUrl(sticker.info.thumbnail_url, { homeserver: State.packsData?.homeserver_url, type: 'thumbnail' }) || ''}?&height=128&width=128&method=scale" / > `;
$`#LayoutPackGrid`.appendChild(stickerElem);
}
const addButton = $().createElement('button');
addButton.innerHTML = `
➕ ️ Upload New Sticker(s)
< input type = file hidden = "true" multiple = "true" accept = "image/jpeg,image/gif,image/png,image/webp" / >
`;
/* TODO
addButton.querySelector('input[type="file"]').onchange = async (event) => {
let newPackStickers = [];
for (const file of event.target.files) {
const result = await RequestUploadFile(State.account, file);
if (result) {
newPackStickers.push({
id: result.content_uri,
url: result.content_uri,
msgtype: "m.sticker",
body: file.name,
info: {
// ... TODO read real image dimensions
w: 256,
h: 256,
size: file.size,
mimetype: file.type,
thumbnail_url: result.content_uri,
thumbnail_info: {
2024-01-03 00:31:15 +01:00
// ... TODO read real image dimensions
w: 256,
h: 256,
size: file.size,
mimetype: file.type,
},
2024-01-03 19:11:14 +01:00
},
});
// ... we must use the result.content_uri to add the new sticker to the screen, add it somewhere keeping the current pack state, and the packs state, ready for committing
} else {
const answer = await Spacc.ShowModal({
label: 'File upload failed. What to do?',
action: () => 'continue',
actionCancel: () => 'cancel',
labelSecondary: '🔄️ Retry', actionSecondary: () => 'retry',
});
if (answer === 'cancel') {
newPackStickers = [];
break;
} else if (answer === 'retry') {
// ... find out how to handle this
} else if (answer === 'continue') {
continue;
2024-01-02 19:23:14 +01:00
}
}
2024-01-03 19:11:14 +01:00
}
// ... we must handle new stickers to add them to the initial object
packData.stickers = [...packData.stickers, newPackStickers];
if (newPackStickers.length > 0) {
$`#LayoutCollectionActions button[name="commit"]`.disabled = false;
}
2024-01-02 19:23:14 +01:00
};
2024-01-03 19:11:14 +01:00
*/
addButton.onclick = (event) => event.srcElement.querySelector('input[type="file"]')?.click();
// TODO: $`#LayoutPackGrid`.appendChild(addButton);
}
async function ReinitStickersAccountData () {
const userTag = GetMatrixUserTag(State.account);
State.packsData = { homeserver_url: State.account.homeserver, packs: [] };
State.stickersData = [];
State.widgetsData = {
stickerpicker: {
content: {
type: "m.stickerpicker",
url: `${$`#LayoutCollectionOptions input[name="pickerUrl"]`.value}?& config=& theme=$theme`,
name: "Stickerpicker",
creatorUserId: userTag,
managedBy: [
`${Defaults.appIdentity}`,
`${Defaults.appIdentity}/${Defaults.appInterface}`,
],
},
sender: userTag,
state_key: "stickerpicker",
type: "m.widget",
id: "stickerpicker",
},
};
await RequestAccountWidgetsData(State.widgetsData);
PreparePacksEditor();
}
async function CommitNewAccountStickersAndData () {
$`#LayoutCollectionActions button[name="commit"]`.disabled = true;
// upload new metadata for sticker packs which have been edited
for (const packIndex in State.stickersData) {
const pack = State.stickersData[packIndex];
if (pack.edited & & pack.data.stickers.length > 0) {
const uploadResult = await RequestUploadFile(JSON.stringify(pack.data), 'application/json');
State.packsData.packs[packIndex] = GetMatrixMediaUrl(uploadResult.content_uri);
}
2024-01-03 00:31:15 +01:00
}
2024-01-03 19:11:14 +01:00
// remove empty sticker packs before committing, they break the picker
for (const packIndex in State.stickersData) {
if (State.stickersData[packIndex].data.stickers.length === 0) {
State.stickersData[packIndex].data.stickers.splice(packIndex, 1);
State.packsData.packs.splice(packIndex, 1);
}
2024-01-03 00:31:15 +01:00
}
2024-01-03 19:11:14 +01:00
// finally upload new index file and update profile data
const uploadResult = await RequestUploadFile(JSON.stringify(State.packsData), 'application/json');
const packsUrlNew = GetMatrixMediaUrl(uploadResult.content_uri);
State.widgetsData.stickerpicker.content.url = `${$`#LayoutCollectionOptions input[name="pickerUrl"]`.value}?&config=${packsUrlNew}&theme=$theme`;
if (await RequestAccountWidgetsData(State.widgetsData)) {
PreparePacksEditor();
} else {
$`#LayoutCollectionActions button[name="commit"]`.disabled = false;
}
2024-01-03 00:31:15 +01:00
}
2024-01-02 19:23:14 +01:00
function DisplayAccountSelect () {
ResetLayouts();
2024-01-03 19:11:14 +01:00
for (const accountIndex in Config.accounts) {
const account = Config.accounts[accountIndex];
2024-01-02 19:23:14 +01:00
const accountButton = $().createElement('button');
accountButton.style.width = 'calc(100% - 3em)';
accountButton.innerHTML += `🐱️ ${GetMatrixUserTag(account)}`;
accountButton.onclick = () => PreparePacksEditor(account);
$`#LayoutAccountSelect`.appendChild(accountButton);
const deleteButton = $().createElement('button');
deleteButton.innerHTML += `❌️`;
deleteButton.onclick = () => Spacc.ShowModal({ label: '❌️ Confirm remove account?', action: () => {
2024-01-03 19:11:14 +01:00
Config.accounts.splice(accountIndex, 1);
2024-01-02 19:23:14 +01:00
Config.Save();
2024-01-03 19:11:14 +01:00
DisplayAccountSelect();
2024-01-02 19:23:14 +01:00
} });
$`#LayoutAccountSelect`.appendChild(deleteButton);
}
const addButton = $().createElement('button');
addButton.innerHTML += '🆕️ Add new account';
2024-01-03 19:11:14 +01:00
addButton.onclick = async () => {
const modal = await Spacc.ShowModal({ label: Defaults.Strings.mLoginHint, extraHTML: `
< label >
Homeserver
< input name = "homeserver" type = "url" placeholder = "https://matrix.example.com" value = "https://matrix.org" / >
< / label >
< label >
Username
< input name = "username" type = "text" placeholder = "AzureDiamond" / >
< / label >
< label >
Password
< input name = "password" type = "password" placeholder = "*******" / >
< / label >
< label >
Import account via session token instead
< input name = "useToken" type = "checkbox" / >
< / label >
`, action: async (event, modalButton) => {
const modal = modalButton.parentElement;
const homeserver = modal.querySelector('input[name="homeserver"]').value;
const username = modal.querySelector('input[name="username"]').value;
const password = modal.querySelector('input[name="password"]').value;
const loginData = {
homeserver: homeserver,
username: username,
};
if (modal.querySelector('input[name="useToken"]').checked) {
loginData.token = password;
} else {
try {
const response = await fetch(`${homeserver}/_matrix/client/v3/login`, {
method: "POST",
body: JSON.stringify({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: username,
},
password: password,
}),
});
const result = await response.json();
if (response.status === 200) {
loginData.token = result.access_token;
} else {
throw JSON.stringify(result);
}
} catch(err) {
Spacc.ShowModal(`Error trying to get a session token: ${err}`);
return;
}
}
Config.accounts.push(loginData);
Config.Save();
DisplayAccountSelect();
} });
const buttonConfirm = modal.querySelector('button[name="confirm"]');
buttonConfirm.disabled = true;
for (const requiredElem of modal.querySelectorAll('input[type="url"], input[type="text"], input[type="password"]')) {
for (const event of ['change', 'input', 'paste']) {
requiredElem[`on${event}`] = () => {
let allFilled = true;
for (const requiredElem of modal.querySelectorAll('input[type="url"], input[type="text"], input[type="password"]')) {
if (!requiredElem.value) {
allFilled = false;
}
}
buttonConfirm.disabled = !allFilled;
};
}
}
};
2024-01-02 19:23:14 +01:00
$`#LayoutAccountSelect`.appendChild(addButton);
}
2024-01-01 19:11:18 +01:00
2024-01-03 19:11:14 +01:00
function Main () {
InitializeState();
DisplayAccountSelect();
2024-01-02 19:23:14 +01:00
}
2024-01-03 19:11:14 +01:00
Main();
2024-01-01 14:42:50 +01:00
< / script >
2024-01-02 19:23:14 +01:00
< style >
:root {
--margin: 8px;
}
* {
box-sizing: border-box;
}
body, .ActionForm, .TableForm {
margin: var(--margin);
overflow-x: hidden;
}
details > summary > * {
display: inline-block;
}
2024-01-03 19:11:14 +01:00
#LayoutPacksList img {
max-width: 64px;
2024-01-02 19:23:14 +01:00
}
2024-01-03 19:11:14 +01:00
#LayoutPackGrid img {
max-width: 128px;
2024-01-02 19:23:14 +01:00
}
< / style >