mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-25 19:42:13 +00:00
feat(context-menu): refactor context menu implementation by creating reusable ContextMenuPanel, ContextMenuItem, ContextMenuDivider, and ContextMenuSectionLabel components; update styles and integrate components across various pages
This commit is contained in:
@@ -140,42 +140,39 @@
|
||||
</div>
|
||||
|
||||
<!-- Contact context menu -->
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="fixed z-[210] min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl"
|
||||
:style="{ top: `${contextMenu.y}px`, left: `${contextMenu.x}px` }"
|
||||
<ContextMenuPanel
|
||||
:show="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
panel-class="z-[210]"
|
||||
>
|
||||
<button type="button" class="context-item" @click="openConversation(contextMenu.contact)">
|
||||
<ContextMenuItem @click="openConversation(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="message-text-outline" class="size-4" />
|
||||
{{ $t("contacts.send_message") }}
|
||||
</button>
|
||||
<button type="button" class="context-item" @click="callContact(contextMenu.contact)">
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="callContact(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="phone-outline" class="size-4" />
|
||||
{{ $t("contacts.call_contact") }}
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
|
||||
<button type="button" class="context-item" @click="editContactName(contextMenu.contact)">
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem @click="editContactName(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="pencil-outline" class="size-4" />
|
||||
{{ $t("contacts.edit_contact") }}
|
||||
</button>
|
||||
<button type="button" class="context-item" @click="shareContact(contextMenu.contact)">
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="shareContact(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="share-variant" class="size-4" />
|
||||
{{ $t("contacts.share_contact") }}
|
||||
</button>
|
||||
<button type="button" class="context-item" @click="copyContactUri(contextMenu.contact)">
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="copyContactUri(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
|
||||
{{ $t("contacts.copy_contact_uri") }}
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-800 my-1"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="context-item text-red-600 dark:text-red-400"
|
||||
@click="removeContact(contextMenu.contact)"
|
||||
>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="removeContact(contextMenu.contact)">
|
||||
<MaterialDesignIcon icon-name="delete-outline" class="size-4" />
|
||||
{{ $t("contacts.remove_contact") }}
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
|
||||
<!-- Add contact dialog -->
|
||||
<div
|
||||
@@ -384,12 +381,18 @@ import ToastUtils from "../../js/ToastUtils";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
|
||||
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
|
||||
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
|
||||
|
||||
export default {
|
||||
name: "ContactsPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
LxmfUserIcon,
|
||||
ContextMenuDivider,
|
||||
ContextMenuItem,
|
||||
ContextMenuPanel,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -891,8 +894,4 @@ export default {
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-1 rounded-xl bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 text-xs font-semibold transition;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
@apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<div class="context-menu-divider" role="separator" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuDivider",
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<button type="button" class="context-item" :class="itemClass" :disabled="disabled" @click="$emit('click', $event)">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuItem",
|
||||
props: {
|
||||
itemClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="context-menu-panel"
|
||||
:class="panelClass"
|
||||
:style="{ top: y + 'px', left: x + 'px' }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot name="header" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuPanel",
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
panelClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="context-menu-section-label">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "ContextMenuSectionLabel",
|
||||
};
|
||||
</script>
|
||||
@@ -300,56 +300,44 @@
|
||||
</div>
|
||||
|
||||
<!-- context menu -->
|
||||
<div
|
||||
v-if="showContextMenu"
|
||||
class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
<ContextMenuPanel
|
||||
:show="showContextMenu"
|
||||
:x="contextMenuPos.x"
|
||||
:y="contextMenuPos.y"
|
||||
panel-class="z-[120] overflow-hidden text-sm"
|
||||
>
|
||||
<div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
|
||||
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
v-if="contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextSelectFeature"
|
||||
<template #header>
|
||||
<div
|
||||
class="px-3 py-2 font-semibold border-b border-gray-100 dark:border-zinc-800 text-gray-700 dark:text-zinc-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
|
||||
<span>Select / Move</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextAddNote"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
|
||||
<span>Add / Edit Note</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
|
||||
@click="contextDeleteFeature"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextCopyCoords"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
|
||||
<span>Copy coords</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextClearMap"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
|
||||
<span>Clear drawings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
|
||||
</div>
|
||||
</template>
|
||||
<ContextMenuItem v-if="contextMenuFeature" @click="contextSelectFeature">
|
||||
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
|
||||
Select / Move
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="contextMenuFeature" @click="contextAddNote">
|
||||
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
|
||||
Add / Edit Note
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
|
||||
item-class="text-red-600 dark:text-red-400"
|
||||
@click="contextDeleteFeature"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-4" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="contextCopyCoords">
|
||||
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
|
||||
Copy coords
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="!contextMenuFeature" @click="contextClearMap">
|
||||
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
|
||||
Clear drawings
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
|
||||
<!-- loading skeleton for map -->
|
||||
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
|
||||
@@ -1325,6 +1313,8 @@ import Overlay from "ol/Overlay";
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
import { extend as extendExtent, createEmpty as createEmptyExtent, isEmpty as isExtentEmpty } from "ol/extent";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
|
||||
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import TileCache from "../../js/TileCache";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
@@ -1334,6 +1324,8 @@ import MiniChat from "./MiniChat.vue";
|
||||
export default {
|
||||
name: "MapPage",
|
||||
components: {
|
||||
ContextMenuItem,
|
||||
ContextMenuPanel,
|
||||
MaterialDesignIcon,
|
||||
Toggle,
|
||||
MiniChat,
|
||||
|
||||
@@ -518,58 +518,43 @@
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div
|
||||
v-if="contextMenu.show"
|
||||
<ContextMenuPanel
|
||||
:show="contextMenu.show"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
panel-class="z-[100]"
|
||||
v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
|
||||
class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="bulkMarkAsRead"
|
||||
>
|
||||
<ContextMenuItem @click="bulkMarkAsRead">
|
||||
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
|
||||
<span class="font-medium">Mark as Read</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenu.targetHash"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="togglePinFromContextMenu"
|
||||
>
|
||||
Mark as Read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-if="contextMenu.targetHash" @click="togglePinFromContextMenu">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="isContextTargetPinned ? 'pin-off' : 'pin'"
|
||||
class="size-4 text-gray-400"
|
||||
/>
|
||||
<span class="font-medium">{{
|
||||
{{
|
||||
isContextTargetPinned
|
||||
? $t("messages.unpin_conversation")
|
||||
: $t("messages.pin_conversation")
|
||||
}}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="contextMenuIngestPaperMessage"
|
||||
>
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="contextMenuIngestPaperMessage">
|
||||
<MaterialDesignIcon icon-name="qrcode-scan" class="size-4 text-gray-400" />
|
||||
<span class="font-medium">{{ $t("messages.ingest_paper_message") }}</span>
|
||||
</button>
|
||||
<button
|
||||
{{ $t("messages.ingest_paper_message") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="contextMenu.targetHash && isBlocked(contextMenu.targetHash)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 transition-all active:scale-95"
|
||||
item-class="text-emerald-600 dark:text-emerald-400"
|
||||
@click="liftBanishmentFromConversationMenu"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-4" />
|
||||
<span class="font-medium">{{ $t("banishment.lift_banishment") }}</span>
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<button
|
||||
{{ $t("banishment.lift_banishment") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem
|
||||
v-if="GlobalState.config.telemetry_enabled"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="toggleTelemetryTrust(contextMenu.targetHash)"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
@@ -580,56 +565,38 @@
|
||||
"
|
||||
:class="
|
||||
contextMenu.targetContact?.is_telemetry_trusted
|
||||
? 'text-blue-500'
|
||||
: 'text-gray-400'
|
||||
? 'size-4 text-blue-500'
|
||||
: 'size-4 text-gray-400'
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="font-medium">{{
|
||||
{{
|
||||
contextMenu.targetContact?.is_telemetry_trusted
|
||||
? "Revoke Telemetry Trust"
|
||||
: "Trust for Telemetry"
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="GlobalState.config.telemetry_enabled"
|
||||
class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
|
||||
></div>
|
||||
<div
|
||||
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
Move to Folder
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="moveSelectedToFolder(null)"
|
||||
>
|
||||
}}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider v-if="GlobalState.config.telemetry_enabled" />
|
||||
<ContextMenuSectionLabel>Move to Folder</ContextMenuSectionLabel>
|
||||
<ContextMenuItem @click="moveSelectedToFolder(null)">
|
||||
<MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
|
||||
<span>Uncategorized</span>
|
||||
</button>
|
||||
Uncategorized
|
||||
</ContextMenuItem>
|
||||
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
<ContextMenuItem
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="moveSelectedToFolder(folder.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
|
||||
<span class="truncate">{{ folder.name }}</span>
|
||||
</button>
|
||||
</ContextMenuItem>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="bulkDelete">
|
||||
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
|
||||
<span class="font-bold">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
|
||||
<!-- loading more spinner -->
|
||||
<div v-if="isLoadingMore" class="p-4 text-center">
|
||||
@@ -802,13 +769,24 @@ import Utils from "../../js/Utils";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
|
||||
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
|
||||
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
|
||||
import ContextMenuSectionLabel from "../contextmenu/ContextMenuSectionLabel.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||
|
||||
export default {
|
||||
name: "MessagesSidebar",
|
||||
components: { MaterialDesignIcon, LxmfUserIcon },
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
LxmfUserIcon,
|
||||
ContextMenuDivider,
|
||||
ContextMenuItem,
|
||||
ContextMenuPanel,
|
||||
ContextMenuSectionLabel,
|
||||
},
|
||||
props: {
|
||||
peers: {
|
||||
type: Object,
|
||||
|
||||
@@ -277,101 +277,84 @@
|
||||
|
||||
<!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="favouriteContextMenu.show"
|
||||
<ContextMenuPanel
|
||||
:show="favouriteContextMenu.show"
|
||||
:x="favouriteContextMenu.x"
|
||||
:y="favouriteContextMenu.y"
|
||||
panel-class="z-[200] min-w-56"
|
||||
v-click-outside="{
|
||||
handler: () => {
|
||||
if (!favouriteContextMenu.justOpened) closeContextMenus();
|
||||
},
|
||||
capture: true,
|
||||
}"
|
||||
class="fixed z-[200] min-w-[220px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
:style="{ top: favouriteContextMenu.y + 'px', left: favouriteContextMenu.x + 'px' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="renameFavouriteFromContext"
|
||||
>
|
||||
<ContextMenuItem @click="renameFavouriteFromContext">
|
||||
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.rename") }}</span>
|
||||
</button>
|
||||
<button
|
||||
{{ $t("nomadnet.rename") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="!isBlocked(favouriteContextMenu.targetHash)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
item-class="text-red-600 dark:text-red-400"
|
||||
@click="banishFavouriteFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
|
||||
</button>
|
||||
<button
|
||||
{{ $t("nomadnet.block_node") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-else
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
|
||||
item-class="text-emerald-600 dark:text-emerald-400"
|
||||
@click="unblockFavouriteFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
@click="removeFavouriteFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-emerald-500" />
|
||||
{{ $t("nomadnet.lift_banishment") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem item-class="text-red-600 dark:text-red-400" @click="removeFavouriteFromContext">
|
||||
<MaterialDesignIcon icon-name="trash-can" class="size-4 text-red-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.remove") }}</span>
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<div
|
||||
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
Move to Section
|
||||
</div>
|
||||
{{ $t("nomadnet.remove") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuDivider />
|
||||
<ContextMenuSectionLabel>Move to Section</ContextMenuSectionLabel>
|
||||
<div class="max-h-56 overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
<ContextMenuItem
|
||||
v-for="section in sectionsWithFavourites"
|
||||
:key="section.id + '-move'"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="moveContextFavouriteToSection(section.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
|
||||
<span class="truncate">{{ section.name }}</span>
|
||||
</button>
|
||||
</ContextMenuItem>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuPanel>
|
||||
</Teleport>
|
||||
|
||||
<!-- Section Context Menu (Teleport to body) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="sectionContextMenu.show"
|
||||
<ContextMenuPanel
|
||||
:show="sectionContextMenu.show"
|
||||
:x="sectionContextMenu.x"
|
||||
:y="sectionContextMenu.y"
|
||||
panel-class="z-[200]"
|
||||
v-click-outside="{ handler: closeContextMenus, capture: true }"
|
||||
class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
:style="{ top: sectionContextMenu.y + 'px', left: sectionContextMenu.x + 'px' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="renameSectionFromContext"
|
||||
>
|
||||
<ContextMenuItem @click="renameSectionFromContext">
|
||||
<MaterialDesignIcon icon-name="pencil" class="size-4 text-gray-400" />
|
||||
<span class="font-medium">Rename Section</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
:disabled="sectionContextMenu.sectionId === defaultSectionId"
|
||||
:class="
|
||||
sectionContextMenu.sectionId === defaultSectionId ? 'opacity-50 cursor-not-allowed' : ''
|
||||
Rename Section
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:item-class="
|
||||
'text-red-600 dark:text-red-400' +
|
||||
(sectionContextMenu.sectionId === defaultSectionId
|
||||
? ' opacity-50 cursor-not-allowed'
|
||||
: '')
|
||||
"
|
||||
:disabled="sectionContextMenu.sectionId === defaultSectionId"
|
||||
@click="removeSectionFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-4 text-red-400" />
|
||||
<span class="font-medium">Delete Section</span>
|
||||
</button>
|
||||
</div>
|
||||
Delete Section
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -478,45 +461,42 @@
|
||||
|
||||
<!-- Announce Context Menu (right-click, Teleport to body) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="announceContextMenu.show"
|
||||
<ContextMenuPanel
|
||||
:show="announceContextMenu.show"
|
||||
:x="announceContextMenu.x"
|
||||
:y="announceContextMenu.y"
|
||||
panel-class="z-[200]"
|
||||
v-click-outside="{
|
||||
handler: () => {
|
||||
if (!announceContextMenu.justOpened) closeContextMenus();
|
||||
},
|
||||
capture: true,
|
||||
}"
|
||||
class="fixed z-[200] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
:style="{ top: announceContextMenu.y + 'px', left: announceContextMenu.x + 'px' }"
|
||||
>
|
||||
<button
|
||||
<ContextMenuItem
|
||||
v-if="!isFavourite(announceContextMenu.node?.destination_hash)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="addFavouriteFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="star-outline" class="size-4 text-yellow-500" />
|
||||
<span class="font-medium">{{ $t("nomadnet.add_favourite") }}</span>
|
||||
</button>
|
||||
<button
|
||||
{{ $t("nomadnet.add_favourite") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="!isBlocked(announceContextMenu.node?.identity_hash)"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
item-class="text-red-600 dark:text-red-400"
|
||||
@click="blockAnnounceFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="gavel" class="size-4 text-red-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.block_node") }}</span>
|
||||
</button>
|
||||
<button
|
||||
{{ $t("nomadnet.block_node") }}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-else
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all active:scale-95"
|
||||
item-class="text-emerald-600 dark:text-emerald-400"
|
||||
@click="unblockAnnounceFromContext"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-green-400" />
|
||||
<span class="font-medium">{{ $t("nomadnet.lift_banishment") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="check-circle" class="size-4 text-emerald-500" />
|
||||
{{ $t("nomadnet.lift_banishment") }}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuPanel>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -526,6 +506,10 @@
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
|
||||
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
|
||||
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
|
||||
import ContextMenuSectionLabel from "../contextmenu/ContextMenuSectionLabel.vue";
|
||||
import DropDownMenu from "../DropDownMenu.vue";
|
||||
import IconButton from "../IconButton.vue";
|
||||
import DropDownMenuItem from "../DropDownMenuItem.vue";
|
||||
@@ -536,7 +520,16 @@ import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
export default {
|
||||
name: "NomadNetworkSidebar",
|
||||
components: { DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon },
|
||||
components: {
|
||||
ContextMenuDivider,
|
||||
ContextMenuItem,
|
||||
ContextMenuPanel,
|
||||
ContextMenuSectionLabel,
|
||||
DropDownMenuItem,
|
||||
IconButton,
|
||||
DropDownMenu,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
nodes: {
|
||||
type: Object,
|
||||
|
||||
@@ -207,3 +207,19 @@ select.input-field option {
|
||||
.animate-spin-reverse {
|
||||
animation: spin-reverse 1s linear infinite;
|
||||
}
|
||||
|
||||
.context-menu-panel {
|
||||
@apply fixed min-w-48 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 shadow-xl;
|
||||
}
|
||||
|
||||
.context-item {
|
||||
@apply w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.context-menu-divider {
|
||||
@apply border-t border-gray-100 dark:border-zinc-800 my-1;
|
||||
}
|
||||
|
||||
.context-menu-section-label {
|
||||
@apply px-3 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("NomadNetworkSidebar.vue", () => {
|
||||
wrapper.vm.sectionContextMenu.show = false;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const menuEls = document.body.querySelectorAll(".fixed");
|
||||
const menuEls = document.body.querySelectorAll(".context-menu-panel");
|
||||
const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename"));
|
||||
expect(menuEl).toBeTruthy();
|
||||
expect(menuEl.textContent).toContain("nomadnet.block_node");
|
||||
@@ -152,7 +152,7 @@ describe("NomadNetworkSidebar.vue", () => {
|
||||
wrapper.vm.sectionContextMenu.show = false;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const menuEls = document.body.querySelectorAll(".fixed");
|
||||
const menuEls = document.body.querySelectorAll(".context-menu-panel");
|
||||
const menuEl = Array.from(menuEls).find((el) => el.textContent.includes("nomadnet.rename"));
|
||||
expect(menuEl).toBeTruthy();
|
||||
const banishBtn = Array.from(menuEl.querySelectorAll("button")).find((b) =>
|
||||
|
||||
44
tests/frontend/contextMenuStyles.test.js
Normal file
44
tests/frontend/contextMenuStyles.test.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function readProjectFile(relativePath) {
|
||||
return readFileSync(join(root, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("context menu styling", () => {
|
||||
it("defines shared classes in style.css", () => {
|
||||
const css = readProjectFile("meshchatx/src/frontend/style.css");
|
||||
expect(css).toMatch(/\.context-menu-panel\s*\{/);
|
||||
expect(css).toMatch(/\.context-item\s*\{/);
|
||||
expect(css).toMatch(/\.context-menu-divider\s*\{/);
|
||||
expect(css).toMatch(/\.context-menu-section-label\s*\{/);
|
||||
expect(css).toContain("min-w-48");
|
||||
expect(css).toContain("rounded-xl");
|
||||
expect(css).toContain("shadow-xl");
|
||||
});
|
||||
|
||||
it("ContextMenuPanel and ContextMenuItem components define the shared CSS classes", () => {
|
||||
const panel = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuPanel.vue");
|
||||
const item = readProjectFile("meshchatx/src/frontend/components/contextmenu/ContextMenuItem.vue");
|
||||
expect(panel).toContain("context-menu-panel");
|
||||
expect(item).toContain("context-item");
|
||||
});
|
||||
|
||||
it("uses ContextMenuPanel and ContextMenuItem on all right-click context menus", () => {
|
||||
const files = [
|
||||
"meshchatx/src/frontend/components/contacts/ContactsPage.vue",
|
||||
"meshchatx/src/frontend/components/messages/MessagesSidebar.vue",
|
||||
"meshchatx/src/frontend/components/messages/ConversationViewer.vue",
|
||||
"meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue",
|
||||
"meshchatx/src/frontend/components/map/MapPage.vue",
|
||||
];
|
||||
for (const f of files) {
|
||||
const src = readProjectFile(f);
|
||||
expect(src, f).toContain("ContextMenuPanel");
|
||||
expect(src, f).toContain("ContextMenuItem");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user