Localize database-config, snapshots panel and new-service wizard.
Add components.dbConfig / components.snapshots and dashboard.servicesNew dictionaries; move the managed-database config (engine, credentials, dump upload, storage), the snapshots panel and the three-step new-service wizard onto them. validateDbDumpStorage now takes a localized template. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useT, useLocale } from '@/i18n/I18nProvider';
|
||||
import type { AppSnapshot } from '@/types';
|
||||
import { formatBytes } from '@/lib/format-utils';
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ function BackupProgressBar({ progress, label }: { progress: number; label?: stri
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-blue-700">
|
||||
<span>{label || 'Creating database dump…'}</span>
|
||||
<span>{label}</span>
|
||||
<span className="font-semibold tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-blue-100 rounded-full h-2.5 overflow-hidden">
|
||||
@@ -54,6 +55,10 @@ export function DatabaseSnapshotsPanel({
|
||||
serviceId: string;
|
||||
isDeployed: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const sn = t.components.snapshots;
|
||||
const locale = useLocale();
|
||||
const snapStatusLabel = (s: string) => (sn.snapStatus as Record<string, string>)[s] ?? s;
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
@@ -82,10 +87,10 @@ export function DatabaseSnapshotsPanel({
|
||||
snap.status === 'completed' &&
|
||||
snap.dbDumpPath
|
||||
) {
|
||||
toast.success('Backup ready — you can download the dump now');
|
||||
toast.success(sn.backupReady);
|
||||
}
|
||||
if (prevInProgressRef.current.has(snap.id) && snap.status === 'failed') {
|
||||
toast.error(snap.errorMessage || 'Backup failed');
|
||||
toast.error(snap.errorMessage || sn.backupFailed);
|
||||
}
|
||||
}
|
||||
prevInProgressRef.current = inProgressIds;
|
||||
@@ -94,18 +99,18 @@ export function DatabaseSnapshotsPanel({
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post(
|
||||
`/snapshots/applications/${serviceId}?label=${encodeURIComponent('Database backup')}`,
|
||||
`/snapshots/applications/${serviceId}?label=${encodeURIComponent(sn.defaultLabel)}`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowPanel(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||
toast.info('Backup started — dump in progress');
|
||||
toast.info(sn.backupStarted);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { message?: string | string[] } } })?.response?.data
|
||||
?.message;
|
||||
const text = Array.isArray(msg) ? msg.join(', ') : msg;
|
||||
toast.error(text || 'Failed to create backup');
|
||||
toast.error(text || sn.createFailed);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,9 +125,9 @@ export function DatabaseSnapshotsPanel({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['snapshots', serviceId] });
|
||||
toast.success('Backup deleted');
|
||||
toast.success(sn.backupDeleted);
|
||||
},
|
||||
onError: () => toast.error('Failed to delete backup'),
|
||||
onError: () => toast.error(sn.deleteFailed),
|
||||
});
|
||||
|
||||
const downloadSnapshotDb = (snapshotId: string) => {
|
||||
@@ -139,16 +144,16 @@ export function DatabaseSnapshotsPanel({
|
||||
link.download = `database-backup-${snapshotId.slice(0, 8)}.sql`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
toast.success('Download started');
|
||||
toast.success(sn.downloadStarted);
|
||||
})
|
||||
.catch(() => toast.error('Failed to download database dump'));
|
||||
.catch(() => toast.error(sn.downloadFailed));
|
||||
};
|
||||
|
||||
const handleDelete = async (snap: AppSnapshot) => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete backup?',
|
||||
message: `Delete "${snap.label || 'backup'}"? The dump file will be permanently removed.`,
|
||||
confirmText: 'Delete',
|
||||
title: sn.deleteConfirmTitle,
|
||||
message: sn.deleteConfirmMessage.replace('{label}', snap.label || sn.defaultLabel),
|
||||
confirmText: t.common.delete,
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteMutation.mutate(snap.id);
|
||||
@@ -163,7 +168,7 @@ export function DatabaseSnapshotsPanel({
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<History className="w-5 h-5" /> Snapshots
|
||||
<History className="w-5 h-5" /> {sn.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -173,30 +178,30 @@ export function DatabaseSnapshotsPanel({
|
||||
className="btn-secondary text-sm disabled:opacity-50"
|
||||
title={
|
||||
!isDeployed
|
||||
? 'Deploy the service first'
|
||||
? sn.deployFirst
|
||||
: hasInProgress
|
||||
? 'Wait for the current backup to finish'
|
||||
? sn.waitCurrent
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{createMutation.isPending || hasInProgress ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> Creating…
|
||||
<Clock className="w-3 h-3 inline animate-spin" /> {sn.creating}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Camera className="w-3 h-3 inline" /> New Snapshot
|
||||
<Camera className="w-3 h-3 inline" /> {sn.newSnapshot}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowPanel(!showPanel)} className="btn-secondary text-sm">
|
||||
{showPanel ? (
|
||||
<>
|
||||
<ChevronDown className="w-4 h-4 inline" /> Hide
|
||||
<ChevronDown className="w-4 h-4 inline" /> {sn.hide}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<History className="w-4 h-4 inline" /> Show
|
||||
<History className="w-4 h-4 inline" /> {sn.show}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -207,19 +212,17 @@ export function DatabaseSnapshotsPanel({
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<Camera className="w-3 h-3 inline" /> <strong>Database snapshots</strong> store a SQL dump you can
|
||||
download later. When progress reaches 100%, use the download button. Up to 10 snapshots are kept;
|
||||
oldest are removed automatically.
|
||||
<Camera className="w-3 h-3 inline" /> {sn.infoNote}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-400 text-sm">Loading backups…</div>
|
||||
<div className="text-center py-8 text-gray-400 text-sm">{sn.loadingBackups}</div>
|
||||
) : snapshots.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Camera className="w-8 h-8 mx-auto text-gray-300 mb-2" />
|
||||
<p className="text-gray-500 text-sm">No backups yet</p>
|
||||
<p className="text-gray-400 text-xs mt-1">Click New backup to create your first database dump.</p>
|
||||
<p className="text-gray-500 text-sm">{sn.noBackups}</p>
|
||||
<p className="text-gray-400 text-xs mt-1">{sn.noBackupsHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||
@@ -237,36 +240,36 @@ export function DatabaseSnapshotsPanel({
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || 'Database backup'}</p>
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{snap.label || sn.defaultLabel}</p>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
snap.type === 'pre_deploy' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{snap.type === 'pre_deploy' ? 'Auto' : 'Manual'}
|
||||
{snap.type === 'pre_deploy' ? sn.auto : sn.manual}
|
||||
</span>
|
||||
<span className={`badge ${statusColors[snap.status] || 'badge-gray'} text-xs`}>
|
||||
{snap.status === 'in_progress' ? 'Dumping…' : snap.status}
|
||||
{snap.status === 'in_progress' ? sn.dumping : snapStatusLabel(snap.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString()}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{new Date(snap.createdAt).toLocaleString(locale)}</p>
|
||||
|
||||
{snap.status === 'in_progress' && (
|
||||
<BackupProgressBar
|
||||
progress={snap.progress ?? 0}
|
||||
label={
|
||||
(snap.progress ?? 0) < 15
|
||||
? 'Preparing dump…'
|
||||
? sn.preparing
|
||||
: (snap.progress ?? 0) < 90
|
||||
? 'Exporting database…'
|
||||
: 'Finalizing…'
|
||||
? sn.exporting
|
||||
: sn.finalizing
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snap.status === 'completed' && snap.dbDumpPath && (
|
||||
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||
<Database className="w-3 h-3" /> Dump: {formatBytes(snap.dbDumpSize)}
|
||||
<Database className="w-3 h-3" /> {sn.dump}: {formatBytes(snap.dbDumpSize)}
|
||||
</p>
|
||||
)}
|
||||
{snap.errorMessage && (
|
||||
@@ -281,7 +284,7 @@ export function DatabaseSnapshotsPanel({
|
||||
type="button"
|
||||
onClick={() => downloadSnapshotDb(snap.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg"
|
||||
title="Download database dump"
|
||||
title={sn.downloadTitle}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -290,7 +293,7 @@ export function DatabaseSnapshotsPanel({
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deletingSnapshotId !== null}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg disabled:opacity-50"
|
||||
title="Delete backup"
|
||||
title={sn.deleteTitle}
|
||||
>
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
@@ -306,7 +309,7 @@ export function DatabaseSnapshotsPanel({
|
||||
onClick={() => handleDelete(snap)}
|
||||
disabled={deletingSnapshotId !== null}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg shrink-0 disabled:opacity-50"
|
||||
title="Remove failed backup"
|
||||
title={sn.removeFailedTitle}
|
||||
>
|
||||
{deletingSnapshotId === snap.id ? (
|
||||
<Clock className="w-4 h-4 animate-spin" />
|
||||
@@ -321,7 +324,7 @@ export function DatabaseSnapshotsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400 text-center">Maximum 10 backups are kept.</p>
|
||||
<p className="text-xs text-gray-400 text-center">{sn.maxKept}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,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 { ONE_GIB, minGiToFitFileBytes } from '@/lib/storage-utils';
|
||||
|
||||
export type DatabaseEngine = 'postgresql' | 'mysql' | 'mariadb' | 'mongodb';
|
||||
@@ -45,6 +46,7 @@ export function ManagedDatabaseConfig({
|
||||
dbDumpFile: File | null;
|
||||
onDbDumpFileChange: (file: File | null) => void;
|
||||
}) {
|
||||
const c = useT().components.dbConfig;
|
||||
const dbDumpInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -54,11 +56,11 @@ export function ManagedDatabaseConfig({
|
||||
|
||||
const acceptDump = (f: File) => {
|
||||
if (!f.name.endsWith('.sql') && !f.name.endsWith('.gz') && !f.name.endsWith('.dump')) {
|
||||
toast.error('Allowed: .sql, .gz, .dump');
|
||||
toast.error(c.allowedFormats);
|
||||
return;
|
||||
}
|
||||
if (f.size > 500 * 1024 * 1024) {
|
||||
toast.error('Max 500MB');
|
||||
toast.error(c.maxSize);
|
||||
return;
|
||||
}
|
||||
onDbDumpFileChange(f);
|
||||
@@ -70,7 +72,7 @@ export function ManagedDatabaseConfig({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Database engine</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{c.engine}</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{DB_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -98,7 +100,7 @@ export function ManagedDatabaseConfig({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{c.version}</label>
|
||||
<select
|
||||
className="input-field max-w-xs"
|
||||
value={form.dbVersion}
|
||||
@@ -115,30 +117,30 @@ export function ManagedDatabaseConfig({
|
||||
<div className="p-4 bg-blue-50/50 border border-blue-200 rounded-xl space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-800">Database credentials</h3>
|
||||
<span className="text-xs text-gray-400">(optional — auto-generated if empty)</span>
|
||||
<h3 className="text-sm font-semibold text-gray-800">{c.credentials}</h3>
|
||||
<span className="text-xs text-gray-400">{c.credentialsOptional}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Username</label>
|
||||
<label className="block text-xs text-gray-500 mb-1">{c.username}</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="appuser"
|
||||
placeholder={c.usernamePlaceholder}
|
||||
value={form.dbUsername}
|
||||
onChange={(e) => onChange({ dbUsername: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Password</label>
|
||||
<label className="block text-xs text-gray-500 mb-1">{c.password}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
className="input-field pr-20"
|
||||
className="input-field pr-20 rtl:pr-3 rtl:pl-20"
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
placeholder="Auto-generated"
|
||||
placeholder={c.passwordPlaceholder}
|
||||
value={form.dbPassword}
|
||||
onChange={(e) => onChange({ dbPassword: e.target.value })}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
|
||||
<div className="absolute inset-y-0 right-0 rtl:right-auto rtl:left-0 flex items-center gap-1 pr-2 rtl:pr-0 rtl:pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -149,7 +151,7 @@ export function ManagedDatabaseConfig({
|
||||
setShowDbPassword(true);
|
||||
}}
|
||||
className="p-1 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
title="Generate random password"
|
||||
title={c.generatePassword}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -164,12 +166,10 @@ export function ManagedDatabaseConfig({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Used for internal cluster access. Use temporary or permanent external access below after deploy.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{c.credentialsNote}</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Optional: upload DB dump to restore at creation</label>
|
||||
<label className="block text-xs text-gray-500 mb-2">{c.uploadDumpLabel}</label>
|
||||
<div
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -204,7 +204,7 @@ export function ManagedDatabaseConfig({
|
||||
/>
|
||||
{dbDumpFile ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-left">
|
||||
<div className="text-sm text-left rtl:text-right">
|
||||
<p className="font-medium text-gray-800">{dbDumpFile.name}</p>
|
||||
<p className="text-xs text-gray-500">{(dbDumpFile.size / (1024 * 1024)).toFixed(2)} MB</p>
|
||||
</div>
|
||||
@@ -216,20 +216,20 @@ export function ManagedDatabaseConfig({
|
||||
}}
|
||||
className="text-sm text-red-500"
|
||||
>
|
||||
Remove
|
||||
{c.remove}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">Upload a SQL dump to be restored after the database is created</p>
|
||||
<p className="text-xs text-gray-400">Optional • Max 500MB • .sql, .gz, .dump</p>
|
||||
<p className="text-sm text-gray-700">{c.uploadHint}</p>
|
||||
<p className="text-xs text-gray-400">{c.uploadConstraints}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label className="block text-xs text-gray-500 mb-2">Database storage size</label>
|
||||
<label className="block text-xs text-gray-500 mb-2">{c.storageSize}</label>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<button
|
||||
@@ -274,13 +274,13 @@ export function ManagedDatabaseConfig({
|
||||
<span className="text-sm font-medium text-gray-700">GB</span>
|
||||
{dbDumpFile && (
|
||||
<span className="text-xs text-blue-500">
|
||||
Suggested from dump ({(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB min fit)
|
||||
{c.suggestedFromDump.replace('{gi}', (dbDumpFile.size / ONE_GIB).toFixed(2))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
Minimum {minDbGiFromRestoreDump} GB
|
||||
{dbDumpFile ? ' (must fit the uploaded dump)' : ''} • Only expansion allowed after creation
|
||||
{c.minimumGb.replace('{n}', String(minDbGiFromRestoreDump))}
|
||||
{dbDumpFile ? c.mustFitDump : ''}{c.expansionOnly}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -288,14 +288,22 @@ export function ManagedDatabaseConfig({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the chosen DB storage fits the uploaded dump. Pass the localized
|
||||
* message template (components.dbConfig.storageTooSmallDetail) with {dumpGi}/{need}/{selected}.
|
||||
*/
|
||||
export function validateDbDumpStorage(
|
||||
dbDumpFile: File | null,
|
||||
dbStorageSizeGi: number,
|
||||
detailTemplate: string,
|
||||
): string | null {
|
||||
if (!dbDumpFile) return null;
|
||||
const need = minGiToFitFileBytes(dbDumpFile.size);
|
||||
if (dbStorageSizeGi < need) {
|
||||
return `Your database dump is about ${(dbDumpFile.size / ONE_GIB).toFixed(2)} GiB. Database storage must be at least ${need} GiB (you selected ${dbStorageSizeGi} GiB). Increase database storage, then try again.`;
|
||||
return detailTemplate
|
||||
.replace('{dumpGi}', (dbDumpFile.size / ONE_GIB).toFixed(2))
|
||||
.replace('{need}', String(need))
|
||||
.replace('{selected}', String(dbStorageSizeGi));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -309,6 +317,7 @@ export function RestoreStorageErrorModal({
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const c = useT().components.dbConfig;
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
@@ -322,12 +331,12 @@ export function RestoreStorageErrorModal({
|
||||
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">Storage too small</h3>
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2">{c.storageTooSmallTitle}</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<button type="button" onClick={onClose} className="btn-primary">
|
||||
OK
|
||||
{c.ok}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user