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:
Ivan
2026-04-13 22:47:52 -05:00
parent 26086fce0f
commit c6d396ba77
11 changed files with 332 additions and 231 deletions

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
<template>
<div class="context-menu-divider" role="separator" />
</template>
<script>
export default {
name: "ContextMenuDivider",
};
</script>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,11 @@
<template>
<div class="context-menu-section-label">
<slot />
</div>
</template>
<script>
export default {
name: "ContextMenuSectionLabel",
};
</script>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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) =>

View 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");
}
});
});