feat: provider list page availability sort and display
- Add FillProviderAvailability() to batch-fill provider availability data - Provider list defaults to sort by availability (desc, no-data last) - Provider cards show availability % with color indicators - Model rankings default sort back to token usage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b77f00396
commit
c1fbcf2b00
4 files changed
+282
-92
No files matched your search
@@ -47,6 +47,7 @@ export const getSiteInfo = () => api.get('/api/dist/site/info');
|
||||
export const getSiteModels = () => api.get('/api/dist/site/models');
|
||||
export const getSitePricing = () => api.get('/api/dist/site/pricing');
|
||||
export const getSitePackages = () => api.get('/api/dist/site/packages');
|
||||
export const getSiteKeyGroups = () => api.get('/api/dist/site/key-groups');
|
||||
|
||||
// ===== Auth =====
|
||||
export const register = (data) => api.post('/api/dist/user/register', data);
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
"tokens.tokenEnabled": "Token enabled",
|
||||
"tokens.tokenDeleted": "Token deleted",
|
||||
"tokens.enterName": "Please enter a name",
|
||||
"tokens.selectGroup": "Create API Key",
|
||||
"tokens.selectGroupSubtitle": "Choose a model group and rate, click a card to create",
|
||||
"tokens.defaultGroup": "All Providers (Default)",
|
||||
"tokens.defaultGroupDesc": "No provider restriction, uses all available channels",
|
||||
"tokens.otherGroups": "Other",
|
||||
"tokens.groupCount": "groups",
|
||||
"tokens.recommended": "Recommended",
|
||||
"tokens.unavailable": "Unavailable",
|
||||
"tokens.rmbPerUsd": "RMB / $1",
|
||||
"tokens.myKeys": "My API Keys",
|
||||
"tokens.selectedGroup": "Selected Group",
|
||||
"tokens.quickStart": "Quick Start",
|
||||
"tokens.quickStartComment": "# Use your API key with OpenAI-compatible endpoint",
|
||||
|
||||
|
||||
@@ -119,6 +119,17 @@
|
||||
"tokens.tokenEnabled": "令牌已启用",
|
||||
"tokens.tokenDeleted": "令牌已删除",
|
||||
"tokens.enterName": "请输入名称",
|
||||
"tokens.selectGroup": "新建 API Key",
|
||||
"tokens.selectGroupSubtitle": "选择模型与费率,点击卡片即可快速创建",
|
||||
"tokens.defaultGroup": "全站调用(默认)",
|
||||
"tokens.defaultGroupDesc": "不限制供应商,使用所有可用渠道",
|
||||
"tokens.otherGroups": "其他",
|
||||
"tokens.groupCount": "个分组",
|
||||
"tokens.recommended": "推荐",
|
||||
"tokens.unavailable": "暂不可用",
|
||||
"tokens.rmbPerUsd": "块 / 1 美元",
|
||||
"tokens.myKeys": "我的 API Keys",
|
||||
"tokens.selectedGroup": "已选分组",
|
||||
"tokens.quickStart": "快速开始",
|
||||
"tokens.quickStartComment": "# 使用 OpenAI 兼容端点调用 API",
|
||||
|
||||
|
||||
+259
-92
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTokens, createToken, updateToken, deleteToken } from '../api';
|
||||
import { getTokens, createToken, updateToken, deleteToken, getSiteKeyGroups } from '../api';
|
||||
import ConfigExporter from '../components/ConfigExporter';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
@@ -8,25 +8,58 @@ export default function Tokens() {
|
||||
const { t } = useTranslation();
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
// Newly created key — shown only once
|
||||
const [newKey, setNewKey] = useState(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
|
||||
// Key groups
|
||||
const [keyGroups, setKeyGroups] = useState([]);
|
||||
|
||||
// Create modal
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTokens();
|
||||
if (res.data.success) setTokens(res.data.data || []);
|
||||
const [tokensRes, groupsRes] = await Promise.all([
|
||||
getTokens(),
|
||||
getSiteKeyGroups().catch(() => ({ data: { success: false } })),
|
||||
]);
|
||||
if (tokensRes.data.success) setTokens(tokensRes.data.data || []);
|
||||
if (groupsRes.data.success) setKeyGroups(groupsRes.data.data || []);
|
||||
} catch (e) { /* interceptor */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// Group by vendor_category
|
||||
const groupedByVendor = useMemo(() => {
|
||||
const map = {};
|
||||
keyGroups.forEach((g) => {
|
||||
const cat = g.vendor_category || t('tokens.otherGroups');
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(g);
|
||||
});
|
||||
return map;
|
||||
}, [keyGroups, t]);
|
||||
|
||||
const openCreateFromGroup = (group) => {
|
||||
if (group.is_unavailable) return;
|
||||
setSelectedGroupId(group.id);
|
||||
setCreateName(group.name);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const openCreateDefault = () => {
|
||||
setSelectedGroupId(0);
|
||||
setCreateName('');
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!createName.trim()) {
|
||||
@@ -35,15 +68,15 @@ export default function Tokens() {
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await createToken({ name: createName.trim() });
|
||||
const payload = { name: createName.trim() };
|
||||
if (selectedGroupId > 0) payload.key_group_id = selectedGroupId;
|
||||
const res = await createToken(payload);
|
||||
if (res.data.success) {
|
||||
setCreateName('');
|
||||
setShowCreate(false);
|
||||
// Show the key — it's only visible at creation time!
|
||||
setSelectedGroupId(0);
|
||||
const createdKey = res.data.data?.key;
|
||||
if (createdKey) {
|
||||
setNewKey(createdKey);
|
||||
}
|
||||
if (createdKey) setNewKey(createdKey);
|
||||
await load();
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
@@ -92,6 +125,10 @@ export default function Tokens() {
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const parseTags = (tagsStr) => {
|
||||
try { return JSON.parse(tagsStr || '[]'); } catch { return []; }
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
@@ -100,23 +137,81 @@ export default function Tokens() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasGroups = keyGroups.length > 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-page">{t('tokens.title')}</h1>
|
||||
<p className="text-sm text-page-secondary mt-1">{t('tokens.subtitle')}</p>
|
||||
|
||||
{/* ========== Section 1: Create Key with Groups ========== */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-2xl font-heading font-bold text-page">
|
||||
{hasGroups ? t('tokens.selectGroup') : t('tokens.title')}
|
||||
</h1>
|
||||
{hasGroups && (
|
||||
<p className="text-sm text-page-secondary mt-1">{t('tokens.selectGroupSubtitle')}</p>
|
||||
)}
|
||||
|
||||
{/* Default (All Providers) Card */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={openCreateDefault}
|
||||
className="w-full glass rounded-xl p-4 flex items-center gap-4 hover:border-brand-500/50 border border-page-divider transition-all group text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-brand-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold text-page">{hasGroups ? t('tokens.defaultGroup') : t('tokens.newKey')}</p>
|
||||
{hasGroups && (
|
||||
<p className="text-xs text-page-secondary mt-0.5">{t('tokens.defaultGroupDesc')}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-brand-500 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
{t('tokens.create')} →
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
{t('tokens.newKey')}
|
||||
</button>
|
||||
|
||||
{/* Vendor Category Sections */}
|
||||
{hasGroups && Object.entries(groupedByVendor).map(([vendor, groups]) => (
|
||||
<div key={vendor} className="mt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h2 className="text-base font-semibold text-page">{vendor}</h2>
|
||||
<span className="text-[11px] text-page-muted bg-page-surface px-2 py-0.5 rounded-full">
|
||||
{groups.length} {t('tokens.groupCount')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{groups.map((group) => (
|
||||
<KeyGroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
parseTags={parseTags}
|
||||
onSelect={openCreateFromGroup}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{/* ========== Create Modal ========== */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => { setShowCreate(false); setSelectedGroupId(0); }}>
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-page mb-4">{t('tokens.createApiKey')}</h2>
|
||||
{selectedGroupId > 0 && (() => {
|
||||
const g = keyGroups.find((x) => x.id === selectedGroupId);
|
||||
return g ? (
|
||||
<div className="mb-4 p-3 rounded-lg bg-page-surface border border-page-divider">
|
||||
<p className="text-xs text-page-muted">{t('tokens.selectedGroup')}</p>
|
||||
<p className="text-sm font-medium text-page">{g.name}</p>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-page-label mb-1.5">{t('tokens.name')}</label>
|
||||
@@ -131,7 +226,7 @@ export default function Tokens() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary">
|
||||
<button type="button" onClick={() => { setShowCreate(false); setSelectedGroupId(0); }} className="btn-secondary">
|
||||
{t('tokens.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="btn-primary">
|
||||
@@ -143,7 +238,7 @@ export default function Tokens() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Key Reveal Modal — shown only once after creation */}
|
||||
{/* ========== New Key Reveal Modal ========== */}
|
||||
{newKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-lg">
|
||||
@@ -173,7 +268,7 @@ export default function Tokens() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{/* ========== Delete Confirmation Modal ========== */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -191,76 +286,78 @@ export default function Tokens() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token List */}
|
||||
{tokens.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-page-surface flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-page-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-page-secondary mb-4">{t('tokens.noKeys')}</p>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
{t('tokens.createFirst')}
|
||||
</button>
|
||||
{/* ========== Section 2: My API Keys ========== */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-heading font-semibold text-page">{t('tokens.myKeys')}</h2>
|
||||
{!hasGroups && (
|
||||
<button onClick={openCreateDefault} className="btn-primary">
|
||||
{t('tokens.newKey')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="glass-sm rounded-xl p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Status dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${token.status === 1 ? 'bg-green-500' : 'bg-page-muted'}`} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-page">{token.name}</p>
|
||||
</div>
|
||||
|
||||
{/* Created time */}
|
||||
<span className="text-xs text-page-muted hidden md:block">
|
||||
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(token)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
|
||||
token.status === 1
|
||||
? 'border-green-500/30 text-page-success hover:bg-green-500/10'
|
||||
: 'border-page-divider text-page-secondary hover:bg-page-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{token.status === 1 ? t('tokens.enabled') : t('tokens.disabled')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(token)}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-red-500/20 text-page-danger hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
{t('tokens.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Key row — always visible with copy button */}
|
||||
{token.key && (
|
||||
<div className="mt-3 flex items-center gap-2 bg-page-inset rounded-lg px-3 py-2">
|
||||
<code className="text-xs font-mono text-page-muted flex-1 break-all select-all">
|
||||
sk-{token.key}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy('sk-' + token.key)}
|
||||
className="flex-shrink-0 px-2.5 py-1 text-xs rounded-md bg-page-surface text-page-secondary hover:bg-page-surface-hover hover:text-page transition-colors"
|
||||
>
|
||||
{copiedId === 'sk-' + token.key ? t('tokens.copied') : t('tokens.copy')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tokens.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-page-surface flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-page-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-page-secondary mb-4">{t('tokens.noKeys')}</p>
|
||||
<button onClick={openCreateDefault} className="btn-primary">
|
||||
{t('tokens.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="glass-sm rounded-xl p-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${token.status === 1 ? 'bg-green-500' : 'bg-page-muted'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-page">{token.name}</p>
|
||||
</div>
|
||||
<span className="text-xs text-page-muted hidden md:block">
|
||||
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(token)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
|
||||
token.status === 1
|
||||
? 'border-green-500/30 text-page-success hover:bg-green-500/10'
|
||||
: 'border-page-divider text-page-secondary hover:bg-page-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{token.status === 1 ? t('tokens.enabled') : t('tokens.disabled')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(token)}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-red-500/20 text-page-danger hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
{t('tokens.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{token.key && (
|
||||
<div className="mt-3 flex items-center gap-2 bg-page-inset rounded-lg px-3 py-2">
|
||||
<code className="text-xs font-mono text-page-muted flex-1 break-all select-all">
|
||||
sk-{token.key}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy('sk-' + token.key)}
|
||||
className="flex-shrink-0 px-2.5 py-1 text-xs rounded-md bg-page-surface text-page-secondary hover:bg-page-surface-hover hover:text-page transition-colors"
|
||||
>
|
||||
{copiedId === 'sk-' + token.key ? t('tokens.copied') : t('tokens.copy')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config File Generator */}
|
||||
<div className="mt-8">
|
||||
@@ -269,3 +366,73 @@ export default function Tokens() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ========== Key Group Card ========== */
|
||||
function KeyGroupCard({ group, parseTags, onSelect, t }) {
|
||||
const tags = parseTags(group.tags);
|
||||
const isUnavailable = group.is_unavailable;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`glass-sm rounded-xl p-4 border border-page-divider transition-all ${
|
||||
isUnavailable
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:border-brand-500/40 cursor-pointer group'
|
||||
}`}
|
||||
onClick={() => !isUnavailable && onSelect(group)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Name + badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-page">{group.name}</span>
|
||||
{group.is_recommended && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/15 text-amber-600 dark:text-amber-400">
|
||||
{t('tokens.recommended')}
|
||||
</span>
|
||||
)}
|
||||
{isUnavailable && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-red-500/10 text-red-500">
|
||||
{t('tokens.unavailable')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price + discount + tags */}
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{group.rmb_per_usd > 0 && (
|
||||
<span className="text-xs font-medium text-page">
|
||||
{group.rmb_per_usd} {t('tokens.rmbPerUsd')}
|
||||
</span>
|
||||
)}
|
||||
{group.discount_label && (
|
||||
<span className="text-[11px] font-semibold text-green-600 dark:text-green-400">
|
||||
{group.discount_label}
|
||||
</span>
|
||||
)}
|
||||
{tags.map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 rounded text-[10px] bg-page-surface text-page-secondary">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{group.description && (
|
||||
<p className="text-xs text-page-muted mt-1">{group.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create arrow */}
|
||||
{!isUnavailable && (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs font-medium text-brand-500">{t('tokens.create')}</span>
|
||||
<svg className="w-4 h-4 text-brand-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user