This commit is contained in:
Cameron Redmore 2025-04-25 14:06:02 +01:00
commit 61d274391b
31 changed files with 4682 additions and 4612 deletions

View file

@ -6,7 +6,7 @@
import { useAuthStore } from './stores/auth';
defineOptions({
preFetch()
preFetch()
{
const authStore = useAuthStore();
return authStore.checkAuthStatus();

View file

@ -1,14 +1,14 @@
import { boot } from 'quasar/wrappers';
import axios from 'axios';
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
axios.defaults.withCredentials = true; // Enable sending cookies with requests
// Export the API instance so you can import it easily elsewhere, e.g. stores
import { boot } from 'quasar/wrappers';
import axios from 'axios';
// Be careful when using SSR for cross-request state pollution
// due to creating a Singleton instance here;
// If any client changes this (global) instance, it might be a
// good idea to move this instance creation inside of the
// "export default () => {}" function below (which runs individually
// for each client)
axios.defaults.withCredentials = true; // Enable sending cookies with requests
// Export the API instance so you can import it easily elsewhere, e.g. stores
export default axios;

View file

@ -1,137 +1,137 @@
<template>
<div class="q-pa-md column full-height">
<q-scroll-area
ref="scrollAreaRef"
class="col"
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
>
<div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message
:name="message.sender.toUpperCase()"
:sent="message.sender === 'user'"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
:text-color="message.sender === 'user' ? 'white' : 'black'"
>
<!-- Use v-html to render parsed markdown -->
<div
v-if="!message.loading"
v-html="parseMarkdown(message.content)"
class="message-content"
/>
<!-- Optional: Add a spinner for a better loading visual -->
<template
v-if="message.loading"
#default
>
<q-spinner-dots size="2em" />
</template>
</q-chat-message>
</div>
</q-scroll-area>
<q-separator />
<div class="q-pa-sm row items-center">
<q-input
v-model="newMessage"
outlined
dense
placeholder="Type a message..."
class="col"
@keyup.enter="sendMessage"
autogrow
/>
<q-btn
round
dense
flat
icon="send"
color="primary"
class="q-ml-sm"
@click="sendMessage"
:disable="!newMessage.trim()"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
import { marked } from 'marked'; // Import marked
const props = defineProps({
messages: {
type: Array,
required: true,
'default': () => [],
// Example message structure:
// { sender: 'Bot', content: 'Hello!', loading: false }
// { sender: 'You', content: 'Thinking...', loading: true }
},
});
const emit = defineEmits(['send-message']);
const newMessage = ref('');
const scrollAreaRef = ref(null);
const scrollToBottom = () =>
{
if (scrollAreaRef.value)
{
const scrollTarget = scrollAreaRef.value.getScrollTarget();
const duration = 300; // Optional: animation duration
// Use getScrollTarget().scrollHeight for accurate height
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
}
};
const sendMessage = () =>
{
const trimmedMessage = newMessage.value.trim();
if (trimmedMessage)
{
emit('send-message', trimmedMessage);
newMessage.value = '';
// Ensure the scroll happens after the message is potentially added to the list
nextTick(() =>
{
scrollToBottom();
});
}
};
const parseMarkdown = (content) =>
{
// Basic check to prevent errors if content is not a string
if (typeof content !== 'string')
{
return '';
}
// Configure marked options if needed (e.g., sanitization)
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
return marked(content);
};
// Scroll to bottom when messages change or component mounts
watch(() => props.messages, () =>
{
nextTick(() =>
{
scrollToBottom();
});
}, { deep: true, immediate: true });
</script>
<style>
.message-content p {
margin: 0;
padding: 0;
}
<template>
<div class="q-pa-md column full-height">
<q-scroll-area
ref="scrollAreaRef"
class="col"
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
>
<div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message
:name="message.sender.toUpperCase()"
:sent="message.sender === 'user'"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
:text-color="message.sender === 'user' ? 'white' : 'black'"
>
<!-- Use v-html to render parsed markdown -->
<div
v-if="!message.loading"
v-html="parseMarkdown(message.content)"
class="message-content"
/>
<!-- Optional: Add a spinner for a better loading visual -->
<template
v-if="message.loading"
#default
>
<q-spinner-dots size="2em" />
</template>
</q-chat-message>
</div>
</q-scroll-area>
<q-separator />
<div class="q-pa-sm row items-center">
<q-input
v-model="newMessage"
outlined
dense
placeholder="Type a message..."
class="col"
@keyup.enter="sendMessage"
autogrow
/>
<q-btn
round
dense
flat
icon="send"
color="primary"
class="q-ml-sm"
@click="sendMessage"
:disable="!newMessage.trim()"
/>
</div>
</div>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue';
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
import { marked } from 'marked'; // Import marked
const props = defineProps({
messages: {
type: Array,
required: true,
'default': () => [],
// Example message structure:
// { sender: 'Bot', content: 'Hello!', loading: false }
// { sender: 'You', content: 'Thinking...', loading: true }
},
});
const emit = defineEmits(['send-message']);
const newMessage = ref('');
const scrollAreaRef = ref(null);
const scrollToBottom = () =>
{
if (scrollAreaRef.value)
{
const scrollTarget = scrollAreaRef.value.getScrollTarget();
const duration = 300; // Optional: animation duration
// Use getScrollTarget().scrollHeight for accurate height
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
}
};
const sendMessage = () =>
{
const trimmedMessage = newMessage.value.trim();
if (trimmedMessage)
{
emit('send-message', trimmedMessage);
newMessage.value = '';
// Ensure the scroll happens after the message is potentially added to the list
nextTick(() =>
{
scrollToBottom();
});
}
};
const parseMarkdown = (content) =>
{
// Basic check to prevent errors if content is not a string
if (typeof content !== 'string')
{
return '';
}
// Configure marked options if needed (e.g., sanitization)
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
return marked(content);
};
// Scroll to bottom when messages change or component mounts
watch(() => props.messages, () =>
{
nextTick(() =>
{
scrollToBottom();
});
}, { deep: true, immediate: true });
</script>
<style>
.message-content p {
margin: 0;
padding: 0;
}
</style>

View file

@ -6,22 +6,83 @@
persistent
:model-value="true"
>
<q-list>
<q-item
clickable
v-ripple
@click="toggleLeftDrawer"
<q-item
clickable
v-ripple
@click="toggleLeftDrawer"
>
<q-item-section avatar>
<q-icon name="menu" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
StylePoint
</q-item-label>
</q-item-section>
</q-item>
<template v-if="authStore.isAuthenticated">
<q-card
v-if="leftDrawerOpen"
bordered
flat
class="q-ma-sm text-center"
>
<q-item-section avatar>
<q-icon name="menu" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
StylePoint
</q-item-label>
</q-item-section>
</q-item>
<q-card-section>
<q-avatar
class="bg-primary cursor-pointer text-white"
>
<q-icon name="mdi-account" />
<q-tooltip>
{{ authStore.user.username }}
</q-tooltip>
</q-avatar>
<div class="text-h6">
{{ authStore.user.username }}
</div>
<q-btn
class="full-width q-mt-sm"
dense
outline
@click="logout"
>
Logout
</q-btn>
</q-card-section>
</q-card>
<q-list
padding
class="menu-list"
v-else
>
<q-item
clickable
v-ripple
dense
@click="logout"
class="q-mb-sm"
>
<q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">
Logout
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</template>
<q-separator />
<q-list
padding
class="menu-list"
>
<!-- Dynamic Navigation Items -->
<q-item
v-for="item in navItems"
@ -30,6 +91,7 @@
v-ripple
:to="{ name: item.name }"
exact
dense
>
<q-tooltip
anchor="center right"
@ -47,27 +109,6 @@
</q-item-label>
</q-item-section>
</q-item>
<!-- Logout Button (Conditional) -->
<q-item
v-if="authStore.isAuthenticated"
clickable
v-ripple
@click="logout"
>
<q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="logout" />
</q-item-section>
<q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
@ -82,11 +123,10 @@
:offset="[18, 18]"
>
<q-fab
v-model="fabOpen"
v-model="chatStore.isChatVisible"
icon="chat"
color="accent"
direction="up"
padding="sm"
@click="toggleChat"
/>
</q-page-sticky>
@ -95,8 +135,6 @@
<q-dialog
v-model="isChatVisible"
:maximized="$q.screen.lt.sm"
fixed
persistent
style="width: max(400px, 25%);"
>
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
@ -162,8 +200,6 @@ const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore();
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
// Computed properties to get state from the store
const isChatVisible = computed(() => chatStore.isChatVisible);
const chatMessages = computed(() => chatStore.chatMessages);
@ -198,7 +234,6 @@ const toggleChat = () =>
if (isAuthenticated.value)
{
chatStore.toggleChat();
fabOpen.value = chatStore.isChatVisible;
}
};
@ -217,32 +252,58 @@ function toggleLeftDrawer()
{
leftDrawerOpen.value = !leftDrawerOpen.value;
}
async function logout()
{
try
$q.dialog({
title: 'Confirm Logout',
message: 'Are you sure you want to logout?',
cancel: true,
persistent: true
}).onOk(async() =>
{
await axios.post('/api/auth/logout');
authStore.logout(); // Use the store action to update state
// No need to manually push, router guard should redirect
// router.push({ name: 'login' });
}
catch (error)
{
console.error('Logout failed:', error);
try
{
await axios.post('/api/auth/logout');
authStore.logout();
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
$q.notify({
color: 'positive',
message: 'Logout successful.',
icon: 'check_circle'
});
router.push({ name: 'login' });
}
catch (error)
{
console.error('Logout failed:', error);
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
});
}
</script>
<style scoped>
/* Add any specific styles for the layout or chat window here */
<style lang="scss" scoped>
.q-dialog .q-card {
overflow: hidden; /* Prevent scrollbars on the card itself */
overflow: hidden;
}
.menu-list .q-item {
border-radius: 32px;
margin: 5px 5px;
}
.menu-list .q-item:first-child {
margin-top: 0px;
}
.menu-list .q-router-link--active {
background-color: var(--q-primary);
color: #fff;
}
</style>

View file

@ -1,220 +1,220 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Create New Form
</div>
<q-form
@submit.prevent="createForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="fieldIndex"
class="q-ml-md q-mb-sm field-item"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
outlined
dense
label="Field Description (Optional)"
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
label="Create Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
const $q = useQuasar();
const router = useRouter();
const form = ref({
title: '',
description: '',
categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
]
});
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
function addCategory()
{
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function createForm()
{
submitting.value = true;
try
{
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" created successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' });
}
catch (error)
{
console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Create New Form
</div>
<q-form
@submit.prevent="createForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="fieldIndex"
class="q-ml-md q-mb-sm field-item"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
outlined
dense
label="Field Description (Optional)"
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
label="Create Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter } from 'vue-router';
const $q = useQuasar();
const router = useRouter();
const form = ref({
title: '',
description: '',
categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
]
});
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
function addCategory()
{
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function createForm()
{
submitting.value = true;
try
{
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" created successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' });
}
catch (error)
{
console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>

View file

@ -1,285 +1,285 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
label="Field Description (Optional)"
outlined
dense
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots
color="primary"
size="40px"
/>
Loading form details...
</div>
<div
v-else
class="text-negative"
>
Failed to load form details.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: String,
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute(); // Use useRoute if needed, though id is from props
const form = ref(null); // Initialize as null
const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm()
{
loading.value = true;
try
{
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || [];
});
form.value = response.data;
}
catch (error)
{
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
form.value = null; // Indicate failure
}
finally
{
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory()
{
if (!form.value.categories)
{
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
if (!form.value.categories[catIndex].fields)
{
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm()
{
submitting.value = true;
try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
// We don't need to send the form ID in the body as it's in the URL
await axios.put(`/api/forms/${props.id}`, payload);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" updated successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
}
catch (error)
{
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>
<template>
<q-page padding>
<div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm">
<q-input
outlined
dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm">
<q-input
outlined
dense
v-model="field.label"
label="Field Label *"
class="col"
lazy-rules
:rules="[ val => val && val.length > 0 || 'Field label required']"
/>
<q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[ val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
label="Field Description (Optional)"
outlined
dense
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
<div v-else-if="loading">
<q-spinner-dots
color="primary"
size="40px"
/>
Loading form details...
</div>
<div
v-else
class="text-negative"
>
Failed to load form details.
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: String,
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute(); // Use useRoute if needed, though id is from props
const form = ref(null); // Initialize as null
const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
async function fetchForm()
{
loading.value = true;
try
{
const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || [];
});
form.value = response.data;
}
catch (error)
{
console.error('Error fetching form details:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
form.value = null; // Indicate failure
}
finally
{
loading.value = false;
}
}
onMounted(fetchForm);
function addCategory()
{
if (!form.value.categories)
{
form.value.categories = [];
}
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
}
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex)
{
if (!form.value.categories[catIndex].fields)
{
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
}
function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
}
async function updateForm()
{
submitting.value = true;
try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description))
// We don't need to send the form ID in the body as it's in the URL
await axios.put(`/api/forms/${props.id}`, payload);
$q.notify({
color: 'positive',
position: 'top',
message: `Form "${form.value.title}" updated successfully!`,
icon: 'check_circle'
});
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
}
catch (error)
{
console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
</script>
<style scoped>
.bordered {
border: 1px solid #ddd;
}
.rounded-borders {
border-radius: 4px;
}
</style>

View file

@ -1,223 +1,223 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">
{{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form
@submit.prevent="submitResponse"
class="q-gutter-md"
>
<div
v-for="category in form.categories"
:key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
v-model="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'number'"
outlined
type="number"
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
v-model="responses[field.id]"
:label="field.label"
stack-label
/>
<q-input
v-else-if="field.type === 'textarea'"
outlined
type="textarea"
autogrow
v-model="responses[field.id]"
:label="field.label"
/>
<q-checkbox
v-else-if="field.type === 'boolean'"
v-model="responses[field.id]"
:label="field.label"
left-label
class="q-mt-sm"
/>
<!-- Add other field types as needed -->
</div>
</div>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</div>
<q-banner
v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const form = ref(null);
const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails()
{
loading.value = true;
form.value = null; // Reset form data
try
{
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default
});
});
}
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
async function submitResponse()
{
submitting.value = true;
try
{
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
// return;
// }
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
$q.notify({
color: 'positive',
position: 'top',
message: 'Response submitted successfully!',
icon: 'check_circle'
});
// Optionally redirect or clear form
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
}
catch (error)
{
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
onMounted(fetchFormDetails);
</script>
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && form">
<div class="text-h4 q-mb-xs">
{{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form
@submit.prevent="submitResponse"
class="q-gutter-md"
>
<div
v-for="category in form.categories"
:key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input
v-if="field.type === 'text'"
outlined
v-model="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'number'"
outlined
type="number"
v-model.number="responses[field.id]"
:label="field.label"
/>
<q-input
v-else-if="field.type === 'date'"
outlined
type="date"
v-model="responses[field.id]"
:label="field.label"
stack-label
/>
<q-input
v-else-if="field.type === 'textarea'"
outlined
type="textarea"
autogrow
v-model="responses[field.id]"
:label="field.label"
/>
<q-checkbox
v-else-if="field.type === 'boolean'"
v-model="responses[field.id]"
:label="field.label"
left-label
class="q-mt-sm"
/>
<!-- Add other field types as needed -->
</div>
</div>
<q-separator class="q-my-lg" />
<div>
<q-btn
outline
label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</div>
<q-banner
v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not be loaded.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
import { useRouter, useRoute } from 'vue-router';
const props = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const router = useRouter();
const route = useRoute();
const form = ref(null);
const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true);
const submitting = ref(false);
async function fetchFormDetails()
{
loading.value = true;
form.value = null; // Reset form data
try
{
const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data;
// Initialize responses object based on fields
form.value.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default
});
});
}
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form details.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
async function submitResponse()
{
submitting.value = true;
try
{
// Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) {
// $q.notify({ color: 'warning', message: 'Please fill in at least one field.' });
// return;
// }
await axios.post(`/api/forms/${props.id}/responses`, { values: responses });
$q.notify({
color: 'positive',
position: 'top',
message: 'Response submitted successfully!',
icon: 'check_circle'
});
// Optionally redirect or clear form
router.push({ name: 'formResponses', params: { id: props.id } }); // Go to responses page after submit
// Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; });
}
catch (error)
{
console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({
color: 'negative',
position: 'top',
message: message,
icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
}
onMounted(fetchFormDetails);
</script>

View file

@ -1,187 +1,187 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Forms
</div>
<q-btn
outline
label="Create New Form"
color="primary"
:to="{ name: 'formCreate' }"
/>
</div>
<q-list
bordered
separator
v-if="forms.length > 0"
>
<q-item
v-for="form in forms"
:key="form.id"
>
<q-item-section>
<q-item-label>{{ form.title }}</q-item-label>
<q-item-label caption>
{{ form.description || 'No description' }}
</q-item-label>
<q-item-label caption>
Created: {{ formatDate(form.createdAt) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-sm">
<q-btn
flat
round
dense
icon="edit_note"
color="info"
:to="{ name: 'formFill', params: { id: form.id } }"
title="Fill Form"
/>
<q-btn
flat
round
dense
icon="visibility"
color="secondary"
:to="{ name: 'formResponses', params: { id: form.id } }"
title="View Responses"
/>
<q-btn
flat
round
dense
icon="edit"
color="warning"
:to="{ name: 'formEdit', params: { id: form.id } }"
title="Edit Form"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click.stop="confirmDeleteForm(form.id)"
title="Delete Form"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<q-banner
v-else
class="bg-info text-white"
>
<template #avatar>
<q-icon
name="info"
color="white"
/>
</template>
No forms created yet. Click the button above to create your first form.
</q-banner>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const forms = ref([]);
const loading = ref(false);
async function fetchForms()
{
loading.value = true;
try
{
const response = await axios.get('/api/forms');
forms.value = response.data;
}
catch (error)
{
console.error('Error fetching forms:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load forms. Please try again later.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Add function to handle delete confirmation
function confirmDeleteForm(id)
{
$q.dialog({
title: 'Confirm Delete',
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
cancel: true,
persistent: true,
ok: {
label: 'Delete',
color: 'negative',
flat: false
},
cancel: {
label: 'Cancel',
flat: true
}
}).onOk(() =>
{
deleteForm(id);
});
}
// Add function to call the delete API
async function deleteForm(id)
{
try
{
await axios.delete(`/api/forms/${id}`);
forms.value = forms.value.filter(form => form.id !== id);
$q.notify({
color: 'positive',
position: 'top',
message: 'Form deleted successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error deleting form ${id}:`, error);
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
$q.notify({
color: 'negative',
position: 'top',
message: errorMessage,
icon: 'report_problem'
});
}
}
// Add function to format date
function formatDate(date)
{
return new Date(date).toLocaleString();
}
onMounted(fetchForms);
</script>
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Forms
</div>
<q-btn
outline
label="Create New Form"
color="primary"
:to="{ name: 'formCreate' }"
/>
</div>
<q-list
bordered
separator
v-if="forms.length > 0"
>
<q-item
v-for="form in forms"
:key="form.id"
>
<q-item-section>
<q-item-label>{{ form.title }}</q-item-label>
<q-item-label caption>
{{ form.description || 'No description' }}
</q-item-label>
<q-item-label caption>
Created: {{ formatDate(form.createdAt) }}
</q-item-label>
</q-item-section>
<q-item-section side>
<div class="q-gutter-sm">
<q-btn
flat
round
dense
icon="edit_note"
color="info"
:to="{ name: 'formFill', params: { id: form.id } }"
title="Fill Form"
/>
<q-btn
flat
round
dense
icon="visibility"
color="secondary"
:to="{ name: 'formResponses', params: { id: form.id } }"
title="View Responses"
/>
<q-btn
flat
round
dense
icon="edit"
color="warning"
:to="{ name: 'formEdit', params: { id: form.id } }"
title="Edit Form"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click.stop="confirmDeleteForm(form.id)"
title="Delete Form"
/>
</div>
</q-item-section>
</q-item>
</q-list>
<q-banner
v-else
class="bg-info text-white"
>
<template #avatar>
<q-icon
name="info"
color="white"
/>
</template>
No forms created yet. Click the button above to create your first form.
</q-banner>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const forms = ref([]);
const loading = ref(false);
async function fetchForms()
{
loading.value = true;
try
{
const response = await axios.get('/api/forms');
forms.value = response.data;
}
catch (error)
{
console.error('Error fetching forms:', error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load forms. Please try again later.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Add function to handle delete confirmation
function confirmDeleteForm(id)
{
$q.dialog({
title: 'Confirm Delete',
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
cancel: true,
persistent: true,
ok: {
label: 'Delete',
color: 'negative',
flat: false
},
cancel: {
label: 'Cancel',
flat: true
}
}).onOk(() =>
{
deleteForm(id);
});
}
// Add function to call the delete API
async function deleteForm(id)
{
try
{
await axios.delete(`/api/forms/${id}`);
forms.value = forms.value.filter(form => form.id !== id);
$q.notify({
color: 'positive',
position: 'top',
message: 'Form deleted successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error deleting form ${id}:`, error);
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
$q.notify({
color: 'negative',
position: 'top',
message: errorMessage,
icon: 'report_problem'
});
}
}
// Add function to format date
function formatDate(date)
{
return new Date(date).toLocaleString();
}
onMounted(fetchForms);
</script>

View file

@ -1,250 +1,250 @@
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div>
<!-- Add Search Input -->
<q-input
v-if="responses.length > 0"
outlined
dense
debounce="300"
v-model="filterText"
placeholder="Search responses..."
class="q-mb-md"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
<q-table
v-if="responses.length > 0"
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat
bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template #body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
dense
round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
aria-label="Download PDF"
>
<q-tooltip>Download PDF</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<q-banner
v-else
class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner
v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const componentProps = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]);
const loading = ref(true);
const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses
async function fetchData()
{
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try
{
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
generatedColumns.push({
name: `field_${field.id}`,
label: field.label,
field: row => row.values[field.id]?.value ?? '',
align: 'left',
sortable: true,
});
});
});
columns.value = generatedColumns;
// Add Actions column
columns.value.push({
name: 'actions',
label: 'Actions',
field: 'actions',
align: 'center'
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data;
}
catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() =>
{
return responses.value.map(response =>
{
const row = {
id: response.id,
submittedAt: response.submittedAt,
// Flatten values for direct access by field function in columns
values: response.values
};
return row;
});
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId)
{
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
// Create a URL for the blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// Clean up
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
$q.notify({
color: 'positive',
position: 'top',
message: `Downloaded ${filename}`,
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to download PDF.',
icon: 'report_problem'
});
}
}
onMounted(fetchData);
</script>
<template>
<q-page padding>
<q-inner-loading :showing="loading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md">
<div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div>
<!-- Add Search Input -->
<q-input
v-if="responses.length > 0"
outlined
dense
debounce="300"
v-model="filterText"
placeholder="Search responses..."
class="q-mb-md"
>
<template #append>
<q-icon name="search" />
</template>
</q-input>
<q-table
v-if="responses.length > 0"
:rows="formattedResponses"
:columns="columns"
row-key="id"
flat
bordered
separator="cell"
wrap-cells
:filter="filterText"
>
<template #body-cell-submittedAt="props">
<q-td :props="props">
{{ new Date(props.value).toLocaleString() }}
</q-td>
</template>
<!-- Slot for Actions column -->
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat
dense
round
icon="download"
color="primary"
@click="downloadResponsePdf(props.row.id)"
aria-label="Download PDF"
>
<q-tooltip>Download PDF</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<q-banner
v-else
class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template>
No responses have been submitted for this form yet.
</q-banner>
</div>
<q-banner
v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" />
</template>
Form not found or could not load responses.
<template #action>
<q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template>
</q-banner>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import axios from 'boot/axios';
import { useQuasar } from 'quasar';
const componentProps = defineProps({
id: {
type: [String, Number],
required: true
}
});
const $q = useQuasar();
const formTitle = ref('');
const responses = ref([]);
const columns = ref([]);
const loading = ref(true);
const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses
async function fetchData()
{
loading.value = true;
formTitle.value = '';
responses.value = [];
columns.value = [];
try
{
// Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data;
formTitle.value = form.title;
// Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat =>
{
cat.fields.forEach(field =>
{
generatedColumns.push({
name: `field_${field.id}`,
label: field.label,
field: row => row.values[field.id]?.value ?? '',
align: 'left',
sortable: true,
});
});
});
columns.value = generatedColumns;
// Add Actions column
columns.value.push({
name: 'actions',
label: 'Actions',
field: 'actions',
align: 'center'
});
// Fetch responses
const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data;
}
catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to load form responses.',
icon: 'report_problem'
});
}
finally
{
loading.value = false;
}
}
// Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() =>
{
return responses.value.map(response =>
{
const row = {
id: response.id,
submittedAt: response.submittedAt,
// Flatten values for direct access by field function in columns
values: response.values
};
return row;
});
});
// Function to download a single response as PDF
async function downloadResponsePdf(responseId)
{
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads
});
// Create a URL for the blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
// Clean up
link.parentNode.removeChild(link);
window.URL.revokeObjectURL(url);
$q.notify({
color: 'positive',
position: 'top',
message: `Downloaded ${filename}`,
icon: 'check_circle'
});
}
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({
color: 'negative',
position: 'top',
message: 'Failed to download PDF.',
icon: 'report_problem'
});
}
}
onMounted(fetchData);
</script>

View file

@ -1,54 +1,54 @@
<template>
<template>
<q-page class="landing-page column items-center q-pa-md">
<div class="hero text-center q-pa-xl full-width">
<div class="hero text-center q-pa-xl full-width">
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">
Welcome to StylePoint
</h1>
</h1>
<p class="text-h6 text-grey-8 q-mb-lg">
The all-in-one tool designed for StyleTech Developers.
</p>
</div>
</p>
</div>
<div
class="features q-mt-xl q-pa-md text-center"
style="max-width: 800px; width: 100%;"
>
>
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">
Features
</h2>
</h2>
<q-list
bordered
separator
class="rounded-borders"
>
>
<q-item
v-for="(feature, index) in features"
:key="index"
class="q-pa-md"
>
<q-item-section>
>
<q-item-section>
<q-item-label class="text-body1">
{{ feature }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</script>

View file

@ -1,130 +1,130 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">
Login
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
@keyup.enter="handleLogin"
:hint="errorMessage ? errorMessage : ''"
:rules="[val => !!val || 'Username is required']"
/>
<q-btn
label="Login with Passkey"
color="primary"
class="full-width"
@click="handleLogin"
:loading="loading"
/>
<div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section>
<q-card-actions align="center">
<q-btn
flat
label="Don't have an account? Register"
to="/register"
/>
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { startAuthentication } from '@simplewebauthn/browser';
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const username = ref('');
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin()
{
loading.value = true;
errorMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
});
const options = optionsRes.data;
// 2. Start authentication ceremony in browser
const authResp = await startAuthentication(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-authentication', {
authenticationResponse: authResp,
});
if (verificationRes.data.verified)
{
// Update the auth store on successful login
authStore.isAuthenticated = true;
authStore.user = verificationRes.data.user;
authStore.error = null; // Clear any previous errors
console.log('Login successful:', verificationRes.data.user);
router.push('/'); // Redirect to home page
}
else
{
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
}
catch (error)
{
console.error('Login error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
// Handle specific simplewebauthn errors if needed
if (error.name === 'NotAllowedError')
{
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
}
else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found'))
{
errorMessage.value = 'User not found. Please check your username or register.';
}
else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found'))
{
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
}
else
{
errorMessage.value = `Login failed: ${message}`;
}
// Optionally update store state on error
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = `Login failed: ${message}`;
}
finally
{
loading.value = false;
}
}
</script>
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">
Login
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
@keyup.enter="handleLogin"
:hint="errorMessage ? errorMessage : ''"
:rules="[val => !!val || 'Username is required']"
/>
<q-btn
label="Login with Passkey"
color="primary"
class="full-width"
@click="handleLogin"
:loading="loading"
/>
<div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section>
<q-card-actions align="center">
<q-btn
flat
label="Don't have an account? Register"
to="/register"
/>
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { startAuthentication } from '@simplewebauthn/browser';
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const username = ref('');
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin()
{
loading.value = true;
errorMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
});
const options = optionsRes.data;
// 2. Start authentication ceremony in browser
const authResp = await startAuthentication(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-authentication', {
authenticationResponse: authResp,
});
if (verificationRes.data.verified)
{
// Update the auth store on successful login
authStore.isAuthenticated = true;
authStore.user = verificationRes.data.user;
authStore.error = null; // Clear any previous errors
console.log('Login successful:', verificationRes.data.user);
router.push('/'); // Redirect to home page
}
else
{
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
}
catch (error)
{
console.error('Login error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
// Handle specific simplewebauthn errors if needed
if (error.name === 'NotAllowedError')
{
errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
}
else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found'))
{
errorMessage.value = 'User not found. Please check your username or register.';
}
else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found'))
{
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
}
else
{
errorMessage.value = `Login failed: ${message}`;
}
// Optionally update store state on error
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = `Login failed: ${message}`;
}
finally
{
loading.value = false;
}
}
</script>

View file

@ -1,248 +1,248 @@
<template>
<q-page padding>
<q-card
flat
bordered
>
<q-card-section class="row items-center justify-between">
<div class="text-h6">
Mantis Summaries
</div>
<q-btn
label="Generate Today's Summary"
color="primary"
@click="generateSummary"
:loading="generating"
:disable="generating"
/>
</q-card-section>
<q-separator />
<q-card-section v-if="generationError">
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ generationError }}
</q-banner>
</q-card-section>
<q-card-section v-if="loading">
<q-spinner-dots
size="40px"
color="primary"
/>
<span class="q-ml-md">Loading summaries...</span>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ error }}
</q-banner>
</q-card-section>
<q-list
separator
v-if="!loading && !error && summaries.length > 0"
>
<q-item
v-for="summary in summaries"
:key="summary.id"
>
<q-item-section>
<q-item-label class="text-weight-bold">
{{ formatDate(summary.summaryDate) }}
</q-item-label>
<q-item-label caption>
Generated: {{ formatDateTime(summary.generatedAt) }}
</q-item-label>
<q-item-label
class="q-mt-sm markdown-content"
>
<div v-html="parseMarkdown(summary.summaryText)" />
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section
v-if="totalPages > 1"
class="flex flex-center q-mt-md"
>
<q-pagination
v-model="currentPage"
:max="totalPages"
@update:model-value="fetchSummaries"
direction-links
flat
color="primary"
active-color="primary"
/>
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">
No summaries found.
</div>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { date, useQuasar } from 'quasar'; // Import useQuasar
import axios from 'boot/axios';
import { marked } from 'marked';
const $q = useQuasar(); // Initialize Quasar plugin usage
const summaries = ref([]);
const loading = ref(true);
const error = ref(null);
const generating = ref(false); // State for generation button
const generationError = ref(null); // State for generation error
const currentPage = ref(1);
const itemsPerPage = ref(10); // Or your desired page size
const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) =>
{
const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
const fetchSummaries = async(page = 1) =>
{
loading.value = true;
error.value = null;
try
{
const response = await axios.get('/api/mantis-summaries', {
params: {
page: page,
limit: itemsPerPage.value
}
});
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
}
catch (err)
{
console.error('Error fetching Mantis summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
}
finally
{
loading.value = false;
}
};
const generateSummary = async() =>
{
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try
{
await axios.post('/api/mantis-summaries/generate');
$q.notify({
color: 'positive',
icon: 'check_circle',
message: 'Summary generation started successfully. It may take a few moments to appear.',
});
// Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
}
catch (err)
{
console.error('Error generating Mantis summary:', err);
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
}
finally
{
generating.value = false;
}
};
const formatDate = (dateString) =>
{
// Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
};
const formatDateTime = (dateTimeString) =>
{
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
};
const parseMarkdown = (markdownText) =>
{
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() =>
{
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() =>
{
fetchSummaries(currentPage.value);
});
</script>
<style scoped>
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
margin-bottom: 1em;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-content :deep(th) {
background-color: rgba(0, 0, 0, 0.25);
}
.markdown-content :deep(a) {
color: var(--q-primary);
text-decoration: none;
font-weight: 600;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
/* Add any specific styles if needed */
</style>
<template>
<q-page padding>
<q-card
flat
bordered
>
<q-card-section class="row items-center justify-between">
<div class="text-h6">
Mantis Summaries
</div>
<q-btn
label="Generate Today's Summary"
color="primary"
@click="generateSummary"
:loading="generating"
:disable="generating"
/>
</q-card-section>
<q-separator />
<q-card-section v-if="generationError">
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ generationError }}
</q-banner>
</q-card-section>
<q-card-section v-if="loading">
<q-spinner-dots
size="40px"
color="primary"
/>
<span class="q-ml-md">Loading summaries...</span>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ error }}
</q-banner>
</q-card-section>
<q-list
separator
v-if="!loading && !error && summaries.length > 0"
>
<q-item
v-for="summary in summaries"
:key="summary.id"
>
<q-item-section>
<q-item-label class="text-weight-bold">
{{ formatDate(summary.summaryDate) }}
</q-item-label>
<q-item-label caption>
Generated: {{ formatDateTime(summary.generatedAt) }}
</q-item-label>
<q-item-label
class="q-mt-sm markdown-content"
>
<div v-html="parseMarkdown(summary.summaryText)" />
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section
v-if="totalPages > 1"
class="flex flex-center q-mt-md"
>
<q-pagination
v-model="currentPage"
:max="totalPages"
@update:model-value="fetchSummaries"
direction-links
flat
color="primary"
active-color="primary"
/>
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">
No summaries found.
</div>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { date, useQuasar } from 'quasar'; // Import useQuasar
import axios from 'boot/axios';
import { marked } from 'marked';
const $q = useQuasar(); // Initialize Quasar plugin usage
const summaries = ref([]);
const loading = ref(true);
const error = ref(null);
const generating = ref(false); // State for generation button
const generationError = ref(null); // State for generation error
const currentPage = ref(1);
const itemsPerPage = ref(10); // Or your desired page size
const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) =>
{
const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
const fetchSummaries = async(page = 1) =>
{
loading.value = true;
error.value = null;
try
{
const response = await axios.get('/api/mantis-summaries', {
params: {
page: page,
limit: itemsPerPage.value
}
});
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
}
catch (err)
{
console.error('Error fetching Mantis summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
}
finally
{
loading.value = false;
}
};
const generateSummary = async() =>
{
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try
{
await axios.post('/api/mantis-summaries/generate');
$q.notify({
color: 'positive',
icon: 'check_circle',
message: 'Summary generation started successfully. It may take a few moments to appear.',
});
// Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
}
catch (err)
{
console.error('Error generating Mantis summary:', err);
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
}
finally
{
generating.value = false;
}
};
const formatDate = (dateString) =>
{
// Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
};
const formatDateTime = (dateTimeString) =>
{
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
};
const parseMarkdown = (markdownText) =>
{
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() =>
{
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() =>
{
fetchSummaries(currentPage.value);
});
</script>
<style scoped>
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
margin-bottom: 1em;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-content :deep(th) {
background-color: rgba(0, 0, 0, 0.25);
}
.markdown-content :deep(a) {
color: var(--q-primary);
text-decoration: none;
font-weight: 600;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
/* Add any specific styles if needed */
</style>

View file

@ -1,371 +1,371 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
:loading="registerLoading"
:disable="registerLoading || !isLoggedIn"
outline
/>
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list
bordered
separator
v-if="passkeys.length > 0 && !fetchLoading"
>
<q-item v-if="registerSuccessMessage || registerErrorMessage">
<div
v-if="registerSuccessMessage"
class="text-positive q-mt-md"
>
{{ registerSuccessMessage }}
</div>
<div
v-if="registerErrorMessage"
class="text-negative q-mt-md"
>
{{ registerErrorMessage }}
</div>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label
caption
v-if="identifiedPasskeyId === passkey.credentialID"
>
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-else-if="fetchLoading"
class="q-mt-md"
>
Loading passkeys...
</div>
<div
v-else
class="q-mt-md"
>
You have no passkeys registered yet.
</div>
<div
v-if="fetchErrorMessage"
class="text-negative q-mt-md"
>
{{ fetchErrorMessage }}
</div>
<div
v-if="deleteSuccessMessage"
class="text-positive q-mt-md"
>
{{ deleteSuccessMessage }}
</div>
<div
v-if="deleteErrorMessage"
class="text-negative q-mt-md"
>
{{ deleteErrorMessage }}
</div>
<div
v-if="identifyErrorMessage"
class="text-negative q-mt-md"
>
{{ identifyErrorMessage }}
</div>
</q-card-section>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try
{
const response = await axios.get('/api/auth/passkeys');
passkeys.value = response.data || [];
}
catch (error)
{
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
}
finally
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
if (!isLoggedIn.value)
{
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.';
}
else if (error.name === 'NotAllowedError')
{
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.';
}
else
{
registerErrorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
await axios.delete(`/api/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try
{
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() =>
{
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
:loading="registerLoading"
:disable="registerLoading || !isLoggedIn"
outline
/>
</div>
</div>
<!-- Passkey List Section -->
<q-card-section>
<h5>Your Registered Passkeys</h5>
<q-list
bordered
separator
v-if="passkeys.length > 0 && !fetchLoading"
>
<q-item v-if="registerSuccessMessage || registerErrorMessage">
<div
v-if="registerSuccessMessage"
class="text-positive q-mt-md"
>
{{ registerSuccessMessage }}
</div>
<div
v-if="registerErrorMessage"
class="text-negative q-mt-md"
>
{{ registerErrorMessage }}
</div>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label
caption
v-if="identifiedPasskeyId === passkey.credentialID"
>
Verified just now!
</q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section>
<q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-else-if="fetchLoading"
class="q-mt-md"
>
Loading passkeys...
</div>
<div
v-else
class="q-mt-md"
>
You have no passkeys registered yet.
</div>
<div
v-if="fetchErrorMessage"
class="text-negative q-mt-md"
>
{{ fetchErrorMessage }}
</div>
<div
v-if="deleteSuccessMessage"
class="text-positive q-mt-md"
>
{{ deleteSuccessMessage }}
</div>
<div
v-if="deleteErrorMessage"
class="text-negative q-mt-md"
>
{{ deleteErrorMessage }}
</div>
<div
v-if="identifyErrorMessage"
class="text-negative q-mt-md"
>
{{ identifyErrorMessage }}
</div>
</q-card-section>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try
{
const response = await axios.get('/api/auth/passkeys');
passkeys.value = response.data || [];
}
catch (error)
{
console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error
}
finally
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
initialAuthError = `Authentication error: ${authStore.error}`;
}
}
if (!isLoggedIn.value)
{
// Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.';
}
else if (error.name === 'NotAllowedError')
{
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.';
}
else
{
registerErrorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
await axios.delete(`/api/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
try
{
// 1. Get authentication options from the server
// We don't need to send username as the server should use the session
const optionsRes = await axios.post('/api/auth/generate-authentication-options', {}); // Send empty body
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight
setTimeout(() =>
{
// Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}
</script>

View file

@ -1,179 +1,179 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<!-- Update title based on login status from store -->
<div class="text-h6">
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
:rules="[val => !!val || 'Username is required']"
@keyup.enter="handleRegister"
:disable="isLoggedIn"
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
:readonly="isLoggedIn"
/>
<q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
color="primary"
class="full-width"
@click="handleRegister"
:loading="loading"
:disable="loading || (!username && !isLoggedIn)"
/>
<div
v-if="successMessage"
class="text-positive q-mt-md"
>
{{ successMessage }}
</div>
<div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section>
<q-card-actions align="center">
<!-- Hide login link if already logged in based on store state -->
<q-btn
v-if="!isLoggedIn"
flat
label="Already have an account? Login"
to="/login"
/>
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router';
import { startRegistration } from '@simplewebauthn/browser';
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input
// Check auth status on component mount using the store action
onMounted(async() =>
{
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in
}
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister()
{
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername)
{
errorMessage.value = 'Username is missing.';
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: currentUsername, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
// Adjust success message based on login state
successMessage.value = isLoggedIn.value
? 'New passkey registered successfully!'
: 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value)
{
// Redirect to login page only if they weren't logged in
setTimeout(() =>
{
router.push('/login');
}, 2000);
}
else
{
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
}
else
{
errorMessage.value = 'Registration failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
}
else if (error.name === 'NotAllowedError')
{
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
errorMessage.value = 'This passkey seems to be registered already.';
}
else
{
errorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
loading.value = false;
}
}
</script>
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<!-- Update title based on login status from store -->
<div class="text-h6">
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
:rules="[val => !!val || 'Username is required']"
@keyup.enter="handleRegister"
:disable="isLoggedIn"
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
:readonly="isLoggedIn"
/>
<q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
color="primary"
class="full-width"
@click="handleRegister"
:loading="loading"
:disable="loading || (!username && !isLoggedIn)"
/>
<div
v-if="successMessage"
class="text-positive q-mt-md"
>
{{ successMessage }}
</div>
<div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section>
<q-card-actions align="center">
<!-- Hide login link if already logged in based on store state -->
<q-btn
v-if="!isLoggedIn"
flat
label="Already have an account? Login"
to="/login"
/>
</q-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router';
import { startRegistration } from '@simplewebauthn/browser';
import axios from 'boot/axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input
// Check auth status on component mount using the store action
onMounted(async() =>
{
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in
}
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister()
{
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername)
{
errorMessage.value = 'Username is missing.';
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/api/auth/generate-registration-options', {
username: currentUsername, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/api/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
// Adjust success message based on login state
successMessage.value = isLoggedIn.value
? 'New passkey registered successfully!'
: 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value)
{
// Redirect to login page only if they weren't logged in
setTimeout(() =>
{
router.push('/login');
}, 2000);
}
else
{
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
}
else
{
errorMessage.value = 'Registration failed.';
}
}
catch (error)
{
console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError')
{
errorMessage.value = 'Authenticator already registered. Try logging in instead.';
}
else if (error.name === 'NotAllowedError')
{
errorMessage.value = 'Registration ceremony was cancelled or timed out.';
}
else if (error.response?.status === 409)
{
errorMessage.value = 'This passkey seems to be registered already.';
}
else
{
errorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
loading.value = false;
}
}
</script>

View file

@ -1,202 +1,202 @@
<template>
<q-page padding>
<div
class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
<q-input
v-model="mantisPrompt"
type="textarea"
filled
autogrow
label="Mantis Prompt"
:loading="loadingPrompt"
:disable="savingPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveMantisPrompt"
:loading="savingPrompt"
:disable="!mantisPrompt || loadingPrompt"
/>
</q-card-actions>
</q-card>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div>
<q-input
v-model="emailPrompt"
type="textarea"
filled
autogrow
label="Email Prompt"
:loading="loadingEmailPrompt"
:disable="savingEmailPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveEmailPrompt"
:loading="savingEmailPrompt"
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import axios from 'boot/axios';
const $q = useQuasar();
const mantisPrompt = ref('');
const loadingPrompt = ref(false);
const savingPrompt = ref(false);
const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true;
try
{
const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingPrompt.value = false;
}
};
const saveMantisPrompt = async() =>
{
savingPrompt.value = true;
try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({
color: 'positive',
message: 'Mantis prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingPrompt.value = false;
}
};
const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingEmailPrompt.value = false;
}
};
onMounted(() =>
{
fetchMantisPrompt();
fetchEmailPrompt();
});
</script>
<style scoped>
/* Add any specific styles if needed */
</style>
<template>
<q-page padding>
<div
class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div>
<q-input
v-model="mantisPrompt"
type="textarea"
filled
autogrow
label="Mantis Prompt"
:loading="loadingPrompt"
:disable="savingPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveMantisPrompt"
:loading="savingPrompt"
:disable="!mantisPrompt || loadingPrompt"
/>
</q-card-actions>
</q-card>
<q-card
flat
bordered
>
<q-card-section>
<div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div>
<q-input
v-model="emailPrompt"
type="textarea"
filled
autogrow
label="Email Prompt"
:loading="loadingEmailPrompt"
:disable="savingEmailPrompt"
/>
</q-card-section>
<q-card-actions align="right">
<q-btn
label="Save Prompt"
color="primary"
@click="saveEmailPrompt"
:loading="savingEmailPrompt"
:disable="!emailPrompt || loadingEmailPrompt"
/>
</q-card-actions>
</q-card>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useQuasar } from 'quasar';
import axios from 'boot/axios';
const $q = useQuasar();
const mantisPrompt = ref('');
const loadingPrompt = ref(false);
const savingPrompt = ref(false);
const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true;
try
{
const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingPrompt.value = false;
}
};
const saveMantisPrompt = async() =>
{
savingPrompt.value = true;
try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({
color: 'positive',
message: 'Mantis prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Mantis prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingPrompt.value = false;
}
};
const emailPrompt = ref('');
const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true;
try
{
const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
}
catch (error)
{
console.error('Error fetching Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to load Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
loadingEmailPrompt.value = false;
}
};
const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true;
try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({
color: 'positive',
message: 'Email prompt updated successfully.',
icon: 'check_circle'
});
}
catch (error)
{
console.error('Error saving Email prompt:', error);
$q.notify({
color: 'negative',
message: 'Failed to save Email prompt setting.',
icon: 'report_problem'
});
}
finally
{
savingEmailPrompt.value = false;
}
};
onMounted(() =>
{
fetchMantisPrompt();
fetchEmailPrompt();
});
</script>
<style scoped>
/* Add any specific styles if needed */
</style>

View file

@ -12,7 +12,7 @@ import { useAuthStore } from 'stores/auth'; // Import the auth store
* with the Router instance.
*/
export default defineRouter(function({ store /* { store, ssrContext } */ })
export default defineRouter(function({ store /* { store, ssrContext } */ })
{
const createHistory = process.env.SERVER
? createMemoryHistory
@ -29,19 +29,19 @@ export default defineRouter(function({ store /* { store, ssrContext } */ })
});
// Navigation Guard using Pinia store
Router.beforeEach(async(to, from, next) =>
Router.beforeEach(async(to, from, next) =>
{
const authStore = useAuthStore(store); // Get store instance
// Ensure auth status is checked, especially on first load or refresh
// This check might be better placed in App.vue or a boot file
if (!authStore.user && !authStore.loading)
if (!authStore.user && !authStore.loading)
{ // Check only if user is not loaded and not already loading
try
try
{
await authStore.checkAuthStatus();
}
catch (e)
catch (e)
{
// console.error('Initial auth check failed', e);
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
@ -53,15 +53,15 @@ export default defineRouter(function({ store /* { store, ssrContext } */ })
const isPublicPage = publicPages.includes(to.path);
const isAuthenticated = authStore.isAuthenticated; // Get status from store
if (requiresAuth && !isAuthenticated)
if (requiresAuth && !isAuthenticated)
{
next('/login');
}
else if (isPublicPage && isAuthenticated)
else if (isPublicPage && isAuthenticated)
{
next('/');
}
else
else
{
next();
}

View file

@ -9,40 +9,40 @@ export const useAuthStore = defineStore('auth', {
error: null, // Optional: track errors
}),
actions: {
async checkAuthStatus()
async checkAuthStatus()
{
this.loading = true;
this.error = null;
try
try
{
const res = await axios.get('/api/auth/status', {
withCredentials: true, // Ensure cookies are sent with the request
});
if (res.data.status === 'authenticated')
if (res.data.status === 'authenticated')
{
this.isAuthenticated = true;
this.user = res.data.user;
}
else
else
{
this.isAuthenticated = false;
this.user = null;
}
}
catch (error)
catch (error)
{
// console.error('Failed to check authentication status:', error);
this.error = 'Could not verify login status.';
this.isAuthenticated = false;
this.user = null;
}
finally
finally
{
this.loading = false;
}
},
// Action to manually set user as logged out (e.g., after logout)
logout()
logout()
{
this.isAuthenticated = false;
this.user = null;

View file

@ -1,256 +1,256 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'boot/axios';
export const useChatStore = defineStore('chat', () =>
{
const isVisible = ref(false);
const currentThreadId = ref(null);
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
const isLoading = ref(false);
const error = ref(null);
const pollingIntervalId = ref(null); // To store the interval ID
// --- Getters ---
const chatMessages = computed(() => messages.value);
const isChatVisible = computed(() => isVisible.value);
const activeThreadId = computed(() => currentThreadId.value);
// --- Actions ---
// New action to create a thread if it doesn't exist
async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread
isLoading.value = true;
error.value = null;
try
{
// Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId;
messages.value = []; // Start with an empty message list for the new thread
console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID
startPolling();
}
catch (err)
{
console.error('Error creating chat thread:', err);
error.value = 'Failed to start chat.';
// Don't set isVisible to false, let the user see the error
}
finally
{
isLoading.value = false;
}
}
function toggleChat()
{
isVisible.value = !isVisible.value;
if (isVisible.value)
{
if (!currentThreadId.value)
{
// If opening and no thread exists, create one
createThreadIfNotExists();
}
else
{
// If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0)
{
fetchMessages();
}
startPolling();
}
}
else
{
// If closing, stop polling
stopPolling();
}
}
async function fetchMessages()
{
if (!currentThreadId.value)
{
console.log('No active thread to fetch messages for.');
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
return;
}
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
// isLoading.value = true; // Might cause flickering during polling
error.value = null; // Clear previous errors on fetch attempt
try
{
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
const newMessages = response.data.map(msg => ({
sender: msg.sender,
content: msg.content,
createdAt: new Date(msg.createdAt),
loading: msg.content === 'Loading...'
})).sort((a, b) => a.createdAt - b.createdAt);
// Only update if messages have actually changed to prevent unnecessary re-renders
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
{
messages.value = newMessages;
}
}
catch (err)
{
console.error('Error fetching messages:', err);
error.value = 'Failed to load messages.';
// Don't clear messages on polling error, keep the last known state
// messages.value = [];
stopPolling(); // Stop polling if there's an error fetching
}
finally
{
// isLoading.value = false;
}
}
// Function to start polling
function startPolling()
{
if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for
console.log('Starting chat polling for thread:', currentThreadId.value);
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
}
// Function to stop polling
function stopPolling()
{
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
async function sendMessage(content)
{
if (!content.trim()) return;
if (!currentThreadId.value)
{
error.value = 'Cannot send message: No active chat thread.';
console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation
}
const userMessage = {
sender: 'user',
content: content.trim(),
createdAt: new Date(),
};
messages.value.push(userMessage);
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
messages.value.push(loadingMessage);
// Stop polling temporarily while sending a message to avoid conflicts
stopPolling();
isLoading.value = true; // Indicate activity
error.value = null;
try
{
const payload = { content: userMessage.content };
// Always post to the existing thread once it's created
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
// Remove loading indicator
messages.value = messages.value.filter(m => !m.loading);
// The POST might return the new message, but we'll rely on the next fetchMessages call
// triggered by startPolling to get the latest state including any potential bot response.
// Immediately fetch messages after sending to get the updated list
await fetchMessages();
}
catch (err)
{
console.error('Error sending message:', err);
error.value = 'Failed to send message.';
// Remove loading indicator on error
messages.value = messages.value.filter(m => !m.loading);
// Optionally add an error message to the chat
// Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
}
finally
{
isLoading.value = false;
// Restart polling after sending attempt is complete
startPolling();
}
}
// Call this when the user logs out or the app closes if you want to clear state
function resetChat()
{
stopPolling(); // Ensure polling stops on reset
isVisible.value = false;
currentThreadId.value = null;
messages.value = [];
isLoading.value = false;
error.value = null;
}
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
// watch(isVisible, (newValue) => {
// if (newValue && currentThreadId.value) {
// startPolling();
// } else {
// stopPolling();
// }
// });
// Watch for thread ID changes (e.g., after creation)
// watch(currentThreadId, (newId) => {
// if (newId && isVisible.value) {
// messages.value = []; // Clear old messages if any
// fetchMessages(); // Fetch messages for the new thread
// startPolling(); // Start polling for the new thread
// } else {
// stopPolling(); // Stop polling if thread ID becomes null
// }
// });
return {
// State refs
isVisible,
currentThreadId,
messages,
isLoading,
error,
// Computed getters
chatMessages,
isChatVisible,
activeThreadId,
// Actions
toggleChat,
sendMessage,
fetchMessages, // Expose if needed externally
resetChat,
// Expose polling control if needed externally, though typically managed internally
// startPolling,
// stopPolling,
};
});
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'boot/axios';
export const useChatStore = defineStore('chat', () =>
{
const isVisible = ref(false);
const currentThreadId = ref(null);
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
const isLoading = ref(false);
const error = ref(null);
const pollingIntervalId = ref(null); // To store the interval ID
// --- Getters ---
const chatMessages = computed(() => messages.value);
const isChatVisible = computed(() => isVisible.value);
const activeThreadId = computed(() => currentThreadId.value);
// --- Actions ---
// New action to create a thread if it doesn't exist
async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread
isLoading.value = true;
error.value = null;
try
{
// Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId;
messages.value = []; // Start with an empty message list for the new thread
console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID
startPolling();
}
catch (err)
{
console.error('Error creating chat thread:', err);
error.value = 'Failed to start chat.';
// Don't set isVisible to false, let the user see the error
}
finally
{
isLoading.value = false;
}
}
function toggleChat()
{
isVisible.value = !isVisible.value;
if (isVisible.value)
{
if (!currentThreadId.value)
{
// If opening and no thread exists, create one
createThreadIfNotExists();
}
else
{
// If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0)
{
fetchMessages();
}
startPolling();
}
}
else
{
// If closing, stop polling
stopPolling();
}
}
async function fetchMessages()
{
if (!currentThreadId.value)
{
console.log('No active thread to fetch messages for.');
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
return;
}
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
// isLoading.value = true; // Might cause flickering during polling
error.value = null; // Clear previous errors on fetch attempt
try
{
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
const newMessages = response.data.map(msg => ({
sender: msg.sender,
content: msg.content,
createdAt: new Date(msg.createdAt),
loading: msg.content === 'Loading...'
})).sort((a, b) => a.createdAt - b.createdAt);
// Only update if messages have actually changed to prevent unnecessary re-renders
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
{
messages.value = newMessages;
}
}
catch (err)
{
console.error('Error fetching messages:', err);
error.value = 'Failed to load messages.';
// Don't clear messages on polling error, keep the last known state
// messages.value = [];
stopPolling(); // Stop polling if there's an error fetching
}
finally
{
// isLoading.value = false;
}
}
// Function to start polling
function startPolling()
{
if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for
console.log('Starting chat polling for thread:', currentThreadId.value);
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
}
// Function to stop polling
function stopPolling()
{
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
async function sendMessage(content)
{
if (!content.trim()) return;
if (!currentThreadId.value)
{
error.value = 'Cannot send message: No active chat thread.';
console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation
}
const userMessage = {
sender: 'user',
content: content.trim(),
createdAt: new Date(),
};
messages.value.push(userMessage);
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
messages.value.push(loadingMessage);
// Stop polling temporarily while sending a message to avoid conflicts
stopPolling();
isLoading.value = true; // Indicate activity
error.value = null;
try
{
const payload = { content: userMessage.content };
// Always post to the existing thread once it's created
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
// Remove loading indicator
messages.value = messages.value.filter(m => !m.loading);
// The POST might return the new message, but we'll rely on the next fetchMessages call
// triggered by startPolling to get the latest state including any potential bot response.
// Immediately fetch messages after sending to get the updated list
await fetchMessages();
}
catch (err)
{
console.error('Error sending message:', err);
error.value = 'Failed to send message.';
// Remove loading indicator on error
messages.value = messages.value.filter(m => !m.loading);
// Optionally add an error message to the chat
// Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
}
finally
{
isLoading.value = false;
// Restart polling after sending attempt is complete
startPolling();
}
}
// Call this when the user logs out or the app closes if you want to clear state
function resetChat()
{
stopPolling(); // Ensure polling stops on reset
isVisible.value = false;
currentThreadId.value = null;
messages.value = [];
isLoading.value = false;
error.value = null;
}
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
// watch(isVisible, (newValue) => {
// if (newValue && currentThreadId.value) {
// startPolling();
// } else {
// stopPolling();
// }
// });
// Watch for thread ID changes (e.g., after creation)
// watch(currentThreadId, (newId) => {
// if (newId && isVisible.value) {
// messages.value = []; // Clear old messages if any
// fetchMessages(); // Fetch messages for the new thread
// startPolling(); // Start polling for the new thread
// } else {
// stopPolling(); // Stop polling if thread ID becomes null
// }
// });
return {
// State refs
isVisible,
currentThreadId,
messages,
isLoading,
error,
// Computed getters
chatMessages,
isChatVisible,
activeThreadId,
// Actions
toggleChat,
sendMessage,
fetchMessages, // Expose if needed externally
resetChat,
// Expose polling control if needed externally, though typically managed internally
// startPolling,
// stopPolling,
};
});

View file

@ -1,21 +1,21 @@
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) =>
{
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) =>
{
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});