Add graph fullscreen mode, collapsible panels, and relation row styling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -69,10 +69,12 @@
|
|||||||
@click="openEditRelation(rel)"
|
@click="openEditRelation(rel)"
|
||||||
>
|
>
|
||||||
<div class="relation-row__body">
|
<div class="relation-row__body">
|
||||||
<span style="font-weight:500;">{{ otherContactName(rel) }}</span>
|
<div class="relation-row__main">
|
||||||
<span :class="`badge badge-${rel.relation_type}`" style="margin-left:8px;">{{ relLabel(rel.relation_type) }}</span>
|
<span class="relation-row__name">{{ otherContactName(rel) }}</span>
|
||||||
<span class="text-muted" style="margin-left:8px;font-size:12px;">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
<span :class="`badge badge-${rel.relation_type}`">{{ relLabel(rel.relation_type) }}</span>
|
||||||
<div v-if="rel.description" class="text-muted mt-1">{{ rel.description }}</div>
|
<span class="relation-row__intensity">{{ intensityLabel(rel.interaction_intensity) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="rel.description" class="relation-row__desc">{{ rel.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relation-row__actions" @click.stop>
|
<div class="relation-row__actions" @click.stop>
|
||||||
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
|
<button class="btn btn-secondary btn-sm" type="button" @click="openEditRelation(rel)">
|
||||||
@@ -155,6 +157,7 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useContactsStore } from '../stores/contacts'
|
import { useContactsStore } from '../stores/contacts'
|
||||||
|
import { useNetworkMapsStore } from '../stores/networkMaps'
|
||||||
import ContactForm from '../components/ContactForm.vue'
|
import ContactForm from '../components/ContactForm.vue'
|
||||||
import EditRelationModal from '../components/EditRelationModal.vue'
|
import EditRelationModal from '../components/EditRelationModal.vue'
|
||||||
import SearchableSelect from '../components/SearchableSelect.vue'
|
import SearchableSelect from '../components/SearchableSelect.vue'
|
||||||
@@ -282,7 +285,85 @@ onMounted(async () => {
|
|||||||
await mapsStore.fetchMaps()
|
await mapsStore.fetchMaps()
|
||||||
await loadContact()
|
await loadContact()
|
||||||
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
await Promise.all([store.fetchContacts(), store.fetchRelations()])
|
||||||
const rt = await store.fetchRelationTypes()
|
const [rt, intensities] = await Promise.all([
|
||||||
|
store.fetchRelationTypes(),
|
||||||
|
store.fetchNetworkMapChoices(),
|
||||||
|
])
|
||||||
relationTypes.value = rt
|
relationTypes.value = rt
|
||||||
|
interactionIntensities.value = intensities?.interaction_intensities || []
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.relations-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row:hover {
|
||||||
|
background: var(--surface-alt);
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row.is-selected {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
margin: 0 -12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__intensity {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="graph-view">
|
<div class="graph-view" :class="{ 'graph-view--chrome-collapsed': chromeCollapsed }">
|
||||||
<GraphHeaderPanel
|
<GraphHeaderPanel
|
||||||
|
v-show="!chromeCollapsed"
|
||||||
title="Граф связей"
|
title="Граф связей"
|
||||||
:show-physics-toggle="true"
|
:show-physics-toggle="true"
|
||||||
:physics-enabled="physicsEnabled"
|
:physics-enabled="physicsEnabled"
|
||||||
@@ -8,7 +9,7 @@
|
|||||||
@toggle-physics="togglePhysics"
|
@toggle-physics="togglePhysics"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="graph-view-toolbar">
|
<div v-show="!chromeCollapsed" class="graph-view-toolbar">
|
||||||
<p class="graph-link-hint text-muted">
|
<p class="graph-link-hint text-muted">
|
||||||
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
Ctrl+клик (⌘+клик) по двум узлам — создать связь.
|
||||||
<span v-if="linkSelectionCount === 1">
|
<span v-if="linkSelectionCount === 1">
|
||||||
@@ -22,7 +23,48 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="graph-area">
|
<div class="graph-area" ref="graphArea">
|
||||||
|
<div class="graph-chrome-bar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="graph-chrome-toggle"
|
||||||
|
:title="chromeCollapsed ? 'Показать панели' : 'Свернуть панели'"
|
||||||
|
@click="toggleChrome"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
:class="{ 'graph-chrome-toggle__icon--collapsed': chromeCollapsed }"
|
||||||
|
>
|
||||||
|
<polyline points="18 15 12 9 6 15"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ chromeCollapsed ? 'Показать панели' : 'Свернуть панели' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!loading && nodes.length > 0"
|
||||||
|
type="button"
|
||||||
|
class="graph-fullscreen-btn"
|
||||||
|
:title="isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'"
|
||||||
|
@click="toggleFullscreen"
|
||||||
|
>
|
||||||
|
<svg v-if="!isFullscreen" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M8 3H5a2 2 0 0 0-2 2v3"/>
|
||||||
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3"/>
|
||||||
|
<path d="M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||||
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 14h6v6"/>
|
||||||
|
<path d="M20 10h-6V4"/>
|
||||||
|
<path d="M14 10l7-7"/>
|
||||||
|
<path d="M3 21l7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<div v-if="loading" class="spinner"></div>
|
<div v-if="loading" class="spinner"></div>
|
||||||
<div v-else-if="nodes.length === 0" class="empty-state card">
|
<div v-else-if="nodes.length === 0" class="empty-state card">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -149,8 +191,11 @@ function openNodeInfo(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const store = useContactsStore()
|
const store = useContactsStore()
|
||||||
|
const graphArea = ref(null)
|
||||||
const graphContainer = ref(null)
|
const graphContainer = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
const chromeCollapsed = ref(false)
|
||||||
const network = ref(null)
|
const network = ref(null)
|
||||||
const physicsEnabled = ref(true)
|
const physicsEnabled = ref(true)
|
||||||
const selectedNode = ref(null)
|
const selectedNode = ref(null)
|
||||||
@@ -702,6 +747,33 @@ function togglePhysics() {
|
|||||||
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
|
network.value?.setOptions({ physics: physicsOptions(physicsEnabled.value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFullscreen() {
|
||||||
|
const el = graphArea.value
|
||||||
|
if (!el) return
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement === el) {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
} else {
|
||||||
|
await el.requestFullscreen()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Browser may block fullscreen without user gesture or in unsupported context.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFullscreenChange() {
|
||||||
|
isFullscreen.value = document.fullscreenElement === graphArea.value
|
||||||
|
nextTick(() => {
|
||||||
|
network.value?.redraw()
|
||||||
|
network.value?.fit({ animation: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChrome() {
|
||||||
|
chromeCollapsed.value = !chromeCollapsed.value
|
||||||
|
nextTick(() => network.value?.redraw())
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => store.dataRevision, async (revision) => {
|
watch(() => store.dataRevision, async (revision) => {
|
||||||
if (!network.value || revision === syncedRevision) return
|
if (!network.value || revision === syncedRevision) return
|
||||||
await applyGraphDataFromStore()
|
await applyGraphDataFromStore()
|
||||||
@@ -716,6 +788,7 @@ watch(() => store.dataRevision, async (revision) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
themeObserver = new MutationObserver(() => applyThemeToNetwork())
|
||||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||||
|
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
@@ -738,6 +811,10 @@ onDeactivated(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
saveLayoutSnapshot()
|
saveLayoutSnapshot()
|
||||||
closeContextMenu()
|
closeContextMenu()
|
||||||
|
document.removeEventListener('fullscreenchange', onFullscreenChange)
|
||||||
|
if (document.fullscreenElement === graphArea.value) {
|
||||||
|
document.exitFullscreen().catch(() => {})
|
||||||
|
}
|
||||||
themeObserver?.disconnect()
|
themeObserver?.disconnect()
|
||||||
teardownNetwork()
|
teardownNetwork()
|
||||||
})
|
})
|
||||||
@@ -755,17 +832,85 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 28px 10px;
|
padding: 0 28px 10px;
|
||||||
}
|
}
|
||||||
|
.graph-chrome-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.graph-chrome-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.graph-chrome-toggle:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.graph-chrome-toggle svg {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.graph-chrome-toggle__icon--collapsed {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.graph-view--chrome-collapsed .graph-area {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
.graph-link-hint {
|
.graph-link-hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
}
|
}
|
||||||
.graph-area {
|
.graph-area {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 28px 20px;
|
padding: 0 28px 20px;
|
||||||
}
|
}
|
||||||
|
.graph-area:fullscreen {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.graph-area:fullscreen #graph-container {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.graph-fullscreen-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 36px;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.graph-fullscreen-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-alt);
|
||||||
|
}
|
||||||
|
.graph-area:fullscreen .graph-fullscreen-btn {
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
#graph-container {
|
#graph-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
|
|||||||
Reference in New Issue
Block a user