
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m23s
- Updated the `check_list_access_for_financials` function to allow access for list creators and members. - Refactored the `list_expenses` endpoint to support filtering by `list_id`, `group_id`, and `isRecurring`, providing more flexible expense retrieval options. - Introduced a new `read_list_expenses` endpoint to fetch expenses associated with a specific list, ensuring proper permission checks. - Enhanced expense retrieval logic in the `get_expenses_for_list` and `get_user_accessible_expenses` functions to include settlement activities. - Updated frontend API configuration to reflect new endpoint paths and ensure consistency across the application.
902 lines
22 KiB
Vue
902 lines
22 KiB
Vue
<template>
|
|
<main class="container page-padding">
|
|
<!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
|
|
|
|
<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="lists.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 && lists.length === 0" class="loading-placeholder">
|
|
{{ t('listsPage.loadingLists') }}
|
|
</div>
|
|
|
|
<div v-else>
|
|
<div class="neo-lists-grid">
|
|
<div v-for="list in lists" :key="list.id" class="neo-list-card"
|
|
:class="{ 'touch-active': touchActiveListId === list.id }" @click="navigateToList(list.id)"
|
|
@touchstart="handleTouchStart(list.id)" @touchend="handleTouchEnd" @touchcancel="handleTouchEnd"
|
|
:data-list-id="list.id">
|
|
<div class="neo-list-header">{{ list.name }}</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" />
|
|
<span class="checkbox-text-span"
|
|
:class="{ 'neo-completed-static': item.is_complete && !item.updating }">{{
|
|
item.name }}</span>
|
|
</label>
|
|
</li>
|
|
<li 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 '@/config/api'; // Adjust path as needed
|
|
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
|
|
import { useStorage } from '@vueuse/core';
|
|
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
|
|
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
|
|
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
|
|
|
|
const { t } = useI18n();
|
|
|
|
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[];
|
|
}
|
|
|
|
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 allFetchedGroups = ref<Group[]>([]);
|
|
const currentViewedGroup = ref<Group | null>(null);
|
|
const showCreateModal = ref(false);
|
|
const newItemInputRefs = ref<HTMLInputElement[]>([]);
|
|
|
|
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);
|
|
// Not critical for page load, modal might not show groups
|
|
}
|
|
};
|
|
|
|
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
|
|
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
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 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();
|
|
// Consider animating new list card in if desired
|
|
};
|
|
|
|
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 } }); // Ensure 'ListDetail' route exists
|
|
};
|
|
|
|
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; };
|
|
|
|
onMounted(() => {
|
|
loadCachedData();
|
|
fetchListsAndGroups().then(() => {
|
|
if (lists.value.length > 0) {
|
|
setupIntersectionObserver();
|
|
}
|
|
});
|
|
});
|
|
|
|
watch(currentGroupId, () => {
|
|
loadCachedData();
|
|
fetchListsAndGroups().then(() => {
|
|
if (lists.value.length > 0) {
|
|
setupIntersectionObserver();
|
|
}
|
|
});
|
|
});
|
|
|
|
watch(() => lists.value.length, (newLength, oldLength) => {
|
|
if (newLength > 0 && oldLength === 0 && !loading.value) {
|
|
setupIntersectionObserver();
|
|
}
|
|
if (newLength > 0) {
|
|
nextTick(() => {
|
|
document.querySelectorAll('.neo-list-card[data-list-id]').forEach(card => {
|
|
if (intersectionObserver) {
|
|
intersectionObserver.observe(card);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (intersectionObserver) {
|
|
intersectionObserver.disconnect();
|
|
}
|
|
});
|
|
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Ensure --light is defined in your global styles or here, e.g., :root { --light: #fff; } */
|
|
.loading-placeholder {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
font-size: 1.2rem;
|
|
color: #555;
|
|
}
|
|
|
|
.page-padding {
|
|
padding: 1rem;
|
|
max-width: 1200px;
|
|
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);
|
|
/* border-color: var(--secondary); */
|
|
}
|
|
|
|
.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: fit-content;
|
|
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: 18px;
|
|
width: 18px;
|
|
outline: none;
|
|
border: 2px solid var(--dark);
|
|
margin: 0;
|
|
cursor: pointer;
|
|
background: var(--light);
|
|
border-radius: 4px;
|
|
display: grid;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:hover {
|
|
border-color: var(--secondary);
|
|
background: var(--light);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]::before,
|
|
.neo-checkbox-label input[type="checkbox"]::after {
|
|
content: "";
|
|
position: absolute;
|
|
height: 2px;
|
|
background: var(--primary);
|
|
border-radius: 2px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]::before {
|
|
width: 0px;
|
|
right: 55%;
|
|
transform-origin: right bottom;
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]::after {
|
|
width: 0px;
|
|
left: 45%;
|
|
transform-origin: left bottom;
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:checked {
|
|
border-color: var(--primary);
|
|
background: var(--light);
|
|
transform: scale(1.05);
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:checked::before {
|
|
opacity: 1;
|
|
animation: check-01 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:checked::after {
|
|
opacity: 1;
|
|
animation: check-02 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
|
}
|
|
|
|
.checkbox-text-span {
|
|
position: relative;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.checkbox-text-span::before,
|
|
.checkbox-text-span::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.checkbox-text-span::before {
|
|
height: 2px;
|
|
width: 8px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: var(--secondary);
|
|
border-radius: 2px;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
|
|
.checkbox-text-span::after {
|
|
content: '';
|
|
position: absolute;
|
|
width: 4px;
|
|
height: 4px;
|
|
top: 50%;
|
|
left: 130%;
|
|
transform: translate(-50%, -50%);
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span {
|
|
color: var(--dark);
|
|
opacity: 0.7;
|
|
text-decoration: line-through var(--dark);
|
|
transform: translateX(4px);
|
|
}
|
|
|
|
.neo-checkbox-label input[type="checkbox"]:checked+.checkbox-text-span::after {
|
|
animation: firework 0.8s cubic-bezier(0.4, 0, 0.2, 1) forwards 0.15s;
|
|
}
|
|
|
|
.neo-completed-static {
|
|
color: var(--dark);
|
|
opacity: 0.7;
|
|
text-decoration: line-through var(--dark);
|
|
}
|
|
|
|
.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);
|
|
/* border-color: var(--secondary); */
|
|
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 {
|
|
/* padding: 0.8rem 0; */
|
|
/* Removed as margin-bottom is used */
|
|
margin-bottom: 0.7rem;
|
|
/* Adjusted for mobile */
|
|
}
|
|
}
|
|
|
|
@keyframes check-01 {
|
|
0% {
|
|
width: 4px;
|
|
top: auto;
|
|
transform: rotate(0);
|
|
}
|
|
|
|
50% {
|
|
width: 0px;
|
|
top: auto;
|
|
transform: rotate(0);
|
|
}
|
|
|
|
51% {
|
|
width: 0px;
|
|
top: 8px;
|
|
transform: rotate(45deg);
|
|
}
|
|
|
|
100% {
|
|
width: 6px;
|
|
top: 8px;
|
|
transform: rotate(45deg);
|
|
}
|
|
}
|
|
|
|
@keyframes check-02 {
|
|
0% {
|
|
width: 4px;
|
|
top: auto;
|
|
transform: rotate(0);
|
|
}
|
|
|
|
50% {
|
|
width: 0px;
|
|
top: auto;
|
|
transform: rotate(0);
|
|
}
|
|
|
|
51% {
|
|
width: 0px;
|
|
top: 8px;
|
|
transform: rotate(-45deg);
|
|
}
|
|
|
|
100% {
|
|
width: 11px;
|
|
top: 8px;
|
|
transform: rotate(-45deg);
|
|
}
|
|
}
|
|
|
|
@keyframes firework {
|
|
0% {
|
|
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);
|
|
}
|
|
|
|
50% {
|
|
opacity: 1;
|
|
transform: translate(-50%, -50%) scale(1);
|
|
}
|
|
|
|
100% {
|
|
opacity: 0;
|
|
transform: translate(-50%, -50%) scale(1.2);
|
|
box-shadow:
|
|
0 -15px 0 0 var(--accent),
|
|
14px -8px 0 0 var(--accent),
|
|
14px 8px 0 0 var(--accent),
|
|
0 15px 0 0 var(--accent),
|
|
-14px 8px 0 0 var(--accent),
|
|
-14px -8px 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;
|
|
}
|
|
</style>
|