Refactor VBadge and GroupDetailPage for enhanced badge variants and UI improvements
- Updated VBadge component to include additional badge variants: 'primary', 'success', 'danger', 'warning', 'info', and 'neutral'. - Modified the GroupDetailPage to utilize the new badge variants for member roles and chore frequencies. - Improved layout and styling of sections within GroupDetailPage for better user experience. - Enhanced error handling and notification messages for invite code generation and clipboard actions.
This commit is contained in:
parent
0aa88d0af7
commit
77178cc67e
@ -3,9 +3,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, computed, PropType } from 'vue';
|
import { defineComponent, computed, type PropType } from 'vue';
|
||||||
|
|
||||||
type BadgeVariant = 'secondary' | 'accent' | 'settled' | 'pending';
|
export type BadgeVariant = 'primary' | 'secondary' | 'accent' | 'settled' | 'pending' | 'success' | 'danger' | 'warning' | 'info' | 'neutral';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'VBadge',
|
name: 'VBadge',
|
||||||
@ -17,7 +17,7 @@ export default defineComponent({
|
|||||||
variant: {
|
variant: {
|
||||||
type: String as PropType<BadgeVariant>,
|
type: String as PropType<BadgeVariant>,
|
||||||
default: 'secondary',
|
default: 'secondary',
|
||||||
validator: (value: string) => ['secondary', 'accent', 'settled', 'pending'].includes(value),
|
validator: (value: string) => ['primary', 'secondary', 'accent', 'settled', 'pending', 'success', 'danger', 'warning', 'info', 'neutral'].includes(value),
|
||||||
},
|
},
|
||||||
sticky: {
|
sticky: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -81,8 +81,7 @@ export default defineComponent({
|
|||||||
color: #FFA500; // Warning-700 (assuming)
|
color: #FFA500; // Warning-700 (assuming)
|
||||||
// Design doc has #FFC107 (Warning-500) for text and #FFF3CD (Warning-100) for background. Let's use those.
|
// Design doc has #FFC107 (Warning-500) for text and #FFF3CD (Warning-100) for background. Let's use those.
|
||||||
background-color: #FFF3CD;
|
background-color: #FFF3CD;
|
||||||
color: #FFC107; // Note: Design shows a darker text #FFA000 (Warning-600 like) but specifies #FFC107 for the color name.
|
color: #856404; // Using a darker color for better contrast
|
||||||
// Using #FFC107 for now, can be adjusted.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sticky style for Accent variant
|
// Sticky style for Accent variant
|
||||||
@ -104,4 +103,34 @@ export default defineComponent({
|
|||||||
// Example: add a small border to distinguish it slightly when sticky.
|
// Example: add a small border to distinguish it slightly when sticky.
|
||||||
border: 1px solid #007AFF; // Primary-500 (same as text color for accent)
|
border: 1px solid #007AFF; // Primary-500 (same as text color for accent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background-color: #CCE5FF;
|
||||||
|
color: #004085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
background-color: #D1E7DD;
|
||||||
|
color: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
background-color: #F8D7DA;
|
||||||
|
color: #721C24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
background-color: #FFF3CD;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-info {
|
||||||
|
background-color: #D1ECF1;
|
||||||
|
color: #0C5460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-neutral {
|
||||||
|
background-color: #F8F9FA;
|
||||||
|
color: #343A40;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -9,20 +9,27 @@
|
|||||||
</template>
|
</template>
|
||||||
</VAlert>
|
</VAlert>
|
||||||
<div v-else-if="group">
|
<div v-else-if="group">
|
||||||
<VHeading level="1" :text="group.name" class="mb-3" />
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<VHeading :level="1" :text="group.name" />
|
||||||
|
<!-- Potential global actions here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="neo-section-container">
|
||||||
<div class="neo-grid">
|
<div class="neo-grid">
|
||||||
<!-- Group Members Section -->
|
<!-- Group Members Section -->
|
||||||
<VCard>
|
<div class="neo-section">
|
||||||
<template #header><VHeading level="3">{{ t('groupDetailPage.members.title') }}</VHeading></template>
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.members.title') }}</VHeading>
|
||||||
<VList v-if="group.members && group.members.length > 0">
|
<VList v-if="group.members && group.members.length > 0">
|
||||||
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
|
<VListItem v-for="member in group.members" :key="member.id" class="flex justify-between items-center">
|
||||||
<div class="neo-member-info">
|
<div class="neo-member-info">
|
||||||
<span class="neo-member-name">{{ member.email }}</span>
|
<span class="neo-member-name">{{ member.email }}</span>
|
||||||
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')" :variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
|
<VBadge :text="member.role || t('groupDetailPage.members.defaultRole')"
|
||||||
|
:variant="member.role?.toLowerCase() === 'owner' ? 'primary' : 'secondary'" />
|
||||||
</div>
|
</div>
|
||||||
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)" :disabled="removingMember === member.id">
|
<VButton v-if="canRemoveMember(member)" variant="danger" size="sm" @click="removeMember(member.id)"
|
||||||
<VSpinner v-if="removingMember === member.id" size="sm"/> {{ t('groupDetailPage.members.removeButton') }}
|
:disabled="removingMember === member.id">
|
||||||
|
<VSpinner v-if="removingMember === member.id" size="sm" /> {{
|
||||||
|
t('groupDetailPage.members.removeButton') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
@ -30,70 +37,75 @@
|
|||||||
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
|
<VIcon name="users" size="lg" class="opacity-50 mb-2" />
|
||||||
<p>{{ t('groupDetailPage.members.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.members.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</div>
|
||||||
|
|
||||||
<!-- Invite Members Section -->
|
<!-- Invite Members Section -->
|
||||||
<VCard>
|
<div class="neo-section">
|
||||||
<template #header><VHeading level="3">{{ t('groupDetailPage.invites.title') }}</VHeading></template>
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.invites.title') }}</VHeading>
|
||||||
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
<VButton variant="primary" class="w-full" @click="generateInviteCode" :disabled="generatingInvite">
|
||||||
<VSpinner v-if="generatingInvite" size="sm"/> {{ inviteCode ? t('groupDetailPage.invites.regenerateButton') : t('groupDetailPage.invites.generateButton') }}
|
<VSpinner v-if="generatingInvite" size="sm" /> {{ inviteCode ?
|
||||||
|
t('groupDetailPage.invites.regenerateButton') :
|
||||||
|
t('groupDetailPage.invites.generateButton') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
<div v-if="inviteCode" class="neo-invite-code mt-3">
|
||||||
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
|
<VFormField :label="t('groupDetailPage.invites.activeCodeLabel')" :label-sr-only="false">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
|
<VInput id="inviteCodeInput" :model-value="inviteCode" readonly class="flex-grow" />
|
||||||
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler" :aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
|
<VButton variant="neutral" :icon-only="true" iconLeft="clipboard" @click="copyInviteCodeHandler"
|
||||||
|
:aria-label="t('groupDetailPage.invites.copyButtonLabel')" />
|
||||||
</div>
|
</div>
|
||||||
</VFormField>
|
</VFormField>
|
||||||
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}</p>
|
<p v-if="copySuccess" class="text-sm text-green-600 mt-1">{{ t('groupDetailPage.invites.copySuccess') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-center py-4 mt-3">
|
<div v-else class="text-center py-4 mt-3">
|
||||||
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
|
<VIcon name="link" size="lg" class="opacity-50 mb-2" />
|
||||||
<p>{{ t('groupDetailPage.invites.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.invites.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lists Section -->
|
<!-- Lists Section -->
|
||||||
<div class="mt-4">
|
<div class="mt-4 neo-section">
|
||||||
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.lists.title') }}</VHeading>
|
||||||
<ListsPage :group-id="groupId" />
|
<ListsPage :group-id="groupId" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chores Section -->
|
<!-- Chores Section -->
|
||||||
<VCard class="mt-4">
|
<div class="mt-4 neo-section">
|
||||||
<template #header>
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<div class="flex justify-between items-center w-full">
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
||||||
<VHeading level="3">{{ t('groupDetailPage.chores.title') }}</VHeading>
|
|
||||||
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
|
<VButton :to="`/groups/${groupId}/chores`" variant="primary">
|
||||||
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{ t('groupDetailPage.chores.manageButton') }}
|
<span class="material-icons" style="margin-right: 0.25em;">cleaning_services</span> {{
|
||||||
|
t('groupDetailPage.chores.manageButton') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<VList v-if="upcomingChores.length > 0">
|
<VList v-if="upcomingChores.length > 0">
|
||||||
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
|
<VListItem v-for="chore in upcomingChores" :key="chore.id" class="flex justify-between items-center">
|
||||||
<div class="neo-chore-info">
|
<div class="neo-chore-info">
|
||||||
<span class="neo-chore-name">{{ chore.name }}</span>
|
<span class="neo-chore-name">{{ chore.name }}</span>
|
||||||
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{ formatDate(chore.next_due_date) }}</span>
|
<span class="neo-chore-due">{{ t('groupDetailPage.chores.duePrefix') }} {{
|
||||||
|
formatDate(chore.next_due_date)
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
<VBadge :text="formatFrequency(chore.frequency)" :variant="getFrequencyBadgeVariant(chore.frequency)" />
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
<div v-else class="text-center py-4">
|
<div v-else class="text-center py-4">
|
||||||
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" /> {/* Assuming cleaning_services is a valid VIcon name or will be added */}
|
<VIcon name="cleaning_services" size="lg" class="opacity-50 mb-2" />
|
||||||
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.chores.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</div>
|
||||||
|
|
||||||
<!-- Expenses Section -->
|
<!-- Expenses Section -->
|
||||||
<VCard class="mt-4">
|
<div class="mt-4 neo-section">
|
||||||
<template #header>
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<div class="flex justify-between items-center w-full">
|
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
|
||||||
<VHeading level="3">{{ t('groupDetailPage.expenses.title') }}</VHeading>
|
|
||||||
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
|
<VButton :to="`/groups/${groupId}/expenses`" variant="primary">
|
||||||
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{ t('groupDetailPage.expenses.manageButton') }}
|
<span class="material-icons" style="margin-right: 0.25em;">payments</span> {{
|
||||||
|
t('groupDetailPage.expenses.manageButton') }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<VList v-if="recentExpenses.length > 0">
|
<VList v-if="recentExpenses.length > 0">
|
||||||
<VListItem v-for="expense in recentExpenses" :key="expense.id" class="flex justify-between items-center">
|
<VListItem v-for="expense in recentExpenses" :key="expense.id" class="flex justify-between items-center">
|
||||||
<div class="neo-expense-info">
|
<div class="neo-expense-info">
|
||||||
@ -102,16 +114,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="neo-expense-details">
|
<div class="neo-expense-details">
|
||||||
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) }}</span>
|
<span class="neo-expense-amount">{{ expense.currency }} {{ formatAmount(expense.total_amount) }}</span>
|
||||||
<VBadge :text="formatSplitType(expense.split_type)" :variant="getSplitTypeBadgeVariant(expense.split_type)" />
|
<VBadge :text="formatSplitType(expense.split_type)"
|
||||||
|
:variant="getSplitTypeBadgeVariant(expense.split_type)" />
|
||||||
</div>
|
</div>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
<div v-else class="text-center py-4">
|
<div v-else class="text-center py-4">
|
||||||
<VIcon name="payments" size="lg" class="opacity-50 mb-2" /> {/* Assuming payments is a valid VIcon name or will be added */}
|
<VIcon name="payments" size="lg" class="opacity-50 mb-2" />
|
||||||
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
|
<p>{{ t('groupDetailPage.expenses.emptyState') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
|
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
|
||||||
@ -130,6 +143,7 @@ import { choreService } from '../services/choreService'
|
|||||||
import type { Chore, ChoreFrequency } from '../types/chore'
|
import type { Chore, ChoreFrequency } from '../types/chore'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import type { Expense } from '@/types/expense'
|
import type { Expense } from '@/types/expense'
|
||||||
|
import type { BadgeVariant } from '@/components/valerie/VBadge.vue';
|
||||||
import VHeading from '@/components/valerie/VHeading.vue';
|
import VHeading from '@/components/valerie/VHeading.vue';
|
||||||
import VSpinner from '@/components/valerie/VSpinner.vue';
|
import VSpinner from '@/components/valerie/VSpinner.vue';
|
||||||
import VAlert from '@/components/valerie/VAlert.vue';
|
import VAlert from '@/components/valerie/VAlert.vue';
|
||||||
@ -327,8 +341,8 @@ const formatFrequency = (frequency: ChoreFrequency) => {
|
|||||||
return options[frequency] || frequency;
|
return options[frequency] || frequency;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
|
const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
|
||||||
const colorMap: Record<ChoreFrequency, string> = {
|
const colorMap: Record<ChoreFrequency, BadgeVariant> = {
|
||||||
one_time: 'neutral',
|
one_time: 'neutral',
|
||||||
daily: 'info',
|
daily: 'info',
|
||||||
weekly: 'success',
|
weekly: 'success',
|
||||||
@ -342,8 +356,10 @@ const getFrequencyBadgeVariant = (frequency: ChoreFrequency): string => {
|
|||||||
const loadRecentExpenses = async () => {
|
const loadRecentExpenses = async () => {
|
||||||
if (!groupId.value) return
|
if (!groupId.value) return
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/api/groups/${groupId.value}/expenses`)
|
const response = await apiClient.get(
|
||||||
recentExpenses.value = response.data.slice(0, 5) // Get only the 5 most recent expenses
|
`${API_ENDPOINTS.FINANCIALS.EXPENSES}?group_id=${groupId.value}&limit=5`
|
||||||
|
)
|
||||||
|
recentExpenses.value = response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading recent expenses:', error)
|
console.error('Error loading recent expenses:', error)
|
||||||
}
|
}
|
||||||
@ -363,8 +379,8 @@ const formatSplitType = (type: string) => {
|
|||||||
return t(key);
|
return t(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSplitTypeBadgeVariant = (type: string): string => {
|
const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, BadgeVariant> = {
|
||||||
equal: 'info',
|
equal: 'info',
|
||||||
exact_amounts: 'success',
|
exact_amounts: 'success',
|
||||||
percentage: 'accent', // Using accent for purple
|
percentage: 'accent', // Using accent for purple
|
||||||
@ -416,38 +432,55 @@ onMounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Neo Grid Layout */
|
.neo-section-container {
|
||||||
.neo-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Neo Card Styles */
|
|
||||||
.neo-card {
|
|
||||||
border-radius: 18px;
|
|
||||||
box-shadow: 6px 6px 0 #111;
|
|
||||||
background: var(--light);
|
|
||||||
border: 3px solid #111;
|
border: 3px solid #111;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgb(255, 248, 240);
|
||||||
|
box-shadow: 6px 6px 0 #111;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-card-header {
|
.neo-section {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border-bottom: 3px solid #111;
|
border-bottom: 1px solid #eee;
|
||||||
background: #fafafa;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-card-header h3 {
|
.neo-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-section-header {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-card-body {
|
.neo-grid {
|
||||||
padding: 1.5rem;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-grid .neo-section {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-grid .neo-section:first-child {
|
||||||
|
border-right: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.neo-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-grid .neo-section:first-child {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Members List Styles */
|
/* Members List Styles */
|
||||||
@ -549,7 +582,7 @@ onMounted(() => {
|
|||||||
/* Responsive Adjustments */
|
/* Responsive Adjustments */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.neo-grid {
|
.neo-grid {
|
||||||
gap: 1.5rem;
|
/* The gap is removed to allow for border-based separators */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -558,11 +591,6 @@ onMounted(() => {
|
|||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.neo-card-header,
|
|
||||||
.neo-card-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.neo-member-item {
|
.neo-member-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
Loading…
Reference in New Issue
Block a user