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

@@ -1,3 +1,5 @@
@import './tokens.less';
.app {
height: 100vh;
width: 100vw;
@@ -14,18 +16,14 @@
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: 24px;
background-color: var(--semi-color-bg-0);
padding: @space-6;
background-color: transparent;
box-sizing: border-box;
display: flex;
flex-direction: column;
@media (max-width: 768px) {
padding: 12px;
padding: @space-3;
}
}
}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle;
}

View File

@@ -1,16 +1,110 @@
@import './tokens.less';
body,
html {
margin: 0;
height: 100%;
width: 100%;
background-color: #232429;
font-family: @font-ui;
background-color: @color-base;
background-image: radial-gradient(ellipse at 60% 0%, rgba(224,74,56,0.05) 0%, transparent 55%);
background-attachment: fixed;
}
@media (max-width: 768px) {
body, html {
background-attachment: scroll;
}
}
// Semi UI theme overrides
body {
--semi-color-bg-0: @color-base !important;
--semi-color-bg-1: @color-surface !important;
--semi-color-bg-2: @color-elevated !important;
--semi-color-bg-3: @color-border !important;
--semi-color-border: @color-border !important;
--semi-color-primary: @color-accent !important;
--semi-color-primary-hover: @color-accent-dim !important;
--semi-color-primary-active: @color-accent-dim !important;
--semi-color-primary-light-default: rgba(224,74,56,0.12) !important;
--semi-color-primary-light-hover: rgba(224,74,56,0.18) !important;
--semi-color-primary-light-active: rgba(224,74,56,0.22) !important;
--semi-color-text-0: @color-text !important;
--semi-color-text-1: @color-text !important;
--semi-color-text-2: @color-muted !important;
--semi-color-text-3: @color-faint !important;
--semi-color-fill-0: rgba(255,255,255,0.04) !important;
--semi-color-fill-1: rgba(255,255,255,0.06) !important;
--semi-color-fill-2: rgba(255,255,255,0.08) !important;
--semi-font-family: @font-ui !important;
}
// Semi table row overrides
.semi-table-row-head {
background-color: #2b2b2b !important;
color: #fff !important;
background-color: rgba(255,255,255,0.06) !important;
}
.semi-table-row-head .semi-table-row-cell {
background-color: rgba(255,255,255,0.06) !important;
color: @color-muted !important;
font-size: @text-xs;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.semi-table-row-cell {
background-color: @color-surface !important;
}
.semi-table-tbody .semi-table-row:nth-child(even) .semi-table-row-cell {
background-color: @color-base !important;
}
.semi-table-tbody .semi-table-row:hover .semi-table-row-cell {
background-color: @color-elevated !important;
}
.semi-table-row-cell {
background-color: #333333 !important;
// Scrollbar
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: @color-surface; }
::-webkit-scrollbar-thumb { background: @color-border-bright; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: @color-muted; }
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon):not(.semi-checkbox .semi-icon) {
vertical-align: middle;
}
// Suppress focus outlines — Semi uses --semi-color-primary (our red) for all rings
button:focus,
button:focus-visible,
.semi-button:focus,
.semi-button:focus-visible,
.semi-input-wrapper:focus-within,
.semi-select:focus-within,
[tabindex]:focus {
outline: none !important;
box-shadow: none !important;
}
.semi-input-wrapper-focus {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
// Semi Modal dark theme overrides
.semi-modal-content {
background: #161616 !important;
border: 1px solid @color-border !important;
border-top: 3px solid @color-accent !important;
border-radius: 14px !important;
}
.semi-modal-header {
background: transparent !important;
border-bottom: 1px solid @color-border !important;
border-radius: 14px 14px 0 0 !important;
padding: 20px 24px 16px !important;
}
.semi-modal-mask {
background: rgba(0,0,0,0.7) !important;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}

View File

@@ -18,6 +18,7 @@
display: grid;
place-items: center;
height: 14rem;
opacity: .7;
color: #94a3b8;
font-size: 0.9rem;
}
}

View File

