Merge pull request #39 from StewKI/feature-whiteboards-recent-and-history
Feature whiteboards recent and history
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
namespace AipsCore.Application.Models.User.Query.GetMe;
|
namespace AipsCore.Application.Models.User.Query.GetMe;
|
||||||
|
|
||||||
public record GetMeQueryDto(string UserName);
|
public record GetMeQueryDto(string UserId, string UserName);
|
||||||
@@ -32,6 +32,6 @@ public class GetMeQueryHandler : IQueryHandler<GetMeQuery, GetMeQueryDto>
|
|||||||
throw new ValidationException(UserErrors.NotFound(new UserId(userId.IdValue)));
|
throw new ValidationException(UserErrors.NotFound(new UserId(userId.IdValue)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new GetMeQueryDto(result.UserName!);
|
return new GetMeQueryDto(result.Id.ToString(), result.UserName!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,4 @@ using AipsCore.Application.Abstract.Query;
|
|||||||
|
|
||||||
namespace AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
namespace AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
||||||
|
|
||||||
public record GetRecentWhiteboardsQuery(string UserId): IQuery<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>;
|
public record GetRecentWhiteboardsQuery: IQuery<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AipsCore.Application.Abstract.Query;
|
using AipsCore.Application.Abstract.Query;
|
||||||
|
using AipsCore.Application.Abstract.UserContext;
|
||||||
using AipsCore.Infrastructure.Persistence.Db;
|
using AipsCore.Infrastructure.Persistence.Db;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -7,20 +8,24 @@ namespace AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
|||||||
public class GetRecentWhiteboardsQueryHandler : IQueryHandler<GetRecentWhiteboardsQuery, ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>
|
public class GetRecentWhiteboardsQueryHandler : IQueryHandler<GetRecentWhiteboardsQuery, ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>
|
||||||
{
|
{
|
||||||
private readonly AipsDbContext _context;
|
private readonly AipsDbContext _context;
|
||||||
|
private readonly IUserContext _userContext;
|
||||||
|
|
||||||
public GetRecentWhiteboardsQueryHandler(AipsDbContext context)
|
public GetRecentWhiteboardsQueryHandler(AipsDbContext context, IUserContext userContext)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_userContext = userContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>> Handle(GetRecentWhiteboardsQuery query, CancellationToken cancellationToken = default)
|
public async Task<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>> Handle(GetRecentWhiteboardsQuery query, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await GetQuery(query.UserId).ToListAsync(cancellationToken);
|
var userId = _userContext.GetCurrentUserId().IdValue;
|
||||||
|
|
||||||
|
return await GetQuery(userId).ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<Infrastructure.Persistence.Whiteboard.Whiteboard> GetQuery(string userId)
|
private IQueryable<Infrastructure.Persistence.Whiteboard.Whiteboard> GetQuery(string userId)
|
||||||
{
|
{
|
||||||
Guid userIdGuid = Guid.Parse(userId);
|
var userIdGuid = Guid.Parse(userId);
|
||||||
|
|
||||||
return _context.WhiteboardMemberships
|
return _context.WhiteboardMemberships
|
||||||
.Include(m => m.Whiteboard)
|
.Include(m => m.Whiteboard)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using AipsCore.Application.Abstract.Query;
|
||||||
|
|
||||||
|
namespace AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardHistory;
|
||||||
|
|
||||||
|
public record GetWhiteboardHistoryQuery : IQuery<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using AipsCore.Application.Abstract.Query;
|
||||||
|
using AipsCore.Application.Abstract.UserContext;
|
||||||
|
using AipsCore.Infrastructure.Persistence.Db;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardHistory;
|
||||||
|
|
||||||
|
public class GetWhiteboardHistoryQueryHandler
|
||||||
|
: IQueryHandler<GetWhiteboardHistoryQuery, ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>
|
||||||
|
{
|
||||||
|
private readonly AipsDbContext _context;
|
||||||
|
private readonly IUserContext _userContext;
|
||||||
|
|
||||||
|
public GetWhiteboardHistoryQueryHandler(AipsDbContext context, IUserContext userContext)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_userContext = userContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>> Handle(GetWhiteboardHistoryQuery query, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var userIdGuid = new Guid(_userContext.GetCurrentUserId().IdValue);
|
||||||
|
|
||||||
|
return await _context.Whiteboards
|
||||||
|
.Where(w => w.OwnerId == userIdGuid)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,7 @@ namespace AipsCore.Application.Models.WhiteboardMembership.Command.CreateWhitebo
|
|||||||
|
|
||||||
public record CreateWhiteboardMembershipCommand(
|
public record CreateWhiteboardMembershipCommand(
|
||||||
string WhiteboardId,
|
string WhiteboardId,
|
||||||
string UserId,
|
|
||||||
bool IsBanned,
|
bool IsBanned,
|
||||||
bool EditingEnabled,
|
bool EditingEnabled,
|
||||||
bool CanJoin,
|
bool CanJoin)
|
||||||
DateTime LastInteractedAt)
|
|
||||||
: ICommand<WhiteboardMembershipId>;
|
: ICommand<WhiteboardMembershipId>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AipsCore.Application.Abstract.Command;
|
using AipsCore.Application.Abstract.Command;
|
||||||
|
using AipsCore.Application.Abstract.UserContext;
|
||||||
using AipsCore.Domain.Abstract;
|
using AipsCore.Domain.Abstract;
|
||||||
using AipsCore.Domain.Models.WhiteboardMembership.External;
|
using AipsCore.Domain.Models.WhiteboardMembership.External;
|
||||||
using AipsCore.Domain.Models.WhiteboardMembership.ValueObjects;
|
using AipsCore.Domain.Models.WhiteboardMembership.ValueObjects;
|
||||||
@@ -8,23 +9,30 @@ namespace AipsCore.Application.Models.WhiteboardMembership.Command.CreateWhitebo
|
|||||||
public class CreateWhiteboardMembershipCommandHandler : ICommandHandler<CreateWhiteboardMembershipCommand, WhiteboardMembershipId>
|
public class CreateWhiteboardMembershipCommandHandler : ICommandHandler<CreateWhiteboardMembershipCommand, WhiteboardMembershipId>
|
||||||
{
|
{
|
||||||
private readonly IWhiteboardMembershipRepository _whiteboardMembershipRepository;
|
private readonly IWhiteboardMembershipRepository _whiteboardMembershipRepository;
|
||||||
|
private readonly IUserContext _userContext;
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
|
||||||
public CreateWhiteboardMembershipCommandHandler(IWhiteboardMembershipRepository whiteboardMembershipRepository, IUnitOfWork unitOfWork)
|
public CreateWhiteboardMembershipCommandHandler(
|
||||||
|
IWhiteboardMembershipRepository whiteboardMembershipRepository,
|
||||||
|
IUserContext userContext,
|
||||||
|
IUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
_whiteboardMembershipRepository = whiteboardMembershipRepository;
|
_whiteboardMembershipRepository = whiteboardMembershipRepository;
|
||||||
|
_userContext = userContext;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WhiteboardMembershipId> Handle(CreateWhiteboardMembershipCommand command, CancellationToken cancellationToken = default)
|
public async Task<WhiteboardMembershipId> Handle(CreateWhiteboardMembershipCommand command, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
var userId = _userContext.GetCurrentUserId();
|
||||||
|
|
||||||
var whiteboardMembership = Domain.Models.WhiteboardMembership.WhiteboardMembership.Create(
|
var whiteboardMembership = Domain.Models.WhiteboardMembership.WhiteboardMembership.Create(
|
||||||
command.WhiteboardId,
|
command.WhiteboardId,
|
||||||
command.UserId,
|
userId.IdValue,
|
||||||
command.IsBanned,
|
command.IsBanned,
|
||||||
command.EditingEnabled,
|
command.EditingEnabled,
|
||||||
command.CanJoin,
|
command.CanJoin,
|
||||||
command.LastInteractedAt);
|
DateTime.UtcNow);
|
||||||
|
|
||||||
await _whiteboardMembershipRepository.SaveAsync(whiteboardMembership, cancellationToken);
|
await _whiteboardMembershipRepository.SaveAsync(whiteboardMembership, cancellationToken);
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
using AipsCore.Application.Abstract;
|
using AipsCore.Application.Abstract;
|
||||||
using AipsCore.Application.Models.Whiteboard.Command.AddUserToWhiteboard;
|
|
||||||
using AipsCore.Application.Models.Whiteboard.Command.BanUserFromWhiteboard;
|
|
||||||
using AipsCore.Application.Models.Whiteboard.Command.CreateWhiteboard;
|
using AipsCore.Application.Models.Whiteboard.Command.CreateWhiteboard;
|
||||||
using AipsCore.Application.Models.Whiteboard.Command.KickUserFromWhiteboard;
|
|
||||||
using AipsCore.Application.Models.Whiteboard.Command.UnbanUserFromWhiteboard;
|
|
||||||
using AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
using AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
|
||||||
using AipsCore.Domain.Models.Whiteboard;
|
using AipsCore.Application.Models.Whiteboard.Query.GetWhiteboardHistory;
|
||||||
|
using AipsCore.Application.Models.WhiteboardMembership.Command.CreateWhiteboardMembership;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Whiteboard = AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard;
|
||||||
|
|
||||||
namespace AipsWebApi.Controllers;
|
namespace AipsWebApi.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("[controller]")]
|
[Route("/api/[controller]")]
|
||||||
public class WhiteboardController : ControllerBase
|
public class WhiteboardController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IDispatcher _dispatcher;
|
private readonly IDispatcher _dispatcher;
|
||||||
@@ -29,4 +27,28 @@ public class WhiteboardController : ControllerBase
|
|||||||
var whiteboardId = await _dispatcher.Execute(command, cancellationToken);
|
var whiteboardId = await _dispatcher.Execute(command, cancellationToken);
|
||||||
return Ok(whiteboardId.IdValue);
|
return Ok(whiteboardId.IdValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("history")]
|
||||||
|
public async Task<ActionResult<ICollection<Whiteboard>>> GetWhiteboardHistory(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var whiteboards = await _dispatcher.Execute(new GetWhiteboardHistoryQuery(), cancellationToken);
|
||||||
|
return Ok(whiteboards);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("recent")]
|
||||||
|
public async Task<ActionResult<ICollection<Whiteboard>>> GetRecentWhiteboards(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var whiteboards = await _dispatcher.Execute(new GetRecentWhiteboardsQuery(), cancellationToken);
|
||||||
|
return Ok(whiteboards);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost("join")]
|
||||||
|
public async Task<ActionResult> JoinWhiteboard(CreateWhiteboardMembershipCommand command, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _dispatcher.Execute(command, cancellationToken);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
"signalr": "^2.4.3",
|
||||||
"vue": "^3.5.27",
|
"vue": "^3.5.27",
|
||||||
"vue-router": "^5.0.1",
|
"vue-router": "^5.0.1",
|
||||||
},
|
},
|
||||||
@@ -547,6 +548,8 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"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-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=="],
|
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||||
@@ -739,6 +742,8 @@
|
|||||||
|
|
||||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
"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=="],
|
"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=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import AppTopBar from './components/AppTopBar.vue'
|
import AppTopBar from './components/AppTopBar.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.initialize()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const hideTopBar = computed(() => route.meta.hideTopBar === true)
|
const hideTopBar = computed(() => route.meta.hideTopBar === true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="hideTopBar">
|
<template v-if="hideTopBar">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const auth = useAuthStore()
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav class="navbar navbar-expand-md navbar-dark bg-dark border-bottom border-secondary">
|
<nav class="navbar navbar-expand-md navbar-dark bg-dark border-bottom border-secondary sticky-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<RouterLink class="navbar-brand" to="/">AIPS</RouterLink>
|
<RouterLink class="navbar-brand" to="/">AIPS</RouterLink>
|
||||||
|
|
||||||
|
|||||||
44
front/src/components/RecentWhiteboardsItem.vue
Normal file
44
front/src/components/RecentWhiteboardsItem.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Whiteboard } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ whiteboard: Whiteboard }>()
|
||||||
|
const emit = defineEmits<{ click: [whiteboard: Whiteboard] }>()
|
||||||
|
|
||||||
|
const formatDate = (date: string | Date) =>
|
||||||
|
new Date(date).toLocaleDateString(
|
||||||
|
(navigator.languages && navigator.languages[0]) || '',
|
||||||
|
{ day: '2-digit', month: '2-digit', year: 'numeric' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = () => emit('click', 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>
|
||||||
|
<small class="text-muted">{{ formatDate(whiteboard.createdAt) }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card:hover {
|
||||||
|
border-color: #007bff !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
39
front/src/components/RecentWhiteboardsList.vue
Normal file
39
front/src/components/RecentWhiteboardsList.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { onMounted, computed } from 'vue'
|
||||||
|
import { useWhiteboardStore } from '@/stores/whiteboards'
|
||||||
|
import RecentWhiteboardsItem from './RecentWhiteboardsItem.vue'
|
||||||
|
|
||||||
|
const store = useWhiteboardStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (store.recentWhiteboards.length === 0) store.getRecentWhiteboards()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedWhiteboards = computed(() =>
|
||||||
|
[...store.recentWhiteboards].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (whiteboard: any) => {
|
||||||
|
console.log('Clicked:', whiteboard)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3 overflow-auto h-100 w-100 p-3" v-if="sortedWhiteboards.length > 0">
|
||||||
|
<RecentWhiteboardsItem
|
||||||
|
v-for="wb in sortedWhiteboards"
|
||||||
|
:key="wb.id"
|
||||||
|
:whiteboard="wb"
|
||||||
|
@click="handleClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column gap-3 overflow-auto h-100 w-100 p-3" v-else>
|
||||||
|
<p class="text-muted">No recent whiteboards</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
27
front/src/components/RecentWhiteboardsPanel.vue
Normal file
27
front/src/components/RecentWhiteboardsPanel.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import RecentWhiteboardsList from './RecentWhiteboardsList.vue'
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<div class="whiteboards-panel card border-secondary shadow-sm h-100">
|
||||||
|
<div class="card-header bg-light text-dark">
|
||||||
|
Recent Whiteboards
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0 overflow-auto">
|
||||||
|
<RecentWhiteboardsList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.whiteboards-panel {
|
||||||
|
max-height: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
44
front/src/components/WhiteboardHistoryItem.vue
Normal file
44
front/src/components/WhiteboardHistoryItem.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Whiteboard } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ whiteboard: Whiteboard }>()
|
||||||
|
const emit = defineEmits<{ click: [whiteboard: Whiteboard] }>()
|
||||||
|
|
||||||
|
const formatDate = (date: string | Date) =>
|
||||||
|
new Date(date).toLocaleDateString(
|
||||||
|
(navigator.languages && navigator.languages[0]) || '',
|
||||||
|
{ day: '2-digit', month: '2-digit', year: 'numeric' }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = () => emit('click', 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>
|
||||||
|
<small class="text-muted">{{ formatDate(whiteboard.createdAt) }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-card:hover {
|
||||||
|
border-color: #007bff !important;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
35
front/src/components/WhiteboardHistoryList.vue
Normal file
35
front/src/components/WhiteboardHistoryList.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, computed } from 'vue'
|
||||||
|
import { useWhiteboardStore } from '@/stores/whiteboards'
|
||||||
|
import WhiteboardHistoryItem from './WhiteboardHistoryItem.vue'
|
||||||
|
|
||||||
|
const store = useWhiteboardStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (store.ownedWhiteboards.length === 0) store.getWhiteboardHistory()
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedWhiteboards = computed(() =>
|
||||||
|
[...store.ownedWhiteboards].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClick = (whiteboard: any) => {
|
||||||
|
console.log('Clicked:', whiteboard)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="d-flex flex-column gap-3 overflow-auto h-100 w-100 p-3" v-if="sortedWhiteboards.length > 0">
|
||||||
|
<WhiteboardHistoryItem
|
||||||
|
v-for="wb in sortedWhiteboards"
|
||||||
|
:key="wb.id"
|
||||||
|
:whiteboard="wb"
|
||||||
|
@click="handleClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column gap-3 overflow-auto h-100 w-100 p-3" v-else>
|
||||||
|
<p class="text-muted">You have not created a whiteboard yet</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
58
front/src/components/WhiteboardHistorySidebar.vue
Normal file
58
front/src/components/WhiteboardHistorySidebar.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import WhiteboardHistoryList from './WhiteboardHistoryList.vue'
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-dark position-fixed top-50 start-0 translate-middle-y rounded-0 rounded-end py-3 px-2"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="offcanvas"
|
||||||
|
data-bs-target="#whiteboardSidebar"
|
||||||
|
aria-controls="whiteboardSidebar"
|
||||||
|
style="z-index: 1040; writing-mode: vertical-rl;"
|
||||||
|
>
|
||||||
|
My Whiteboards
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="whiteboardSidebar"
|
||||||
|
class="offcanvas offcanvas-start bg-dark text-light"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="whiteboardSidebarLabel"
|
||||||
|
>
|
||||||
|
<div class="offcanvas-header border-bottom border-secondary">
|
||||||
|
<h5 id="whiteboardSidebarLabel" class="offcanvas-title">
|
||||||
|
My Whiteboards
|
||||||
|
</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close btn-close-white"
|
||||||
|
data-bs-dismiss="offcanvas"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="offcanvas-body p-0">
|
||||||
|
<WhiteboardHistoryList />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
#whiteboardSidebar.offcanvas-start {
|
||||||
|
top: 56px;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#whiteboardSidebar.offcanvas-start {
|
||||||
|
top: 56px;
|
||||||
|
height: calc(100vh - 56px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
11
front/src/enums/index.ts
Normal file
11
front/src/enums/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export enum WhiteboardJoinPolicy {
|
||||||
|
FreeToJoin,
|
||||||
|
RequestToJoin,
|
||||||
|
Private
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum WhiteboardState {
|
||||||
|
Active,
|
||||||
|
Inactive,
|
||||||
|
Deleted
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import './assets/scss/main.scss'
|
import './assets/scss/main.scss'
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||||
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|||||||
27
front/src/services/whiteboardService.ts
Normal file
27
front/src/services/whiteboardService.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { Whiteboard } from "@/types";
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
export const whiteboardService = {
|
||||||
|
async getWhiteboardHistory(): Promise<Whiteboard[]> {
|
||||||
|
const raw = await api.get<any[]>('/api/Whiteboard/history')
|
||||||
|
return raw.map(mapWhiteboard)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecentWhiteboards(): Promise<Whiteboard[]> {
|
||||||
|
const raw = await api.get<any[]>('/api/Whiteboard/recent')
|
||||||
|
return raw.map(mapWhiteboard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWhiteboard(raw: any): Whiteboard {
|
||||||
|
return {
|
||||||
|
id: raw.id,
|
||||||
|
ownerId: raw.ownerId,
|
||||||
|
title: raw.title,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
deletedAt: raw.deletedAt ? new Date(raw.deletedAt) : undefined,
|
||||||
|
maxParticipants: raw.maxParticipants,
|
||||||
|
joinPolicy: raw.joinPolicy,
|
||||||
|
state: raw.state,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { User, LoginCredentials, SignupCredentials, AuthResponse } from '@/types'
|
import type { User, LoginCredentials, SignupCredentials, AuthResponse } from '@/types'
|
||||||
|
import { useWhiteboardStore } from '@/stores/whiteboards'
|
||||||
import { authService } from '@/services/authService'
|
import { authService } from '@/services/authService'
|
||||||
|
|
||||||
const ACCESS_TOKEN = 'auth_token'
|
const ACCESS_TOKEN = 'auth_token'
|
||||||
@@ -14,7 +15,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const isAuthenticated = computed(() => !!user.value)
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
// single-flight promise for refresh to avoid concurrent refresh requests
|
|
||||||
let refreshPromise: Promise<void> | null = null
|
let refreshPromise: Promise<void> | null = null
|
||||||
|
|
||||||
function setTokens(access: string | null, refresh: string | null) {
|
function setTokens(access: string | null, refresh: string | null) {
|
||||||
@@ -28,28 +28,23 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
if (refresh) {
|
if (refresh) {
|
||||||
localStorage.setItem(REFRESH_TOKEN, refresh)
|
localStorage.setItem(REFRESH_TOKEN, refresh)
|
||||||
} else if (refresh === null) {
|
} else if (refresh === null) {
|
||||||
// explicit null means remove
|
|
||||||
localStorage.removeItem(REFRESH_TOKEN)
|
localStorage.removeItem(REFRESH_TOKEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryRefresh(): Promise<void> {
|
async function tryRefresh(): Promise<void> {
|
||||||
// if a refresh is already in progress, return that promise
|
|
||||||
if (refreshPromise) return refreshPromise
|
if (refreshPromise) return refreshPromise
|
||||||
|
|
||||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
// nothing to do
|
|
||||||
throw new Error('No refresh token')
|
throw new Error('No refresh token')
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const res = await authService.refreshLogin(refreshToken)
|
const res = await authService.refreshLogin(refreshToken)
|
||||||
// API expected to return { accessToken, refreshToken }
|
|
||||||
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
|
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
|
||||||
} finally {
|
} finally {
|
||||||
// clear so subsequent calls can create a new promise
|
|
||||||
refreshPromise = null
|
refreshPromise = null
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
@@ -70,7 +65,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// token might be expired; try refresh if we have refresh token
|
|
||||||
if (savedRefresh) {
|
if (savedRefresh) {
|
||||||
try {
|
try {
|
||||||
await tryRefresh()
|
await tryRefresh()
|
||||||
@@ -78,14 +72,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// refresh failed - fallthrough to clearing auth
|
|
||||||
console.warn('Token refresh failed during initialize', err)
|
console.warn('Token refresh failed during initialize', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// not authenticated or refresh failed
|
|
||||||
setTokens(null, null)
|
setTokens(null, null)
|
||||||
user.value = null
|
user.value = null
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
@@ -96,14 +89,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res: AuthResponse = await authService.login(credentials)
|
const res: AuthResponse = await authService.login(credentials)
|
||||||
// expect AuthResponse to have accessToken and refreshToken
|
|
||||||
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
|
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
user.value = await authService.getMe()
|
user.value = await authService.getMe()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Logged in but failed to fetch user profile', e)
|
console.error('Logged in but failed to fetch user profile', e)
|
||||||
// keep tokens but clear user
|
|
||||||
user.value = null
|
user.value = null
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -124,7 +117,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
try {
|
try {
|
||||||
user.value = await authService.getMe()
|
user.value = await authService.getMe()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// keep tokens but no profile
|
|
||||||
user.value = null
|
user.value = null
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -136,16 +129,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout(allDevices = false) {
|
async function logout(allDevices = false) {
|
||||||
|
const whiteboardStore = useWhiteboardStore()
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
if (allDevices) await authService.logoutAll()
|
if (allDevices) await authService.logoutAll()
|
||||||
else await authService.logout(localStorage.getItem(REFRESH_TOKEN)!)
|
else await authService.logout(localStorage.getItem(REFRESH_TOKEN)!)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore network errors on logout
|
|
||||||
console.warn('Logout request failed', e)
|
console.warn('Logout request failed', e)
|
||||||
} finally {
|
} finally {
|
||||||
setTokens(null, null)
|
setTokens(null, null)
|
||||||
|
whiteboardStore.clearWhiteboards()
|
||||||
user.value = null
|
user.value = null
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
50
front/src/stores/whiteboards.ts
Normal file
50
front/src/stores/whiteboards.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Whiteboard } from '@/types'
|
||||||
|
import { whiteboardService } from '@/services/whiteboardService'
|
||||||
|
|
||||||
|
export const useWhiteboardStore = defineStore('whiteboards', () => {
|
||||||
|
const ownedWhiteboards = ref<Whiteboard[]>([])
|
||||||
|
const recentWhiteboards = ref<Whiteboard[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function getWhiteboardHistory() {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
ownedWhiteboards.value = await whiteboardService.getWhiteboardHistory()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message ?? 'Failed to load whiteboards'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecentWhiteboards() {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
recentWhiteboards.value = await whiteboardService.getRecentWhiteboards()
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.message ?? 'Failed to load whiteboards'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWhiteboards() {
|
||||||
|
ownedWhiteboards.value = []
|
||||||
|
recentWhiteboards.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownedWhiteboards: ownedWhiteboards,
|
||||||
|
recentWhiteboards: recentWhiteboards,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
getWhiteboardHistory: getWhiteboardHistory,
|
||||||
|
getRecentWhiteboards: getRecentWhiteboards,
|
||||||
|
clearWhiteboards: clearWhiteboards
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {type WhiteboardJoinPolicy, WhiteboardState} from "@/enums";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
username: string
|
username: string
|
||||||
email: string
|
email: string
|
||||||
@@ -18,3 +20,14 @@ export interface AuthResponse {
|
|||||||
accessToken: string
|
accessToken: string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Whiteboard {
|
||||||
|
id: string
|
||||||
|
ownerId: string
|
||||||
|
title: string
|
||||||
|
createdAt: Date
|
||||||
|
deletedAt?: Date
|
||||||
|
maxParticipants?: number
|
||||||
|
joinPolicy?: WhiteboardJoinPolicy
|
||||||
|
state: WhiteboardState
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import WhiteboardHistorySidebar from '@/components/WhiteboardHistorySidebar.vue'
|
||||||
|
import RecentWhiteboardsPanel from '@/components/RecentWhiteboardsPanel.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Home</h1>
|
<WhiteboardHistorySidebar v-if="auth.isAuthenticated" />
|
||||||
|
|
||||||
|
<div class="container py-4">
|
||||||
|
<div
|
||||||
|
v-if="auth.isAuthenticated"
|
||||||
|
class="d-flex justify-content-center position-absolute bottom-0 start-50 translate-middle-x mb-3 w-50"
|
||||||
|
>
|
||||||
|
<RecentWhiteboardsPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user