mitlist/fe/src/pages/ListsPage.vue
mohamad 448a0705d2
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s
feat: Implement comprehensive roadmap for feature updates and enhancements
This commit introduces a detailed roadmap for implementing various features, focusing on backend and frontend improvements. Key additions include:

- New database schema designs for financial audit logging, archiving lists, and categorizing items.
- Backend logic for financial audit logging, archiving functionality, and chore subtasks.
- Frontend UI updates for archiving lists, managing categories, and enhancing the chore interface.
- Introduction of a guest user flow and integration of Redis for caching to improve performance.

These changes aim to enhance the application's functionality, user experience, and maintainability.
2025-06-10 08:16:55 +02:00

1006 lines
26 KiB
Vue

<template>
<main class="container page-padding">
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
</template>
</VAlert>
<VCard v-else-if="filteredLists.length === 0 && !loading" variant="empty-state" empty-icon="clipboard"
:empty-title="t(noListsMessageKey)">
<template #default>
<p v-if="!currentGroupId">{{ t('listsPage.emptyState.personalGlobalInfo') }}</p>
<p v-else>{{ t('listsPage.emptyState.groupSpecificInfo') }}</p>
</template>
<template #empty-actions>
<VButton variant="primary" class="mt-2" @click="showCreateModal = true" icon-left="plus">
{{ t('listsPage.createNewListButton') }}
</VButton>
</template>
</VCard>
<div v-else-if="loading && filteredLists.length === 0" class="loading-placeholder">
{{ t('listsPage.loadingLists') }}
</div>
<div v-else>
<div class="neo-lists-grid">
<div v-for="list in filteredLists" :key="list.id" class="neo-list-card"
:class="{ 'touch-active': touchActiveListId === list.id, 'archived': list.archived_at }"
@click="navigateToList(list.id)" @touchstart.passive="handleTouchStart(list.id)"
@touchend.passive="handleTouchEnd" @touchcancel.passive="handleTouchEnd" :data-list-id="list.id">
<div class="neo-list-header">
<span>{{ list.name }}</span>
<div class="actions">
<VButton v-if="!list.archived_at" @click.stop="archiveList(list)" variant="danger" size="sm"
icon="archive" />
<VButton v-else @click.stop="unarchiveList(list)" variant="success" size="sm" icon="unarchive" />
</div>
</div>
<div class="neo-list-desc">{{ list.description || t('listsPage.noDescription') }}</div>
<ul class="neo-item-list">
<li v-for="item in list.items" :key="item.id || item.tempId" class="neo-list-item" :data-item-id="item.id"
:data-item-temp-id="item.tempId" :class="{ 'is-updating': item.updating }">
<label class="neo-checkbox-label" @click.stop>
<input type="checkbox" :checked="item.is_complete" @change="toggleItem(list, item)"
:disabled="item.id === undefined && item.tempId !== undefined" />
<div class="checkbox-content">
<span class="checkbox-text-span"
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
item.name }}</span>
</div>
</label>
</li>
<li v-if="!list.archived_at" class="neo-list-item new-item-input-container">
<label class="neo-checkbox-label">
<input type="checkbox" disabled />
<input type="text" class="neo-new-item-input" :placeholder="t('listsPage.addItemPlaceholder')"
ref="newItemInputRefs" :data-list-id="list.id" @keyup.enter="addNewItem(list, $event)"
@blur="handleNewItemBlur(list, $event)" @click.stop />
</label>
</li>
</ul>
</div>
<div class="neo-create-list-card" @click="showCreateModal = true" ref="createListCardRef">
{{ t('listsPage.createCard.title') }}
</div>
</div>
</div>
<CreateListModal v-model="showCreateModal" :groups="availableGroupsForModal" @created="onListCreated" />
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/services/api';
import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue';
import VToggleSwitch from '@/components/valerie/VToggleSwitch.vue';
const { t } = useI18n();
interface ListStatus {
id: number;
updated_at: string;
item_count: number;
latest_item_updated_at: string | null;
}
interface List {
id: number;
name: string;
description?: string;
is_complete: boolean;
updated_at: string;
created_by_id: number;
group_id?: number | null;
created_at: string;
version: number;
items: Item[];
archived_at?: string | null;
}
interface Group {
id: number;
name: string;
}
interface Item {
id: number | string;
tempId?: string;
name: string;
quantity?: string | number;
is_complete: boolean;
price?: number | null;
version: number;
updating?: boolean;
created_at?: string;
updated_at: string;
}
const props = defineProps<{
groupId?: number | string;
}>();
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const error = ref<string | null>(null);
const lists = ref<(List & { items: Item[] })[]>([]);
const archivedLists = ref<List[]>([]);
const haveFetchedArchived = ref(false);
const allFetchedGroups = ref<Group[]>([]);
const currentViewedGroup = ref<Group | null>(null);
const showCreateModal = ref(false);
const newItemInputRefs = ref<HTMLInputElement[]>([]);
const pollingInterval = ref<ReturnType<typeof setInterval> | null>(null);
const currentGroupId = computed<number | null>(() => {
const idFromProp = props.groupId;
const idFromRoute = route.params.groupId;
if (idFromProp) {
return typeof idFromProp === 'string' ? parseInt(idFromProp, 10) : idFromProp;
}
if (idFromRoute) {
return parseInt(idFromRoute as string, 10);
}
return null;
});
const fetchCurrentViewGroupName = async () => {
if (!currentGroupId.value) {
currentViewedGroup.value = null;
return;
}
const found = allFetchedGroups.value.find(g => g.id === currentGroupId.value);
if (found) {
currentViewedGroup.value = found;
return;
}
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(String(currentGroupId.value)));
currentViewedGroup.value = response.data as Group;
} catch (err) {
console.error(`Failed to fetch group name for ID ${currentGroupId.value}:`, err);
currentViewedGroup.value = null;
}
};
const pageTitle = computed(() => {
if (currentGroupId.value) {
return currentViewedGroup.value
? t('listsPage.pageTitle.forGroup', { groupName: currentViewedGroup.value.name })
: t('listsPage.pageTitle.forGroupId', { groupId: currentGroupId.value });
}
return t('listsPage.pageTitle.myLists');
});
const noListsMessageKey = computed(() => {
if (currentGroupId.value) {
return 'listsPage.emptyState.noListsForGroup';
}
return 'listsPage.emptyState.noListsYet';
});
const fetchAllAccessibleGroups = async () => {
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
allFetchedGroups.value = (response.data as Group[]);
} catch (err) {
console.error('Failed to fetch groups for modal:', err);
}
};
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000;
const loadCachedData = () => {
const now = Date.now();
if (cachedLists.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
lists.value = JSON.parse(JSON.stringify(cachedLists.value));
loading.value = false;
}
};
const fetchLists = async () => {
error.value = null;
try {
const endpoint = currentGroupId.value
? API_ENDPOINTS.GROUPS.LISTS(String(currentGroupId.value))
: API_ENDPOINTS.LISTS.BASE;
const response = await apiClient.get(endpoint);
lists.value = (response.data as (List & { items: Item[] })[]).map(list => ({
...list,
items: list.items || []
}));
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('listsPage.errors.fetchFailed');
console.error(error.value, err);
if (cachedLists.value.length === 0) lists.value = [];
}
};
const fetchArchivedLists = async () => {
if (haveFetchedArchived.value) return;
try {
const endpoint = API_ENDPOINTS.LISTS.ARCHIVED;
const response = await apiClient.get(endpoint);
archivedLists.value = response.data as List[];
haveFetchedArchived.value = true;
} catch (err) {
console.error('Failed to fetch archived lists:', err);
}
};
const fetchListsAndGroups = async () => {
loading.value = true;
try {
await Promise.all([
fetchLists(),
fetchAllAccessibleGroups()
]);
await fetchCurrentViewGroupName();
} catch (err) {
console.error('Error in fetchListsAndGroups:', err);
} finally {
loading.value = false;
}
};
const availableGroupsForModal = computed(() => {
return allFetchedGroups.value.map(group => ({
label: group.name,
value: group.id,
}));
});
const getGroupName = (groupId?: number | null): string | undefined => {
if (!groupId) return undefined;
return allFetchedGroups.value.find(g => g.id === groupId)?.name;
}
const onListCreated = (newList: List & { items: Item[] }) => {
lists.value.push({
...newList,
items: newList.items || []
});
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
};
const toggleItem = async (list: List, item: Item) => {
if (typeof item.id === 'string' && item.id.startsWith('temp-')) {
return;
}
const originalIsComplete = 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 = originalIsComplete;
console.error('Failed to update item:', err);
const itemElement = document.querySelector(`.neo-list-item[data-item-id="${item.id}"]`);
if (itemElement) {
itemElement.classList.add('error-flash');
setTimeout(() => itemElement.classList.remove('error-flash'), 800);
}
} finally {
item.updating = false;
}
};
const addNewItem = async (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
const itemName = inputElement.value.trim();
if (!itemName) {
if (event.type === 'blur') inputElement.value = '';
return;
}
const localTempId = `temp-${Date.now()}`;
const newItem: Item = {
id: localTempId,
tempId: localTempId,
name: itemName,
is_complete: false,
version: 0,
updating: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
list.items.push(newItem);
const originalInputValue = inputElement.value;
inputElement.value = '';
inputElement.disabled = true;
await nextTick();
const newItemLiElement = document.querySelector(`.neo-list-item[data-item-temp-id="${localTempId}"]`);
if (newItemLiElement) {
newItemLiElement.classList.add('item-appear');
}
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.ITEMS(String(list.id)), {
name: itemName,
is_complete: false,
});
const addedItemFromServer = response.data as Item;
const itemIndex = list.items.findIndex(i => i.tempId === localTempId);
if (itemIndex !== -1) {
list.items.splice(itemIndex, 1, {
...newItem,
...addedItemFromServer,
updating: false,
tempId: undefined
});
}
if (event.type === 'keyup' && (event as KeyboardEvent).key === 'Enter') {
inputElement.disabled = false;
inputElement.focus();
} else {
inputElement.disabled = false;
}
} catch (err) {
console.error('Failed to add new item:', err);
list.items = list.items.filter(i => i.tempId !== localTempId);
inputElement.value = originalInputValue;
inputElement.disabled = false;
inputElement.style.transition = 'border-color 0.5s ease';
inputElement.style.borderColor = 'red';
setTimeout(() => {
inputElement.style.borderColor = '#ccc';
}, 500);
}
};
const handleNewItemBlur = (list: List, event: Event) => {
const inputElement = event.target as HTMLInputElement;
if (inputElement.value.trim()) {
addNewItem(list, event);
}
};
const navigateToList = (listId: number) => {
const selectedList = lists.value.find(l => l.id === listId);
if (selectedList) {
const listShell = {
id: selectedList.id,
name: selectedList.name,
description: selectedList.description,
group_id: selectedList.group_id,
};
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
}
router.push({ name: 'ListDetail', params: { id: listId } });
};
const prefetchListDetails = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const fullListData = response.data;
sessionStorage.setItem(`listDetailFull_${listId}`, JSON.stringify(fullListData));
} catch (err) {
console.warn('Pre-fetch failed:', err);
}
};
let intersectionObserver: IntersectionObserver | null = null;
const setupIntersectionObserver = () => {
if (intersectionObserver) intersectionObserver.disconnect();
intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const listId = entry.target.getAttribute('data-list-id');
if (listId) {
const cachedFullData = sessionStorage.getItem(`listDetailFull_${listId}`);
if (!cachedFullData) {
prefetchListDetails(Number(listId));
}
}
}
});
}, {
rootMargin: '100px 0px',
threshold: 0.01
});
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
intersectionObserver!.observe(card);
});
});
};
const touchActiveListId = ref<number | null>(null);
const handleTouchStart = (listId: number) => { touchActiveListId.value = listId; };
const handleTouchEnd = () => { touchActiveListId.value = null; };
const refetchList = async (listId: number) => {
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.BY_ID(String(listId)));
const updatedList = response.data as List;
const listIndex = lists.value.findIndex(l => l.id === listId);
if (listIndex !== -1) {
lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] };
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} else {
}
} catch (err) {
console.error(`Failed to refetch list ${listId}:`, err);
}
};
const checkForUpdates = async () => {
if (lists.value.length === 0) {
return;
}
const listIds = lists.value.map(l => l.id);
if (listIds.length === 0) return;
try {
const response = await apiClient.get(API_ENDPOINTS.LISTS.STATUSES, {
params: { ids: listIds }
});
const statuses = response.data as ListStatus[];
for (const status of statuses) {
const localList = lists.value.find(l => l.id === status.id);
if (localList) {
const localUpdatedAt = new Date(localList.updated_at).getTime();
const remoteUpdatedAt = new Date(status.updated_at).getTime();
const localItemCount = localList.items.length;
const remoteItemCount = status.item_count;
const localLatestItemUpdate = localList.items.reduce((latest, item) => {
const itemDate = new Date(item.updated_at).getTime();
return itemDate > latest ? itemDate : latest;
}, 0);
const remoteLatestItemUpdate = status.latest_item_updated_at
? new Date(status.latest_item_updated_at).getTime()
: 0;
if (
remoteUpdatedAt > localUpdatedAt ||
localItemCount !== remoteItemCount ||
(remoteLatestItemUpdate > localLatestItemUpdate)
) {
await refetchList(status.id);
}
}
}
} catch (err) {
console.warn('Polling for list updates failed:', err);
}
};
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(checkForUpdates, 15000);
};
const stopPolling = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value);
pollingInterval.value = null;
}
};
const showArchived = ref(false);
watch(showArchived, (isShowing) => {
if (isShowing) {
fetchArchivedLists();
}
});
const filteredLists = computed(() => {
if (showArchived.value) {
const combined = [...lists.value, ...archivedLists.value];
const uniqueLists = Array.from(new Map(combined.map(l => [l.id, l])).values());
return uniqueLists.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}
return lists.value.filter(list => !list.archived_at);
});
const archiveList = async (list: List) => {
try {
await apiClient.delete(API_ENDPOINTS.LISTS.BY_ID(list.id.toString()));
list.archived_at = new Date().toISOString();
const listIndex = lists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
const [archivedItem] = lists.value.splice(listIndex, 1);
archivedLists.value.push(archivedItem);
}
} catch (error) {
console.error('Failed to archive list', error);
}
};
const unarchiveList = async (list: List) => {
try {
const response = await apiClient.post(API_ENDPOINTS.LISTS.UNARCHIVE(list.id.toString()));
const unarchivedList = response.data as List;
const listIndex = archivedLists.value.findIndex(l => l.id === list.id);
if (listIndex > -1) {
archivedLists.value.splice(listIndex, 1);
}
lists.value.push({ ...unarchivedList, items: unarchivedList.items || [] });
} catch (error) {
console.error('Failed to unarchive list', error);
}
};
onMounted(() => {
loadCachedData();
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
}
});
});
watch(currentGroupId, () => {
loadCachedData();
haveFetchedArchived.value = false;
archivedLists.value = [];
fetchListsAndGroups().then(() => {
if (lists.value.length > 0) {
setupIntersectionObserver();
startPolling();
} else {
stopPolling();
}
});
});
watch(() => lists.value.length, (newLength, oldLength) => {
if (newLength > 0 && oldLength === 0 && !loading.value) {
setupIntersectionObserver();
}
if (newLength > 0) {
startPolling();
nextTick(() => {
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
if (intersectionObserver) {
intersectionObserver.observe(card);
}
});
});
}
});
onUnmounted(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
stopPolling();
});
</script>
<style scoped>
.loading-placeholder {
text-align: center;
padding: 2rem;
font-size: 1.2rem;
color: #555;
}
.page-padding {
padding: 1rem;
max-width: 1600px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.neo-lists-grid {
columns: 3 500px;
column-gap: 2rem;
margin-bottom: 2rem;
}
.neo-list-card,
.neo-create-list-card {
break-inside: avoid;
border-radius: 18px;
box-shadow: 6px 6px 0 var(--dark);
width: 100%;
margin: 0 0 2rem 0;
background: var(--light);
display: flex;
flex-direction: column;
border: 3px solid var(--dark);
padding: 1.5rem;
cursor: pointer;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
-webkit-tap-highlight-color: transparent;
}
.neo-list-card:hover {
transform: translateY(-4px);
box-shadow: 6px 10px 0 var(--dark);
}
.neo-list-card.touch-active {
transform: scale(0.97);
box-shadow: 3px 3px 0 var(--dark);
transition: transform 0.1s ease-out, box-shadow 0.1s ease-out;
}
.neo-list-header {
font-weight: 900;
font-size: 1.25rem;
margin-bottom: 0.5rem;
letter-spacing: 0.5px;
color: var(--dark);
}
.neo-list-desc {
font-size: 1rem;
color: var(--dark);
opacity: 0.7;
margin-bottom: 1.2rem;
font-weight: 500;
line-height: 1.4;
}
.neo-item-list {
list-style: none;
padding: 0;
margin: 0;
}
.neo-list-item {
margin-bottom: 0.8rem;
font-size: 1.05rem;
font-weight: 600;
display: flex;
align-items: center;
position: relative;
}
.neo-list-item.is-updating .checkbox-text-span {
opacity: 0.6;
}
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.8em;
cursor: pointer;
position: relative;
width: 100%;
font-weight: 500;
color: #414856;
transition: color 0.3s ease;
}
.neo-checkbox-label input[type="checkbox"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
position: relative;
height: 20px;
width: 20px;
outline: none;
border: 2px solid #b8c1d1;
margin: 0;
cursor: pointer;
background: transparent;
border-radius: 6px;
display: grid;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.neo-checkbox-label input[type="checkbox"]:hover {
border-color: var(--secondary);
transform: scale(1.05);
}
.neo-checkbox-label input[type="checkbox"]::before,
.neo-checkbox-label input[type="checkbox"]::after {
content: none;
}
.neo-checkbox-label input[type="checkbox"]::after {
content: "";
position: absolute;
opacity: 0;
left: 5px;
top: 1px;
width: 6px;
height: 12px;
border: solid var(--primary);
border-width: 0 3px 3px 0;
transform: rotate(45deg) scale(0);
transition: all 0.2s cubic-bezier(0.18, 0.89, 0.32, 1.28);
transition-property: transform, opacity;
}
.neo-checkbox-label input[type="checkbox"]:checked {
border-color: var(--primary);
}
.neo-checkbox-label input[type="checkbox"]:checked::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.checkbox-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.checkbox-text-span {
position: relative;
transition: color 0.4s ease, opacity 0.4s ease;
width: fit-content;
}
.checkbox-text-span::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(0);
transform-origin: right;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
.checkbox-text-span::after {
content: '';
position: absolute;
width: 6px;
height: 6px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: var(--accent);
opacity: 0;
pointer-events: none;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::before {
transform: scaleX(1);
transform-origin: left;
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1) 0.1s;
}
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span::after {
animation: firework-refined 0.7s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.2s;
}
.neo-completed-static {
color: var(--dark);
opacity: 0.6;
position: relative;
}
.neo-completed-static::before {
content: '';
position: absolute;
top: 50%;
left: -0.1em;
right: -0.1em;
height: 2px;
background: var(--dark);
transform: scaleX(1);
transform-origin: left;
}
.new-item-input-container .neo-checkbox-label {
width: 100%;
}
.neo-new-item-input {
all: unset;
width: 100%;
font-size: 1.05rem;
font-weight: 500;
color: #444;
padding: 0.2rem 0;
border-bottom: 1px dashed #ccc;
transition: border-color 0.2s ease;
}
.neo-new-item-input:focus {
border-bottom-color: var(--secondary);
}
.neo-new-item-input::placeholder {
color: #999;
font-weight: 400;
}
.neo-new-item-input:disabled {
opacity: 0.7;
cursor: not-allowed;
background-color: transparent;
}
.neo-create-list-card {
border: 3px dashed var(--dark);
background: var(--light);
padding: 2.5rem 0;
text-align: center;
font-weight: 900;
font-size: 1.1rem;
color: var(--dark);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
margin-bottom: 2.5rem;
transition: all 0.15s ease-out;
}
.neo-create-list-card:hover {
background: var(--light);
transform: translateY(-3px) scale(1.01);
box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05);
color: var(--primary);
}
@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;
column-gap: 1rem;
}
.neo-list-card {
margin-bottom: 1rem;
padding: 1rem;
font-size: 1rem;
min-height: 80px;
}
.neo-list-header {
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.neo-list-desc {
font-size: 0.9rem;
margin-bottom: 0.8rem;
}
.neo-checkbox-label input[type="checkbox"] {
width: 1.4em;
height: 1.4em;
}
.neo-list-item {
margin-bottom: 0.7rem;
}
}
@keyframes firework-refined {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(0.5);
box-shadow: 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent), 0 0 0 0 var(--accent);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(2);
box-shadow: 0 -20px 0 0 var(--accent), 20px 0px 0 0 var(--accent), 0 20px 0 0 var(--accent), -20px 0px 0 0 var(--accent), 14px -14px 0 0 var(--accent), 14px 14px 0 0 var(--accent), -14px 14px 0 0 var(--accent), -14px -14px 0 0 var(--accent);
}
}
@keyframes error-flash {
0% {
background-color: var(--danger);
opacity: 0.2;
}
100% {
background-color: transparent;
opacity: 0;
}
}
@keyframes item-appear {
0% {
opacity: 0;
transform: translateY(-15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.error-flash {
animation: error-flash 0.8s ease-out forwards;
}
.item-appear {
animation: item-appear 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.archived {
opacity: 0.6;
background-color: #f0f0f0;
}
.actions {
display: flex;
gap: 0.5rem;
}
</style>