@@ -1,45 +1,34 @@
@import './DashboardCardColors.less';
@keyframes card-glow-rotate {
0% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
25% { box-shadow: -3px 3px 14px -4px var(--card-glow); }
50% { box-shadow: -3px -3px 14px -4px var(--card-glow); }
75% { box-shadow: 3px -3px 14px -4px var(--card-glow); }
100% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
}
.dashboard-card {
width: 100%;
height: 140px;
margin-bottom: 16px;
transition: transform 0.2s, box-shadow 0.2s;
background-color: #181b26;
border: 1px solid #232735;
border-radius: 10px;
--pulse-color: rgba(255, 255, 255, 0.08);
height: 112px;
border-radius: @radius-card !important;
border: 1px solid @color-border !important;
background-color: @color-surface !important;
transition: box-shadow @transition-card;
position: relative;
z-index: 1;
overflow: visible;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
box-shadow: 0 4px 25px -2px var(--pulse-color);
opacity: 0;
animation: pulse 5s infinite ease-in-out;
pointer-events: none;
z-index: -1;
will-change: opacity;
}
&__icon {
font-size: 20px;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
color: var(--card-accent, #94a3b8);
color: var(--card-accent, @color-gray-text);
}
&__title {
color: var(--semi-color-text-2) !important;
font-size: 12px !important;
color: rgba(148, 163, 184, 0.7) !important;
font-size: @text-xs !important;
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -49,61 +38,50 @@
}
&__value {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 4px;
color: var(--card-accent, var(--semi-color-text-0));
color: var(--card-accent, @color-text);
}
&__desc {
color: var(--semi-color-text-3) !important;
color: @color-faint !important;
font-size: @text-xs;
}
&.blue {
--pulse-color: @color-blue-border;
--card-accent: @color-blue-text;
background-color: @color-blue-bg;
border-color: @color-blue-border;
box-shadow: 0 2px 16px -6px @color-blue-border;
--card-glow: @color-blue-border;
background-color: @color-blue-bg !important;
border-color: @color-blue-border !important;
animation: card-glow-rotate 8s linear infinite;
}
&.orange {
--pulse-color: @color-orange-border;
--card-accent: @color-orange-text;
background-color: @color-orange-bg;
border-color: @color-orange-border;
box-shadow: 0 2px 16px -6px @color-orange-border;
--card-glow: @color-orange-border;
background-color: @color-orange-bg !important;
border-color: @color-orange-border !important;
animation: card-glow-rotate 8s linear infinite;
}
&.green {
--pulse-color: @color-green-border;
--card-accent: @color-green-text;
background-color: @color-green-bg;
border-color: @color-green-border;
box-shadow: 0 2px 16px -6px @color-green-border;
--card-glow: @color-green-border;
background-color: @color-green-bg !important;
border-color: @color-green-border !important;
animation: card-glow-rotate 8s linear infinite;
}
&.purple {
--pulse-color: @color-purple-border;
--card-accent: @color-purple-text;
background-color: @color-purple-bg;
border-color: @color-purple-border;
box-shadow: 0 2px 16px -6px @color-purple-border;
--card-glow: @color-purple-border;
background-color: @color-purple-bg !important;
border-color: @color-purple-border !important;
animation: card-glow-rotate 8s linear infinite;
}
&.gray {
--pulse-color: @color-gray-border;
--card-accent: @color-gray-text;
background-color: @color-gray-bg;
border-color: @color-gray-border;
box-shadow: 0 2px 16px -6px @color-gray-border;
}
}
@keyframes pulse {
0%, 100% {
opacity: 0.1;
}
50% {
opacity: 0.4;
--card-glow: @color-gray-border;
background-color: @color-gray-bg !important;
border-color: @color-gray-border !important;
animation: card-glow-rotate 8s linear infinite;
}
}

View File

@@ -1,19 +1 @@
@color-blue-bg: rgba(96, 165, 250, 0.10);
@color-blue-border: #3b6ea8;
@color-blue-text: #60a5fa;
@color-orange-bg: rgba(251, 146, 60, 0.10);
@color-orange-border: #c2622a;
@color-orange-text: #fb923c;
@color-green-bg: rgba(52, 211, 153, 0.10);
@color-green-border: #2a8a61;
@color-green-text: #34d399;
@color-purple-bg: rgba(167, 139, 250, 0.10);
@color-purple-border: #6d4fc2;
@color-purple-text: #a78bfa;
@color-gray-bg: rgba(148, 163, 184, 0.10);
@color-gray-border: #323a47;
@color-gray-text: #94a3b8;
@import '../../tokens.less';

View File

@@ -6,15 +6,7 @@
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less';
export default function KpiCard({
title,
icon,
value,
valueFontSize = '1.5rem',
description,
color = 'gray',
children,
}) {
export default function KpiCard({ title, icon, value, description, color = 'gray', children }) {
const { Text } = Typography;
return (
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
@@ -26,12 +18,12 @@ export default function KpiCard({
</Text>
</Space>
<div className="dashboard-card__content">
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
<div className="dashboard-card__value">
{value}
{children}
</div>
{description && (
<Text size="small" type="tertiary" className="dashboard-card__desc">
<Text size="small" className="dashboard-card__desc">
{description}
</Text>
)}

View File

@@ -5,23 +5,21 @@
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19';
import { Layout } from '@douyinfe/semi-ui-19';
export default function FredyFooter() {
const { Text } = Typography;
const { Footer } = Layout;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Footer className="fredyFooter">
<Space split={<Divider layout="vertical" />}>
<Text type="tertiary" size="small">
Fredy V{version?.localFredyVersion || 'N/A'}
</Text>
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
Made with
</Text>
</Space>
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
<span className="fredyFooter__credit">
Made with by{' '}
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
Christian Kellner
</a>
</span>
</Footer>
);
}

View File

@@ -1,12 +1,34 @@
@import '../../tokens.less';
.fredyFooter {
background-color: var(--semi-color-bg-1);
background-color: @color-base;
border-top: 1px solid @color-border;
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
padding: 0 1rem;
height: 32px;
border-top: 1px solid var(--semi-color-border);
z-index: 1000;
position: relative;
padding: 10px 24px;
height: 36px;
flex-shrink: 0;
box-sizing: border-box;
&__version {
font-size: @text-xs;
color: @color-faint;
font-family: @font-mono;
}
&__credit {
font-size: @text-xs;
color: @color-faint;
a {
color: @color-muted;
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: @color-text;
}
}
}
}

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
}
}
}

