Merge remote-tracking branch 'origin/main'
This commit is contained in:
@@ -163,7 +163,11 @@ const API_DEMOS: ApiDemoConfig[] = [
|
||||
const CYCLE_INTERVAL = 4500
|
||||
const TRANSITION_MS = 220
|
||||
|
||||
export function HeroTerminalDemo() {
|
||||
interface HeroTerminalDemoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function HeroTerminalDemo(props: HeroTerminalDemoProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [transitioning, setTransitioning] = useState(false)
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
@@ -202,7 +206,7 @@ export function HeroTerminalDemo() {
|
||||
const accent = ACCENT_CLASSES[demo.accent]
|
||||
|
||||
return (
|
||||
<div className='mx-auto mt-16 w-full max-w-2xl'>
|
||||
<div className={cn('mx-auto w-full max-w-2xl', props.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-2xl border backdrop-blur-sm',
|
||||
|
||||
+191
-58
@@ -17,9 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { ArrowRight, BookOpen } from 'lucide-react'
|
||||
import { CherryStudio } from '@lobehub/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { HeroTerminalDemo } from '../hero-terminal-demo'
|
||||
|
||||
interface HeroProps {
|
||||
@@ -27,11 +29,59 @@ interface HeroProps {
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
// Stylized three-dots indicator representing "More"
|
||||
const MoreIcon = () => (
|
||||
<svg
|
||||
className='size-6 shrink-0 text-muted-foreground/60 transition-colors group-hover:text-foreground'
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<circle cx='6' cy='12' r='2' fill='currentColor' />
|
||||
<circle cx='12' cy='12' r='2' fill='currentColor' />
|
||||
<circle cx='18' cy='12' r='2' fill='currentColor' />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function Hero(props: HeroProps) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
const docsUrl = (status?.docs_link as string | undefined) || 'https://docs.newapi.pro'
|
||||
|
||||
const renderDocsButton = () => {
|
||||
const isExternal = docsUrl.startsWith('http')
|
||||
if (isExternal) {
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='group border-border/50 hover:border-border hover:bg-muted/50 rounded-lg h-11 px-5 text-sm font-medium inline-flex items-center gap-1.5'
|
||||
render={
|
||||
<a
|
||||
href={docsUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<BookOpen className='size-4 text-muted-foreground/80 group-hover:text-foreground transition-colors duration-200' />
|
||||
<span>{t('Docs')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
className='group border-border/50 hover:border-border hover:bg-muted/50 rounded-lg h-11 px-5 text-sm font-medium inline-flex items-center gap-1.5'
|
||||
render={<Link to={docsUrl} />}
|
||||
>
|
||||
<BookOpen className='size-4 text-muted-foreground/80 group-hover:text-foreground transition-colors duration-200' />
|
||||
<span>{t('Docs')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className='relative z-10 flex flex-col items-center overflow-hidden px-6 pt-28 pb-16 md:pt-36 md:pb-24'>
|
||||
<section className='relative z-10 overflow-hidden px-6 pt-24 pb-16 md:pt-32 md:pb-24 lg:pt-36 lg:pb-28'>
|
||||
{/* Radial gradient background */}
|
||||
<div
|
||||
aria-hidden
|
||||
@@ -50,63 +100,146 @@ export function Hero(props: HeroProps) {
|
||||
className='absolute inset-0 -z-10 bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_30%,black_20%,transparent_100%)] bg-[size:4rem_4rem] opacity-[0.08]'
|
||||
/>
|
||||
|
||||
<div className='flex max-w-3xl flex-col items-center text-center'>
|
||||
<h1
|
||||
className='landing-animate-fade-up text-[clamp(2rem,5.5vw,3.5rem)] leading-[1.15] font-bold tracking-tight'
|
||||
style={{ animationDelay: '0ms' }}
|
||||
>
|
||||
{t('Unified API Gateway for')}
|
||||
<br />
|
||||
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
|
||||
{t('All Your AI Models')}
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-lg text-base leading-relaxed opacity-0 md:text-lg'
|
||||
style={{ animationDelay: '80ms' }}
|
||||
>
|
||||
{t(
|
||||
'Power AI applications, manage digital assets, connect the Future'
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
className='landing-animate-fade-up mt-8 flex items-center gap-3 opacity-0'
|
||||
style={{ animationDelay: '160ms' }}
|
||||
>
|
||||
{props.isAuthenticated ? (
|
||||
<Button
|
||||
className='group rounded-lg'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Go to Dashboard')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='group rounded-lg'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1 size-3.5 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg'
|
||||
render={<Link to='/pricing' />}
|
||||
>
|
||||
{t('View Pricing')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto grid max-w-6xl grid-cols-1 items-start gap-12 lg:grid-cols-12 lg:gap-8'>
|
||||
{/* Left Column: Title, description, action buttons and application support */}
|
||||
<div className='flex flex-col items-start text-left lg:col-span-6'>
|
||||
{/* Top Pill Badge */}
|
||||
<div
|
||||
className='landing-animate-fade-up mb-5 inline-flex items-center gap-1.5 rounded-full border border-blue-500/20 bg-blue-500/5 px-3 py-1.5 text-[11px] font-medium text-blue-600 dark:border-blue-400/20 dark:bg-blue-400/5 dark:text-blue-400 opacity-0 shadow-xs'
|
||||
style={{ animationDelay: '0ms' }}
|
||||
>
|
||||
<span className='relative flex size-1.5'>
|
||||
<span className='absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75' />
|
||||
<span className='relative inline-flex size-1.5 rounded-full bg-blue-500 dark:bg-blue-400' />
|
||||
</span>
|
||||
<span>{t('AI Application Infrastructure Foundation')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className='landing-animate-fade-up w-full opacity-0'
|
||||
style={{ animationDelay: '300ms' }}
|
||||
>
|
||||
<HeroTerminalDemo />
|
||||
<h1
|
||||
className='landing-animate-fade-up text-[clamp(2.25rem,4.5vw,3.25rem)] leading-[1.15] font-bold tracking-tight'
|
||||
style={{ animationDelay: '60ms' }}
|
||||
>
|
||||
{t('Unified API Gateway for')}
|
||||
<br />
|
||||
<span className='bg-gradient-to-r from-blue-400 via-violet-400 to-purple-500 bg-clip-text text-transparent'>
|
||||
{t('Vast Range of AI Models')}
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
className='landing-animate-fade-up text-muted-foreground/80 mt-5 max-w-xl text-base leading-relaxed opacity-0 md:text-[15px]'
|
||||
style={{ animationDelay: '120ms' }}
|
||||
>
|
||||
{t(
|
||||
'Access a vast selection of models via a standard, unified API protocol. Power AI applications, manage digital assets, and connect the Future.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className='landing-animate-fade-up mt-8 flex flex-wrap items-center gap-3 opacity-0'
|
||||
style={{ animationDelay: '180ms' }}
|
||||
>
|
||||
{props.isAuthenticated ? (
|
||||
<>
|
||||
<Button
|
||||
className='group rounded-lg h-11 px-5 text-sm font-medium'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Go to Dashboard')}
|
||||
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
{renderDocsButton()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='group rounded-lg h-11 px-5 text-sm font-medium'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
<ArrowRight className='ml-1.5 size-4 transition-transform duration-200 group-hover:translate-x-0.5' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg h-11 px-5 text-sm font-medium'
|
||||
render={<Link to='/pricing' />}
|
||||
>
|
||||
{t('View Pricing')}
|
||||
</Button>
|
||||
{renderDocsButton()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supported Apps (参考图二样式,进行卡片化和信息扩充设计,增加视觉高度) */}
|
||||
<div
|
||||
className='landing-animate-fade-up mt-10 w-full max-w-xl opacity-0'
|
||||
style={{ animationDelay: '240ms' }}
|
||||
>
|
||||
<div className='flex flex-col gap-1 mb-4'>
|
||||
<span className='text-[10px] font-bold tracking-[0.15em] text-muted-foreground/50 uppercase'>
|
||||
{t('Supported Applications')}
|
||||
</span>
|
||||
<p className='text-xs text-muted-foreground/60 leading-relaxed'>
|
||||
{t('Supports one-click configuration and perfectly adapts to NewAPI multi-protocol configuration.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
{/* Cherry Studio */}
|
||||
<a
|
||||
href='https://cherry-ai.com'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 rounded-full border border-border/40 bg-muted/15 px-5 py-2.5 text-sm font-medium text-foreground/80 shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:border-border hover:bg-muted/30 hover:text-foreground hover:scale-[1.02]'
|
||||
>
|
||||
<CherryStudio.Color size={24} className='shrink-0' />
|
||||
<span>Cherry Studio</span>
|
||||
</a>
|
||||
|
||||
{/* CC Switch */}
|
||||
<a
|
||||
href='https://ccswitch.io'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='group flex items-center gap-3 rounded-full border border-border/40 bg-muted/15 px-5 py-2.5 text-sm font-medium text-foreground/80 shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:border-border hover:bg-muted/30 hover:text-foreground hover:scale-[1.02]'
|
||||
>
|
||||
<img
|
||||
src='https://ccswitch.io/favicon.png'
|
||||
alt='CC Switch'
|
||||
className='size-6 shrink-0 rounded-md object-contain'
|
||||
onError={(e) => {
|
||||
// Fallback to a styled text avatar if the remote favicon fails to load in sandbox or local environments
|
||||
e.currentTarget.style.display = 'none'
|
||||
const fallback = e.currentTarget.nextSibling as HTMLElement
|
||||
if (fallback) fallback.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{ display: 'none' }}
|
||||
className='size-6 shrink-0 items-center justify-center rounded-md bg-blue-500/10 text-[10px] font-bold text-blue-600 dark:bg-blue-400/10 dark:text-blue-400'
|
||||
>
|
||||
CC
|
||||
</span>
|
||||
<span>CC Switch</span>
|
||||
</a>
|
||||
|
||||
{/* "更多" */}
|
||||
<div
|
||||
className='group flex items-center gap-2.5 rounded-full border border-border/40 bg-muted/15 px-5 py-2.5 text-sm font-medium text-foreground/55 shadow-[0_1px_2.5px_rgba(0,0,0,0.01)] backdrop-blur-xs transition-all duration-300 hover:border-border hover:bg-muted/30 hover:text-foreground hover:scale-[1.02] cursor-default'
|
||||
>
|
||||
<MoreIcon />
|
||||
<span>{t('More Apps')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Hero Terminal API Demo */}
|
||||
<div
|
||||
className='landing-animate-fade-up flex justify-center w-full opacity-0 lg:col-span-6'
|
||||
style={{ animationDelay: '320ms' }}
|
||||
>
|
||||
<HeroTerminalDemo className='mt-8 lg:mt-0' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
Vendored
+1
-1
@@ -42,7 +42,7 @@ export async function getApiKeys(
|
||||
// Search API keys by keyword or token (with pagination)
|
||||
export async function searchApiKeys(
|
||||
params: SearchApiKeysParams
|
||||
): Promise<{ success: boolean; message?: string; data?: ApiKey[] }> {
|
||||
): Promise<GetApiKeysResponse> {
|
||||
const { keyword = '', token = '', p, size } = params
|
||||
const queryParams = new URLSearchParams()
|
||||
if (keyword) queryParams.set('keyword', keyword)
|
||||
|
||||
+62
-34
@@ -30,6 +30,7 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { useDebounce } from '@/hooks'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
@@ -207,9 +209,35 @@ export function ApiKeysTable() {
|
||||
navigate: route.useNavigate(),
|
||||
pagination: { defaultPage: 1, defaultPageSize: 20 },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [{ columnId: 'status', searchKey: 'status', type: 'array' }],
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
{ columnId: '_tokenSearch', searchKey: 'token', type: 'string' },
|
||||
],
|
||||
})
|
||||
|
||||
const tokenFilterFromUrl =
|
||||
(columnFilters.find((f) => f.id === '_tokenSearch')?.value as string) || ''
|
||||
const [tokenFilterInput, setTokenFilterInput] = useState(tokenFilterFromUrl)
|
||||
const debouncedTokenFilter = useDebounce(tokenFilterInput, 500)
|
||||
|
||||
useEffect(() => {
|
||||
setTokenFilterInput(tokenFilterFromUrl)
|
||||
}, [tokenFilterFromUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTokenFilter !== tokenFilterFromUrl) {
|
||||
onColumnFiltersChange((prev) => {
|
||||
const filtered = prev.filter((f) => f.id !== '_tokenSearch')
|
||||
return debouncedTokenFilter
|
||||
? [...filtered, { id: '_tokenSearch', value: debouncedTokenFilter }]
|
||||
: filtered
|
||||
})
|
||||
}
|
||||
}, [debouncedTokenFilter, tokenFilterFromUrl, onColumnFiltersChange])
|
||||
|
||||
const tokenFilter = tokenFilterFromUrl
|
||||
const shouldSearch = Boolean(globalFilter?.trim() || tokenFilter.trim())
|
||||
|
||||
// Fetch data with React Query
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
@@ -218,32 +246,31 @@ export function ApiKeysTable() {
|
||||
pagination.pageIndex + 1,
|
||||
pagination.pageSize,
|
||||
globalFilter,
|
||||
tokenFilter,
|
||||
refreshTrigger,
|
||||
],
|
||||
queryFn: async () => {
|
||||
// If there's a global filter, use search
|
||||
const hasFilter = globalFilter?.trim()
|
||||
|
||||
if (hasFilter) {
|
||||
const result = await searchApiKeys({ keyword: globalFilter })
|
||||
if (!result.success) {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.SEARCH_FAILED))
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
return {
|
||||
items: result.data || [],
|
||||
total: result.data?.length || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise use pagination
|
||||
const result = await getApiKeys({
|
||||
p: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
})
|
||||
const result = shouldSearch
|
||||
? await searchApiKeys({
|
||||
keyword: globalFilter,
|
||||
token: tokenFilter,
|
||||
p: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
})
|
||||
: await getApiKeys({
|
||||
p: pagination.pageIndex + 1,
|
||||
size: pagination.pageSize,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.message || t(ERROR_MESSAGES.LOAD_FAILED))
|
||||
toast.error(
|
||||
result.message ||
|
||||
t(
|
||||
shouldSearch
|
||||
? ERROR_MESSAGES.SEARCH_FAILED
|
||||
: ERROR_MESSAGES.LOAD_FAILED
|
||||
)
|
||||
)
|
||||
return { items: [], total: 0 }
|
||||
}
|
||||
|
||||
@@ -272,13 +299,7 @@ export function ApiKeysTable() {
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const name = String(row.getValue('name')).toLowerCase()
|
||||
const key = String(row.original.key).toLowerCase()
|
||||
const searchValue = String(filterValue).toLowerCase()
|
||||
|
||||
return name.includes(searchValue) || key.includes(searchValue)
|
||||
},
|
||||
globalFilterFn: () => true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
@@ -288,10 +309,8 @@ export function ApiKeysTable() {
|
||||
onPaginationChange,
|
||||
onGlobalFilterChange,
|
||||
onColumnFiltersChange,
|
||||
manualPagination: !globalFilter,
|
||||
pageCount: globalFilter
|
||||
? Math.ceil((data?.total || 0) / pagination.pageSize)
|
||||
: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil((data?.total || 0) / pagination.pageSize),
|
||||
})
|
||||
|
||||
const pageCount = table.getPageCount()
|
||||
@@ -311,7 +330,16 @@ export function ApiKeysTable() {
|
||||
)}
|
||||
skeletonKeyPrefix='api-keys-skeleton'
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name or key...'),
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by API key...')}
|
||||
aria-label={t('Filter by API key...')}
|
||||
value={tokenFilterInput}
|
||||
onChange={(e) => setTokenFilterInput(e.target.value)}
|
||||
className='w-full sm:w-50 lg:w-60'
|
||||
/>
|
||||
),
|
||||
filters: [
|
||||
{
|
||||
columnId: 'status',
|
||||
|
||||
Reference in New Issue
Block a user