Refactor frontend components and styles for improved UI consistency and responsiveness. Update HTML structure in index.html, enhance SCSS variables in valerie-ui.scss, and implement new layout styles across various pages. Adjust component props and event emissions for better data handling in CreateListModal and ConflictResolutionDialog. Add Material Icons for better visual representation in navigation. Ensure all changes align with the overall design system for a cohesive user experience.
This commit is contained in:
parent
c8cdbd571e
commit
eb19230b22
@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
|
||||
@ -7,26 +8,58 @@
|
||||
<meta name="description" content="mitlist pwa">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
<!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
|
||||
<title>mitlist</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<svg width="0" height="0" style="position: absolute">
|
||||
<defs>
|
||||
<symbol viewBox="0 0 24 24" id="icon-plus"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-edit"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-trash"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-check"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-close"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-alert-triangle"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-clipboard"><path d="M16 2H8C6.9 2 6 2.9 6 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-4 18c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-10H8V8h8v2zm2-4V4l4 4h-4z" /></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-info"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-user"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-bell"> <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.21 1.79-4 4-4s4 1.79 4 4v6z"/> </symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-plus">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-edit">
|
||||
<path
|
||||
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-trash">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-check">
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-close">
|
||||
<path
|
||||
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-alert-triangle">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-clipboard">
|
||||
<path
|
||||
d="M16 2H8C6.9 2 6 2.9 6 4v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm-4 18c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-10H8V8h8v2zm2-4V4l4 4h-4z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-info">
|
||||
<path
|
||||
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-settings">
|
||||
<path
|
||||
d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-user">
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" id="icon-bell">
|
||||
<path
|
||||
d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.21 1.79-4 4-4s4 1.79 4 4v6z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
@ -18,7 +18,8 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: #2c3e50;
|
||||
background-color: #f0f2f5; /* Example background */
|
||||
background-color: #f0f2f5;
|
||||
/* Example background */
|
||||
}
|
||||
|
||||
#app {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -57,7 +57,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'created'): void;
|
||||
(e: 'created', newList: any): void;
|
||||
}>();
|
||||
|
||||
const isOpen = useVModel(props, 'modelValue', emit);
|
||||
@ -108,7 +108,7 @@ const onSubmit = async () => {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
|
||||
const response = await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
|
||||
name: listName.value,
|
||||
description: description.value,
|
||||
group_id: selectedGroupId.value,
|
||||
@ -116,7 +116,7 @@ const onSubmit = async () => {
|
||||
|
||||
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
|
||||
|
||||
emit('created');
|
||||
emit('created', response.data);
|
||||
closeModal();
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create list';
|
||||
|
@ -5,7 +5,11 @@
|
||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||
<button @click="toggleUserMenu" class="user-menu-button">
|
||||
<!-- Placeholder for user icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ff7b54">
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||
@ -14,29 +18,47 @@
|
||||
</header>
|
||||
|
||||
<main class="page-container">
|
||||
<router-view />
|
||||
<keep-alive>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<component :is="Component" v-if="route.meta.keepAlive" />
|
||||
<component :is="Component" v-else :key="route.fullPath" />
|
||||
</router-view>
|
||||
</keep-alive>
|
||||
</main>
|
||||
|
||||
<OfflineIndicator />
|
||||
|
||||
<footer class="app-footer">
|
||||
<nav class="tabs">
|
||||
<router-link to="/lists" class="tab-item" active-class="active">Lists</router-link>
|
||||
<router-link to="/groups" class="tab-item" active-class="active">Groups</router-link>
|
||||
<router-link to="/account" class="tab-item" active-class="active">Account</router-link>
|
||||
<router-link to="/lists" class="tab-item" active-class="active">
|
||||
<span class="material-icons">list</span>
|
||||
<span class="tab-text">Lists</span>
|
||||
</router-link>
|
||||
<router-link to="/groups" class="tab-item" active-class="active">
|
||||
<span class="material-icons">group</span>
|
||||
<span class="tab-text">Groups</span>
|
||||
</router-link>
|
||||
<!-- <router-link to="/account" class="tab-item" active-class="active">
|
||||
<span class="material-icons">person</span>
|
||||
<span class="tab-text">Account</span>
|
||||
</router-link> -->
|
||||
</nav>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, defineComponent } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
|
||||
defineComponent({
|
||||
name: 'MainLayout'
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
@ -113,6 +135,7 @@ const handleLogout = async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@ -135,6 +158,7 @@ const handleLogout = async () => {
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
@ -170,15 +194,29 @@ const handleLogout = async () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color); // Or a specific inactive tab color
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.8rem; // Example size
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
gap: 4px;
|
||||
|
||||
.material-icons {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
// Icon would go here if you add them
|
||||
// Example: svg or <i> for icon fonts
|
||||
.tab-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
|
||||
.tab-text {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--primary-color);
|
||||
|
@ -9,7 +9,9 @@
|
||||
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true"><use xlink:href="#icon-alert-triangle" /></svg>
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
|
||||
@ -229,31 +231,44 @@ onMounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem; /* Or use var(--padding-page) if defined in Valerie UI */
|
||||
padding: 1rem;
|
||||
/* Or use var(--padding-page) if defined in Valerie UI */
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* From Valerie UI */
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.mb-3 { margin-bottom: 1.5rem; } /* From Valerie UI */
|
||||
.flex-grow { flex-grow: 1; }
|
||||
|
||||
.preference-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preference-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #eee; /* Softer border for list items */
|
||||
border-bottom: 1px solid #eee;
|
||||
/* Softer border for list items */
|
||||
}
|
||||
|
||||
.preference-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.preference-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.preference-label small {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
|
@ -4,28 +4,30 @@
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading group details...</p>
|
||||
</div>
|
||||
<div v-else-if="error" class="alert alert-error" role="alert">
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroupDetails">Retry</button>
|
||||
</div>
|
||||
<div v-else-if="group">
|
||||
<h1 class="mb-3">Group: {{ group.name }}</h1>
|
||||
<h1 class="mb-3">{{ group.name }}</h1>
|
||||
|
||||
<div class="neo-grid">
|
||||
<!-- Group Members Section -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div class="neo-card">
|
||||
<div class="neo-card-header">
|
||||
<h3>Group Members</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="group.members && group.members.length > 0" class="members-list">
|
||||
<div v-for="member in group.members" :key="member.id" class="member-item">
|
||||
<div class="member-info">
|
||||
<span class="member-name">{{ member.email }}</span>
|
||||
<span class="member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
|
||||
<div class="neo-card-body">
|
||||
<div v-if="group.members && group.members.length > 0" class="neo-members-list">
|
||||
<div v-for="member in group.members" :key="member.id" class="neo-member-item">
|
||||
<div class="neo-member-info">
|
||||
<span class="neo-member-name">{{ member.email }}</span>
|
||||
<span class="neo-member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
|
||||
</div>
|
||||
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
|
||||
:disabled="removingMember === member.id">
|
||||
@ -35,45 +37,52 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted">
|
||||
No members found.
|
||||
<div v-else class="neo-empty-state">
|
||||
<svg class="icon icon-lg" aria-hidden="true">
|
||||
<use xlink:href="#icon-users" />
|
||||
</svg>
|
||||
<p>No members found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder for lists related to this group -->
|
||||
<div class="mt-4">
|
||||
<ListsPage :group-id="groupId" />
|
||||
</div>
|
||||
|
||||
<!-- Invite Members Section -->
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<div class="neo-card">
|
||||
<div class="neo-card-header">
|
||||
<h3>Invite Members</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
|
||||
<div class="neo-card-body">
|
||||
<button class="btn btn-primary w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
{{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
|
||||
</button>
|
||||
<div v-if="inviteCode" class="form-group mt-2">
|
||||
<label for="inviteCodeInput" class="form-label">Current Active Invite Code:</label>
|
||||
<div class="flex items-center">
|
||||
<input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
|
||||
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
|
||||
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
||||
<label for="inviteCodeInput" class="neo-label">Current Active Invite Code:</label>
|
||||
<div class="neo-input-group">
|
||||
<input id="inviteCodeInput" type="text" :value="inviteCode" class="neo-input" readonly />
|
||||
<button class="btn btn-neutral btn-icon-only" @click="copyInviteCodeHandler"
|
||||
aria-label="Copy invite code">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-clipboard"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
||||
<p v-if="copySuccess" class="neo-success-text">Invite code copied to clipboard!</p>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<p class="text-muted">No active invite code. Click the button above to generate one.</p>
|
||||
<div v-else class="neo-empty-state mt-3">
|
||||
<svg class="icon icon-lg" aria-hidden="true">
|
||||
<use xlink:href="#icon-link" />
|
||||
</svg>
|
||||
<p>No active invite code. Click the button above to generate one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lists Section -->
|
||||
<div class="mt-4">
|
||||
<ListsPage :group-id="groupId" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -247,6 +256,8 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
@ -273,64 +284,167 @@ onMounted(() => {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Adjusted from Valerie UI for tighter fit */
|
||||
|
||||
.form-success-text {
|
||||
color: var(--success);
|
||||
/* Or a darker green for text */
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
/* Neo Grid Layout */
|
||||
.neo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Members list styles */
|
||||
.members-list {
|
||||
/* Neo Card Styles */
|
||||
.neo-card {
|
||||
border-radius: 18px;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
background: #fff;
|
||||
border: 3px solid #111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.neo-card-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 3px solid #111;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.neo-card-header h3 {
|
||||
font-weight: 900;
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.neo-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Members List Styles */
|
||||
.neo-members-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
.neo-member-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--surface-2);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
border: 2px solid #111;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
.neo-member-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.neo-member-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-weight: 500;
|
||||
.neo-member-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.member-role {
|
||||
.neo-member-role {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--surface-3);
|
||||
background: #e0e0e0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-role.owner {
|
||||
background-color: var(--primary);
|
||||
.neo-member-role.owner {
|
||||
background: #111;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
/* Invite Code Styles */
|
||||
.neo-invite-code {
|
||||
background: #fafafa;
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #111;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-2);
|
||||
font-style: italic;
|
||||
.neo-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #111;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.neo-success-text {
|
||||
color: var(--success);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Empty State Styles */
|
||||
.neo-empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.neo-empty-state .icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.neo-grid {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-padding {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-card-header,
|
||||
.neo-card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.neo-member-item {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.neo-member-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,56 +1,58 @@
|
||||
<template>
|
||||
<main class="container page-padding">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h1>Your Groups</h1>
|
||||
<button class="btn btn-primary" @click="openCreateGroupDialog">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
<!-- <h1 class="mb-3">Your Groups</h1> -->
|
||||
|
||||
|
||||
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading groups...</p>
|
||||
</div>
|
||||
<div v-else-if="fetchError" class="alert alert-error" role="alert">
|
||||
<div v-if="fetchError" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
{{ fetchError }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button>
|
||||
</div>
|
||||
<ul v-else-if="groups.length" class="item-list">
|
||||
<li v-for="group in groups" :key="group.id" class="list-item interactive-list-item" @click="selectGroup(group)"
|
||||
@keydown.enter="selectGroup(group)" tabindex="0">
|
||||
<div class="list-item-content">
|
||||
<span class="item-text">{{ group.name }}</span>
|
||||
<!-- Could add more details here if needed -->
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="card empty-state-card">
|
||||
|
||||
<div v-else-if="groups.length === 0" class="card empty-state-card">
|
||||
<svg class="icon icon-lg" aria-hidden="true">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg>
|
||||
<h3>No Groups Yet!</h3>
|
||||
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
|
||||
<button class="btn btn-primary mt-2" @click="openCreateGroupDialog">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
Create New Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="card mb-3">
|
||||
<summary class="card-header flex items-center cursor-pointer"
|
||||
style="display: flex; justify-content: space-between;">
|
||||
<div v-else class="mb-3">
|
||||
<div class="neo-groups-grid">
|
||||
<div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
|
||||
<h1 class="neo-group-header">{{ group.name }}</h1>
|
||||
<div class="neo-group-actions">
|
||||
<button class="btn btn-sm btn-secondary" @click.stop="openCreateListDialog(group)">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="neo-create-group-card" @click="openCreateGroupDialog">
|
||||
+ Group
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="card mb-3 mt-4">
|
||||
<summary class="card-header flex items-center cursor-pointer justify-between">
|
||||
<h3>
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-user" />
|
||||
</svg> <!-- Placeholder icon -->
|
||||
</svg>
|
||||
Join a Group with Invite Code
|
||||
</h3>
|
||||
<span class="expand-icon" aria-hidden="true">▼</span> <!-- Basic expand indicator -->
|
||||
<span class="expand-icon" aria-hidden="true">▼</span>
|
||||
</summary>
|
||||
<div class="card-body">
|
||||
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
|
||||
@ -67,6 +69,7 @@
|
||||
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Dialog -->
|
||||
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
||||
@ -99,26 +102,34 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create List Modal -->
|
||||
<CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { useNotificationStore } from '@/stores/notifications';
|
||||
import CreateListModal from '@/components/CreateListModal.vue';
|
||||
|
||||
interface Group {
|
||||
id: string | number;
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
member_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
const groups = ref<Group[]>([]);
|
||||
const loading = ref(true);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref<string | null>(null);
|
||||
|
||||
const showCreateGroupDialog = ref(false);
|
||||
@ -133,20 +144,37 @@ const joiningGroup = ref(false);
|
||||
const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
|
||||
const joinGroupFormError = ref<string | null>(null);
|
||||
|
||||
const showCreateListModal = ref(false);
|
||||
const availableGroupsForModal = ref<{ label: string; value: number; }[]>([]);
|
||||
|
||||
// Cache groups in localStorage
|
||||
const cachedGroups = useStorage<Group[]>('cached-groups', []);
|
||||
const cachedTimestamp = useStorage<number>('cached-groups-timestamp', 0);
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Load cached data immediately if available and not expired
|
||||
const loadCachedData = () => {
|
||||
const now = Date.now();
|
||||
if (cachedGroups.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
||||
groups.value = cachedGroups.value;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch fresh data from API
|
||||
const fetchGroups = async () => {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||
groups.value = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load groups. Please try again.';
|
||||
fetchError.value = message;
|
||||
groups.value = response.data;
|
||||
|
||||
// Update cache
|
||||
cachedGroups.value = response.data;
|
||||
cachedTimestamp.value = Date.now();
|
||||
} catch (err) {
|
||||
fetchError.value = err instanceof Error ? err.message : 'Failed to load groups';
|
||||
// If we have cached data, keep showing it even if refresh failed
|
||||
if (cachedGroups.value.length === 0) {
|
||||
groups.value = [];
|
||||
console.error('Error fetching groups:', error);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -182,6 +210,9 @@ const handleCreateGroup = async () => {
|
||||
groups.value.push(newGroup);
|
||||
closeCreateGroupDialog();
|
||||
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
|
||||
// Update cache
|
||||
cachedGroups.value = groups.value;
|
||||
cachedTimestamp.value = Date.now();
|
||||
} else {
|
||||
throw new Error('Invalid data received from server.');
|
||||
}
|
||||
@ -213,6 +244,9 @@ const handleJoinGroup = async () => {
|
||||
}
|
||||
inviteCodeToJoin.value = '';
|
||||
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
||||
// Update cache
|
||||
cachedGroups.value = groups.value;
|
||||
cachedTimestamp.value = Date.now();
|
||||
} else {
|
||||
// If API returns only success message, re-fetch groups
|
||||
await fetchGroups(); // Refresh the list of groups
|
||||
@ -233,20 +267,45 @@ const selectGroup = (group: Group) => {
|
||||
router.push(`/groups/${group.id}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups();
|
||||
const openCreateListDialog = (group: Group) => {
|
||||
availableGroupsForModal.value = [{
|
||||
label: group.name,
|
||||
value: group.id
|
||||
}];
|
||||
showCreateListModal.value = true;
|
||||
};
|
||||
|
||||
const onListCreated = (newList: any) => {
|
||||
notificationStore.addNotification({
|
||||
message: `List '${newList.name}' created successfully.`,
|
||||
type: 'success'
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
// Load cached data immediately
|
||||
loadCachedData();
|
||||
|
||||
// Then fetch fresh data in background
|
||||
await fetchGroups();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@ -255,17 +314,74 @@ onMounted(() => {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
/* Responsive grid for cards */
|
||||
.neo-groups-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.interactive-list-item:hover,
|
||||
.interactive-list-item:focus-visible {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: -3px;
|
||||
/* Adjust to be inside the border */
|
||||
/* Card styles */
|
||||
.neo-group-card,
|
||||
.neo-create-group-card {
|
||||
border-radius: 18px;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
max-width: 420px;
|
||||
min-width: 260px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0;
|
||||
padding: 2rem 2rem 1.5rem 2rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
||||
border: 3px solid #111;
|
||||
}
|
||||
|
||||
.neo-group-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 6px 9px 0 #111;
|
||||
}
|
||||
|
||||
.neo-group-header {
|
||||
font-weight: 900;
|
||||
font-size: 1.25rem;
|
||||
/* margin-bottom: 1rem; */
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.neo-group-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.neo-create-group-card {
|
||||
border: 3px dashed #111;
|
||||
background: #fafafa;
|
||||
padding: 2.5rem 0;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
margin-top: 0;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.neo-create-group-card:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
@ -279,12 +395,10 @@ onMounted(() => {
|
||||
|
||||
details>summary {
|
||||
list-style: none;
|
||||
/* Hide default marker */
|
||||
}
|
||||
|
||||
details>summary::-webkit-details-marker {
|
||||
display: none;
|
||||
/* Hide default marker for Chrome */
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
@ -298,4 +412,35 @@ details[open] .expand-icon {
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.neo-groups-grid {
|
||||
gap: 1.2rem;
|
||||
}
|
||||
|
||||
.neo-group-card,
|
||||
.neo-create-group-card {
|
||||
max-width: 95vw;
|
||||
min-width: 180px;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-padding {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-group-card,
|
||||
.neo-create-group-card {
|
||||
padding: 1.2rem 0.7rem 1rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.neo-group-header {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,113 +1,100 @@
|
||||
<template>
|
||||
<main class="container page-padding">
|
||||
<div v-if="loading" class="text-center">
|
||||
<main class="neo-container page-padding">
|
||||
<div v-if="loading" class="neo-loading-state">
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading list details...</p>
|
||||
<p>Loading list...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<div v-else-if="error" class="neo-error-state">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
|
||||
<button class="neo-button" @click="fetchListDetails">Retry</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="list">
|
||||
<div class="flex justify-between items-center flex-wrap mb-2">
|
||||
<h1>{{ list.name }}</h1>
|
||||
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<!-- Header -->
|
||||
<div class="neo-list-header">
|
||||
<h1 class="neo-title mb-3">{{ list.name }}</h1>
|
||||
<div class="neo-header-actions">
|
||||
<button class="neo-action-button" @click="showCostSummaryDialog = true"
|
||||
:class="{ 'neo-disabled': !isOnline }">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg>
|
||||
Cost Summary
|
||||
</svg> Cost Summary
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
|
||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
||||
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
|
||||
<svg class="icon icon-sm">
|
||||
<button class="neo-action-button" @click="openOcrDialog" :class="{ 'neo-disabled': !isOnline }">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
Add via OCR
|
||||
</svg> Add via OCR
|
||||
</button>
|
||||
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||
{{ list.is_complete ? 'Complete' : 'Active' }}
|
||||
</span>
|
||||
<div class="neo-status" :class="list.is_complete ? 'neo-status-complete' : 'neo-status-active'">
|
||||
<span v-if="list.group_id">Group List</span>
|
||||
<span v-else>Personal List</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Item Form -->
|
||||
<form @submit.prevent="onAddItem" class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="flex items-end flex-wrap" style="gap: 1rem;">
|
||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||
<label for="newItemName" class="form-label">Item Name</label>
|
||||
<input type="text" id="newItemName" v-model="newItem.name" class="form-input" required
|
||||
ref="itemNameInputRef" />
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 0; min-width: 120px;">
|
||||
<label for="newItemQuantity" class="form-label">Quantity</label>
|
||||
<input type="number" id="newItemQuantity" v-model="newItem.quantity" class="form-input" min="1" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="addingItem">
|
||||
<span v-if="addingItem" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
||||
|
||||
<!-- Items List -->
|
||||
<div v-if="list.items.length === 0" class="card empty-state-card">
|
||||
<div v-if="list.items.length === 0" class="neo-empty-state">
|
||||
<svg class="icon icon-lg" aria-hidden="true">
|
||||
<use xlink:href="#icon-clipboard" />
|
||||
</svg>
|
||||
<h3>No Items Yet!</h3>
|
||||
<p>This list is empty. Add some items using the form above.</p>
|
||||
<p>Add some items using the form below.</p>
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
||||
'completed': item.is_complete,
|
||||
'is-swiped': item.swiped,
|
||||
'offline-item': isItemPendingSync(item),
|
||||
'synced': !isItemPendingSync(item)
|
||||
}" @touchstart="handleTouchStart" @touchmove="handleTouchMove" @touchend="handleTouchEnd">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-main">
|
||||
<label class="checkbox-label mb-0 flex-shrink-0">
|
||||
<div v-else class="neo-list-card">
|
||||
<ul class="neo-item-list">
|
||||
<li v-for="item in list.items" :key="item.id" class="neo-item"
|
||||
:class="{ 'neo-item-complete': item.is_complete }">
|
||||
<div class="neo-item-content">
|
||||
<label class="neo-checkbox-label">
|
||||
<input type="checkbox" :checked="item.is_complete"
|
||||
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
||||
:disabled="item.updating" :aria-label="item.name" />
|
||||
<span class="checkmark"></span>
|
||||
<span class="neo-checkmark"></span>
|
||||
</label>
|
||||
<div class="item-text flex-grow">
|
||||
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
|
||||
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
|
||||
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
|
||||
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
|
||||
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
|
||||
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
|
||||
<div class="neo-item-details">
|
||||
<span class="neo-item-name">{{ item.name }}</span>
|
||||
<span v-if="item.quantity" class="neo-item-quantity">× {{ item.quantity }}</span>
|
||||
<div v-if="item.is_complete" class="neo-price-input">
|
||||
<input type="number" v-model.number="item.priceInput" class="neo-number-input" placeholder="Price"
|
||||
step="0.01" @blur="updateItemPrice(item)"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
||||
<div class="neo-item-actions">
|
||||
<button class="neo-icon-button neo-edit-button" @click.stop="editItem(item)" aria-label="Edit item">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-edit"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="neo-icon-button neo-delete-button" @click.stop="confirmDeleteItem(item)"
|
||||
:disabled="item.deleting" aria-label="Delete item">
|
||||
<svg class="icon icon-sm">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-trash"></use>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="neo-item new-item-input">
|
||||
<form @submit.prevent="onAddItem" class="neo-checkbox-label neo-new-item-form">
|
||||
<input type="checkbox" disabled />
|
||||
<input type="text" v-model="newItem.name" class="neo-new-item-input" placeholder="Add a new item" required
|
||||
ref="itemNameInputRef" />
|
||||
<input type="number" v-model="newItem.quantity" class="neo-quantity-input" placeholder="Qty" min="1" />
|
||||
<button type="submit" class="neo-add-button" :disabled="addingItem">
|
||||
<span v-if="addingItem" class="spinner-dots-sm"><span /><span /><span /></span>
|
||||
<span v-else>Add</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- OCR Dialog -->
|
||||
@ -261,15 +248,7 @@ interface Item {
|
||||
swiped?: boolean; // For swipe UI
|
||||
}
|
||||
|
||||
interface List {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
is_complete: boolean;
|
||||
items: Item[];
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
interface List { id: number; name: string; description?: string; is_complete: boolean; items: Item[]; version: number; updated_at: string; group_id?: number; }
|
||||
|
||||
interface UserCostShare {
|
||||
user_id: number;
|
||||
@ -749,151 +728,524 @@ onUnmounted(() => {
|
||||
stopPolling();
|
||||
});
|
||||
|
||||
// Add after deleteItem function
|
||||
const editItem = (item: Item) => {
|
||||
// For now, just simulate editing by toggling name and adding "(Edited)" when clicked
|
||||
// In a real implementation, you would show a modal or inline form
|
||||
if (!item.name.includes('(Edited)')) {
|
||||
item.name += ' (Edited)';
|
||||
}
|
||||
// Placeholder for future edit functionality
|
||||
notificationStore.addNotification({
|
||||
message: 'Edit functionality would show here (modal or inline form)',
|
||||
type: 'info'
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.neo-container {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
.neo-loading-state,
|
||||
.neo-error-state,
|
||||
.neo-empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
margin: 2rem 0;
|
||||
border: 3px solid #111;
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
}
|
||||
|
||||
.neo-error-state {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.neo-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.neo-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.neo-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.neo-description {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.neo-status {
|
||||
font-weight: 900;
|
||||
font-size: 1rem;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 3px solid #111;
|
||||
border-radius: 50px;
|
||||
background: #fff;
|
||||
box-shadow: 3px 3px 0 #111;
|
||||
}
|
||||
|
||||
.neo-status-active {
|
||||
background: #f7f7d4;
|
||||
}
|
||||
|
||||
.neo-status-complete {
|
||||
background: #d4f7dd;
|
||||
}
|
||||
|
||||
.neo-list-card {
|
||||
break-inside: avoid;
|
||||
border-radius: 18px;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
width: 100%;
|
||||
margin: 0 0 2rem 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
border: 3px solid #111;
|
||||
}
|
||||
|
||||
.neo-item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 2rem 0;
|
||||
break-inside: avoid;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.neo-item {
|
||||
padding: 1.2rem;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
transition: background-color 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.neo-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.neo-item:hover {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.neo-item-complete {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.neo-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.neo-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.neo-checkbox-label input[type="checkbox"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #111;
|
||||
border: 2px solid #111;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.neo-item-details {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.neo-item-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.neo-item-complete .neo-item-name {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neo-item-quantity {
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.neo-price-input {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
.neo-item-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.neo-edit-button {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.neo-edit-button:hover {
|
||||
background: #eef7fd;
|
||||
}
|
||||
|
||||
.neo-delete-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #e74c3c;
|
||||
padding: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.neo-delete-button:hover {
|
||||
background: #fee;
|
||||
}
|
||||
|
||||
.neo-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.neo-action-button {
|
||||
background: #fff;
|
||||
border: 3px solid #111;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 3px 3px 0 #111;
|
||||
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.neo-action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 3px 5px 0 #111;
|
||||
}
|
||||
|
||||
.neo-action-button .icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.neo-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.neo-add-item-form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
border: 3px solid #111;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
background: #f9f9f9;
|
||||
box-shadow: 4px 4px 0 #111;
|
||||
}
|
||||
|
||||
.neo-new-item-form {
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.neo-text-input {
|
||||
flex-grow: 1;
|
||||
border: 2px solid #111;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.neo-new-item-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
all: unset;
|
||||
width: 100%;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.neo-new-item-input::placeholder {
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.neo-quantity-input {
|
||||
width: 80px;
|
||||
border: 2px solid #111;
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.neo-number-input {
|
||||
border: 2px solid #111;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.neo-add-button {
|
||||
background: #111;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0 1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
min-width: 60px;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.neo-button {
|
||||
background: #111;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
.new-item-input {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.neo-container {
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.neo-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.neo-item {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.neo-container {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-header-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.neo-item-name {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.neo-add-item-form {
|
||||
flex-direction: column;
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.neo-quantity-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: white;
|
||||
border-radius: 18px;
|
||||
border: 3px solid #111;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 16px;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.badge-settled {
|
||||
background-color: #d4f7dd;
|
||||
color: #2c784c;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #ffe1d6;
|
||||
color: #c64600;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-caption {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 0.25rem;
|
||||
.spinner-dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.text-decoration-line-through {
|
||||
text-decoration: line-through;
|
||||
.spinner-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #555;
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.form-input-sm {
|
||||
/* For price input */
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
.spinner-dots-sm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.cost-overview p {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
.spinner-dots-sm span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.form-error-text {
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
.spinner-dots span:nth-child(1),
|
||||
.spinner-dots-sm span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.list-item.completed .item-text {
|
||||
/* text-decoration: line-through; is handled by span class */
|
||||
opacity: 0.7;
|
||||
.spinner-dots span:nth-child(2),
|
||||
.spinner-dots-sm span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.list-item-actions {
|
||||
margin-left: auto;
|
||||
/* Pushes actions to the right */
|
||||
padding-left: 1rem;
|
||||
/* Space before actions */
|
||||
@keyframes dot-pulse {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.offline-item {
|
||||
position: relative;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.3s ease;
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.offline-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3Cpath d='M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16'/%3E%3Cpath d='M16 21h5v-5'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.offline-item.synced {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.offline-item.synced::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-offline-disabled {
|
||||
position: relative;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.feature-offline-disabled::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5rem;
|
||||
background-color: var(--bg-color-tooltip, #333);
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.feature-offline-disabled:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
@ -1,13 +1,8 @@
|
||||
<template>
|
||||
<main class="container page-padding">
|
||||
<h1 class="mb-3">{{ pageTitle }}</h1>
|
||||
<!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
|
||||
|
||||
<div v-if="loading" class="text-center">
|
||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||
<p>Loading lists...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div v-if="error" class="alert alert-error mb-3" role="alert">
|
||||
<div class="alert-content">
|
||||
<svg class="icon" aria-hidden="true">
|
||||
<use xlink:href="#icon-alert-triangle" />
|
||||
@ -32,47 +27,31 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-else class="item-list">
|
||||
<li v-for="list in lists" :key="list.id" class="list-item interactive-list-item" tabindex="0"
|
||||
@click="navigateToList(list.id)" @keydown.enter="navigateToList(list.id)">
|
||||
<div class="list-item-content">
|
||||
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
|
||||
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
|
||||
<small class="item-caption">{{ list.description || 'No description' }}</small>
|
||||
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-user" />
|
||||
</svg> Personal List
|
||||
</small>
|
||||
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
|
||||
<svg class="icon icon-sm">
|
||||
<use xlink:href="#icon-user" />
|
||||
</svg> <!-- Placeholder, group icon not in Valerie -->
|
||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}` }})
|
||||
</small>
|
||||
</div>
|
||||
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
||||
<span class="item-badge" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
||||
{{ list.is_complete ? 'Complete' : 'Active' }}
|
||||
</span>
|
||||
<small class="item-caption mt-1">
|
||||
Updated: {{ new Date(list.updated_at).toLocaleDateString() }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="neo-lists-grid">
|
||||
<div v-for="list in lists" :key="list.id" class="neo-list-card" @click="navigateToList(list.id)">
|
||||
<div class="neo-list-header">{{ list.name }}</div>
|
||||
<div class="neo-list-desc">{{ list.description || 'No description' }}</div>
|
||||
<ul class="neo-item-list">
|
||||
<li v-for="item in list.items" :key="item.id" class="neo-list-item">
|
||||
<label class="neo-checkbox-label" @click.stop>
|
||||
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)" />
|
||||
<span :class="{ 'neo-completed': item.is_complete }">{{ item.name }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li class="neo-list-item new-item-input">
|
||||
<label class="neo-checkbox-label">
|
||||
<input type="checkbox" disabled />
|
||||
<input type="text" class="neo-new-item-input" placeholder="Add new item..."
|
||||
@keyup.enter="addNewItem(list, $event)" @blur="addNewItem(list, $event)" @click.stop />
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="page-sticky-bottom-right">
|
||||
<button class="btn btn-primary btn-icon-only" style="width: 56px; height: 56px; border-radius: 50%; padding: 0;"
|
||||
@click="showCreateModal = true" :aria-label="currentGroupId ? 'Create Group List' : 'Create List'"
|
||||
data-tooltip="Create New List">
|
||||
<svg class="icon icon-lg" style="margin-right:0;">
|
||||
<use xlink:href="#icon-plus" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Basic Tooltip (requires JS from Valerie UI example to function on hover/focus) -->
|
||||
<!-- <span class="tooltip-text" role="tooltip">{{ currentGroupId ? 'Create Group List' : 'Create List' }}</span> -->
|
||||
</div>
|
||||
<div class="neo-create-list-card" @click="showCreateModal = true">
|
||||
+ Create a new list
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||
@ -83,7 +62,8 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
||||
import CreateListModal from '@/components/CreateListModal.vue'; // Adjusted path
|
||||
import CreateListModal from '@/components/CreateListModal.vue';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
|
||||
interface List {
|
||||
id: number;
|
||||
@ -95,6 +75,7 @@ interface List {
|
||||
group_id?: number | null;
|
||||
created_at: string;
|
||||
version: number;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface Group {
|
||||
@ -102,6 +83,17 @@ interface Group {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
quantity?: string | number;
|
||||
is_complete: boolean;
|
||||
price?: number | null;
|
||||
version: number;
|
||||
updating?: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
|
||||
}>();
|
||||
@ -109,12 +101,11 @@ const props = defineProps<{
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const lists = ref<List[]>([]);
|
||||
const allFetchedGroups = ref<Group[]>([]); // Store all groups user has access to for display
|
||||
const currentViewedGroup = ref<Group | null>(null); // For the title if on a specific group's list page
|
||||
|
||||
const lists = ref<(List & { items: Item[] })[]>([]);
|
||||
const allFetchedGroups = ref<Group[]>([]);
|
||||
const currentViewedGroup = ref<Group | null>(null);
|
||||
const showCreateModal = ref(false);
|
||||
|
||||
const currentGroupId = computed<number | null>(() => {
|
||||
@ -176,35 +167,47 @@ const fetchAllAccessibleGroups = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Cache lists in localStorage
|
||||
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
|
||||
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
const loadCachedData = () => {
|
||||
const now = Date.now();
|
||||
if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
||||
lists.value = cachedLists.value;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLists = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// If currentGroupId is set, fetch lists for that group. Otherwise, fetch all user's lists.
|
||||
const endpoint = currentGroupId.value
|
||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||
: API_ENDPOINTS.LISTS.BASE;
|
||||
const response = await apiClient.get(endpoint);
|
||||
lists.value = response.data as List[];
|
||||
lists.value = response.data as (List & { items: Item[] })[];
|
||||
|
||||
// Update cache
|
||||
cachedLists.value = response.data;
|
||||
cachedTimestamp.value = Date.now();
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
|
||||
console.error(error.value, err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// If we have cached data, keep showing it even if refresh failed
|
||||
if (cachedLists.value.length === 0) {
|
||||
lists.value = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchListsAndGroups = async () => {
|
||||
loading.value = true;
|
||||
await Promise.all([
|
||||
fetchLists(),
|
||||
fetchAllAccessibleGroups()
|
||||
]);
|
||||
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
|
||||
loading.value = false;
|
||||
await fetchCurrentViewGroupName();
|
||||
};
|
||||
|
||||
|
||||
const availableGroupsForModal = computed(() => {
|
||||
return allFetchedGroups.value.map(group => ({
|
||||
label: group.name,
|
||||
@ -217,20 +220,76 @@ const getGroupName = (groupId?: number | null): string | undefined => {
|
||||
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
||||
}
|
||||
|
||||
const onListCreated = () => {
|
||||
fetchLists(); // Refresh lists after one is created
|
||||
const onListCreated = (newList: List & { items: Item[] }) => {
|
||||
lists.value = [...lists.value, newList];
|
||||
// Update cache
|
||||
cachedLists.value = lists.value;
|
||||
cachedTimestamp.value = Date.now();
|
||||
};
|
||||
|
||||
const toggleItem = async (list: (List & { items: Item[] }), item: Item) => {
|
||||
const original = item.is_complete;
|
||||
item.is_complete = !item.is_complete;
|
||||
item.updating = true;
|
||||
try {
|
||||
await apiClient.put(
|
||||
API_ENDPOINTS.LISTS.ITEM(String(list.id), String(item.id)),
|
||||
{
|
||||
is_complete: item.is_complete,
|
||||
version: item.version,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
price: item.price
|
||||
}
|
||||
);
|
||||
item.version++;
|
||||
} catch (err) {
|
||||
item.is_complete = original;
|
||||
console.error('Failed to update item:', err);
|
||||
} finally {
|
||||
item.updating = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addNewItem = async (list: (List & { items: Item[] }), event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const itemName = input.value.trim();
|
||||
|
||||
if (!itemName) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), {
|
||||
name: itemName,
|
||||
is_complete: false,
|
||||
quantity: null,
|
||||
price: null
|
||||
});
|
||||
|
||||
list.items.push(response.data as Item);
|
||||
input.value = '';
|
||||
} catch (err) {
|
||||
console.error('Failed to add new item:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToList = (listId: number) => {
|
||||
router.push(`/lists/${listId}`);
|
||||
router.push({ name: 'ListDetail', params: { id: listId } });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Load cached data immediately
|
||||
loadCachedData();
|
||||
|
||||
// Then fetch fresh data in background
|
||||
fetchListsAndGroups();
|
||||
});
|
||||
|
||||
// Watch for changes in groupId (e.g., if used as a component and prop changes)
|
||||
// Watch for changes in groupId
|
||||
watch(currentGroupId, () => {
|
||||
loadCachedData();
|
||||
fetchListsAndGroups();
|
||||
});
|
||||
|
||||
@ -239,75 +298,173 @@ watch(currentGroupId, () => {
|
||||
<style scoped>
|
||||
.page-padding {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 0.5rem;
|
||||
/* Masonry grid for cards */
|
||||
.neo-lists-grid {
|
||||
columns: 3 500px;
|
||||
column-gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.interactive-list-item {
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
||||
}
|
||||
|
||||
.interactive-list-item:hover,
|
||||
.interactive-list-item:focus-visible {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
outline: var(--focus-outline);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.item-caption {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.icon-caption .icon {
|
||||
vertical-align: -0.1em;
|
||||
/* Align icon better with text */
|
||||
}
|
||||
|
||||
.page-sticky-bottom-right {
|
||||
position: fixed;
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
z-index: 999;
|
||||
/* Below modals */
|
||||
}
|
||||
|
||||
.page-sticky-bottom-right .btn {
|
||||
box-shadow: var(--shadow-lg);
|
||||
/* Make it pop more */
|
||||
}
|
||||
|
||||
/* Ensure list item content uses full width for proper layout */
|
||||
.list-item-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
/* Card styles */
|
||||
.neo-list-card,
|
||||
.neo-create-list-card {
|
||||
break-inside: avoid;
|
||||
border-radius: 18px;
|
||||
box-shadow: 6px 6px 0 #111;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
/* Align items to top if they wrap */
|
||||
margin: 0 0 2rem 0;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* padding: 2rem 2rem 1.5rem 2rem;
|
||||
padding: 2rem 2rem 1.5rem 2rem; */
|
||||
/* padding-inline: ; */
|
||||
cursor: pointer;
|
||||
/* transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out; */
|
||||
border: 3px solid #111;
|
||||
}
|
||||
|
||||
.list-item-main {
|
||||
flex-grow: 1;
|
||||
margin-right: 1rem;
|
||||
/* Space before details */
|
||||
.neo-list-card:hover {
|
||||
/* transform: translateY(-3px); */
|
||||
box-shadow: 6px 9px 0 #111;
|
||||
/* padding: 2rem 2rem 1.5rem 2rem; */
|
||||
border: 3px solid #111;
|
||||
}
|
||||
|
||||
.list-item-details {
|
||||
flex-shrink: 0;
|
||||
/* Prevent badges from shrinking */
|
||||
text-align: right;
|
||||
.neo-list-header {
|
||||
padding-block-start: 1rem;
|
||||
font-weight: 900;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.neo-list-desc {
|
||||
font-size: 1rem;
|
||||
color: #444;
|
||||
margin-bottom: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.neo-item-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.neo-list-item {
|
||||
margin-bottom: 1.1rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.neo-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.neo-checkbox-label input[type="checkbox"] {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #111;
|
||||
border: 2px solid #111;
|
||||
border-radius: 4px;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
.neo-completed {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.neo-create-list-card {
|
||||
border: 3px dashed #111;
|
||||
background: #fafafa;
|
||||
padding: 2.5rem 0;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
font-size: 1.1rem;
|
||||
color: #222;
|
||||
cursor: pointer;
|
||||
margin-top: 0;
|
||||
transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 120px;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.neo-create-list-card:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.neo-lists-grid {
|
||||
columns: 2 260px;
|
||||
column-gap: 1.2rem;
|
||||
}
|
||||
|
||||
.neo-list-card,
|
||||
.neo-create-list-card {
|
||||
margin-bottom: 1.2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-padding {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-lists-grid {
|
||||
columns: 1 280px;
|
||||
}
|
||||
|
||||
.neo-list-card,
|
||||
.neo-create-list-card {
|
||||
padding: 1.2rem 0.7rem 1rem 0.7rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.neo-list-header {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.neo-new-item-input {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.neo-new-item-input input[type="text"] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
all: unset;
|
||||
width: 100%;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.neo-new-item-input input[type="text"]::placeholder {
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
@ -8,27 +8,45 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../layouts/MainLayout.vue'), // Use .. alias
|
||||
children: [
|
||||
{ path: '', redirect: '/lists' },
|
||||
{ path: 'lists', name: 'PersonalLists', component: () => import('../pages/ListsPage.vue') },
|
||||
{
|
||||
path: 'lists',
|
||||
name: 'PersonalLists',
|
||||
component: () => import('../pages/ListsPage.vue'),
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'lists/:id',
|
||||
name: 'ListDetail',
|
||||
component: () => import('../pages/ListDetailPage.vue'),
|
||||
props: true,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'groups',
|
||||
name: 'GroupsList',
|
||||
component: () => import('../pages/GroupsPage.vue'),
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{ path: 'groups', name: 'GroupsList', component: () => import('../pages/GroupsPage.vue') },
|
||||
{
|
||||
path: 'groups/:id',
|
||||
name: 'GroupDetail',
|
||||
component: () => import('../pages/GroupDetailPage.vue'),
|
||||
props: true,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'groups/:groupId/lists',
|
||||
name: 'GroupLists',
|
||||
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
|
||||
props: true,
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'Account',
|
||||
component: () => import('../pages/AccountPage.vue'),
|
||||
meta: { keepAlive: true }
|
||||
},
|
||||
{ path: 'account', name: 'Account', component: () => import('../pages/AccountPage.vue') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user