View File

@@ -3,13 +3,16 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Typography } from '@douyinfe/semi-ui-19';
import './Headline.less';
export default function Headline({ text, size = 3 } = {}) {
const { Title } = Typography;
export default function Headline({ text, actions } = {}) {
return (
<Title heading={size} style={{ marginBottom: '1rem' }}>
{text}
</Title>
<div className="page-heading">
<div className="page-heading__row">
<h1 className="page-heading__title">{text}</h1>
{actions && <div>{actions}</div>}
</div>
<div className="page-heading__line" />
</div>
);
}

View File

@@ -0,0 +1,27 @@
@import '../../tokens.less';
.page-heading {
margin-bottom: @space-6;
margin-top: 0;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
&__title {
font-size: @text-lg !important;
font-weight: 700 !important;
color: @color-text !important;
margin: 0 !important;
line-height: 1.2;
}
&__line {
height: 1px;
background: linear-gradient(90deg, rgba(224,74,56,0.5) 0%, rgba(224,74,56,0) 100%);
width: 100%;
}
}

View File

@@ -3,25 +3,20 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout({ text }) {
const handleLogout = async () => {
await xhrPost('/api/login/logout');
location.reload();
};
return (
<div>
<Button
icon={<IconUser />}
type="danger"
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
{text && 'Logout'}
</Button>
</div>
<button className={`navigate__logout-btn${!text ? ' navigate__logout-btn--icon-only' : ''}`} onClick={handleLogout}>
<IconUser size="default" />
{text && 'Logout'}
</button>
);
};

View File

@@ -1,11 +1,218 @@
@import '../../tokens.less';
.navigate {
&__footer {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: @color-surface;
border-right: 1px solid @color-border;
transition: @transition-sidebar;
overflow: hidden;
&__header {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
width: 100%;
display: flex;
padding-bottom: 12px;
padding: 20px 16px 16px;
min-height: 64px;
flex-shrink: 0;
img {
transition: width @transition-fast, opacity @transition-fast;
}
}
}
&__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding: 1rem 8px 10px !important;
margin-top: auto;
flex-shrink: 0;
border-top: 1px solid @color-border;
&--collapsed {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 8px 10px !important;
gap: 6px;
}
}
&__logout-btn {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border: 1px solid rgba(251,113,133,0.25);
background: rgba(251,113,133,0.06);
color: #fb7185;
border-radius: @radius-btn;
cursor: pointer;
font-size: @text-sm;
font-weight: 500;
font-family: @font-ui;
transition: background @transition-fast, border-color @transition-fast;
white-space: nowrap;
overflow: hidden;
&:hover {
background: rgba(251,113,133,0.12);
border-color: rgba(251,113,133,0.4);
}
&--icon-only {
flex: none;
width: 32px;
height: 32px;
justify-content: center;
padding: 0;
border: none;
background: transparent;
border-radius: @radius-btn;
&:hover {
background: rgba(251,113,133,0.08);
}
}
}
&__toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
flex-shrink: 0;
border: none;
background: transparent;
color: @color-faint;
cursor: pointer;
border-radius: @radius-btn;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: rgba(255,255,255,0.06);
color: @color-muted;
}
}
}
// Semi Nav overrides
.semi-navigation {
background: @color-surface !important;
border-right: none !important;
}
.semi-navigation-item {
border-radius: @radius-btn !important;
color: @color-muted !important;
transition: background @transition-fast, color @transition-fast !important;
margin: 2px 8px !important;
&:hover {
color: @color-text !important;
}
&.semi-navigation-item-selected,
&[aria-selected="true"] {
background: rgba(224,74,56,0.12) !important;
border: 1px solid rgba(224,74,56,0.25) !important;
color: @color-text !important;
.semi-navigation-item-icon {
color: @color-accent !important;
}
}
}
.semi-navigation-sub-title {
color: @color-muted !important;
}
// Collapsed state — icons perfectly centered
.semi-navigation-collapsed {
// Text span is display:block and takes up flex space — must be removed so justify-content:center works
.semi-navigation-item-text {
display: none !important;
}
.semi-navigation-item,
.semi-navigation-sub-title {
margin: 2px 0 !important;
padding: 0 !important;
width: 100% !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.semi-navigation-item-inner,
.semi-navigation-sub-title-inner {
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-width: 0 !important;
}
// Semi adds margin-right to icons for text spacing — remove it when collapsed
.semi-navigation-item-icon,
.semi-navigation-sub-title-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
margin-right: 0 !important;
padding: 0 !important;
width: auto !important;
min-width: 0 !important;
}
}
// Semi Nav.Footer — full width, no extra padding (our BEM class controls it)
.semi-navigation-footer {
width: 100% !important;
box-sizing: border-box !important;
}
// Collapsed submenu popup — actual class used by Semi UI is .semi-navigation-popover
.semi-navigation-popover {
background: @color-elevated !important;
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.5) !important;
overflow: hidden !important;
.semi-navigation-item {
margin: 2px 6px !important;
color: @color-muted !important;
border: none !important;
border-radius: @radius-btn !important;
&:hover {
background: rgba(255,255,255,0.06) !important;
color: @color-text !important;
}
&.semi-navigation-item-selected,
&.semi-dropdown-item-active {
background: rgba(224,74,56,0.10) !important;
border: none !important;
color: @color-text !important;
.semi-navigation-item-icon {
color: @color-accent !important;
}
}
}
}

