mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-27 10:45:44 +00:00
refactor(frontend): streamline file download methods and update user interface elements across components
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
<div
|
||||
v-if="customImage"
|
||||
class="rounded-full overflow-hidden shrink-0 flex items-center justify-center"
|
||||
:class="iconClass || 'size-6'"
|
||||
:class="resolvedShellClass"
|
||||
:style="iconStyle"
|
||||
>
|
||||
<img :src="customImage" class="w-full h-full object-cover" />
|
||||
@@ -13,14 +13,14 @@
|
||||
v-else-if="iconName"
|
||||
class="p-[10%] rounded-full shrink-0 flex items-center justify-center"
|
||||
:style="[iconStyle, { 'background-color': finalBackgroundColor }]"
|
||||
:class="iconClass || 'size-6'"
|
||||
:class="resolvedShellClass"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="iconName" class="size-full" :style="{ color: finalForegroundColor }" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-zinc-500 p-[15%] rounded-full shrink-0 flex items-center justify-center border border-gray-200 dark:border-zinc-700"
|
||||
:class="iconClass || 'size-6'"
|
||||
:class="resolvedShellClass"
|
||||
:style="iconStyle"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="w-full h-full" />
|
||||
@@ -61,6 +61,19 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
resolvedShellClass() {
|
||||
const extra = (this.iconClass || "").trim();
|
||||
if (
|
||||
/\bsize-[\w.]+\b/.test(extra) ||
|
||||
/\bw-[\w.]+\b/.test(extra) ||
|
||||
/\bh-[\w.]+\b/.test(extra) ||
|
||||
/\bmin-w-/.test(extra) ||
|
||||
/\bmin-h-/.test(extra)
|
||||
) {
|
||||
return extra;
|
||||
}
|
||||
return ["size-6", extra].filter(Boolean).join(" ").trim();
|
||||
},
|
||||
finalForegroundColor() {
|
||||
return this.iconForegroundColour && this.iconForegroundColour !== ""
|
||||
? this.iconForegroundColour
|
||||
|
||||
@@ -233,6 +233,7 @@
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import MicronParser from "../../js/MicronParser.js";
|
||||
import GlobalState from "../../js/GlobalState.js";
|
||||
import { renderNomadPageByPath } from "../../js/NomadPageRenderer.js";
|
||||
@@ -504,17 +505,9 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
downloadTextAsFile(content, filename) {
|
||||
async downloadTextAsFile(content, filename) {
|
||||
const blob = new Blob([content ?? ""], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
await DownloadUtils.downloadFile(filename, blob);
|
||||
},
|
||||
muExportBasename(archive) {
|
||||
let base = (archive.page_path || "page").split("/").pop() || "page";
|
||||
|
||||
@@ -894,7 +894,7 @@
|
||||
<span
|
||||
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2 shrink-0"
|
||||
>
|
||||
{{ formatTimeAgo(announce.updated_at) }} ago
|
||||
{{ formatTimeAgo(announce.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
|
||||
@@ -995,7 +995,7 @@ export default {
|
||||
const response = await window.api.post("/api/v1/reticulum/interfaces/export");
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||
await DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
|
||||
} catch (e) {
|
||||
DialogUtils.alert(this.$t("interfaces.failed_export_all"));
|
||||
console.error(e);
|
||||
@@ -1009,7 +1009,7 @@ export default {
|
||||
});
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||
await DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||
} catch (e) {
|
||||
DialogUtils.alert(this.$t("interfaces.failed_export_single"));
|
||||
console.error(e);
|
||||
|
||||
@@ -62,7 +62,9 @@
|
||||
>
|
||||
<div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Share Contact</h3>
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("messages.share_contact_modal_title") }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
|
||||
@@ -77,7 +79,7 @@
|
||||
<input
|
||||
v-model="contactsSearch"
|
||||
type="text"
|
||||
placeholder="Search contacts..."
|
||||
:placeholder="$t('messages.share_contact_search_placeholder')"
|
||||
class="block w-full rounded-lg border-0 py-2 pl-10 text-gray-900 dark:text-white shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-zinc-800 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm dark:bg-zinc-900"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
@@ -94,10 +96,9 @@
|
||||
@click="shareContact(contact)"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="size-6" />
|
||||
</div>
|
||||
class="h-10 w-10 shrink-0 rounded-full border-2 border-dashed border-gray-300 dark:border-zinc-600 bg-gray-50 dark:bg-zinc-800/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ contact.name }}
|
||||
@@ -1487,6 +1488,7 @@
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import { clampFloatingToViewport } from "../../js/clampFloatingToViewport.js";
|
||||
import { isNearBottom, scrollContainerToBottom, shouldLoadPreviousMessages } from "./conversationScroll.js";
|
||||
import {
|
||||
@@ -3766,33 +3768,7 @@ export default {
|
||||
return this.imageGroupSortedChron(items).map((it) => this.lxmfImageUrl(it.lxmf_message.hash));
|
||||
},
|
||||
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
|
||||
// create blob from base64 encoded file bytes
|
||||
const byteCharacters = atob(fileBytesBase64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray]);
|
||||
|
||||
// create object url for blob
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
// create link element to download blob
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
link.style.display = "none";
|
||||
document.body.append(link);
|
||||
|
||||
// click link to download file in browser
|
||||
link.click();
|
||||
|
||||
// link element is no longer needed
|
||||
link.remove();
|
||||
|
||||
// revoke object url to clear memory
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||
DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
|
||||
},
|
||||
async processAudioForSelectedPeerChatItems() {
|
||||
for (const chatItem of this.selectedPeerChatItems) {
|
||||
|
||||
@@ -450,6 +450,7 @@ import DialogUtils from "../../js/DialogUtils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import NomadNetworkSidebar from "./NomadNetworkSidebar.vue";
|
||||
import Utils from "../../js/Utils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
@@ -1762,33 +1763,7 @@ export default {
|
||||
ToastUtils.warning(this.$t("nomadnet.unsupported_url") + url);
|
||||
},
|
||||
downloadFileFromBase64: async function (fileName, fileBytesBase64) {
|
||||
// create blob from base64 encoded file bytes
|
||||
const byteCharacters = atob(fileBytesBase64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray]);
|
||||
|
||||
// create object url for blob
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
// create link element to download blob
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = fileName;
|
||||
link.style.display = "none";
|
||||
document.body.append(link);
|
||||
|
||||
// click link to download file in browser
|
||||
link.click();
|
||||
|
||||
// link element is no longer needed
|
||||
link.remove();
|
||||
|
||||
// revoke object url to clear memory
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
|
||||
DownloadUtils.downloadFromBase64(fileName, fileBytesBase64);
|
||||
},
|
||||
formatBytesPerSecond: function (bytesPerSecond) {
|
||||
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||
|
||||
@@ -358,6 +358,10 @@
|
||||
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
|
||||
Rename Section
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="exportSectionFavouritesFromContext">
|
||||
<MaterialDesignIcon icon-name="file-export" class="size-4 text-gray-400" />
|
||||
{{ $t("nomadnet.export_section_favourites") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:item-class="
|
||||
'text-red-600 dark:text-red-400' +
|
||||
@@ -423,7 +427,9 @@
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
|
||||
<span class="truncate">{{
|
||||
$t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) })
|
||||
$t("nomadnet.announced_time_ago", {
|
||||
time: formatTimeAgoForI18n(node.updated_at),
|
||||
})
|
||||
}}</span>
|
||||
<span
|
||||
class="cursor-pointer hover:text-blue-500 dark:hover:text-blue-400 inline-flex items-center"
|
||||
@@ -534,6 +540,7 @@ import DialogUtils from "../../js/DialogUtils";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
|
||||
export default {
|
||||
name: "NomadNetworkSidebar",
|
||||
@@ -730,11 +737,18 @@ export default {
|
||||
};
|
||||
this._smUpResize();
|
||||
this._smUpMql.addEventListener("change", this._smUpResize);
|
||||
this._onNomadnetFavouritesLayoutImported = () => {
|
||||
this.loadFavouriteLayout();
|
||||
};
|
||||
GlobalEmitter.on("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
|
||||
},
|
||||
unmounted() {
|
||||
if (this._smUpMql && this._smUpResize) {
|
||||
this._smUpMql.removeEventListener("change", this._smUpResize);
|
||||
}
|
||||
if (this._onNomadnetFavouritesLayoutImported) {
|
||||
GlobalEmitter.off("nomadnet-favourites-layout-imported", this._onNomadnetFavouritesLayoutImported);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startEditingSection(section) {
|
||||
@@ -1067,6 +1081,41 @@ export default {
|
||||
};
|
||||
this.favouriteContextMenu.show = false;
|
||||
},
|
||||
async exportSectionFavouritesFromContext() {
|
||||
const sid = this.sectionContextMenu.sectionId;
|
||||
if (!sid) {
|
||||
this.closeContextMenus();
|
||||
return;
|
||||
}
|
||||
const section = this.sections.find((s) => s.id === sid);
|
||||
this.closeContextMenus();
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
const hashes = this.favouritesBySection[sid] || [];
|
||||
const payload = {
|
||||
format: "meshchatx/nomadnet_favourites_section/v1",
|
||||
exported_at: new Date().toISOString(),
|
||||
section: {
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
collapsed: section.collapsed === true,
|
||||
},
|
||||
destination_hashes: hashes.filter((h) => typeof h === "string"),
|
||||
};
|
||||
const slug = (section.name || "section")
|
||||
.replace(/[^a-z0-9]+/gi, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.slice(0, 48);
|
||||
const namePart = slug || "section";
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
try {
|
||||
await DownloadUtils.downloadFile(`nomadnet_favourites_section_${namePart}.json`, blob);
|
||||
ToastUtils.success(this.$t("nomadnet.section_favourites_exported"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("nomadnet.section_favourites_export_failed"));
|
||||
}
|
||||
},
|
||||
closeContextMenus() {
|
||||
this.favouriteContextMenu.show = false;
|
||||
this.sectionContextMenu.show = false;
|
||||
@@ -1227,6 +1276,9 @@ export default {
|
||||
formatTimeAgo: function (datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
formatTimeAgoForI18n: function (datetimeString) {
|
||||
return Utils.formatTimeAgoForI18n(datetimeString);
|
||||
},
|
||||
formatDestinationHash: function (destinationHash) {
|
||||
return Utils.formatDestinationHash(destinationHash);
|
||||
},
|
||||
|
||||
@@ -622,6 +622,43 @@
|
||||
@change="importFolders"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-teal-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-teal-500 transition group"
|
||||
@click="exportNomadnetFavouritesLayout"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="file-export"
|
||||
class="size-6 text-teal-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">
|
||||
{{ $t("maintenance.export_nomadnet_favourites") }}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-cyan-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-cyan-500 transition group"
|
||||
@click="triggerNomadnetFavouritesImport"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="import"
|
||||
class="size-6 text-cyan-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">
|
||||
{{ $t("maintenance.import_nomadnet_favourites") }}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref="nomadnetFavouritesImportFile"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="importNomadnetFavouritesLayoutFile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -2077,7 +2114,7 @@
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">{{
|
||||
$t("app.last_synced", {
|
||||
time: formatSecondsAgo(
|
||||
time: formatSecondsAgoForI18n(
|
||||
config.lxmf_preferred_propagation_node_last_synced_at
|
||||
),
|
||||
})
|
||||
@@ -2271,6 +2308,8 @@ import Utils from "../../js/Utils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
import ShortcutRecorder from "./ShortcutRecorder.vue";
|
||||
@@ -2460,6 +2499,8 @@ export default {
|
||||
"maintenance.export_messages_desc",
|
||||
"maintenance.import_messages",
|
||||
"maintenance.import_messages_desc",
|
||||
"maintenance.export_nomadnet_favourites",
|
||||
"maintenance.import_nomadnet_favourites",
|
||||
"Automatic Backup Limit",
|
||||
"Export Folders",
|
||||
"Import Folders",
|
||||
@@ -3703,9 +3744,171 @@ export default {
|
||||
// Reset input
|
||||
event.target.value = "";
|
||||
},
|
||||
normalizeNomadnetFavouritesLayoutShape(layout) {
|
||||
if (!layout || typeof layout !== "object" || !Array.isArray(layout.sections)) {
|
||||
return null;
|
||||
}
|
||||
const favouritesBySection =
|
||||
layout.favouritesBySection && typeof layout.favouritesBySection === "object"
|
||||
? layout.favouritesBySection
|
||||
: {};
|
||||
const sectionOrder = Array.isArray(layout.sectionOrder)
|
||||
? layout.sectionOrder
|
||||
: layout.sections.map((s) => s && s.id).filter(Boolean);
|
||||
const sections = layout.sections
|
||||
.filter((s) => s && typeof s.id === "string")
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: typeof s.name === "string" ? s.name : "",
|
||||
collapsed: s.collapsed === true,
|
||||
}));
|
||||
const sanitizedMap = {};
|
||||
for (const k of Object.keys(favouritesBySection)) {
|
||||
const arr = favouritesBySection[k];
|
||||
if (Array.isArray(arr)) {
|
||||
sanitizedMap[k] = arr.filter((h) => typeof h === "string");
|
||||
}
|
||||
}
|
||||
return { sections, sectionOrder, favouritesBySection: sanitizedMap };
|
||||
},
|
||||
parseNomadnetFavouritesImportData(data) {
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
if (data.format === "meshchatx/nomadnet_favourites/v1" && data.layout && typeof data.layout === "object") {
|
||||
const layout = this.normalizeNomadnetFavouritesLayoutShape(data.layout);
|
||||
return layout ? { kind: "full", layout } : null;
|
||||
}
|
||||
if (data.format === "meshchatx/nomadnet_favourites_section/v1") {
|
||||
const sec = data.section;
|
||||
if (!sec || typeof sec.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
return { kind: "section", payload: data };
|
||||
}
|
||||
const layout = this.normalizeNomadnetFavouritesLayoutShape(data);
|
||||
return layout ? { kind: "full", layout } : null;
|
||||
},
|
||||
mergeNomadnetFavouritesSectionImport(payload) {
|
||||
const sec = payload.section;
|
||||
const hashes = Array.isArray(payload.destination_hashes)
|
||||
? payload.destination_hashes.filter((h) => typeof h === "string")
|
||||
: [];
|
||||
let raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
let base = { sections: [], sectionOrder: [], favouritesBySection: {} };
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
|
||||
if (normalized) {
|
||||
base = normalized;
|
||||
}
|
||||
} catch {
|
||||
// keep default base
|
||||
}
|
||||
}
|
||||
const sections = [...base.sections];
|
||||
const sectionOrder = [...base.sectionOrder];
|
||||
const favouritesBySection = { ...base.favouritesBySection };
|
||||
const idx = sections.findIndex((s) => s.id === sec.id);
|
||||
const sectionObj = {
|
||||
id: sec.id,
|
||||
name:
|
||||
typeof sec.name === "string" && sec.name.trim() !== "" ? sec.name : this.$t("nomadnet.favourites"),
|
||||
collapsed: sec.collapsed === true,
|
||||
};
|
||||
if (idx === -1) {
|
||||
sections.push(sectionObj);
|
||||
if (!sectionOrder.includes(sec.id)) {
|
||||
sectionOrder.push(sec.id);
|
||||
}
|
||||
} else {
|
||||
sections[idx] = { ...sections[idx], ...sectionObj };
|
||||
}
|
||||
favouritesBySection[sec.id] = hashes;
|
||||
const merged = this.normalizeNomadnetFavouritesLayoutShape({
|
||||
sections,
|
||||
sectionOrder,
|
||||
favouritesBySection,
|
||||
});
|
||||
if (!merged) {
|
||||
throw new Error("invalid layout");
|
||||
}
|
||||
localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(merged));
|
||||
},
|
||||
async exportNomadnetFavouritesLayout() {
|
||||
let layout = { sections: [], sectionOrder: [], favouritesBySection: {} };
|
||||
try {
|
||||
const raw = localStorage.getItem("meshchat.nomadnet.favourites.layout");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const normalized = this.normalizeNomadnetFavouritesLayoutShape(parsed);
|
||||
if (normalized) {
|
||||
layout = normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// keep empty layout
|
||||
}
|
||||
const body = {
|
||||
format: "meshchatx/nomadnet_favourites/v1",
|
||||
exported_at: new Date().toISOString(),
|
||||
layout,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(body, null, 2)], { type: "application/json" });
|
||||
try {
|
||||
await DownloadUtils.downloadFile(
|
||||
`meshchat_nomadnet_favourites_${new Date().toISOString().slice(0, 10)}.json`,
|
||||
blob
|
||||
);
|
||||
ToastUtils.success(this.$t("maintenance.nomadnet_favourites_exported"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("maintenance.nomadnet_favourites_export_failed"));
|
||||
}
|
||||
},
|
||||
triggerNomadnetFavouritesImport() {
|
||||
this.$refs.nomadnetFavouritesImportFile.click();
|
||||
},
|
||||
importNomadnetFavouritesLayoutFile(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
const parsed = this.parseNomadnetFavouritesImportData(data);
|
||||
if (!parsed) {
|
||||
throw new Error("invalid file");
|
||||
}
|
||||
if (parsed.kind === "full") {
|
||||
localStorage.setItem("meshchat.nomadnet.favourites.layout", JSON.stringify(parsed.layout));
|
||||
} else if (parsed.kind === "section") {
|
||||
this.mergeNomadnetFavouritesSectionImport(parsed.payload);
|
||||
} else {
|
||||
throw new Error("invalid file");
|
||||
}
|
||||
GlobalEmitter.emit("nomadnet-favourites-layout-imported");
|
||||
ToastUtils.success(this.$t("maintenance.nomadnet_favourites_imported"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("maintenance.nomadnet_favourites_import_failed"));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
event.target.value = "";
|
||||
},
|
||||
formatSecondsAgo: function (seconds) {
|
||||
return Utils.formatSecondsAgo(seconds);
|
||||
},
|
||||
formatSecondsAgoForI18n: function (seconds) {
|
||||
return Utils.formatSecondsAgoForI18n(seconds);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user