[dev]database filter

This commit is contained in:
susie-laptop 2025-05-08 21:22:14 -04:00
parent 0a74e9a59c
commit d9e0750441
14 changed files with 5471 additions and 5543 deletions

10177
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,9 +17,11 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"bootstrap": "^5.3.3",
"lodash": "^4.17.21",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
"react-bootstrap": "^2.10.1",
"react-bootstrap-range-slider": "^3.0.8",
"react-dom": "^19.1.0",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.0"

View File

@ -0,0 +1,260 @@
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Form, InputGroup, Button } from 'react-bootstrap';
import { Eye } from 'lucide-react';
import RangeSlider from './RangeSlider';
import '../styles/DatabaseFilter.scss';
export default function DatabaseFilter() {
//
const categories = [
'Phones & Electronics',
'Homes Supplies',
'Kitchenware',
'Textiles & Soft Furnishings',
'Household Appliances',
];
//
const ecommerceRatings = ['L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
//
const exposureRatings = ['KOC-1', 'KOC-2', 'KOL-1', 'KOL-2', 'KOL-3'];
// GMV
const gmvRanges = [
{ label: '$0 - $5k', min: 0, max: 5000 },
{ label: '$5k - $25k', min: 5000, max: 25000 },
{ label: '$25k - $60k', min: 25000, max: 60000 },
{ label: '$60k - $150k', min: 60000, max: 150000 },
{ label: '$150k - $400k', min: 150000, max: 400000 },
{ label: '$400k - $1500k', min: 400000, max: 1500000 },
{ label: '$1500k+', min: 1500000, max: Number.MAX_SAFE_INTEGER },
];
//
const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000];
//
const findClosestDiscreteIndex = (value) => {
let closestIndex = 0;
let minDiff = Math.abs(discreteValues[0] - value);
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - value);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}
return closestIndex;
};
//
const [selectedCategory, setSelectedCategory] = useState('Homes Supplies');
const [selectedEcommerceRatings, setSelectedEcommerceRatings] = useState(['L2', 'L3']);
const [selectedExposureRatings, setSelectedExposureRatings] = useState([]);
const [selectedGmvRanges, setSelectedGmvRanges] = useState(['$5k - $25k', '$25k - $60k']);
// - 使
const [viewsRange, setViewsRange] = useState([0, 100000]);
const [minViews, setMinViews] = useState(0);
const [maxViews, setMaxViews] = useState(100000);
//
const handleCategorySelect = (category) => {
setSelectedCategory(category);
};
//
const handleEcommerceRatingSelect = (rating) => {
if (selectedEcommerceRatings.includes(rating)) {
setSelectedEcommerceRatings(selectedEcommerceRatings.filter((r) => r !== rating));
} else {
setSelectedEcommerceRatings([...selectedEcommerceRatings, rating]);
}
};
//
const handleExposureRatingSelect = (rating) => {
if (selectedExposureRatings.includes(rating)) {
setSelectedExposureRatings(selectedExposureRatings.filter((r) => r !== rating));
} else {
setSelectedExposureRatings([...selectedExposureRatings, rating]);
}
};
// GMV
const handleGmvRangeSelect = (range) => {
if (selectedGmvRanges.includes(range)) {
setSelectedGmvRanges(selectedGmvRanges.filter((r) => r !== range));
} else {
setSelectedGmvRanges([...selectedGmvRanges, range]);
}
};
//
const handleViewsRangeChange = (newRange) => {
setViewsRange(newRange);
setMinViews(newRange[0]);
setMaxViews(newRange[1]);
};
// min input
const handleMinViewsChange = (e) => {
const inputValue = parseInt(e.target.value) || 0;
setMinViews(inputValue);
};
// max input
const handleMaxViewsChange = (e) => {
const inputValue = parseInt(e.target.value) || 0;
setMaxViews(inputValue);
};
//
const handleInputBlur = (type) => {
if (type === 'min') {
//
const closestIndex = findClosestDiscreteIndex(minViews);
const discreteValue = discreteValues[closestIndex];
//
const finalValue = Math.min(discreteValue, maxViews);
setMinViews(finalValue);
setViewsRange([finalValue, viewsRange[1]]);
} else {
//
const closestIndex = findClosestDiscreteIndex(maxViews);
const discreteValue = discreteValues[closestIndex];
//
const finalValue = Math.max(discreteValue, minViews);
setMaxViews(finalValue);
setViewsRange([viewsRange[0], finalValue]);
}
};
//
const formatValue = (value) => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value;
};
return (
<Card className='shadow-sm mb-4 filter-card'>
<Card.Body>
<h3 className='mb-4'>Filter</h3>
{/* 类别筛选 */}
<div className='filter-item'>
<h5 className='filter-title'>Category</h5>
<div className='filter-options'>
{categories.map((category) => (
<Button
key={category}
variant={selectedCategory === category ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleCategorySelect(category)}
>
{category}
</Button>
))}
</div>
</div>
{/* 电商评级筛选 */}
<div className='filter-item'>
<h5 className='filter-title'>E-commerce Rating</h5>
<div className='filter-options'>
{ecommerceRatings.map((rating) => (
<Button
key={rating}
variant={selectedEcommerceRatings.includes(rating) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleEcommerceRatingSelect(rating)}
>
{rating}
</Button>
))}
</div>
</div>
{/* 曝光评级筛选 */}
<div className='filter-item'>
<h5 className='filter-title'>Exposure Rating</h5>
<div className='filter-options'>
{exposureRatings.map((rating) => (
<Button
key={rating}
variant={selectedExposureRatings.includes(rating) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleExposureRatingSelect(rating)}
>
{rating}
</Button>
))}
</div>
</div>
{/* GMV筛选 */}
<div className='filter-item'>
<h5 className='filter-title'>GMV</h5>
<div className='filter-options'>
{gmvRanges.map((range) => (
<Button
key={range.label}
variant={selectedGmvRanges.includes(range.label) ? 'primary' : 'light'}
className='rounded-pill'
onClick={() => handleGmvRangeSelect(range.label)}
>
{range.label}
</Button>
))}
</div>
</div>
{/* 视频观看量筛选 */}
<div className='filter-item'>
<h5 className='filter-title'>Views</h5>
<div className='filter-options filter-views'>
<RangeSlider min={0} max={500000} value={viewsRange} onChange={handleViewsRangeChange} />
<div className='range-input'>
<InputGroup>
<InputGroup.Text>
<Eye size={16} />
</InputGroup.Text>
<Form.Control
type='number'
value={minViews}
onChange={handleMinViewsChange}
onBlur={() => handleInputBlur('min')}
/>
</InputGroup>
<span>-</span>
<InputGroup>
<InputGroup.Text>
<Eye size={16} />
</InputGroup.Text>
<Form.Control
type='number'
value={maxViews}
onChange={handleMaxViewsChange}
onBlur={() => handleInputBlur('max')}
/>
</InputGroup>
</div>
</div>
</div>
</Card.Body>
</Card>
);
}