View File

@@ -4,7 +4,7 @@
*/
import { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui-19';
import { Nav } from '@douyinfe/semi-ui-19';
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import heart from '../../assets/heart.png';
@@ -65,20 +65,32 @@ export default function Navigation({ isAdmin }) {
return '/' + split[0];
}
const sidebarWidth = collapsed ? '60px' : '220px';
return (
<Nav
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
style={{ height: '100%', width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
header={
<div className="navigate__header">
<img src={collapsed ? heart : logoWhite} width={collapsed ? 30 : 160} alt="Fredy Logo" />
</div>
}
footer={
<Nav.Footer className="navigate__footer">
<Nav.Footer className={`navigate__footer${collapsed ? ' navigate__footer--collapsed' : ''}`}>
<Logout text={!collapsed} />
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
<button
className="navigate__toggle-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<IconSidebar size="default" />
</button>
</Nav.Footer>
}
/>

View File

@@ -1,7 +1,89 @@
@import '../../tokens.less';
.segmentParts {
border: 1px solid #323232 !important;
border-radius: .9rem !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 0 0 1rem 0;
background: rgba(255,255,255,0.03) !important;
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
margin-bottom: @space-4;
// Semi Card header
.semi-card-header {
border-bottom: 1px solid @color-border !important;
padding: 16px 20px !important;
}
.semi-card-header-wrapper {
padding: 0 !important;
}
.semi-card-meta-title {
font-weight: 700 !important;
color: @color-text !important;
font-size: @text-base !important;
}
.semi-card-meta-description {
color: #b8b8b8 !important;
font-size: @text-sm !important;
margin-top: 2px;
}
.semi-card-body {
padding: 16px 20px !important;
}
// Semi input focus — subtle, not accent
.semi-input-wrapper:focus-within,
.semi-select:focus-within {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
// Icon in card header
.semi-card-meta-avatar {
color: @color-accent !important;
display: flex;
align-items: center;
}
// Inputs inside segment cards
.semi-input,
.semi-input-number-wrapper {
background: rgba(255,255,255,0.06) !important;
border: 1px solid rgba(255,255,255,0.10) !important;
border-radius: @radius-input !important;
}
// TagInput
.semi-tagInput-wrapper {
background: transparent !important;
border: 1px solid rgba(255,255,255,0.12) !important;
border-radius: @radius-input !important;
min-height: 38px;
outline: none !important;
&:focus-within {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
}
.semi-tagInput {
background: transparent !important;
}
// Tag chips inside TagInput
.semi-tag {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
color: @color-text !important;
border-radius: @radius-chip !important;
font-size: @text-sm !important;
height: 24px !important;
line-height: 22px !important;
}
.semi-tag-close {
color: @color-muted !important;
&:hover {
color: @color-text !important;
background: transparent !important;
}
}
}

View File

@@ -5,15 +5,11 @@
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { format } from '../../services/time/timeService';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19';
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description={'No users found.'}
/>
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
);
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
@@ -23,47 +19,73 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
empty={empty}
columns={[
{
title: 'Username',
title: 'User',
dataIndex: 'username',
render: (value, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ color: '#efefef', fontWeight: 500 }}>{value}</span>
{record.isAdmin && (
<Tag
size="small"
style={{
background: 'rgba(224,74,56,0.12)',
border: '1px solid rgba(224,74,56,0.35)',
color: '#e04a38',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.04em',
padding: '0 8px',
}}
>
ADMIN
</Tag>
)}
</div>
),
},
{
title: 'Last login',
dataIndex: 'lastLogin',
render: (value) => {
return format(value);
},
render: (value) => format(value),
},
{
title: 'Number of jobs',
title: 'Jobs',
dataIndex: 'numberOfJobs',
},
{
title: 'MCP Token',
dataIndex: 'mcpToken',
render: (value) => {
return (
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}>
{value || '---'}
</span>
);
},
render: (value) => (
<span
style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '0.85em',
wordBreak: 'break-all',
color: '#505050',
}}
>
{value || '---'}
</span>
),
},
{
title: '',
dataIndex: 'tools',
render: (value, user) => {
return (
<div style={{ float: 'right' }}>
<Button
type="danger"
icon={<IconDelete />}
onClick={() => onUserRemoval(user.id)}
style={{ marginRight: '1rem' }}
/>
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
</div>
);
},
render: (_, record) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button
style={{
background: 'transparent',
border: '1px solid rgba(251,113,133,0.2)',
color: '#fb7185',
}}
icon={<IconDelete />}
onClick={() => onUserRemoval(record.id)}
/>
<Button type="primary" theme="solid" icon={<IconEdit />} onClick={() => onUserEdit(record.id)} />
</div>
),
},
]}
dataSource={user}

