Compare commits
10 Commits
c4ab8aa53e
...
94ec4e7135
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94ec4e7135 | ||
| ff7c0d58a7 | |||
| 3610e0cb7d | |||
|
|
7030e76c03 | ||
|
|
83db13ef53 | ||
|
|
7177446470 | ||
|
|
2a3e971f41 | ||
|
|
258e190bb2 | ||
|
|
7f4a7c034f | ||
|
|
c99aaa1062 |
@@ -0,0 +1,5 @@
|
||||
using AipsCore.Application.Abstract.Command;
|
||||
|
||||
namespace AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard;
|
||||
|
||||
public record DeleteWhiteboardCommand(string WhiteboardId) : ICommand;
|
||||
@@ -0,0 +1,48 @@
|
||||
using AipsCore.Application.Abstract.Command;
|
||||
using AipsCore.Application.Abstract.UserContext;
|
||||
using AipsCore.Domain.Abstract;
|
||||
using AipsCore.Domain.Abstract.Validation;
|
||||
using AipsCore.Domain.Common.Validation;
|
||||
using AipsCore.Domain.Models.Whiteboard.External;
|
||||
using AipsCore.Domain.Models.Whiteboard.Validation;
|
||||
using AipsCore.Domain.Models.Whiteboard.ValueObjects;
|
||||
|
||||
namespace AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard;
|
||||
|
||||
public class DeleteWhiteboardCommandHandler : ICommandHandler<DeleteWhiteboardCommand>
|
||||
{
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly IWhiteboardRepository _whiteboardRepository;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public DeleteWhiteboardCommandHandler(
|
||||
IUserContext userContext,
|
||||
IWhiteboardRepository whiteboardRepository,
|
||||
IUnitOfWork unitOfWork)
|
||||
{
|
||||
_userContext = userContext;
|
||||
_whiteboardRepository = whiteboardRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public async Task Handle(DeleteWhiteboardCommand command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var whiteboardId = new WhiteboardId(command.WhiteboardId);
|
||||
var userId = _userContext.GetCurrentUserId();
|
||||
|
||||
var whiteboard = await _whiteboardRepository.GetByIdAsync(whiteboardId, cancellationToken);
|
||||
|
||||
if (whiteboard is null)
|
||||
{
|
||||
throw new ValidationException(WhiteboardErrors.NotFound(whiteboardId));
|
||||
}
|
||||
|
||||
if (!whiteboard.CanUserDelete(userId))
|
||||
{
|
||||
throw new ValidationException(WhiteboardErrors.OnlyOwnerCanDeleteWhiteboard(userId));
|
||||
}
|
||||
|
||||
await _whiteboardRepository.SoftDeleteAsync(whiteboardId, cancellationToken);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using AipsCore.Application.Abstract.Query;
|
||||
using AipsCore.Application.Abstract.UserContext;
|
||||
using AipsCore.Domain.Models.Whiteboard.Enums;
|
||||
using AipsCore.Infrastructure.Persistence.Db;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -32,7 +33,8 @@ public class GetRecentWhiteboardsQueryHandler : IQueryHandler<GetRecentWhiteboar
|
||||
.Where(m => (
|
||||
m.UserId == userIdGuid &&
|
||||
m.IsBanned == false &&
|
||||
m.Whiteboard != null
|
||||
m.Whiteboard != null &&
|
||||
m.Whiteboard.State != WhiteboardState.Deleted
|
||||
))
|
||||
.OrderByDescending(m => m.LastInteractedAt)
|
||||
.Select(m => m.Whiteboard!);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AipsCore.Application.Abstract.Query;
|
||||
using AipsCore.Application.Abstract.UserContext;
|
||||
using AipsCore.Domain.Models.Whiteboard.Enums;
|
||||
using AipsCore.Infrastructure.Persistence.Db;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -22,7 +23,7 @@ public class GetWhiteboardHistoryQueryHandler
|
||||
var userIdGuid = new Guid(_userContext.GetCurrentUserId().IdValue);
|
||||
|
||||
return await _context.Whiteboards
|
||||
.Where(w => w.OwnerId == userIdGuid)
|
||||
.Where(w => w.OwnerId == userIdGuid && w.State != WhiteboardState.Deleted)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using AipsCore.Domain.Common.ValueObjects;
|
||||
|
||||
namespace AipsCore.Domain.Abstract;
|
||||
|
||||
public interface ISoftDeletableRepository<in TId> where TId : DomainId
|
||||
{
|
||||
Task SoftDeleteAsync(TId id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ using AipsCore.Domain.Models.Whiteboard.ValueObjects;
|
||||
|
||||
namespace AipsCore.Domain.Models.Whiteboard.External;
|
||||
|
||||
public interface IWhiteboardRepository : IAbstractRepository<Whiteboard, WhiteboardId>
|
||||
public interface IWhiteboardRepository
|
||||
: IAbstractRepository<Whiteboard, WhiteboardId>, ISoftDeletableRepository<WhiteboardId>
|
||||
{
|
||||
Task<bool> WhiteboardCodeExists(WhiteboardCode whiteboardCode);
|
||||
}
|
||||
@@ -38,4 +38,12 @@ public class WhiteboardErrors : AbstractErrors<Whiteboard, WhiteboardId>
|
||||
|
||||
return CreateValidationError(code, message);
|
||||
}
|
||||
|
||||
public static ValidationError OnlyOwnerCanDeleteWhiteboard(UserId currentUserId)
|
||||
{
|
||||
string code = "only_owner_can_delete_whiteboard";
|
||||
string message = $"Only owner of whiteboard can delete the whiteboard. Current user id: '{currentUserId}' is not the owner.";
|
||||
|
||||
return CreateValidationError(code, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using AipsCore.Domain.Models.User.ValueObjects;
|
||||
|
||||
namespace AipsCore.Domain.Models.Whiteboard;
|
||||
|
||||
public partial class Whiteboard
|
||||
{
|
||||
public bool CanUserDelete(UserId userId)
|
||||
{
|
||||
return WhiteboardOwnerId.IdValue == userId.IdValue;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using AipsCore.Domain.Models.Whiteboard.Enums;
|
||||
using AipsCore.Domain.Models.Whiteboard.External;
|
||||
using AipsCore.Domain.Models.Whiteboard.ValueObjects;
|
||||
using AipsCore.Infrastructure.Persistence.Abstract;
|
||||
@@ -61,4 +62,16 @@ public class WhiteboardRepository
|
||||
{
|
||||
return await Context.Whiteboards.AnyAsync(w => w.Code == whiteboardCode.CodeValue);
|
||||
}
|
||||
|
||||
public async Task SoftDeleteAsync(WhiteboardId id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await Context.Whiteboards.FindAsync([new Guid(id.IdValue)], cancellationToken);
|
||||
|
||||
if (entity != null)
|
||||
{
|
||||
entity.State = WhiteboardState.Deleted;
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
Context.Whiteboards.Update(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ public class Arrow : Shape
|
||||
|
||||
public override void Move(Position newPosition)
|
||||
{
|
||||
var difference = newPosition - EndPosition;
|
||||
var difference = newPosition - Position;
|
||||
EndPosition += difference;
|
||||
base.Move(newPosition);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ public class Line : Shape
|
||||
|
||||
public override void Move(Position newPosition)
|
||||
{
|
||||
var difference = newPosition - EndPosition;
|
||||
var difference = newPosition - Position;
|
||||
EndPosition += difference;
|
||||
base.Move(newPosition);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AipsCore.Application.Abstract;
|
||||
using AipsCore.Application.Models.Whiteboard.Command.CreateWhiteboard;
|
||||
using AipsCore.Application.Models.Whiteboard.Command.DeleteWhiteboard;
|
||||
using AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
||||
using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboard;
|
||||
using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardHistory;
|
||||
@@ -41,6 +42,15 @@ public class WhiteboardController : ControllerBase
|
||||
return Ok(whiteboard);
|
||||
}
|
||||
|
||||
[Authorize]
|
||||
[HttpDelete("{whiteboardId}")]
|
||||
public async Task<ActionResult> DeleteWhiteboard([FromRoute] string whiteboardId, CancellationToken cancellationToken)
|
||||
{
|
||||
await _dispatcher.Execute(new DeleteWhiteboardCommand(whiteboardId), cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
[Authorize]
|
||||
[HttpGet("history")]
|
||||
public async Task<ActionResult<ICollection<Whiteboard>>> GetWhiteboardHistory(CancellationToken cancellationToken)
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"pinia": "^3.0.4",
|
||||
"signalr": "^2.4.3",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1",
|
||||
},
|
||||
@@ -398,6 +398,8 @@
|
||||
|
||||
"bootstrap": ["bootstrap@5.3.8", "", { "peerDependencies": { "@popperjs/core": "^2.11.8" } }, "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg=="],
|
||||
|
||||
"bootstrap-icons": ["bootstrap-icons@1.13.1", "", {}, "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
@@ -548,8 +550,6 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jquery": ["jquery@4.0.0", "", {}, "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
@@ -742,8 +742,6 @@
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"signalr": ["signalr@2.4.3", "", { "dependencies": { "jquery": ">=1.6.4" } }, "sha512-RbBKFVCZvDgyyxZDeu6Yck9T+diZO07GB0bDiKondUhBY1H8JRQSOq8R0pLkf47ddllQAssYlp7ckQAeom24mw=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.1"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@import 'variables';
|
||||
@import 'bootstrap/scss/bootstrap';
|
||||
@import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
@import 'app';
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import type { Whiteboard } from '@/types'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{ whiteboard: Whiteboard }>()
|
||||
const emit = defineEmits<{ click: [whiteboard: Whiteboard] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', whiteboard: Whiteboard): void
|
||||
(e: 'delete', whiteboard: Whiteboard): void
|
||||
}>()
|
||||
|
||||
const showConfirm = ref(false)
|
||||
|
||||
const formatDate = (date: string | Date) =>
|
||||
new Date(date).toLocaleDateString(
|
||||
@@ -11,18 +18,89 @@ const formatDate = (date: string | Date) =>
|
||||
)
|
||||
|
||||
const handleClick = () => emit('click', props.whiteboard)
|
||||
|
||||
const handleDeleteClick = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
showConfirm.value = true
|
||||
}
|
||||
|
||||
const handleCancel = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
showConfirm.value = false
|
||||
}
|
||||
|
||||
const handleConfirmDelete = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
showConfirm.value = false
|
||||
emit('delete', props.whiteboard)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div
|
||||
class="card border rounded-3 p-3 cursor-pointer hover-card"
|
||||
@click="handleClick"
|
||||
>
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h5 class="mb-0 text-dark">{{ whiteboard.title }}</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<small class="text-muted">{{ formatDate(whiteboard.createdAt) }}</small>
|
||||
<button
|
||||
class="btn btn-link p-0 text-danger"
|
||||
title="Delete whiteboard"
|
||||
@click="handleDeleteClick"
|
||||
>
|
||||
<i class="bi bi-trash fs-5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showConfirm"
|
||||
class="modal d-block"
|
||||
tabindex="-1"
|
||||
style="background: rgba(0,0,0,0.5)"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title">Delete Whiteboard</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
@click="handleCancel"
|
||||
></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Are you sure you want to delete this whiteboard?
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
@click="handleCancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger"
|
||||
@click="handleConfirmDelete"
|
||||
>
|
||||
Yes, delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useWhiteboardsStore } from '@/stores/whiteboards'
|
||||
import WhiteboardHistoryItem from './WhiteboardHistoryItem.vue'
|
||||
import type {Whiteboard} from "@/types";
|
||||
|
||||
const router = useRouter()
|
||||
const store = useWhiteboardsStore()
|
||||
@@ -20,6 +21,11 @@ const sortedWhiteboards = computed(() =>
|
||||
const handleClick = (whiteboard: any) => {
|
||||
router.push({ name: 'whiteboard', params: { id: whiteboard.id } })
|
||||
}
|
||||
|
||||
const handleDelete = async (whiteboard: Whiteboard) => {
|
||||
await store.deleteWhiteboard(whiteboard.id)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -29,6 +35,7 @@ const handleClick = (whiteboard: any) => {
|
||||
:key="wb.id"
|
||||
:whiteboard="wb"
|
||||
@click="handleClick"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-3 overflow-auto h-100 w-100 p-3" v-else>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {computed, ref} from 'vue'
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import { useWhiteboardsStore } from "@/stores/whiteboards.ts";
|
||||
import type { ShapeTool, Arrow, Line, Rectangle, TextShape } from '@/types/whiteboard.ts'
|
||||
|
||||
const store = useWhiteboardStore()
|
||||
const sessionStore = useWhiteboardStore()
|
||||
const infoStore = useWhiteboardsStore()
|
||||
const emit = defineEmits<{ leave: [] }>()
|
||||
|
||||
const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[] = [
|
||||
@@ -16,43 +18,60 @@ const tools: { name: ShapeTool; label: string; icon: string; enabled: boolean }[
|
||||
|
||||
const colors = ['#4f9dff', '#ff4f4f', '#4fff4f', '#ffff4f', '#ff4fff', '#ffffff', '#ff9f4f', '#4fffff']
|
||||
|
||||
const isReadOnly = computed(() => store.selectedTool === 'hand' && !!store.selectedShape)
|
||||
const isReadOnly = computed(() => sessionStore.selectedTool === 'hand' && !!sessionStore.selectedShape)
|
||||
|
||||
const showProperties = computed(() => {
|
||||
if (['rectangle', 'arrow', 'line', 'text'].includes(store.selectedTool)) return true
|
||||
if (store.selectedTool === 'hand' && store.selectedShape) return true
|
||||
if (['rectangle', 'arrow', 'line', 'text'].includes(sessionStore.selectedTool)) return true
|
||||
if (sessionStore.selectedTool === 'hand' && sessionStore.selectedShape) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const showThickness = computed(() => {
|
||||
if (['rectangle', 'arrow', 'line'].includes(store.selectedTool)) return true
|
||||
if (isReadOnly.value && store.selectedShapeType && ['rectangle', 'arrow', 'line'].includes(store.selectedShapeType)) return true
|
||||
if (['rectangle', 'arrow', 'line'].includes(sessionStore.selectedTool)) return true
|
||||
if (isReadOnly.value && sessionStore.selectedShapeType && ['rectangle', 'arrow', 'line'].includes(sessionStore.selectedShapeType)) return true
|
||||
return false
|
||||
})
|
||||
|
||||
const showTextSize = computed(() => {
|
||||
if (store.selectedTool === 'text') return true
|
||||
if (isReadOnly.value && store.selectedShapeType === 'textShape') return true
|
||||
if (sessionStore.selectedTool === 'text') return true
|
||||
if (isReadOnly.value && sessionStore.selectedShapeType === 'textShape') return true
|
||||
return false
|
||||
})
|
||||
|
||||
const displayColor = computed(() => {
|
||||
if (isReadOnly.value && store.selectedShape) return store.selectedShape.color
|
||||
return store.toolColor
|
||||
if (isReadOnly.value && sessionStore.selectedShape) return sessionStore.selectedShape.color
|
||||
return sessionStore.toolColor
|
||||
})
|
||||
|
||||
const displayThickness = computed(() => {
|
||||
if (isReadOnly.value && store.selectedShape) {
|
||||
if (store.selectedShapeType === 'rectangle') return (store.selectedShape as Rectangle).borderThickness
|
||||
return (store.selectedShape as Arrow | Line).thickness
|
||||
if (isReadOnly.value && sessionStore.selectedShape) {
|
||||
if (sessionStore.selectedShapeType === 'rectangle') return (sessionStore.selectedShape as Rectangle).borderThickness
|
||||
return (sessionStore.selectedShape as Arrow | Line).thickness
|
||||
}
|
||||
return store.toolThickness
|
||||
return sessionStore.toolThickness
|
||||
})
|
||||
|
||||
const displayTextSize = computed(() => {
|
||||
if (isReadOnly.value && store.selectedShape) return (store.selectedShape as TextShape).textSize
|
||||
return store.toolTextSize
|
||||
if (isReadOnly.value && sessionStore.selectedShape) return (sessionStore.selectedShape as TextShape).textSize
|
||||
return sessionStore.toolTextSize
|
||||
})
|
||||
|
||||
const showCopiedTooltip = ref(false)
|
||||
|
||||
const whiteboardCode = computed(() => infoStore.getCurrentWhiteboard()?.code || '')
|
||||
|
||||
const copyCodeToClipboard = async () => {
|
||||
console.info(whiteboardCode.value)
|
||||
if (!whiteboardCode.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(whiteboardCode.value)
|
||||
showCopiedTooltip.value = true
|
||||
setTimeout(() => showCopiedTooltip.value = false, 1500)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -62,10 +81,10 @@ const displayTextSize = computed(() => {
|
||||
v-for="tool in tools"
|
||||
:key="tool.name"
|
||||
class="tool-btn"
|
||||
:class="{ active: store.selectedTool === tool.name, disabled: !tool.enabled }"
|
||||
:class="{ active: sessionStore.selectedTool === tool.name, disabled: !tool.enabled }"
|
||||
:disabled="!tool.enabled"
|
||||
:title="tool.enabled ? tool.label : `${tool.label} (coming soon)`"
|
||||
@click="tool.enabled && store.selectTool(tool.name)"
|
||||
@click="tool.enabled && sessionStore.selectTool(tool.name)"
|
||||
>
|
||||
{{ tool.icon }}
|
||||
</button>
|
||||
@@ -85,9 +104,9 @@ const displayTextSize = computed(() => {
|
||||
v-for="c in colors"
|
||||
:key="c"
|
||||
class="color-swatch"
|
||||
:class="{ active: store.toolColor === c }"
|
||||
:class="{ active: sessionStore.toolColor === c }"
|
||||
:style="{ backgroundColor: c }"
|
||||
@click="store.setToolColor(c)"
|
||||
@click="sessionStore.setToolColor(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,8 +120,8 @@ const displayTextSize = computed(() => {
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
:value="store.toolThickness"
|
||||
@input="store.setToolThickness(Number(($event.target as HTMLInputElement).value))"
|
||||
:value="sessionStore.toolThickness"
|
||||
@input="sessionStore.setToolThickness(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,13 +134,29 @@ const displayTextSize = computed(() => {
|
||||
min="12"
|
||||
max="72"
|
||||
step="2"
|
||||
:value="store.toolTextSize"
|
||||
@input="store.setToolTextSize(Number(($event.target as HTMLInputElement).value))"
|
||||
:value="sessionStore.toolTextSize"
|
||||
@input="sessionStore.setToolTextSize(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-footer">
|
||||
<div class="position-relative mb-2">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-primary w-100"
|
||||
@click="copyCodeToClipboard"
|
||||
:title="whiteboardCode"
|
||||
>
|
||||
{{ whiteboardCode }}
|
||||
</button>
|
||||
<div
|
||||
v-if="showCopiedTooltip"
|
||||
class="tooltip-custom position-absolute start-50 translate-middle-x"
|
||||
>
|
||||
Code copied to clipboard
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-outline-danger leave-btn"
|
||||
title="Leave whiteboard"
|
||||
@@ -227,4 +262,17 @@ const displayTextSize = computed(() => {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.tooltip-custom {
|
||||
bottom: 110%;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import type { TextShape } from '@/types/whiteboard.ts'
|
||||
|
||||
const props = withDefaults(defineProps<{ textShape: TextShape; isSelected?: boolean }>(), {
|
||||
@@ -7,19 +7,31 @@ const props = withDefaults(defineProps<{ textShape: TextShape; isSelected?: bool
|
||||
})
|
||||
|
||||
const textEl = ref<SVGTextElement>()
|
||||
const bbox = ref<{ x: number; y: number; width: number; height: number } | null>(null)
|
||||
const textMetrics = ref<{ width: number; height: number; offsetX: number; offsetY: number } | null>(null)
|
||||
|
||||
function updateBBox() {
|
||||
function measureText() {
|
||||
if (textEl.value) {
|
||||
const b = textEl.value.getBBox()
|
||||
bbox.value = { x: b.x, y: b.y, width: b.width, height: b.height }
|
||||
textMetrics.value = {
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
offsetX: b.x - props.textShape.position.x,
|
||||
offsetY: b.y - props.textShape.position.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(updateBBox)
|
||||
watch(() => props.isSelected, (val) => {
|
||||
if (val) nextTick(updateBBox)
|
||||
}, { flush: 'post' })
|
||||
onMounted(measureText)
|
||||
watch(
|
||||
() => [props.textShape.textValue, props.textShape.textSize],
|
||||
() => { nextTick(measureText) },
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
const outlineX = computed(() => props.textShape.position.x + (textMetrics.value?.offsetX ?? 0) - 4)
|
||||
const outlineY = computed(() => props.textShape.position.y + (textMetrics.value?.offsetY ?? 0) - 4)
|
||||
const outlineW = computed(() => (textMetrics.value?.width ?? 0) + 8)
|
||||
const outlineH = computed(() => (textMetrics.value?.height ?? 0) + 8)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -35,21 +47,21 @@ watch(() => props.isSelected, (val) => {
|
||||
>
|
||||
{{ props.textShape.textValue }}
|
||||
</text>
|
||||
<template v-if="isSelected && bbox">
|
||||
<template v-if="isSelected && textMetrics">
|
||||
<rect
|
||||
:x="bbox.x - 4"
|
||||
:y="bbox.y - 4"
|
||||
:width="bbox.width + 8"
|
||||
:height="bbox.height + 8"
|
||||
:x="outlineX"
|
||||
:y="outlineY"
|
||||
:width="outlineW"
|
||||
:height="outlineH"
|
||||
stroke="#4f9dff"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="4 2"
|
||||
fill="none"
|
||||
/>
|
||||
<circle :cx="bbox.x - 4" :cy="bbox.y - 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="bbox.x + bbox.width + 4" :cy="bbox.y - 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="bbox.x - 4" :cy="bbox.y + bbox.height + 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="bbox.x + bbox.width + 4" :cy="bbox.y + bbox.height + 4" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="outlineX" :cy="outlineY" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="outlineX + outlineW" :cy="outlineY" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="outlineX" :cy="outlineY + outlineH" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
<circle :cx="outlineX + outlineW" :cy="outlineY + outlineH" r="4" fill="white" stroke="#4f9dff" stroke-width="1.5" />
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
@@ -18,6 +18,10 @@ export const whiteboardService = {
|
||||
|
||||
async getWhiteboardById(id: string): Promise<Whiteboard> {
|
||||
return await api.get<any>(`/api/Whiteboard/${id}`).then(mapWhiteboard)
|
||||
},
|
||||
|
||||
async deleteWhiteboard(id: string): Promise<void> {
|
||||
await api.delete(`/api/Whiteboard/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +29,7 @@ function mapWhiteboard(raw: any): Whiteboard {
|
||||
return {
|
||||
id: raw.id,
|
||||
ownerId: raw.ownerId,
|
||||
code: raw.code,
|
||||
title: raw.title,
|
||||
createdAt: new Date(raw.createdAt),
|
||||
deletedAt: raw.deletedAt ? new Date(raw.deletedAt) : undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { whiteboardService } from '@/services/whiteboardService'
|
||||
export const useWhiteboardsStore = defineStore('whiteboards', () => {
|
||||
const ownedWhiteboards = ref<Whiteboard[]>([])
|
||||
const recentWhiteboards = ref<Whiteboard[]>([])
|
||||
const currentWhiteboard = ref<Whiteboard | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -52,6 +53,36 @@ export const useWhiteboardsStore = defineStore('whiteboards', () => {
|
||||
return newWhiteboard.id;
|
||||
}
|
||||
|
||||
async function deleteWhiteboard(id: string): Promise<void> {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await whiteboardService.deleteWhiteboard(id)
|
||||
ownedWhiteboards.value = ownedWhiteboards.value.filter(wb => wb.id !== id)
|
||||
} catch (err: any) {
|
||||
error.value = err.message ?? 'Failed to delete whiteboard'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentWhiteboard(): Whiteboard | null {
|
||||
return currentWhiteboard.value
|
||||
}
|
||||
|
||||
function selectWhiteboard(whiteboardId: string) {
|
||||
currentWhiteboard.value =
|
||||
ownedWhiteboards.value.find(wb => wb.id === whiteboardId) ||
|
||||
recentWhiteboards.value.find(wb => wb.id === whiteboardId) ||
|
||||
null
|
||||
}
|
||||
|
||||
function deselectWhiteboard() {
|
||||
currentWhiteboard.value = null
|
||||
}
|
||||
|
||||
function clearWhiteboards() {
|
||||
ownedWhiteboards.value = []
|
||||
recentWhiteboards.value = []
|
||||
@@ -65,6 +96,10 @@ export const useWhiteboardsStore = defineStore('whiteboards', () => {
|
||||
getWhiteboardHistory: getWhiteboardHistory,
|
||||
getRecentWhiteboards: getRecentWhiteboards,
|
||||
createNewWhiteboard: createNewWhiteboard,
|
||||
clearWhiteboards: clearWhiteboards
|
||||
deleteWhiteboard: deleteWhiteboard,
|
||||
getCurrentWhiteboard: getCurrentWhiteboard,
|
||||
selectWhiteboard: selectWhiteboard,
|
||||
deselectWhiteboard: deselectWhiteboard,
|
||||
clearWhiteboards: clearWhiteboards,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AuthResponse {
|
||||
export interface Whiteboard {
|
||||
id: string
|
||||
ownerId: string
|
||||
code: string
|
||||
title: string
|
||||
createdAt: Date
|
||||
deletedAt?: Date
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { onMounted, onUnmounted, onBeforeMount, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWhiteboardStore } from '@/stores/whiteboard.ts'
|
||||
import { useWhiteboardsStore } from "@/stores/whiteboards.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 sessionStore = useWhiteboardStore()
|
||||
const infoStore = useWhiteboardsStore()
|
||||
|
||||
const whiteboardId = route.params.id as string
|
||||
|
||||
onBeforeMount(() => {
|
||||
infoStore.selectWhiteboard(whiteboardId)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.joinWhiteboard(whiteboardId)
|
||||
sessionStore.joinWhiteboard(whiteboardId)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
infoStore.deselectWhiteboard()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.leaveWhiteboard()
|
||||
sessionStore.leaveWhiteboard()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await store.leaveWhiteboard()
|
||||
await sessionStore.leaveWhiteboard()
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.isLoading" class="d-flex flex-column justify-content-center align-items-center vh-100">
|
||||
<div v-if="sessionStore.isLoading" class="d-flex flex-column justify-content-center align-items-center vh-100">
|
||||
<div class="spinner-border text-primary mb-3" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
@@ -35,9 +45,9 @@ async function handleLeave() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="d-flex justify-content-center align-items-center vh-100">
|
||||
<div v-else-if="sessionStore.error" class="d-flex justify-content-center align-items-center vh-100">
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ store.error }}
|
||||
{{ sessionStore.error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user