View File

@ -0,0 +1,4 @@
export default function DatabaseList() {
return <div>DatabaseList</div>;
}

View File

@ -4,7 +4,7 @@ import { Button } from 'react-bootstrap';
import { List } from 'lucide-react';
import Sidebar from './Sidebar';
export default function Layout() {
export default function MainLayout() {
const [showSidebar, setShowSidebar] = useState(true);
const toggleSidebar = () => {

View File

@ -0,0 +1,188 @@
import React, { useState, useEffect, useRef } from 'react';
import '../styles/RangeSlider.scss';
import debounce from 'lodash/debounce';
export default function RangeSlider({ min = 0, max = 100, value, onChange }) {
//
const discreteValues = [0, 100, 1000, 10000, 100000, 250000, 500000];
const marks = ['0', '100', '1k', '10k', '100k', '250k', '500k+'];
// 使
const getIndexForValue = (val) => {
for (let i = 0; i < discreteValues.length; i++) {
if (val <= discreteValues[i]) {
return i;
}
}
return discreteValues.length - 1;
};
//
const findClosestDiscreteIndex = (value) => {
let closestIndex = 0;
let minDiff = Math.abs(discreteValues[0] - value);
for (let i = 1; i < discreteValues.length; i++) {
const diff = Math.abs(discreteValues[i] - value);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}
return closestIndex;
};
// 使
const initialMinIndex = findClosestDiscreteIndex(value[0]);
const initialMaxIndex = findClosestDiscreteIndex(value[1]);
const [minValIndex, setMinValIndex] = useState(initialMinIndex);
const [maxValIndex, setMaxValIndex] = useState(initialMaxIndex);
const minValIndexRef = useRef(initialMinIndex);
const maxValIndexRef = useRef(initialMaxIndex);
const range = useRef(null);
//
const getPercent = (index) => {
return Math.round((index / (discreteValues.length - 1)) * 100);
};
const debouncedOnChange = useRef(
debounce((minIndex, maxIndex) => {
onChange([discreteValues[minIndex], discreteValues[maxIndex]]);
}, 300)
).current;
//
useEffect(() => {
const newMinIndex = findClosestDiscreteIndex(value[0]);
const newMaxIndex = findClosestDiscreteIndex(value[1]);
setMinValIndex(newMinIndex);
setMaxValIndex(newMaxIndex);
minValIndexRef.current = newMinIndex;
maxValIndexRef.current = newMaxIndex;
}, [value]);
//
useEffect(() => {
if (range.current) {
const minPercent = getPercent(minValIndex);
const maxPercent = getPercent(maxValIndexRef.current);
range.current.style.left = `${minPercent}%`;
range.current.style.width = `${maxPercent - minPercent}%`;
}
}, [minValIndex]);
//
useEffect(() => {
if (range.current) {
const minPercent = getPercent(minValIndexRef.current);
const maxPercent = getPercent(maxValIndex);
range.current.style.width = `${maxPercent - minPercent}%`;
}
}, [maxValIndex]);
//
useEffect(() => {
const minVal = discreteValues[minValIndex];
const maxVal = discreteValues[maxValIndex];
//
if (minVal !== value[0] || maxVal !== value[1]) {
debouncedOnChange(minValIndex, maxValIndex);
}
}, [minValIndex, maxValIndex]);
// 1k, 10k, 100k
const formatValue = (value) => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value;
};
const handleSliderChange = (which, val) => {
// 0-6
const index = Math.round(val);
if (which === 'min') {
//
const newIndex = Math.min(index, maxValIndex - 1);
setMinValIndex(newIndex);
minValIndexRef.current = newIndex;
} else {
//
const newIndex = Math.max(index, minValIndex + 1);
setMaxValIndex(newIndex);
maxValIndexRef.current = newIndex;
}
};
const handleMouseUp = () => {
onChange([discreteValues[minValIndex], discreteValues[maxValIndex]]);
};
return (
<div className='range-slider slider-container'>
<input
type='range'
min={0}
max={discreteValues.length - 1}
step={1}
value={minValIndex}
onChange={(e) => handleSliderChange('min', +e.target.value)}
onMouseUp={handleMouseUp}
onTouchEnd={handleMouseUp}
className='thumb thumb--left'
style={{ zIndex: minValIndex > discreteValues.length - 3 ? 5 : 4 }}
/>
<input
type='range'
min={0}
max={discreteValues.length - 1}
step={1}
value={maxValIndex}
onChange={(e) => handleSliderChange('max', +e.target.value)}
onMouseUp={handleMouseUp}
onTouchEnd={handleMouseUp}
className='thumb thumb--right'
/>
<div className='slider'>
<div className='slider__track'></div>
<div ref={range} className='slider__range'></div>
{/* 添加离散标记点 */}
<div className='slider__steps'>
{discreteValues.map((_, index) => (
<div
key={index}
className={`slider__step ${index >= minValIndex && index <= maxValIndex ? 'active' : ''}`}
style={{ left: `${getPercent(index)}%` }}
/>
))}
</div>
</div>
<div className='range-slider-marks'>
{marks.map((mark, index) => (
<span key={index}>{mark}</span>
))}
</div>
{/* 显示当前选中的值 */}
{/* <div className='slider__values'>
<div className='slider__value-left' style={{ left: `${getPercent(minValIndex)}%` }}>
{formatValue(discreteValues[minValIndex])}
</div>
<div className='slider__value-right' style={{ left: `${getPercent(maxValIndex)}%` }}>
{formatValue(discreteValues[maxValIndex])}
</div>
</div> */}
</div>
);
}

View File

@ -1,6 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '../src/styles/custom-theme.scss';
import './styles/global.scss';
import './index.css';
import App from './App.jsx';

16
src/pages/Database.jsx Normal file
View File

@ -0,0 +1,16 @@
import React from 'react';
import DatabaseFilter from '../components/DatabaseFilter';
import DatabaseList from '../components/DatabaseList';
import { Link } from 'react-router-dom';
export default function Database({ path }) {
return (
<React.Fragment>
<div className='breadcrumb'>
<div className='breadcrumb-item'>Creator Database</div>
<div className='breadcrumb-item'>{path}</div>
</div>
<DatabaseFilter />
<DatabaseList />
</React.Fragment>
);
}

View File

@ -1,6 +1,7 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Home from '../pages/Home';
import BootstrapLayout from '../components/Layout';
import Database from '../pages/Database';
import MainLayout from '../components/MainLayout';
// Routes configuration object
const routes = [
@ -17,19 +18,19 @@ const routes = [
children: [
{
path: '',
element: <Home />,
element: <Database />,
},
{
path: 'tiktok',
element: <Home />,
element: <Database path='tiktok' />,
},
{
path: 'instagram',
element: <Home />,
element: <Database path='instagram' />,
},
{
path: 'youtube',
element: <Home />,
element: <Database path='youtube' />,
},
],
},
@ -59,7 +60,7 @@ const routes = [
const router = createBrowserRouter([
{
path: '/',
element: <BootstrapLayout />,
element: <MainLayout />,
children: routes,
},
]);

View File

@ -0,0 +1,106 @@
@import './custom-theme.scss';
.filter-card {
border-radius: 12px;
border: none;
overflow: hidden;
background-color: #fff;
.card-body {
padding: 1.5rem;
.filter-item {
display: flex;
align-items: flex-start;
margin-bottom: 1.5rem;
.filter-title {
width: 170px;
margin: 0;
color: $gray-500;
}
.filter-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex: 1;
&.filter-views {
flex-direction: row;
flex-wrap: nowrap;
gap: 1rem;
.range-input {
display: flex;
align-items: center;
gap: 10px;
.input-group {
flex: 1;
max-width: 150px;
}
span {
padding: 0 5px;
color: $gray-600;
}
}
}
}
}
}
h3 {
font-weight: 600;
font-size: 1.5rem;
color: $dark;
}
h5 {
font-weight: 500;
font-size: 1rem;
color: $dark;
}
.btn-light {
background-color: #f5f5f5;
border-color: transparent;
color: $gray-800;
font-size: 0.875rem;
&:hover {
border-color: transparent;
background-color: #eee;
color: $primary;
}
}
.btn-primary {
font-size: 0.875rem;
font-weight: 500;
}
.form-control {
border-color: #e2e8f0;
&:focus {
border-color: $primary;
box-shadow: 0 0 0 0.25rem rgba($primary, 0.25);
}
}
.input-group-text {
background-color: #f8f9fa;
border-color: #e2e8f0;
}
}
// 圆角标签样式
.rounded-pill {
padding: 0.375rem 1rem;
transition: all 0.2s ease;
&.btn-primary {
box-shadow: 0 2px 5px rgba($primary, 0.3);
}
}

214
src/styles/RangeSlider.scss Normal file
View File

@ -0,0 +1,214 @@
@import './custom-theme.scss';
$primary: #6366f1; // 使用与主题一致的颜色
.range-slider {
width: 70%;
position: relative;
padding: 0.5rem 10px 0;
.range-slider-marks {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
margin-top: 5px;
font-size: 0.75rem;
color: #6c757d;
position: absolute;
width: 100%;
left: 10px;
}
.slider-container {
position: relative;
width: 100%;
height: 50px;
}
.slider {
position: relative;
width: 100%;
height: 5px;
&__track {
position: absolute;
width: 100%;
height: 5px;
border-radius: 3px;
background-color: #e9ecef;
}
&__range {
position: absolute;
height: 5px;
border-radius: 3px;
background-color: #6366f1;
}
&__steps {
position: absolute;
width: 100%;
height: 5px;
.slider__step {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #e9ecef;
top: -2.5px;
margin-left: -5px;
z-index: 1;
border: 1px solid #adb5bd;
transition: all 0.2s ease;
&.active {
background-color: #6366f1;
border-color: #6366f1;
transform: scale(1.2);
}
}
}
&__values {
position: absolute;
width: 100%;
top: 25px;
.slider__value-left,
.slider__value-right {
position: absolute;
transform: translateX(-50%);
font-size: 0.75rem;
color: white;
background-color: #6366f1;
padding: 2px 6px;
border-radius: 10px;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
&:after {
content: '';
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-bottom: 5px solid #6366f1;
}
}
}
}
.thumb {
position: absolute;
height: 0;
width: 100%;
outline: none;
z-index: 3;
appearance: none;
pointer-events: none;
left: 0;
&::-webkit-slider-thumb {
appearance: none;
pointer-events: all;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: white;
border: 2px solid #6366f1;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
&:hover {
background-color: #6366f1;
transform: scale(1.1);
}
&:active {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
transform: scale(1.15);
}
}
&::-moz-range-thumb {
appearance: none;
pointer-events: all;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: white;
border: 2px solid #6366f1;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
&:hover {
background-color: #6366f1;
transform: scale(1.1);
}
&:active {
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
transform: scale(1.15);
}
}
}
}
/* Removing the default appearance */
.thumb,
.thumb::-webkit-slider-thumb {
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
}
.thumb {
pointer-events: none;
position: absolute;
height: 0;
width: 100%;
outline: none;
}
.thumb--left {
z-index: 3;
}
.thumb--right {
z-index: 4;
}
/* For Chrome browsers */
.thumb::-webkit-slider-thumb {
background-color: #fff;
border: 2px solid $primary;
border-radius: 50%;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
cursor: pointer;
height: 20px;
width: 20px;
margin-top: 4px;
pointer-events: all;
position: relative;
}
/* For Firefox browsers */
.thumb::-moz-range-thumb {
background-color: #fff;
border: 2px solid $primary;
border-radius: 50%;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
cursor: pointer;
height: 20px;
width: 20px;
margin-top: 4px;
pointer-events: all;
position: relative;
}

View File

@ -29,6 +29,27 @@ $box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
// 导入Bootstrap
@import 'bootstrap/scss/bootstrap';
:root {
--bs-breadcrumb-font-size: 1.5rem;
--bs-body-color: #171A1FFF;
--bs-btn-color: white;
--bs-btn-hover-color: white;
}
.btn-primary {
--bs-btn-color: white !important;
--bs-btn-hover-color: white !important;
}
#root {
background-color: #f5f3ff;
}
a {
text-decoration: none !important;
&:hover {
text-decoration: none !important;
}
}

7
src/styles/global.scss Normal file
View File

@ -0,0 +1,7 @@
@import 'custom-theme.scss';
.breadcrumb {
a {
color: var(--bs-body-color) !important;
}
}

View File

@ -103,7 +103,7 @@
// Adjust main content when sidebar is present
.main-content {
padding: 1rem;
padding: 1.5rem;
margin: 1rem;
margin-left: 220px;
height: 100vh;