Merge branch 'main' into feature-whiteboards-recent-and-history
# Conflicts: # front/src/App.vue
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import AppTopBar from './components/AppTopBar.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -11,12 +12,20 @@ onMounted(() => {
|
||||
auth.initialize()
|
||||
})
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const hideTopBar = computed(() => route.meta.hideTopBar === true)
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<AppTopBar />
|
||||
<main class="container py-4">
|
||||
<template v-if="hideTopBar">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<AppTopBar />
|
||||
<main class="container py-4">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
100
front/src/components/whiteboard/WhiteboardCanvas.vue
Normal file
100
front/src/components/whiteboard/WhiteboardCanvas.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import type { Rectangle } from '@/types/whiteboard.ts'
|
||||
import SvgRectangle from '@/components/whiteboard/shapes/SvgRectangle.vue'
|
||||
import SvgDraftRect from '@/components/whiteboard/shapes/SvgDraftRect.vue'
|
||||
|
||||
const store = useWhiteboardStore()
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragStart = ref({ x: 0, y: 0 })
|
||||
const dragEnd = ref({ x: 0, y: 0 })
|
||||
|
||||
const draftRect = computed(() => {
|
||||
if (!isDragging.value) return null
|
||||
const x = Math.min(dragStart.value.x, dragEnd.value.x)
|
||||
const y = Math.min(dragStart.value.y, dragEnd.value.y)
|
||||
const width = Math.abs(dragEnd.value.x - dragStart.value.x)
|
||||
const height = Math.abs(dragEnd.value.y - dragStart.value.y)
|
||||
return { x, y, width, height }
|
||||
})
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (store.selectedTool !== 'rectangle') return
|
||||
const svg = (e.currentTarget as SVGSVGElement)
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
dragStart.value = { x, y }
|
||||
dragEnd.value = { x, y }
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
const svg = (e.currentTarget as SVGSVGElement)
|
||||
const rect = svg.getBoundingClientRect()
|
||||
dragEnd.value = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
if (!isDragging.value) return
|
||||
isDragging.value = false
|
||||
|
||||
const x1 = Math.min(dragStart.value.x, dragEnd.value.x)
|
||||
const y1 = Math.min(dragStart.value.y, dragEnd.value.y)
|
||||
const x2 = Math.max(dragStart.value.x, dragEnd.value.x)
|
||||
const y2 = Math.max(dragStart.value.y, dragEnd.value.y)
|
||||
|
||||
if (x2 - x1 < 5 && y2 - y1 < 5) return
|
||||
|
||||
const rectangle: Rectangle = {
|
||||
id: crypto.randomUUID(),
|
||||
ownerId: '00000000-0000-0000-0000-000000000000',
|
||||
position: { x: Math.round(x1), y: Math.round(y1) },
|
||||
endPosition: { x: Math.round(x2), y: Math.round(y2) },
|
||||
color: '#4f9dff',
|
||||
borderThickness: 2,
|
||||
}
|
||||
|
||||
store.addRectangle(rectangle)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
class="whiteboard-canvas"
|
||||
@mousedown="onMouseDown"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp"
|
||||
>
|
||||
<SvgRectangle
|
||||
v-for="rect in store.whiteboard?.rectangles"
|
||||
:key="rect.id"
|
||||
:rectangle="rect"
|
||||
/>
|
||||
<SvgDraftRect
|
||||
v-if="draftRect"
|
||||
:x="draftRect.x"
|
||||
:y="draftRect.y"
|
||||
:width="draftRect.width"
|
||||
:height="draftRect.height"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.whiteboard-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #1a1a2e;
|
||||
cursor: crosshair;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
54
front/src/components/whiteboard/WhiteboardToolbar.vue
Normal file
54
front/src/components/whiteboard/WhiteboardToolbar.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import type { ShapeTool } from '@/types/whiteboard.ts'
|
||||
|
||||
const store = useWhiteboardStore()
|
||||
const emit = defineEmits<{ leave: [] }>()
|
||||
|
||||
const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[] = [
|
||||
{ name: 'rectangle', label: 'Rectangle', icon: '▭', enabled: true },
|
||||
{ name: 'arrow', label: 'Arrow', icon: '→', enabled: false },
|
||||
{ name: 'line', label: 'Line', icon: '╱', enabled: false },
|
||||
{ name: 'text', label: 'Text', icon: 'T', enabled: false },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar d-flex flex-column align-items-center py-2 gap-2">
|
||||
<button
|
||||
v-for="tool in tools"
|
||||
:key="tool.name"
|
||||
class="btn btn-sm"
|
||||
:class="[
|
||||
store.selectedTool === tool.name ? 'btn-primary' : 'btn-outline-secondary',
|
||||
{ disabled: !tool.enabled },
|
||||
]"
|
||||
:disabled="!tool.enabled"
|
||||
:title="tool.enabled ? tool.label : `${tool.label} (coming soon)`"
|
||||
style="width: 40px; height: 40px; font-size: 1.1rem"
|
||||
@click="tool.enabled && store.selectTool(tool.name)"
|
||||
>
|
||||
{{ tool.icon }}
|
||||
</button>
|
||||
|
||||
<div class="mt-auto mb-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
title="Leave whiteboard"
|
||||
style="width: 40px; height: 40px"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
width: 56px;
|
||||
background-color: #0d0d1a;
|
||||
border-right: 1px solid #2a2a3e;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
22
front/src/components/whiteboard/shapes/SvgDraftRect.vue
Normal file
22
front/src/components/whiteboard/shapes/SvgDraftRect.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<rect
|
||||
:x="props.x"
|
||||
:y="props.y"
|
||||
:width="props.width"
|
||||
:height="props.height"
|
||||
stroke="#4f9dff"
|
||||
:stroke-width="2"
|
||||
stroke-dasharray="6 3"
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
/>
|
||||
</template>
|
||||
17
front/src/components/whiteboard/shapes/SvgRectangle.vue
Normal file
17
front/src/components/whiteboard/shapes/SvgRectangle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { Rectangle } from '@/types/whiteboard.ts'
|
||||
|
||||
const props = defineProps<{ rectangle: Rectangle }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<rect
|
||||
:x="Math.min(props.rectangle.position.x, props.rectangle.endPosition.x)"
|
||||
:y="Math.min(props.rectangle.position.y, props.rectangle.endPosition.y)"
|
||||
:width="Math.abs(props.rectangle.endPosition.x - props.rectangle.position.x)"
|
||||
:height="Math.abs(props.rectangle.endPosition.y - props.rectangle.position.y)"
|
||||
:stroke="props.rectangle.color"
|
||||
:stroke-width="props.rectangle.borderThickness"
|
||||
fill="none"
|
||||
/>
|
||||
</template>
|
||||
@@ -35,6 +35,12 @@ const router = createRouter({
|
||||
component: () => import('../views/SignupView.vue'),
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/whiteboard/:id',
|
||||
name: 'whiteboard',
|
||||
component: () => import('../views/WhiteboardView.vue'),
|
||||
meta: { requiresAuth: true, hideTopBar: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
HubConnectionBuilder,
|
||||
HubConnectionState,
|
||||
} from "@microsoft/signalr";
|
||||
import {useAuthStore} from "@/stores/auth.ts";
|
||||
|
||||
export class SignalRService {
|
||||
private connection: HubConnection;
|
||||
@@ -10,8 +11,12 @@ export class SignalRService {
|
||||
constructor(
|
||||
hubUrl: string,
|
||||
) {
|
||||
const authStore = useAuthStore();
|
||||
|
||||
this.connection = new HubConnectionBuilder()
|
||||
.withUrl(hubUrl)
|
||||
.withUrl(hubUrl, {
|
||||
accessTokenFactory: () => authStore.accessToken!
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {SignalRService} from "@/services/signalr.ts";
|
||||
|
||||
|
||||
const client = new SignalRService(
|
||||
`http://localhost:5039/testhub`,
|
||||
`/hubs/test`,
|
||||
);
|
||||
|
||||
export const testHubService = {
|
||||
|
||||
49
front/src/services/whiteboardHubService.ts
Normal file
49
front/src/services/whiteboardHubService.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { SignalRService } from '@/services/signalr.ts'
|
||||
import type { Rectangle, Whiteboard } from '@/types/whiteboard.ts'
|
||||
|
||||
const client = new SignalRService(`/hubs/whiteboard`)
|
||||
|
||||
export const whiteboardHubService = {
|
||||
async connect() {
|
||||
await client.start()
|
||||
},
|
||||
|
||||
async disconnect() {
|
||||
await client.stop()
|
||||
},
|
||||
|
||||
async joinWhiteboard(id: string) {
|
||||
await client.invoke('JoinWhiteboard', id)
|
||||
},
|
||||
|
||||
async leaveWhiteboard(id: string) {
|
||||
await client.invoke('LeaveWhiteboard', id)
|
||||
},
|
||||
|
||||
async addRectangle(rectangle: Rectangle) {
|
||||
await client.invoke('AddRectangle', rectangle)
|
||||
},
|
||||
|
||||
onInitWhiteboard(callback: (whiteboard: Whiteboard) => void) {
|
||||
client.on<Whiteboard>('InitWhiteboard', callback)
|
||||
},
|
||||
|
||||
onAddedRectangle(callback: (rectangle: Rectangle) => void) {
|
||||
client.on<Rectangle>('AddedRectangle', callback)
|
||||
},
|
||||
|
||||
onJoined(callback: (userId: string) => void) {
|
||||
client.on<string>('Joined', callback)
|
||||
},
|
||||
|
||||
onLeaved(callback: (userId: string) => void) {
|
||||
client.on<string>('Leaved', callback)
|
||||
},
|
||||
|
||||
offAll() {
|
||||
client.off('InitWhiteboard')
|
||||
client.off('AddedRectangle')
|
||||
client.off('Joined')
|
||||
client.off('Leaved')
|
||||
},
|
||||
}
|
||||
88
front/src/stores/whiteboard.ts
Normal file
88
front/src/stores/whiteboard.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Rectangle, ShapeTool, Whiteboard } from '@/types/whiteboard.ts'
|
||||
import { whiteboardHubService } from '@/services/whiteboardHubService.ts'
|
||||
|
||||
export const useWhiteboardStore = defineStore('whiteboard', () => {
|
||||
const whiteboard = ref<Whiteboard | null>(null)
|
||||
const selectedTool = ref<ShapeTool>('rectangle')
|
||||
const isConnected = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function joinWhiteboard(id: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await whiteboardHubService.connect()
|
||||
isConnected.value = true
|
||||
|
||||
whiteboardHubService.onInitWhiteboard((wb) => {
|
||||
whiteboard.value = wb
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
whiteboardHubService.onAddedRectangle((rectangle) => {
|
||||
whiteboard.value?.rectangles.push(rectangle)
|
||||
})
|
||||
|
||||
whiteboardHubService.onJoined((userId) => {
|
||||
console.log('User joined:', userId)
|
||||
})
|
||||
|
||||
whiteboardHubService.onLeaved((userId) => {
|
||||
console.log('User left:', userId)
|
||||
})
|
||||
|
||||
await whiteboardHubService.joinWhiteboard(id)
|
||||
} catch (e: any) {
|
||||
error.value = e?.message ?? 'Failed to join whiteboard'
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function leaveWhiteboard() {
|
||||
if (!whiteboard.value) return
|
||||
|
||||
try {
|
||||
await whiteboardHubService.leaveWhiteboard(whiteboard.value.whiteboardId)
|
||||
} catch (e) {
|
||||
console.warn('Leave request failed', e)
|
||||
}
|
||||
|
||||
whiteboardHubService.offAll()
|
||||
await whiteboardHubService.disconnect()
|
||||
|
||||
whiteboard.value = null
|
||||
isConnected.value = false
|
||||
selectedTool.value = 'rectangle'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function addRectangle(rectangle: Rectangle) {
|
||||
whiteboard.value?.rectangles.push(rectangle)
|
||||
|
||||
try {
|
||||
await whiteboardHubService.addRectangle(rectangle)
|
||||
} catch (e: any) {
|
||||
console.error('Failed to send rectangle', e)
|
||||
}
|
||||
}
|
||||
|
||||
function selectTool(tool: ShapeTool) {
|
||||
selectedTool.value = tool
|
||||
}
|
||||
|
||||
return {
|
||||
whiteboard,
|
||||
selectedTool,
|
||||
isConnected,
|
||||
isLoading,
|
||||
error,
|
||||
joinWhiteboard,
|
||||
leaveWhiteboard,
|
||||
addRectangle,
|
||||
selectTool,
|
||||
}
|
||||
})
|
||||
42
front/src/types/whiteboard.ts
Normal file
42
front/src/types/whiteboard.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Shape {
|
||||
id: string
|
||||
ownerId: string
|
||||
position: Position
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface Rectangle extends Shape {
|
||||
endPosition: Position
|
||||
borderThickness: number
|
||||
}
|
||||
|
||||
export interface Arrow extends Shape {
|
||||
endPosition: Position
|
||||
thickness: number
|
||||
}
|
||||
|
||||
export interface Line extends Shape {
|
||||
endPosition: Position
|
||||
thickness: number
|
||||
}
|
||||
|
||||
export interface TextShape extends Shape {
|
||||
textValue: string
|
||||
textSize: number
|
||||
}
|
||||
|
||||
export interface Whiteboard {
|
||||
whiteboardId: string
|
||||
ownerId: string
|
||||
rectangles: Rectangle[]
|
||||
arrows: Arrow[]
|
||||
lines: Line[]
|
||||
textShapes: TextShape[]
|
||||
}
|
||||
|
||||
export type ShapeTool = 'rectangle' | 'arrow' | 'line' | 'text'
|
||||
@@ -1,13 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {testHubService} from "@/services/testHubService.ts";
|
||||
|
||||
const router = useRouter();
|
||||
const displayText = ref("");
|
||||
const whiteboardId = ref("");
|
||||
|
||||
function addText(textToAdd: string): void {
|
||||
displayText.value = displayText.value + textToAdd;
|
||||
}
|
||||
|
||||
function joinWhiteboard() {
|
||||
const id = whiteboardId.value.trim();
|
||||
if (!id) return;
|
||||
router.push(`/whiteboard/${id}`);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await testHubService.connect();
|
||||
|
||||
@@ -19,4 +28,19 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<h1>{{ displayText }}</h1>
|
||||
|
||||
<div class="mt-4" style="max-width: 500px">
|
||||
<div class="input-group">
|
||||
<input
|
||||
v-model="whiteboardId"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Whiteboard ID (GUID)"
|
||||
@keyup.enter="joinWhiteboard"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="joinWhiteboard">
|
||||
Join Whiteboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
45
front/src/views/WhiteboardView.vue
Normal file
45
front/src/views/WhiteboardView.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import WhiteboardToolbar from '@/components/whiteboard/WhiteboardToolbar.vue'
|
||||
import WhiteboardCanvas from '@/components/whiteboard/WhiteboardCanvas.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useWhiteboardStore()
|
||||
|
||||
const whiteboardId = route.params.id as string
|
||||
|
||||
onMounted(() => {
|
||||
store.joinWhiteboard(whiteboardId)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.leaveWhiteboard()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await store.leaveWhiteboard()
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.isLoading" class="d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="d-flex vh-100">
|
||||
<WhiteboardToolbar @leave="handleLeave" />
|
||||
<WhiteboardCanvas />
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +21,11 @@ export default defineConfig({
|
||||
'/api': {
|
||||
target: 'http://localhost:5266',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/hubs/': {
|
||||
target: 'http://localhost:5039',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user