feat(openai): add provider sorting and model filters

This commit is contained in:
liucong2013
2026-04-14 22:58:26 +00:00
Unverified
parent 5cbfbe8fea
commit 4c4856794f
11 changed files with 691 additions and 31 deletions
@@ -89,8 +89,8 @@ export function ClaudeSection({
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.claude_empty_title')}
emptyDescription={t('ai_providers.claude_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
@@ -89,8 +89,8 @@ export function CodexSection({
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.codex_empty_title')}
emptyDescription={t('ai_providers.codex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
@@ -89,8 +89,8 @@ export function GeminiSection({
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.gemini_empty_title')}
emptyDescription={t('ai_providers.gemini_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
@@ -1,4 +1,4 @@
import { Fragment, useMemo } from 'react';
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
@@ -18,6 +18,10 @@ import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getOpenAIProviderStats, getStatsBySource } from '../utils';
type SortOption = 'name' | 'priority' | 'recent-success';
type SortDirection = 'asc' | 'desc';
type ToolbarPosition = 'top' | 'bottom';
interface OpenAISectionProps {
configs: OpenAIProviderConfig[];
keyStats: KeyStats;
@@ -45,6 +49,38 @@ export function OpenAISection({
}: OpenAISectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || loading || isSwitching;
const [sortOption, setSortOption] = useState<SortOption>('priority');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [activeDropdown, setActiveDropdown] = useState<ToolbarPosition | null>(null);
const topDropdownRef = useRef<HTMLDivElement>(null);
const bottomDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
const clickedTop = topDropdownRef.current?.contains(target);
const clickedBottom = bottomDropdownRef.current?.contains(target);
if (!clickedTop && !clickedBottom) {
setActiveDropdown(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const allModelNames = useMemo(() => {
const modelSet = new Set<string>();
configs.forEach((provider) => {
provider.models?.forEach((model) => {
if (model.name) {
modelSet.add(model.name);
}
});
});
return Array.from(modelSet).sort();
}, [configs]);
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
@@ -65,6 +101,171 @@ export function OpenAISection({
return cache;
}, [configs, usageDetailsBySource]);
const sortedConfigs = useMemo(() => {
const filtered = configs.filter((provider) => {
if (selectedModels.size === 0) return true;
return provider.models?.some((model) => selectedModels.has(model.name));
});
const sorted = [...filtered];
const direction = sortDirection === 'desc' ? -1 : 1;
switch (sortOption) {
case 'name':
sorted.sort((a, b) => direction * a.name.localeCompare(b.name));
break;
case 'priority':
sorted.sort((a, b) => {
const priorityA = a.priority ?? Number.MAX_SAFE_INTEGER;
const priorityB = b.priority ?? Number.MAX_SAFE_INTEGER;
return direction * (priorityA - priorityB) || a.name.localeCompare(b.name);
});
break;
case 'recent-success':
sorted.sort((a, b) => {
const statsA = getOpenAIProviderStats(a.apiKeyEntries, keyStats, a.prefix);
const statsB = getOpenAIProviderStats(b.apiKeyEntries, keyStats, b.prefix);
return direction * (statsA.success - statsB.success) || a.name.localeCompare(b.name);
});
break;
default:
break;
}
return sorted;
}, [configs, sortOption, sortDirection, keyStats, selectedModels]);
const getProviderKey = (item: OpenAIProviderConfig) => `${item.name}-${item.prefix ?? ''}-${item.baseUrl}`;
const getProviderIndex = (item: OpenAIProviderConfig) =>
configs.findIndex((config) => config === item);
const toggleModelSelection = (modelName: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(modelName)) {
next.delete(modelName);
} else {
next.add(modelName);
}
return next;
});
};
const clearAllModels = () => {
setSelectedModels(new Set());
};
const toggleSortDirection = () => {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
};
const toggleDropdown = (position: ToolbarPosition) => {
setActiveDropdown((prev) => (prev === position ? null : position));
};
const renderSortControls = () => (
<div className={styles.sortControls}>
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value as SortOption)}
className={styles.sortSelect}
disabled={actionsDisabled}
>
<option value="priority">{t('ai_providers.sort_by_priority')}</option>
<option value="name">{t('ai_providers.sort_by_name')}</option>
<option value="recent-success">{t('ai_providers.sort_by_recent_success')}</option>
</select>
<button
type="button"
onClick={toggleSortDirection}
className={styles.sortDirectionButton}
disabled={actionsDisabled}
title={sortDirection === 'asc' ? t('common.sort_ascending') : t('common.sort_descending')}
>
{sortDirection === 'asc' ? '↑' : '↓'}
</button>
</div>
);
const renderToolbar = (position: ToolbarPosition) => {
const isDropdownOpen = activeDropdown === position;
const dropdownRef = position === 'top' ? topDropdownRef : bottomDropdownRef;
const wrapperClassName =
position === 'top' ? styles.cardHeaderActions : `${styles.cardHeaderActions} ${styles.cardFooterActions}`;
return (
<div className={wrapperClassName}>
<div className={styles.modelMultiSelectWrapper} ref={dropdownRef}>
<div className={styles.modelSelectedTags} onClick={() => toggleDropdown(position)}>
{selectedModels.size === 0 ? (
<span className={styles.modelSelectPlaceholder}>
{t('ai_providers.model_search_placeholder')}
</span>
) : (
<>
{Array.from(selectedModels).map((name) => (
<span key={`${position}-${name}`} className={styles.modelTag}>
<span className={styles.modelTagName}>{name}</span>
<button
type="button"
className={styles.modelTagRemove}
onClick={(e) => {
e.stopPropagation();
toggleModelSelection(name);
}}
title={t('ai_providers.model_search_clear')}
>
×
</button>
</span>
))}
</>
)}
<span className={styles.modelSelectArrow}></span>
</div>
{isDropdownOpen && (
<div className={styles.modelDropdownList}>
<div className={styles.modelDropdownHeader}>
<button
type="button"
onClick={() => setSelectedModels(new Set(allModelNames))}
className={styles.modelDropdownSelectAll}
>
{t('ai_providers.model_select_all')}
</button>
{selectedModels.size > 0 && (
<button
type="button"
onClick={clearAllModels}
className={styles.modelDropdownClear}
>
{t('ai_providers.model_search_clear')}
</button>
)}
</div>
{allModelNames.map((name) => (
<label key={`${position}-option-${name}`} className={styles.modelDropdownItem}>
<input
type="checkbox"
checked={selectedModels.has(name)}
onChange={() => toggleModelSelection(name)}
/>
<span>{name}</span>
</label>
))}
</div>
)}
</div>
{renderSortControls()}
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
</div>
);
};
return (
<>
<Card
@@ -78,20 +279,26 @@ export function OpenAISection({
{t('ai_providers.openai_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.openai_add_button')}
</Button>
}
extra={renderToolbar('top')}
>
<ProviderList<OpenAIProviderConfig>
items={configs}
items={sortedConfigs}
loading={loading}
keyField={(_, index) => `openai-provider-${index}`}
keyField={(item) => getProviderKey(item)}
emptyTitle={t('ai_providers.openai_empty_title')}
emptyDescription={t('ai_providers.openai_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(item) => {
const index = getProviderIndex(item);
if (index >= 0) {
onEdit(index);
}
}}
onDelete={(item) => {
const index = getProviderIndex(item);
if (index >= 0) {
onDelete(index);
}
}}
actionsDisabled={actionsDisabled}
renderContent={(item) => {
const stats = getOpenAIProviderStats(item.apiKeyEntries, keyStats, item.prefix);
@@ -195,6 +402,7 @@ export function OpenAISection({
);
}}
/>
<div className={styles.providerToolbarFooter}>{renderToolbar('bottom')}</div>
</Card>
</>
);
+4 -4
View File
@@ -8,8 +8,8 @@ interface ProviderListProps<T> {
loading: boolean;
keyField: (item: T, index: number) => string;
renderContent: (item: T, index: number) => ReactNode;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onEdit: (item: T, index: number) => void;
onDelete: (item: T, index: number) => void;
emptyTitle: string;
emptyDescription: string;
deleteLabel?: string;
@@ -57,7 +57,7 @@ export function ProviderList<T>({
<Button
variant="secondary"
size="sm"
onClick={() => onEdit(index)}
onClick={() => onEdit(item, index)}
disabled={actionsDisabled}
>
{t('common.edit')}
@@ -65,7 +65,7 @@ export function ProviderList<T>({
<Button
variant="danger"
size="sm"
onClick={() => onDelete(index)}
onClick={() => onDelete(item, index)}
disabled={actionsDisabled}
>
{deleteLabel || t('common.delete')}
@@ -89,8 +89,8 @@ export function VertexSection({
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
onEdit={(_, index) => onEdit(index)}
onDelete={(_, index) => onDelete(index)}
actionsDisabled={actionsDisabled}
getRowDisabled={(item) => hasDisableAllModelsRule(item.excludedModels)}
renderExtraActions={(item, index) => (
+24 -1
View File
@@ -402,6 +402,25 @@
"openai_add_button": "Add Provider",
"openai_empty_title": "No OpenAI Compatible Providers",
"openai_empty_desc": "Click the button above to add the first provider",
"sort_by_name": "Sort by Name",
"sort_by_priority": "Sort by Priority",
"sort_by_recent_success": "Sort by Recent Success",
"sort_ascending": "Ascending",
"sort_descending": "Descending",
"model_search.title": "Model Search",
"model_search.search_card_title": "Search All Models",
"model_search.refresh": "Refresh",
"model_search.search_label": "Search Models",
"model_search.search_placeholder": "Filter by name, alias, description, or provider",
"model_search.select_all": "Select All",
"model_search.clear_selection": "Clear",
"model_search.selected_count": "{{count}} selected",
"model_search.loading": "Fetching models from all providers...",
"model_search.empty_title": "No Models Found",
"model_search.empty_desc": "No models could be fetched from the configured providers",
"model_search.no_results": "No models match your search. Try a different keyword.",
"model_search.fetch_failed": "Failed to fetch models",
"model_search.models_count": "{{count}} models",
"openai_add_modal_title": "Add OpenAI Compatible Provider",
"openai_add_modal_name_label": "Provider Name:",
"openai_add_modal_name_placeholder": "e.g.: openrouter",
@@ -455,7 +474,11 @@
"openai_test_all_hint": "Test connection status for all keys",
"openai_test_all_success": "All {{count}} keys passed the test",
"openai_test_all_failed": "All {{count}} keys failed the test",
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed",
"model_search_placeholder": "Filter by models...",
"model_search_clear": "Clear",
"model_select_all": "Select All",
"model_selected_count": "{{count}} selected"
},
"auth_files": {
"title": "Auth Files Management",
+10 -1
View File
@@ -402,6 +402,11 @@
"openai_add_button": "Добавить провайдера",
"openai_empty_title": "Провайдеры OpenAI отсутствуют",
"openai_empty_desc": "Нажмите кнопку выше, чтобы добавить первого провайдера",
"sort_by_name": "Сортировать по имени",
"sort_by_priority": "Сортировать по приоритету",
"sort_by_recent_success": "Сортировать по недавним успехам",
"sort_ascending": "По возрастанию",
"sort_descending": "По убыванию",
"openai_add_modal_title": "Добавление совместимого с OpenAI провайдера",
"openai_add_modal_name_label": "Имя провайдера:",
"openai_add_modal_name_placeholder": "например: openrouter",
@@ -455,7 +460,11 @@
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
"openai_test_all_success": "Все {{count}} ключей прошли тест",
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло",
"model_search_placeholder": "Фильтр по моделям...",
"model_search_clear": "Очистить",
"model_select_all": "Выбрать все",
"model_selected_count": "Выбрано: {{count}}"
},
"auth_files": {
"title": "Управление файлами авторизации",
+24 -1
View File
@@ -402,6 +402,25 @@
"openai_add_button": "添加提供商",
"openai_empty_title": "暂无OpenAI兼容提供商",
"openai_empty_desc": "点击上方按钮添加第一个提供商",
"sort_by_name": "按名称排序",
"sort_by_priority": "按优先级排序",
"sort_by_recent_success": "按最近成功数排序",
"sort_ascending": "正序",
"sort_descending": "倒序",
"model_search.title": "模型搜索",
"model_search.search_card_title": "搜索所有模型",
"model_search.refresh": "刷新",
"model_search.search_label": "搜索模型",
"model_search.search_placeholder": "按名称、别名、描述或提供商筛选",
"model_search.select_all": "全选",
"model_search.clear_selection": "清除",
"model_search.selected_count": "已选择 {{count}} 个",
"model_search.loading": "正在从所有提供商获取模型...",
"model_search.empty_title": "未找到模型",
"model_search.empty_desc": "无法从配置的提供商获取模型",
"model_search.no_results": "没有匹配的模型,请尝试其他关键词。",
"model_search.fetch_failed": "获取模型失败",
"model_search.models_count": "{{count}} 个模型",
"openai_add_modal_title": "添加OpenAI兼容提供商",
"openai_add_modal_name_label": "提供商名称:",
"openai_add_modal_name_placeholder": "例如: openrouter",
@@ -455,7 +474,11 @@
"openai_test_all_hint": "测试所有密钥的连接状态",
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败",
"model_search_placeholder": "按模型筛选...",
"model_search_clear": "清除",
"model_select_all": "全选",
"model_selected_count": "已选择 {{count}} 个"
},
"auth_files": {
"title": "认证文件管理",
+386
View File
@@ -57,11 +57,249 @@
gap: $spacing-md;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
@media (min-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@include mobile {
grid-template-columns: 1fr;
}
}
// 排序控件
.sortControls {
display: flex;
align-items: center;
gap: $spacing-xs;
}
.sortSelect {
padding: 6px 10px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--primary-color);
}
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(59, 130, 246, 0.1));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 排序方向按钮
.sortDirectionButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 16px;
font-weight: 600;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
&:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(59, 130, 246, 0.1));
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 卡片头部操作区
.cardHeaderActions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: $spacing-sm;
}
.cardFooterActions {
justify-content: flex-end;
width: 100%;
}
.providerToolbarFooter {
margin-top: $spacing-md;
padding-top: $spacing-md;
border-top: 1px solid var(--border-primary);
}
// 模型多选下拉框
.modelMultiSelectWrapper {
position: relative;
}
.modelSelectedTags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
padding: 4px 8px;
border: 1px solid var(--border-primary);
border-radius: 6px;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.15s ease;
min-height: 32px;
&:hover {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
}
.modelSelectPlaceholder {
font-size: 13px;
color: var(--text-tertiary);
flex: 1;
}
.modelTag {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
padding: 4px 8px 4px 8px;
background: var(--primary-color);
color: white;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
max-width: 150px;
position: relative;
}
.modelTagName {
overflow: hidden;
text-overflow: ellipsis;
}
.modelTagRemove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
background: rgba(0, 0, 0, 0.15);
color: #000;
font-size: 18px;
line-height: 1;
cursor: pointer;
border-radius: 50%;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
.modelSelectArrow {
font-size: 10px;
color: var(--text-tertiary);
margin-left: 4px;
}
.modelDropdownList {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 280px;
max-height: 300px;
overflow-y: auto;
background: var(--bg-primary);
border: 1px solid var(--border-primary);
border-radius: $radius-md;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
}
.modelDropdownHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-xs;
padding: $spacing-xs $spacing-sm;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-tertiary);
}
.modelDropdownSelectAll,
.modelDropdownClear {
padding: 4px 8px;
font-size: 12px;
background: transparent;
border: none;
color: var(--primary-color);
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
}
.modelDropdownItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: var(--bg-secondary);
}
input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
span {
flex: 1;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 成功失败次数统计样式
.cardStats {
display: flex;
@@ -1045,3 +1283,151 @@
color: #f1b0a6;
}
}
// ============================================
// Model Search Page Styles
// ============================================
.modelSearchContainer {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.modelSearchContent {
display: flex;
flex-direction: column;
gap: $spacing-xl;
padding-bottom: calc(
var(--provider-nav-height, 60px) + 12px + env(safe-area-inset-bottom) + #{$spacing-md}
);
}
.modelSearchToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
margin: $spacing-sm 0;
}
.modelSearchToolbarActions {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.modelSearchSelectionSummary {
font-size: 13px;
color: var(--text-tertiary);
line-height: 1.4;
}
.modelSearchList {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-top: $spacing-md;
}
.modelSearchGroup {
border: 1px solid var(--border-primary);
border-radius: $radius-md;
overflow: hidden;
background: var(--bg-secondary);
}
.modelSearchGroupHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
padding: $spacing-md;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-primary);
h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.modelSearchGroupCount {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
}
.modelSearchGroupList {
display: flex;
flex-direction: column;
gap: $spacing-xs;
padding: $spacing-sm;
}
.modelSearchRow {
display: flex;
align-items: flex-start;
gap: $spacing-sm;
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid transparent;
border-radius: $radius-sm;
background: var(--bg-primary);
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
input[type='checkbox'] {
margin-top: 2px;
cursor: pointer;
}
&:hover {
border-color: var(--primary-color);
background: var(--bg-secondary);
}
}
.modelSearchRowSelected {
border-color: var(--primary-color);
background: var(--bg-tertiary);
}
.modelSearchSelectionLabel {
flex: 1;
min-width: 0;
}
.modelSearchMeta {
display: flex;
flex-direction: column;
gap: 4px;
}
.modelSearchName {
font-weight: 600;
color: var(--text-primary);
word-break: break-all;
}
.modelSearchAlias {
margin-left: 6px;
color: var(--text-tertiary);
font-style: italic;
&::before {
content: '';
}
}
.modelSearchDesc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
+17 -6
View File
@@ -642,9 +642,17 @@ textarea {
}
.item-list {
display: flex;
flex-direction: column;
display: grid;
gap: $spacing-sm;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
@media (min-width: 1400px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.item-row {
@@ -653,15 +661,16 @@ textarea {
padding: $spacing-md;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
flex-direction: column;
align-items: stretch;
gap: $spacing-sm;
min-height: 0;
.item-meta {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.item-title {
@@ -679,6 +688,8 @@ textarea {
.item-actions {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
justify-content: flex-end;
}
}