mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 08:55:54 +08:00
Refine editor layout and adjust node connectors
This commit is contained in:
@@ -41,40 +41,46 @@
|
||||
@pointerdown.stop="(event) => onNodePointerDown(event, node)"
|
||||
@dblclick.prevent="() => emit('select', node.id)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 px-4 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold uppercase tracking-wide"
|
||||
:style="{ backgroundColor: node.accent, color: '#041726' }"
|
||||
>
|
||||
{{ node.role }}
|
||||
</span>
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ node.phase }}</span>
|
||||
<span class="node-connector node-connector--input" />
|
||||
<span class="node-connector node-connector--output" />
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="flex items-center justify-between gap-2 px-4 pt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs font-semibold uppercase tracking-wide"
|
||||
:style="{ backgroundColor: node.accent, color: '#041726' }"
|
||||
>
|
||||
{{ node.role }}
|
||||
</span>
|
||||
<span class="text-xs uppercase tracking-wider text-white/50">{{ node.phase }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-white/60">
|
||||
<span v-if="node.isStart" class="rounded bg-emerald-400/20 px-1.5 py-0.5 text-emerald-200">Start</span>
|
||||
<span v-if="node.isEnd" class="rounded bg-purple-400/20 px-1.5 py-0.5 text-purple-200">End</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-white/60">
|
||||
<span v-if="node.isStart" class="rounded bg-emerald-400/20 px-1.5 py-0.5 text-emerald-200">Start</span>
|
||||
<span v-if="node.isEnd" class="rounded bg-purple-400/20 px-1.5 py-0.5 text-purple-200">End</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1 px-4 pb-3 pt-1">
|
||||
<p class="font-mono text-xs tracking-wider text-cyan-200/80">{{ node.id }}</p>
|
||||
<p class="text-base font-semibold text-white/90">{{ node.title || 'Untitled node' }}</p>
|
||||
<p class="line-clamp-2 text-sm text-white/60">{{ node.summary }}</p>
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
<span
|
||||
v-for="transition in node.previewTransitions"
|
||||
:key="transition.key"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="transition.class"
|
||||
@click.stop="emit('navigate', transition.target)"
|
||||
>
|
||||
<span class="font-mono">→ {{ transition.target }}</span>
|
||||
<span v-if="transition.type !== 'next'" class="uppercase tracking-wider">{{ transition.type }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="node.autopCount > 0" class="flex items-center gap-1 text-xs text-amber-200">
|
||||
<v-icon icon="mdi-auto-fix" size="16" class="text-amber-300" />
|
||||
<span>{{ node.autopCount }} auto trigger{{ node.autopCount > 1 ? 's' : '' }}</span>
|
||||
<div class="flex flex-1 flex-col gap-2 px-4 pb-4 pt-2">
|
||||
<div class="space-y-1">
|
||||
<p class="font-mono text-xs tracking-wider text-cyan-200/80">{{ node.id }}</p>
|
||||
<p class="text-base font-semibold text-white/90">{{ node.title || 'Untitled node' }}</p>
|
||||
<p class="line-clamp-2 text-sm text-white/60">{{ node.summary }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 pt-1">
|
||||
<span
|
||||
v-for="transition in node.previewTransitions"
|
||||
:key="transition.key"
|
||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
||||
:class="transition.class"
|
||||
@click.stop="emit('navigate', transition.target)"
|
||||
>
|
||||
<span class="font-mono">→ {{ transition.target }}</span>
|
||||
<span v-if="transition.type !== 'next'" class="uppercase tracking-wider">{{ transition.type }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="node.autopCount > 0" class="flex items-center gap-1 text-xs text-amber-200">
|
||||
<v-icon icon="mdi-auto-fix" size="16" class="text-amber-300" />
|
||||
<span>{{ node.autopCount }} auto trigger{{ node.autopCount > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,14 +331,14 @@ function transitionColor(transition: DecisionNodeTransition) {
|
||||
}
|
||||
|
||||
function computeEdgePath(source: { x: number; y: number; width: number; height: number }, target: { x: number; y: number; width: number; height: number }) {
|
||||
const startX = source.x + source.width
|
||||
const startY = source.y + source.height / 2
|
||||
const endX = target.x
|
||||
const endY = target.y + target.height / 2
|
||||
const deltaX = Math.max(80, Math.abs(endX - startX) / 1.5)
|
||||
const control1X = startX + deltaX
|
||||
const control2X = endX - deltaX
|
||||
return `M ${startX} ${startY} C ${control1X} ${startY}, ${control2X} ${endY}, ${endX} ${endY}`
|
||||
const startX = source.x + source.width / 2
|
||||
const startY = source.y + source.height
|
||||
const endX = target.x + target.width / 2
|
||||
const endY = target.y
|
||||
const deltaY = Math.max(80, Math.abs(endY - startY) / 1.5)
|
||||
const control1Y = startY + deltaY
|
||||
const control2Y = endY - deltaY
|
||||
return `M ${startX} ${startY} C ${startX} ${control1Y}, ${endX} ${control2Y}, ${endX} ${endY}`
|
||||
}
|
||||
|
||||
let panPointerId: number | null = null
|
||||
@@ -492,4 +498,20 @@ onBeforeUnmount(() => {
|
||||
.control-btn {
|
||||
@apply pointer-events-auto flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-black/40 text-white/80 transition hover:border-cyan-400 hover:text-cyan-200;
|
||||
}
|
||||
|
||||
.node-connector {
|
||||
@apply pointer-events-none absolute h-3 w-3 rounded-full border-2 border-white/60 bg-[#070d1a];
|
||||
}
|
||||
|
||||
.node-connector--input {
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.node-connector--output {
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,12 +7,10 @@
|
||||
flat
|
||||
density="comfortable"
|
||||
color="rgba(11, 18, 36, 0.92)"
|
||||
class="border-b border-white/10 backdrop-blur-md shrink-0"
|
||||
:extended="true"
|
||||
height="72"
|
||||
extension-height="92"
|
||||
class="shrink-0 border-b border-white/10 backdrop-blur-md"
|
||||
height="76"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3 overflow-hidden">
|
||||
<div class="flex w-full items-center gap-4 overflow-hidden px-1">
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<v-icon icon="mdi-radar" size="24" color="cyan" />
|
||||
<div class="leading-tight">
|
||||
@@ -20,105 +18,195 @@
|
||||
<p class="text-sm font-semibold text-white/90">Decision Flow Studio</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-autocomplete
|
||||
v-model="selectedFlowSlug"
|
||||
v-model:search="flowSearch"
|
||||
:items="filteredFlows"
|
||||
item-title="name"
|
||||
item-value="slug"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
class="min-w-[220px] max-w-[320px]"
|
||||
prepend-inner-icon="mdi-file-tree"
|
||||
:loading="flowsLoading"
|
||||
label="Flow auswählen"
|
||||
:custom-filter="() => true"
|
||||
@update:model-value="(value) => value && selectFlow(value)"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ item?.raw?.name }}</span>
|
||||
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
|
||||
{{ item.raw.nodeCount }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<v-autocomplete
|
||||
v-model="selectedFlowSlug"
|
||||
v-model:search="flowSearch"
|
||||
:items="filteredFlows"
|
||||
item-title="name"
|
||||
item-value="slug"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
class="w-[240px]"
|
||||
prepend-inner-icon="mdi-file-tree"
|
||||
:loading="flowsLoading"
|
||||
label="Flow auswählen"
|
||||
:custom-filter="() => true"
|
||||
@update:model-value="(value) => value && selectFlow(value)"
|
||||
>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-medium">{{ item?.raw?.name }}</span>
|
||||
<v-chip v-if="item?.raw?.nodeCount" size="x-small" color="cyan" variant="tonal">
|
||||
{{ item.raw.nodeCount }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
<v-tooltip text="Neuen Flow anlegen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon size="small" variant="text" color="cyan" @click="openCreateFlow">
|
||||
<v-icon icon="mdi-plus" />
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #subtitle>
|
||||
<span class="text-xs text-white/60">Start: {{ item?.raw?.startState }}</span>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="Legacy ATC Import">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="purple"
|
||||
:loading="importLoading"
|
||||
@click="runImport"
|
||||
>
|
||||
<v-icon icon="mdi-database-import" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
<v-tooltip text="Neuen Flow anlegen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" icon variant="text" color="cyan" @click="openCreateFlow">
|
||||
<v-icon icon="mdi-plus" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="Legacy ATC Import">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
color="purple"
|
||||
:loading="importLoading"
|
||||
@click="runImport"
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<v-text-field
|
||||
v-model="nodeFilter.search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Node suchen"
|
||||
class="w-[200px]"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.role"
|
||||
:items="roleFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[140px]"
|
||||
label="Rolle"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.phase"
|
||||
:items="phaseFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[160px]"
|
||||
label="Phase"
|
||||
/>
|
||||
<v-tooltip text="Nur Auto-Trigger anzeigen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:color="nodeFilter.autopOnly ? 'amber' : undefined"
|
||||
class="text-white/70 hover:text-white"
|
||||
@click="toggleAutopOnly"
|
||||
>
|
||||
<v-icon icon="mdi-auto-fix" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex min-w-0 flex-1 items-center gap-3 overflow-hidden">
|
||||
<div class="flex shrink-0 items-center gap-2 text-xs uppercase tracking-widest text-white/60">
|
||||
<span>Nodes</span>
|
||||
<v-chip v-if="flowDetail" size="x-small" color="cyan" variant="flat">
|
||||
{{ nodeSelectorItems.length }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<v-slide-group
|
||||
v-if="flowDetail"
|
||||
v-model="selectedNodeId"
|
||||
show-arrows
|
||||
center-active
|
||||
class="max-w-full"
|
||||
>
|
||||
<v-icon icon="mdi-database-import" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-divider vertical class="mx-1 h-8 border-white/10" />
|
||||
<v-text-field
|
||||
v-model="nodeFilter.search"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
placeholder="Node suchen"
|
||||
class="max-w-[220px]"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.role"
|
||||
:items="roleFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[140px]"
|
||||
label="Rolle"
|
||||
/>
|
||||
<v-select
|
||||
v-model="nodeFilter.phase"
|
||||
:items="phaseFilterOptions"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
class="w-[150px]"
|
||||
label="Phase"
|
||||
/>
|
||||
<v-tooltip text="Nur Auto-Trigger anzeigen">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
:color="nodeFilter.autopOnly ? 'amber' : undefined"
|
||||
class="text-white/70 hover:text-white"
|
||||
@click="toggleAutopOnly"
|
||||
>
|
||||
<v-icon icon="mdi-auto-fix" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-spacer />
|
||||
<div class="flex min-w-0 items-center gap-3">
|
||||
<div class="min-w-0">
|
||||
<v-slide-group-item
|
||||
v-for="node in nodeSelectorItems"
|
||||
:key="node.id"
|
||||
:value="node.id"
|
||||
v-slot="{ isSelected, toggle }"
|
||||
>
|
||||
<v-tooltip
|
||||
:text="`${node.id} — ${node.title || 'Ohne Titel'}`"
|
||||
location="bottom"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<span class="inline-flex">
|
||||
<v-badge
|
||||
v-if="node.autopCount > 0"
|
||||
:content="node.autopCount"
|
||||
color="amber"
|
||||
floating
|
||||
offset-x="8"
|
||||
offset-y="8"
|
||||
class="text-[10px]"
|
||||
>
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
:class="[
|
||||
'h-9 w-9 rounded-xl transition-all',
|
||||
isSelected
|
||||
? 'bg-cyan-500/20 text-cyan-100'
|
||||
: 'text-white/70 hover:text-white',
|
||||
]"
|
||||
@click="toggle(); selectNode(node.id)"
|
||||
>
|
||||
<v-icon
|
||||
:icon="node.icon || roleIcons[node.role] || 'mdi-shape-outline'"
|
||||
:color="isSelected ? roleColors[node.role] || 'cyan' : undefined"
|
||||
/>
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
<v-btn
|
||||
v-else
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
:class="[
|
||||
'h-9 w-9 rounded-xl transition-all',
|
||||
isSelected
|
||||
? 'bg-cyan-500/20 text-cyan-100'
|
||||
: 'text-white/70 hover:text-white',
|
||||
]"
|
||||
@click="toggle(); selectNode(node.id)"
|
||||
>
|
||||
<v-icon
|
||||
:icon="node.icon || roleIcons[node.role] || 'mdi-shape-outline'"
|
||||
:color="isSelected ? roleColors[node.role] || 'cyan' : undefined"
|
||||
/>
|
||||
</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-slide-group-item>
|
||||
</v-slide-group>
|
||||
<p v-else class="truncate text-xs text-white/50">Kein Flow ausgewählt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider vertical class="h-9 border-white/10" />
|
||||
<div class="flex shrink-0 items-center gap-3 pl-2 min-w-0">
|
||||
<div class="min-w-0 text-right leading-tight">
|
||||
<p class="text-[11px] uppercase tracking-wider text-white/50">Aktueller Flow</p>
|
||||
<p class="truncate text-sm font-semibold">
|
||||
{{ flowForm.name || 'Kein Flow ausgewählt' }}
|
||||
@@ -150,108 +238,29 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<template #extension>
|
||||
<div class="w-full space-y-2 px-3 pb-3 pt-2">
|
||||
<v-progress-linear
|
||||
v-if="flowsLoading"
|
||||
indeterminate
|
||||
color="cyan"
|
||||
class="bg-white/10"
|
||||
height="3"
|
||||
rounded
|
||||
/>
|
||||
<v-alert
|
||||
v-if="flowsError"
|
||||
type="warning"
|
||||
density="comfortable"
|
||||
class="bg-amber-500/10 text-amber-200"
|
||||
border="start"
|
||||
>
|
||||
{{ flowsError }}
|
||||
</v-alert>
|
||||
<div class="flex items-center gap-3 overflow-hidden">
|
||||
<div class="flex shrink-0 items-center gap-2 text-xs uppercase tracking-widest text-white/60">
|
||||
<span>Nodes</span>
|
||||
<v-chip v-if="flowDetail" size="x-small" color="cyan" variant="flat">
|
||||
{{ nodeSelectorItems.length }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<v-slide-group
|
||||
v-if="flowDetail"
|
||||
v-model="selectedNodeId"
|
||||
show-arrows
|
||||
center-active
|
||||
class="max-w-full"
|
||||
>
|
||||
<v-slide-group-item
|
||||
v-for="node in nodeSelectorItems"
|
||||
:key="node.id"
|
||||
:value="node.id"
|
||||
v-slot="{ isSelected, toggle }"
|
||||
>
|
||||
<v-tooltip
|
||||
:text="`${node.id} — ${node.title || 'Ohne Titel'}`"
|
||||
location="bottom"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<span class="inline-flex">
|
||||
<v-badge
|
||||
v-if="node.autopCount > 0"
|
||||
:content="node.autopCount"
|
||||
color="amber"
|
||||
floating
|
||||
offset-x="8"
|
||||
offset-y="8"
|
||||
class="text-[10px]"
|
||||
>
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
:class="[
|
||||
'h-9 w-9 rounded-xl transition-all',
|
||||
isSelected
|
||||
? 'bg-cyan-500/20 text-cyan-100'
|
||||
: 'text-white/70 hover:text-white',
|
||||
]"
|
||||
@click="toggle(); selectNode(node.id)"
|
||||
>
|
||||
<v-icon
|
||||
:icon="node.icon || roleIcons[node.role] || 'mdi-shape-outline'"
|
||||
:color="isSelected ? roleColors[node.role] || 'cyan' : undefined"
|
||||
/>
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
<v-btn
|
||||
v-else
|
||||
v-bind="props"
|
||||
icon
|
||||
variant="text"
|
||||
:class="[
|
||||
'h-9 w-9 rounded-xl transition-all',
|
||||
isSelected
|
||||
? 'bg-cyan-500/20 text-cyan-100'
|
||||
: 'text-white/70 hover:text-white',
|
||||
]"
|
||||
@click="toggle(); selectNode(node.id)"
|
||||
>
|
||||
<v-icon
|
||||
:icon="node.icon || roleIcons[node.role] || 'mdi-shape-outline'"
|
||||
:color="isSelected ? roleColors[node.role] || 'cyan' : undefined"
|
||||
/>
|
||||
</v-btn>
|
||||
</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-slide-group-item>
|
||||
</v-slide-group>
|
||||
<p v-else class="text-xs text-white/50">Kein Flow ausgewählt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
<div
|
||||
v-if="flowsLoading || flowsError"
|
||||
class="border-b border-white/10 bg-[#0b1224]/75 px-4 py-2 backdrop-blur"
|
||||
>
|
||||
<v-progress-linear
|
||||
v-if="flowsLoading"
|
||||
indeterminate
|
||||
color="cyan"
|
||||
class="bg-white/10"
|
||||
height="3"
|
||||
rounded
|
||||
/>
|
||||
<v-alert
|
||||
v-if="flowsError"
|
||||
type="warning"
|
||||
density="comfortable"
|
||||
class="mt-2 bg-amber-500/10 text-amber-200"
|
||||
border="start"
|
||||
>
|
||||
{{ flowsError }}
|
||||
</v-alert>
|
||||
</div>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<section class="relative flex-1 overflow-hidden bg-[#070d1a]">
|
||||
<DecisionNodeCanvas
|
||||
|
||||
Reference in New Issue
Block a user