
All checks were successful
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Successful in 1m30s
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.
1006 lines
26 KiB
Vue
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>
|