Merge pull request #39 from StewKI/feature-whiteboards-recent-and-history

Feature whiteboards recent and history
This commit is contained in:
Veljko
2026-02-16 18:31:16 +01:00
committed by GitHub
25 changed files with 482 additions and 33 deletions

View File

@@ -1,3 +1,3 @@
namespace AipsCore.Application.Models.User.Query.GetMe;
public record GetMeQueryDto(string UserName);
public record GetMeQueryDto(string UserId, string UserName);

View File

@@ -32,6 +32,6 @@ public class GetMeQueryHandler : IQueryHandler<GetMeQuery, GetMeQueryDto>
throw new ValidationException(UserErrors.NotFound(new UserId(userId.IdValue)));
}
return new GetMeQueryDto(result.UserName!);
return new GetMeQueryDto(result.Id.ToString(), result.UserName!);
}
}

View File

@@ -2,4 +2,4 @@ using AipsCore.Application.Abstract.Query;
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>>;

View File

@@ -1,4 +1,5 @@
using AipsCore.Application.Abstract.Query;
using AipsCore.Application.Abstract.UserContext;
using AipsCore.Infrastructure.Persistence.Db;
using Microsoft.EntityFrameworkCore;
@@ -7,20 +8,24 @@ namespace AipsCore.Application.Models.Whiteboard.Query.GetRecentWhiteboards;
public class GetRecentWhiteboardsQueryHandler : IQueryHandler<GetRecentWhiteboardsQuery, ICollection<Infrastructure.Persistence.Whiteboard.Whiteboard>>
{
private readonly AipsDbContext _context;
private readonly IUserContext _userContext;
public GetRecentWhiteboardsQueryHandler(AipsDbContext context)
public GetRecentWhiteboardsQueryHandler(AipsDbContext context, IUserContext userContext)
{
_context = context;
_userContext = userContext;
}
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)
{
Guid userIdGuid = Guid.Parse(userId);
var userIdGuid = Guid.Parse(userId);
return _context.WhiteboardMemberships
.Include(m => m.Whiteboard)

View File

@@ -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>>;

View File

@@ -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);
}
}

View File

@@ -5,9 +5,7 @@ namespace AipsCore.Application.Models.WhiteboardMembership.Command.CreateWhitebo
public record CreateWhiteboardMembershipCommand(
string WhiteboardId,
string UserId,
bool IsBanned,
bool EditingEnabled,
bool CanJoin,
DateTime LastInteractedAt)
bool CanJoin)
: ICommand<WhiteboardMembershipId>;

View File

@@ -1,4 +1,5 @@
using AipsCore.Application.Abstract.Command;
using AipsCore.Application.Abstract.UserContext;
using AipsCore.Domain.Abstract;
using AipsCore.Domain.Models.WhiteboardMembership.External;
using AipsCore.Domain.Models.WhiteboardMembership.ValueObjects;
@@ -8,23 +9,30 @@ namespace AipsCore.Application.Models.WhiteboardMembership.Command.CreateWhitebo
public class CreateWhiteboardMembershipCommandHandler : ICommandHandler<CreateWhiteboardMembershipCommand, WhiteboardMembershipId>
{
private readonly IWhiteboardMembershipRepository _whiteboardMembershipRepository;
private readonly IUserContext _userContext;
private readonly IUnitOfWork _unitOfWork;
public CreateWhiteboardMembershipCommandHandler(IWhiteboardMembershipRepository whiteboardMembershipRepository, IUnitOfWork unitOfWork)
public CreateWhiteboardMembershipCommandHandler(
IWhiteboardMembershipRepository whiteboardMembershipRepository,
IUserContext userContext,
IUnitOfWork unitOfWork)
{
_whiteboardMembershipRepository = whiteboardMembershipRepository;
_userContext = userContext;
_unitOfWork = unitOfWork;
}
public async Task<WhiteboardMembershipId> Handle(CreateWhiteboardMembershipCommand command, CancellationToken cancellationToken = default)
{
var userId = _userContext.GetCurrentUserId();
var whiteboardMembership = Domain.Models.WhiteboardMembership.WhiteboardMembership.Create(
command.WhiteboardId,
command.UserId,
userId.IdValue,
command.IsBanned,
command.EditingEnabled,
command.CanJoin,
command.LastInteractedAt);
DateTime.UtcNow);
await _whiteboardMembershipRepository.SaveAsync(whiteboardMembership, cancellationToken);
await _unitOfWork.SaveChangesAsync(cancellationToken);

View File

@@ -1,18 +1,16 @@
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.KickUserFromWhiteboard;
using AipsCore.Application.Models.Whiteboard.Command.UnbanUserFromWhiteboard;
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.Mvc;
using Whiteboard = AipsCore.Infrastructure.Persistence.Whiteboard.Whiteboard;
namespace AipsWebApi.Controllers;
[ApiController]
[Route("[controller]")]
[Route("/api/[controller]")]
public class WhiteboardController : ControllerBase
{
private readonly IDispatcher _dispatcher;
@@ -29,4 +27,28 @@ public class WhiteboardController : ControllerBase
var whiteboardId = await _dispatcher.Execute(command, cancellationToken);
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);
}
}

