![google-labs-jules[bot]](/assets/img/avatar_default.png)
This commit introduces internationalization for several page components by identifying hardcoded strings, adding them to translation files, and updating the components to use translation keys. Processed pages: - fe/src/pages/AuthCallbackPage.vue: I internationalized an error message. - fe/src/pages/ChoresPage.vue: I internationalized console error messages and an input placeholder. - fe/src/pages/ErrorNotFound.vue: I found no missing translations. - fe/src/pages/GroupDetailPage.vue: I internationalized various UI elements (ARIA labels, button text, fallback user display names) and console/error messages. - fe/src/pages/GroupsPage.vue: I internationalized error messages and console logs. - fe/src/pages/IndexPage.vue: I found no missing user-facing translations. - fe/src/pages/ListDetailPage.vue: My analysis is complete, and I identified a console message and a fallback string for translation (implementation of changes for this page is pending). For each processed page where changes were needed: - I added new keys to `fe/src/i18n/en.json`. - I added corresponding placeholder keys `"[TRANSLATE] Original Text"` to `fe/src/i18n/de.json`, `fe/src/i18n/es.json`, and `fe/src/i18n/fr.json`. - I updated the Vue component to use the `t()` function with the new keys. Further pages in `fe/src/pages/` are pending analysis and internationalization as per our original plan.
683 lines
22 KiB
Vue
683 lines
22 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, computed } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { format, startOfDay, isEqual, isToday as isTodayDate } from 'date-fns'
|
|
import { choreService } from '../services/choreService'
|
|
import { useNotificationStore } from '../stores/notifications'
|
|
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency, ChoreAssignmentUpdate } from '../types/chore'
|
|
import { groupService } from '../services/groupService'
|
|
import { useStorage } from '@vueuse/core'
|
|
|
|
const { t } = useI18n()
|
|
|
|
// Types
|
|
interface ChoreWithCompletion extends Chore {
|
|
current_assignment_id: number | null;
|
|
is_completed: boolean;
|
|
completed_at: string | null;
|
|
updating: boolean;
|
|
}
|
|
|
|
interface ChoreFormData {
|
|
name: string;
|
|
description: string;
|
|
frequency: ChoreFrequency;
|
|
custom_interval_days: number | undefined;
|
|
next_due_date: string;
|
|
type: 'personal' | 'group';
|
|
group_id: number | undefined;
|
|
}
|
|
|
|
const notificationStore = useNotificationStore()
|
|
|
|
// State
|
|
const chores = ref<ChoreWithCompletion[]>([])
|
|
const groups = ref<{ id: number, name: string }[]>([])
|
|
const showChoreModal = ref(false)
|
|
const showDeleteDialog = ref(false)
|
|
const isEditing = ref(false)
|
|
const selectedChore = ref<ChoreWithCompletion | null>(null)
|
|
|
|
const cachedChores = useStorage<ChoreWithCompletion[]>('cached-chores-v2', [])
|
|
const cachedTimestamp = useStorage<number>('cached-chores-timestamp-v2', 0)
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
|
|
const initialChoreFormState: ChoreFormData = {
|
|
name: '',
|
|
description: '',
|
|
frequency: 'daily',
|
|
custom_interval_days: undefined,
|
|
next_due_date: format(new Date(), 'yyyy-MM-dd'),
|
|
type: 'personal',
|
|
group_id: undefined,
|
|
}
|
|
|
|
const choreForm = ref({ ...initialChoreFormState })
|
|
const isLoading = ref(true)
|
|
|
|
const loadChores = async () => {
|
|
const now = Date.now();
|
|
if (cachedChores.value && cachedChores.value.length > 0 && (now - cachedTimestamp.value) < CACHE_DURATION) {
|
|
chores.value = cachedChores.value;
|
|
isLoading.value = false;
|
|
} else {
|
|
isLoading.value = true;
|
|
}
|
|
|
|
try {
|
|
const fetchedChores = await choreService.getAllChores()
|
|
const mappedChores = fetchedChores.map(c => {
|
|
const currentAssignment = c.assignments && c.assignments.length > 0 ? c.assignments[0] : null;
|
|
return {
|
|
...c,
|
|
current_assignment_id: currentAssignment?.id ?? null,
|
|
is_completed: currentAssignment?.is_complete ?? c.is_completed ?? false,
|
|
completed_at: currentAssignment?.completed_at ?? c.completed_at ?? null,
|
|
updating: false,
|
|
}
|
|
});
|
|
chores.value = mappedChores;
|
|
cachedChores.value = mappedChores;
|
|
cachedTimestamp.value = Date.now()
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.loadFailed'), error)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadFailed', 'Failed to load chores.'), type: 'error' })
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const loadGroups = async () => {
|
|
try {
|
|
groups.value = await groupService.getUserGroups();
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.loadGroupsFailed'), error);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.loadGroupsFailed', 'Failed to load groups.'), type: 'error' });
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadChores()
|
|
loadGroups()
|
|
})
|
|
|
|
const frequencyOptions = computed(() => [
|
|
{ label: t('choresPage.frequencyOptions.oneTime'), value: 'one_time' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.daily'), value: 'daily' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.weekly'), value: 'weekly' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.monthly'), value: 'monthly' as ChoreFrequency },
|
|
{ label: t('choresPage.frequencyOptions.custom'), value: 'custom' as ChoreFrequency }
|
|
]);
|
|
|
|
const getChoreSubtext = (chore: ChoreWithCompletion): string => {
|
|
if (chore.is_completed && chore.completed_at) {
|
|
const completedDate = new Date(chore.completed_at);
|
|
if (isTodayDate(completedDate)) {
|
|
return t('choresPage.completedToday');
|
|
}
|
|
return t('choresPage.completedOn', { date: format(completedDate, 'd MMM') });
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
|
|
if (chore.frequency && chore.frequency !== 'one_time') {
|
|
const freqOption = frequencyOptions.value.find(f => f.value === chore.frequency);
|
|
if (freqOption) {
|
|
if (chore.frequency === 'custom' && chore.custom_interval_days) {
|
|
parts.push(t('choresPage.frequency.customInterval', { n: chore.custom_interval_days }));
|
|
} else {
|
|
parts.push(freqOption.label);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (chore.type === 'group' && chore.group_id) {
|
|
const group = groups.value.find(g => g.id === chore.group_id);
|
|
if (group) {
|
|
parts.push(group.name);
|
|
}
|
|
}
|
|
|
|
return parts.join(' · ');
|
|
};
|
|
|
|
const groupedChores = computed(() => {
|
|
if (!chores.value) return []
|
|
|
|
const choresByDate = chores.value.reduce((acc, chore) => {
|
|
const dueDate = format(startOfDay(new Date(chore.next_due_date)), 'yyyy-MM-dd')
|
|
if (!acc[dueDate]) {
|
|
acc[dueDate] = []
|
|
}
|
|
acc[dueDate].push(chore)
|
|
return acc
|
|
}, {} as Record<string, ChoreWithCompletion[]>)
|
|
|
|
return Object.keys(choresByDate)
|
|
.sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
|
.map(dateStr => {
|
|
// Create a new Date object and ensure it's interpreted as local time, not UTC
|
|
const dateParts = dateStr.split('-').map(Number);
|
|
const date = new Date(dateParts[0], dateParts[1] - 1, dateParts[2]);
|
|
return {
|
|
date,
|
|
chores: choresByDate[dateStr]
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
.map(chore => ({
|
|
...chore,
|
|
subtext: getChoreSubtext(chore)
|
|
}))
|
|
}
|
|
});
|
|
})
|
|
|
|
const formatDateHeader = (date: Date) => {
|
|
const today = startOfDay(new Date())
|
|
const itemDate = startOfDay(date)
|
|
|
|
if (isEqual(itemDate, today)) {
|
|
return `${t('choresPage.today', 'Today')}, ${format(itemDate, 'eee, d MMM')}`
|
|
}
|
|
return format(itemDate, 'eee, d MMM')
|
|
}
|
|
|
|
const resetChoreForm = () => {
|
|
choreForm.value = { ...initialChoreFormState, next_due_date: format(new Date(), 'yyyy-MM-dd') };
|
|
isEditing.value = false
|
|
selectedChore.value = null
|
|
}
|
|
|
|
const openCreateChoreModal = () => {
|
|
resetChoreForm()
|
|
showChoreModal.value = true
|
|
}
|
|
|
|
const openEditChoreModal = (chore: ChoreWithCompletion) => {
|
|
isEditing.value = true
|
|
selectedChore.value = chore
|
|
choreForm.value = {
|
|
name: chore.name,
|
|
description: chore.description || '',
|
|
frequency: chore.frequency,
|
|
custom_interval_days: chore.custom_interval_days ?? undefined,
|
|
next_due_date: chore.next_due_date,
|
|
type: chore.type,
|
|
group_id: chore.group_id ?? undefined,
|
|
}
|
|
showChoreModal.value = true
|
|
}
|
|
|
|
const handleFormSubmit = async () => {
|
|
try {
|
|
let createdChore;
|
|
if (isEditing.value && selectedChore.value) {
|
|
const updateData: ChoreUpdate = { ...choreForm.value };
|
|
createdChore = await choreService.updateChore(selectedChore.value.id, updateData);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.updateSuccess', 'Chore updated successfully!'), type: 'success' });
|
|
} else {
|
|
const createData = { ...choreForm.value };
|
|
createdChore = await choreService.createChore(createData as ChoreCreate);
|
|
|
|
// Create an assignment for the new chore
|
|
if (createdChore) {
|
|
try {
|
|
await choreService.createAssignment({
|
|
chore_id: createdChore.id,
|
|
assigned_to_user_id: createdChore.created_by_id,
|
|
due_date: createdChore.next_due_date
|
|
});
|
|
} catch (assignmentError) {
|
|
console.error(t('choresPage.consoleErrors.createAssignmentForNewChoreFailed'), assignmentError);
|
|
// Continue anyway since the chore was created
|
|
}
|
|
}
|
|
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.createSuccess', 'Chore created successfully!'), type: 'success' });
|
|
}
|
|
showChoreModal.value = false;
|
|
await loadChores();
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.saveFailed'), error);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.saveFailed', 'Failed to save the chore.'), type: 'error' });
|
|
}
|
|
}
|
|
|
|
const confirmDelete = (chore: ChoreWithCompletion) => {
|
|
selectedChore.value = chore
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
const deleteChore = async () => {
|
|
if (!selectedChore.value) return
|
|
try {
|
|
await choreService.deleteChore(selectedChore.value.id, selectedChore.value.type, selectedChore.value.group_id ?? undefined)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.deleteSuccess', 'Chore deleted successfully.'), type: 'success' })
|
|
showDeleteDialog.value = false
|
|
await loadChores()
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.deleteFailed'), error)
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.deleteFailed', 'Failed to delete chore.'), type: 'error' })
|
|
}
|
|
}
|
|
|
|
const toggleCompletion = async (chore: ChoreWithCompletion) => {
|
|
if (chore.current_assignment_id === null) {
|
|
// If no assignment exists, create one
|
|
try {
|
|
const assignment = await choreService.createAssignment({
|
|
chore_id: chore.id,
|
|
assigned_to_user_id: chore.created_by_id,
|
|
due_date: chore.next_due_date
|
|
});
|
|
chore.current_assignment_id = assignment.id;
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.createAssignmentFailed'), error);
|
|
notificationStore.addNotification({
|
|
message: t('choresPage.notifications.createAssignmentFailed', 'Failed to create assignment for chore.'),
|
|
type: 'error'
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const originalCompleted = chore.is_completed;
|
|
chore.updating = true;
|
|
const newCompletedStatus = !chore.is_completed;
|
|
chore.is_completed = newCompletedStatus;
|
|
|
|
try {
|
|
if (newCompletedStatus) {
|
|
await choreService.completeAssignment(chore.current_assignment_id);
|
|
} else {
|
|
const assignmentUpdate: ChoreAssignmentUpdate = { is_complete: false };
|
|
await choreService.updateAssignment(chore.current_assignment_id, assignmentUpdate);
|
|
}
|
|
|
|
notificationStore.addNotification({
|
|
message: newCompletedStatus ? t('choresPage.notifications.completed', 'Chore marked as complete!') : t('choresPage.notifications.uncompleted', 'Chore marked as incomplete.'),
|
|
type: 'success'
|
|
});
|
|
await loadChores();
|
|
} catch (error) {
|
|
console.error(t('choresPage.consoleErrors.updateCompletionStatusFailed'), error);
|
|
notificationStore.addNotification({ message: t('choresPage.notifications.updateFailed', 'Failed to update chore status.'), type: 'error' });
|
|
chore.is_completed = originalCompleted;
|
|
} finally {
|
|
chore.updating = false;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container">
|
|
<header class="flex justify-between items-center">
|
|
<h1 style="margin-block-start: 0;">{{ t('choresPage.title') }}</h1>
|
|
<button class="btn btn-primary" @click="openCreateChoreModal">
|
|
{{ t('choresPage.addChore', '+') }}
|
|
</button>
|
|
</header>
|
|
|
|
<div v-if="isLoading" class="flex justify-center mt-4">
|
|
<div class="spinner-dots">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else-if="groupedChores.length === 0" class="empty-state-card">
|
|
<h3>{{ t('choresPage.empty.title', 'No Chores Yet') }}</h3>
|
|
<p>{{ t('choresPage.empty.message', 'Get started by adding your first chore!') }}</p>
|
|
<button class="btn btn-primary" @click="openCreateChoreModal">
|
|
{{ t('choresPage.addFirstChore', 'Add First Chore') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-else class="schedule-list">
|
|
<div v-for="group in groupedChores" :key="group.date.toISOString()" class="schedule-group">
|
|
<h2 class="date-header">{{ formatDateHeader(group.date) }}</h2>
|
|
<div class="neo-item-list-container">
|
|
<ul class="neo-item-list">
|
|
<li v-for="chore in group.chores" :key="chore.id" class="neo-list-item">
|
|
<div class="neo-item-content">
|
|
<label class="neo-checkbox-label">
|
|
<input type="checkbox" :checked="chore.is_completed" @change="toggleCompletion(chore)">
|
|
<div class="checkbox-content">
|
|
<span class="checkbox-text-span"
|
|
:class="{ 'neo-completed-static': chore.is_completed && !chore.updating }">
|
|
{{ chore.name }}
|
|
</span>
|
|
<span v-if="chore.subtext" class="item-time">{{ chore.subtext }}</span>
|
|
</div>
|
|
</label>
|
|
<div class="neo-item-actions">
|
|
<button class="btn btn-sm btn-neutral" @click="openEditChoreModal(chore)">
|
|
{{ t('choresPage.edit', 'Edit') }}
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" @click="confirmDelete(chore)">
|
|
{{ t('choresPage.delete', 'Delete') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chore Form Modal -->
|
|
<div v-if="showChoreModal" class="modal-backdrop open" @click.self="showChoreModal = false">
|
|
<div class="modal-container">
|
|
<form @submit.prevent="handleFormSubmit">
|
|
<div class="modal-header">
|
|
<h3>{{ isEditing ? t('choresPage.editChore', 'Edit Chore') : t('choresPage.createChore', 'Create Chore') }}
|
|
</h3>
|
|
<button type="button" @click="showChoreModal = false" class="close-button">
|
|
×
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label class="form-label" for="chore-name">{{ t('choresPage.form.name', 'Name') }}</label>
|
|
<input id="chore-name" type="text" v-model="choreForm.name" class="form-input" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="chore-desc">{{ t('choresPage.form.description', 'Description') }}</label>
|
|
<textarea id="chore-desc" v-model="choreForm.description" class="form-input"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label" for="chore-date">{{ t('choresPage.form.dueDate', 'Due Date') }}</label>
|
|
<input id="chore-date" type="date" v-model="choreForm.next_due_date" class="form-input" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">{{ t('choresPage.form.frequency', 'Frequency') }}</label>
|
|
<div class="radio-group">
|
|
<label v-for="option in frequencyOptions" :key="option.value" class="radio-label">
|
|
<input type="radio" v-model="choreForm.frequency" :value="option.value">
|
|
<span class="checkmark radio-mark"></span>
|
|
<span>{{ option.label }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div v-if="choreForm.frequency === 'custom'" class="form-group">
|
|
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
|
|
}}</label>
|
|
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
|
|
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">{{ t('choresPage.form.type', 'Type') }}</label>
|
|
<div class="radio-group">
|
|
<label class="radio-label">
|
|
<input type="radio" v-model="choreForm.type" value="personal">
|
|
<span class="checkmark radio-mark"></span>
|
|
<span>{{ t('choresPage.form.personal', 'Personal') }}</span>
|
|
</label>
|
|
<label class="radio-label">
|
|
<input type="radio" v-model="choreForm.type" value="group">
|
|
<span class="checkmark radio-mark"></span>
|
|
<span>{{ t('choresPage.form.group', 'Group') }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div v-if="choreForm.type === 'group'" class="form-group">
|
|
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
|
|
}}</label>
|
|
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
|
|
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
|
|
t('choresPage.form.cancel', 'Cancel')
|
|
}}</button>
|
|
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
|
|
t('choresPage.form.create', 'Create') }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Dialog -->
|
|
<div v-if="showDeleteDialog" class="modal-backdrop open" @click.self="showDeleteDialog = false">
|
|
<div class="modal-container confirm-modal">
|
|
<div class="modal-header">
|
|
<h3>{{ t('choresPage.deleteConfirm.title', 'Confirm Deletion') }}</h3>
|
|
<button type="button" @click="showDeleteDialog = false" class="close-button">
|
|
×
|
|
</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>{{ t('choresPage.deleteConfirm.message', 'Really want to delete? This action cannot be undone.') }}</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-neutral" @click="showDeleteDialog = false">{{
|
|
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
|
|
<button type="button" class="btn btn-danger" @click="deleteChore">{{
|
|
t('choresPage.deleteConfirm.delete', 'Delete')
|
|
}}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.schedule-group {
|
|
margin-bottom: 2rem;
|
|
position: relative;
|
|
}
|
|
|
|
.date-header {
|
|
font-size: clamp(1rem, 4vw, 1.2rem);
|
|
font-weight: bold;
|
|
color: var(--dark);
|
|
text-transform: none;
|
|
letter-spacing: normal;
|
|
margin-bottom: 1rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 2px solid var(--dark);
|
|
position: sticky;
|
|
top: 0;
|
|
background-color: var(--light);
|
|
z-index: 10;
|
|
|
|
&::after {
|
|
display: none; // Hides the default h-tag underline from valerie-ui
|
|
}
|
|
}
|
|
|
|
.item-time {
|
|
font-size: 0.9rem;
|
|
color: var(--dark);
|
|
opacity: 0.7;
|
|
margin-left: 1rem;
|
|
}
|
|
|
|
.neo-item-list-container {
|
|
border: 3px solid #111;
|
|
border-radius: 18px;
|
|
background: var(--light);
|
|
box-shadow: 6px 6px 0 #111;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Neo-style list items from ListDetailPage */
|
|
.neo-item-list {
|
|
list-style: none;
|
|
padding: 0.5rem 1rem;
|
|
margin: 0;
|
|
}
|
|
|
|
.neo-list-item {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0.75rem 0;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
|
position: relative;
|
|
transition: background-color 0.2s ease;
|
|
}
|
|
|
|
.neo-list-item:hover {
|
|
background-color: #f8f8f8;
|
|
}
|
|
|
|
.neo-list-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.neo-item-content {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.neo-item-actions {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
opacity: 0;
|
|
transition: opacity 0.2s ease;
|
|
margin-left: auto;
|
|
|
|
.btn {
|
|
margin-left: 0.25rem;
|
|
}
|
|
}
|
|
|
|
.neo-list-item:hover .neo-item-actions {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Custom Checkbox Styles from ListDetailPage */
|
|
.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;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.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;
|
|
font-weight: 500;
|
|
color: var(--dark);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* Animated strikethrough line */
|
|
.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);
|
|
}
|
|
|
|
.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-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;
|
|
}
|
|
</style>
|