feat: Fredy UI redesign

* New design :)
This commit is contained in:
Christian Kellner
2026-04-22 21:11:18 +02:00
committed by GitHub
parent c78472bd19
commit f30ec4645c
43 changed files with 4004 additions and 794 deletions

View File

@@ -32,7 +32,6 @@ import {
IconBriefcase,
IconBell,
IconSearch,
IconPlusCircle,
IconArrowUp,
IconArrowDown,
IconHome,
@@ -202,10 +201,6 @@ const JobGrid = () => {
return (
<div className="jobGrid">
<div className="jobGrid__topbar">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<Input
className="jobGrid__topbar__search"
prefix={<IconSearch />}

View File

@@ -1,18 +1,33 @@
@import '../../cards/DashboardCardColors.less';
@import '../../../tokens.less';
.jobGrid {
display: flex;
flex-direction: column;
flex: 1;
&__topbar {
display: flex;
align-items: center;
gap: @space-3;
margin-bottom: @space-4;
flex-wrap: wrap;
&__search {
flex: 1;
min-width: 160px;
}
}
&__card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
background-color: @color-surface !important;
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
transition: transform @transition-card, box-shadow @transition-card;
&:hover {
transform: translateY(-4px);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
box-shadow: 0 4px 20px -4px rgba(0,0,0,0.5);
}
&__header {
@@ -35,10 +50,10 @@
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--semi-color-text-3);
background-color: rgba(251,113,133,0.7);
&--active {
background-color: #21aa21;
background-color: rgba(52,211,153,0.8);
}
}
@@ -52,21 +67,21 @@
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.04);
background: rgba(255,255,255,0.04);
border: 1px solid transparent;
border-radius: var(--semi-border-radius-small);
border-radius: @radius-chip;
padding: 10px 4px 8px;
&__number {
font-size: 22px;
font-weight: 600;
color: var(--semi-color-text-0);
color: @color-text;
line-height: 1.2;
}
&__label {
font-size: 11px;
color: var(--semi-color-text-3);
font-size: @text-xs;
color: @color-faint;
display: flex;
align-items: center;
gap: 3px;
@@ -102,59 +117,25 @@
}
}
&__topbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.jobGrid__topbar__search {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
flex-wrap: wrap;
.semi-button:first-child {
flex-shrink: 0;
}
.jobGrid__topbar__search {
flex: 1;
min-width: 160px;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
}
&__title {
margin-bottom: 0 !important;
color: @color-text !important;
}
&__actions {
display: flex;
gap: 6px;
gap: 4px;
flex-shrink: 0;
}
&__pagination {
margin-top: 2rem;
margin-top: @space-4;
display: flex;
justify-content: center;
}
}
.jobPopoverContent {
padding: .4rem;
color: var(--semi-color-white);
font-size: @text-sm;
padding: 4px 8px;
color: @color-text;
}

View File

@@ -10,34 +10,16 @@ import {
parseString,
parseNullableBoolean,
} from '../../../hooks/useSearchParamState.js';
import {
Card,
Col,
Row,
Image,
Button,
Typography,
Pagination,
Toast,
Divider,
Input,
Select,
Empty,
Radio,
RadioGroup,
Space,
} from '@douyinfe/semi-ui-19';
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
IconClock,
IconDelete,
IconLink,
IconMapPin,
IconStar,
IconStarStroked,
IconSearch,
IconActivity,
IconEyeOpened,
IconArrowUp,
IconArrowDown,
@@ -53,8 +35,6 @@ import { debounce } from '../../../utils';
import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
const { Text } = Typography;
const ListingsGrid = () => {
const listingsData = useSelector((state) => state.listingsData);
const providers = useSelector((state) => state.provider);
@@ -137,10 +117,6 @@ const ListingsGrid = () => {
}
};
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return (
<div className="listingsGrid">
<div className="listingsGrid__topbar">
@@ -238,111 +214,107 @@ const ListingsGrid = () => {
description="No listings available yet..."
/>
)}
<Row gutter={[16, 16]}>
<div className="listingsGrid__grid">
{(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)}
cover={
<div style={{ position: 'relative' }}>
<div className="listingsGrid__imageContainer">
<Image
src={item.image_url || no_image}
fallback={no_image}
width="100%"
height={180}
style={{ objectFit: 'cover' }}
preview={false}
/>
<Button
icon={
item.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="light"
shape="circle"
size="small"
className="listingsGrid__watchButton"
onClick={(e) => handleWatch(e, item)}
/>
</div>
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
</div>
}
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<div className="listingsGrid__price">
<IconCart size="small" />
{item.price}
</div>
<div className="listingsGrid__meta">
<Text
type="secondary"
icon={<IconMapPin />}
size="small"
ellipsis={{ showTooltip: true }}
style={{ width: '100%' }}
>
{item.address || 'No address provided'}
</Text>
<Space spacing={12} wrap>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
</Space>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated
</Text>
)}
</div>
<Divider margin=".6rem" />
<div className="listingsGrid__actions">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
type="secondary"
size="small"
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"
size="small"
onClick={(e) => {
e.stopPropagation();
setListingToDelete(item.id);
setDeleteModalVisible(true);
}}
icon={<IconDelete />}
/>
<div
key={item.id}
className="listingsGrid__card"
style={{ cursor: 'pointer' }}
role="button"
tabIndex={0}
onClick={() => navigate(`/listings/listing/${item.id}`)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') navigate(`/listings/listing/${item.id}`);
}}
>
<div className="listingsGrid__card__image-wrapper">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
{!item.is_active && (
<div className="listingsGrid__card__inactive-watermark">
<span>Inactive</span>
</div>
)}
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => handleWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</div>
<div className="listingsGrid__card__body">
<div className="listingsGrid__card__title" title={item.title}>
{item.title}
</div>
</Card>
</Col>
{item.price && (
<div className="listingsGrid__card__price">
<IconCart size="small" />
{item.price}
</div>
)}
{item.address && (
<div className="listingsGrid__card__meta">
<IconMapPin />
{item.address}
</div>
)}
<div className="listingsGrid__card__meta">
<IconBriefcase />
{item.provider}
</div>
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
</div>
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<Tooltip content="Original Listing">
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
navigate(`/listings/listing/${item.id}`);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
setListingToDelete(item.id);
setDeleteModalVisible(true);
}}
/>
</Tooltip>
</div>
</div>
))}
</Row>
</div>
{(listingsData?.result || []).length > 0 && (
<div className="listingsGrid__pagination">
<Pagination

View File

@@ -1,22 +1,16 @@
@import '../../cards/DashboardCardColors.less';
@import '../../../tokens.less';
.listingsGrid {
&__imageContainer {
position: relative;
height: 180px;
overflow: hidden;
}
&__topbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
gap: @space-3;
margin-bottom: @space-4;
flex-wrap: wrap;
.listingsGrid__topbar__search {
flex: 1;
&__search {
min-width: 200px;
flex: 1;
}
@media (max-width: 768px) {
@@ -37,149 +31,151 @@
}
}
&__watchButton {
position: absolute;
top: 8px;
right: 8px;
background-color: white !important;
box-shadow: var(--semi-shadow-elevated);
&:hover {
background-color: var(--semi-color-fill-0) !important;
}
}
&__statusTag {
position: absolute;
bottom: 8px;
left: 8px;
&__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
&__card {
height: 100%;
transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
background: @color-elevated !important;
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
overflow: hidden;
transition: transform @transition-card, box-shadow @transition-card;
display: flex;
flex-direction: column;
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
background-color: rgba(36, 36, 36, 1);
transform: translateY(-2px);
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
}
&--inactive {
&__image-wrapper {
position: relative;
height: 160px;
overflow: hidden;
flex-shrink: 0;
.listingsGrid__imageContainer,
.listingsGrid__content {
opacity: 0.6;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__inactive-watermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.35);
span {
font-size: 18px;
font-weight: 800;
color: rgba(251,113,133,0.9);
text-transform: uppercase;
letter-spacing: 0.15em;
transform: rotate(-30deg);
border: 2px solid rgba(251,113,133,0.5);
padding: 4px 12px;
border-radius: @radius-chip;
backdrop-filter: blur(2px);
}
}
&__star {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background @transition-fast;
padding: 0;
&:hover {
background: rgba(0,0,0,0.75);
}
svg {
color: @color-accent;
font-size: 14px;
}
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
&__title {
font-weight: 700;
font-size: @text-sm;
color: @color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__price {
font-size: @text-base;
font-weight: 600;
color: @color-success;
display: flex;
align-items: center;
gap: 4px;
}
&__meta {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
.semi-icon {
font-size: 11px;
color: @color-faint;
}
}
&__provider {
font-size: @text-xs;
color: @color-faint;
}
&__actions {
display: flex;
justify-content: space-around;
padding: 8px 12px;
border-top: 1px solid @color-border;
gap: 4px;
margin-top: auto;
button {
flex: 1;
border: none !important;
border-radius: @radius-chip !important;
}
}
}
&__inactiveOverlay {
position: absolute;
top: 70px;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
color: var(--semi-color-danger);
font-weight: bold;
font-size: 1.3rem;
text-transform: uppercase;
transform: rotate(-30deg);
padding: 5px;
max-height: fit-content;
margin: auto;
}
&__titleLink {
color: inherit;
text-decoration: none;
&:hover {
color: var(--semi-color-primary);
}
}
&__title {
display: block;
height: 1.5em;
}
&__pagination {
margin-top: 2rem;
margin-top: @space-4;
display: flex;
justify-content: center;
}
&__price {
font-size: 18px;
font-weight: 700;
color: @color-green-text;
display: flex;
align-items: center;
gap: 5px;
margin: 8px 0 6px;
}
&__meta {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
&__setupButton {
margin-bottom: 1rem;
}
&__linkButton {
background: var(--semi-color-primary);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
}
}
// Ensure icons and text are vertically aligned
.semi-typography {
display: inline-flex;
align-items: center;
.semi-typography-icon {
display: flex;
align-items: center;
margin-top: 1px; // Minor nudge if needed, but flex should handle most
}
}
}