mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-21 04:54:04 +00:00
fix(dashboard): mobile-first layout for the dashboard and shared responsive primitives (#15532)
Merged via /merge-batch (2026-10-18). Maintainer merge of the current base on top: resolved ConnectionRow / ConnectionsHeaderToolbar / CustomModelsSection keeping every later base feature (ConnectionIdChip, CodexPingNowButton, proxy badge button, Antigravity verify link, Codex vertical layout #16248, the music endpoint) under the new responsive layout; moved ModelEndpointTagsEditor (#16956) to its own line in ModelRow; aligned two layout assertions to the new action-bar wrapper (no assertion weakened). typecheck core, ESLint on all changed files, complexity/cognitive/file-size, i18n key coverage, and the PR + touching UI tests green (load-induced timeouts re-passed in isolation). Remaining CI reds are the inherited base-red #15306. Thanks @HDBR!
This commit is contained in:
parent
0a2753c551
commit
40523566e6
37 changed files with 1023 additions and 488 deletions
1
changelog.d/fixes/15532-dashboard-mobile-layout.md
Normal file
1
changelog.d/fixes/15532-dashboard-mobile-layout.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
- Fix the dashboard on phones: Material Symbols icons now honour their Tailwind size classes (the icon font's unlayered `font-size: 24px` overrode every utility), provider screens, filters, toolbars and model cards fit and fill the viewport, page/section titles stay on one line with header actions below, toggle thumbs are centred and spinners rotate around their own centre. Adds shared responsive primitives (`ActionBar`, `FilterPills`, `ReorderControl`, `ProviderCardGrid`, `AutoGrid`, `CapabilityBadge`, compact `Select`).
|
||||
|
|
@ -795,7 +795,7 @@
|
|||
},
|
||||
"src/app/(dashboard)/dashboard/providers/page.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 4
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {
|
||||
|
|
|
|||
|
|
@ -247,7 +247,10 @@ export default function FaroChat() {
|
|||
onTouchStart={() => void startRecording()}
|
||||
onTouchEnd={stopRecording}
|
||||
>
|
||||
🎙 {voiceLabel[voice]}
|
||||
<span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">
|
||||
mic
|
||||
</span>{" "}
|
||||
{voiceLabel[voice]}
|
||||
</button>
|
||||
<label className="flex items-center gap-1 text-xs text-text-muted">
|
||||
<input type="checkbox" checked={speak} onChange={(e) => setSpeak(e.target.checked)} />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import ConnectionTestButton from "@/shared/components/ConnectionTestButton";
|
|||
import { readCookieExpiresAt } from "@/shared/utils/webCookieExpiry";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Badge, Button, Toggle } from "@/shared/components";
|
||||
import { Badge, Button, ReorderControl, Toggle } from "@/shared/components";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
|
||||
|
|
@ -320,9 +320,14 @@ export default function ConnectionRow({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`group flex ${isCodex ? "flex-col items-stretch gap-3" : "items-center justify-between"} p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}
|
||||
className={`group flex ${isCodex ? "flex-col items-stretch gap-3" : "flex-wrap items-center justify-between gap-x-3 gap-y-2"} p-3 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* basis-64 lets the action bar drop to its own line on narrow screens instead of
|
||||
pushing the row past the viewport (the page clips x-overflow, so buttons vanished).
|
||||
Codex rows stack vertically (#16248), where a 16rem basis would become a height. */}
|
||||
<div
|
||||
className={`flex items-center gap-2 sm:gap-3 flex-1 min-w-0 ${isCodex ? "" : "basis-64"}`}
|
||||
>
|
||||
{onToggleSelect && (
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -331,28 +336,17 @@ export default function ConnectionRow({
|
|||
className="w-4 h-4 shrink-0 rounded border-border text-primary focus:ring-primary/30 cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
{/* Priority arrows */}
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-base text-text-muted">
|
||||
<ReorderControl
|
||||
onMoveUp={onMoveUp}
|
||||
onMoveDown={onMoveDown}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
<span className="material-symbols-outlined hidden text-base text-text-muted sm:inline-block">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<p className="text-sm font-medium break-words sm:truncate">{displayName}</p>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<Badge variant={statusPresentation.statusVariant as any} size="sm" dot>
|
||||
{statusPresentation.statusLabel}
|
||||
|
|
@ -427,7 +421,7 @@ export default function ConnectionRow({
|
|||
</span>
|
||||
)}
|
||||
{/* Rate Limit Protection — inline toggle with label */}
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onToggleRateLimit(!rateLimitEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
|
|
@ -450,7 +444,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{onToggleAutoSync && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onToggleAutoSync?.(!autoSyncEnabled)}
|
||||
disabled={connection.isActive === false}
|
||||
|
|
@ -479,7 +473,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{isClaude && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onToggleClaudeExtraUsage?.(!claudeBlockExtraUsageEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
|
|
@ -497,7 +491,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{isCodex && connection.provider === "codex" && onToggleCodexPaidCredits && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onToggleCodexPaidCredits(!codexPaidCreditsEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
|
|
@ -523,7 +517,7 @@ export default function ConnectionRow({
|
|||
one connection type it was actually built for. */}
|
||||
{(isClaude || isCcCompatible) && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<select
|
||||
value={effectiveUpstreamProxyMode}
|
||||
onChange={(e) =>
|
||||
|
|
@ -559,7 +553,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{isCodex && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
{codexServiceTierBadge && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium ${codexServiceTierBadge.className}`}
|
||||
|
|
@ -599,7 +593,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{onToggleProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onToggleProxyEnabled(!proxyEnabled)}
|
||||
aria-label={proxyEnabled ? t("proxyEnabledTitle") : t("proxyDisabledTitle")}
|
||||
|
|
@ -617,7 +611,7 @@ export default function ConnectionRow({
|
|||
)}
|
||||
{onTogglePerKeyProxyEnabled && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
onClick={() => onTogglePerKeyProxyEnabled(!perKeyProxyEnabled)}
|
||||
aria-label={
|
||||
|
|
@ -661,7 +655,7 @@ export default function ConnectionRow({
|
|||
: t("proxySourceKey");
|
||||
return (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProxy}
|
||||
|
|
@ -681,163 +675,175 @@ export default function ConnectionRow({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 ${isCodex ? "flex-wrap" : ""}`}>
|
||||
<ConnectionTestButton
|
||||
connectionId={connection.id}
|
||||
disabled={connection.isActive === false}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="refresh"
|
||||
loading={isRetesting}
|
||||
disabled={connection.isActive === false}
|
||||
onClick={onRetest}
|
||||
className="!h-7 !px-2 text-xs"
|
||||
title={t("retestAuthentication")}
|
||||
>
|
||||
{t("retest")}
|
||||
</Button>
|
||||
{/* T12: Manual token refresh for OAuth accounts */}
|
||||
{onRefreshToken && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="token"
|
||||
loading={isRefreshing}
|
||||
disabled={connection.isActive === false || isRefreshing}
|
||||
onClick={onRefreshToken}
|
||||
className="!h-7 !px-2 text-xs text-amber-500 hover:text-amber-400"
|
||||
title={t("refreshOauthTokenTitle")}
|
||||
>
|
||||
{t("tokenShort")}
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && isOAuth && connection.provider === "codex" && (
|
||||
<CodexPingNowButton
|
||||
<div className="basis-full min-w-0 xl:basis-auto">
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-0.5 sm:gap-1 xl:justify-end [&>button]:grow-0">
|
||||
<ConnectionTestButton
|
||||
connectionId={connection.id}
|
||||
disabled={connection.isActive === false}
|
||||
/>
|
||||
)}
|
||||
{isCodex && onApplyCodexAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download_done"
|
||||
loading={isApplyingCodexAuthLocal}
|
||||
disabled={isApplyingCodexAuthLocal}
|
||||
onClick={onApplyCodexAuthLocal}
|
||||
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
title={applyCodexAuthLabel}
|
||||
icon="refresh"
|
||||
loading={isRetesting}
|
||||
disabled={connection.isActive === false}
|
||||
onClick={onRetest}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs"
|
||||
aria-label={t("retest")}
|
||||
title={t("retestAuthentication")}
|
||||
>
|
||||
{applyCodexAuthLabel}
|
||||
<span className="hidden sm:inline">{t("retest")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onExportCodexAuthFile && (
|
||||
<Button
|
||||
{/* T12: Manual token refresh for OAuth accounts */}
|
||||
{onRefreshToken && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="token"
|
||||
loading={isRefreshing}
|
||||
disabled={connection.isActive === false || isRefreshing}
|
||||
onClick={onRefreshToken}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-amber-500 hover:text-amber-400"
|
||||
aria-label={t("tokenShort")}
|
||||
title={t("refreshOauthTokenTitle")}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("tokenShort")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && isOAuth && connection.provider === "codex" && (
|
||||
<CodexPingNowButton
|
||||
connectionId={connection.id}
|
||||
disabled={connection.isActive === false}
|
||||
/>
|
||||
)}
|
||||
{isCodex && onApplyCodexAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download_done"
|
||||
loading={isApplyingCodexAuthLocal}
|
||||
disabled={isApplyingCodexAuthLocal}
|
||||
onClick={onApplyCodexAuthLocal}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
aria-label={applyCodexAuthLabel}
|
||||
title={applyCodexAuthLabel}
|
||||
>
|
||||
<span className="hidden sm:inline">{applyCodexAuthLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
{isCodex && onExportCodexAuthFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingCodexAuthFile}
|
||||
disabled={isExportingCodexAuthFile}
|
||||
onClick={onExportCodexAuthFile}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
aria-label={exportCodexAuthLabel}
|
||||
title={exportCodexAuthLabel}
|
||||
>
|
||||
<span className="hidden sm:inline">{exportCodexAuthLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
{isClaude && onApplyClaudeAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="install_desktop"
|
||||
loading={isApplyingClaudeAuthLocal}
|
||||
disabled={isApplyingClaudeAuthLocal}
|
||||
onClick={onApplyClaudeAuthLocal}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
aria-label={applyClaudeAuthLabel}
|
||||
title={applyClaudeAuthLabel}
|
||||
>
|
||||
<span className="hidden sm:inline">{applyClaudeAuthLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
{isClaude && onExportClaudeAuthFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingClaudeAuthFile}
|
||||
disabled={isExportingClaudeAuthFile}
|
||||
onClick={onExportClaudeAuthFile}
|
||||
className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
aria-label={exportClaudeAuthLabel}
|
||||
title={exportClaudeAuthLabel}
|
||||
>
|
||||
<span className="hidden sm:inline">{exportClaudeAuthLabel}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingCodexAuthFile}
|
||||
disabled={isExportingCodexAuthFile}
|
||||
onClick={onExportCodexAuthFile}
|
||||
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
title={exportCodexAuthLabel}
|
||||
>
|
||||
{exportCodexAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
{isClaude && onApplyClaudeAuthLocal && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="install_desktop"
|
||||
loading={isApplyingClaudeAuthLocal}
|
||||
disabled={isApplyingClaudeAuthLocal}
|
||||
onClick={onApplyClaudeAuthLocal}
|
||||
className="!h-7 !px-2 text-xs text-emerald-500 hover:text-emerald-400"
|
||||
title={applyClaudeAuthLabel}
|
||||
>
|
||||
{applyClaudeAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
{isClaude && onExportClaudeAuthFile && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon="download"
|
||||
loading={isExportingClaudeAuthFile}
|
||||
disabled={isExportingClaudeAuthFile}
|
||||
onClick={onExportClaudeAuthFile}
|
||||
className="!h-7 !px-2 text-xs text-sky-500 hover:text-sky-400"
|
||||
title={exportClaudeAuthLabel}
|
||||
>
|
||||
{exportClaudeAuthLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={connection.isActive ?? true}
|
||||
onChange={onToggleActive}
|
||||
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
|
||||
/>
|
||||
<div className="flex gap-1 ms-1 transition-opacity">
|
||||
{onReauth && (
|
||||
checked={connection.isActive ?? true}
|
||||
onChange={onToggleActive}
|
||||
title={(connection.isActive ?? true) ? t("disableConnection") : t("enableConnection")}
|
||||
/>
|
||||
<div className="contents">
|
||||
{onReauth && (
|
||||
<button
|
||||
onClick={onReauth}
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-amber-500/10 text-amber-600 hover:text-amber-500"
|
||||
title={t("reauthenticateConnection")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">passkey</span>
|
||||
</button>
|
||||
)}
|
||||
{verificationUrl && (
|
||||
<a
|
||||
href={verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-amber-500/10 text-amber-600 hover:text-amber-500"
|
||||
title={t("verifyAccountHint")}
|
||||
aria-label={t("verifyAccount")}
|
||||
data-testid="connection-verify-account"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">verified_user</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onReauth}
|
||||
className="p-2 hover:bg-amber-500/10 rounded text-amber-600 hover:text-amber-500"
|
||||
title={t("reauthenticateConnection")}
|
||||
onClick={onEdit}
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">passkey</span>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
)}
|
||||
{verificationUrl && (
|
||||
<a
|
||||
href={verificationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 hover:bg-amber-500/10 rounded text-amber-600 hover:text-amber-500"
|
||||
title={t("verifyAccountHint")}
|
||||
aria-label={t("verifyAccount")}
|
||||
data-testid="connection-verify-account"
|
||||
<button
|
||||
onClick={onProxy}
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary"
|
||||
title={t("proxyConfig")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">verified_user</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
|
||||
title={t("edit")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onProxy}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary"
|
||||
title={t("proxyConfig")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500"
|
||||
title={t("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[18px]">vpn_lock</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="inline-flex size-7 items-center justify-center rounded hover:bg-red-500/10 text-red-500"
|
||||
title={t("delete")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isCodex && connection.codexAccountPool ? (
|
||||
<CodexAccountDetails pool={connection.codexAccountPool} />
|
||||
<div className="basis-full min-w-0">
|
||||
<CodexAccountDetails pool={connection.codexAccountPool} />
|
||||
</div>
|
||||
) : null}
|
||||
{quotaPanelSupported ? (
|
||||
<ConnectionQuotaPanel
|
||||
providerId={String(connection.provider || "")}
|
||||
connection={connection}
|
||||
cache={quotaCache}
|
||||
refreshing={quotaRefreshing}
|
||||
onRefresh={onRefreshQuota}
|
||||
/>
|
||||
<div className="basis-full min-w-0">
|
||||
<ConnectionQuotaPanel
|
||||
providerId={String(connection.provider || "")}
|
||||
connection={connection}
|
||||
cache={quotaCache}
|
||||
refreshing={quotaRefreshing}
|
||||
onRefresh={onRefreshQuota}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Button, DistributeProxiesButton, Toggle } from "@/shared/components";
|
||||
import { ActionBar, Button, DistributeProxiesButton, Toggle } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
import type { CodexGlobalServiceMode } from "@/lib/providers/codexFastTier";
|
||||
import AccountProxyResetButton from "./AccountProxyResetButton";
|
||||
|
|
@ -235,9 +235,7 @@ export default function ConnectionsHeaderToolbar({
|
|||
: t("providerProxy")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-2 ${providerId === "codex" ? "justify-start" : "shrink-0 justify-end"}`}
|
||||
>
|
||||
<ActionBar align={providerId === "codex" ? "start" : "end"}>
|
||||
{connections.length > 0 && (
|
||||
<DistributeProxiesButton
|
||||
onDistribute={async () => {
|
||||
|
|
@ -405,7 +403,7 @@ export default function ConnectionsHeaderToolbar({
|
|||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ActionBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import React from "react";
|
||||
import { type ConnectionRowConnection } from "./ConnectionRow";
|
||||
import ConnectionRow from "./ConnectionRow";
|
||||
import { Button, DistributeProxiesButton } from "@/shared/components";
|
||||
import { ActionBar, Button, DistributeProxiesButton, FilterPills } from "@/shared/components";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import { readBooleanToggle, providerCountText } from "../providerPageHelpers";
|
||||
import { compareTr } from "@/shared/utils/turkishText";
|
||||
|
|
@ -182,7 +182,7 @@ export default function ConnectionsListPanel({
|
|||
const someSelected = selectedIds.size > 0 && selectedIds.size < connections.length;
|
||||
const bulkBusy = batchUpdating !== null || batchRetesting || batchDeleting || batchTesting;
|
||||
const bulkActions = selectedIds.size > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<ActionBar>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -223,7 +223,7 @@ export default function ConnectionsListPanel({
|
|||
>
|
||||
{t("batchDeleteSelected", { count: selectedIds.size })}
|
||||
</Button>
|
||||
</div>
|
||||
</ActionBar>
|
||||
);
|
||||
|
||||
const isHealthy = (c: ConnectionRowConnection): boolean => {
|
||||
|
|
@ -261,7 +261,7 @@ export default function ConnectionsListPanel({
|
|||
const pageEnd = pageStart + PAGE_SIZE;
|
||||
|
||||
const accountSearchInput = (
|
||||
<div className="relative min-w-[160px] max-w-[220px]">
|
||||
<div className="relative w-full sm:w-auto sm:min-w-[160px] sm:max-w-[220px]">
|
||||
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
|
||||
search
|
||||
</span>
|
||||
|
|
@ -277,25 +277,15 @@ export default function ConnectionsListPanel({
|
|||
);
|
||||
|
||||
const filterPills = (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{STATUS_FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setHealthFilter(opt.value);
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
className={`px-2.5 py-1 text-xs rounded-full font-medium transition-colors ${
|
||||
healthFilter === opt.value
|
||||
? "bg-primary text-white"
|
||||
: "bg-muted/60 text-text-muted hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FilterPills
|
||||
options={STATUS_FILTER_OPTIONS}
|
||||
value={healthFilter}
|
||||
onChange={(v) => {
|
||||
setHealthFilter(v);
|
||||
setPage(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const paginationBar =
|
||||
|
|
@ -334,7 +324,7 @@ export default function ConnectionsListPanel({
|
|||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex w-full items-center gap-2 flex-wrap sm:w-auto">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -525,7 +515,7 @@ export default function ConnectionsListPanel({
|
|||
<>
|
||||
{selectedIds.size > 0 || connections.length > 0 ? (
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 px-3 py-2 bg-muted/50 rounded-t-lg border border-b-0 border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex w-full items-center gap-2 flex-wrap sm:w-auto">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/shared/components";
|
||||
import { Button, CapabilityBadge } from "@/shared/components";
|
||||
import {
|
||||
normalizeModelSupportedEndpoints,
|
||||
type ModelSupportedEndpoint,
|
||||
|
|
@ -68,16 +68,23 @@ const MODEL_ENDPOINT_OPTIONS: ModelSupportedEndpoint[] = [
|
|||
"music",
|
||||
];
|
||||
|
||||
// Layout shared by the add form and the inline edit form: two columns on phones (fields
|
||||
// pair up, endpoint checkboxes form a tidy grid), one flowing row from lg up.
|
||||
const FORMAT_FIELDS_GRID = "grid grid-cols-2 items-end gap-3 lg:flex lg:flex-wrap lg:gap-x-4";
|
||||
const ENDPOINTS_GRID =
|
||||
"grid grid-cols-2 gap-x-3 gap-y-1.5 sm:grid-cols-3 lg:flex lg:flex-wrap lg:items-center lg:gap-y-1";
|
||||
const CHECK_LABEL = "flex min-w-0 items-center gap-1.5 text-xs text-text-main cursor-pointer";
|
||||
|
||||
function endpointLabel(endpoint: ModelSupportedEndpoint, t: (key: string) => string): string {
|
||||
const labels: Partial<Record<ModelSupportedEndpoint, string>> = {
|
||||
chat: `💬 ${t("supportedEndpointChat")}`,
|
||||
embeddings: `📐 ${t("supportedEndpointEmbeddings")}`,
|
||||
chat: t("supportedEndpointChat"),
|
||||
embeddings: t("supportedEndpointEmbeddings"),
|
||||
rerank: providerText(t, "rerankEndpoint", "Rerank"),
|
||||
images: `🖼️ ${t("supportedEndpointImages")}`,
|
||||
videos: "🎬 Video",
|
||||
"audio-speech": `🔊 ${t("audioSpeech")}`,
|
||||
"audio-transcriptions": `🎙️ ${t("audioTranscriptions")}`,
|
||||
music: "🎵 Music",
|
||||
images: t("supportedEndpointImages"),
|
||||
videos: "Video",
|
||||
"audio-speech": t("audioSpeech"),
|
||||
"audio-transcriptions": t("audioTranscriptions"),
|
||||
music: "Music",
|
||||
};
|
||||
return labels[endpoint] || endpoint;
|
||||
}
|
||||
|
|
@ -422,8 +429,8 @@ export default function CustomModelsSection({
|
|||
|
||||
{/* Add form */}
|
||||
<div className="flex flex-col gap-3 mb-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-end gap-2 sm:flex sm:flex-wrap">
|
||||
<div className="col-span-2 flex-1 basis-48 min-w-0">
|
||||
<label htmlFor="custom-model-id" className="text-xs text-text-muted mb-1 block">
|
||||
{t("modelId")}
|
||||
</label>
|
||||
|
|
@ -437,7 +444,7 @@ export default function CustomModelsSection({
|
|||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<div className="min-w-0 sm:w-40">
|
||||
<label htmlFor="custom-model-name" className="text-xs text-text-muted mb-1 block">
|
||||
{t("displayName")}
|
||||
</label>
|
||||
|
|
@ -457,8 +464,8 @@ export default function CustomModelsSection({
|
|||
</div>
|
||||
|
||||
{/* API Format + Supported Endpoints */}
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="w-48">
|
||||
<div className={FORMAT_FIELDS_GRID}>
|
||||
<div className="min-w-0 lg:w-48">
|
||||
<label htmlFor="custom-api-format" className="text-xs text-text-muted mb-1 block">
|
||||
API Format
|
||||
</label>
|
||||
|
|
@ -482,7 +489,7 @@ export default function CustomModelsSection({
|
|||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-48">
|
||||
<div className="min-w-0 lg:w-48">
|
||||
<label htmlFor="custom-target-format" className="text-xs text-text-muted mb-1 block">
|
||||
{t("targetFormatLabel")}
|
||||
</label>
|
||||
|
|
@ -501,16 +508,13 @@ export default function CustomModelsSection({
|
|||
<option value="antigravity">{t("targetFormatAntigravity")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="col-span-2 min-w-0 lg:flex-1">
|
||||
<span className="text-xs text-text-muted mb-1 block">
|
||||
{t("supportedEndpointsLabel")}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={ENDPOINTS_GRID}>
|
||||
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer"
|
||||
>
|
||||
<label key={ep} className={CHECK_LABEL}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newEndpoints.includes(ep)}
|
||||
|
|
@ -528,8 +532,8 @@ export default function CustomModelsSection({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs text-text-muted mb-1 block"> </span>
|
||||
<div className="col-span-2 flex flex-wrap gap-x-4 gap-y-1 lg:block">
|
||||
<span className="hidden text-xs text-text-muted mb-1 lg:block"> </span>
|
||||
<label
|
||||
htmlFor="custom-model-supports-vision"
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
|
||||
|
|
@ -542,7 +546,7 @@ export default function CustomModelsSection({
|
|||
onChange={(e) => setNewSupportsVision(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{`👁️ ${t("visionCapableLabel")}`}
|
||||
{t("visionCapableLabel")}
|
||||
</label>
|
||||
<label
|
||||
htmlFor="custom-model-is-free"
|
||||
|
|
@ -622,20 +626,22 @@ export default function CustomModelsSection({
|
|||
</span>
|
||||
)}
|
||||
{typeof model.contextWindowOverride === "number" && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/15 text-orange-400 font-medium"
|
||||
<CapabilityBadge
|
||||
icon="width_normal"
|
||||
className="bg-orange-500/15 text-orange-400"
|
||||
title={t("contextWindowOverrideHint")}
|
||||
>
|
||||
{`🪟 ${model.contextWindowOverride.toLocaleString()}`}
|
||||
</span>
|
||||
{model.contextWindowOverride.toLocaleString()}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.supportsVision === true && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full bg-pink-500/15 text-pink-400 font-medium"
|
||||
<CapabilityBadge
|
||||
icon="visibility"
|
||||
className="bg-pink-500/15 text-pink-400"
|
||||
title={t("visionCapableHint")}
|
||||
>
|
||||
{`👁️ ${t("visionCapableLabel")}`}
|
||||
</span>
|
||||
{t("visionCapableLabel")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.isFree === true && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
|
||||
|
|
@ -643,35 +649,38 @@ export default function CustomModelsSection({
|
|||
</span>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("embeddings") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-purple-500/15 text-purple-400 font-medium">
|
||||
{`📐 ${t("supportedEndpointEmbeddings")}`}
|
||||
</span>
|
||||
<CapabilityBadge
|
||||
icon="scatter_plot"
|
||||
className="bg-purple-500/15 text-purple-400"
|
||||
>
|
||||
{t("supportedEndpointEmbeddings")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("images") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-500/15 text-amber-400 font-medium">
|
||||
{`🖼️ ${t("imagesShortLabel")}`}
|
||||
</span>
|
||||
<CapabilityBadge icon="image" className="bg-amber-500/15 text-amber-400">
|
||||
{t("imagesShortLabel")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
|
||||
{`🔊 ${t("audioShortLabel")}`}
|
||||
</span>
|
||||
<CapabilityBadge icon="volume_up" className="bg-green-500/15 text-green-400">
|
||||
{t("audioShortLabel")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{(model.supportedEndpoints?.includes("videos") ||
|
||||
model.supportedEndpoints?.includes("video")) && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-red-500/15 text-red-400 font-medium">
|
||||
🎬 Video
|
||||
</span>
|
||||
<CapabilityBadge icon="movie" className="bg-red-500/15 text-red-400">
|
||||
Video
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio-speech") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 font-medium">
|
||||
{`🔊 ${t("audioSpeech")}`}
|
||||
</span>
|
||||
<CapabilityBadge icon="volume_up" className="bg-green-500/15 text-green-400">
|
||||
{t("audioSpeech")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{model.supportedEndpoints?.includes("audio-transcriptions") && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-cyan-500/15 text-cyan-400 font-medium">
|
||||
{`🎙️ ${t("audioTranscriptions")}`}
|
||||
</span>
|
||||
<CapabilityBadge icon="mic" className="bg-cyan-500/15 text-cyan-400">
|
||||
{t("audioTranscriptions")}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{anyNormalizeCompatBadge(model.id!, customMap, overrideMap) && (
|
||||
<span
|
||||
|
|
@ -701,8 +710,8 @@ export default function CustomModelsSection({
|
|||
|
||||
{editingModelId === model.id && (
|
||||
<div className="mt-3 min-w-0 max-w-full rounded-lg border border-border bg-muted p-3 dark:bg-zinc-900">
|
||||
<div className="flex min-w-0 flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<div className="w-[11rem] shrink-0 min-w-0">
|
||||
<div className={FORMAT_FIELDS_GRID}>
|
||||
<div className="min-w-0 lg:w-[11rem] lg:shrink-0">
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("apiFormatLabel")}
|
||||
</label>
|
||||
|
|
@ -728,7 +737,7 @@ export default function CustomModelsSection({
|
|||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[11rem] shrink-0 min-w-0">
|
||||
<div className="min-w-0 lg:w-[11rem] lg:shrink-0">
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("targetFormatLabel")}
|
||||
</label>
|
||||
|
|
@ -748,7 +757,7 @@ export default function CustomModelsSection({
|
|||
<option value="antigravity">{t("targetFormatAntigravity")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-[10rem] shrink-0 min-w-0">
|
||||
<div className="min-w-0 lg:w-[10rem] lg:shrink-0">
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
{t("contextWindowOverrideLabel")}
|
||||
</label>
|
||||
|
|
@ -762,11 +771,13 @@ export default function CustomModelsSection({
|
|||
className="w-full px-2.5 py-2 text-xs border border-border rounded-lg bg-background text-text-main focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[9rem] shrink-0 min-w-0">
|
||||
<label className="text-xs text-text-muted mb-1 block"> </label>
|
||||
<div className="flex min-w-0 flex-col lg:w-[9rem] lg:shrink-0">
|
||||
<label className="hidden text-xs text-text-muted mb-1 lg:block">
|
||||
|
||||
</label>
|
||||
<label
|
||||
htmlFor={`custom-model-edit-vision-${model.id}`}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap px-2.5 py-2"
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap py-1 lg:px-2.5 lg:py-2"
|
||||
title={t("visionCapableHint")}
|
||||
>
|
||||
<input
|
||||
|
|
@ -776,11 +787,11 @@ export default function CustomModelsSection({
|
|||
onChange={(e) => setEditingSupportsVision(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
{`👁️ ${t("visionCapableLabel")}`}
|
||||
{t("visionCapableLabel")}
|
||||
</label>
|
||||
<label
|
||||
htmlFor={`custom-model-edit-free-${model.id}`}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap px-2.5 py-2"
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap py-1 lg:px-2.5 lg:py-2"
|
||||
title="Mark as free-tier"
|
||||
>
|
||||
<input
|
||||
|
|
@ -793,16 +804,13 @@ export default function CustomModelsSection({
|
|||
FREE
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1 overflow-x-auto overflow-y-visible [scrollbar-width:thin]">
|
||||
<div className="col-span-2 flex min-w-0 flex-col gap-1 lg:flex-1 lg:flex-row lg:flex-wrap lg:items-center lg:gap-x-3">
|
||||
<span className="text-xs text-text-muted shrink-0">
|
||||
{t("supportedEndpointsLabel")}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-x-2 sm:gap-x-3 gap-y-1 min-w-0">
|
||||
<div className={ENDPOINTS_GRID}>
|
||||
{MODEL_ENDPOINT_OPTIONS.map((ep) => (
|
||||
<label
|
||||
key={ep}
|
||||
className="flex items-center gap-1.5 text-xs text-text-main cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<label key={ep} className={CHECK_LABEL}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingEndpoints.includes(ep)}
|
||||
|
|
@ -822,7 +830,7 @@ export default function CustomModelsSection({
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 pb-0.5">
|
||||
<div className="col-span-2 flex flex-wrap items-center gap-2 pb-0.5 lg:shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveEdit(model.id!)}
|
||||
|
|
|
|||
|
|
@ -528,11 +528,12 @@ export default function ModelCompatPopover({
|
|||
type="button"
|
||||
onClick={handleToggleOpen}
|
||||
disabled={disabled}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
|
||||
className="inline-flex items-center gap-1.5 px-1.5 py-1.5 text-xs font-medium rounded-lg border sm:px-2.5 border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
|
||||
title={t("compatAdjustmentsTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-base leading-none">tune</span>
|
||||
{!compact && t("compatButtonLabel")}
|
||||
{/* Phones: icon only (the title keeps the label), so a model card fits in two lines. */}
|
||||
{!compact && <span className="hidden sm:inline">{t("compatButtonLabel")}</span>}
|
||||
</button>
|
||||
{open &&
|
||||
typeof document !== "undefined" &&
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ function getModelSourceBadgeClass(source?: string): string {
|
|||
export function ModelSourceBadge({ source }: { source?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getModelSourceBadgeClass(
|
||||
className={`shrink-0 whitespace-nowrap rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getModelSourceBadgeClass(
|
||||
source
|
||||
)}`}
|
||||
>
|
||||
|
|
@ -124,7 +124,7 @@ export function ModelVisibilityToolbar({
|
|||
}: ModelVisibilityToolbarProps) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<div className="relative w-full min-w-0 flex-1 sm:w-auto sm:min-w-[220px]">
|
||||
<span className="material-symbols-outlined pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-[15px] text-text-muted">
|
||||
search
|
||||
</span>
|
||||
|
|
@ -137,7 +137,7 @@ export function ModelVisibilityToolbar({
|
|||
/>
|
||||
</div>
|
||||
{visibilityFilter !== undefined && onVisibilityFilterChange && (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
|
||||
<div className="flex w-full items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5 sm:w-auto [&>button]:grow sm:[&>button]:grow-0">
|
||||
{(["all", "visible", "hidden"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
|
|
@ -158,7 +158,7 @@ export function ModelVisibilityToolbar({
|
|||
</div>
|
||||
)}
|
||||
{freeFilter !== undefined && onFreeFilterChange && (
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5">
|
||||
<div className="flex w-full items-center gap-1 rounded-lg border border-border bg-sidebar/50 p-0.5 sm:w-auto [&>button]:grow sm:[&>button]:grow-0">
|
||||
{(["all", "free", "paid"] as const).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
|
|
@ -347,26 +347,36 @@ export default function ModelRow({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-[220px] max-w-md items-center gap-2 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50 transition-opacity ${
|
||||
className={`relative flex h-full w-full min-w-0 flex-col gap-1.5 rounded-lg border border-border px-3 py-2 hover:bg-sidebar/50 transition-opacity ${
|
||||
isHidden ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
{/* Line 1: icon + full model name (wraps, never cut); copy pinned to the top-right corner. */}
|
||||
<div className="flex min-w-0 items-start gap-2 pr-7">
|
||||
<span
|
||||
className="material-symbols-outlined shrink-0 text-base"
|
||||
className="material-symbols-outlined mt-0.5 shrink-0 text-base"
|
||||
style={{ color: isHidden ? "var(--color-text-muted)" : undefined }}
|
||||
>
|
||||
smart_toy
|
||||
</span>
|
||||
<code className="rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted">
|
||||
<code className="min-w-0 break-all pt-px font-mono text-xs leading-5 text-text-main">
|
||||
{fullModel}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
{/* Line 2 (never wraps): source, alias (truncates) and the actions, in the name's column. */}
|
||||
<div className="flex min-w-0 items-center gap-2 pl-6">
|
||||
<ModelSourceBadge source={model.source} />
|
||||
{endpointInfo && (
|
||||
<ModelEndpointTagsEditor provider={provider} modelId={model.id} t={t} {...endpointInfo} />
|
||||
)}
|
||||
{onSetAlias && (
|
||||
<span className="flex min-w-0 items-center text-[9px] gap-1">
|
||||
<span className="flex min-w-0 flex-1 items-center text-[10px] gap-1">
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
|
|
@ -380,7 +390,7 @@ export default function ModelRow({
|
|||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`truncate text-[9px] italic cursor-pointer hover:text-primary transition-colors ${alias ? "text-primary/80" : "text-text-muted/70"}`}
|
||||
className={`min-w-0 truncate text-[10px] italic cursor-pointer hover:text-primary transition-colors ${alias ? "text-primary/80" : "text-text-muted/70"}`}
|
||||
onClick={startEditing}
|
||||
title={
|
||||
alias
|
||||
|
|
@ -395,96 +405,93 @@ export default function ModelRow({
|
|||
)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
title={t("copyModel")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{onToggleHidden && testStatus === "error" && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(model.id, true)}
|
||||
disabled={togglingHidden}
|
||||
className={`flex items-center gap-1 rounded p-0.5 text-[11px] text-red-500 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${togglingHidden ? "animate-pulse" : ""}`}
|
||||
title={providerText(
|
||||
t,
|
||||
"blacklistModelTooltip",
|
||||
"Blacklist this model — hide it from the catalog"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{togglingHidden ? "progress_activity" : "block"}
|
||||
</span>
|
||||
<span>{providerText(t, "blacklistModel", "Blacklist")}</span>
|
||||
</button>
|
||||
)}
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(model.id, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "quota" ? "text-amber-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "quota"
|
||||
? t("modelTestQuotaTooltip")
|
||||
: testStatus === "error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{onToggleHidden && testStatus === "error" && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(model.id, true)}
|
||||
disabled={togglingHidden}
|
||||
className={`flex items-center gap-1 rounded p-0.5 text-[11px] text-red-500 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${togglingHidden ? "animate-pulse" : ""}`}
|
||||
title={providerText(
|
||||
t,
|
||||
"blacklistModelTooltip",
|
||||
"Blacklist this model — hide it from the catalog"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{togglingHidden ? "progress_activity" : "block"}
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "quota" ? (
|
||||
<span className="material-symbols-outlined text-sm">warning</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(model.id, !isHidden)}
|
||||
disabled={togglingHidden}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={
|
||||
isHidden
|
||||
? providerText(t, "showModel", "Show model")
|
||||
: providerText(t, "hideModel", "Hide model")
|
||||
<span>{providerText(t, "blacklistModel", "Blacklist")}</span>
|
||||
</button>
|
||||
)}
|
||||
{onTestModel && (
|
||||
<button
|
||||
onClick={() => onTestModel(model.id, fullModel)}
|
||||
disabled={testingModel}
|
||||
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "quota" ? "text-amber-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
|
||||
title={
|
||||
testingModel
|
||||
? t("testingModel")
|
||||
: testStatus === "ok"
|
||||
? "OK"
|
||||
: testStatus === "quota"
|
||||
? t("modelTestQuotaTooltip")
|
||||
: testStatus === "error"
|
||||
? providerText(t, "errorShort", "Error")
|
||||
: t("testModel")
|
||||
}
|
||||
>
|
||||
{testingModel ? (
|
||||
<span className="material-symbols-outlined text-sm animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
) : testStatus === "ok" ? (
|
||||
<span className="material-symbols-outlined text-sm">check_circle</span>
|
||||
) : testStatus === "quota" ? (
|
||||
<span className="material-symbols-outlined text-sm">warning</span>
|
||||
) : testStatus === "error" ? (
|
||||
<span className="material-symbols-outlined text-sm">error</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined text-sm">play_circle</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
onClick={() => onToggleHidden(model.id, !isHidden)}
|
||||
disabled={togglingHidden}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={
|
||||
isHidden
|
||||
? providerText(t, "showModel", "Show model")
|
||||
: providerText(t, "hideModel", "Hide model")
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{isHidden ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={provider}
|
||||
modelId={model.id}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{isHidden ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ModelCompatPopover
|
||||
t={t}
|
||||
providerId={provider}
|
||||
modelId={model.id}
|
||||
effectiveModelNormalize={(p) => effectiveModelNormalize(model.id, p)}
|
||||
effectiveModelPreserveDeveloper={(p) => effectiveModelPreserveDeveloper(model.id, p)}
|
||||
getUpstreamHeadersRecord={getUpstreamHeadersRecord}
|
||||
onCompatPatch={(protocol, payload) =>
|
||||
saveModelCompatFlags(model.id, { compatByProtocol: { [protocol]: payload } })
|
||||
}
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
showDeveloperToggle={showDeveloperToggle}
|
||||
disabled={compatDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Line 3: endpoint capability tags (#16929) wrap freely, so line 2 stays one row. */}
|
||||
{endpointInfo && (
|
||||
<div className="min-w-0 pl-6">
|
||||
<ModelEndpointTagsEditor provider={provider} modelId={model.id} t={t} {...endpointInfo} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* Never imports from ProviderDetailPageClient.
|
||||
*/
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Badge } from "@/shared/components";
|
||||
import { Badge, CapabilityBadge } from "@/shared/components";
|
||||
import { parseContextWindowOverrideInput, providerText } from "../providerPageHelpers";
|
||||
import ModelCompatPopover from "./ModelCompatPopover";
|
||||
import ModelEndpointTagsEditor, { type ModelEndpointEditorInfo } from "./ModelEndpointTagsEditor";
|
||||
|
|
@ -260,15 +260,16 @@ export default function PassthroughModelRow({
|
|||
{providerText(t, "freeBadge", "Free")}
|
||||
</Badge>
|
||||
)}
|
||||
{/* #14337: the same 🪟 badge custom rows carry, so an override set here
|
||||
is visible after reload instead of being write-only. */}
|
||||
{/* #14337: the same context-window badge custom rows carry, so an override set
|
||||
here is visible after reload instead of being write-only. */}
|
||||
{typeof contextWindowOverride === "number" && !editingContext && (
|
||||
<span
|
||||
className="shrink-0 rounded-full bg-orange-500/15 px-1.5 py-0.5 text-[10px] font-medium text-orange-400"
|
||||
<CapabilityBadge
|
||||
icon="width_normal"
|
||||
className="shrink-0 bg-orange-500/15 text-orange-400"
|
||||
title={t("contextWindowOverrideHint")}
|
||||
>
|
||||
{`🪟 ${contextWindowOverride.toLocaleString()}`}
|
||||
</span>
|
||||
{contextWindowOverride.toLocaleString()}
|
||||
</CapabilityBadge>
|
||||
)}
|
||||
{editingContext && (
|
||||
<span className="flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/shared/components";
|
||||
import { ActionBar, AutoGrid, Button } from "@/shared/components";
|
||||
import { matchesModelCatalogQuery } from "@/shared/utils/modelCatalogSearch";
|
||||
import { isFreeModel, sortModelsFreeFirst } from "@/shared/utils/freeModels";
|
||||
import {
|
||||
|
|
@ -271,10 +271,10 @@ export default function ProviderModelsSection({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ActionBar align="start" className="mb-4">
|
||||
{modelDiscoveryControls}
|
||||
{clearAllButton}
|
||||
</div>
|
||||
</ActionBar>
|
||||
<CompatibleModelsSection
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
|
|
@ -340,7 +340,7 @@ export default function ProviderModelsSection({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ActionBar align="start" className="mb-4">
|
||||
{allowModelImport && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
@ -357,7 +357,7 @@ export default function ProviderModelsSection({
|
|||
{allowModelImport && !canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
</ActionBar>
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
|
|
@ -401,7 +401,7 @@ export default function ProviderModelsSection({
|
|||
}
|
||||
|
||||
const importButton = allowModelImport ? (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ActionBar align="start" className="mb-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
|
@ -415,7 +415,7 @@ export default function ProviderModelsSection({
|
|||
{!canImportModels && (
|
||||
<span className="text-xs text-text-muted">{t("addConnectionToImport")}</span>
|
||||
)}
|
||||
</div>
|
||||
</ActionBar>
|
||||
) : null;
|
||||
|
||||
if (models.length === 0) {
|
||||
|
|
@ -510,7 +510,7 @@ export default function ProviderModelsSection({
|
|||
onSortFreeFirstChange={setSortFreeFirst}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<AutoGrid>
|
||||
{displayModels.map((model) => {
|
||||
return (
|
||||
<ModelRow
|
||||
|
|
@ -563,13 +563,13 @@ export default function ProviderModelsSection({
|
|||
);
|
||||
})}
|
||||
{filteredModels.length === 0 && modelFilter && (
|
||||
<p className="text-sm text-text-muted py-2">
|
||||
<p className="col-span-full text-sm text-text-muted py-2">
|
||||
{providerText(t, "noModelsMatch", `No models match "${modelFilter}"`, {
|
||||
filter: modelFilter,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AutoGrid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function ProviderQuotaVisibilityToggle({
|
|||
|
||||
return (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<span className="hidden text-text-muted/30 select-none sm:inline">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(!visible)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ProviderCardGrid } from "@/shared/components";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import type { ProviderEntry } from "../providerPageUtils";
|
||||
import ProviderCard from "./ProviderCard";
|
||||
|
|
@ -109,7 +110,7 @@ export default function NoAuthProvidersSection({
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("noAuthProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{visibleEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<ProviderCard
|
||||
key={providerId}
|
||||
|
|
@ -120,13 +121,13 @@ export default function NoAuthProvidersSection({
|
|||
onToggle={(active) => onToggleProvider(providerId, toggleAuthType, active)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
{blockedEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-text-muted">
|
||||
{t("disabled")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{blockedEntries.map(({ providerId, provider }) => (
|
||||
<div
|
||||
key={providerId}
|
||||
|
|
@ -151,7 +152,7 @@ export default function NoAuthProvidersSection({
|
|||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export default function ProviderDisplayModeControl({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center rounded-lg border border-border bg-bg-subtle p-0.5"
|
||||
className="flex w-full items-center rounded-lg border border-border bg-bg-subtle p-0.5 sm:w-auto"
|
||||
role="radiogroup"
|
||||
aria-label={providerText(t, "providerDisplayMode", "Provider display mode")}
|
||||
data-testid="provider-display-mode-control"
|
||||
|
|
@ -92,7 +92,7 @@ export default function ProviderDisplayModeControl({
|
|||
title={option.title}
|
||||
data-testid={`provider-display-mode-${option.mode}`}
|
||||
data-active={isActive ? "true" : "false"}
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors ${
|
||||
className={`inline-flex h-7 grow items-center justify-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors sm:grow-0 ${
|
||||
isActive
|
||||
? "bg-bg-primary text-text-main shadow-sm"
|
||||
: "text-text-muted hover:bg-bg-primary/70 hover:text-text-main"
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export default function ProviderSummaryCard({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2 [&>button]:grow [&>button]:justify-center sm:[&>button]:grow-0">
|
||||
{categories.map((cat) => {
|
||||
const isActive =
|
||||
(cat.key === null && !activeCategory && !showFreeOnly) ||
|
||||
|
|
@ -278,7 +278,7 @@ export default function ProviderSummaryCard({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2">
|
||||
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2 [&>button]:grow [&>button]:justify-center sm:[&>button]:grow-0">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-text-muted mr-1">
|
||||
{providerText(t, "filterByMedia", "Media")}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, Suspense } from "react";
|
||||
import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components";
|
||||
import { Card, CardSkeleton, Button, ProviderCardGrid } from "@/shared/components";
|
||||
import {
|
||||
AGGREGATOR_PROVIDER_IDS,
|
||||
EMBEDDING_RERANK_PROVIDER_IDS,
|
||||
|
|
@ -1073,7 +1073,7 @@ function ProvidersPageContent() {
|
|||
<span>{t("noCompatibleYet")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{compatibleProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1088,7 +1088,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1146,7 +1146,7 @@ function ProvidersPageContent() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("oauthProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{oauthProviderEntries
|
||||
.filter((e) => !IDE_PROVIDER_IDS.has(e.providerId))
|
||||
.map(({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
|
|
@ -1161,7 +1161,7 @@ function ProvidersPageContent() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1205,7 +1205,7 @@ function ProvidersPageContent() {
|
|||
{t("noIdeProviders") || "No IDE providers match the current filters."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{ideProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1220,7 +1220,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1256,7 +1256,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("webCookieProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{webCookieProviderEntries.map(
|
||||
({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1271,7 +1271,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1305,7 +1305,7 @@ function ProvidersPageContent() {
|
|||
{testingMode === "free" ? t("testing") : t("testAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{freeSectionEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1320,7 +1320,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1358,7 +1358,7 @@ function ProvidersPageContent() {
|
|||
<h3 className="text-xs font-semibold uppercase tracking-wider text-text-muted">
|
||||
{t("llmProviders")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{llmProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1373,7 +1373,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1427,7 +1427,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("upstreamProxyProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
key={providerId}
|
||||
|
|
@ -1440,7 +1440,7 @@ function ProvidersPageContent() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1458,7 +1458,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("webFetchProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{visibleWebFetchEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1473,7 +1473,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1491,7 +1491,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("aggregatorsGatewaysDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{aggregatorProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1506,7 +1506,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1524,7 +1524,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("enterpriseCloudDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{enterpriseProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1539,7 +1539,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1574,7 +1574,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("cloudAgentProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{cloudAgentProviderEntries.map(
|
||||
({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1589,7 +1589,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1624,7 +1624,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("localProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
key={providerId}
|
||||
|
|
@ -1637,7 +1637,7 @@ function ProvidersPageContent() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1672,7 +1672,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("searchProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
key={providerId}
|
||||
|
|
@ -1685,7 +1685,7 @@ function ProvidersPageContent() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1703,7 +1703,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("embeddingRerankProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{embeddingRerankProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1718,7 +1718,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1736,7 +1736,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("imageProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{imageProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1751,7 +1751,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1786,7 +1786,7 @@ function ProvidersPageContent() {
|
|||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("audioProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
key={providerId}
|
||||
|
|
@ -1799,7 +1799,7 @@ function ProvidersPageContent() {
|
|||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -1817,7 +1817,7 @@ function ProvidersPageContent() {
|
|||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted -mt-2">{t("videoProvidersDesc")}</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">
|
||||
<ProviderCardGrid>
|
||||
{videoProviderEntries.map(
|
||||
({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
|
||||
<HighlightableProviderCard
|
||||
|
|
@ -1832,7 +1832,7 @@ function ProvidersPageContent() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ProviderCardGrid>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -23,11 +23,26 @@ interface ConceptItem {
|
|||
}
|
||||
|
||||
const CONCEPTS: ConceptItem[] = [
|
||||
{ icon: "🔍", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" },
|
||||
{ icon: "📄", key: "scrape", titleKey: "scrapeConceptTitle", descKey: "scrapeConceptDesc" },
|
||||
{ icon: "⚖", key: "compare", titleKey: "compareConceptTitle", descKey: "compareConceptDesc" },
|
||||
{ icon: "↕", key: "rerank", titleKey: "rerankConceptTitle", descKey: "rerankConceptDesc" },
|
||||
{ icon: "⚡", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" },
|
||||
{ icon: "search", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" },
|
||||
{
|
||||
icon: "description",
|
||||
key: "scrape",
|
||||
titleKey: "scrapeConceptTitle",
|
||||
descKey: "scrapeConceptDesc",
|
||||
},
|
||||
{
|
||||
icon: "balance",
|
||||
key: "compare",
|
||||
titleKey: "compareConceptTitle",
|
||||
descKey: "compareConceptDesc",
|
||||
},
|
||||
{
|
||||
icon: "swap_vert",
|
||||
key: "rerank",
|
||||
titleKey: "rerankConceptTitle",
|
||||
descKey: "rerankConceptDesc",
|
||||
},
|
||||
{ icon: "bolt", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" },
|
||||
];
|
||||
|
||||
interface SearchConceptCardProps {
|
||||
|
|
@ -73,7 +88,10 @@ export default function SearchConceptCard({ defaultCollapsed = false }: SearchCo
|
|||
className="flex gap-3 p-3 bg-bg-alt rounded-lg border border-border"
|
||||
data-testid={`concept-item-${c.key}`}
|
||||
>
|
||||
<span className="text-lg shrink-0" aria-hidden="true">
|
||||
<span
|
||||
className="material-symbols-outlined shrink-0 text-[20px] text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{c.icon}
|
||||
</span>
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -15,11 +15,12 @@ interface SearchToolsTopBarProps {
|
|||
exportState?: PlaygroundState;
|
||||
}
|
||||
|
||||
const TABS: { id: ActiveTab; icon: string; labelKey: "tabSearch" | "tabScrape" | "tabCompare" }[] = [
|
||||
{ id: "search", icon: "🔍", labelKey: "tabSearch" },
|
||||
{ id: "scrape", icon: "📄", labelKey: "tabScrape" },
|
||||
{ id: "compare", icon: "⚖", labelKey: "tabCompare" },
|
||||
];
|
||||
const TABS: { id: ActiveTab; icon: string; labelKey: "tabSearch" | "tabScrape" | "tabCompare" }[] =
|
||||
[
|
||||
{ id: "search", icon: "search", labelKey: "tabSearch" },
|
||||
{ id: "scrape", icon: "description", labelKey: "tabScrape" },
|
||||
{ id: "compare", icon: "balance", labelKey: "tabCompare" },
|
||||
];
|
||||
|
||||
export default function SearchToolsTopBar({
|
||||
activeTab,
|
||||
|
|
@ -56,7 +57,9 @@ export default function SearchToolsTopBar({
|
|||
onClick={() => onTabChange(tab.id)}
|
||||
data-testid={`tab-${tab.id}`}
|
||||
>
|
||||
<span aria-hidden="true">{tab.icon}</span>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
{tab.icon}
|
||||
</span>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export default function ProviderAccountRoutingCard({ providerKey, connectionCoun
|
|||
<p className="text-xs text-text-muted mb-3">{t("providerAccountRoutingDesc")}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Select
|
||||
compact
|
||||
label={t("providerRoutingStrategy")}
|
||||
disabled={busy}
|
||||
value={strategy}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,11 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
|
|||
fetchTproxyStatus()
|
||||
.then((s) => {
|
||||
if (alive) {
|
||||
setTproxy({ running: s.running, available: s.available, interceptCount: s.interceptCount });
|
||||
setTproxy({
|
||||
running: s.running,
|
||||
available: s.available,
|
||||
interceptCount: s.interceptCount,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -134,7 +138,14 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
|
|||
)}
|
||||
/>
|
||||
{label}
|
||||
{warn && <span className="text-amber-400">⚠</span>}
|
||||
{warn && (
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] leading-none text-amber-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
warning
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
@ -162,7 +173,12 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
|
|||
{tproxy.running && typeof tproxy.interceptCount === "number" && (
|
||||
<span className="text-amber-400">· {tproxy.interceptCount}</span>
|
||||
)}
|
||||
<span className="text-amber-400">⚠</span>
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] leading-none text-amber-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
warning
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
|
|
@ -171,7 +187,10 @@ export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProp
|
|||
onClick={() => setShowHosts(true)}
|
||||
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
|
||||
>
|
||||
⚙ {t("manageHosts")}
|
||||
<span className="material-symbols-outlined text-[14px] leading-none" aria-hidden="true">
|
||||
settings
|
||||
</span>{" "}
|
||||
{t("manageHosts")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -76,7 +76,13 @@ export function RequestRow({ request, selected, onClick, onSameContext, style }:
|
|||
className="text-text-muted shrink-0 font-mono opacity-70 truncate max-w-[120px]"
|
||||
title={request.pid ? `PID ${request.pid}` : undefined}
|
||||
>
|
||||
⚙ {request.processName}
|
||||
<span
|
||||
className="material-symbols-outlined text-[14px] leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
settings
|
||||
</span>{" "}
|
||||
{request.processName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
the layout. Bundling the font locally removes that runtime CDN dependency.
|
||||
The bundled @font-face uses family "Material Symbols Outlined", matching the
|
||||
.material-symbols-outlined rule defined later in this file. */
|
||||
@import "material-symbols/outlined.css";
|
||||
/* Layered as base so Tailwind size utilities (text-sm, text-[13px]…) on an icon win;
|
||||
unlayered, the package's `font-size: 24px` beat every utility and all icons rendered at 24px. */
|
||||
@import "material-symbols/outlined.css" layer(base);
|
||||
/* react18-json-view (request/response JSON tree in RequestLoggerDetail) — imported here
|
||||
instead of in the component so node:test / esbuild never see a .css import. */
|
||||
@import "react18-json-view/src/style.css";
|
||||
|
|
@ -369,27 +371,70 @@ body.electron-macos {
|
|||
}
|
||||
|
||||
/* Material Symbols */
|
||||
:where(.material-symbols-outlined) {
|
||||
font-family: "Material Symbols Outlined", sans-serif !important;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-feature-settings: "liga";
|
||||
-webkit-font-feature-settings: "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings:
|
||||
"FILL" 0,
|
||||
"wght" 400,
|
||||
"GRAD" 0,
|
||||
"opsz" 24;
|
||||
user-select: none;
|
||||
@layer base {
|
||||
:where(.material-symbols-outlined) {
|
||||
font-family: "Material Symbols Outlined", sans-serif !important;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
font-feature-settings: "liga";
|
||||
-webkit-font-feature-settings: "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-variation-settings:
|
||||
"FILL" 0,
|
||||
"wght" 400,
|
||||
"GRAD" 0,
|
||||
"opsz" 24;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones: any wrapping row of 3+ buttons, and any row made only of 3+ buttons (tabs,
|
||||
filters, toolbars), stretches its buttons to fill each line instead of leaving a ragged
|
||||
gap on the right. Same behaviour as the shared ActionBar / FilterPills; a component
|
||||
opts out with `grow-0` on the button. */
|
||||
@layer components {
|
||||
@media (max-width: 639px) {
|
||||
main .flex-wrap:has(> button:nth-of-type(3)) > button,
|
||||
main .flex:has(> button:nth-of-type(3)):not(:has(> :not(button))) > button {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones: page and section titles stay on one line (ellipsis instead of wrapping), and a
|
||||
header row that starts with the title drops its actions to the line below instead of
|
||||
squeezing (or overlapping) the title. */
|
||||
@layer components {
|
||||
@media (max-width: 639px) {
|
||||
main :is(h1, h2) {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
main
|
||||
.flex:not(.flex-col):has(
|
||||
> :is(h1, h2):first-child,
|
||||
> div:first-child > :is(h1, h2):first-child
|
||||
) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
main .flex:not(.flex-col) > :is(h1, h2):first-child,
|
||||
main .flex:not(.flex-col) > div:first-child:has(> :is(h1, h2):first-child) {
|
||||
/* important: a `flex-1` (basis 0) on the title would otherwise squeeze it to nothing */
|
||||
flex-basis: 100% !important;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure icon clicks always bubble to the parent button */
|
||||
|
|
|
|||
29
src/shared/components/ActionBar.tsx
Normal file
29
src/shared/components/ActionBar.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface ActionBarProps {
|
||||
children: ReactNode;
|
||||
/** Alignment from `sm` up; on phones the buttons always fill the line. */
|
||||
align?: "start" | "end";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row of action buttons. On narrow screens the buttons grow to fill each line and
|
||||
* wrap; from `sm` up the bar keeps its natural width.
|
||||
*/
|
||||
export default function ActionBar({ children, align = "end", className }: ActionBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 [&>*]:grow [&>*]:justify-center sm:[&>*]:grow-0",
|
||||
align === "end" ? "sm:justify-end" : "sm:justify-start",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/shared/components/AutoGrid.tsx
Normal file
29
src/shared/components/AutoGrid.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface AutoGridProps {
|
||||
children: ReactNode;
|
||||
/** Minimum card width from `sm` up; columns fill the row with equal widths. */
|
||||
min?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symmetric card grid: one column on phones, then as many equal columns as fit
|
||||
* (`repeat(auto-fill, minmax(min, 1fr))`); cards in a row share the same height.
|
||||
*/
|
||||
export default function AutoGrid({ children, min = "260px", className }: AutoGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3 sm:[grid-template-columns:repeat(auto-fill,minmax(var(--auto-grid-min),1fr))]",
|
||||
className
|
||||
)}
|
||||
style={{ "--auto-grid-min": min } as CSSProperties}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/shared/components/CapabilityBadge.tsx
Normal file
36
src/shared/components/CapabilityBadge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface CapabilityBadgeProps {
|
||||
/** Material Symbols name shown before the label (replaces the old emoji prefixes). */
|
||||
icon?: string;
|
||||
/** Tone classes, e.g. "bg-pink-500/15 text-pink-400". */
|
||||
className?: string;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Small pill describing a model capability (vision, images, audio…). */
|
||||
export default function CapabilityBadge({
|
||||
icon,
|
||||
className,
|
||||
title,
|
||||
children,
|
||||
}: CapabilityBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span className="material-symbols-outlined text-[12px] leading-none" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
65
src/shared/components/FilterPills.tsx
Normal file
65
src/shared/components/FilterPills.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export interface FilterPillOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface FilterPillsProps {
|
||||
options: FilterPillOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter chips that wrap instead of overflowing. On narrow screens each line is
|
||||
* stretched to the full width (pills grow), from `sm` up they keep their natural size.
|
||||
*/
|
||||
export default function FilterPills({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
"aria-label": ariaLabel,
|
||||
}: FilterPillsProps) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("flex w-full flex-wrap items-center gap-1.5 sm:w-auto", className)}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const active = value === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={cn(
|
||||
"inline-flex grow items-center justify-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium transition-colors sm:grow-0",
|
||||
active ? "bg-primary text-white" : "bg-muted/60 text-text-muted hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{opt.icon && (
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{opt.icon}
|
||||
</span>
|
||||
)}
|
||||
{opt.label}
|
||||
{opt.count !== undefined && (
|
||||
<span className={active ? "text-white/80" : "text-text-muted/70"}>{opt.count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -7,11 +7,13 @@ import { cn } from "@/shared/utils/cn";
|
|||
type SpinnerSize = "sm" | "md" | "lg" | "xl";
|
||||
type LoadingType = "spinner" | "page" | "skeleton" | "card";
|
||||
|
||||
// Box and glyph share one size so the icon spins around its own centre (a 24px glyph in a
|
||||
// 16px box rotated around the box centre and wobbled).
|
||||
const spinnerSizes: Record<SpinnerSize, string> = {
|
||||
sm: "size-4",
|
||||
md: "size-6",
|
||||
lg: "size-8",
|
||||
xl: "size-12",
|
||||
sm: "size-4 text-[16px]",
|
||||
md: "size-6 text-[24px]",
|
||||
lg: "size-8 text-[32px]",
|
||||
xl: "size-12 text-[48px]",
|
||||
};
|
||||
|
||||
interface SpinnerProps {
|
||||
|
|
@ -52,7 +54,7 @@ export function Spinner({ size = "md", className, label }: SpinnerProps) {
|
|||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"material-symbols-outlined text-primary animate-spin motion-reduce:animate-none",
|
||||
"material-symbols-outlined inline-flex items-center justify-center leading-none text-primary animate-spin motion-reduce:animate-none",
|
||||
spinnerSizes[size]
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
24
src/shared/components/ProviderCardGrid.tsx
Normal file
24
src/shared/components/ProviderCardGrid.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
/** Grid of provider cards: one column on phones, two from 480px, then 3 and 4. */
|
||||
export default function ProviderCardGrid({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-3 min-[480px]:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/shared/components/ReorderControl.tsx
Normal file
58
src/shared/components/ReorderControl.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface ReorderControlProps {
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
upLabel?: string;
|
||||
downLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Compact up/down pair used to change an item's priority in a list. */
|
||||
export default function ReorderControl({
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
upLabel = "Move up",
|
||||
downLabel = "Move down",
|
||||
className,
|
||||
}: ReorderControlProps) {
|
||||
const btn = (disabled: boolean) =>
|
||||
cn(
|
||||
"flex size-5 items-center justify-center rounded",
|
||||
disabled
|
||||
? "cursor-not-allowed text-text-muted/30"
|
||||
: "text-text-muted hover:bg-sidebar hover:text-primary"
|
||||
);
|
||||
return (
|
||||
<div className={cn("flex shrink-0 flex-col", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
aria-label={upLabel}
|
||||
className={btn(isFirst)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] leading-none">
|
||||
keyboard_arrow_up
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
aria-label={downLabel}
|
||||
className={btn(isLast)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] leading-none">
|
||||
keyboard_arrow_down
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,7 +36,8 @@ export default function SegmentedControl({
|
|||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"inline-flex items-center p-1 rounded-lg",
|
||||
// Phones: full width, segments share the line and wrap; from sm up, natural width.
|
||||
"flex w-full flex-wrap items-center p-1 rounded-lg sm:inline-flex sm:w-auto",
|
||||
"bg-black/5 dark:bg-white/5",
|
||||
className
|
||||
)}
|
||||
|
|
@ -49,12 +50,11 @@ export default function SegmentedControl({
|
|||
tabIndex={value === option.value ? 0 : -1}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"px-4 rounded-md font-medium transition-all",
|
||||
"grow px-3 rounded-md font-medium transition-all whitespace-nowrap flex items-center justify-center sm:grow-0 sm:px-4",
|
||||
sizes[size],
|
||||
value === option.value
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main",
|
||||
option.icon && "flex items-center",
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
{option.icon && (
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>
|
|||
selectClassName?: string;
|
||||
/** Keep the placeholder selectable after a real value is chosen. */
|
||||
placeholderDisabled?: boolean;
|
||||
/** Label and select on one line, with a shorter select (settings rows, cards). */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
|
|
@ -33,6 +35,7 @@ export default function Select({
|
|||
className,
|
||||
selectClassName,
|
||||
placeholderDisabled = true,
|
||||
compact = false,
|
||||
id: externalId,
|
||||
children,
|
||||
...props
|
||||
|
|
@ -45,9 +48,19 @@ export default function Select({
|
|||
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
compact
|
||||
? "flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
|
||||
: "flex flex-col gap-1.5",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<label htmlFor={selectId} className="text-sm font-medium text-text-main">
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className={cn("font-medium text-text-main", compact ? "text-xs" : "text-sm")}
|
||||
>
|
||||
{label}
|
||||
{required && (
|
||||
<span className="text-red-500 ml-1" aria-hidden="true">
|
||||
|
|
@ -56,7 +69,7 @@ export default function Select({
|
|||
)}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<div className={cn("relative", compact && "min-w-0 flex-1 sm:flex-none")}>
|
||||
<select
|
||||
id={selectId}
|
||||
value={value}
|
||||
|
|
@ -67,7 +80,9 @@ export default function Select({
|
|||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={describedBy}
|
||||
className={cn(
|
||||
"w-full py-2 px-3 pe-10 text-sm text-text-main",
|
||||
compact
|
||||
? "w-full py-1.5 ps-2.5 pe-8 text-sm text-text-main"
|
||||
: "w-full py-2 px-3 pe-10 text-sm text-text-main",
|
||||
"bg-surface border border-black/10 dark:border-white/10 rounded-control appearance-none",
|
||||
"focus:ring-1 focus:ring-accent/30 focus:border-accent/50 focus:outline-none",
|
||||
"transition-all disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function Toggle({
|
|||
xs: {
|
||||
track: "w-6 h-3",
|
||||
thumb: "size-[8px]",
|
||||
translate: "translate-x-3.5",
|
||||
translate: "translate-x-3",
|
||||
},
|
||||
sm: {
|
||||
track: "w-8 h-4",
|
||||
|
|
@ -71,7 +71,7 @@ export default function Toggle({
|
|||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"relative inline-flex shrink-0 cursor-pointer rounded-full",
|
||||
"relative inline-flex shrink-0 cursor-pointer items-center rounded-full",
|
||||
"transition-colors duration-200 ease-in-out",
|
||||
"border shadow-inner",
|
||||
"focus:outline-none focus:ring-1 focus:ring-accent/30",
|
||||
|
|
@ -86,8 +86,7 @@ export default function Toggle({
|
|||
"pointer-events-none inline-block rounded-full bg-white shadow-sm",
|
||||
"transform transition duration-200 ease-in-out",
|
||||
checked ? sizes[size].translate : "translate-x-0.5",
|
||||
sizes[size].thumb,
|
||||
"mt-0.5"
|
||||
sizes[size].thumb
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
|
|||
export { default as CursorAuthModal } from "./CursorAuthModal";
|
||||
export { default as TraeAuthModal } from "./TraeAuthModal";
|
||||
export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as FilterPills } from "./FilterPills";
|
||||
export { default as ActionBar } from "./ActionBar";
|
||||
export { default as ReorderControl } from "./ReorderControl";
|
||||
export { default as ProviderCardGrid } from "./ProviderCardGrid";
|
||||
export { default as CapabilityBadge } from "./CapabilityBadge";
|
||||
export { default as AutoGrid } from "./AutoGrid";
|
||||
export { default as Breadcrumbs } from "./Breadcrumbs";
|
||||
export { default as EmptyState } from "./EmptyState";
|
||||
export { default as NotificationToast } from "./NotificationToast";
|
||||
|
|
|
|||
|
|
@ -38,8 +38,12 @@ const rowFile = path.join(
|
|||
test("ConnectionRow action-icon wrapper must use an RTL-mirroring (logical) spacing utility, not a physical one", () => {
|
||||
const source = readFileSync(rowFile, "utf8");
|
||||
|
||||
// The action-icon wrapper is the div that directly holds the retest button
|
||||
// (the first icon of the cluster); match it by that anchor rather than by its
|
||||
// exact class list, so layout changes keep the RTL check meaningful. The tip's
|
||||
// ConnectionTestButton (send-a-test-message) may sit first in the same cluster.
|
||||
const match = source.match(
|
||||
/<div className="flex gap-1 ([^"]*)transition-opacity">/
|
||||
/<div className="([^"]*)">\s*(?:<ConnectionTestButton\b[^>]*\/>\s*)?<Button\b[^>]*?icon="refresh"/
|
||||
);
|
||||
assert.ok(match, "expected to find the action-icon wrapper div in ConnectionRow.tsx");
|
||||
|
||||
|
|
|
|||
157
tests/unit/provider-connection-row-mobile-layout.test.ts
Normal file
157
tests/unit/provider-connection-row-mobile-layout.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(
|
||||
new URL(
|
||||
"../../src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx",
|
||||
import.meta.url
|
||||
),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("provider connection actions fill the row width and wrap on mobile", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/className="flex w-full flex-wrap items-center justify-between gap-0.5 sm:gap-1 xl:justify-end \[&>button\]:grow-0"/,
|
||||
"on narrow screens the action icons must spread across the full row width and wrap instead of scrolling"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="contents"/,
|
||||
"icon-only actions must join the same flex line so they are spread with the other actions"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="inline-flex size-7 items-center justify-center/,
|
||||
"icon-only actions must use the same square touch target"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="!size-7 !p-0 sm:!w-auto sm:!px-2 text-xs"/,
|
||||
"labeled actions must collapse to the same icon-only square on narrow rows"
|
||||
);
|
||||
// PR #15532 review: the collapse is phone-only — from `sm` up the labeled actions
|
||||
// keep their visible text (Retest, Token, Apply/Export Codex/Claude auth).
|
||||
for (const label of [
|
||||
't("retest")',
|
||||
't("tokenShort")',
|
||||
"applyCodexAuthLabel",
|
||||
"exportCodexAuthLabel",
|
||||
"applyClaudeAuthLabel",
|
||||
"exportClaudeAuthLabel",
|
||||
]) {
|
||||
assert.ok(
|
||||
source.includes(`<span className="hidden sm:inline">{${label}}</span>`),
|
||||
`the ${label} action must show its text label from the sm breakpoint up`
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/className="!size-7 !p-0 text-xs[^"]*"/,
|
||||
"no labeled action may stay icon-only on desktop widths"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/className="basis-full min-w-0 xl:basis-auto"/,
|
||||
"narrow screens must give the action bar the full row width"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/w-max min-w-full|overflow-x-auto xl:basis-auto/,
|
||||
"the action bar must not fall back to a horizontally scrolling single row"
|
||||
);
|
||||
});
|
||||
|
||||
const read = (rel: string) => readFileSync(new URL(`../../${rel}`, import.meta.url), "utf8");
|
||||
const providerComponents = "src/app/(dashboard)/dashboard/providers/[id]/components";
|
||||
|
||||
test("connections toolbar and bulk actions use the shared full-width ActionBar", () => {
|
||||
// Codex keeps its start-aligned bar (#16248); every provider uses the shared ActionBar.
|
||||
assert.match(
|
||||
read(`${providerComponents}/ConnectionsHeaderToolbar.tsx`),
|
||||
/<ActionBar align=\{providerId === "codex" \? "start" : "end"\}>/
|
||||
);
|
||||
assert.match(read(`${providerComponents}/ConnectionsListPanel.tsx`), /<ActionBar>/);
|
||||
const actionBar = read("src/shared/components/ActionBar.tsx");
|
||||
assert.match(
|
||||
actionBar,
|
||||
/flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 \[&>\*\]:grow \[&>\*\]:justify-center sm:\[&>\*\]:grow-0/,
|
||||
"on narrow screens the bar buttons must grow to fill each line and wrap"
|
||||
);
|
||||
});
|
||||
|
||||
test("connection filters use the shared FilterPills that fill the line on phones", () => {
|
||||
assert.match(read(`${providerComponents}/ConnectionsListPanel.tsx`), /<FilterPills/);
|
||||
const pills = read("src/shared/components/FilterPills.tsx");
|
||||
assert.match(pills, /flex w-full flex-wrap items-center gap-1\.5 sm:w-auto/);
|
||||
assert.match(pills, /inline-flex grow items-center justify-center/);
|
||||
});
|
||||
|
||||
test("connection rows use the compact shared ReorderControl and hide separators on phones", () => {
|
||||
assert.match(source, /<ReorderControl/);
|
||||
assert.doesNotMatch(source, /<span className="text-text-muted\/30 select-none">\|<\/span>/);
|
||||
assert.match(
|
||||
read("src/shared/components/ReorderControl.tsx"),
|
||||
/flex size-5 items-center justify-center/
|
||||
);
|
||||
});
|
||||
|
||||
test("icon size utilities win over the Material Symbols base size", () => {
|
||||
const globals = read("src/app/globals.css");
|
||||
assert.match(globals, /@import "material-symbols\/outlined\.css" layer\(base\);/);
|
||||
assert.match(globals, /@layer base \{\s*:where\(\.material-symbols-outlined\) \{/);
|
||||
});
|
||||
|
||||
test("provider lists use the shared one-column-on-phones card grid", () => {
|
||||
const page = read("src/app/(dashboard)/dashboard/providers/page.tsx");
|
||||
assert.match(page, /<ProviderCardGrid>/);
|
||||
assert.doesNotMatch(
|
||||
page,
|
||||
/<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">/
|
||||
);
|
||||
assert.match(
|
||||
read("src/shared/components/ProviderCardGrid.tsx"),
|
||||
/grid grid-cols-1 gap-3 min-\[480px\]:grid-cols-2/
|
||||
);
|
||||
});
|
||||
|
||||
test("no bare pipe separators are left visible on phones in the connection row", () => {
|
||||
const quotaToggle = read(`${providerComponents}/ProviderQuotaVisibilityToggle.tsx`);
|
||||
assert.doesNotMatch(quotaToggle, /<span className="text-text-muted\/30 select-none">\|<\/span>/);
|
||||
});
|
||||
|
||||
test("custom model add and edit forms share the two-column phone grid", () => {
|
||||
const custom = read(`${providerComponents}/CustomModelsSection.tsx`);
|
||||
assert.match(custom, /const FORMAT_FIELDS_GRID = "grid grid-cols-2 items-end gap-3/);
|
||||
assert.equal(custom.match(/className=\{FORMAT_FIELDS_GRID\}/g)?.length, 2);
|
||||
assert.equal(custom.match(/className=\{ENDPOINTS_GRID\}/g)?.length, 2);
|
||||
assert.doesNotMatch(custom, /overflow-x-auto overflow-y-visible/);
|
||||
});
|
||||
|
||||
test("toggle thumb is vertically centred in its track", () => {
|
||||
const toggle = read("src/shared/components/Toggle.tsx");
|
||||
assert.match(toggle, /"relative inline-flex shrink-0 cursor-pointer items-center rounded-full"/);
|
||||
assert.doesNotMatch(toggle, /"mt-0\.5"/);
|
||||
});
|
||||
|
||||
test("provider models use the symmetric AutoGrid", () => {
|
||||
assert.match(read(`${providerComponents}/ProviderModelsSection.tsx`), /<AutoGrid>/);
|
||||
assert.match(
|
||||
read("src/shared/components/AutoGrid.tsx"),
|
||||
/repeat\(auto-fill,minmax\(var\(--auto-grid-min\),1fr\)\)/
|
||||
);
|
||||
assert.doesNotMatch(read(`${providerComponents}/ModelRow.tsx`), /max-w-md/);
|
||||
});
|
||||
|
||||
test("model card: icon + full name on top, copy pinned top-right, details below", () => {
|
||||
const row = read(`${providerComponents}/ModelRow.tsx`);
|
||||
assert.match(row, /relative flex h-full w-full min-w-0 flex-col gap-1\.5/);
|
||||
assert.match(row, /<code className="min-w-0 break-all pt-px font-mono/);
|
||||
assert.match(row, /<div className="flex min-w-0 items-center gap-2 pl-6">/);
|
||||
assert.match(
|
||||
read(`${providerComponents}/ModelCompatPopover.tsx`),
|
||||
/<span className="hidden sm:inline">\{t\("compatButtonLabel"\)\}<\/span>/
|
||||
);
|
||||
assert.match(row, /absolute right-2 top-2 rounded p-0\.5/);
|
||||
});
|
||||
|
|
@ -115,7 +115,9 @@ describe("ConnectionRow Codex account pool", () => {
|
|||
const row = container.firstElementChild as HTMLElement;
|
||||
expect(row.classList.contains("flex-col")).toBe(true);
|
||||
expect(row.classList.contains("items-stretch")).toBe(true);
|
||||
expect(row.children[1].classList.contains("flex-wrap")).toBe(true);
|
||||
// #15532: the actions live in a full-width wrapper; the wrapping flex bar is its child.
|
||||
const actionBar = row.children[1].firstElementChild as HTMLElement;
|
||||
expect(actionBar.classList.contains("flex-wrap")).toBe(true);
|
||||
|
||||
act(() => {
|
||||
(container.querySelector("button[title='edit']") as HTMLButtonElement).click();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ vi.mock("next-intl", () => ({
|
|||
|
||||
vi.mock("@/shared/components", () => ({
|
||||
Badge: ({ children, title }: any) => <span title={title}>{children}</span>,
|
||||
CapabilityBadge: ({ children, title }: any) => <span title={title}>{children}</span>,
|
||||
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue