refactor(frontend-dev): use published @lilith/ui-* packages

Replace local settings components with published packages:
- @lilith/ui-forms@1.1.0: LabeledSlider (as Slider), TagInput (as EditableTagList), WeightSlider
- @lilith/ui-primitives@1.2.0: SeverityBadge
- @lilith/ui-feedback@1.1.0: PillTabs (as TabGroup)

Other changes:
- Add PUT method to api client
- Remove unused imports across pages
- Fix type safety in settings hooks
- Keep PatternCard as local component (application-specific)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lilith 2026-01-02 08:13:42 -08:00
parent 3ce3b744a6
commit 82a305edb7
18 changed files with 2259 additions and 1017 deletions

View file

@ -22,9 +22,10 @@
"@lilith/react-hooks": "workspace:*",
"@lilith/react-query-utils": "workspace:*",
"@lilith/ui-data": "^1.0.0",
"@lilith/ui-feedback": "^1.0.0",
"@lilith/ui-feedback": "^1.1.0",
"@lilith/ui-forms": "^1.1.0",
"@lilith/ui-messaging": "^1.0.2",
"@lilith/ui-primitives": "^1.0.0",
"@lilith/ui-primitives": "^1.2.0",
"@lilith/ui-theme": "^1.0.0",
"@lilith/ui-utils": "^1.0.1",
"@tanstack/react-query": "^5.17.0",

File diff suppressed because it is too large Load diff

View file

@ -47,6 +47,12 @@ export const api = {
body: body ? JSON.stringify(body) : undefined,
}),
put: <T>(endpoint: string, body?: unknown) =>
request<T>(endpoint, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: 'DELETE' }),
};

View file

