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,32 +1,65 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <!-- Or your favicon -->
|
||||||
<meta name="description" content="mitlist pwa">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="description" content="mitlist pwa">
|
||||||
<meta name="msapplication-tap-highlight" content="no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
|
<meta name="msapplication-tap-highlight" content="no">
|
||||||
<title>mitlist</title>
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
<!-- PWA manifest and theme color will be injected by vite-plugin-pwa -->
|
||||||
<body>
|
<title>mitlist</title>
|
||||||
<svg width="0" height="0" style="position: absolute">
|
</head>
|
||||||
<defs>
|
|
||||||
<symbol viewBox="0 0 24 24" id="icon-plus"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" /></symbol>
|
<body>
|
||||||
<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>
|
<svg width="0" height="0" style="position: absolute">
|
||||||
<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>
|
<defs>
|
||||||
<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-plus">
|
||||||
<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>
|
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
|
||||||
<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>
|
||||||
<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-edit">
|
||||||
<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>
|
<path
|
||||||
<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>
|
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 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>
|
||||||
<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-trash">
|
||||||
</defs>
|
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||||
</svg>
|
</symbol>
|
||||||
<div id="app"></div>
|
<symbol viewBox="0 0 24 24" id="icon-check">
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||||
</body>
|
</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>
|
</html>
|
@ -18,7 +18,8 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
background-color: #f0f2f5; /* Example background */
|
background-color: #f0f2f5;
|
||||||
|
/* Example background */
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -40,7 +40,7 @@
|
|||||||
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
|
<li v-for="(value, key) in conflictData?.localVersion.data" :key="key" class="list-item-simple">
|
||||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
||||||
}}</span>
|
}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
|
<li v-for="(value, key) in conflictData?.serverVersion.data" :key="key" class="list-item-simple">
|
||||||
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
<strong class="text-caption-strong">{{ formatKey(key) }}</strong>
|
||||||
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
<span :class="{ 'text-positive-inline': isDifferent(key as string) }">{{ formatValue(value)
|
||||||
}}</span>
|
}}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -57,7 +57,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
(e: 'update:modelValue', value: boolean): void;
|
||||||
(e: 'created'): void;
|
(e: 'created', newList: any): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isOpen = useVModel(props, 'modelValue', emit);
|
const isOpen = useVModel(props, 'modelValue', emit);
|
||||||
@ -108,7 +108,7 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
|
const response = await apiClient.post(API_ENDPOINTS.LISTS.BASE, {
|
||||||
name: listName.value,
|
name: listName.value,
|
||||||
description: description.value,
|
description: description.value,
|
||||||
group_id: selectedGroupId.value,
|
group_id: selectedGroupId.value,
|
||||||
@ -116,7 +116,7 @@ const onSubmit = async () => {
|
|||||||
|
|
||||||
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
|
notificationStore.addNotification({ message: 'List created successfully', type: 'success' });
|
||||||
|
|
||||||
emit('created');
|
emit('created', response.data);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create list';
|
const message = error instanceof Error ? error.message : 'Failed to create list';
|
||||||
|
@ -5,7 +5,11 @@
|
|||||||
<div class="user-menu" v-if="authStore.isAuthenticated">
|
<div class="user-menu" v-if="authStore.isAuthenticated">
|
||||||
<button @click="toggleUserMenu" class="user-menu-button">
|
<button @click="toggleUserMenu" class="user-menu-button">
|
||||||
<!-- Placeholder for user icon -->
|
<!-- 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>
|
</button>
|
||||||
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
<div v-if="userMenuOpen" class="dropdown-menu" ref="userMenuDropdown">
|
||||||
<a href="#" @click.prevent="handleLogout">Logout</a>
|
<a href="#" @click.prevent="handleLogout">Logout</a>
|
||||||
@ -14,29 +18,47 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="page-container">
|
<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>
|
</main>
|
||||||
|
|
||||||
<OfflineIndicator />
|
<OfflineIndicator />
|
||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<router-link to="/lists" class="tab-item" active-class="active">Lists</router-link>
|
<router-link to="/lists" class="tab-item" active-class="active">
|
||||||
<router-link to="/groups" class="tab-item" active-class="active">Groups</router-link>
|
<span class="material-icons">list</span>
|
||||||
<router-link to="/account" class="tab-item" active-class="active">Account</router-link>
|
<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>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref, defineComponent } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
import OfflineIndicator from '@/components/OfflineIndicator.vue';
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
|
||||||
|
defineComponent({
|
||||||
|
name: 'MainLayout'
|
||||||
|
});
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
@ -86,7 +108,7 @@ const handleLogout = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
@ -113,8 +135,9 @@ const handleLogout = async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(255,255,255,0.1);
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +149,7 @@ const handleLogout = async () => {
|
|||||||
background-color: #f3f3f3;
|
background-color: #f3f3f3;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
z-index: 101;
|
z-index: 101;
|
||||||
|
|
||||||
@ -135,6 +158,7 @@ const handleLogout = async () => {
|
|||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
@ -170,15 +194,29 @@ const handleLogout = async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-color); // Or a specific inactive tab color
|
color: var(--text-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.8rem; // Example size
|
font-size: 0.8rem;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
// Icon would go here if you add them
|
.tab-text {
|
||||||
// Example: svg or <i> for icon fonts
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.tab-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
|
@ -3,16 +3,18 @@
|
|||||||
<h1 class="mb-3">Account Settings</h1>
|
<h1 class="mb-3">Account Settings</h1>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="text-center">
|
||||||
<div class="spinner-dots" role="status"><span/><span/><span/></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading profile...</p>
|
<p>Loading profile...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
||||||
<div class="alert-content">
|
<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 }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchProfile">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else @submit.prevent="onSubmitProfile">
|
<form v-else @submit.prevent="onSubmitProfile">
|
||||||
@ -35,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||||
<span v-if="saving" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
<span v-if="saving" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -62,7 +64,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
|
<button type="submit" class="btn btn-primary" :disabled="changingPassword">
|
||||||
<span v-if="changingPassword" class="spinner-dots-sm" role="status"><span/><span/><span/></span>
|
<span v-if="changingPassword" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -193,8 +195,8 @@ const onChangePassword = async () => {
|
|||||||
try {
|
try {
|
||||||
// API endpoint expects 'new' not 'newPassword'
|
// API endpoint expects 'new' not 'newPassword'
|
||||||
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, {
|
await apiClient.put(API_ENDPOINTS.USERS.PASSWORD, {
|
||||||
current: password.value.current,
|
current: password.value.current,
|
||||||
new: password.value.newPassword
|
new: password.value.newPassword
|
||||||
});
|
});
|
||||||
password.value = { current: '', newPassword: '' };
|
password.value = { current: '', newPassword: '' };
|
||||||
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
|
notificationStore.addNotification({ message: 'Password changed successfully', type: 'success' });
|
||||||
@ -229,31 +231,44 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding {
|
.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 {
|
.preference-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preference-item {
|
.preference-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.75rem 0;
|
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 {
|
.preference-item:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preference-label {
|
.preference-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preference-label small {
|
.preference-label small {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
@ -4,77 +4,86 @@
|
|||||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading group details...</p>
|
<p>Loading group details...</p>
|
||||||
</div>
|
</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">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-alert-triangle" />
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroupDetails">Retry</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="group">
|
<div v-else-if="group">
|
||||||
<h1 class="mb-3">Group: {{ group.name }}</h1>
|
<h1 class="mb-3">{{ group.name }}</h1>
|
||||||
|
|
||||||
<!-- Group Members Section -->
|
<div class="neo-grid">
|
||||||
<div class="card mt-3">
|
<!-- Group Members Section -->
|
||||||
<div class="card-header">
|
<div class="neo-card">
|
||||||
<h3>Group Members</h3>
|
<div class="neo-card-header">
|
||||||
</div>
|
<h3>Group Members</h3>
|
||||||
<div class="card-body">
|
</div>
|
||||||
<div v-if="group.members && group.members.length > 0" class="members-list">
|
<div class="neo-card-body">
|
||||||
<div v-for="member in group.members" :key="member.id" class="member-item">
|
<div v-if="group.members && group.members.length > 0" class="neo-members-list">
|
||||||
<div class="member-info">
|
<div v-for="member in group.members" :key="member.id" class="neo-member-item">
|
||||||
<span class="member-name">{{ member.email }}</span>
|
<div class="neo-member-info">
|
||||||
<span class="member-role" :class="member.role?.toLowerCase()">{{ member.role || 'Member' }}</span>
|
<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">
|
||||||
|
<span v-if="removingMember === member.id" class="spinner-dots-sm"
|
||||||
|
role="status"><span /><span /><span /></span>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="canRemoveMember(member)" class="btn btn-danger btn-sm" @click="removeMember(member.id)"
|
</div>
|
||||||
:disabled="removingMember === member.id">
|
<div v-else class="neo-empty-state">
|
||||||
<span v-if="removingMember === member.id" class="spinner-dots-sm"
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
role="status"><span /><span /><span /></span>
|
<use xlink:href="#icon-users" />
|
||||||
Remove
|
</svg>
|
||||||
</button>
|
<p>No members found.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-muted">
|
</div>
|
||||||
No members found.
|
|
||||||
|
<!-- Invite Members Section -->
|
||||||
|
<div class="neo-card">
|
||||||
|
<div class="neo-card-header">
|
||||||
|
<h3>Invite Members</h3>
|
||||||
|
</div>
|
||||||
|
<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="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="neo-success-text">Invite code copied to clipboard!</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Placeholder for lists related to this group -->
|
<!-- Lists Section -->
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<ListsPage :group-id="groupId" />
|
<ListsPage :group-id="groupId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invite Members Section -->
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3>Invite Members</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<button class="btn btn-secondary" @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"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mt-2">
|
|
||||||
<p class="text-muted">No active invite code. Click the button above to generate one.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="alert alert-info" role="status">
|
<div v-else class="alert alert-info" role="status">
|
||||||
@ -247,6 +256,8 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-1 {
|
.mt-1 {
|
||||||
@ -273,64 +284,167 @@ onMounted(() => {
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adjusted from Valerie UI for tighter fit */
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
.form-success-text {
|
|
||||||
color: var(--success);
|
|
||||||
/* Or a darker green for text */
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-grow {
|
/* Neo Grid Layout */
|
||||||
flex-grow: 1;
|
.neo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Members list styles */
|
/* Neo Card Styles */
|
||||||
.members-list {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-item {
|
.neo-member-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0.5rem;
|
padding: 1rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 12px;
|
||||||
background-color: var(--surface-2);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.neo-member-name {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role {
|
.neo-member-role {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.75rem;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background-color: var(--surface-3);
|
background: #e0e0e0;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-role.owner {
|
.neo-member-role.owner {
|
||||||
background-color: var(--primary);
|
background: #111;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-sm {
|
/* Invite Code Styles */
|
||||||
padding: 0.25rem 0.5rem;
|
.neo-invite-code {
|
||||||
font-size: 0.875rem;
|
background: #fafafa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-muted {
|
.neo-label {
|
||||||
color: var(--text-2);
|
display: block;
|
||||||
font-style: italic;
|
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>
|
</style>
|
@ -1,72 +1,75 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="container page-padding">
|
||||||
<div class="flex justify-between items-center mb-3">
|
<!-- <h1 class="mb-3">Your Groups</h1> -->
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<div v-if="fetchError" class="alert alert-error mb-3" role="alert">
|
||||||
|
|
||||||
<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 class="alert-content">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-alert-triangle" />
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ fetchError }}
|
{{ fetchError }}
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger" @click="fetchGroups">Retry</button>
|
||||||
</div>
|
</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)"
|
<div v-else-if="groups.length === 0" class="card empty-state-card">
|
||||||
@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">
|
|
||||||
<svg class="icon icon-lg" aria-hidden="true">
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
<use xlink:href="#icon-clipboard" />
|
<use xlink:href="#icon-clipboard" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No Groups Yet!</h3>
|
<h3>No Groups Yet!</h3>
|
||||||
<p>You are not a member of any groups yet. Create one or join using an invite code.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<details class="card mb-3">
|
<div v-else class="mb-3">
|
||||||
<summary class="card-header flex items-center cursor-pointer"
|
<div class="neo-groups-grid">
|
||||||
style="display: flex; justify-content: space-between;">
|
<div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
|
||||||
<h3>
|
<h1 class="neo-group-header">{{ group.name }}</h1>
|
||||||
<svg class="icon" aria-hidden="true">
|
<div class="neo-group-actions">
|
||||||
<use xlink:href="#icon-user" />
|
<button class="btn btn-sm btn-secondary" @click.stop="openCreateListDialog(group)">
|
||||||
</svg> <!-- Placeholder icon -->
|
<svg class="icon" aria-hidden="true">
|
||||||
Join a Group with Invite Code
|
<use xlink:href="#icon-plus" />
|
||||||
</h3>
|
</svg>
|
||||||
<span class="expand-icon" aria-hidden="true">▼</span> <!-- Basic expand indicator -->
|
List
|
||||||
</summary>
|
</button>
|
||||||
<div class="card-body">
|
|
||||||
<form @submit.prevent="handleJoinGroup" class="flex items-center" style="gap: 0.5rem;">
|
|
||||||
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
|
||||||
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
|
||||||
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
|
||||||
placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
</div>
|
||||||
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
<div class="neo-create-group-card" @click="openCreateGroupDialog">
|
||||||
Join
|
+ Group
|
||||||
</button>
|
</div>
|
||||||
</form>
|
|
||||||
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
|
||||||
|
<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>
|
||||||
|
Join a Group with Invite Code
|
||||||
|
</h3>
|
||||||
|
<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;">
|
||||||
|
<div class="form-group flex-grow" style="margin-bottom: 0;">
|
||||||
|
<label for="joinInviteCodeInput" class="sr-only">Enter Invite Code</label>
|
||||||
|
<input type="text" id="joinInviteCodeInput" v-model="inviteCodeToJoin" class="form-input"
|
||||||
|
placeholder="Enter Invite Code" required ref="joinInviteCodeInputRef" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary" :disabled="joiningGroup">
|
||||||
|
<span v-if="joiningGroup" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||||
|
Join
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p v-if="joinGroupFormError" class="form-error-text mt-1">{{ joinGroupFormError }}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Group Dialog -->
|
<!-- Create Group Dialog -->
|
||||||
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
|
||||||
@ -99,26 +102,34 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create List Modal -->
|
||||||
|
<CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick } from 'vue';
|
import { ref, onMounted, nextTick } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 { onClickOutside } from '@vueuse/core';
|
||||||
import { useNotificationStore } from '@/stores/notifications';
|
import { useNotificationStore } from '@/stores/notifications';
|
||||||
|
import CreateListModal from '@/components/CreateListModal.vue';
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
id: string | number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
member_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const notificationStore = useNotificationStore();
|
const notificationStore = useNotificationStore();
|
||||||
const groups = ref<Group[]>([]);
|
const groups = ref<Group[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(false);
|
||||||
const fetchError = ref<string | null>(null);
|
const fetchError = ref<string | null>(null);
|
||||||
|
|
||||||
const showCreateGroupDialog = ref(false);
|
const showCreateGroupDialog = ref(false);
|
||||||
@ -133,20 +144,37 @@ const joiningGroup = ref(false);
|
|||||||
const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
|
const joinInviteCodeInputRef = ref<HTMLInputElement | null>(null);
|
||||||
const joinGroupFormError = ref<string | 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 () => {
|
const fetchGroups = async () => {
|
||||||
loading.value = true;
|
|
||||||
fetchError.value = null;
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
|
||||||
groups.value = Array.isArray(response.data) ? response.data : [];
|
groups.value = response.data;
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to load groups. Please try again.';
|
// Update cache
|
||||||
fetchError.value = message;
|
cachedGroups.value = response.data;
|
||||||
groups.value = [];
|
cachedTimestamp.value = Date.now();
|
||||||
console.error('Error fetching groups:', error);
|
} catch (err) {
|
||||||
notificationStore.addNotification({ message, type: 'error' });
|
fetchError.value = err instanceof Error ? err.message : 'Failed to load groups';
|
||||||
} finally {
|
// If we have cached data, keep showing it even if refresh failed
|
||||||
loading.value = false;
|
if (cachedGroups.value.length === 0) {
|
||||||
|
groups.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -182,6 +210,9 @@ const handleCreateGroup = async () => {
|
|||||||
groups.value.push(newGroup);
|
groups.value.push(newGroup);
|
||||||
closeCreateGroupDialog();
|
closeCreateGroupDialog();
|
||||||
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
|
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully.`, type: 'success' });
|
||||||
|
// Update cache
|
||||||
|
cachedGroups.value = groups.value;
|
||||||
|
cachedTimestamp.value = Date.now();
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid data received from server.');
|
throw new Error('Invalid data received from server.');
|
||||||
}
|
}
|
||||||
@ -213,6 +244,9 @@ const handleJoinGroup = async () => {
|
|||||||
}
|
}
|
||||||
inviteCodeToJoin.value = '';
|
inviteCodeToJoin.value = '';
|
||||||
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
notificationStore.addNotification({ message: `Successfully joined group '${joinedGroup.name}'.`, type: 'success' });
|
||||||
|
// Update cache
|
||||||
|
cachedGroups.value = groups.value;
|
||||||
|
cachedTimestamp.value = Date.now();
|
||||||
} else {
|
} else {
|
||||||
// If API returns only success message, re-fetch groups
|
// If API returns only success message, re-fetch groups
|
||||||
await fetchGroups(); // Refresh the list of groups
|
await fetchGroups(); // Refresh the list of groups
|
||||||
@ -233,20 +267,45 @@ const selectGroup = (group: Group) => {
|
|||||||
router.push(`/groups/${group.id}`);
|
router.push(`/groups/${group.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const openCreateListDialog = (group: Group) => {
|
||||||
fetchGroups();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mb-3 {
|
.mb-3 {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mt-1 {
|
.mt-1 {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
@ -255,17 +314,74 @@ onMounted(() => {
|
|||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive-list-item {
|
/* Responsive grid for cards */
|
||||||
cursor: pointer;
|
.neo-groups-grid {
|
||||||
transition: background-color var(--transition-speed) var(--transition-ease-out);
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interactive-list-item:hover,
|
/* Card styles */
|
||||||
.interactive-list-item:focus-visible {
|
.neo-group-card,
|
||||||
background-color: rgba(0, 0, 0, 0.03);
|
.neo-create-group-card {
|
||||||
outline: var(--focus-outline);
|
border-radius: 18px;
|
||||||
outline-offset: -3px;
|
box-shadow: 6px 6px 0 #111;
|
||||||
/* Adjust to be inside the border */
|
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 {
|
.form-error-text {
|
||||||
@ -279,12 +395,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
details>summary {
|
details>summary {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
/* Hide default marker */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
details>summary::-webkit-details-marker {
|
details>summary::-webkit-details-marker {
|
||||||
display: none;
|
display: none;
|
||||||
/* Hide default marker for Chrome */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.expand-icon {
|
.expand-icon {
|
||||||
@ -298,4 +412,35 @@ details[open] .expand-icon {
|
|||||||
.cursor-pointer {
|
.cursor-pointer {
|
||||||
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>
|
</style>
|
@ -1,113 +1,100 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<main class="neo-container page-padding">
|
||||||
<div v-if="loading" class="text-center">
|
<div v-if="loading" class="neo-loading-state">
|
||||||
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
<div class="spinner-dots" role="status"><span /><span /><span /></div>
|
||||||
<p>Loading list details...</p>
|
<p>Loading list...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="error" class="alert alert-error mb-3" role="alert">
|
<div v-else-if="error" class="neo-error-state">
|
||||||
<div class="alert-content">
|
<svg class="icon" aria-hidden="true">
|
||||||
<svg class="icon" aria-hidden="true">
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
<use xlink:href="#icon-alert-triangle" />
|
</svg>
|
||||||
</svg>
|
{{ error }}
|
||||||
{{ error }}
|
<button class="neo-button" @click="fetchListDetails">Retry</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-sm btn-danger" @click="fetchListDetails">Retry</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="list">
|
<template v-else-if="list">
|
||||||
<div class="flex justify-between items-center flex-wrap mb-2">
|
<!-- Header -->
|
||||||
<h1>{{ list.name }}</h1>
|
<div class="neo-list-header">
|
||||||
<div class="flex items-center flex-wrap" style="gap: 0.5rem;">
|
<h1 class="neo-title mb-3">{{ list.name }}</h1>
|
||||||
<button class="btn btn-neutral btn-sm" @click="showCostSummaryDialog = true"
|
<div class="neo-header-actions">
|
||||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
<button class="neo-action-button" @click="showCostSummaryDialog = true"
|
||||||
:data-tooltip="!isOnline ? 'Cost summary requires online connection' : ''">
|
:class="{ 'neo-disabled': !isOnline }">
|
||||||
<svg class="icon icon-sm">
|
<svg class="icon">
|
||||||
<use xlink:href="#icon-clipboard" />
|
<use xlink:href="#icon-clipboard" />
|
||||||
</svg>
|
</svg> Cost Summary
|
||||||
Cost Summary
|
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary btn-sm" @click="openOcrDialog"
|
<button class="neo-action-button" @click="openOcrDialog" :class="{ 'neo-disabled': !isOnline }">
|
||||||
:class="{ 'feature-offline-disabled': !isOnline }"
|
<svg class="icon">
|
||||||
:data-tooltip="!isOnline ? 'OCR requires online connection' : ''">
|
|
||||||
<svg class="icon icon-sm">
|
|
||||||
<use xlink:href="#icon-plus" />
|
<use xlink:href="#icon-plus" />
|
||||||
</svg>
|
</svg> Add via OCR
|
||||||
Add via OCR
|
|
||||||
</button>
|
</button>
|
||||||
<span class="item-badge ml-1" :class="list.is_complete ? 'badge-settled' : 'badge-pending'">
|
<div class="neo-status" :class="list.is_complete ? 'neo-status-complete' : 'neo-status-active'">
|
||||||
{{ list.is_complete ? 'Complete' : 'Active' }}
|
<span v-if="list.group_id">Group List</span>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
|
<p v-if="list.description" class="neo-description">{{ list.description }}</p>
|
||||||
|
|
||||||
<!-- Items List -->
|
<!-- 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">
|
<svg class="icon icon-lg" aria-hidden="true">
|
||||||
<use xlink:href="#icon-clipboard" />
|
<use xlink:href="#icon-clipboard" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3>No Items Yet!</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="item-list">
|
<div v-else class="neo-list-card">
|
||||||
<li v-for="item in list.items" :key="item.id" class="list-item" :class="{
|
<ul class="neo-item-list">
|
||||||
'completed': item.is_complete,
|
<li v-for="item in list.items" :key="item.id" class="neo-item"
|
||||||
'is-swiped': item.swiped,
|
:class="{ 'neo-item-complete': item.is_complete }">
|
||||||
'offline-item': isItemPendingSync(item),
|
<div class="neo-item-content">
|
||||||
'synced': !isItemPendingSync(item)
|
<label class="neo-checkbox-label">
|
||||||
}" @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">
|
|
||||||
<input type="checkbox" :checked="item.is_complete"
|
<input type="checkbox" :checked="item.is_complete"
|
||||||
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
@change="confirmUpdateItem(item, ($event.target as HTMLInputElement).checked)"
|
||||||
:disabled="item.updating" :aria-label="item.name" />
|
:disabled="item.updating" :aria-label="item.name" />
|
||||||
<span class="checkmark"></span>
|
<span class="neo-checkmark"></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="item-text flex-grow">
|
<div class="neo-item-details">
|
||||||
<span :class="{ 'text-decoration-line-through': item.is_complete }">{{ item.name }}</span>
|
<span class="neo-item-name">{{ item.name }}</span>
|
||||||
<small v-if="item.quantity" class="item-caption">Quantity: {{ item.quantity }}</small>
|
<span v-if="item.quantity" class="neo-item-quantity">× {{ item.quantity }}</span>
|
||||||
<div v-if="item.is_complete" class="form-group mt-1" style="max-width: 150px; margin-bottom: 0;">
|
<div v-if="item.is_complete" class="neo-price-input">
|
||||||
<label :for="`price-${item.id}`" class="sr-only">Price for {{ item.name }}</label>
|
<input type="number" v-model.number="item.priceInput" class="neo-number-input" placeholder="Price"
|
||||||
<input :id="`price-${item.id}`" type="number" v-model.number="item.priceInput"
|
step="0.01" @blur="updateItemPrice(item)"
|
||||||
class="form-input form-input-sm" placeholder="Price" step="0.01" @blur="updateItemPrice(item)"
|
|
||||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
||||||
|
<use xlink:href="#icon-trash"></use>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-item-actions">
|
</li>
|
||||||
<button class="btn btn-danger btn-sm btn-icon-only" @click.stop="confirmDeleteItem(item)"
|
<li class="neo-item new-item-input">
|
||||||
:disabled="item.deleting" aria-label="Delete item">
|
<form @submit.prevent="onAddItem" class="neo-checkbox-label neo-new-item-form">
|
||||||
<svg class="icon icon-sm">
|
<input type="checkbox" disabled />
|
||||||
<use xlink:href="#icon-trash"></use>
|
<input type="text" v-model="newItem.name" class="neo-new-item-input" placeholder="Add a new item" required
|
||||||
</svg>
|
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>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OCR Dialog -->
|
<!-- OCR Dialog -->
|
||||||
@ -261,15 +248,7 @@ interface Item {
|
|||||||
swiped?: boolean; // For swipe UI
|
swiped?: boolean; // For swipe UI
|
||||||
}
|
}
|
||||||
|
|
||||||
interface List {
|
interface List { id: number; name: string; description?: string; is_complete: boolean; items: Item[]; version: number; updated_at: string; group_id?: number; }
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
is_complete: boolean;
|
|
||||||
items: Item[];
|
|
||||||
version: number;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserCostShare {
|
interface UserCostShare {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@ -749,151 +728,524 @@ onUnmounted(() => {
|
|||||||
stopPolling();
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.neo-container {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
.mb-1 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mb-3 {
|
.mb-3 {
|
||||||
margin-bottom: 1.5rem;
|
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;
|
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;
|
margin-top: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-1 {
|
.new-item-input {
|
||||||
margin-left: 0.25rem;
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-2 {
|
/* Responsive adjustments */
|
||||||
margin-left: 0.5rem;
|
@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-right {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-grow {
|
.text-center {
|
||||||
flex-grow: 1;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-caption {
|
.spinner-dots {
|
||||||
display: block;
|
display: flex;
|
||||||
font-size: 0.8rem;
|
align-items: center;
|
||||||
opacity: 0.6;
|
justify-content: center;
|
||||||
margin-top: 0.25rem;
|
gap: 0.3rem;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-decoration-line-through {
|
.spinner-dots span {
|
||||||
text-decoration: line-through;
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: #555;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input-sm {
|
.spinner-dots-sm {
|
||||||
/* For price input */
|
display: inline-flex;
|
||||||
padding: 0.4rem 0.6rem;
|
align-items: center;
|
||||||
font-size: 0.9rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cost-overview p {
|
.spinner-dots-sm span {
|
||||||
margin-bottom: 0.5rem;
|
width: 4px;
|
||||||
font-size: 1.05rem;
|
height: 4px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: dot-pulse 1.4s infinite ease-in-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-error-text {
|
.spinner-dots span:nth-child(1),
|
||||||
color: var(--danger);
|
.spinner-dots-sm span:nth-child(1) {
|
||||||
font-size: 0.85rem;
|
animation-delay: -0.32s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item.completed .item-text {
|
.spinner-dots span:nth-child(2),
|
||||||
/* text-decoration: line-through; is handled by span class */
|
.spinner-dots-sm span:nth-child(2) {
|
||||||
opacity: 0.7;
|
animation-delay: -0.16s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item-actions {
|
@keyframes dot-pulse {
|
||||||
margin-left: auto;
|
|
||||||
/* Pushes actions to the right */
|
|
||||||
padding-left: 1rem;
|
|
||||||
/* Space before actions */
|
|
||||||
}
|
|
||||||
|
|
||||||
.offline-item {
|
0%,
|
||||||
position: relative;
|
80%,
|
||||||
opacity: 0.8;
|
100% {
|
||||||
transition: opacity 0.3s ease;
|
transform: scale(0);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
40% {
|
||||||
transform: rotate(360deg);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.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>
|
</style>
|
@ -1,13 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="container page-padding">
|
<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 v-if="error" class="alert alert-error mb-3" role="alert">
|
||||||
<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 class="alert-content">
|
<div class="alert-content">
|
||||||
<svg class="icon" aria-hidden="true">
|
<svg class="icon" aria-hidden="true">
|
||||||
<use xlink:href="#icon-alert-triangle" />
|
<use xlink:href="#icon-alert-triangle" />
|
||||||
@ -32,47 +27,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="item-list">
|
<div v-else>
|
||||||
<li v-for="list in lists" :key="list.id" class="list-item interactive-list-item" tabindex="0"
|
<div class="neo-lists-grid">
|
||||||
@click="navigateToList(list.id)" @keydown.enter="navigateToList(list.id)">
|
<div v-for="list in lists" :key="list.id" class="neo-list-card" @click="navigateToList(list.id)">
|
||||||
<div class="list-item-content">
|
<div class="neo-list-header">{{ list.name }}</div>
|
||||||
<div class="list-item-main" style="flex-direction: column; align-items: flex-start;">
|
<div class="neo-list-desc">{{ list.description || 'No description' }}</div>
|
||||||
<span class="item-text" style="font-size: 1.1rem; font-weight: bold;">{{ list.name }}</span>
|
<ul class="neo-item-list">
|
||||||
<small class="item-caption">{{ list.description || 'No description' }}</small>
|
<li v-for="item in list.items" :key="item.id" class="neo-list-item">
|
||||||
<small v-if="!list.group_id && !props.groupId" class="item-caption icon-caption">
|
<label class="neo-checkbox-label" @click.stop>
|
||||||
<svg class="icon icon-sm">
|
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)" />
|
||||||
<use xlink:href="#icon-user" />
|
<span :class="{ 'neo-completed': item.is_complete }">{{ item.name }}</span>
|
||||||
</svg> Personal List
|
</label>
|
||||||
</small>
|
</li>
|
||||||
<small v-if="list.group_id && !props.groupId" class="item-caption icon-caption">
|
<li class="neo-list-item new-item-input">
|
||||||
<svg class="icon icon-sm">
|
<label class="neo-checkbox-label">
|
||||||
<use xlink:href="#icon-user" />
|
<input type="checkbox" disabled />
|
||||||
</svg> <!-- Placeholder, group icon not in Valerie -->
|
<input type="text" class="neo-new-item-input" placeholder="Add new item..."
|
||||||
Group List ({{ getGroupName(list.group_id) || `ID: ${list.group_id}` }})
|
@keyup.enter="addNewItem(list, $event)" @blur="addNewItem(list, $event)" @click.stop />
|
||||||
</small>
|
</label>
|
||||||
</div>
|
</li>
|
||||||
<div class="list-item-details" style="flex-direction: column; align-items: flex-end;">
|
</ul>
|
||||||
<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>
|
||||||
</li>
|
<div class="neo-create-list-card" @click="showCreateModal = true">
|
||||||
</ul>
|
+ Create a new list
|
||||||
|
</div>
|
||||||
<div class="page-sticky-bottom-right">
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
|
||||||
@ -83,7 +62,8 @@
|
|||||||
import { ref, onMounted, computed, watch } from 'vue';
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { apiClient, API_ENDPOINTS } from '@/config/api';
|
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 {
|
interface List {
|
||||||
id: number;
|
id: number;
|
||||||
@ -95,6 +75,7 @@ interface List {
|
|||||||
group_id?: number | null;
|
group_id?: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
version: number;
|
version: number;
|
||||||
|
items: Item[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Group {
|
interface Group {
|
||||||
@ -102,6 +83,17 @@ interface Group {
|
|||||||
name: string;
|
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<{
|
const props = defineProps<{
|
||||||
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
|
groupId?: number | string; // Prop for when ListsPage is embedded (e.g. in GroupDetailPage)
|
||||||
}>();
|
}>();
|
||||||
@ -109,12 +101,11 @@ const props = defineProps<{
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(true);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const lists = ref<List[]>([]);
|
const lists = ref<(List & { items: Item[] })[]>([]);
|
||||||
const allFetchedGroups = ref<Group[]>([]); // Store all groups user has access to for display
|
const allFetchedGroups = ref<Group[]>([]);
|
||||||
const currentViewedGroup = ref<Group | null>(null); // For the title if on a specific group's list page
|
const currentViewedGroup = ref<Group | null>(null);
|
||||||
|
|
||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
|
|
||||||
const currentGroupId = computed<number | null>(() => {
|
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 () => {
|
const fetchLists = async () => {
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
try {
|
||||||
// If currentGroupId is set, fetch lists for that group. Otherwise, fetch all user's lists.
|
|
||||||
const endpoint = currentGroupId.value
|
const endpoint = currentGroupId.value
|
||||||
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
|
||||||
: API_ENDPOINTS.LISTS.BASE;
|
: API_ENDPOINTS.LISTS.BASE;
|
||||||
const response = await apiClient.get(endpoint);
|
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) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
|
error.value = err instanceof Error ? err.message : 'Failed to fetch lists.';
|
||||||
console.error(error.value, err);
|
console.error(error.value, err);
|
||||||
} finally {
|
// If we have cached data, keep showing it even if refresh failed
|
||||||
loading.value = false;
|
if (cachedLists.value.length === 0) {
|
||||||
|
lists.value = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchListsAndGroups = async () => {
|
const fetchListsAndGroups = async () => {
|
||||||
loading.value = true;
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchLists(),
|
fetchLists(),
|
||||||
fetchAllAccessibleGroups()
|
fetchAllAccessibleGroups()
|
||||||
]);
|
]);
|
||||||
await fetchCurrentViewGroupName(); // Depends on allFetchedGroups
|
await fetchCurrentViewGroupName();
|
||||||
loading.value = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const availableGroupsForModal = computed(() => {
|
const availableGroupsForModal = computed(() => {
|
||||||
return allFetchedGroups.value.map(group => ({
|
return allFetchedGroups.value.map(group => ({
|
||||||
label: group.name,
|
label: group.name,
|
||||||
@ -217,20 +220,76 @@ const getGroupName = (groupId?: number | null): string | undefined => {
|
|||||||
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onListCreated = () => {
|
const onListCreated = (newList: List & { items: Item[] }) => {
|
||||||
fetchLists(); // Refresh lists after one is created
|
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) => {
|
const navigateToList = (listId: number) => {
|
||||||
router.push(`/lists/${listId}`);
|
router.push({ name: 'ListDetail', params: { id: listId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Load cached data immediately
|
||||||
|
loadCachedData();
|
||||||
|
|
||||||
|
// Then fetch fresh data in background
|
||||||
fetchListsAndGroups();
|
fetchListsAndGroups();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for changes in groupId (e.g., if used as a component and prop changes)
|
// Watch for changes in groupId
|
||||||
watch(currentGroupId, () => {
|
watch(currentGroupId, () => {
|
||||||
|
loadCachedData();
|
||||||
fetchListsAndGroups();
|
fetchListsAndGroups();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -239,75 +298,173 @@ watch(currentGroupId, () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.page-padding {
|
.page-padding {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mb-3 {
|
.mb-3 {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-1 {
|
/* Masonry grid for cards */
|
||||||
margin-top: 0.5rem;
|
.neo-lists-grid {
|
||||||
|
columns: 3 500px;
|
||||||
|
column-gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt-2 {
|
/* Card styles */
|
||||||
margin-top: 1rem;
|
.neo-list-card,
|
||||||
}
|
.neo-create-list-card {
|
||||||
|
break-inside: avoid;
|
||||||
.interactive-list-item {
|
border-radius: 18px;
|
||||||
cursor: pointer;
|
box-shadow: 6px 6px 0 #111;
|
||||||
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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: flex-start;
|
margin: 0 0 2rem 0;
|
||||||
/* Align items to top if they wrap */
|
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 {
|
.neo-list-card:hover {
|
||||||
flex-grow: 1;
|
/* transform: translateY(-3px); */
|
||||||
margin-right: 1rem;
|
box-shadow: 6px 9px 0 #111;
|
||||||
/* Space before details */
|
/* padding: 2rem 2rem 1.5rem 2rem; */
|
||||||
|
border: 3px solid #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-item-details {
|
.neo-list-header {
|
||||||
flex-shrink: 0;
|
padding-block-start: 1rem;
|
||||||
/* Prevent badges from shrinking */
|
font-weight: 900;
|
||||||
text-align: right;
|
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>
|
</style>
|
@ -8,27 +8,45 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../layouts/MainLayout.vue'), // Use .. alias
|
component: () => import('../layouts/MainLayout.vue'), // Use .. alias
|
||||||
children: [
|
children: [
|
||||||
{ path: '', redirect: '/lists' },
|
{ 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',
|
path: 'lists/:id',
|
||||||
name: 'ListDetail',
|
name: 'ListDetail',
|
||||||
component: () => import('../pages/ListDetailPage.vue'),
|
component: () => import('../pages/ListDetailPage.vue'),
|
||||||
props: true,
|
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',
|
path: 'groups/:id',
|
||||||
name: 'GroupDetail',
|
name: 'GroupDetail',
|
||||||
component: () => import('../pages/GroupDetailPage.vue'),
|
component: () => import('../pages/GroupDetailPage.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
|
meta: { keepAlive: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'groups/:groupId/lists',
|
path: 'groups/:groupId/lists',
|
||||||
name: 'GroupLists',
|
name: 'GroupLists',
|
||||||
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
|
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
|
||||||
props: true,
|
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