Add member management system with NocoDB integration
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
All checks were successful
Build And Push Image / docker (push) Successful in 3m5s
- Add member CRUD operations with API endpoints - Implement member list page with card-based layout - Add member creation and viewing dialogs - Support multiple nationalities with country flags - Include phone number input with international formatting - Integrate NocoDB as backend database - Add comprehensive member data types and utilities
This commit is contained in:
@@ -4,8 +4,8 @@
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 font-weight-bold mb-4">
|
||||
<v-icon left>mdi-shield-crown</v-icon>
|
||||
Administration
|
||||
<v-icon left>mdi-account</v-icon>
|
||||
Welcome Back, {{ firstName }}
|
||||
</h1>
|
||||
<p class="text-body-1 mb-6">
|
||||
Manage users and portal settings for the MonacoUSA Portal.
|
||||
@@ -99,100 +99,7 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title>
|
||||
<v-icon left>mdi-lightning-bolt</v-icon>
|
||||
Quick Actions
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="6" md="3">
|
||||
<v-btn
|
||||
color="success"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="createUser"
|
||||
>
|
||||
<v-icon start>mdi-account-plus</v-icon>
|
||||
Add User
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-btn
|
||||
color="info"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="generateReport"
|
||||
>
|
||||
<v-icon start>mdi-file-chart</v-icon>
|
||||
User Report
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-btn
|
||||
color="warning"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="manageRoles"
|
||||
>
|
||||
<v-icon start>mdi-shield-account</v-icon>
|
||||
Manage Roles
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="6" md="3">
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="outlined"
|
||||
block
|
||||
@click="systemMaintenance"
|
||||
>
|
||||
<v-icon start>mdi-wrench</v-icon>
|
||||
Maintenance
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card elevation="2">
|
||||
<v-card-title>
|
||||
<v-icon left>mdi-history</v-icon>
|
||||
Recent Admin Activity
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="activity in recentActivity"
|
||||
:key="activity.id"
|
||||
class="px-0"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="activity.color" class="mr-3">{{ activity.icon }}</v-icon>
|
||||
</template>
|
||||
|
||||
<v-list-item-title>{{ activity.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ activity.description }}</v-list-item-subtitle>
|
||||
|
||||
<template #append>
|
||||
<small class="text-medium-emphasis">{{ activity.time }}</small>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
||||
@@ -265,8 +172,7 @@ const loadStats = async () => {
|
||||
|
||||
// Action methods (placeholders for now)
|
||||
const manageUsers = () => {
|
||||
console.log('Navigate to user management');
|
||||
// TODO: Implement user management navigation
|
||||
window.open('https://auth.monacousa.org', '_blank');
|
||||
};
|
||||
|
||||
const viewAuditLogs = () => {
|
||||
|
||||
466
pages/dashboard/member-list.vue
Normal file
466
pages/dashboard/member-list.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<!-- Header -->
|
||||
<v-row class="mb-4">
|
||||
<v-col>
|
||||
<h1 class="text-h4 font-weight-bold mb-4">
|
||||
<v-icon left>mdi-account-multiple</v-icon>
|
||||
Welcome Back, {{ firstName }}
|
||||
</h1>
|
||||
<p class="text-body-1 mb-4">
|
||||
Manage MonacoUSA association members and their information.
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Search and Filter Controls -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="4">
|
||||
<v-text-field
|
||||
v-model="searchTerm"
|
||||
label="Search members..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
clearable
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="nationalityFilter"
|
||||
:items="nationalityOptions"
|
||||
label="Nationality"
|
||||
variant="outlined"
|
||||
clearable
|
||||
@update:model-value="filterMembers"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<CountryFlag :country-code="item.raw.code" :show-name="true" size="small" />
|
||||
</template>
|
||||
<template #item="{ props, item }">
|
||||
<v-list-item v-bind="props">
|
||||
<template #prepend>
|
||||
<CountryFlag :country-code="item.raw.code" :show-name="false" size="small" />
|
||||
</template>
|
||||
<v-list-item-title>{{ item.raw.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="statusFilter"
|
||||
:items="statusOptions"
|
||||
label="Membership Status"
|
||||
variant="outlined"
|
||||
clearable
|
||||
@update:model-value="filterMembers"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="2">
|
||||
<v-btn
|
||||
color="primary"
|
||||
block
|
||||
size="large"
|
||||
@click="showAddDialog = true"
|
||||
:disabled="!canCreateMembers"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
Add Member
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Member Statistics -->
|
||||
<v-row class="mb-6">
|
||||
<v-col cols="12" md="3">
|
||||
<v-card elevation="2">
|
||||
<v-card-text>
|
||||
<div class="text-h6 text-primary font-weight-bold">{{ totalMembers }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">Total Members</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card elevation="2">
|
||||
<v-card-text>
|
||||
<div class="text-h6 text-success font-weight-bold">{{ activeMembers }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">Active Members</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card elevation="2">
|
||||
<v-card-text>
|
||||
<div class="text-h6 text-success font-weight-bold">{{ paidDuesMembers }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">Paid Dues</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-card elevation="2">
|
||||
<v-card-text>
|
||||
<div class="text-h6 font-weight-bold">{{ uniqueNationalities }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">Countries</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Loading State -->
|
||||
<v-row v-if="loading" justify="center" class="my-12">
|
||||
<v-col cols="auto" class="text-center">
|
||||
<v-progress-circular indeterminate color="primary" size="64" />
|
||||
<p class="mt-4 text-h6">Loading members...</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Error State -->
|
||||
<v-alert
|
||||
v-else-if="error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
closable
|
||||
@click:close="error = ''"
|
||||
>
|
||||
<template #title>Failed to load members</template>
|
||||
{{ error }}
|
||||
<template #append>
|
||||
<v-btn color="error" variant="text" @click="loadMembers">
|
||||
Try Again
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
<!-- Members Grid -->
|
||||
<v-row v-else>
|
||||
<v-col
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.Id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<MemberCard
|
||||
:member="member"
|
||||
@edit="editMember"
|
||||
@delete="confirmDeleteMember"
|
||||
@view="viewMember"
|
||||
:can-edit="canEditMembers"
|
||||
:can-delete="canDeleteMembers"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<!-- No Results State -->
|
||||
<v-col v-if="filteredMembers.length === 0 && !loading && !error" cols="12" class="text-center">
|
||||
<v-card elevation="0" class="pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-account-search</v-icon>
|
||||
<h3 class="text-h5 mb-2">No members found</h3>
|
||||
<p class="text-body-1 mb-4">
|
||||
{{ searchTerm || nationalityFilter || statusFilter
|
||||
? 'Try adjusting your filters to find members.'
|
||||
: 'No members have been added yet.' }}
|
||||
</p>
|
||||
<v-btn
|
||||
v-if="canCreateMembers && !searchTerm && !nationalityFilter && !statusFilter"
|
||||
color="primary"
|
||||
@click="showAddDialog = true"
|
||||
>
|
||||
<v-icon start>mdi-plus</v-icon>
|
||||
Add First Member
|
||||
</v-btn>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Add Member Dialog -->
|
||||
<AddMemberDialog
|
||||
v-model="showAddDialog"
|
||||
@member-created="handleMemberCreated"
|
||||
/>
|
||||
|
||||
<!-- Edit Member Dialog -->
|
||||
<EditMemberDialog
|
||||
v-model="showEditDialog"
|
||||
:member="selectedMember"
|
||||
@member-updated="handleMemberUpdated"
|
||||
/>
|
||||
|
||||
<!-- View Member Dialog -->
|
||||
<ViewMemberDialog
|
||||
v-model="showViewDialog"
|
||||
:member="selectedMember"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<v-dialog v-model="showDeleteDialog" max-width="400">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">
|
||||
<v-icon color="error" class="mr-2">mdi-delete-alert</v-icon>
|
||||
Confirm Delete
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
Are you sure you want to delete <strong>{{ selectedMember?.FullName }}</strong>?
|
||||
<br><br>
|
||||
<v-alert type="warning" variant="tonal" class="mt-2">
|
||||
This action cannot be undone.
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showDeleteDialog = false" variant="text">
|
||||
Cancel
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
@click="deleteMember"
|
||||
:loading="deleteLoading"
|
||||
>
|
||||
<v-icon start>mdi-delete</v-icon>
|
||||
Delete
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Success Snackbar -->
|
||||
<v-snackbar
|
||||
v-model="showSuccess"
|
||||
:timeout="4000"
|
||||
color="success"
|
||||
location="top"
|
||||
>
|
||||
{{ successMessage }}
|
||||
<template #actions>
|
||||
<v-btn variant="text" @click="showSuccess = false">
|
||||
Close
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Member, MembershipStatus } from '~/utils/types';
|
||||
import { getAllCountries, searchCountries } from '~/utils/countries';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
middleware: 'auth-board'
|
||||
});
|
||||
|
||||
// Auth and permissions
|
||||
const { firstName, isBoard, isAdmin } = useAuth();
|
||||
const canCreateMembers = computed(() => isBoard.value || isAdmin.value);
|
||||
const canEditMembers = computed(() => isBoard.value || isAdmin.value);
|
||||
const canDeleteMembers = computed(() => isAdmin.value);
|
||||
|
||||
// Reactive data
|
||||
const members = ref<Member[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
// Search and filtering
|
||||
const searchTerm = ref('');
|
||||
const nationalityFilter = ref('');
|
||||
const statusFilter = ref('');
|
||||
|
||||
// Dialogs
|
||||
const showAddDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const showViewDialog = ref(false);
|
||||
const showDeleteDialog = ref(false);
|
||||
const selectedMember = ref<Member | null>(null);
|
||||
const deleteLoading = ref(false);
|
||||
|
||||
// Success handling
|
||||
const showSuccess = ref(false);
|
||||
const successMessage = ref('');
|
||||
|
||||
// Filter options
|
||||
const statusOptions = [
|
||||
{ title: 'Active', value: 'Active' },
|
||||
{ title: 'Inactive', value: 'Inactive' },
|
||||
{ title: 'Pending', value: 'Pending' },
|
||||
{ title: 'Expired', value: 'Expired' }
|
||||
];
|
||||
|
||||
const nationalityOptions = computed(() => {
|
||||
const countries = getAllCountries();
|
||||
return countries.map(country => ({
|
||||
title: country.name,
|
||||
value: country.code,
|
||||
code: country.code,
|
||||
name: country.name
|
||||
}));
|
||||
});
|
||||
|
||||
// Computed properties
|
||||
const filteredMembers = computed(() => {
|
||||
let filtered = [...members.value];
|
||||
|
||||
// Search filter
|
||||
if (searchTerm.value) {
|
||||
const search = searchTerm.value.toLowerCase();
|
||||
filtered = filtered.filter(member =>
|
||||
member.FullName?.toLowerCase().includes(search) ||
|
||||
member.Email?.toLowerCase().includes(search) ||
|
||||
member.Phone?.includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Nationality filter
|
||||
if (nationalityFilter.value) {
|
||||
filtered = filtered.filter(member =>
|
||||
member.Nationality === nationalityFilter.value
|
||||
);
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusFilter.value) {
|
||||
filtered = filtered.filter(member =>
|
||||
member['Membership Status'] === statusFilter.value
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
const totalMembers = computed(() => members.value.length);
|
||||
const activeMembers = computed(() =>
|
||||
members.value.filter(m => m['Membership Status'] === 'Active').length
|
||||
);
|
||||
const paidDuesMembers = computed(() =>
|
||||
members.value.filter(m => m['Current Year Dues Paid'] === 'true').length
|
||||
);
|
||||
const uniqueNationalities = computed(() => {
|
||||
const nationalities = new Set(members.value.map(m => m.Nationality).filter(Boolean));
|
||||
return nationalities.size;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadMembers = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; data: { list: Member[] } }>('/api/members');
|
||||
|
||||
if (response.success) {
|
||||
members.value = response.data.list || [];
|
||||
} else {
|
||||
throw new Error('Failed to load members');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error loading members:', err);
|
||||
error.value = err.message || 'Failed to load members. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Simple debounce function
|
||||
const debouncedSearch = (() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
// Search happens automatically via computed
|
||||
}, 300);
|
||||
};
|
||||
})();
|
||||
|
||||
const filterMembers = () => {
|
||||
// Filtering happens automatically via computed
|
||||
};
|
||||
|
||||
const viewMember = (member: Member) => {
|
||||
selectedMember.value = member;
|
||||
showViewDialog.value = true;
|
||||
};
|
||||
|
||||
const editMember = (member: Member) => {
|
||||
selectedMember.value = member;
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteMember = (member: Member) => {
|
||||
selectedMember.value = member;
|
||||
showDeleteDialog.value = true;
|
||||
};
|
||||
|
||||
const deleteMember = async () => {
|
||||
if (!selectedMember.value) return;
|
||||
|
||||
deleteLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ success: boolean; message?: string }>(`/api/members/${selectedMember.value.Id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Remove from local array
|
||||
const index = members.value.findIndex(m => m.Id === selectedMember.value?.Id);
|
||||
if (index !== -1) {
|
||||
members.value.splice(index, 1);
|
||||
}
|
||||
|
||||
showSuccess.value = true;
|
||||
successMessage.value = `${selectedMember.value.FullName} has been deleted successfully.`;
|
||||
showDeleteDialog.value = false;
|
||||
selectedMember.value = null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting member:', err);
|
||||
error.value = err.message || 'Failed to delete member. Please try again.';
|
||||
} finally {
|
||||
deleteLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberCreated = (newMember: Member) => {
|
||||
members.value.unshift(newMember);
|
||||
showSuccess.value = true;
|
||||
successMessage.value = `${newMember.FullName} has been added successfully.`;
|
||||
};
|
||||
|
||||
const handleMemberUpdated = (updatedMember: Member) => {
|
||||
const index = members.value.findIndex(m => m.Id === updatedMember.Id);
|
||||
if (index !== -1) {
|
||||
members.value[index] = updatedMember;
|
||||
}
|
||||
showSuccess.value = true;
|
||||
successMessage.value = `${updatedMember.FullName} has been updated successfully.`;
|
||||
};
|
||||
|
||||
// Load members on mount
|
||||
onMounted(() => {
|
||||
loadMembers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.v-card {
|
||||
border-radius: 12px !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.v-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.member-grid {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: #a31515 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,6 @@
|
||||
<v-container fluid class="fill-height">
|
||||
<v-row class="fill-height" justify="center" align="center">
|
||||
<v-col cols="12" sm="8" md="6" lg="4" xl="3">
|
||||
<transition name="login-form" appear>
|
||||
<v-card class="login-card" elevation="24">
|
||||
<v-card-text class="pa-8">
|
||||
<!-- Logo and Welcome -->
|
||||
@@ -12,7 +11,7 @@
|
||||
src="/MONACOUSA-Flags_376x376.png"
|
||||
width="120"
|
||||
height="120"
|
||||
class="mx-auto mb-4 pulse-animation"
|
||||
class="mx-auto mb-4"
|
||||
alt="MonacoUSA Logo"
|
||||
/>
|
||||
<h1 class="text-h4 font-weight-bold mb-2" style="color: #a31515;">
|
||||
@@ -89,22 +88,15 @@
|
||||
:loading="loading"
|
||||
:disabled="!isFormValid"
|
||||
class="mb-4"
|
||||
style="background-color: #a31515 !important;"
|
||||
style="background-color: #a31515 !important; color: white !important;"
|
||||
>
|
||||
<v-icon left>mdi-login</v-icon>
|
||||
Sign In
|
||||
</v-btn>
|
||||
</v-form>
|
||||
|
||||
<!-- Additional Options -->
|
||||
<div class="text-center">
|
||||
<p class="text-body-2 text-medium-emphasis">
|
||||
Need help? Contact your administrator
|
||||
</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</transition>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
@@ -246,38 +238,6 @@ onMounted(() => {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.pulse-animation {
|
||||
animation: pulse 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form-enter-active {
|
||||
transition: all 0.6s ease;
|
||||
}
|
||||
|
||||
.login-form-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
.login-form-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for mobile */
|
||||
::-webkit-scrollbar {
|
||||
|
||||
Reference in New Issue
Block a user