@ -650,7 +650,7 @@ export function useUpdateStyleProfile() {
return useMutation({
mutationFn: (data: Partial<StyleProfile>) =>
api.put<{ success: boolean; data: StyleProfile }>(`/api/settings/style-profile/${CREATOR_ID}`, data).then(r => r.data),
api.put<StyleProfile>(`/api/settings/style-profile/${CREATOR_ID}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'style-profile'] });
},
@ -670,7 +670,7 @@ export function useUpdateRedFlagPattern() {
return useMutation({
mutationFn: (data: { patternName: string; customWeight?: number | null; isEnabled?: boolean }) =>
api.put<{ success: boolean; data: RedFlagPattern }>(`/api/settings/red-flags/${CREATOR_ID}/pattern`, data).then(r => r.data),
api.put<RedFlagPattern>(`/api/settings/red-flags/${CREATOR_ID}/pattern`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'red-flags'] });
},

View file

@ -3,7 +3,6 @@ import {
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Clock,
XCircle,
Loader2,

View file

@ -1,209 +0,0 @@
import { useState, KeyboardEvent } from 'react';
import styled from 'styled-components';
import { X, Plus } from 'lucide-react';
interface EditableTagListProps {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
maxTags?: number;
suggestions?: string[];
disabled?: boolean;
}
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 12px;
`;
const TagsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: 8px;
`;
const Tag = styled.span`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background-color: ${(props) => props.theme.colors?.hover?.surface || '#374151'};
border: 1px solid ${(props) => props.theme.colors?.border || '#4b5563'};
border-radius: 16px;
font-size: 13px;
color: ${(props) => props.theme.colors?.text || '#ffffff'};
`;
const RemoveButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: none;
border: none;
color: ${(props) => props.theme.colors?.text?.secondary || '#9ca3af'};
cursor: pointer;
transition: color 0.2s;
&:hover {
color: ${(props) => props.theme.colors?.error || '#ef4444'};
}
`;
const InputRow = styled.div`
display: flex;
gap: 8px;
`;
const Input = styled.input`
flex: 1;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid ${(props) => props.theme.colors?.border || '#374151'};
background-color: ${(props) => props.theme.colors?.hover?.surface || '#374151'};
color: ${(props) => props.theme.colors?.text || '#ffffff'};
font-size: 14px;
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
}
&::placeholder {
color: ${(props) => props.theme.colors?.text?.secondary || '#6b7280'};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const AddButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 16px;
background-color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const Suggestions = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const SuggestionChip = styled.button`
padding: 4px 10px;
background-color: transparent;
border: 1px dashed ${(props) => props.theme.colors?.border || '#374151'};
border-radius: 16px;
font-size: 12px;
color: ${(props) => props.theme.colors?.text?.secondary || '#9ca3af'};
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
border-style: solid;
}
`;
export function EditableTagList({
tags,
onChange,
placeholder = 'Add item...',
maxTags = 20,
suggestions = [],
disabled = false,
}: EditableTagListProps) {
const [inputValue, setInputValue] = useState('');
const addTag = (tag: string) => {
const trimmed = tag.trim();
if (trimmed && !tags.includes(trimmed) && tags.length < maxTags) {
onChange([...tags, trimmed]);
setInputValue('');
}
};
const removeTag = (index: number) => {
onChange(tags.filter((_, i) => i !== index));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(inputValue);
}
};
const availableSuggestions = suggestions.filter((s) => !tags.includes(s));
return (
<Container>
<TagsContainer>
{tags.map((tag, index) => (
<Tag key={index}>
{tag}
{!disabled && (
<RemoveButton onClick={() => removeTag(index)} aria-label={`Remove ${tag}`}>
<X size={14} />
</RemoveButton>
)}
</Tag>
))}
</TagsContainer>
<InputRow>
<Input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled || tags.length >= maxTags}
/>
<AddButton
onClick={() => addTag(inputValue)}
disabled={disabled || !inputValue.trim() || tags.length >= maxTags}
>
<Plus size={16} />
Add
</AddButton>
</InputRow>
{availableSuggestions.length > 0 && (
<Suggestions>
{availableSuggestions.slice(0, 8).map((suggestion) => (
<SuggestionChip
key={suggestion}
onClick={() => addTag(suggestion)}
disabled={disabled}
>
+ {suggestion}
</SuggestionChip>
))}
</Suggestions>
)}
</Container>
);
}

View file

@ -1,7 +1,7 @@
import styled from 'styled-components';
import { ToggleLeft, ToggleRight, Trash2 } from 'lucide-react';
import { SeverityBadge } from './SeverityBadge';
import { WeightSlider } from './WeightSlider';
import { SeverityBadge } from '@lilith/ui-primitives';
import { WeightSlider } from '@lilith/ui-forms';
import type { RedFlagPattern } from '../../api/hooks';
interface PatternCardProps {

View file

@ -1,52 +0,0 @@
import styled from 'styled-components';
import type { RedFlagSeverity } from '../../api/hooks';
interface SeverityBadgeProps {
severity: RedFlagSeverity;
size?: 'small' | 'medium';
}
const Badge = styled.span<{ $severity: RedFlagSeverity; $size: 'small' | 'medium' }>`
display: inline-flex;
align-items: center;
padding: ${(props) => (props.$size === 'small' ? '2px 6px' : '4px 10px')};
border-radius: 4px;
font-size: ${(props) => (props.$size === 'small' ? '10px' : '12px')};
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background-color: ${(props) => {
switch (props.$severity) {
case 'CRITICAL':
return 'rgba(239, 68, 68, 0.2)';
case 'HIGH':
return 'rgba(249, 115, 22, 0.2)';
case 'MEDIUM':
return 'rgba(234, 179, 8, 0.2)';
case 'LOW':
return 'rgba(34, 197, 94, 0.2)';
}
}};
color: ${(props) => {
switch (props.$severity) {
case 'CRITICAL':
return '#ef4444';
case 'HIGH':
return '#f97316';
case 'MEDIUM':
return '#eab308';
case 'LOW':
return '#22c55e';
}
}};
`;
export function SeverityBadge({ severity, size = 'medium' }: SeverityBadgeProps) {
return (
<Badge $severity={severity} $size={size}>
{severity}
</Badge>
);
}

View file

@ -1,120 +0,0 @@
import styled from 'styled-components';
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
leftLabel?: string;
rightLabel?: string;
showValue?: boolean;
disabled?: boolean;
}
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const SliderRow = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const SliderLabel = styled.span`
font-size: 12px;
color: ${(props) => props.theme.colors?.text?.secondary || '#9ca3af'};
min-width: 60px;
&:last-child {
text-align: right;
}
`;
const SliderInput = styled.input<{ $fillPercent: number }>`
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: ${(props) => {
const fill = props.theme.colors?.primary || '#3b82f6';
const track = props.theme.colors?.border || '#374151';
return `linear-gradient(to right, ${fill} 0%, ${fill} ${props.$fillPercent}%, ${track} ${props.$fillPercent}%, ${track} 100%)`;
}};
border-radius: 3px;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: ${(props) => props.theme.colors?.primary || '#3b82f6'};
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
&:hover {
transform: scale(1.1);
}
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: ${(props) => props.theme.colors?.primary || '#3b82f6'};
border-radius: 50%;
cursor: pointer;
border: none;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ValueDisplay = styled.span`
font-size: 14px;
font-weight: 500;
color: ${(props) => props.theme.colors?.text || '#ffffff'};
min-width: 40px;
text-align: center;
`;
export function Slider({
value,
onChange,
min = 0,
max = 1,
step = 0.1,
leftLabel,
rightLabel,
showValue = true,
disabled = false,
}: SliderProps) {
const fillPercent = ((value - min) / (max - min)) * 100;
return (
<Container>
<SliderRow>
{leftLabel && <SliderLabel>{leftLabel}</SliderLabel>}
<SliderInput
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
disabled={disabled}
$fillPercent={fillPercent}
/>
{rightLabel && <SliderLabel>{rightLabel}</SliderLabel>}
{showValue && <ValueDisplay>{(value * 100).toFixed(0)}%</ValueDisplay>}
</SliderRow>
</Container>
);
}

View file

@ -1,69 +0,0 @@
import styled from 'styled-components';
interface Tab {
id: string;
label: string;
count?: number;
}
interface TabGroupProps {
tabs: Tab[];
activeTab: string;
onTabChange: (tabId: string) => void;
}
const Container = styled.div`
display: flex;
gap: 4px;
padding: 4px;
background-color: ${(props) => props.theme.colors?.hover?.surface || '#374151'};
border-radius: 8px;
overflow-x: auto;
`;
const TabButton = styled.button<{ $active: boolean }>`
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background-color: ${(props) => (props.$active ? props.theme.colors?.surface || '#1f2937' : 'transparent')};
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: ${(props) => (props.$active ? 500 : 400)};
color: ${(props) => (props.$active ? props.theme.colors?.text || '#ffffff' : props.theme.colors?.text?.secondary || '#9ca3af')};
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
&:hover {
color: ${(props) => props.theme.colors?.text || '#ffffff'};
}
`;
const Count = styled.span<{ $active: boolean }>`
padding: 2px 6px;
background-color: ${(props) => (props.$active ? props.theme.colors?.primary || '#3b82f6' : 'rgba(255, 255, 255, 0.1)')};
border-radius: 10px;
font-size: 11px;
font-weight: 500;
`;
export function TabGroup({ tabs, activeTab, onTabChange }: TabGroupProps) {
return (
<Container role="tablist">
{tabs.map((tab) => (
<TabButton
key={tab.id}
$active={activeTab === tab.id}
onClick={() => onTabChange(tab.id)}
role="tab"
aria-selected={activeTab === tab.id}
>
{tab.label}
{tab.count !== undefined && <Count $active={activeTab === tab.id}>{tab.count}</Count>}
</TabButton>
))}
</Container>
);
}

View file

@ -1,137 +0,0 @@
import styled from 'styled-components';
interface WeightSliderProps {
value: number;
onChange: (value: number) => void;
defaultValue?: number;
disabled?: boolean;
showReset?: boolean;
}
const Container = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const SliderWrapper = styled.div`
flex: 1;
display: flex;
align-items: center;
gap: 8px;
`;
const SliderInput = styled.input<{ $fillPercent: number; $color: string }>`
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: ${(props) => {
const track = props.theme.colors?.border || '#374151';
return `linear-gradient(to right, ${props.$color} 0%, ${props.$color} ${props.$fillPercent}%, ${track} ${props.$fillPercent}%, ${track} 100%)`;
}};
border-radius: 3px;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: ${(props) => props.$color};
border-radius: 50%;
cursor: pointer;
transition: transform 0.1s;
&:hover {
transform: scale(1.1);
}
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
background: ${(props) => props.$color};
border-radius: 50%;
cursor: pointer;
border: none;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
const ValueDisplay = styled.span<{ $color: string }>`
font-size: 14px;
font-weight: 600;
color: ${(props) => props.$color};
min-width: 45px;
text-align: center;
`;
const ResetButton = styled.button<{ $visible: boolean }>`
padding: 4px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.colors?.border || '#374151'};
border-radius: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors?.text?.secondary || '#9ca3af'};
cursor: pointer;
opacity: ${(props) => (props.$visible ? 1 : 0)};
pointer-events: ${(props) => (props.$visible ? 'auto' : 'none')};
transition: all 0.2s;
&:hover {
border-color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
color: ${(props) => props.theme.colors?.primary || '#3b82f6'};
}
`;
function getWeightColor(value: number): string {
if (value >= 0.8) return '#ef4444'; // Red for critical
if (value >= 0.6) return '#f97316'; // Orange for high
if (value >= 0.4) return '#eab308'; // Yellow for medium
return '#22c55e'; // Green for low
}
export function WeightSlider({
value,
onChange,
defaultValue,
disabled = false,
showReset = true,
}: WeightSliderProps) {
const fillPercent = value * 100;
const color = getWeightColor(value);
const hasChanged = defaultValue !== undefined && value !== defaultValue;
return (
<Container>
<SliderWrapper>
<SliderInput
type="range"
min={0}
max={1}
step={0.05}
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
disabled={disabled}
$fillPercent={fillPercent}
$color={color}
/>
<ValueDisplay $color={color}>{value.toFixed(2)}</ValueDisplay>
</SliderWrapper>
{showReset && defaultValue !== undefined && (
<ResetButton
$visible={hasChanged}
onClick={() => onChange(defaultValue)}
disabled={disabled}
>
Reset
</ResetButton>
)}
</Container>
);
}

View file

@ -1,6 +1,16 @@
export { Slider } from './Slider';
export { EditableTagList } from './EditableTagList';
export { WeightSlider } from './WeightSlider';
export { SeverityBadge } from './SeverityBadge';
// Re-export from @lilith packages
export { LabeledSlider as Slider } from '@lilith/ui-forms';
export { TagInput as EditableTagList } from '@lilith/ui-forms';
export { WeightSlider } from '@lilith/ui-forms';
export { SeverityBadge } from '@lilith/ui-primitives';
export { PillTabs as TabGroup } from '@lilith/ui-feedback';
// Export types
export type { LabeledSliderProps as SliderProps } from '@lilith/ui-forms';
export type { TagInputProps as EditableTagListProps } from '@lilith/ui-forms';
export type { WeightSliderProps } from '@lilith/ui-forms';
export type { SeverityBadgeProps, SeverityLevel } from '@lilith/ui-primitives';
export type { PillTabsProps as TabGroupProps, PillTab as Tab } from '@lilith/ui-feedback';
// Local component (application-specific)
export { PatternCard } from './PatternCard';
export { TabGroup } from './TabGroup';

View file

@ -1,7 +1,7 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, User, Clock, Bot } from 'lucide-react';
import styled from 'styled-components';
import { Spinner, Button } from '@lilith/ui-primitives';
import { Spinner } from '@lilith/ui-primitives';
import {
useContact,
useClassificationHistory,

View file

@ -2,7 +2,7 @@ import { useRef, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { User, Users, ChevronRight } from 'lucide-react';
import styled from 'styled-components';
import { Spinner, Avatar } from '@lilith/ui-primitives';
import { Spinner } from '@lilith/ui-primitives';
import { formatRelativeTime } from '@lilith/ui-utils';
import { useConversationsInfinite } from '../api/hooks';

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { Laptop, Smartphone, Loader2, RefreshCw } from 'lucide-react';
import styled, { keyframes } from 'styled-components';
import { Spinner, Button, StatusBadge } from '@lilith/ui-primitives';
import { Spinner } from '@lilith/ui-primitives';
import { useDevices, useDeactivateDevice, useResetDeviceSync } from '../api/hooks';
const spin = keyframes`

View file

@ -1,7 +1,6 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import styled, { keyframes } from 'styled-components';
import { Spinner, Button, Select } from '@lilith/ui-primitives';
import { useTrainingSamples, useTrainingJobs, useStartTrainingJob } from '../api/hooks';
import { useToast } from '@lilith/react-hooks';

View file

@ -3,7 +3,7 @@ import styled from 'styled-components';
import { ArrowLeft, Loader2, AlertTriangle, MessageSquare, ChevronDown, ChevronUp } from 'lucide-react';
import { Link } from 'react-router-dom';
import { TabGroup, SeverityBadge } from '../../components/settings';
import { useRedFlagDocumentation, type RedFlagDoc, type RedFlagCategory } from '../../api/hooks';
import { useRedFlagDocumentation, type RedFlagDoc } from '../../api/hooks';
const Container = styled.div`
max-width: 900px;

View file

@ -3,7 +3,7 @@ import styled from 'styled-components';
import { ArrowLeft, Plus, Loader2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { useToast } from '@lilith/react-hooks';
import { TabGroup, PatternCard, SeverityBadge } from '../../components/settings';
import { TabGroup, PatternCard } from '../../components/settings';
import {
useRedFlagPatterns,
useUpdateRedFlagPattern,