Unify all dropdowns on the custom Select component.
Replace every native <select> across the dashboard, admin pages, and shared components with the custom Select used by the optional-service version pickers, for consistent styling and mobile-safe anchoring. Add a disabled prop to Select to cover the former read-only native cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
import { TruncatedText } from '@/components/truncated-text';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
running: 'badge-green',
|
||||
@@ -605,23 +606,18 @@ export default function AdminAppsPage() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={aa.selectHealthyCluster}
|
||||
placeholder={aa.selectHealthyCluster}
|
||||
value={targetClusterId}
|
||||
onChange={(e) => setTargetClusterId(e.target.value)}
|
||||
>
|
||||
<option value="">{aa.selectHealthyCluster}</option>
|
||||
{targetClusters
|
||||
.map((cluster) => (
|
||||
<option
|
||||
key={cluster.id}
|
||||
value={cluster.id}
|
||||
disabled={cluster.status !== 'active' || cluster.healthStatus !== 'healthy'}
|
||||
>
|
||||
{cluster.name} · {cluster.region || aa.na} · {cluster.status}/{cluster.healthStatus || aa.unknown}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setTargetClusterId(v)}
|
||||
options={targetClusters.map((cluster) => ({
|
||||
value: cluster.id,
|
||||
label: `${cluster.name} · ${cluster.region || aa.na} · ${cluster.status}/${cluster.healthStatus || aa.unknown}`,
|
||||
disabled: cluster.status !== 'active' || cluster.healthStatus !== 'healthy',
|
||||
}))}
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{aa.currentExcludedNote}
|
||||
</p>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
LifecycleSettings,
|
||||
} from '@/types';
|
||||
import { DollarSign, Edit2, Shield, Clock, Layers, Info, Box, Server, Globe } from 'lucide-react';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
const cycles: BillingCycle[] = ['hourly', 'monthly', 'yearly'];
|
||||
|
||||
@@ -198,16 +199,19 @@ function DeployDefaultsFields({
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
<Select
|
||||
size="md"
|
||||
className="mt-0.5"
|
||||
ariaLabel={b.cpuRequest}
|
||||
value={profile.cpuRequest || '50m'}
|
||||
onChange={(e) => onUpdate({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m</option>
|
||||
<option value="100m">100m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
</select>
|
||||
onChange={(v) => onUpdate({ cpuRequest: v })}
|
||||
options={[
|
||||
{ value: '50m', label: '50m' },
|
||||
{ value: '100m', label: '100m' },
|
||||
{ value: '250m', label: '250m' },
|
||||
{ value: '500m', label: '500m' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -215,17 +219,20 @@ function DeployDefaultsFields({
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.cpuLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
<Select
|
||||
size="md"
|
||||
className="mt-0.5"
|
||||
ariaLabel={b.cpuLimit}
|
||||
value={profile.cpuLimit}
|
||||
onChange={(e) => onUpdate({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">{b.oneCore}</option>
|
||||
<option value="2">{b.twoCores}</option>
|
||||
</select>
|
||||
onChange={(v) => onUpdate({ cpuLimit: v })}
|
||||
options={[
|
||||
{ value: '200m', label: '200m' },
|
||||
{ value: '250m', label: '250m' },
|
||||
{ value: '500m', label: '500m' },
|
||||
{ value: '1', label: b.oneCore },
|
||||
{ value: '2', label: b.twoCores },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -233,16 +240,19 @@ function DeployDefaultsFields({
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryRequest || '—'}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
<Select
|
||||
size="md"
|
||||
className="mt-0.5"
|
||||
ariaLabel={b.memoryRequest}
|
||||
value={profile.memoryRequest || '64Mi'}
|
||||
onChange={(e) => onUpdate({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
onChange={(v) => onUpdate({ memoryRequest: v })}
|
||||
options={[
|
||||
{ value: '64Mi', label: '64 Mi' },
|
||||
{ value: '128Mi', label: '128 Mi' },
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
@@ -250,16 +260,19 @@ function DeployDefaultsFields({
|
||||
{readOnly ? (
|
||||
<p className="text-sm font-mono mt-1">{profile.memoryLimit}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input-field w-full text-sm mt-0.5"
|
||||
<Select
|
||||
size="md"
|
||||
className="mt-0.5"
|
||||
ariaLabel={b.memoryLimit}
|
||||
value={profile.memoryLimit}
|
||||
onChange={(e) => onUpdate({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
onChange={(v) => onUpdate({ memoryLimit: v })}
|
||||
options={[
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
{ value: '1Gi', label: '1 Gi' },
|
||||
{ value: '2Gi', label: '2 Gi' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { Cluster, ClusterResources } from '@/types';
|
||||
import { Server, CheckCircle, XCircle, BarChart3, Clock, RotateCw, Plug, X, ScrollText } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
function ResourcePanel({ clusterId }: { clusterId: string }) {
|
||||
const cl = useT().dashboard.clusters;
|
||||
@@ -345,17 +346,16 @@ function ClusterToolsPanel({
|
||||
<p className="text-sm text-gray-600 mt-1">{cl.toolsSubtitle}</p>
|
||||
</div>
|
||||
{clusters.length > 1 && (
|
||||
<select
|
||||
className="input-field text-sm py-1.5 max-w-[220px]"
|
||||
<Select
|
||||
className="max-w-[220px]"
|
||||
ariaLabel={cl.toolsTitle}
|
||||
value={clusterId || ''}
|
||||
onChange={(e) => onSelectCluster(e.target.value)}
|
||||
>
|
||||
{clusters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}{c.isDefault ? cl.defaultSuffix : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => onSelectCluster(v)}
|
||||
options={clusters.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.name}${c.isDefault ? cl.defaultSuffix : ''}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -502,13 +502,19 @@ export default function AdminClustersPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.provider}</label>
|
||||
<select className="input-field" value={form.provider} onChange={(e) => setForm({ ...form, provider: e.target.value })}>
|
||||
<option value="">{cl.selectProvider}</option>
|
||||
<option value="aws">AWS (EKS)</option>
|
||||
<option value="gcp">GCP (GKE)</option>
|
||||
<option value="azure">Azure (AKS)</option>
|
||||
<option value="bare-metal">Bare Metal</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={cl.provider}
|
||||
placeholder={cl.selectProvider}
|
||||
value={form.provider}
|
||||
onChange={(v) => setForm({ ...form, provider: v })}
|
||||
options={[
|
||||
{ value: 'aws', label: 'AWS (EKS)' },
|
||||
{ value: 'gcp', label: 'GCP (GKE)' },
|
||||
{ value: 'azure', label: 'Azure (AKS)' },
|
||||
{ value: 'bare-metal', label: 'Bare Metal' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{cl.weight}</label>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import api from '@/lib/api';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { Invoice, InvoiceStatus, PaymentMethod } from '@/types';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
const statusClasses: Record<InvoiceStatus, string> = {
|
||||
draft: 'badge-gray',
|
||||
@@ -108,18 +109,31 @@ export default function AdminInvoicesPage() {
|
||||
className="input-field pl-9 rtl:pl-3 rtl:pr-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as any)} className="input-field">
|
||||
<option value="all">{inv.allStatuses}</option>
|
||||
{(Object.keys(inv.status) as InvoiceStatus[]).map((value) => (
|
||||
<option key={value} value={value}>{statusLabel(value)}</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value as any)} className="input-field">
|
||||
<option value="all">{inv.allMethods}</option>
|
||||
<option value="wallet">{inv.methodWallet}</option>
|
||||
<option value="gateway">{inv.methodGateway}</option>
|
||||
<option value="mixed">{inv.methodMixed}</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={inv.allStatuses}
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v as any)}
|
||||
options={[
|
||||
{ value: 'all', label: inv.allStatuses },
|
||||
...(Object.keys(inv.status) as InvoiceStatus[]).map((value) => ({
|
||||
value,
|
||||
label: statusLabel(value),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={inv.allMethods}
|
||||
value={paymentMethod}
|
||||
onChange={(v) => setPaymentMethod(v as any)}
|
||||
options={[
|
||||
{ value: 'all', label: inv.allMethods },
|
||||
{ value: 'wallet', label: inv.methodWallet },
|
||||
{ value: 'gateway', label: inv.methodGateway },
|
||||
{ value: 'mixed', label: inv.methodMixed },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { Cluster, ClusterPool } from '@/types';
|
||||
import { Scale, BarChart3, RotateCw, CheckCircle, XCircle, Pencil, Clock, X } from 'lucide-react';
|
||||
import { useConfirm } from '@/components/confirm-modal';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
export default function AdminPoolsPage() {
|
||||
const t = useT();
|
||||
@@ -150,18 +151,20 @@ export default function AdminPoolsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{p.strategy}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={p.strategy}
|
||||
value={form.strategy}
|
||||
onChange={(e) => setForm({ ...form, strategy: e.target.value as any })}
|
||||
>
|
||||
<option value="weighted-resource">{strategyOption('weighted-resource')}</option>
|
||||
<option value="least-loaded">{strategyOption('least-loaded')}</option>
|
||||
<option value="weighted-round-robin">{strategyOption('weighted-round-robin')}</option>
|
||||
<option value="region-based">{strategyOption('region-based')}</option>
|
||||
<option value="least-apps">{strategyOption('least-apps')}</option>
|
||||
<option value="round-robin">{strategyOption('round-robin')}</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, strategy: v as any })}
|
||||
options={[
|
||||
{ value: 'weighted-resource', label: strategyOption('weighted-resource') },
|
||||
{ value: 'least-loaded', label: strategyOption('least-loaded') },
|
||||
{ value: 'weighted-round-robin', label: strategyOption('weighted-round-robin') },
|
||||
{ value: 'region-based', label: strategyOption('region-based') },
|
||||
{ value: 'least-apps', label: strategyOption('least-apps') },
|
||||
{ value: 'round-robin', label: strategyOption('round-robin') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{p.priority}</label>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { AdminUser } from '@/types';
|
||||
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const t = useT();
|
||||
@@ -166,16 +167,19 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{u.role}</label>
|
||||
<select
|
||||
className="input-field w-full sm:w-48"
|
||||
<Select
|
||||
size="md"
|
||||
className="w-full sm:w-48"
|
||||
ariaLabel={u.role}
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
|
||||
>
|
||||
<option value="user">{u.roles.user}</option>
|
||||
<option value="admin">{u.roles.admin}</option>
|
||||
<option value="technical">{u.roles.technical}</option>
|
||||
<option value="sales">{u.roles.sales}</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, role: v as typeof form.role })}
|
||||
options={[
|
||||
{ value: 'user', label: u.roles.user },
|
||||
{ value: 'admin', label: u.roles.admin },
|
||||
{ value: 'technical', label: u.roles.technical },
|
||||
{ value: 'sales', label: u.roles.sales },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createUser.mutate(form)}
|
||||
@@ -241,16 +245,18 @@ export default function AdminUsersPage() {
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
<Select
|
||||
className="w-40"
|
||||
ariaLabel={u.role}
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">{u.roles.user}</option>
|
||||
<option value="admin">{u.roles.admin}</option>
|
||||
<option value="technical">{u.roles.technical}</option>
|
||||
<option value="sales">{u.roles.sales}</option>
|
||||
</select>
|
||||
onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
|
||||
options={[
|
||||
{ value: 'user', label: u.roles.user },
|
||||
{ value: 'admin', label: u.roles.admin },
|
||||
{ value: 'technical', label: u.roles.technical },
|
||||
{ value: 'sales', label: u.roles.sales },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className={`badge ${
|
||||
user.role === 'admin' ? 'badge-purple' :
|
||||
@@ -322,16 +328,18 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
<Select
|
||||
className="w-40"
|
||||
ariaLabel={u.role}
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">{u.roles.user}</option>
|
||||
<option value="admin">{u.roles.admin}</option>
|
||||
<option value="technical">{u.roles.technical}</option>
|
||||
<option value="sales">{u.roles.sales}</option>
|
||||
</select>
|
||||
onChange={(v) => changeRole.mutate({ id: user.id, role: v })}
|
||||
options={[
|
||||
{ value: 'user', label: u.roles.user },
|
||||
{ value: 'admin', label: u.roles.admin },
|
||||
{ value: 'technical', label: u.roles.technical },
|
||||
{ value: 'sales', label: u.roles.sales },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">{roleLabel(user.role)}</span>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useConfirm } from '@/components/confirm-modal';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
/** Matches backend multipart limit for POST /applications/:id/upload */
|
||||
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
||||
@@ -2243,16 +2244,19 @@ export default function AppDetailPage() {
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-500 mb-1">{ad.workload}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
className="max-w-md"
|
||||
ariaLabel={ad.workload}
|
||||
value={scaleWorkload}
|
||||
onChange={(e) => setScaleWorkload(e.target.value as typeof scaleWorkload)}
|
||||
className="input-field text-sm max-w-md"
|
||||
>
|
||||
<option value="app">{ad.productTypeApp}</option>
|
||||
{app?.databaseType !== 'none' && <option value="database">{ad.database}</option>}
|
||||
{app?.enableRedis && <option value="redis">{ad.workload}</option>}
|
||||
{app?.enableRabbitmq && <option value="rabbitmq">{ad.workload}</option>}
|
||||
</select>
|
||||
onChange={(v) => setScaleWorkload(v as typeof scaleWorkload)}
|
||||
options={[
|
||||
{ value: 'app', label: ad.productTypeApp },
|
||||
...(app?.databaseType !== 'none' ? [{ value: 'database', label: ad.database }] : []),
|
||||
...(app?.enableRedis ? [{ value: 'redis', label: ad.workload }] : []),
|
||||
...(app?.enableRabbitmq ? [{ value: 'rabbitmq', label: ad.workload }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { parseDotenv } from '@/lib/parseDotenv';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
@@ -100,60 +101,68 @@ function WorkloadResourceFields({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.cpuRequest}
|
||||
disabled={readOnly}
|
||||
value={config.cpuRequest || '50m'}
|
||||
onChange={(e) => onChange({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m (0.05 core)</option>
|
||||
<option value="100m">100m (0.1 core)</option>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ cpuRequest: v })}
|
||||
options={[
|
||||
{ value: '50m', label: '50m (0.05 core)' },
|
||||
{ value: '100m', label: '100m (0.1 core)' },
|
||||
{ value: '250m', label: '250m (0.25 core)' },
|
||||
{ value: '500m', label: '500m (0.5 core)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.cpuLimit}
|
||||
disabled={readOnly}
|
||||
value={config.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m (0.2 core)</option>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ cpuLimit: v })}
|
||||
options={[
|
||||
{ value: '200m', label: '200m (0.2 core)' },
|
||||
{ value: '250m', label: '250m (0.25 core)' },
|
||||
{ value: '500m', label: '500m (0.5 core)' },
|
||||
{ value: '1', label: '1 core' },
|
||||
{ value: '2', label: '2 cores' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.memoryRequest}
|
||||
disabled={readOnly}
|
||||
value={config.memoryRequest || '64Mi'}
|
||||
onChange={(e) => onChange({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ memoryRequest: v })}
|
||||
options={[
|
||||
{ value: '64Mi', label: '64 Mi' },
|
||||
{ value: '128Mi', label: '128 Mi' },
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.memoryLimit}
|
||||
disabled={readOnly}
|
||||
value={config.memoryLimit}
|
||||
onChange={(e) => onChange({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ memoryLimit: v })}
|
||||
options={[
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
{ value: '1Gi', label: '1 Gi' },
|
||||
{ value: '2Gi', label: '2 Gi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
@@ -1353,119 +1362,89 @@ export default function DeployPage() {
|
||||
{form.runtime === 'nodejs' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.nodeVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.runtimeVersion || '20'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="22">Node.js 22 (LTS)</option>
|
||||
<option value="20">Node.js 20 (LTS)</option>
|
||||
<option value="18">Node.js 18</option>
|
||||
<option value="16">Node.js 16</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, runtimeVersion: v })}
|
||||
options={[{ value: '22', label: 'Node.js 22 (LTS)' },{ value: '20', label: 'Node.js 20 (LTS)' },{ value: '18', label: 'Node.js 18' },{ value: '16', label: 'Node.js 16' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'laravel' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.phpVersion || '8.3'}
|
||||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||||
>
|
||||
<option value="8.4">PHP 8.4</option>
|
||||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, phpVersion: v })}
|
||||
options={[{ value: '8.4', label: 'PHP 8.4' },{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'wordpress' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.wordpressVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.runtimeVersion || '6.7'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="6.7">WordPress 6.7 (Latest)</option>
|
||||
<option value="6.6">WordPress 6.6</option>
|
||||
<option value="6.5">WordPress 6.5</option>
|
||||
<option value="6.4">WordPress 6.4</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
value={form.runtimeVersion || '6.7'}
|
||||
onChange={(v) => setForm({ ...form, runtimeVersion: v })}
|
||||
options={[{ value: '6.7', label: 'WordPress 6.7 (Latest)' },{ value: '6.6', label: 'WordPress 6.6' },{ value: '6.5', label: 'WordPress 6.5' },{ value: '6.4', label: 'WordPress 6.4' }]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.phpVersion || '8.3'}
|
||||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||||
>
|
||||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
value={form.phpVersion || '8.3'}
|
||||
onChange={(v) => setForm({ ...form, phpVersion: v })}
|
||||
options={[{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{form.runtime === 'go' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.goVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.runtimeVersion || '1.22'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="1.23">Go 1.23 (Latest)</option>
|
||||
<option value="1.22">Go 1.22 (Recommended)</option>
|
||||
<option value="1.21">Go 1.21</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, runtimeVersion: v })}
|
||||
options={[{ value: '1.23', label: 'Go 1.23 (Latest)' },{ value: '1.22', label: 'Go 1.22 (Recommended)' },{ value: '1.21', label: 'Go 1.21' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(form.runtime === 'python' || form.runtime === 'django') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.pythonVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.runtimeVersion || '3.12'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="3.13">Python 3.13 (Latest)</option>
|
||||
<option value="3.12">Python 3.12 (Recommended)</option>
|
||||
<option value="3.11">Python 3.11</option>
|
||||
<option value="3.10">Python 3.10</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, runtimeVersion: v })}
|
||||
options={[{ value: '3.13', label: 'Python 3.13 (Latest)' },{ value: '3.12', label: 'Python 3.12 (Recommended)' },{ value: '3.11', label: 'Python 3.11' },{ value: '3.10', label: 'Python 3.10' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'php' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.phpVersion}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.phpVersion || '8.3'}
|
||||
onChange={(e) => setForm({ ...form, phpVersion: e.target.value })}
|
||||
>
|
||||
<option value="8.4">PHP 8.4 (Latest)</option>
|
||||
<option value="8.3">PHP 8.3 (Recommended)</option>
|
||||
<option value="8.2">PHP 8.2</option>
|
||||
<option value="8.1">PHP 8.1</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, phpVersion: v })}
|
||||
options={[{ value: '8.4', label: 'PHP 8.4 (Latest)' },{ value: '8.3', label: 'PHP 8.3 (Recommended)' },{ value: '8.2', label: 'PHP 8.2' },{ value: '8.1', label: 'PHP 8.1' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{form.runtime === 'dotnet' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">.NET Version</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
value={form.runtimeVersion || '8.0'}
|
||||
onChange={(e) => setForm({ ...form, runtimeVersion: e.target.value })}
|
||||
>
|
||||
<option value="9.0">.NET 9.0 (Latest)</option>
|
||||
<option value="8.0">.NET 8.0 LTS (Recommended)</option>
|
||||
<option value="7.0">.NET 7.0</option>
|
||||
<option value="6.0">.NET 6.0 LTS</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, runtimeVersion: v })}
|
||||
options={[{ value: '9.0', label: '.NET 9.0 (Latest)' },{ value: '8.0', label: '.NET 8.0 LTS (Recommended)' },{ value: '7.0', label: '.NET 7.0' },{ value: '6.0', label: '.NET 6.0 LTS' }]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1518,40 +1497,40 @@ export default function DeployPage() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{form.databaseType === 'postgresql' ? 'PostgreSQL' : form.databaseType === 'mysql' ? 'MySQL' : form.databaseType === 'mariadb' ? 'MariaDB' : 'MongoDB'} Version
|
||||
</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
<Select
|
||||
size="md"
|
||||
className="max-w-xs"
|
||||
value={form.dbVersion || (form.databaseType === 'postgresql' ? '16' : form.databaseType === 'mysql' ? '8.0' : form.databaseType === 'mariadb' ? '11.4' : '7.0')}
|
||||
onChange={(e) => setForm({ ...form, dbVersion: e.target.value })}
|
||||
>
|
||||
{form.databaseType === 'postgresql' ? (
|
||||
<>
|
||||
<option value="17">PostgreSQL 17 (Latest)</option>
|
||||
<option value="16">PostgreSQL 16 (LTS)</option>
|
||||
<option value="15">PostgreSQL 15</option>
|
||||
<option value="14">PostgreSQL 14</option>
|
||||
</>
|
||||
) : form.databaseType === 'mysql' ? (
|
||||
<>
|
||||
<option value="9.0">MySQL 9.0 (Latest)</option>
|
||||
<option value="8.4">MySQL 8.4 (LTS)</option>
|
||||
<option value="8.0">MySQL 8.0</option>
|
||||
<option value="5.7">MySQL 5.7</option>
|
||||
</>
|
||||
) : form.databaseType === 'mariadb' ? (
|
||||
<>
|
||||
<option value="11.4">MariaDB 11.4 (LTS)</option>
|
||||
<option value="11.3">MariaDB 11.3</option>
|
||||
<option value="10.11">MariaDB 10.11 (LTS)</option>
|
||||
<option value="10.6">MariaDB 10.6</option>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<option value="7.0">MongoDB 7.0 (Latest)</option>
|
||||
<option value="6.0">MongoDB 6.0</option>
|
||||
<option value="5.0">MongoDB 5.0</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, dbVersion: v })}
|
||||
options={
|
||||
form.databaseType === 'postgresql'
|
||||
? [
|
||||
{ value: '17', label: 'PostgreSQL 17 (Latest)' },
|
||||
{ value: '16', label: 'PostgreSQL 16 (LTS)' },
|
||||
{ value: '15', label: 'PostgreSQL 15' },
|
||||
{ value: '14', label: 'PostgreSQL 14' },
|
||||
]
|
||||
: form.databaseType === 'mysql'
|
||||
? [
|
||||
{ value: '9.0', label: 'MySQL 9.0 (Latest)' },
|
||||
{ value: '8.4', label: 'MySQL 8.4 (LTS)' },
|
||||
{ value: '8.0', label: 'MySQL 8.0' },
|
||||
{ value: '5.7', label: 'MySQL 5.7' },
|
||||
]
|
||||
: form.databaseType === 'mariadb'
|
||||
? [
|
||||
{ value: '11.4', label: 'MariaDB 11.4 (LTS)' },
|
||||
{ value: '11.3', label: 'MariaDB 11.3' },
|
||||
{ value: '10.11', label: 'MariaDB 10.11 (LTS)' },
|
||||
{ value: '10.6', label: 'MariaDB 10.6' },
|
||||
]
|
||||
: [
|
||||
{ value: '7.0', label: 'MongoDB 7.0 (Latest)' },
|
||||
{ value: '6.0', label: 'MongoDB 6.0' },
|
||||
{ value: '5.0', label: 'MongoDB 5.0' },
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1793,17 +1772,18 @@ export default function DeployPage() {
|
||||
<div className="mt-3 pt-3 border-t border-red-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
|
||||
<select
|
||||
className="select-compact flex-1 min-w-0"
|
||||
<Select
|
||||
className="flex-1 min-w-0"
|
||||
ariaLabel="Redis version"
|
||||
value={form.redisVersion || '7.2'}
|
||||
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="7.2">7.2 (Latest)</option>
|
||||
<option value="7.0">7.0</option>
|
||||
<option value="6.2">6.2 (LTS)</option>
|
||||
<option value="6.0">6.0</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, redisVersion: v })}
|
||||
options={[
|
||||
{ value: '7.2', label: '7.2 (Latest)' },
|
||||
{ value: '7.0', label: '7.0' },
|
||||
{ value: '6.2', label: '6.2 (LTS)' },
|
||||
{ value: '6.0', label: '6.0' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-red-600">REDIS_HOST, REDIS_PASSWORD, REDIS_URL</p>
|
||||
</div>
|
||||
@@ -1851,17 +1831,18 @@ export default function DeployPage() {
|
||||
<div className="mt-3 pt-3 border-t border-orange-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
|
||||
<select
|
||||
className="select-compact flex-1 min-w-0"
|
||||
<Select
|
||||
className="flex-1 min-w-0"
|
||||
ariaLabel="RabbitMQ version"
|
||||
value={form.rabbitmqVersion || '3.13'}
|
||||
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="3.13">3.13 (Latest)</option>
|
||||
<option value="3.12">3.12 (LTS)</option>
|
||||
<option value="3.11">3.11</option>
|
||||
<option value="3.10">3.10</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
|
||||
options={[
|
||||
{ value: '3.13', label: '3.13 (Latest)' },
|
||||
{ value: '3.12', label: '3.12 (LTS)' },
|
||||
{ value: '3.11', label: '3.11' },
|
||||
{ value: '3.10', label: '3.10' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-orange-600">RABBITMQ_HOST, RABBITMQ_USER, AMQP_URL</p>
|
||||
</div>
|
||||
@@ -1896,17 +1877,18 @@ export default function DeployPage() {
|
||||
<div className="mt-3 pt-3 border-t border-yellow-200 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs font-medium text-gray-600 shrink-0">{dw.versionColon}</label>
|
||||
<select
|
||||
className="select-compact flex-1 min-w-0"
|
||||
<Select
|
||||
className="flex-1 min-w-0"
|
||||
ariaLabel="Elasticsearch version"
|
||||
value={form.elasticsearchVersion || '8.12'}
|
||||
onChange={(e) => setForm({ ...form, elasticsearchVersion: e.target.value })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<option value="8.12">8.12 (Latest)</option>
|
||||
<option value="8.11">8.11</option>
|
||||
<option value="7.17">7.17 (LTS)</option>
|
||||
<option value="7.10">7.10</option>
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, elasticsearchVersion: v })}
|
||||
options={[
|
||||
{ value: '8.12', label: '8.12 (Latest)' },
|
||||
{ value: '8.11', label: '8.11' },
|
||||
{ value: '7.17', label: '7.17 (LTS)' },
|
||||
{ value: '7.10', label: '7.10' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-yellow-600">{dw.logsViaFluentBit}</p>
|
||||
</div>
|
||||
@@ -2305,39 +2287,63 @@ export default function DeployPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuRequest}</label>
|
||||
<select className="input-field" value={form.cpuRequest} onChange={(e) => setForm({ ...form, cpuRequest: e.target.value })}>
|
||||
<option value="50m">50m (0.05 core)</option>
|
||||
<option value="100m">100m (0.1 core)</option>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.cpuRequest}
|
||||
value={form.cpuRequest || '100m'}
|
||||
onChange={(v) => setForm({ ...form, cpuRequest: v })}
|
||||
options={[
|
||||
{ value: '50m', label: '50m (0.05 core)' },
|
||||
{ value: '100m', label: '100m (0.1 core)' },
|
||||
{ value: '250m', label: '250m (0.25 core)' },
|
||||
{ value: '500m', label: '500m (0.5 core)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.cpuLimit}</label>
|
||||
<select className="input-field" value={form.cpuLimit} onChange={(e) => setForm({ ...form, cpuLimit: e.target.value })}>
|
||||
<option value="250m">250m (0.25 core)</option>
|
||||
<option value="500m">500m (0.5 core)</option>
|
||||
<option value="1">1 core</option>
|
||||
<option value="2">2 cores</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.cpuLimit}
|
||||
value={form.cpuLimit || '500m'}
|
||||
onChange={(v) => setForm({ ...form, cpuLimit: v })}
|
||||
options={[
|
||||
{ value: '250m', label: '250m (0.25 core)' },
|
||||
{ value: '500m', label: '500m (0.5 core)' },
|
||||
{ value: '1', label: '1 core' },
|
||||
{ value: '2', label: '2 cores' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryRequest}</label>
|
||||
<select className="input-field" value={form.memoryRequest} onChange={(e) => setForm({ ...form, memoryRequest: e.target.value })}>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.memoryRequest}
|
||||
value={form.memoryRequest || '128Mi'}
|
||||
onChange={(v) => setForm({ ...form, memoryRequest: v })}
|
||||
options={[
|
||||
{ value: '64Mi', label: '64 Mi' },
|
||||
{ value: '128Mi', label: '128 Mi' },
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{dw.memoryLimit}</label>
|
||||
<select className="input-field" value={form.memoryLimit} onChange={(e) => setForm({ ...form, memoryLimit: e.target.value })}>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={dw.memoryLimit}
|
||||
value={form.memoryLimit || '512Mi'}
|
||||
onChange={(v) => setForm({ ...form, memoryLimit: v })}
|
||||
options={[
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
{ value: '1Gi', label: '1 Gi' },
|
||||
{ value: '2Gi', label: '2 Gi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { Application, LogEntry, LogSearchResult, LogStatsResult } from '@/types';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
@@ -223,85 +224,69 @@ function LogsPageContent() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.resource}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={lg.resource}
|
||||
value={appId}
|
||||
onChange={(e) => {
|
||||
setAppId(e.target.value);
|
||||
onChange={(v) => {
|
||||
setAppId(v);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
<option value="">{lg.allResources}</option>
|
||||
{applications.length > 0 && (
|
||||
<optgroup label={lg.applicationsGroup}>
|
||||
{applications.map((app) => (
|
||||
<option key={app.id} value={app.id}>
|
||||
{app.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
{managedServices.length > 0 && (
|
||||
<optgroup label={lg.servicesGroup}>
|
||||
{managedServices.map((svc) => (
|
||||
<option key={svc.id} value={svc.id}>
|
||||
{svc.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
)}
|
||||
</select>
|
||||
options={[
|
||||
{ value: '', label: lg.allResources },
|
||||
...(applications.length > 0
|
||||
? [
|
||||
{ value: '__apps__', label: lg.applicationsGroup, disabled: true },
|
||||
...applications.map((app) => ({ value: app.id, label: app.name })),
|
||||
]
|
||||
: []),
|
||||
...(managedServices.length > 0
|
||||
? [
|
||||
{ value: '__svcs__', label: lg.servicesGroup, disabled: true },
|
||||
...managedServices.map((svc) => ({ value: svc.id, label: svc.name })),
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.source}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={lg.source}
|
||||
value={workload}
|
||||
onChange={(e) => {
|
||||
setWorkload(e.target.value);
|
||||
onChange={(v) => {
|
||||
setWorkload(v);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{workloadOptions.map((w) => (
|
||||
<option key={w.value || 'all'} value={w.value}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={workloadOptions.map((w) => ({ value: w.value, label: w.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.level}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={lg.level}
|
||||
value={level}
|
||||
onChange={(e) => {
|
||||
setLevel(e.target.value);
|
||||
onChange={(v) => {
|
||||
setLevel(v);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{LOG_LEVELS.map((l) => (
|
||||
<option key={l.value || 'all'} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={LOG_LEVELS.map((l) => ({ value: l.value, label: l.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.timeRange}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={lg.timeRange}
|
||||
value={timeRange}
|
||||
onChange={(e) => {
|
||||
setTimeRange(e.target.value);
|
||||
onChange={(v) => {
|
||||
setTimeRange(v);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input w-full text-sm"
|
||||
>
|
||||
{TIME_RANGES.map((tr) => (
|
||||
<option key={tr.value} value={tr.value}>
|
||||
{tr.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options={TIME_RANGES.map((tr) => ({ value: tr.value, label: tr.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{lg.search}</label>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { useLocalizedRouter } from '@/i18n/navigation';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import type {
|
||||
Application,
|
||||
CreateApplicationDto,
|
||||
@@ -416,17 +417,14 @@ export default function NewManagedServicePage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{s.redisVersion}</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
<Select
|
||||
size="md"
|
||||
className="max-w-xs"
|
||||
ariaLabel={s.redisVersion}
|
||||
value={form.redisVersion}
|
||||
onChange={(e) => setForm({ ...form, redisVersion: e.target.value })}
|
||||
>
|
||||
{['7.2', '7.0', '6.2', '6.0'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, redisVersion: v })}
|
||||
options={['7.2', '7.0', '6.2', '6.0'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</div>
|
||||
{form.optionalServiceResources?.redis && (
|
||||
<OptionalServiceResourceFields
|
||||
@@ -453,17 +451,14 @@ export default function NewManagedServicePage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{s.rabbitmqVersion}</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
<Select
|
||||
size="md"
|
||||
className="max-w-xs"
|
||||
ariaLabel={s.rabbitmqVersion}
|
||||
value={form.rabbitmqVersion}
|
||||
onChange={(e) => setForm({ ...form, rabbitmqVersion: e.target.value })}
|
||||
>
|
||||
{['3.13', '3.12', '3.11', '3.10'].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, rabbitmqVersion: v })}
|
||||
options={['3.13', '3.12', '3.11', '3.10'].map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</div>
|
||||
{form.optionalServiceResources?.rabbitmq && (
|
||||
<OptionalServiceResourceFields
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { Ticket, CreateTicketDto, TicketDepartment, TicketPriority } from '@/types';
|
||||
import { Wrench, Briefcase, Ticket as TicketIcon, X } from 'lucide-react';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-yellow-100 text-yellow-700',
|
||||
@@ -103,27 +104,27 @@ export default function TicketsPage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.departmentField}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={tk.departmentField}
|
||||
value={form.department}
|
||||
onChange={(e) => setForm({ ...form, department: e.target.value as TicketDepartment })}
|
||||
>
|
||||
{availableDepartments.map((dept) => (
|
||||
<option key={dept.value} value={dept.value}>{dept.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setForm({ ...form, department: v as TicketDepartment })}
|
||||
options={availableDepartments.map((dept) => ({ value: dept.value, label: dept.label }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{tk.priorityField}</label>
|
||||
<select
|
||||
className="input-field"
|
||||
value={form.priority}
|
||||
onChange={(e) => setForm({ ...form, priority: e.target.value as TicketPriority })}
|
||||
>
|
||||
<option value="low">{tk.priority.low}</option>
|
||||
<option value="medium">{tk.priority.medium}</option>
|
||||
<option value="high">{tk.priority.high}</option>
|
||||
</select>
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={tk.priorityField}
|
||||
value={form.priority || 'low'}
|
||||
onChange={(v) => setForm({ ...form, priority: v as TicketPriority })}
|
||||
options={[
|
||||
{ value: 'low', label: tk.priority.low },
|
||||
{ value: 'medium', label: tk.priority.medium },
|
||||
{ value: 'high', label: tk.priority.high },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Database } from 'lucide-react';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { Select } from '@/components/ui/select';
|
||||
|
||||
export interface DatabaseWorkloadValues {
|
||||
cpuRequest: string;
|
||||
@@ -27,55 +28,63 @@ export function DatabaseWorkloadResources({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.cpuRequest}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.cpuRequest}
|
||||
value={values.cpuRequest}
|
||||
onChange={(e) => onChange({ cpuRequest: e.target.value })}
|
||||
>
|
||||
<option value="50m">50m</option>
|
||||
<option value="100m">100m</option>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ cpuRequest: v })}
|
||||
options={[
|
||||
{ value: '50m', label: '50m' },
|
||||
{ value: '100m', label: '100m' },
|
||||
{ value: '250m', label: '250m' },
|
||||
{ value: '500m', label: '500m' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.cpuLimit}
|
||||
value={values.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="250m">250m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">{r.oneCore}</option>
|
||||
<option value="2">{r.twoCores}</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ cpuLimit: v })}
|
||||
options={[
|
||||
{ value: '250m', label: '250m' },
|
||||
{ value: '500m', label: '500m' },
|
||||
{ value: '1', label: r.oneCore },
|
||||
{ value: '2', label: r.twoCores },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.memoryRequest}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.memoryRequest}
|
||||
value={values.memoryRequest}
|
||||
onChange={(e) => onChange({ memoryRequest: e.target.value })}
|
||||
>
|
||||
<option value="64Mi">64 Mi</option>
|
||||
<option value="128Mi">128 Mi</option>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ memoryRequest: v })}
|
||||
options={[
|
||||
{ value: '64Mi', label: '64 Mi' },
|
||||
{ value: '128Mi', label: '128 Mi' },
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.memoryLimit}
|
||||
value={values.memoryLimit}
|
||||
onChange={(e) => onChange({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ memoryLimit: v })}
|
||||
options={[
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
{ value: '1Gi', label: '1 Gi' },
|
||||
{ value: '2Gi', label: '2 Gi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
|
||||
import { Database, Eye, EyeOff, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||||
|
||||
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
||||
@@ -101,17 +102,17 @@ export function ManagedDatabaseConfig({
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
<Select
|
||||
size="md"
|
||||
className="max-w-xs"
|
||||
ariaLabel={c.version}
|
||||
value={form.dbVersion}
|
||||
onChange={(e) => onChange({ dbVersion: e.target.value })}
|
||||
>
|
||||
{versionOptions(form.databaseType).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{versionLabel(form.databaseType, v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => onChange({ dbVersion: v })}
|
||||
options={versionOptions(form.databaseType).map((v) => ({
|
||||
value: v,
|
||||
label: versionLabel(form.databaseType, v),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Server } from 'lucide-react';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import type { OptionalServiceResourceConfig } from '@/types';
|
||||
|
||||
export function OptionalServiceResourceFields({
|
||||
@@ -34,31 +35,35 @@ export function OptionalServiceResourceFields({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.cpuLimit}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.cpuLimit}
|
||||
disabled={readOnly}
|
||||
value={config.cpuLimit}
|
||||
onChange={(e) => onChange({ cpuLimit: e.target.value })}
|
||||
>
|
||||
<option value="200m">200m</option>
|
||||
<option value="500m">500m</option>
|
||||
<option value="1">{r.oneCore}</option>
|
||||
<option value="2">{r.twoCores}</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ cpuLimit: v })}
|
||||
options={[
|
||||
{ value: '200m', label: '200m' },
|
||||
{ value: '500m', label: '500m' },
|
||||
{ value: '1', label: r.oneCore },
|
||||
{ value: '2', label: r.twoCores },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.memoryLimit}</label>
|
||||
<select
|
||||
className="input-field text-sm"
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={r.memoryLimit}
|
||||
disabled={readOnly}
|
||||
value={config.memoryLimit}
|
||||
onChange={(e) => onChange({ memoryLimit: e.target.value })}
|
||||
>
|
||||
<option value="256Mi">256 Mi</option>
|
||||
<option value="512Mi">512 Mi</option>
|
||||
<option value="1Gi">1 Gi</option>
|
||||
<option value="2Gi">2 Gi</option>
|
||||
</select>
|
||||
onChange={(v) => onChange({ memoryLimit: v })}
|
||||
options={[
|
||||
{ value: '256Mi', label: '256 Mi' },
|
||||
{ value: '512Mi', label: '512 Mi' },
|
||||
{ value: '1Gi', label: '1 Gi' },
|
||||
{ value: '2Gi', label: '2 Gi' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-gray-500 mb-1">{r.storageGb}</label>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import type { Application, ServiceAccessGrant, ServiceAccessTarget } from '@/types';
|
||||
import { ExternalLink, ShieldAlert, Copy, Check, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
@@ -129,17 +130,13 @@ export function ServiceExternalAccessPanel({
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="flex-1 min-w-[160px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.service}</label>
|
||||
<select
|
||||
<Select
|
||||
size="md"
|
||||
ariaLabel={ea.service}
|
||||
value={accessTarget}
|
||||
onChange={(e) => setAccessTarget(e.target.value as ServiceAccessTarget)}
|
||||
className="input-field w-full text-sm"
|
||||
>
|
||||
{accessTargetOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setAccessTarget(v as ServiceAccessTarget)}
|
||||
options={accessTargetOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label className="text-xs font-medium text-gray-600 block mb-1">{ea.accessMode}</label>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ChevronDown, Check } from 'lucide-react';
|
||||
|
||||
export type SelectOption = { value: string; label: string; disabled?: boolean };
|
||||
|
||||
/**
|
||||
* Lightweight custom dropdown. Unlike a native <select>, its option list is
|
||||
* absolutely anchored right below the trigger, so it can never render detached
|
||||
* at the top of the screen (a common mobile/emulator quirk). Flips above the
|
||||
* trigger automatically when there isn't enough room below.
|
||||
*/
|
||||
export function Select({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className = '',
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
size = 'sm',
|
||||
disabled = false,
|
||||
}: {
|
||||
value: string;
|
||||
options: SelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
/** sm: compact inline pickers; md: matches the .input-field form control. */
|
||||
size?: 'sm' | 'md';
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [flipUp, setFlipUp] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const toggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
if (!open && rootRef.current) {
|
||||
const rect = rootRef.current.getBoundingClientRect();
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setFlipUp(spaceBelow < 240 && rect.top > spaceBelow);
|
||||
}
|
||||
setOpen((o) => !o);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
className={`w-full flex items-center justify-between gap-2 border border-gray-300 bg-white text-sm text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500 transition-all duration-150 disabled:opacity-60 disabled:cursor-not-allowed disabled:bg-gray-50 ${
|
||||
size === 'md' ? 'rounded-xl px-3.5 py-2.5' : 'rounded-lg px-2.5 py-1.5'
|
||||
}`}
|
||||
>
|
||||
<span className={`truncate ${selected ? '' : 'text-gray-400'}`}>
|
||||
{selected?.label ?? placeholder ?? value}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className={`absolute z-50 left-0 right-0 max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black/5 ${
|
||||
flipUp ? 'bottom-full mb-1' : 'top-full mt-1'
|
||||
}`}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<li
|
||||
key={opt.value}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={opt.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (opt.disabled) return;
|
||||
onChange(opt.value);
|
||||
close();
|
||||
}}
|
||||
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
opt.disabled
|
||||
? 'text-gray-300 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-primary-50/70 text-primary-700 font-medium cursor-pointer'
|
||||
: 'text-gray-700 hover:bg-gray-50 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{opt.label}</span>
|
||||
{isSelected && <Check className="w-4 h-4 text-primary-600 shrink-0" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user