Merge branch 'main' into feature-whiteboards-recent-and-history

# Conflicts:
#	front/src/App.vue
This commit is contained in:
Veljko Tosic
2026-02-16 18:20:35 +01:00
38 changed files with 989 additions and 13 deletions

View File

@@ -0,0 +1,6 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Models.Shape.Command.CreateRectangle;
namespace AipsCore.Application.Common.Message.AddRectangle;
public record AddRectangleMessage(CreateRectangleCommand Command) : IMessage;

View File

@@ -0,0 +1,19 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking;
namespace AipsCore.Application.Common.Message.AddRectangle;
public class AddRectangleMessageHandler : IMessageHandler<AddRectangleMessage>
{
private readonly IDispatcher _dispatcher;
public AddRectangleMessageHandler(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task Handle(AddRectangleMessage message, CancellationToken cancellationToken)
{
await _dispatcher.Execute(message.Command, cancellationToken);
}
}

View File

@@ -4,6 +4,7 @@ using AipsCore.Domain.Models.Shape.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateRectangle; namespace AipsCore.Application.Models.Shape.Command.CreateRectangle;
public record CreateRectangleCommand( public record CreateRectangleCommand(
string Id,
string WhiteboardId, string WhiteboardId,
string AuthorId, string AuthorId,
int PositionX, int PositionX,
@@ -11,4 +12,4 @@ public record CreateRectangleCommand(
string Color, string Color,
int EndPositionX, int EndPositionX,
int EndPositionY, int EndPositionY,
int BorderThickness) : ICommand<ShapeId>; int BorderThickness) : ICommand;

View File

@@ -13,7 +13,7 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
namespace AipsCore.Application.Models.Shape.Command.CreateRectangle; namespace AipsCore.Application.Models.Shape.Command.CreateRectangle;
public class CreateRectangleCommandHandler : ICommandHandler<CreateRectangleCommand, ShapeId> public class CreateRectangleCommandHandler : ICommandHandler<CreateRectangleCommand>
{ {
private readonly IShapeRepository _shapeRepository; private readonly IShapeRepository _shapeRepository;
private readonly IWhiteboardRepository _whiteboardRepository; private readonly IWhiteboardRepository _whiteboardRepository;
@@ -28,11 +28,12 @@ public class CreateRectangleCommandHandler : ICommandHandler<CreateRectangleComm
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
} }
public async Task<ShapeId> Handle(CreateRectangleCommand command, CancellationToken cancellationToken = default) public async Task Handle(CreateRectangleCommand command, CancellationToken cancellationToken = default)
{ {
Validate(command); Validate(command);
var rectangle = Rectangle.Create( var rectangle = Rectangle.Create(
command.Id,
command.WhiteboardId, command.WhiteboardId,
command.AuthorId, command.AuthorId,
command.PositionX, command.PositionY, command.PositionX, command.PositionY,
@@ -43,8 +44,6 @@ public class CreateRectangleCommandHandler : ICommandHandler<CreateRectangleComm
await _shapeRepository.SaveAsync(rectangle, cancellationToken); await _shapeRepository.SaveAsync(rectangle, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return rectangle.Id;
} }
private void Validate(CreateRectangleCommand command) private void Validate(CreateRectangleCommand command)

View File

@@ -0,0 +1,5 @@
using AipsCore.Application.Abstract.Query;
namespace AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardInfoRT;
public record GetWhiteboardInfoRTQuery(Guid WhiteboardId) : IQuery<Infrastructure.Persistence.Whiteboard.Whiteboard>;

View File

@@ -0,0 +1,40 @@
using AipsCore.Application.Abstract.Query;
using AipsCore.Domain.Common.Validation;
using AipsCore.Domain.Models.Whiteboard.Validation;
using AipsCore.Domain.Models.Whiteboard.ValueObjects;
using AipsCore.Infrastructure.Persistence.Db;
using Microsoft.EntityFrameworkCore;
namespace AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardInfoRT;
public class GetWhiteboardInfoRTQueryHandler
: IQueryHandler<GetWhiteboardInfoRTQuery, Infrastructure.Persistence.Whiteboard.Whiteboard>
{
private readonly AipsDbContext _context;
public GetWhiteboardInfoRTQueryHandler(AipsDbContext context)
{
_context = context;
}
public async Task<Infrastructure.Persistence.Whiteboard.Whiteboard> Handle(GetWhiteboardInfoRTQuery query, CancellationToken cancellationToken = default)
{
var whiteboard = await GetQuery(query.WhiteboardId).FirstOrDefaultAsync(cancellationToken);
if (whiteboard is null)
{
throw new ValidationException(WhiteboardErrors.NotFound(new WhiteboardId(query.WhiteboardId.ToString())));
}
return whiteboard;
}
private IQueryable<Infrastructure.Persistence.Whiteboard.Whiteboard> GetQuery(Guid whiteboardId)
{
return _context.Whiteboards
.Where(w => w.Id == whiteboardId)
.Include(w => w.Memberships)
.Include(w => w.Owner)
.Include(w => w.Shapes);
}
}

View File

@@ -30,6 +30,7 @@ public class RabbitMqSubscriber : IMessageSubscriber
await channel.QueueDeclareAsync( await channel.QueueDeclareAsync(
queue: GetQueueName<T>(), queue: GetQueueName<T>(),
autoDelete: false,
durable: true); durable: true);
await channel.QueueBindAsync( await channel.QueueBindAsync(

View File

@@ -6,4 +6,16 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AipsCore\AipsCore.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DotNetEnv" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="Hubs\Dtos\" />
</ItemGroup>
</Project> </Project>

View File

@@ -1,9 +1,19 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace AipsRT.Hubs; namespace AipsRT.Hubs;
[Authorize]
public class TestHub : Hub public class TestHub : Hub
{ {
public override async Task OnConnectedAsync()
{
Console.WriteLine($"LOOOOOOOOOG: [{Context.UserIdentifier}] User identifier connected");
Console.WriteLine($"LOOOOOG222: [{Context.User?.Identity?.Name}] User identity name connected");
await base.OnConnectedAsync();
}
public async Task SendText(string text) public async Task SendText(string text)
{ {
await Clients.All.SendAsync("ReceiveText", text); await Clients.All.SendAsync("ReceiveText", text);

View File

@@ -0,0 +1,57 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsRT.Model.Whiteboard;
using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace AipsRT.Hubs;
[Authorize]
public class WhiteboardHub : Hub
{
private readonly WhiteboardManager _whiteboardManager;
private readonly IMessagingService _messagingService;
public WhiteboardHub(WhiteboardManager whiteboardManager, IMessagingService messagingService)
{
_whiteboardManager = whiteboardManager;
_messagingService = messagingService;
}
public async Task JoinWhiteboard(Guid whiteboardId)
{
if (!_whiteboardManager.WhiteboardExists(whiteboardId))
await _whiteboardManager.AddWhiteboard(whiteboardId);
await Groups.AddToGroupAsync(Context.ConnectionId, whiteboardId.ToString());
var state = _whiteboardManager.GetWhiteboard(whiteboardId)!;
_whiteboardManager.AddUserToWhiteboard(Guid.Parse(Context.UserIdentifier!), whiteboardId);
await Clients.Caller.SendAsync("InitWhiteboard", state);
await Clients.GroupExcept(whiteboardId.ToString(), Context.ConnectionId)
.SendAsync("Joined", Context.UserIdentifier!);
}
public async Task LeaveWhiteboard(Guid whiteboardId)
{
await Clients.GroupExcept(whiteboardId.ToString(), Context.ConnectionId)
.SendAsync("Leaved", Context.UserIdentifier!);
}
public async Task AddRectangle(Rectangle rectangle)
{
var whiteboard = _whiteboardManager.GetWhiteboardForUser(Guid.Parse(Context.UserIdentifier!))!;
rectangle.OwnerId = Guid.Parse(Context.UserIdentifier!);
await _messagingService.CreatedRectangle(whiteboard.WhiteboardId, rectangle);
whiteboard.AddRectangle(rectangle);
await Clients.GroupExcept(whiteboard.WhiteboardId.ToString(), Context.ConnectionId)
.SendAsync("AddedRectangle", rectangle);
}
}

View File

@@ -0,0 +1,52 @@
using AipsCore.Application.Abstract;
using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardInfoRT;
using AipsCore.Domain.Models.Shape.Enums;
using AipsRT.Model.Whiteboard.Shapes.Map;
namespace AipsRT.Model.Whiteboard;
public class GetWhiteboardService
{
private readonly IDispatcher _dispatcher;
public GetWhiteboardService(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
public async Task<Whiteboard> GetWhiteboard(Guid whiteboardId)
{
var query = new GetWhiteboardInfoRTQuery(whiteboardId);
return Map(await _dispatcher.Execute(query));
}
private static Whiteboard Map(AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard entity)
{
var whiteboard = new Whiteboard()
{
WhiteboardId = entity.Id,
OwnerId = entity.OwnerId,
};
foreach (var shape in entity.Shapes)
{
switch (shape.Type)
{
case ShapeType.Rectangle:
whiteboard.AddRectangle(shape.ToRectangle());
break;
case ShapeType.Arrow:
whiteboard.AddArrow(shape.ToArrow());
break;
case ShapeType.Line:
whiteboard.AddLine(shape.ToLine());
break;
case ShapeType.Text:
whiteboard.AddTextShape(shape.ToTextShape());
break;
}
}
return whiteboard;
}
}

View File

@@ -0,0 +1,10 @@
using AipsRT.Model.Whiteboard.Structs;
namespace AipsRT.Model.Whiteboard.Shapes;
public class Arrow : Shape
{
public Position EndPosition { get; set; }
public int Thickness { get; set; }
}

View File

@@ -0,0 +1,10 @@
using AipsRT.Model.Whiteboard.Structs;
namespace AipsRT.Model.Whiteboard.Shapes;
public class Line : Shape
{
public Position EndPosition { get; set; }
public int Thickness { get; set; }
}

View File

@@ -0,0 +1,57 @@
using AipsRT.Model.Whiteboard.Structs;
namespace AipsRT.Model.Whiteboard.Shapes.Map;
public static class ShapeMappingExtensions
{
extension(AipsCore.Infrastructure.Persistence.Shape.Shape shape)
{
public Rectangle ToRectangle()
{
return new Rectangle()
{
Id = shape.Id,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
BorderThickness = shape.Thickness!.Value,
};
}
public Arrow ToArrow()
{
return new Arrow()
{
Id = shape.Id,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
Thickness = shape.Thickness!.Value,
};
}
public Line ToLine()
{
return new Line()
{
Id = shape.Id,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
EndPosition = new Position(shape.EndPositionX!.Value, shape.EndPositionY!.Value),
Thickness = shape.Thickness!.Value,
};
}
public TextShape ToTextShape()
{
return new TextShape()
{
Id = shape.Id,
Position = new Position(shape.PositionX, shape.PositionY),
Color = shape.Color,
TextValue = shape.TextValue!,
TextSize = shape.TextSize!.Value
};
}
}
}

View File

@@ -0,0 +1,10 @@
using AipsRT.Model.Whiteboard.Structs;
namespace AipsRT.Model.Whiteboard.Shapes;
public class Rectangle : Shape
{
public Position EndPosition { get; set; }
public int BorderThickness { get; set; }
}

View File

@@ -0,0 +1,14 @@
using AipsRT.Model.Whiteboard.Structs;
namespace AipsRT.Model.Whiteboard.Shapes;
public abstract class Shape
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public Position Position { get; set; }
public string Color { get; set; }
}

View File

@@ -0,0 +1,10 @@
using AipsRT.Model.Whiteboard.Shapes;
namespace AipsRT.Model.Whiteboard.Shapes;
public class TextShape : Shape
{
public string TextValue { get; set; }
public int TextSize { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace AipsRT.Model.Whiteboard.Structs;
public struct Position
{
public int X { get; set; }
public int Y { get; set; }
public Position(int x, int y)
{
X = x;
Y = y;
}
}

View File

@@ -0,0 +1,41 @@
using AipsRT.Model.Whiteboard.Shapes;
namespace AipsRT.Model.Whiteboard;
public class Whiteboard
{
public Guid WhiteboardId { get; set; }
public Guid OwnerId { get; set; }
public List<Shape> Shapes { get; } = [];
public List<Rectangle> Rectangles { get; } = [];
public List<Arrow> Arrows { get; } = [];
public List<Line> Lines { get; } = [];
public List<TextShape> TextShapes { get; } = [];
public void AddRectangle(Rectangle shape)
{
Shapes.Add(shape);
Rectangles.Add(shape);
}
public void AddArrow(Arrow shape)
{
Shapes.Add(shape);
Arrows.Add(shape);
}
public void AddLine(Line shape)
{
Shapes.Add(shape);
Lines.Add(shape);
}
public void AddTextShape(TextShape shape)
{
Shapes.Add(shape);
TextShapes.Add(shape);
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using AipsCore.Application.Abstract;
namespace AipsRT.Model.Whiteboard;
public class WhiteboardManager
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ConcurrentDictionary<Guid, Whiteboard> _whiteboards = new();
private readonly ConcurrentDictionary<Guid, Guid> _userInWhiteboards = new();
public WhiteboardManager(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task AddWhiteboard(Guid whiteboardId)
{
var getWhiteboardService = _scopeFactory.CreateScope().ServiceProvider.GetRequiredService<GetWhiteboardService>();
var whiteboard = await getWhiteboardService.GetWhiteboard(whiteboardId);
_whiteboards[whiteboardId] = whiteboard;
}
public bool WhiteboardExists(Guid whiteboardId)
{
return _whiteboards.ContainsKey(whiteboardId);
}
public void RemoveWhiteboard(Guid whiteboardId)
{
_whiteboards.TryRemove(whiteboardId, out _);
}
public Whiteboard? GetWhiteboard(Guid whiteboardId)
{
return _whiteboards.GetValueOrDefault(whiteboardId);
}
public void AddUserToWhiteboard(Guid userId, Guid whiteboardId)
{
_userInWhiteboards[userId] = whiteboardId;
}
public Guid GetUserWhiteboard(Guid userId)
{
return _userInWhiteboards[userId];
}
public void RemoveUserFromWhiteboard(Guid userId, Guid whiteboardId)
{
_userInWhiteboards.TryRemove(whiteboardId, out _);
}
public Whiteboard? GetWhiteboardForUser(Guid userId)
{
return GetWhiteboard(GetUserWhiteboard(userId));
}
}

View File

@@ -1,10 +1,25 @@
using AipsCore.Infrastructure.DI;
using AipsRT.Hubs; using AipsRT.Hubs;
using AipsRT.Model.Whiteboard;
using AipsRT.Services;
using AipsRT.Services.Interfaces;
using DotNetEnv;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
Env.Load("../../.env");
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddAips(builder.Configuration);
builder.Services.AddScoped<GetWhiteboardService>();
builder.Services.AddSingleton<WhiteboardManager>();
builder.Services.AddSingleton<IMessagingService, MessagingService>();
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("frontend", options.AddPolicy("frontend",
@@ -26,6 +41,11 @@ app.MapGet("/test", (IHubContext<TestHub> hubContext) =>
}); });
app.UseCors("frontend"); app.UseCors("frontend");
app.MapHub<TestHub>("/testhub");
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<TestHub>("/hubs/test");
app.MapHub<WhiteboardHub>("/hubs/whiteboard");
app.Run(); app.Run();

View File

@@ -0,0 +1,8 @@
using AipsRT.Model.Whiteboard.Shapes;
namespace AipsRT.Services.Interfaces;
public interface IMessagingService
{
Task CreatedRectangle(Guid whiteboardId, Rectangle rectangle);
}

View File

@@ -0,0 +1,36 @@
using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Common.Message.AddRectangle;
using AipsCore.Application.Models.Shape.Command.CreateRectangle;
using AipsRT.Model.Whiteboard.Shapes;
using AipsRT.Services.Interfaces;
namespace AipsRT.Services;
public class MessagingService : IMessagingService
{
private readonly IMessagePublisher _messagePublisher;
public MessagingService(IMessagePublisher messagePublisher)
{
_messagePublisher = messagePublisher;
}
public async Task CreatedRectangle(Guid whiteboardId, Rectangle rectangle)
{
var command = new CreateRectangleCommand(
rectangle.Id.ToString(),
whiteboardId.ToString(),
rectangle.OwnerId.ToString(),
rectangle.Position.X,
rectangle.Position.Y,
rectangle.Color,
rectangle.EndPosition.X,
rectangle.EndPosition.Y,
rectangle.BorderThickness
);
var message = new AddRectangleMessage(command);
await _messagePublisher.PublishAsync(message);
}
}

View File

@@ -2,6 +2,7 @@ using System.Reflection;
using AipsCore.Application.Abstract; using AipsCore.Application.Abstract;
using AipsCore.Application.Abstract.MessageBroking; using AipsCore.Application.Abstract.MessageBroking;
using AipsCore.Application.Common.Message.TestMessage; using AipsCore.Application.Common.Message.TestMessage;
using AipsCore.Domain.Common.Validation;
using AipsWorker.Utilities; using AipsWorker.Utilities;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -47,8 +48,27 @@ public class WorkerService : BackgroundService
} }
private async Task HandleMessage<T>(T message, CancellationToken ct) where T : IMessage private async Task HandleMessage<T>(T message, CancellationToken ct) where T : IMessage
{
try
{ {
await _dispatcher.Execute(message, ct); await _dispatcher.Execute(message, ct);
Console.WriteLine($"OK: {message.GetType().Name}");
}
catch (ValidationException validationException)
{
Console.WriteLine("===Validation Exception: ");
foreach (var error in validationException.ValidationErrors)
{
Console.WriteLine(" * Code: " + error.Code);
Console.WriteLine(" * Message: " + error.Message);
Console.WriteLine("===================");
}
}
catch (Exception ex)
{
Console.WriteLine("Unhandled Exception: " + ex.Message);
}
} }
private MethodInfo GetMessageHandleMethod(Type messageType) private MethodInfo GetMessageHandleMethod(Type messageType)

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' 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 AppTopBar from './components/AppTopBar.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -11,12 +12,20 @@ onMounted(() => {
auth.initialize() auth.initialize()
}) })
const route = useRoute()
const hideTopBar = computed(() => route.meta.hideTopBar === true)
</script> </script>
<template> <template>
<template v-if="hideTopBar">
<RouterView />
</template>
<template v-else>
<AppTopBar /> <AppTopBar />
<main class="container py-4"> <main class="container py-4">
<RouterView /> <RouterView />
</main> </main>
</template> </template>
</template>

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

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

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

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

View File

@@ -35,6 +35,12 @@ const router = createRouter({
component: () => import('../views/SignupView.vue'), component: () => import('../views/SignupView.vue'),
meta: { guestOnly: true }, meta: { guestOnly: true },
}, },
{
path: '/whiteboard/:id',
name: 'whiteboard',
component: () => import('../views/WhiteboardView.vue'),
meta: { requiresAuth: true, hideTopBar: true },
},
], ],
}) })

View File

@@ -3,6 +3,7 @@ import {
HubConnectionBuilder, HubConnectionBuilder,
HubConnectionState, HubConnectionState,
} from "@microsoft/signalr"; } from "@microsoft/signalr";
import {useAuthStore} from "@/stores/auth.ts";
export class SignalRService { export class SignalRService {
private connection: HubConnection; private connection: HubConnection;
@@ -10,8 +11,12 @@ export class SignalRService {
constructor( constructor(
hubUrl: string, hubUrl: string,
) { ) {
const authStore = useAuthStore();
this.connection = new HubConnectionBuilder() this.connection = new HubConnectionBuilder()
.withUrl(hubUrl) .withUrl(hubUrl, {
accessTokenFactory: () => authStore.accessToken!
})
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
} }

View File

@@ -2,7 +2,7 @@ import {SignalRService} from "@/services/signalr.ts";
const client = new SignalRService( const client = new SignalRService(
`http://localhost:5039/testhub`, `/hubs/test`,
); );
export const testHubService = { export const testHubService = {

View 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')
},
}

View 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,
}
})

View 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'

View File

@@ -1,13 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref} from "vue"; import {onMounted, ref} from "vue";
import {useRouter} from "vue-router";
import {testHubService} from "@/services/testHubService.ts"; import {testHubService} from "@/services/testHubService.ts";
const router = useRouter();
const displayText = ref(""); const displayText = ref("");
const whiteboardId = ref("");
function addText(textToAdd: string): void { function addText(textToAdd: string): void {
displayText.value = displayText.value + textToAdd; displayText.value = displayText.value + textToAdd;
} }
function joinWhiteboard() {
const id = whiteboardId.value.trim();
if (!id) return;
router.push(`/whiteboard/${id}`);
}
onMounted(async () => { onMounted(async () => {
await testHubService.connect(); await testHubService.connect();
@@ -19,4 +28,19 @@ onMounted(async () => {
<template> <template>
<h1>{{ displayText }}</h1> <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> </template>

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

View File

@@ -21,6 +21,11 @@ export default defineConfig({
'/api': { '/api': {
target: 'http://localhost:5266', target: 'http://localhost:5266',
changeOrigin: true changeOrigin: true
},
'/hubs/': {
target: 'http://localhost:5039',
changeOrigin: true,
ws: true
} }
} }
}, },