front
This commit is contained in:
@@ -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=="],
|
||||||
|
|||||||
@@ -18,6 +18,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import AppTopBar from './components/AppTopBar.vue'
|
import AppTopBar from './components/AppTopBar.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
auth.initialize()
|
||||||
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppTopBar />
|
<AppTopBar />
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
|
|||||||
@@ -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