69
ui/src/tokens.less Normal file
View File

@@ -0,0 +1,69 @@
// Backgrounds
@color-base: #0d0d0d;
@color-surface: #161616;
@color-elevated: #1e1e1e;
@color-border: #2a2a2a;
@color-border-bright: #383838;
// Accent
@color-accent: #e04a38;
@color-accent-dim: #c13827;
@color-accent-glow: rgba(224, 74, 56, 0.13);
// Text
@color-text: #efefef;
@color-muted: #909090;
@color-faint: #505050;
// Semantic
@color-success: #34d399;
@color-success-dim: #065f46;
@color-success-active: #21aa21;
@color-error: #fb7185;
@color-error-dim: #881337;
@color-warning: #fbbf24;
@color-info: #60a5fa;
// Fill overlays
@color-fill-subtle: rgba(255, 255, 255, 0.04);
@color-fill-overlay: rgba(255, 255, 255, 0.08);
// KPI card accents
@color-blue-text: #60a5fa; @color-blue-border: #3b6ea8; @color-blue-bg: rgba(96,165,250,0.10);
@color-orange-text: #fb923c; @color-orange-border: #c2622a; @color-orange-bg: rgba(251,146,60,0.10);
@color-green-text: #34d399; @color-green-border: #2a8a61; @color-green-bg: rgba(52,211,153,0.10);
@color-purple-text: #a78bfa; @color-purple-border: #6d4fc2; @color-purple-bg: rgba(167,139,250,0.10);
@color-gray-text: #94a3b8; @color-gray-border: #323a47; @color-gray-bg: rgba(148,163,184,0.10);
// Typography
@font-ui: 'Outfit', system-ui, sans-serif;
@font-mono: 'JetBrains Mono', monospace;
@text-xs: 11px;
@text-sm: 12px;
@text-base: 14px;
@text-md: 16px;
@text-lg: 20px;
@text-xl: 24px;
// Spacing
@space-1: 4px;
@space-2: 8px;
@space-3: 12px;
@space-4: 16px;
@space-5: 20px;
@space-6: 24px;
@space-8: 32px;
@space-12: 48px;
// Radius
@radius-input: 10px;
@radius-card: 10px;
@radius-btn: 6px;
@radius-pill: 9999px;
@radius-chip: 4px;
// Transitions
@transition-fast: 0.15s ease-in-out;
@transition-card: 0.18s ease-in-out;
@transition-sidebar: width 0.25s ease-in-out;

View File

