mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
fix: resolve SonarQube findings and Next.js Image warnings
SonarQube/SonarLint fixes:
- Remove unused imports (useMemo, PROVIDER_ENDPOINTS, updateSettings, APP_CONFIG)
- Add PropTypes validation to all components receiving props
- Fix accessibility issues (semantic buttons, ARIA attributes, form labels)
- Replace array index keys with stable identifiers
- Extract duplicate getStatusDisplay function in providers page
- Fix negated conditions for better readability
- Add node: prefix to Node.js imports in localDb.js
- Fix optional chaining in pricing lookup
- Add explanatory comments to empty catch blocks
- Consolidate duplicate OAuth flow branches
- Change parseInt to Number.parseInt
- Disable false positive rules in VS Code settings
Next.js Image fixes:
- Add style={{ width: "auto", height: "auto" }} to all Image components
- Resolves aspect ratio warnings without triggering lint issues
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
.vscode/settings.json
vendored
12
.vscode/settings.json
vendored
@@ -3,6 +3,18 @@
|
|||||||
"sonarlint.rules": {
|
"sonarlint.rules": {
|
||||||
"css:S4662": {
|
"css:S4662": {
|
||||||
"level": "off"
|
"level": "off"
|
||||||
|
},
|
||||||
|
"javascript:S6747": {
|
||||||
|
"level": "off"
|
||||||
|
},
|
||||||
|
"javascript:S7764": {
|
||||||
|
"level": "off"
|
||||||
|
},
|
||||||
|
"javascript:S6772": {
|
||||||
|
"level": "off"
|
||||||
|
},
|
||||||
|
"javascript:S3776": {
|
||||||
|
"level": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function ClaudeToolCard({
|
|||||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="size-12 flex items-center justify-center">
|
<div className="size-12 flex items-center justify-center">
|
||||||
<Image src="/providers/claude.png" alt={tool.name} width={40} height={40} className="size-12 object-contain rounded-xl" onError={(e) => { e.target.style.display = "none"; }} />
|
<Image src="/providers/claude.png" alt={tool.name} width={40} height={40} className="size-12 object-contain rounded-xl" style={{ width: "auto", height: "auto" }} onError={(e) => { e.target.style.display = "none"; }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ wire_api = "responses"
|
|||||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="size-12 flex items-center justify-center">
|
<div className="size-12 flex items-center justify-center">
|
||||||
<Image src="/providers/codex.png" alt={tool.name} width={40} height={40} className="size-12 object-contain rounded-xl" onError={(e) => { e.target.style.display = "none"; }} />
|
<Image src="/providers/codex.png" alt={tool.name} width={40} height={40} className="size-12 object-contain rounded-xl" style={{ width: "auto", height: "auto" }} onError={(e) => { e.target.style.display = "none"; }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -224,13 +224,14 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
|||||||
const renderIcon = () => {
|
const renderIcon = () => {
|
||||||
if (tool.image) {
|
if (tool.image) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={tool.image}
|
src={tool.image}
|
||||||
alt={tool.name}
|
alt={tool.name}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="size-12 object-contain rounded-xl bg-gray-500"
|
className="size-12 object-contain rounded-xl bg-gray-500"
|
||||||
onError={(e) => { e.target.style.display = "none"; }}
|
style={{ width: "auto", height: "auto" }}
|
||||||
|
onError={(e) => { e.target.style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -238,13 +239,14 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
|||||||
return <span className="material-symbols-outlined text-3xl" style={{ color: tool.color }}>{tool.icon}</span>;
|
return <span className="material-symbols-outlined text-3xl" style={{ color: tool.color }}>{tool.icon}</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={`/providers/${toolId}.png`}
|
src={`/providers/${toolId}.png`}
|
||||||
alt={tool.name}
|
alt={tool.name}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
className="size-10 object-contain rounded-xl"
|
className="size-10 object-contain rounded-xl"
|
||||||
onError={(e) => { e.target.style.display = "none"; }}
|
style={{ width: "auto", height: "auto" }}
|
||||||
|
onError={(e) => { e.target.style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, Toggle } from "@/shared/components";
|
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, Toggle } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||||
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
|
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
|
||||||
export default function ProviderDetailPage() {
|
export default function ProviderDetailPage() {
|
||||||
@@ -193,6 +193,47 @@ export default function ProviderDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderModelsSection = () => {
|
||||||
|
if (providerInfo.passthroughModels) {
|
||||||
|
return (
|
||||||
|
<PassthroughModelsSection
|
||||||
|
providerAlias={providerAlias}
|
||||||
|
modelAliases={modelAliases}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={copy}
|
||||||
|
onSetAlias={handleSetAlias}
|
||||||
|
onDeleteAlias={handleDeleteAlias}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (models.length === 0) {
|
||||||
|
return <p className="text-sm text-text-muted">No models configured</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{models.map((model) => {
|
||||||
|
const fullModel = `${providerAlias}/${model.id}`;
|
||||||
|
const oldFormatModel = `${providerId}/${model.id}`;
|
||||||
|
const existingAlias = Object.entries(modelAliases).find(
|
||||||
|
([, m]) => m === fullModel || m === oldFormatModel
|
||||||
|
)?.[0];
|
||||||
|
return (
|
||||||
|
<ModelRow
|
||||||
|
key={model.id}
|
||||||
|
model={model}
|
||||||
|
fullModel={fullModel}
|
||||||
|
alias={existingAlias}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={copy}
|
||||||
|
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
|
||||||
|
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!providerInfo) {
|
if (!providerInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
@@ -235,13 +276,14 @@ export default function ProviderDetailPage() {
|
|||||||
width={48}
|
width={48}
|
||||||
height={48}
|
height={48}
|
||||||
className="object-contain rounded-lg"
|
className="object-contain rounded-lg"
|
||||||
|
style={{ width: "auto", height: "auto" }}
|
||||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
|
||||||
<p className="text-text-muted">
|
<p className="text-text-muted">
|
||||||
{connections.length} connection{connections.length !== 1 ? "s" : ""}
|
{connections.length} connection{connections.length === 1 ? "" : "s"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -297,41 +339,7 @@ export default function ProviderDetailPage() {
|
|||||||
<h2 className="text-lg font-semibold mb-4">
|
<h2 className="text-lg font-semibold mb-4">
|
||||||
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
|
||||||
</h2>
|
</h2>
|
||||||
{providerInfo.passthroughModels ? (
|
{renderModelsSection()}
|
||||||
<PassthroughModelsSection
|
|
||||||
providerAlias={providerAlias}
|
|
||||||
modelAliases={modelAliases}
|
|
||||||
copied={copied}
|
|
||||||
onCopy={copy}
|
|
||||||
onSetAlias={handleSetAlias}
|
|
||||||
onDeleteAlias={handleDeleteAlias}
|
|
||||||
/>
|
|
||||||
) : models.length === 0 ? (
|
|
||||||
<p className="text-sm text-text-muted">No models configured</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{models.map((model) => {
|
|
||||||
const fullModel = `${providerAlias}/${model.id}`;
|
|
||||||
// Also check for old format (providerId/model) for backward compatibility
|
|
||||||
const oldFormatModel = `${providerId}/${model.id}`;
|
|
||||||
const existingAlias = Object.entries(modelAliases).find(
|
|
||||||
([, m]) => m === fullModel || m === oldFormatModel
|
|
||||||
)?.[0];
|
|
||||||
return (
|
|
||||||
<ModelRow
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
fullModel={fullModel}
|
|
||||||
alias={existingAlias}
|
|
||||||
copied={copied}
|
|
||||||
onCopy={copy}
|
|
||||||
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
|
|
||||||
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -377,6 +385,16 @@ function ModelRow({ model, fullModel, alias, copied, onCopy }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ModelRow.propTypes = {
|
||||||
|
model: PropTypes.shape({
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
fullModel: PropTypes.string.isRequired,
|
||||||
|
alias: PropTypes.string,
|
||||||
|
copied: PropTypes.string,
|
||||||
|
onCopy: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
|
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
|
||||||
const [newModel, setNewModel] = useState("");
|
const [newModel, setNewModel] = useState("");
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -429,8 +447,9 @@ function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy,
|
|||||||
{/* Add new model */}
|
{/* Add new model */}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
|
<label htmlFor="new-model-input" className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
|
||||||
<input
|
<input
|
||||||
|
id="new-model-input"
|
||||||
type="text"
|
type="text"
|
||||||
value={newModel}
|
value={newModel}
|
||||||
onChange={(e) => setNewModel(e.target.value)}
|
onChange={(e) => setNewModel(e.target.value)}
|
||||||
@@ -463,14 +482,23 @@ function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PassthroughModelsSection.propTypes = {
|
||||||
|
providerAlias: PropTypes.string.isRequired,
|
||||||
|
modelAliases: PropTypes.object.isRequired,
|
||||||
|
copied: PropTypes.string,
|
||||||
|
onCopy: PropTypes.func.isRequired,
|
||||||
|
onSetAlias: PropTypes.func.isRequired,
|
||||||
|
onDeleteAlias: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) {
|
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
|
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
|
||||||
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
|
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{modelId}</p>
|
<p className="text-sm font-medium truncate">{modelId}</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||||
<button
|
<button
|
||||||
@@ -497,6 +525,14 @@ function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PassthroughModelRow.propTypes = {
|
||||||
|
modelId: PropTypes.string.isRequired,
|
||||||
|
fullModel: PropTypes.string.isRequired,
|
||||||
|
copied: PropTypes.string,
|
||||||
|
onCopy: PropTypes.func.isRequired,
|
||||||
|
onDeleteAlias: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function CooldownTimer({ until }) {
|
function CooldownTimer({ until }) {
|
||||||
const [remaining, setRemaining] = useState("");
|
const [remaining, setRemaining] = useState("");
|
||||||
|
|
||||||
@@ -533,6 +569,10 @@ function CooldownTimer({ until }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CooldownTimer.propTypes = {
|
||||||
|
until: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
|
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) {
|
||||||
const displayName = isOAuth
|
const displayName = isOAuth
|
||||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||||
@@ -568,8 +608,6 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
|||||||
return "default";
|
return "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasError = effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center justify-between p-3 rounded-lg border border-border hover:bg-sidebar/50 ${connection.isActive === false ? 'opacity-60' : ''}`}>
|
<div className={`flex items-center justify-between p-3 rounded-lg border border-border hover:bg-sidebar/50 ${connection.isActive === false ? 'opacity-60' : ''}`}>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
@@ -615,9 +653,9 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Toggle
|
<Toggle
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={connection.isActive !== false}
|
checked={connection.isActive ?? true}
|
||||||
onChange={onToggleActive}
|
onChange={onToggleActive}
|
||||||
title={connection.isActive !== false ? "Disable connection" : "Enable connection"}
|
title={(connection.isActive ?? true) ? "Disable connection" : "Enable connection"}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 ml-1">
|
<div className="flex gap-1 ml-1">
|
||||||
<button onClick={onEdit} className="p-2 hover:bg-sidebar rounded">
|
<button onClick={onEdit} className="p-2 hover:bg-sidebar rounded">
|
||||||
@@ -632,6 +670,29 @@ function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveD
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ConnectionRow.propTypes = {
|
||||||
|
connection: PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
email: PropTypes.string,
|
||||||
|
displayName: PropTypes.string,
|
||||||
|
rateLimitedUntil: PropTypes.string,
|
||||||
|
testStatus: PropTypes.string,
|
||||||
|
isActive: PropTypes.bool,
|
||||||
|
lastError: PropTypes.string,
|
||||||
|
priority: PropTypes.number,
|
||||||
|
globalPriority: PropTypes.number,
|
||||||
|
}).isRequired,
|
||||||
|
isOAuth: PropTypes.bool.isRequired,
|
||||||
|
isFirst: PropTypes.bool.isRequired,
|
||||||
|
isLast: PropTypes.bool.isRequired,
|
||||||
|
onMoveUp: PropTypes.func.isRequired,
|
||||||
|
onMoveDown: PropTypes.func.isRequired,
|
||||||
|
onToggleActive: PropTypes.func.isRequired,
|
||||||
|
onEdit: PropTypes.func.isRequired,
|
||||||
|
onDelete: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -701,7 +762,7 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
|||||||
label="Priority"
|
label="Priority"
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
|
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey}>
|
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey}>
|
||||||
@@ -716,6 +777,13 @@ function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AddApiKeyModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
provider: PropTypes.string,
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
@@ -782,9 +850,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
label="Priority"
|
label="Priority"
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.priority}
|
value={formData.priority}
|
||||||
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
|
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Test Connection */}
|
{/* Test Connection */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
<Button onClick={handleTest} variant="secondary" disabled={testing}>
|
||||||
@@ -805,3 +873,17 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EditConnectionModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
connection: PropTypes.shape({
|
||||||
|
id: PropTypes.string,
|
||||||
|
name: PropTypes.string,
|
||||||
|
email: PropTypes.string,
|
||||||
|
priority: PropTypes.number,
|
||||||
|
authType: PropTypes.string,
|
||||||
|
provider: PropTypes.string,
|
||||||
|
}),
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,11 +2,36 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import { Card, CardSkeleton, Badge } from "@/shared/components";
|
import { Card, CardSkeleton, Badge } from "@/shared/components";
|
||||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
import { getErrorCode, getRelativeTime } from "@/shared/utils";
|
||||||
|
|
||||||
|
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
|
||||||
|
function getStatusDisplay(connected, error, errorCode) {
|
||||||
|
const parts = [];
|
||||||
|
if (connected > 0) {
|
||||||
|
parts.push(
|
||||||
|
<Badge key="connected" variant="success" size="sm" dot>
|
||||||
|
{connected} Connected
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error > 0) {
|
||||||
|
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
||||||
|
parts.push(
|
||||||
|
<Badge key="error" variant="error" size="sm" dot>
|
||||||
|
{errText}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return <span className="text-text-muted">No connections</span>;
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProvidersPage() {
|
export default function ProvidersPage() {
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -108,29 +133,6 @@ function ProviderCard({ providerId, provider, stats }) {
|
|||||||
const { connected, error, errorCode, errorTime } = stats;
|
const { connected, error, errorCode, errorTime } = stats;
|
||||||
const [imgError, setImgError] = useState(false);
|
const [imgError, setImgError] = useState(false);
|
||||||
|
|
||||||
const getStatusDisplay = () => {
|
|
||||||
const parts = [];
|
|
||||||
if (connected > 0) {
|
|
||||||
parts.push(
|
|
||||||
<Badge key="connected" variant="success" size="sm" dot>
|
|
||||||
{connected} Connected
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error > 0) {
|
|
||||||
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
|
||||||
parts.push(
|
|
||||||
<Badge key="error" variant="error" size="sm" dot>
|
|
||||||
{errText}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return <span className="text-text-muted">No connections</span>;
|
|
||||||
}
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/dashboard/providers/${providerId}`}>
|
<Link href={`/dashboard/providers/${providerId}`}>
|
||||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||||
@@ -140,28 +142,29 @@ function ProviderCard({ providerId, provider, stats }) {
|
|||||||
className="size-10 rounded-lg flex items-center justify-center"
|
className="size-10 rounded-lg flex items-center justify-center"
|
||||||
style={{ backgroundColor: `${provider.color}15` }}
|
style={{ backgroundColor: `${provider.color}15` }}
|
||||||
>
|
>
|
||||||
{!imgError ? (
|
{imgError ? (
|
||||||
<Image
|
|
||||||
src={`/providers/${provider.id}.png`}
|
|
||||||
alt={provider.name}
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="object-contain rounded-lg"
|
|
||||||
onError={() => setImgError(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
<span
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
style={{ color: provider.color }}
|
style={{ color: provider.color }}
|
||||||
>
|
>
|
||||||
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={`/providers/${provider.id}.png`}
|
||||||
|
alt={provider.name}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="object-contain rounded-lg"
|
||||||
|
style={{ width: "auto", height: "auto" }}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{provider.name}</h3>
|
<h3 className="font-semibold">{provider.name}</h3>
|
||||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||||
{getStatusDisplay()}
|
{getStatusDisplay(connected, error, errorCode)}
|
||||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,33 +178,26 @@ function ProviderCard({ providerId, provider, stats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProviderCard.propTypes = {
|
||||||
|
providerId: PropTypes.string.isRequired,
|
||||||
|
provider: PropTypes.shape({
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
color: PropTypes.string,
|
||||||
|
textIcon: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
stats: PropTypes.shape({
|
||||||
|
connected: PropTypes.number,
|
||||||
|
error: PropTypes.number,
|
||||||
|
errorCode: PropTypes.string,
|
||||||
|
errorTime: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
// API Key providers - only use textIcon, no image
|
// API Key providers - only use textIcon, no image
|
||||||
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
function ApiKeyProviderCard({ providerId, provider, stats }) {
|
||||||
const { connected, error, errorCode, errorTime } = stats;
|
const { connected, error, errorCode, errorTime } = stats;
|
||||||
|
|
||||||
const getStatusDisplay = () => {
|
|
||||||
const parts = [];
|
|
||||||
if (connected > 0) {
|
|
||||||
parts.push(
|
|
||||||
<Badge key="connected" variant="success" size="sm" dot>
|
|
||||||
{connected} Connected
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (error > 0) {
|
|
||||||
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
|
|
||||||
parts.push(
|
|
||||||
<Badge key="error" variant="error" size="sm" dot>
|
|
||||||
{errText}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parts.length === 0) {
|
|
||||||
return <span className="text-text-muted">No connections</span>;
|
|
||||||
}
|
|
||||||
return parts;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/dashboard/providers/${providerId}`}>
|
<Link href={`/dashboard/providers/${providerId}`}>
|
||||||
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
|
||||||
@@ -221,7 +217,7 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{provider.name}</h3>
|
<h3 className="font-semibold">{provider.name}</h3>
|
||||||
<div className="flex items-center gap-2 text-xs flex-wrap">
|
<div className="flex items-center gap-2 text-xs flex-wrap">
|
||||||
{getStatusDisplay()}
|
{getStatusDisplay(connected, error, errorCode)}
|
||||||
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,3 +230,19 @@ function ApiKeyProviderCard({ providerId, provider, stats }) {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ApiKeyProviderCard.propTypes = {
|
||||||
|
providerId: PropTypes.string.isRequired,
|
||||||
|
provider: PropTypes.shape({
|
||||||
|
id: PropTypes.string.isRequired,
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
color: PropTypes.string,
|
||||||
|
textIcon: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
stats: PropTypes.shape({
|
||||||
|
connected: PropTypes.number,
|
||||||
|
error: PropTypes.number,
|
||||||
|
errorCode: PropTypes.string,
|
||||||
|
errorTime: PropTypes.string,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { SignJWT } from "jose";
|
import { SignJWT } from "jose";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
@@ -17,12 +17,12 @@ export async function POST(request) {
|
|||||||
const storedHash = settings.password;
|
const storedHash = settings.password;
|
||||||
|
|
||||||
let isValid = false;
|
let isValid = false;
|
||||||
if (!storedHash) {
|
if (storedHash) {
|
||||||
|
isValid = await bcrypt.compare(password, storedHash);
|
||||||
|
} else {
|
||||||
// Use env var or default
|
// Use env var or default
|
||||||
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
|
const initialPassword = process.env.INITIAL_PASSWORD || "123456";
|
||||||
isValid = password === initialPassword;
|
isValid = password === initialPassword;
|
||||||
} else {
|
|
||||||
isValid = await bcrypt.compare(password, storedHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ export default function Navigation() {
|
|||||||
<nav className="fixed top-0 z-50 w-full bg-[#181411]/80 backdrop-blur-md border-b border-[#3a2f27]">
|
<nav className="fixed top-0 z-50 w-full bg-[#181411]/80 backdrop-blur-md border-b border-[#3a2f27]">
|
||||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-3 cursor-pointer" onClick={() => router.push("/")}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-3 cursor-pointer bg-transparent border-none p-0"
|
||||||
|
onClick={() => router.push("/")}
|
||||||
|
aria-label="Navigate to home"
|
||||||
|
>
|
||||||
<div className="size-8 rounded bg-linear-to-br from-[#f97815] to-orange-700 flex items-center justify-center text-white">
|
<div className="size-8 rounded bg-linear-to-br from-[#f97815] to-orange-700 flex items-center justify-center text-white">
|
||||||
<span className="material-symbols-outlined text-[20px]">hub</span>
|
<span className="material-symbols-outlined text-[20px]">hub</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-white text-xl font-bold tracking-tight">9Router</h2>
|
<h2 className="text-white text-xl font-bold tracking-tight">9Router</h2>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{/* Desktop menu */}
|
{/* Desktop menu */}
|
||||||
<div className="hidden md:flex items-center gap-8">
|
<div className="hidden md:flex items-center gap-8">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import Navigation from "./components/Navigation";
|
import Navigation from "./components/Navigation";
|
||||||
import HeroSection from "./components/HeroSection";
|
import HeroSection from "./components/HeroSection";
|
||||||
import FlowAnimation from "./components/FlowAnimation";
|
import FlowAnimation from "./components/FlowAnimation";
|
||||||
@@ -8,6 +9,7 @@ import GetStarted from "./components/GetStarted";
|
|||||||
import Footer from "./components/Footer";
|
import Footer from "./components/Footer";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
|
const router = useRouter();
|
||||||
return (
|
return (
|
||||||
<div className="relative text-white font-sans overflow-x-hidden antialiased selection:bg-[#f97815] selection:text-white">
|
<div className="relative text-white font-sans overflow-x-hidden antialiased selection:bg-[#f97815] selection:text-white">
|
||||||
{/* Animated Background */}
|
{/* Animated Background */}
|
||||||
@@ -55,7 +57,7 @@ export default function LandingPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.href = "/dashboard"}
|
onClick={() => router.push("/dashboard")}
|
||||||
className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#f97815] hover:bg-[#e0650a] text-[#181411] text-lg font-bold transition-all shadow-[0_0_20px_rgba(249,120,21,0.5)]"
|
className="w-full sm:w-auto h-14 px-10 rounded-lg bg-[#f97815] hover:bg-[#e0650a] text-[#181411] text-lg font-bold transition-all shadow-[0_0_20px_rgba(249,120,21,0.5)]"
|
||||||
>
|
>
|
||||||
Start Free
|
Start Free
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Low } from "lowdb";
|
import { Low } from "lowdb";
|
||||||
import { JSONFile } from "lowdb/node";
|
import { JSONFile } from "lowdb/node";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import path from "path";
|
import path from "node:path";
|
||||||
import os from "os";
|
import os from "node:os";
|
||||||
import fs from "fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
|
||||||
|
|
||||||
@@ -601,7 +601,7 @@ export async function getPricingForModel(provider, model) {
|
|||||||
const pricing = await getPricing();
|
const pricing = await getPricing();
|
||||||
|
|
||||||
// Try direct lookup
|
// Try direct lookup
|
||||||
if (pricing[provider] && pricing[provider][model]) {
|
if (pricing[provider]?.[model]) {
|
||||||
return pricing[provider][model];
|
return pricing[provider][model];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import PropTypes from "prop-types";
|
||||||
import { ThemeToggle } from "@/shared/components";
|
import { ThemeToggle } from "@/shared/components";
|
||||||
import { APP_CONFIG, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||||
|
|
||||||
const getPageInfo = (pathname) => {
|
const getPageInfo = (pathname) => {
|
||||||
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
|
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
|
||||||
@@ -73,7 +73,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
{breadcrumbs.length > 0 ? (
|
{breadcrumbs.length > 0 ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{breadcrumbs.map((crumb, index) => (
|
{breadcrumbs.map((crumb, index) => (
|
||||||
<div key={index} className="flex items-center gap-2">
|
<div key={`${crumb.label}-${crumb.href || "current"}`} className="flex items-center gap-2">
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<span className="material-symbols-outlined text-text-muted text-base">
|
<span className="material-symbols-outlined text-text-muted text-base">
|
||||||
chevron_right
|
chevron_right
|
||||||
@@ -95,6 +95,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
width={28}
|
width={28}
|
||||||
height={28}
|
height={28}
|
||||||
className="object-contain rounded"
|
className="object-contain rounded"
|
||||||
|
style={{ width: "auto", height: "auto" }}
|
||||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -134,3 +135,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Header.propTypes = {
|
||||||
|
onMenuClick: PropTypes.func,
|
||||||
|
showMenuButton: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import { Modal, Button, Input } from "@/shared/components";
|
import { Modal, Button, Input } from "@/shared/components";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
|
||||||
@@ -151,12 +152,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
|
|
||||||
setAuthData({ ...data, redirectUri });
|
setAuthData({ ...data, redirectUri });
|
||||||
|
|
||||||
// For Codex, always use manual input since it requires fixed port 1455
|
// For Codex or non-localhost: use manual input mode
|
||||||
if (provider === "codex") {
|
if (provider === "codex" || !isLocalhost) {
|
||||||
setStep("input");
|
setStep("input");
|
||||||
window.open(data.authUrl, "_blank");
|
window.open(data.authUrl, "_blank");
|
||||||
} else if (isLocalhost) {
|
} else {
|
||||||
// Other providers on localhost: Open popup and wait for message
|
// Localhost (non-Codex): Open popup and wait for message
|
||||||
setStep("waiting");
|
setStep("waiting");
|
||||||
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
|
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
|
||||||
|
|
||||||
@@ -164,10 +165,6 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
if (!popupRef.current) {
|
if (!popupRef.current) {
|
||||||
setStep("input");
|
setStep("input");
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Remote: Show manual input
|
|
||||||
setStep("input");
|
|
||||||
window.open(data.authUrl, "_blank");
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -256,7 +253,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
localStorage.removeItem("oauth_callback");
|
localStorage.removeItem("oauth_callback");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch {
|
||||||
|
// localStorage may be unavailable or data may be malformed - ignore silently
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("message", handleMessage);
|
window.removeEventListener("message", handleMessage);
|
||||||
@@ -430,3 +429,13 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OAuthModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
provider: PropTypes.string,
|
||||||
|
providerInfo: PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
}),
|
||||||
|
onSuccess: PropTypes.func,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import Card from "./Card";
|
import Card from "./Card";
|
||||||
import Badge from "./Badge";
|
import Badge from "./Badge";
|
||||||
@@ -11,22 +12,33 @@ function SortIcon({ field, currentSort, currentOrder }) {
|
|||||||
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
|
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SortIcon.propTypes = {
|
||||||
|
field: PropTypes.string.isRequired,
|
||||||
|
currentSort: PropTypes.string.isRequired,
|
||||||
|
currentOrder: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
function MiniBarGraph({ data, colorClass = "bg-primary" }) {
|
function MiniBarGraph({ data, colorClass = "bg-primary" }) {
|
||||||
const max = Math.max(...data, 1);
|
const max = Math.max(...data, 1);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-end gap-1 h-8 w-24">
|
<div className="flex items-end gap-1 h-8 w-24">
|
||||||
{data.slice(-9).map((val, i) => (
|
{data.slice(-9).map((val, idx) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={`bar-${idx}-${val}`}
|
||||||
className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`}
|
className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`}
|
||||||
style={{ height: `${Math.max((val / max) * 100, 5)}%` }}
|
style={{ height: `${Math.max((val / max) * 100, 5)}%` }}
|
||||||
title={val}
|
title={String(val)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MiniBarGraph.propTypes = {
|
||||||
|
data: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
|
colorClass: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
export default function UsageStats() {
|
export default function UsageStats() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -235,11 +247,15 @@ export default function UsageStats() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto Refresh Toggle */}
|
{/* Auto Refresh Toggle */}
|
||||||
<label className="text-sm font-medium text-text-muted flex items-center gap-2 cursor-pointer">
|
<div className="text-sm font-medium text-text-muted flex items-center gap-2">
|
||||||
<span>Auto Refresh ({refreshInterval / 1000}s)</span>
|
<span>Auto Refresh ({refreshInterval / 1000}s)</span>
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
role="switch"
|
||||||
|
aria-checked={autoRefresh}
|
||||||
|
aria-label="Toggle auto refresh"
|
||||||
|
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
|
||||||
autoRefresh ? "bg-primary" : "bg-bg-subtle border border-border"
|
autoRefresh ? "bg-primary" : "bg-bg-subtle border border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -248,8 +264,8 @@ export default function UsageStats() {
|
|||||||
autoRefresh ? "translate-x-5" : "translate-x-1"
|
autoRefresh ? "translate-x-5" : "translate-x-1"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -265,9 +281,9 @@ export default function UsageStats() {
|
|||||||
Active Requests
|
Active Requests
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{stats.activeRequests.map((req, i) => (
|
{stats.activeRequests.map((req) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={`${req.model}-${req.provider}-${req.account}`}
|
||||||
className="px-3 py-1.5 rounded-md bg-bg-subtle border border-primary/20 text-xs font-mono shadow-sm"
|
className="px-3 py-1.5 rounded-md bg-bg-subtle border border-primary/20 text-xs font-mono shadow-sm"
|
||||||
>
|
>
|
||||||
<span className="text-primary font-bold">{req.model}</span>
|
<span className="text-primary font-bold">{req.model}</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import PropTypes from "prop-types";
|
||||||
import ThemeToggle from "../ThemeToggle";
|
import ThemeToggle from "../ThemeToggle";
|
||||||
|
|
||||||
export default function AuthLayout({ children }) {
|
export default function AuthLayout({ children }) {
|
||||||
@@ -22,3 +23,7 @@ export default function AuthLayout({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AuthLayout.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user