View File

@@ -9,6 +9,7 @@
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"pinia": "^3.0.4",
"signalr": "^2.4.3",
"vue": "^3.5.27",
"vue-router": "^5.0.1",
},
@@ -547,6 +548,8 @@
"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=="],
@@ -739,6 +742,8 @@
"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=="],

View File

@@ -1,12 +1,23 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import { computed } from 'vue'
import AppTopBar from './components/AppTopBar.vue'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
onMounted(() => {
auth.initialize()
})
const route = useRoute()
const hideTopBar = computed(() => route.meta.hideTopBar === true)
</script>
<template>
<template v-if="hideTopBar">
<RouterView />

View File

@@ -6,7 +6,7 @@ const auth = useAuthStore()
</script>
<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">
<RouterLink class="navbar-brand" to="/">AIPS</RouterLink>

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

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

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

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

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

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

@@ -0,0 +1,11 @@
export enum WhiteboardJoinPolicy {
FreeToJoin,
RequestToJoin,
Private
}
export enum WhiteboardState {
Active,
Inactive,
Deleted
}

View File

@@ -1,4 +1,6 @@
import './assets/scss/main.scss'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import { createApp } from 'vue'
import { createPinia } from 'pinia'

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

View File

@@ -1,6 +1,7 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { User, LoginCredentials, SignupCredentials, AuthResponse } from '@/types'
import { useWhiteboardStore } from '@/stores/whiteboards'
import { authService } from '@/services/authService'
const ACCESS_TOKEN = 'auth_token'
@@ -14,7 +15,6 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!user.value)
// single-flight promise for refresh to avoid concurrent refresh requests
let refreshPromise: Promise<void> | null = null
function setTokens(access: string | null, refresh: string | null) {
@@ -28,28 +28,23 @@ export const useAuthStore = defineStore('auth', () => {
if (refresh) {
localStorage.setItem(REFRESH_TOKEN, refresh)
} else if (refresh === null) {
// explicit null means remove
localStorage.removeItem(REFRESH_TOKEN)
}
}
async function tryRefresh(): Promise<void> {
// if a refresh is already in progress, return that promise
if (refreshPromise) return refreshPromise
const refreshToken = localStorage.getItem(REFRESH_TOKEN)
if (!refreshToken) {
// nothing to do
throw new Error('No refresh token')
}
refreshPromise = (async () => {
try {
const res = await authService.refreshLogin(refreshToken)
// API expected to return { accessToken, refreshToken }
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
} finally {
// clear so subsequent calls can create a new promise
refreshPromise = null
}
})()
@@ -70,7 +65,6 @@ export const useAuthStore = defineStore('auth', () => {
isLoading.value = false
return
} catch (e) {
// token might be expired; try refresh if we have refresh token
if (savedRefresh) {
try {
await tryRefresh()
@@ -78,14 +72,13 @@ export const useAuthStore = defineStore('auth', () => {
isLoading.value = false
return
} catch (err) {
// refresh failed - fallthrough to clearing auth
console.warn('Token refresh failed during initialize', err)
}
}
}
}
// not authenticated or refresh failed
setTokens(null, null)
user.value = null
isLoading.value = false
@@ -96,14 +89,14 @@ export const useAuthStore = defineStore('auth', () => {
error.value = null
try {
const res: AuthResponse = await authService.login(credentials)
// expect AuthResponse to have accessToken and refreshToken
setTokens(res.accessToken ?? null, res.refreshToken ?? null)
try {
user.value = await authService.getMe()
} catch (e) {
console.error('Logged in but failed to fetch user profile', e)
// keep tokens but clear user
user.value = null
}
} catch (e: any) {
@@ -124,7 +117,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
user.value = await authService.getMe()
} catch (e) {
// keep tokens but no profile
user.value = null
}
} catch (e: any) {
@@ -136,16 +129,17 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout(allDevices = false) {
const whiteboardStore = useWhiteboardStore()
isLoading.value = true
error.value = null
try {
if (allDevices) await authService.logoutAll()
else await authService.logout(localStorage.getItem(REFRESH_TOKEN)!)
} catch (e) {
// ignore network errors on logout
console.warn('Logout request failed', e)
} finally {
setTokens(null, null)
whiteboardStore.clearWhiteboards()
user.value = null
isLoading.value = false
}

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

View File

@@ -1,3 +1,5 @@
import {type WhiteboardJoinPolicy, WhiteboardState} from "@/enums";
export interface User {
username: string
email: string
@@ -18,3 +20,14 @@ export interface AuthResponse {
accessToken: string
refreshToken: string
}
export interface Whiteboard {
id: string
ownerId: string
title: string
createdAt: Date
deletedAt?: Date
maxParticipants?: number
joinPolicy?: WhiteboardJoinPolicy
state: WhiteboardState
}

View File

@@ -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>
<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>
<style scoped>
</style>