@@ -4,7 +4,7 @@
*/
import React from 'react';
import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19';
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import {
IconTerminal,
IconStar,
@@ -20,6 +20,7 @@ import {
import { useSelector, useActions } from '../../services/state/store';
import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Dashboard.less';
import { xhrPost } from '../../services/xhr.js';
@@ -34,11 +35,12 @@ export default function Dashboard() {
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
const pieData = dashboard?.pie || [];
const { Text } = Typography;
return (
<div className="dashboard">
<Text className="dashboard__section-label">General</Text>
<Headline text="Dashboard" />
<div className="dashboard__section-label">General</div>
<Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
@@ -51,7 +53,6 @@ export default function Dashboard() {
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
title="Last Search"
valueFontSize="14px"
value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---'
@@ -69,7 +70,6 @@ export default function Dashboard() {
? '---'
: format(dashboard?.general?.nextRun)
}
valueFontSize="14px"
icon={<IconDoubleChevronRight />}
description="Next execution timestamp"
/>
@@ -96,7 +96,7 @@ export default function Dashboard() {
</Col>
</Row>
<Text className="dashboard__section-label">Overview</Text>
<div className="dashboard__section-label">Overview</div>
<Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard
@@ -132,10 +132,9 @@ export default function Dashboard() {
value={`${
!kpis.medianPriceOfListings
? '---'
: new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(kpis.medianPriceOfListings)
: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
kpis.medianPriceOfListings,
)
}`}
icon={<IconNoteMoney />}
description="Median Price of listings"
@@ -143,7 +142,7 @@ export default function Dashboard() {
</Col>
</Row>
<Text className="dashboard__section-label">Provider Insights</Text>
<div className="dashboard__section-label">Provider Insights</div>
<div className="dashboard__pie-wrapper">
<PieChartCard data={pieData} />
</div>

View File

@@ -1,3 +1,5 @@
@import '../../tokens.less';
.dashboard {
display: flex;
flex-direction: column;
@@ -5,13 +7,13 @@
&__section-label {
display: block;
font-size: 11px !important;
font-weight: 600 !important;
font-size: @text-xs;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #5a6478 !important;
color: @color-faint;
margin-bottom: 10px;
margin-top: 4px;
margin-top: 1.5rem;
}
&__row {
@@ -22,9 +24,8 @@
&__pie-wrapper {
background: #23242a;
border: 1px solid #37404e;
border-radius: 10px;
padding: 24px;
border-radius: @radius-card;
padding: 28px;
max-height: 320px;
flex: 1;
display: flex;

View File

@@ -30,6 +30,7 @@ import {
} from '../../services/backupRestoreClient';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import { debounce } from '../../utils';
import Headline from '../../components/headline/Headline.jsx';
import './GeneralSettings.less';
function formatFromTimestamp(ts) {
@@ -244,6 +245,7 @@ const GeneralSettings = function GeneralSettings() {
return (
<div className="generalSettings">
<Headline text="Settings" />
{!loading && (
<>
<Tabs type="line">

View File

@@ -1,17 +1,73 @@
@import '../../tokens.less';
.generalSettings {
display: flex;
flex-direction: column;
flex: 1;
&__tab-content {
padding: 20px 0;
max-width: 860px;
padding: @space-4 0;
}
&__timePickerContainer {
display: flex;
align-items: baseline;
gap: 1rem;
gap: @space-3;
flex-wrap: wrap;
align-items: center;
}
&__save-row {
margin-top: 1.5rem;
display: flex;
justify-content: flex-end;
margin-top: @space-2;
}
}
// InputNumber fix
.semi-input-number {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
color: @color-text !important;
}
.semi-input-number-button-up,
.semi-input-number-button-down {
background: rgba(255,255,255,0.06) !important;
border-color: @color-border-bright !important;
color: @color-muted !important;
&:hover {
background: rgba(255,255,255,0.12) !important;
color: @color-text !important;
}
}
// TimePicker fix — scoped so it doesn't pollute modal headers
.semi-timepicker .semi-input-wrapper,
.semi-timepicker .semi-input-inset-label-wrapper {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
color: @color-text !important;
}
// Tabs styling
.semi-tabs-bar-line .semi-tabs-tab {
color: @color-faint;
font-size: @text-base;
transition: color @transition-fast;
&:hover {
color: @color-muted;
}
&.semi-tabs-tab-active {
color: @color-text !important;
}
}
.semi-tabs-bar-line .semi-tabs-ink-bar {
background-color: @color-accent !important;
height: 2px;
}

View File

@@ -3,12 +3,25 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useNavigate } from 'react-router-dom';
import { Button } from '@douyinfe/semi-ui-19';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Jobs.less';
export default function Jobs() {
const navigate = useNavigate();
return (
<div className="jobs">
<Headline
text="Jobs"
actions={
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
}
/>
<JobGrid />
</div>
);

View File

@@ -1,8 +1,7 @@
@import '../../tokens.less';
.jobs {
&__newButton {
margin-top: 1rem !important;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
display: flex;
flex-direction: column;
flex: 1;
}

View File

@@ -18,6 +18,7 @@ import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyin
import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import {
IconArrowLeft,
IconBell,
IconBriefcase,
IconPaperclip,
@@ -144,7 +145,19 @@ export default function JobMutator() {
/>
)}
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} />
<Headline
text={jobToBeEdit ? 'Edit Job' : 'Create new Job'}
actions={
<Button
icon={<IconArrowLeft />}
onClick={() => navigate('/jobs')}
theme="borderless"
style={{ color: '#909090' }}
>
Back
</Button>
}
/>
<form>
<SegmentPart name="Name" Icon={IconPaperclip}>
<Input

View File

@@ -1,25 +1,36 @@
@import '../../../tokens.less';
.jobMutation {
&__newButton {
float: right;
margin-bottom: 1rem;
margin-bottom: @space-4;
}
&__specFilter {
display: flex;
gap: 1.5rem;
gap: @space-4;
flex-wrap: wrap;
}
&__specFilterItem {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: @space-2;
flex: 1;
min-width: 150px;
}
&__specFilterLabel {
font-weight: 500;
font-size: @text-sm;
color: @color-muted;
}
&__actions {
display: flex;
gap: @space-3;
margin-top: @space-4;
justify-content: flex-end;
}
}

View File

@@ -41,6 +41,7 @@ import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js';
import Headline from '../../components/headline/Headline.jsx';
import './ListingDetail.less';
const { Title, Text } = Typography;
@@ -278,7 +279,7 @@ export default function ListingDetail() {
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />,
},
{
@@ -290,40 +291,45 @@ export default function ListingDetail() {
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Headline
text={listing?.title || 'Listing Detail'}
actions={
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
Back
</Button>
}
/>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
{listing.address ? (
<a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(listing.address)}`}
target="_blank"
rel="noopener noreferrer"
className="listing-detail__address-link"
>
{listing.address}
</a>
) : (
<Text type="secondary">No address provided</Text>
)}
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
icon={listing.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
onClick={handleWatch}
theme="light"
theme="borderless"
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline>
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} />
Open listing
</Text>
</a>
</Space>
</div>

View File

@@ -1,10 +1,8 @@
@import '../../tokens.less';
.listing-detail {
padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card {
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
@@ -45,14 +43,6 @@
}
}
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container {
width: 100%;
height: 400px;
@@ -69,7 +59,61 @@
img {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
.semi-image,
.semi-image-img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
}
&__address-link {
color: @color-muted;
text-decoration: none;
font-size: @text-base;
transition: color @transition-fast;
&:hover {
color: @color-text;
text-decoration: underline;
}
}
&__watch-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-btn !important;
&:hover {
color: @color-text !important;
background: rgba(255,255,255,0.06) !important;
}
&--active {
color: @color-accent !important;
border-color: rgba(224,74,56,0.4) !important;
background: rgba(224,74,56,0.08) !important;
}
}
&__open-btn {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: 1px solid @color-border-bright;
border-radius: @radius-btn;
color: @color-muted;
font-size: @text-base;
font-family: @font-ui;
font-weight: 500;
text-decoration: none;
transition: color @transition-fast, border-color @transition-fast, background @transition-fast;
&:hover {
color: @color-text;
border-color: rgba(255,255,255,0.25);
background: rgba(255,255,255,0.06);
}
}

View File

@@ -4,7 +4,13 @@
*/
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
export default function Listings() {
return <ListingsGrid />;
return (
<>
<Headline text="Listings" />
<ListingsGrid />
</>
);
}

View File

@@ -21,6 +21,7 @@ import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
import Headline from '../../components/headline/Headline.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
@@ -354,127 +355,130 @@ export default function MapView() {
}, [listings, priceRange, homeAddress, distanceFilter]);
return (
<div className="map-view-container">
{!homeAddress && (
<>
<Headline text="Map View" />
<div className="map-view-container">
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description={
<span>
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="warning"
type="info"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description={
<span>
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description="Only listings with valid addresses are shown on this map."
/>
<div className="map-view-container__map-wrapper">
<Map
mapContainerRef={mapContainer}
style={style}
show3dBuildings={show3dBuildings}
onMapReady={handleMapReady}
description="Only listings with valid addresses are shown on this map."
/>
{/* Floating filter panel */}
<div className="map-view-container__floating-panel">
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Job
</Text>
<Select
placeholder="All jobs"
showClear
size="small"
onChange={(val) => setJobId(val)}
value={jobId}
style={{ width: 160 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
<div className="map-view-container__map-wrapper">
<Map
mapContainerRef={mapContainer}
style={style}
show3dBuildings={show3dBuildings}
onMapReady={handleMapReady}
/>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Distance
</Text>
<Select
placeholder="None"
size="small"
onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
{/* Floating filter panel */}
<div className="map-view-container__floating-panel">
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Job
</Text>
<Select
placeholder="All jobs"
showClear
size="small"
onChange={(val) => setJobId(val)}
value={jobId}
style={{ width: 160 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Distance
</Text>
<Select
placeholder="None"
size="small"
onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
</div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div>
</div>
</div>
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}}
/>
</div>
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmListingDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}}
/>
</div>
</>
);
}

View File

@@ -3,6 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
@import '../../tokens.less';
.map-view-container {
display: flex;
flex-direction: column;
@@ -21,11 +23,11 @@
top: 12px;
right: 12px;
z-index: 10;
background: rgba(13, 15, 20, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid #262a3a;
border-radius: 10px;
background: rgba(22, 25, 38, 0.95);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid @color-border;
border-radius: @radius-card;
padding: 14px 16px;
min-width: 220px;
display: flex;
@@ -183,13 +185,19 @@
position: absolute;
z-index: 3;
top: 50%;
width: 14px;
height: 14px;
width: 12px;
height: 12px;
transform: translate(-50%, -50%);
border-radius: 50%;
background: #0ab5b3;
background: @color-accent;
}
.range-slider .range-slider__range {
background: #0ab5b3;
background: @color-accent;
}
.range-slider {
background: rgba(255,255,255,0.12);
border-radius: 4px;
height: 4px !important;
}

View File

@@ -6,9 +6,8 @@
import { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19';
import { Banner, Button, Checkbox, Space, Typography } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';
export default function WatchlistManagement() {
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
@@ -31,7 +30,9 @@ export default function WatchlistManagement() {
description="Youll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
/>
<Space />
<Headline size={5} text="Notify me when:" style={{ marginTop: '1rem' }} />
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me when:
</Typography.Title>
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
Listing state changes (e.g. listing becomes inactive)
@@ -41,7 +42,9 @@ export default function WatchlistManagement() {
</Checkbox>
<Space />
<Headline size={5} text="Notify me with:" style={{ marginTop: '1rem' }} />
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me with:
</Typography.Title>
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
<NotificationAdapterMutator

View File

@@ -30,7 +30,7 @@
border-radius: 24px;
padding: 3rem;
width: 90%;
max-width: 420px;
max-width: 500px;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -4,16 +4,14 @@
*/
import React from 'react';
import { Toast } from '@douyinfe/semi-ui-19';
import { Toast, Button } from '@douyinfe/semi-ui-19';
import { IconPlus } from '@douyinfe/semi-icons';
import UserTable from '../../components/table/UserTable';
import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui-19';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import Headline from '../../components/headline/Headline.jsx';
import './Users.less';
const Users = function Users() {
@@ -28,14 +26,13 @@ const Users = function Users() {
await actions.user.getUsers();
setLoading(false);
}
init();
}, []);
const onUserRemoval = async () => {
try {
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
Toast.success('User successfully remove');
Toast.success('User successfully removed');
setUserIdToBeRemoved(null);
await actions.jobsData.getJobs();
await actions.user.getUsers();
@@ -46,30 +43,22 @@ const Users = function Users() {
};
return (
<div>
<div className="users">
<Headline
text="Users"
actions={
<Button type="primary" theme="solid" icon={<IconPlus />} onClick={() => navigate('/users/new')}>
New User
</Button>
}
/>
{!loading && (
<React.Fragment>
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
<Button
type="primary"
className="users__newButton"
icon={<IconPlus />}
onClick={() => navigate('/users/new')}
>
New User
</Button>
<UserTable
user={users}
onUserEdit={(userId) => {
navigate(`/users/edit/${userId}`);
}}
onUserRemoval={(userId) => {
setUserIdToBeRemoved(userId);
//throw warning message that all jobs will be removed associated to this user
//check if at least 1 admin is available
}}
onUserEdit={(userId) => navigate(`/users/edit/${userId}`)}
onUserRemoval={(userId) => setUserIdToBeRemoved(userId)}
/>
</React.Fragment>
)}

View File

@@ -1,8 +1,7 @@
@import '../../tokens.less';
.users {
&__newButton {
margin-top: 1rem !important;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
display: flex;
flex-direction: column;
flex: 1;
}

View File

@@ -11,7 +11,8 @@ import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import { IconPlusCircle, IconArrowLeft } from '@douyinfe/semi-icons';
import Headline from '../../../components/headline/Headline.jsx';
const UserMutator = function UserMutator() {
const params = useParams();
@@ -63,53 +64,70 @@ const UserMutator = function UserMutator() {
};
return (
<form className="userMutator">
<SegmentPart name="Username" helpText="The username used to login to Fredy">
<Input
type="text"
label="Username"
maxLength={30}
placeholder="Username"
autoFocus
width={6}
value={username}
onChange={(val) => setUsername(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Password" helpText="The password used to login to Fredy">
<Input
mode="password"
label="Password"
placeholder="Password"
width={6}
value={password}
onChange={(val) => setPassword(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
<Input
mode="password"
label="Retype password"
placeholder="Retype password"
width={6}
value={password2}
onChange={(val) => setPassword2(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</SegmentPart>
<Divider margin="1rem" />
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}>
Cancel
</Button>
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
Save
</Button>
</form>
<>
<Headline
text={params.userId ? 'Edit User' : 'New User'}
actions={
<Button
icon={<IconArrowLeft />}
onClick={() => navigate('/users')}
theme="borderless"
style={{ color: '#909090' }}
>
Back
</Button>
}
/>
<form className="userMutator">
<SegmentPart name="Username" helpText="The username used to login to Fredy">
<Input
type="text"
label="Username"
maxLength={30}
placeholder="Username"
autoFocus
width={6}
value={username}
onChange={(val) => setUsername(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Password" helpText="The password used to login to Fredy">
<Input
mode="password"
label="Password"
placeholder="Password"
width={6}
value={password}
onChange={(val) => setPassword(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
<Input
mode="password"
label="Retype password"
placeholder="Retype password"
width={6}
value={password2}
onChange={(val) => setPassword2(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</SegmentPart>
<Divider margin="1rem" />
<div className="userMutator__actions">
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
Cancel
</Button>
<Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
Save
</Button>
</div>
</form>
</>
);
};

View File

@@ -1,3 +1,13 @@
@import '../../../tokens.less';
.userMutator {
margin-top: 2rem;
display: flex;
flex-direction: column;
&__actions {
display: flex;
gap: @space-2;
margin-top: @space-2;
justify-content: flex-start;
}
}