Migrate frontend to shadcn/ui + add Gitea branch panel in endpoint editor
shadcn/ui migration: - Installed shadcn components: Button, Input, Label, Card, Badge, Checkbox, Switch, Tabs, Dialog, Select, Tooltip, Toaster (sonner) - Migrated all pages: Login, Dashboard, Settings, Endpoints, EndpointEditor, Folders, ApiKeys, Logs - Replaced react-hot-toast with sonner - Added CSS variables for theming - Updated tailwind.config.js for shadcn Gitea branch panel in EndpointEditor: - GiteaBranchPanel component: branch selector, save to branch, compare with main, create PR, merge, commit history - New backend endpoints: getEndpointFileContent, commitEndpoint, getEndpointCommits, getEndpointFromBranch, commitEndpointToBranch - Frontend giteaApi: extended with endpoint-specific methods Fixes: - Fixed sync-all PostgreSQL-compatible UPDATE LIMIT - .gitignore: added tmp/ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,3 +13,5 @@ coverage/
|
|||||||
*.sw?
|
*.sw?
|
||||||
.claude
|
.claude
|
||||||
.git_backup/
|
.git_backup/
|
||||||
|
tmp/
|
||||||
|
!tmp/.gitkeep
|
||||||
@@ -133,6 +133,42 @@ export const getEndpointInfo = async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getEndpointFileContent = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { branch } = req.query as { branch: string };
|
||||||
|
if (!branch) return res.status(400).json({ error: 'branch query param required' });
|
||||||
|
const content = await giteaService.getEndpointFromBranch(id, branch);
|
||||||
|
if (!content) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json(content);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commitEndpoint = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { branch, message, changes } = req.body;
|
||||||
|
if (!branch || !message) return res.status(400).json({ error: 'branch and message required' });
|
||||||
|
const result = await giteaService.commitEndpointToBranch(id, branch, message, changes || {});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEndpointCommits = async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { branch } = req.query as { branch?: string };
|
||||||
|
const commits = await giteaService.getEndpointCommitHistory(id, branch);
|
||||||
|
res.json(commits);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const createPR = async (req: AuthRequest, res: Response) => {
|
export const createPR = async (req: AuthRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { title, head, base, body } = req.body;
|
const { title, head, base, body } = req.body;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
compareBranches,
|
compareBranches,
|
||||||
getCommitHistory,
|
getCommitHistory,
|
||||||
getEndpointInfo,
|
getEndpointInfo,
|
||||||
|
getEndpointFileContent,
|
||||||
|
commitEndpoint,
|
||||||
|
getEndpointCommits,
|
||||||
createPR,
|
createPR,
|
||||||
mergePR,
|
mergePR,
|
||||||
} from '../controllers/giteaController';
|
} from '../controllers/giteaController';
|
||||||
@@ -40,6 +43,9 @@ router.get('/commits', getCommitHistory);
|
|||||||
|
|
||||||
// Endpoint-specific
|
// Endpoint-specific
|
||||||
router.get('/endpoints/:id/info', getEndpointInfo);
|
router.get('/endpoints/:id/info', getEndpointInfo);
|
||||||
|
router.get('/endpoints/:id/file-content', getEndpointFileContent);
|
||||||
|
router.post('/endpoints/:id/commit', commitEndpoint);
|
||||||
|
router.get('/endpoints/:id/commits', getEndpointCommits);
|
||||||
|
|
||||||
// Pull Requests
|
// Pull Requests
|
||||||
router.post('/pulls', createPR);
|
router.post('/pulls', createPR);
|
||||||
|
|||||||
@@ -419,6 +419,100 @@ class GiteaService {
|
|||||||
return { synced, errors };
|
return { synced, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Endpoint branch operations ---
|
||||||
|
|
||||||
|
private async getEndpointRepoPath(endpointId: string): Promise<{ basePath: string; endpoint: any } | null> {
|
||||||
|
const result = await mainPool.query(
|
||||||
|
`SELECT e.*, f.name as folder_name, d.name as database_name
|
||||||
|
FROM endpoints e
|
||||||
|
LEFT JOIN folders f ON e.folder_id = f.id
|
||||||
|
LEFT JOIN databases d ON e.database_id = d.id
|
||||||
|
WHERE e.id = $1`,
|
||||||
|
[endpointId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const ep = result.rows[0];
|
||||||
|
const folder = ep.folder_name ? this.sanitize(ep.folder_name) : '_root';
|
||||||
|
const basePath = `endpoints/${folder}/${this.sanitize(ep.name)}`;
|
||||||
|
return { basePath, endpoint: ep };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEndpointFromBranch(endpointId: string, branch: string): Promise<any> {
|
||||||
|
if (!await this.isEnabled()) return null;
|
||||||
|
const cfg = await this.getConfig();
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const info = await this.getEndpointRepoPath(endpointId);
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
const { basePath } = info;
|
||||||
|
|
||||||
|
const metaData = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${basePath}/endpoint.json?ref=${encodeURIComponent(branch)}`);
|
||||||
|
let metadata = null;
|
||||||
|
if (metaData?.content) {
|
||||||
|
metadata = JSON.parse(Buffer.from(metaData.content, 'base64').toString('utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = null;
|
||||||
|
const sqlData = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${basePath}/query.sql?ref=${encodeURIComponent(branch)}`);
|
||||||
|
if (sqlData?.content) {
|
||||||
|
query = Buffer.from(sqlData.content, 'base64').toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
let script = null;
|
||||||
|
for (const ext of ['js', 'py']) {
|
||||||
|
const scriptData = await this.api('GET', `/repos/${cfg.owner}/${cfg.repo}/contents/${basePath}/main.${ext}?ref=${encodeURIComponent(branch)}`);
|
||||||
|
if (scriptData?.content) {
|
||||||
|
script = Buffer.from(scriptData.content, 'base64').toString('utf-8');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { metadata, query, script, branch, repo_path: basePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitEndpointToBranch(
|
||||||
|
endpointId: string,
|
||||||
|
branch: string,
|
||||||
|
message: string,
|
||||||
|
changes: { query?: string; script?: string; metadata?: any }
|
||||||
|
): Promise<{ sha: string | null }> {
|
||||||
|
if (!await this.isEnabled()) throw new Error('Gitea not enabled');
|
||||||
|
|
||||||
|
const info = await this.getEndpointRepoPath(endpointId);
|
||||||
|
if (!info) throw new Error('Endpoint not found');
|
||||||
|
|
||||||
|
const { basePath } = info;
|
||||||
|
let lastSha: string | null = null;
|
||||||
|
|
||||||
|
if (changes.metadata) {
|
||||||
|
lastSha = await this.commitFile(
|
||||||
|
`${basePath}/endpoint.json`,
|
||||||
|
JSON.stringify(changes.metadata, null, 2),
|
||||||
|
message,
|
||||||
|
branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.query !== undefined) {
|
||||||
|
lastSha = await this.commitFile(`${basePath}/query.sql`, changes.query, message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.script !== undefined) {
|
||||||
|
const ext = changes.metadata?.script_language === 'python' ? 'py' : 'js';
|
||||||
|
lastSha = await this.commitFile(`${basePath}/main.${ext}`, changes.script, message, branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sha: lastSha };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEndpointCommitHistory(endpointId: string, branch?: string): Promise<GiteaCommit[]> {
|
||||||
|
const info = await this.getEndpointRepoPath(endpointId);
|
||||||
|
if (!info) return [];
|
||||||
|
return this.getCommitHistory(info.basePath, branch);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
async getEndpointGiteaInfo(endpointId: string): Promise<any> {
|
async getEndpointGiteaInfo(endpointId: string): Promise<any> {
|
||||||
|
|||||||
20
frontend/components.json
Normal file
20
frontend/components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
1589
frontend/package-lock.json
generated
1589
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,21 +17,38 @@
|
|||||||
"@dagrejs/dagre": "^1.1.8",
|
"@dagrejs/dagre": "^1.1.8",
|
||||||
"@hookform/resolvers": "^3.3.3",
|
"@hookform/resolvers": "^3.3.3",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-query": "^5.14.2",
|
"@tanstack/react-query": "^5.14.2",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
"framer-motion": "^10.16.16",
|
"framer-motion": "^10.16.16",
|
||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.49.2",
|
"react-hook-form": "^7.49.2",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.4.1",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-router-dom": "^6.20.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.2.0",
|
"tailwind-merge": "^2.2.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { authApi } from '@/services/api';
|
import { authApi } from '@/services/api';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -171,7 +171,7 @@ function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" richColors />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { X } from 'lucide-react';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
interface DialogProps {
|
interface DialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,43 +30,25 @@ export default function Dialog({
|
|||||||
confirmText = 'OK',
|
confirmText = 'OK',
|
||||||
cancelText = 'Отмена',
|
cancelText = 'Отмена',
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (onConfirm) {
|
onConfirm?.();
|
||||||
onConfirm();
|
|
||||||
}
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100] p-4">
|
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<div className="bg-white rounded-lg max-w-md w-full shadow-xl">
|
<AlertDialogContent>
|
||||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
<AlertDialogHeader>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
<button
|
<AlertDialogDescription className="whitespace-pre-wrap">{message}</AlertDialogDescription>
|
||||||
onClick={onClose}
|
</AlertDialogHeader>
|
||||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
<AlertDialogFooter>
|
||||||
>
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{message}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 p-4 border-t border-gray-200 justify-end">
|
|
||||||
{type === 'confirm' && (
|
{type === 'confirm' && (
|
||||||
<button onClick={onClose} className="btn btn-secondary">
|
<AlertDialogCancel onClick={onClose}>{cancelText}</AlertDialogCancel>
|
||||||
{cancelText}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button onClick={handleConfirm} className="btn btn-primary">
|
<AlertDialogAction onClick={handleConfirm}>{confirmText}</AlertDialogAction>
|
||||||
{confirmText}
|
</AlertDialogFooter>
|
||||||
</button>
|
</AlertDialogContent>
|
||||||
</div>
|
</AlertDialog>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
391
frontend/src/components/GiteaBranchPanel.tsx
Normal file
391
frontend/src/components/GiteaBranchPanel.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { giteaApi } from '@/services/api';
|
||||||
|
import { GiteaBranch, GiteaCommit, GiteaEndpointInfo } from '@/types';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
GitBranch, GitCommit, GitPullRequest,
|
||||||
|
Plus, Save, Eye, ExternalLink, Loader2, RefreshCw, Check, ChevronDown, X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface GiteaBranchPanelProps {
|
||||||
|
endpointId: string;
|
||||||
|
endpointName: string;
|
||||||
|
giteaInfo: GiteaEndpointInfo;
|
||||||
|
currentQuery?: string;
|
||||||
|
currentScript?: string;
|
||||||
|
currentMetadata?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GiteaBranchPanel({
|
||||||
|
endpointId,
|
||||||
|
endpointName,
|
||||||
|
giteaInfo,
|
||||||
|
currentQuery,
|
||||||
|
currentScript,
|
||||||
|
currentMetadata,
|
||||||
|
}: GiteaBranchPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [selectedBranch, setSelectedBranch] = useState<string>('main');
|
||||||
|
const [showNewBranch, setShowNewBranch] = useState(false);
|
||||||
|
const [newBranchName, setNewBranchName] = useState('');
|
||||||
|
const [commitMessage, setCommitMessage] = useState('');
|
||||||
|
const [showCommitForm, setShowCommitForm] = useState(false);
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
const [showPrForm, setShowPrForm] = useState(false);
|
||||||
|
const [prTitle, setPrTitle] = useState('');
|
||||||
|
const [prBody, setPrBody] = useState('');
|
||||||
|
const [branchDropdownOpen, setBranchDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: branches = [], isLoading: branchesLoading, refetch: refetchBranches } = useQuery({
|
||||||
|
queryKey: ['gitea-branches'],
|
||||||
|
queryFn: () => giteaApi.listBranches().then(r => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: commits = [], isLoading: commitsLoading, refetch: refetchCommits } = useQuery({
|
||||||
|
queryKey: ['gitea-endpoint-commits', endpointId, selectedBranch],
|
||||||
|
queryFn: () => giteaApi.getEndpointCommits(endpointId, selectedBranch).then(r => r.data),
|
||||||
|
enabled: !!endpointId,
|
||||||
|
});
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['gitea-endpoint-content', endpointId, selectedBranch],
|
||||||
|
queryFn: () => giteaApi.getEndpointFileContent(endpointId, selectedBranch).then(r => r.data),
|
||||||
|
enabled: !!endpointId && selectedBranch !== 'main' && showDiff,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: diffData, isLoading: diffLoading } = useQuery({
|
||||||
|
queryKey: ['gitea-compare', selectedBranch],
|
||||||
|
queryFn: () => giteaApi.compareBranches('main', selectedBranch).then(r => r.data),
|
||||||
|
enabled: selectedBranch !== 'main' && showDiff,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createBranchMutation = useMutation({
|
||||||
|
mutationFn: () => giteaApi.createBranch(newBranchName, 'main'),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(`Ветка ${newBranchName} создана`);
|
||||||
|
setSelectedBranch(newBranchName);
|
||||||
|
setNewBranchName('');
|
||||||
|
setShowNewBranch(false);
|
||||||
|
refetchBranches();
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.error || 'Ошибка создания ветки'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitMutation = useMutation({
|
||||||
|
mutationFn: () => giteaApi.commitEndpoint(endpointId, {
|
||||||
|
branch: selectedBranch,
|
||||||
|
message: commitMessage,
|
||||||
|
changes: {
|
||||||
|
query: currentQuery,
|
||||||
|
script: currentScript,
|
||||||
|
metadata: currentMetadata,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Изменения сохранены в ветку');
|
||||||
|
setCommitMessage('');
|
||||||
|
setShowCommitForm(false);
|
||||||
|
refetchCommits();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-info'] });
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.error || 'Ошибка коммита'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPrMutation = useMutation({
|
||||||
|
mutationFn: () => giteaApi.createPR({
|
||||||
|
title: prTitle,
|
||||||
|
head: selectedBranch,
|
||||||
|
base: 'main',
|
||||||
|
body: prBody,
|
||||||
|
}),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
toast.success('Pull Request создан');
|
||||||
|
setPrTitle('');
|
||||||
|
setPrBody('');
|
||||||
|
setShowPrForm(false);
|
||||||
|
if (res.data?.html_url) {
|
||||||
|
window.open(res.data.html_url, '_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.error || 'Ошибка создания PR'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useMutation({
|
||||||
|
mutationFn: (index: number) => giteaApi.mergePR(index),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('PR смержен');
|
||||||
|
refetchBranches();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gitea-info'] });
|
||||||
|
},
|
||||||
|
onError: (e: any) => toast.error(e.response?.data?.error || 'Ошибка мержа'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const branchBadge = (name: string) => {
|
||||||
|
if (name === 'main' || name === 'master') return 'bg-green-100 text-green-700';
|
||||||
|
if (name.startsWith('drafts/')) return 'bg-orange-100 text-orange-700';
|
||||||
|
if (name.startsWith('feature/')) return 'bg-blue-100 text-blue-700';
|
||||||
|
return 'bg-gray-100 text-gray-700';
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeBranchName = () => {
|
||||||
|
const sanitized = endpointName.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_').toLowerCase();
|
||||||
|
return `feature/${sanitized}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
Gitea
|
||||||
|
</CardTitle>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => refetchBranches()}>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3 pt-0">
|
||||||
|
{/* Branch Selector */}
|
||||||
|
<div className="relative">
|
||||||
|
<Label className="text-xs mb-1">Ветка</Label>
|
||||||
|
<button
|
||||||
|
onClick={() => setBranchDropdownOpen(!branchDropdownOpen)}
|
||||||
|
className="flex items-center justify-between w-full h-8 px-2 text-xs rounded-md border border-input bg-transparent hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 truncate">
|
||||||
|
<Badge variant="secondary" className={cn('text-[10px] px-1.5 py-0', branchBadge(selectedBranch))}>
|
||||||
|
{selectedBranch}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{branchDropdownOpen && (
|
||||||
|
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-md">
|
||||||
|
<div className="max-h-40 overflow-y-auto p-1">
|
||||||
|
{branchesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
branches.map((b: GiteaBranch) => (
|
||||||
|
<button
|
||||||
|
key={b.name}
|
||||||
|
onClick={() => { setSelectedBranch(b.name); setBranchDropdownOpen(false); }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent',
|
||||||
|
selectedBranch === b.name && 'bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedBranch === b.name && <Check className="h-3 w-3" />}
|
||||||
|
<Badge variant="secondary" className={cn('text-[10px] px-1.5 py-0', branchBadge(b.name))}>
|
||||||
|
{b.name}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowNewBranch(true); setBranchDropdownOpen(false); setNewBranchName(safeBranchName()); }}
|
||||||
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-primary-600 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
Новая ветка
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Branch Form */}
|
||||||
|
{showNewBranch && (
|
||||||
|
<div className="space-y-2 p-2 rounded border bg-muted/50">
|
||||||
|
<Input
|
||||||
|
value={newBranchName}
|
||||||
|
onChange={(e) => setNewBranchName(e.target.value)}
|
||||||
|
placeholder="feature/my-change"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" className="h-6 text-xs" disabled={!newBranchName || createBranchMutation.isPending}
|
||||||
|
onClick={() => createBranchMutation.mutate()}>
|
||||||
|
{createBranchMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Создать'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setShowNewBranch(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Info */}
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{giteaInfo.file_url && (
|
||||||
|
<a href={giteaInfo.file_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-primary-600 hover:text-primary-700 truncate">
|
||||||
|
<ExternalLink className="h-3 w-3 shrink-0" />
|
||||||
|
Открыть в Gitea
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{giteaInfo.last_commit_sha && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500">
|
||||||
|
<GitCommit className="h-3 w-3" />
|
||||||
|
<a href={giteaInfo.commit_url || '#'} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="font-mono text-primary-600 hover:text-primary-700">
|
||||||
|
{giteaInfo.last_commit_sha.slice(0, 7)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!giteaInfo.last_commit_sha && (
|
||||||
|
<span className="text-gray-400">Не синхронизирован</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Tabs defaultValue="actions" className="w-full">
|
||||||
|
<TabsList className="w-full h-7">
|
||||||
|
<TabsTrigger value="actions" className="text-[10px] h-5">Действия</TabsTrigger>
|
||||||
|
<TabsTrigger value="history" className="text-[10px] h-5">История</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="actions" className="space-y-2 mt-2">
|
||||||
|
{/* Save to Branch */}
|
||||||
|
{!showCommitForm ? (
|
||||||
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs" onClick={() => setShowCommitForm(true)}>
|
||||||
|
<Save className="mr-1.5 h-3 w-3" />
|
||||||
|
Сохранить в ветку
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 p-2 rounded border bg-muted/50">
|
||||||
|
<Input
|
||||||
|
value={commitMessage}
|
||||||
|
onChange={(e) => setCommitMessage(e.target.value)}
|
||||||
|
placeholder="Сообщение коммита..."
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" className="h-6 text-xs" disabled={!commitMessage || commitMutation.isPending}
|
||||||
|
onClick={() => commitMutation.mutate()}>
|
||||||
|
{commitMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : `Коммит → ${selectedBranch}`}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setShowCommitForm(false)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compare with main */}
|
||||||
|
{selectedBranch !== 'main' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs"
|
||||||
|
onClick={() => setShowDiff(!showDiff)}>
|
||||||
|
<Eye className="mr-1.5 h-3 w-3" />
|
||||||
|
{showDiff ? 'Скрыть diff' : 'Сравнить с main'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showDiff && (
|
||||||
|
<div className="rounded border bg-muted/50 p-2 max-h-48 overflow-y-auto">
|
||||||
|
{diffLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : diffData ? (
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<p className="text-gray-500">{diffData.total_commits} коммит(ов)</p>
|
||||||
|
{diffData.files?.map((f: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className={cn('text-[10px] px-1',
|
||||||
|
f.status === 'added' ? 'bg-green-100 text-green-700' :
|
||||||
|
f.status === 'modified' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
)}>
|
||||||
|
{f.status === 'added' ? 'A' : f.status === 'modified' ? 'M' : 'D'}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate font-mono">{f.filename}</span>
|
||||||
|
<span className="text-green-600">+{f.additions}</span>
|
||||||
|
<span className="text-red-600">-{f.deletions}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(!diffData.files || diffData.files.length === 0) && (
|
||||||
|
<p className="text-gray-400">Нет изменений</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400 text-xs">Нет данных</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create PR */}
|
||||||
|
{!showPrForm ? (
|
||||||
|
<Button variant="outline" size="sm" className="w-full h-7 text-xs"
|
||||||
|
onClick={() => { setShowPrForm(true); setPrTitle(`Update ${endpointName}`); }}>
|
||||||
|
<GitPullRequest className="mr-1.5 h-3 w-3" />
|
||||||
|
Создать PR
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 p-2 rounded border bg-muted/50">
|
||||||
|
<Input value={prTitle} onChange={(e) => setPrTitle(e.target.value)}
|
||||||
|
placeholder="Заголовок PR" className="h-7 text-xs" />
|
||||||
|
<Input value={prBody} onChange={(e) => setPrBody(e.target.value)}
|
||||||
|
placeholder="Описание (необязательно)" className="h-7 text-xs" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" className="h-6 text-xs" disabled={!prTitle || createPrMutation.isPending}
|
||||||
|
onClick={() => createPrMutation.mutate()}>
|
||||||
|
{createPrMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Создать PR'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="h-6 text-xs" onClick={() => setShowPrForm(false)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="mt-2">
|
||||||
|
{commitsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-3">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : commits.length > 0 ? (
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||||
|
{commits.slice(0, 20).map((c: GiteaCommit) => (
|
||||||
|
<a key={c.sha} href={c.html_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-start gap-2 p-1.5 rounded hover:bg-accent text-xs group">
|
||||||
|
<GitCommit className="h-3 w-3 mt-0.5 shrink-0 text-gray-400" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-gray-900 group-hover:text-primary-600">
|
||||||
|
{c.message.split('\n')[0]}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-400">
|
||||||
|
{c.author.name} · {c.sha.slice(0, 7)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-400 text-center py-2">Нет коммитов</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { endpointsApi } from '@/services/api';
|
import { endpointsApi } from '@/services/api';
|
||||||
import { ImportPreviewResponse } from '@/types';
|
import { ImportPreviewResponse } from '@/types';
|
||||||
import { X, AlertTriangle, CheckCircle, Database, ArrowRight } from 'lucide-react';
|
import { X, AlertTriangle, CheckCircle, Database, ArrowRight } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
interface ImportEndpointModalProps {
|
interface ImportEndpointModalProps {
|
||||||
preview: ImportPreviewResponse;
|
preview: ImportPreviewResponse;
|
||||||
@@ -74,19 +78,19 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectClasses = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-xl">
|
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto shadow-xl">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Импорт эндпоинта</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Импорт эндпоинта</h2>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
<X size={24} />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Endpoint Preview */}
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Информация об эндпоинте</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Информация об эндпоинте</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
@@ -96,9 +100,9 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<span className="font-medium text-gray-700 w-32">Метод:</span>
|
<span className="font-medium text-gray-700 w-32">Метод:</span>
|
||||||
<span className={`px-2 py-0.5 text-xs font-semibold rounded ${methodColor(preview.endpoint.method)}`}>
|
<Badge variant="secondary" className={methodColor(preview.endpoint.method)}>
|
||||||
{preview.endpoint.method}
|
{preview.endpoint.method}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<span className="font-medium text-gray-700 w-32">Путь:</span>
|
<span className="font-medium text-gray-700 w-32">Путь:</span>
|
||||||
@@ -117,7 +121,6 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Path conflict */}
|
|
||||||
{preview.path_exists && (
|
{preview.path_exists && (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -127,16 +130,10 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
<p className="text-sm text-yellow-700 mb-2">
|
<p className="text-sm text-yellow-700 mb-2">
|
||||||
Эндпоинт с путем <code className="bg-yellow-100 px-1 rounded">{preview.endpoint.path}</code> уже существует. Укажите другой путь:
|
Эндпоинт с путем <code className="bg-yellow-100 px-1 rounded">{preview.endpoint.path}</code> уже существует. Укажите другой путь:
|
||||||
</p>
|
</p>
|
||||||
<input
|
<Input value={overridePath} onChange={(e) => setOverridePath(e.target.value)} />
|
||||||
type="text"
|
|
||||||
value={overridePath}
|
|
||||||
onChange={(e) => setOverridePath(e.target.value)}
|
|
||||||
className="input w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Database Mapping */}
|
|
||||||
{preview.databases.length > 0 && (
|
{preview.databases.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">Сопоставление баз данных</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Сопоставление баз данных</h3>
|
||||||
@@ -161,7 +158,7 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
<select
|
<select
|
||||||
value={databaseMapping[db.name] || ''}
|
value={databaseMapping[db.name] || ''}
|
||||||
onChange={(e) => handleMappingChange(db.name, e.target.value)}
|
onChange={(e) => handleMappingChange(db.name, e.target.value)}
|
||||||
className="input w-full text-sm"
|
className={selectClasses}
|
||||||
>
|
>
|
||||||
<option value="">-- Выберите базу данных --</option>
|
<option value="">-- Выберите базу данных --</option>
|
||||||
{preview.local_databases
|
{preview.local_databases
|
||||||
@@ -193,7 +190,6 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Folder info */}
|
|
||||||
{preview.folder && !preview.folder.found && (
|
{preview.folder && !preview.folder.found && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">
|
||||||
@@ -203,20 +199,13 @@ export default function ImportEndpointModal({ preview, file, onClose }: ImportEn
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="flex gap-3 p-6 border-t justify-end">
|
||||||
<div className="flex gap-3 p-6 border-t border-gray-200 justify-end">
|
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||||
<button onClick={onClose} className="btn btn-secondary">
|
<Button onClick={() => importMutation.mutate()} disabled={!allMapped || importMutation.isPending}>
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => importMutation.mutate()}
|
|
||||||
disabled={!allMapped || importMutation.isPending}
|
|
||||||
className="btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{importMutation.isPending ? 'Импорт...' : 'Импортировать'}
|
{importMutation.isPending ? 'Импорт...' : 'Импортировать'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { useEffect } from 'react';
|
|||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { authApi } from '@/services/api';
|
import { authApi } from '@/services/api';
|
||||||
import { LogOut, User } from 'lucide-react';
|
import { LogOut, User } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user, logout, setAuth } = useAuthStore();
|
const { user, logout, setAuth } = useAuthStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load user data on mount if we have a token
|
|
||||||
const loadUser = async () => {
|
const loadUser = async () => {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (token && !user) {
|
if (token && !user) {
|
||||||
@@ -36,17 +37,14 @@ export default function Navbar() {
|
|||||||
<div className="flex items-center gap-2 text-gray-700">
|
<div className="flex items-center gap-2 text-gray-700">
|
||||||
<User size={20} />
|
<User size={20} />
|
||||||
<span className="font-medium">{user.username}</span>
|
<span className="font-medium">{user.username}</span>
|
||||||
<span className="text-xs bg-primary-100 text-primary-700 px-2 py-1 rounded">
|
<Badge variant="secondary">
|
||||||
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button variant="outline" onClick={logout}>
|
||||||
onClick={logout}
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
className="btn btn-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<LogOut size={18} />
|
|
||||||
Выход
|
Выход
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database, GitBranch } from 'lucide-react';
|
import { Book, Home, Key, Folder, Settings, FileCode, FileText, ExternalLink, Database, GitBranch } from 'lucide-react';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', icon: Home, label: 'Главная' },
|
{ to: '/', icon: Home, label: 'Главная' },
|
||||||
|
|||||||
141
frontend/src/components/ui/alert-dialog.tsx
Normal file
141
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName =
|
||||||
|
AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"mt-2 sm:mt-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
}
|
||||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
56
frontend/src/components/ui/button.tsx
Normal file
56
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
79
frontend/src/components/ui/card.tsx
Normal file
79
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
30
frontend/src/components/ui/checkbox.tsx
Normal file
30
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||||
|
import { Check } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Checkbox = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
className={cn("grid place-content-center text-current")}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
))
|
||||||
|
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
11
frontend/src/components/ui/collapsible.tsx
Normal file
11
frontend/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
const Collapsible = CollapsiblePrimitive.Root
|
||||||
|
|
||||||
|
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
|
||||||
|
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
120
frontend/src/components/ui/dialog.tsx
Normal file
120
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
200
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
46
frontend/src/components/ui/scroll-area.tsx
Normal file
46
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const ScrollArea = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn("relative overflow-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
))
|
||||||
|
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const ScrollBar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
ref={ref}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
))
|
||||||
|
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
158
frontend/src/components/ui/select.tsx
Normal file
158
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
31
frontend/src/components/ui/separator.tsx
Normal file
31
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||||
|
ref
|
||||||
|
) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border",
|
||||||
|
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
138
frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: "right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = "right", className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(sheetVariants({ side }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-2 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = "SheetHeader"
|
||||||
|
|
||||||
|
const SheetFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = "SheetFooter"
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
43
frontend/src/components/ui/sonner.tsx
Normal file
43
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
CheckCircle2 as CircleCheck,
|
||||||
|
Info,
|
||||||
|
Loader2 as LoaderCircle,
|
||||||
|
XOctagon as OctagonX,
|
||||||
|
AlertTriangle as TriangleAlert,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: <CircleCheck className="h-4 w-4" />,
|
||||||
|
info: <Info className="h-4 w-4" />,
|
||||||
|
warning: <TriangleAlert className="h-4 w-4" />,
|
||||||
|
error: <OctagonX className="h-4 w-4" />,
|
||||||
|
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||||
|
}}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
27
frontend/src/components/ui/switch.tsx
Normal file
27
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
117
frontend/src/components/ui/table.tsx
Normal file
117
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
55
frontend/src/components/ui/tabs.tsx
Normal file
55
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
22
frontend/src/components/ui/textarea.tsx
Normal file
22
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
28
frontend/src/components/ui/tooltip.tsx
Normal file
28
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const TooltipProvider = TooltipPrimitive.Provider
|
||||||
|
|
||||||
|
const Tooltip = TooltipPrimitive.Root
|
||||||
|
|
||||||
|
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||||
|
|
||||||
|
const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -3,8 +3,57 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 48%;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-gray-900;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,11 +74,11 @@
|
|||||||
@apply bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
|
@apply bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input-custom {
|
||||||
@apply px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
@apply px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card-custom {
|
||||||
@apply bg-white rounded-lg shadow-sm border border-gray-200;
|
@apply bg-white rounded-lg shadow-sm border border-gray-200;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -2,8 +2,14 @@ import { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiKeysApi, endpointsApi, foldersApi } from '@/services/api';
|
import { apiKeysApi, endpointsApi, foldersApi } from '@/services/api';
|
||||||
import { Plus, Copy, Trash2, Eye, EyeOff, Edit2, Folder as FolderIcon, ChevronRight, ChevronDown } from 'lucide-react';
|
import { Plus, Copy, Trash2, Eye, EyeOff, Edit2, Folder as FolderIcon, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import Dialog from '@/components/Dialog';
|
import Dialog from '@/components/Dialog';
|
||||||
|
|
||||||
export default function ApiKeys() {
|
export default function ApiKeys() {
|
||||||
@@ -58,12 +64,10 @@ export default function ApiKeys() {
|
|||||||
|
|
||||||
const copyToClipboard = async (key: string) => {
|
const copyToClipboard = async (key: string) => {
|
||||||
try {
|
try {
|
||||||
// Try modern clipboard API first (requires HTTPS or localhost)
|
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
await navigator.clipboard.writeText(key);
|
await navigator.clipboard.writeText(key);
|
||||||
toast.success('API ключ скопирован в буфер обмена');
|
toast.success('API ключ скопирован в буфер обмена');
|
||||||
} else {
|
} else {
|
||||||
// Fallback for HTTP
|
|
||||||
const textArea = document.createElement('textarea');
|
const textArea = document.createElement('textarea');
|
||||||
textArea.value = key;
|
textArea.value = key;
|
||||||
textArea.style.position = 'fixed';
|
textArea.style.position = 'fixed';
|
||||||
@@ -75,12 +79,12 @@ export default function ApiKeys() {
|
|||||||
try {
|
try {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
toast.success('API ключ скопирован в буфер обмена');
|
toast.success('API ключ скопирован в буфер обмена');
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error('Не удалось скопировать');
|
toast.error('Не удалось скопировать');
|
||||||
}
|
}
|
||||||
textArea.remove();
|
textArea.remove();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
toast.error('Не удалось скопировать');
|
toast.error('Не удалось скопировать');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -88,25 +92,12 @@ export default function ApiKeys() {
|
|||||||
const toggleReveal = (id: string) => {
|
const toggleReveal = (id: string) => {
|
||||||
setRevealedKeys(prev => {
|
setRevealedKeys(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
if (newSet.has(id)) {
|
if (newSet.has(id)) newSet.delete(id);
|
||||||
newSet.delete(id);
|
else newSet.add(id);
|
||||||
} else {
|
|
||||||
newSet.add(id);
|
|
||||||
}
|
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
|
||||||
setEditingApiKey(null);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (apiKey: any) => {
|
|
||||||
setEditingApiKey(apiKey);
|
|
||||||
setShowModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -114,10 +105,10 @@ export default function ApiKeys() {
|
|||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Ключи</h1>
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Ключи</h1>
|
||||||
<p className="text-gray-600">Управление API ключами и правами доступа</p>
|
<p className="text-gray-600">Управление API ключами и правами доступа</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
<Button onClick={() => { setEditingApiKey(null); setShowModal(true); }}>
|
||||||
<Plus size={20} />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Сгенерировать API ключ
|
Сгенерировать API ключ
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -127,80 +118,57 @@ export default function ApiKeys() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{apiKeys?.map((apiKey) => (
|
{apiKeys?.map((apiKey) => (
|
||||||
<div key={apiKey.id} className="card p-6">
|
<Card key={apiKey.id}>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<CardContent className="pt-6">
|
||||||
<div className="flex-1">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">{apiKey.name}</h3>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
<h3 className="text-xl font-semibold text-gray-900">{apiKey.name}</h3>
|
||||||
apiKey.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
<Badge variant={apiKey.is_active ? 'default' : 'destructive'}
|
||||||
}`}>
|
className={apiKey.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}>
|
||||||
{apiKey.is_active ? 'Активен' : 'Неактивен'}
|
{apiKey.is_active ? 'Активен' : 'Неактивен'}
|
||||||
</span>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800 flex-1">
|
||||||
|
{revealedKeys.has(apiKey.id) ? apiKey.key : '•'.repeat(40)}
|
||||||
|
</code>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => toggleReveal(apiKey.id)}
|
||||||
|
title={revealedKeys.has(apiKey.id) ? 'Скрыть' : 'Показать'}>
|
||||||
|
{revealedKeys.has(apiKey.id) ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(apiKey.key)} title="Копировать">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>Создан: {format(new Date(apiKey.created_at), 'PPP')}</p>
|
||||||
|
{apiKey.expires_at && <p>Истекает: {format(new Date(apiKey.expires_at), 'PPP')}</p>}
|
||||||
|
<p>Права: {apiKey.permissions.length === 0 ? 'Нет' :
|
||||||
|
apiKey.permissions.includes('*') ? 'Все эндпоинты' :
|
||||||
|
`${apiKey.permissions.filter((p: string) => !p.startsWith('folder:')).length} эндпоинт(ов), ${apiKey.permissions.filter((p: string) => p.startsWith('folder:')).length} папок`}</p>
|
||||||
|
<p>Логгирование: {apiKey.enable_logging ? 'Включено' : 'Выключено'}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex gap-2">
|
||||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800 flex-1">
|
<Button variant="ghost" size="icon" onClick={() => { setEditingApiKey(apiKey); setShowModal(true); }} title="Редактировать">
|
||||||
{revealedKeys.has(apiKey.id) ? apiKey.key : '•'.repeat(40)}
|
<Edit2 className="h-4 w-4" />
|
||||||
</code>
|
</Button>
|
||||||
<button
|
<Button variant="outline" onClick={() => toggleMutation.mutate({ id: apiKey.id, is_active: !apiKey.is_active })}>
|
||||||
onClick={() => toggleReveal(apiKey.id)}
|
{apiKey.is_active ? 'Деактивировать' : 'Активировать'}
|
||||||
className="p-2 hover:bg-gray-100 rounded"
|
</Button>
|
||||||
title={revealedKeys.has(apiKey.id) ? 'Скрыть' : 'Показать'}
|
<Button variant="destructive" size="icon" onClick={() => {
|
||||||
>
|
|
||||||
{revealedKeys.has(apiKey.id) ? <EyeOff size={18} /> : <Eye size={18} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(apiKey.key)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded"
|
|
||||||
title="Копировать"
|
|
||||||
>
|
|
||||||
<Copy size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<p>Создан: {format(new Date(apiKey.created_at), 'PPP')}</p>
|
|
||||||
{apiKey.expires_at && (
|
|
||||||
<p>Истекает: {format(new Date(apiKey.expires_at), 'PPP')}</p>
|
|
||||||
)}
|
|
||||||
<p>Права: {apiKey.permissions.length === 0 ? 'Нет' :
|
|
||||||
apiKey.permissions.includes('*') ? 'Все эндпоинты' :
|
|
||||||
`${apiKey.permissions.filter((p: string) => !p.startsWith('folder:')).length} эндпоинт(ов), ${apiKey.permissions.filter((p: string) => p.startsWith('folder:')).length} папок`}</p>
|
|
||||||
<p>Логгирование: {apiKey.enable_logging ? 'Включено' : 'Выключено'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(apiKey)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<Edit2 size={18} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleMutation.mutate({ id: apiKey.id, is_active: !apiKey.is_active })}
|
|
||||||
className={`btn ${apiKey.is_active ? 'btn-secondary' : 'btn-primary'}`}
|
|
||||||
>
|
|
||||||
{apiKey.is_active ? 'Деактивировать' : 'Активировать'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDialog({
|
setDialog({
|
||||||
isOpen: true,
|
isOpen: true, title: 'Подтверждение', message: 'Удалить этот API ключ?', type: 'confirm',
|
||||||
title: 'Подтверждение',
|
onConfirm: () => deleteMutation.mutate(apiKey.id),
|
||||||
message: 'Удалить этот API ключ?',
|
|
||||||
type: 'confirm',
|
|
||||||
onConfirm: () => {
|
|
||||||
deleteMutation.mutate(apiKey.id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}}
|
}}>
|
||||||
className="btn btn-danger"
|
<Trash2 className="h-4 w-4" />
|
||||||
>
|
</Button>
|
||||||
<Trash2 size={18} />
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -256,55 +224,30 @@ function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | nu
|
|||||||
const toggleFolder = (folderId: string) => {
|
const toggleFolder = (folderId: string) => {
|
||||||
setExpandedFolders(prev => {
|
setExpandedFolders(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
if (newSet.has(folderId)) {
|
if (newSet.has(folderId)) newSet.delete(folderId);
|
||||||
newSet.delete(folderId);
|
else newSet.add(folderId);
|
||||||
} else {
|
|
||||||
newSet.add(folderId);
|
|
||||||
}
|
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAllDescendantEndpoints = (folderId: string): string[] => {
|
const getAllDescendantEndpoints = (folderId: string): string[] => {
|
||||||
const descendants: string[] = [];
|
const descendants: string[] = [];
|
||||||
|
|
||||||
// Get all endpoints in this folder
|
|
||||||
const folderEndpoints = endpoints.filter(e => e.folder_id === folderId);
|
const folderEndpoints = endpoints.filter(e => e.folder_id === folderId);
|
||||||
descendants.push(...folderEndpoints.map(e => e.id));
|
descendants.push(...folderEndpoints.map(e => e.id));
|
||||||
|
|
||||||
// Get all subfolders
|
|
||||||
const subfolders = folders.filter(f => f.parent_id === folderId);
|
const subfolders = folders.filter(f => f.parent_id === folderId);
|
||||||
subfolders.forEach(subfolder => {
|
subfolders.forEach(subfolder => descendants.push(...getAllDescendantEndpoints(subfolder.id)));
|
||||||
descendants.push(...getAllDescendantEndpoints(subfolder.id));
|
|
||||||
});
|
|
||||||
|
|
||||||
return descendants;
|
return descendants;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFolderPermission = (folderId: string) => {
|
const toggleFolderPermission = (folderId: string) => {
|
||||||
const folderKey = `folder:${folderId}`;
|
const folderKey = `folder:${folderId}`;
|
||||||
|
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const newPermissions = [...prev.permissions];
|
const newPermissions = [...prev.permissions];
|
||||||
const hasFolder = newPermissions.includes(folderKey);
|
if (newPermissions.includes(folderKey)) {
|
||||||
|
return { ...prev, permissions: newPermissions.filter(p => p !== folderKey) };
|
||||||
if (hasFolder) {
|
|
||||||
// Remove folder permission
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
permissions: newPermissions.filter(p => p !== folderKey),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Add folder permission and remove any individual endpoint permissions for this folder
|
|
||||||
const descendantEndpoints = getAllDescendantEndpoints(folderId);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
permissions: [
|
|
||||||
...newPermissions.filter(p => !descendantEndpoints.includes(p)),
|
|
||||||
folderKey,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
const descendantEndpoints = getAllDescendantEndpoints(folderId);
|
||||||
|
return { ...prev, permissions: [...newPermissions.filter(p => !descendantEndpoints.includes(p)), folderKey] };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -324,12 +267,9 @@ function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | nu
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build folder tree
|
|
||||||
const buildTree = () => {
|
const buildTree = () => {
|
||||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||||
const tree: any[] = [];
|
const tree: any[] = [];
|
||||||
|
|
||||||
// Group folders by parent_id
|
|
||||||
folders.forEach(folder => {
|
folders.forEach(folder => {
|
||||||
const node = folderMap.get(folder.id)!;
|
const node = folderMap.get(folder.id)!;
|
||||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||||
@@ -338,23 +278,17 @@ function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | nu
|
|||||||
tree.push(node);
|
tree.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add endpoints to folders
|
|
||||||
endpoints.forEach(endpoint => {
|
endpoints.forEach(endpoint => {
|
||||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||||
folderMap.get(endpoint.folder_id)!.endpoints.push(endpoint);
|
folderMap.get(endpoint.folder_id)!.endpoints.push(endpoint);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tree;
|
return tree;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if endpoint should appear checked (either directly or via folder permission)
|
|
||||||
const isEndpointChecked = (endpointId: string, folderId?: string): boolean => {
|
const isEndpointChecked = (endpointId: string, folderId?: string): boolean => {
|
||||||
if (formData.permissions.includes('*')) return true;
|
if (formData.permissions.includes('*')) return true;
|
||||||
if (formData.permissions.includes(endpointId)) return true;
|
if (formData.permissions.includes(endpointId)) return true;
|
||||||
|
|
||||||
// Check if any parent folder has permission
|
|
||||||
if (folderId) {
|
if (folderId) {
|
||||||
let currentFolderId: string | null | undefined = folderId;
|
let currentFolderId: string | null | undefined = folderId;
|
||||||
while (currentFolderId) {
|
while (currentFolderId) {
|
||||||
@@ -363,11 +297,9 @@ function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | nu
|
|||||||
currentFolderId = folder?.parent_id;
|
currentFolderId = folder?.parent_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if folder should appear checked
|
|
||||||
const isFolderChecked = (folderId: string): boolean => {
|
const isFolderChecked = (folderId: string): boolean => {
|
||||||
if (formData.permissions.includes('*')) return true;
|
if (formData.permissions.includes('*')) return true;
|
||||||
return formData.permissions.includes(`folder:${folderId}`);
|
return formData.permissions.includes(`folder:${folderId}`);
|
||||||
@@ -378,187 +310,115 @@ function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | nu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
{apiKey ? 'Редактировать API ключ' : 'Сгенерировать API ключ'}
|
{apiKey ? 'Редактировать API ключ' : 'Сгенерировать API ключ'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }} className="p-6 space-y-4">
|
<form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }} className="p-6 space-y-4">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название ключа</label>
|
<Label>Название ключа</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="input w-full"
|
|
||||||
placeholder="Мой API ключ"
|
placeholder="Мой API ключ"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Истекает (необязательно)</label>
|
<Label>Истекает (необязательно)</Label>
|
||||||
<input
|
<Input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={formData.expires_at}
|
value={formData.expires_at}
|
||||||
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||||
className="input w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">Права доступа</label>
|
<Label>Права доступа</Label>
|
||||||
<button
|
<button type="button" onClick={toggleAllPermissions} className="text-sm text-primary-600 hover:text-primary-700">
|
||||||
type="button"
|
|
||||||
onClick={toggleAllPermissions}
|
|
||||||
className="text-sm text-primary-600 hover:text-primary-700"
|
|
||||||
>
|
|
||||||
{formData.permissions.includes('*') ? 'Снять все' : 'Выбрать все'}
|
{formData.permissions.includes('*') ? 'Снять все' : 'Выбрать все'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-gray-300 rounded-lg p-4 max-h-96 overflow-y-auto">
|
<div className="border rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||||
{/* Root folders */}
|
|
||||||
{tree.map((folder) => (
|
{tree.map((folder) => (
|
||||||
<PermissionTreeNode
|
<PermissionTreeNode
|
||||||
key={folder.id}
|
key={folder.id} folder={folder} level={0}
|
||||||
folder={folder}
|
|
||||||
level={0}
|
|
||||||
expandedFolders={expandedFolders}
|
expandedFolders={expandedFolders}
|
||||||
isFolderChecked={isFolderChecked}
|
isFolderChecked={isFolderChecked} isEndpointChecked={isEndpointChecked}
|
||||||
isEndpointChecked={isEndpointChecked}
|
toggleFolder={toggleFolder} toggleFolderPermission={toggleFolderPermission}
|
||||||
toggleFolder={toggleFolder}
|
togglePermission={togglePermission} disabled={formData.permissions.includes('*')}
|
||||||
toggleFolderPermission={toggleFolderPermission}
|
|
||||||
togglePermission={togglePermission}
|
|
||||||
disabled={formData.permissions.includes('*')}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Root endpoints (without folder) */}
|
|
||||||
{rootEndpoints.map((endpoint) => (
|
{rootEndpoints.map((endpoint) => (
|
||||||
<PermissionEndpointNode
|
<PermissionEndpointNode
|
||||||
key={endpoint.id}
|
key={endpoint.id} endpoint={endpoint} level={0}
|
||||||
endpoint={endpoint}
|
|
||||||
level={0}
|
|
||||||
isChecked={isEndpointChecked(endpoint.id)}
|
isChecked={isEndpointChecked(endpoint.id)}
|
||||||
togglePermission={togglePermission}
|
togglePermission={togglePermission} disabled={formData.permissions.includes('*')}
|
||||||
disabled={formData.permissions.includes('*')}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||||
<div className="text-center py-8 text-gray-500 text-sm">
|
<div className="text-center py-8 text-gray-500 text-sm">Нет доступных папок и эндпоинтов</div>
|
||||||
Нет доступных папок и эндпоинтов
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex items-center gap-2">
|
<Switch
|
||||||
<input
|
checked={formData.enable_logging}
|
||||||
type="checkbox"
|
onCheckedChange={(checked) => setFormData({ ...formData, enable_logging: checked })}
|
||||||
checked={formData.enable_logging}
|
/>
|
||||||
onChange={(e) => setFormData({ ...formData, enable_logging: e.target.checked })}
|
<Label>Логгировать запросы</Label>
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700">Логгировать запросы</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
<div className="flex gap-3 pt-4 border-t">
|
||||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
<Button type="button" variant="outline" onClick={onClose}>Отмена</Button>
|
||||||
Отмена
|
<Button type="submit" disabled={saveMutation.isPending}>
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
|
||||||
{saveMutation.isPending ? 'Сохранение...' : (apiKey ? 'Сохранить изменения' : 'Сгенерировать ключ')}
|
{saveMutation.isPending ? 'Сохранение...' : (apiKey ? 'Сохранить изменения' : 'Сгенерировать ключ')}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionTreeNode({
|
function PermissionTreeNode({ folder, level, expandedFolders, isFolderChecked, isEndpointChecked, toggleFolder, toggleFolderPermission, togglePermission, disabled }: any) {
|
||||||
folder,
|
|
||||||
level,
|
|
||||||
expandedFolders,
|
|
||||||
isFolderChecked,
|
|
||||||
isEndpointChecked,
|
|
||||||
toggleFolder,
|
|
||||||
toggleFolderPermission,
|
|
||||||
togglePermission,
|
|
||||||
disabled,
|
|
||||||
}: any) {
|
|
||||||
const isExpanded = expandedFolders.has(folder.id);
|
const isExpanded = expandedFolders.has(folder.id);
|
||||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||||
const checked = isFolderChecked(folder.id);
|
const checked = isFolderChecked(folder.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors" style={{ paddingLeft: `${level * 20}px` }}>
|
||||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
|
||||||
style={{ paddingLeft: `${level * 20}px` }}
|
|
||||||
>
|
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button onClick={() => toggleFolder(folder.id)} className="p-0.5 hover:bg-gray-200 rounded flex-shrink-0" type="button">
|
||||||
onClick={() => toggleFolder(folder.id)}
|
|
||||||
className="p-0.5 hover:bg-gray-200 rounded flex-shrink-0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : <div className="w-5" />}
|
||||||
<div className="w-5" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||||
<input
|
<input type="checkbox" checked={checked} onChange={() => toggleFolderPermission(folder.id)} disabled={disabled} className="rounded flex-shrink-0" />
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleFolderPermission(folder.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="rounded flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">({folder.endpoints.length} эндпоинт(ов))</span>
|
||||||
({folder.endpoints.length} эндпоинт(ов))
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
{/* Subfolders */}
|
|
||||||
{folder.children.map((child: any) => (
|
{folder.children.map((child: any) => (
|
||||||
<PermissionTreeNode
|
<PermissionTreeNode key={child.id} folder={child} level={level + 1}
|
||||||
key={child.id}
|
expandedFolders={expandedFolders} isFolderChecked={isFolderChecked} isEndpointChecked={isEndpointChecked}
|
||||||
folder={child}
|
toggleFolder={toggleFolder} toggleFolderPermission={toggleFolderPermission}
|
||||||
level={level + 1}
|
togglePermission={togglePermission} disabled={disabled} />
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
isFolderChecked={isFolderChecked}
|
|
||||||
isEndpointChecked={isEndpointChecked}
|
|
||||||
toggleFolder={toggleFolder}
|
|
||||||
toggleFolderPermission={toggleFolderPermission}
|
|
||||||
togglePermission={togglePermission}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Endpoints in this folder */}
|
|
||||||
{folder.endpoints.map((endpoint: any) => (
|
{folder.endpoints.map((endpoint: any) => (
|
||||||
<PermissionEndpointNode
|
<PermissionEndpointNode key={endpoint.id} endpoint={endpoint} level={level + 1}
|
||||||
key={endpoint.id}
|
isChecked={isEndpointChecked(endpoint.id, folder.id)} togglePermission={togglePermission} disabled={disabled} />
|
||||||
endpoint={endpoint}
|
|
||||||
level={level + 1}
|
|
||||||
isChecked={isEndpointChecked(endpoint.id, folder.id)}
|
|
||||||
togglePermission={togglePermission}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -566,29 +426,12 @@ function PermissionTreeNode({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermissionEndpointNode({
|
function PermissionEndpointNode({ endpoint, level, isChecked, togglePermission, disabled }: any) {
|
||||||
endpoint,
|
|
||||||
level,
|
|
||||||
isChecked,
|
|
||||||
togglePermission,
|
|
||||||
disabled,
|
|
||||||
}: any) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors" style={{ paddingLeft: `${level * 20 + 24}px` }}>
|
||||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
|
||||||
style={{ paddingLeft: `${level * 20 + 24}px` }}
|
|
||||||
>
|
|
||||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||||
<input
|
<input type="checkbox" checked={isChecked} onChange={() => togglePermission(endpoint.id)} disabled={disabled} className="rounded flex-shrink-0" />
|
||||||
type="checkbox"
|
<span className="text-sm text-gray-700">{endpoint.name} ({endpoint.method} {endpoint.path})</span>
|
||||||
checked={isChecked}
|
|
||||||
onChange={() => togglePermission(endpoint.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="rounded flex-shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
{endpoint.name} ({endpoint.method} {endpoint.path})
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { endpointsApi, foldersApi, apiKeysApi } from '@/services/api';
|
import { endpointsApi, foldersApi, apiKeysApi } from '@/services/api';
|
||||||
import { FileCode, Folder, Key, Database } from 'lucide-react';
|
import { FileCode, Folder, Key, Database } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { data: endpoints } = useQuery({
|
const { data: endpoints } = useQuery({
|
||||||
@@ -19,30 +21,10 @@ export default function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{
|
{ label: 'Всего эндпоинтов', value: endpoints?.length || 0, icon: FileCode, color: 'bg-blue-500' },
|
||||||
label: 'Всего эндпоинтов',
|
{ label: 'Папки', value: folders?.length || 0, icon: Folder, color: 'bg-green-500' },
|
||||||
value: endpoints?.length || 0,
|
{ label: 'API Ключи', value: apiKeys?.length || 0, icon: Key, color: 'bg-purple-500' },
|
||||||
icon: FileCode,
|
{ label: 'Активные ключи', value: apiKeys?.filter(k => k.is_active).length || 0, icon: Database, color: 'bg-orange-500' },
|
||||||
color: 'bg-blue-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Папки',
|
|
||||||
value: folders?.length || 0,
|
|
||||||
icon: Folder,
|
|
||||||
color: 'bg-green-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'API Ключи',
|
|
||||||
value: apiKeys?.length || 0,
|
|
||||||
icon: Key,
|
|
||||||
color: 'bg-purple-500',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Активные ключи',
|
|
||||||
value: apiKeys?.filter(k => k.is_active).length || 0,
|
|
||||||
icon: Database,
|
|
||||||
color: 'bg-orange-500',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,65 +36,74 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="card p-6">
|
<Card key={stat.label}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<CardContent className="pt-6">
|
||||||
<div className={`${stat.color} p-3 rounded-lg text-white`}>
|
<div className="flex items-center justify-between mb-4">
|
||||||
<stat.icon size={24} />
|
<div className={`${stat.color} p-3 rounded-lg text-white`}>
|
||||||
|
<stat.icon size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-3xl font-bold text-gray-900">{stat.value}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-3xl font-bold text-gray-900">{stat.value}</span>
|
<h3 className="text-sm font-medium text-gray-600">{stat.label}</h3>
|
||||||
</div>
|
</CardContent>
|
||||||
<h3 className="text-sm font-medium text-gray-600">{stat.label}</h3>
|
</Card>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<div className="card p-6">
|
<Card>
|
||||||
<h2 className="text-xl font-semibold mb-4">Последние эндпоинты</h2>
|
<CardHeader>
|
||||||
<div className="space-y-3">
|
<CardTitle>Последние эндпоинты</CardTitle>
|
||||||
{endpoints?.slice(0, 5).map((endpoint) => (
|
</CardHeader>
|
||||||
<div key={endpoint.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
<CardContent>
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<p className="font-medium text-gray-900">{endpoint.name}</p>
|
{endpoints?.slice(0, 5).map((endpoint) => (
|
||||||
<p className="text-sm text-gray-500">{endpoint.path}</p>
|
<div key={endpoint.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{endpoint.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{endpoint.path}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant={
|
||||||
|
endpoint.method === 'GET' ? 'default' :
|
||||||
|
endpoint.method === 'POST' ? 'secondary' :
|
||||||
|
endpoint.method === 'PUT' ? 'outline' : 'destructive'
|
||||||
|
}>
|
||||||
|
{endpoint.method}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
))}
|
||||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
{(!endpoints || endpoints.length === 0) && (
|
||||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
<p className="text-gray-500 text-sm text-center py-4">Нет созданных эндпоинтов</p>
|
||||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
)}
|
||||||
'bg-red-100 text-red-700'
|
</div>
|
||||||
}`}>
|
</CardContent>
|
||||||
{endpoint.method}
|
</Card>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(!endpoints || endpoints.length === 0) && (
|
|
||||||
<p className="text-gray-500 text-sm text-center py-4">Нет созданных эндпоинтов</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card p-6">
|
<Card>
|
||||||
<h2 className="text-xl font-semibold mb-4">Быстрые действия</h2>
|
<CardHeader>
|
||||||
<div className="space-y-3">
|
<CardTitle>Быстрые действия</CardTitle>
|
||||||
<a href="/endpoints" className="block p-4 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors">
|
</CardHeader>
|
||||||
<h3 className="font-semibold text-primary-700">Создать новый эндпоинт</h3>
|
<CardContent>
|
||||||
<p className="text-sm text-primary-600">Создайте новый API эндпоинт с SQL запросом</p>
|
<div className="space-y-3">
|
||||||
</a>
|
<a href="/endpoints" className="block p-4 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors">
|
||||||
<a href="/api-keys" className="block p-4 bg-green-50 hover:bg-green-100 rounded-lg transition-colors">
|
<h3 className="font-semibold text-primary-700">Создать новый эндпоинт</h3>
|
||||||
<h3 className="font-semibold text-green-700">Сгенерировать API ключ</h3>
|
<p className="text-sm text-primary-600">Создайте новый API эндпоинт с SQL запросом</p>
|
||||||
<p className="text-sm text-green-600">Создайте новый API ключ для внешних систем</p>
|
</a>
|
||||||
</a>
|
<a href="/api-keys" className="block p-4 bg-green-50 hover:bg-green-100 rounded-lg transition-colors">
|
||||||
<a href="/api-docs" target="_blank" rel="noopener noreferrer" className="block p-4 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
|
<h3 className="font-semibold text-green-700">Сгенерировать API ключ</h3>
|
||||||
<h3 className="font-semibold text-blue-700">📚 Swagger документация</h3>
|
<p className="text-sm text-green-600">Создайте новый API ключ для внешних систем</p>
|
||||||
<p className="text-sm text-blue-600">Документация API для пользователей с API ключами</p>
|
</a>
|
||||||
</a>
|
<a href="/api-docs" target="_blank" rel="noopener noreferrer" className="block p-4 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
|
||||||
<a href="/settings" className="block p-4 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors">
|
<h3 className="font-semibold text-blue-700">Swagger документация</h3>
|
||||||
<h3 className="font-semibold text-purple-700">Настройки системы</h3>
|
<p className="text-sm text-blue-600">Документация API для пользователей с API ключами</p>
|
||||||
<p className="text-sm text-purple-600">Управление профилем и глобальными настройками</p>
|
</a>
|
||||||
</a>
|
<a href="/settings" className="block p-4 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors">
|
||||||
</div>
|
<h3 className="font-semibold text-purple-700">Настройки системы</h3>
|
||||||
</div>
|
<p className="text-sm text-purple-600">Управление профилем и глобальными настройками</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { endpointsApi, foldersApi, databasesApi, versionsApi, giteaApi } from '@/services/api';
|
import { endpointsApi, foldersApi, databasesApi, versionsApi, giteaApi } from '@/services/api';
|
||||||
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
import { EndpointParameter, QueryTestResult, LogEntry, QueryExecution, EndpointVersion } from '@/types';
|
||||||
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload, GitBranch } from 'lucide-react';
|
import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, ArrowLeft, CheckCircle, XCircle, Clock, Copy, X, Terminal, History, RotateCcw, Upload, GitBranch } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import CodeEditor from '@/components/CodeEditor';
|
import CodeEditor from '@/components/CodeEditor';
|
||||||
|
import GiteaBranchPanel from '@/components/GiteaBranchPanel';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
export default function EndpointEditor() {
|
export default function EndpointEditor() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -350,10 +357,10 @@ export default function EndpointEditor() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<button onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 transition-colors">
|
<Button variant="ghost" onClick={() => navigate(-1)} className="flex items-center gap-2 text-gray-600 hover:text-gray-900 px-0">
|
||||||
<ArrowLeft size={20} />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<span>Назад</span>
|
<span>Назад</span>
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
@@ -363,24 +370,25 @@ export default function EndpointEditor() {
|
|||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* Left column - Form */}
|
{/* Left column - Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex-[3] min-w-0 space-y-4">
|
<form onSubmit={handleSubmit} className="flex-[3] min-w-0 space-y-4">
|
||||||
<div className="card p-6 space-y-4">
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
<Label className="mb-1">Название</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="input w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Метод</label>
|
<Label className="mb-1">Метод</Label>
|
||||||
<select
|
<select
|
||||||
value={formData.method}
|
value={formData.method}
|
||||||
onChange={(e) => setFormData({ ...formData, method: e.target.value as any })}
|
onChange={(e) => setFormData({ ...formData, method: e.target.value as any })}
|
||||||
className="input w-full"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="GET">GET</option>
|
<option value="GET">GET</option>
|
||||||
<option value="POST">POST</option>
|
<option value="POST">POST</option>
|
||||||
@@ -392,22 +400,22 @@ export default function EndpointEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
|
<Label className="mb-1">Описание</Label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="input w-full"
|
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isAqlDatabase && (
|
{!isAqlDatabase && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип выполнения</label>
|
<Label className="mb-1">Тип выполнения</Label>
|
||||||
<select
|
<select
|
||||||
value={formData.execution_type}
|
value={formData.execution_type}
|
||||||
onChange={(e) => setFormData({ ...formData, execution_type: e.target.value as 'sql' | 'script' })}
|
onChange={(e) => setFormData({ ...formData, execution_type: e.target.value as 'sql' | 'script' })}
|
||||||
className="input w-full"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="sql">QL Запрос</option>
|
<option value="sql">QL Запрос</option>
|
||||||
<option value="script">Скрипт (JavaScript/Python) + QL запросы</option>
|
<option value="script">Скрипт (JavaScript/Python) + QL запросы</option>
|
||||||
@@ -417,24 +425,24 @@ export default function EndpointEditor() {
|
|||||||
|
|
||||||
<div className={`grid ${(!isAqlDatabase && formData.execution_type === 'sql') || isAqlDatabase ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
|
<div className={`grid ${(!isAqlDatabase && formData.execution_type === 'sql') || isAqlDatabase ? 'grid-cols-3' : 'grid-cols-2'} gap-4`}>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Путь</label>
|
<Label className="mb-1">Путь</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.path}
|
value={formData.path}
|
||||||
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
|
||||||
className="input w-full"
|
className="w-full"
|
||||||
placeholder="/api/v1/users"
|
placeholder="/api/v1/users"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(formData.execution_type === 'sql' || isAqlDatabase) && (
|
{(formData.execution_type === 'sql' || isAqlDatabase) && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
|
<Label className="mb-1">База данных</Label>
|
||||||
<select
|
<select
|
||||||
required
|
required
|
||||||
value={formData.database_id}
|
value={formData.database_id}
|
||||||
onChange={(e) => setFormData({ ...formData, database_id: e.target.value, execution_type: databases?.find(db => db.id === e.target.value)?.type === 'aql' ? 'aql' : 'sql' })}
|
onChange={(e) => setFormData({ ...formData, database_id: e.target.value, execution_type: databases?.find(db => db.id === e.target.value)?.type === 'aql' ? 'aql' : 'sql' })}
|
||||||
className="input w-full"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">Выберите базу данных</option>
|
<option value="">Выберите базу данных</option>
|
||||||
{databases?.map((db) => (
|
{databases?.map((db) => (
|
||||||
@@ -444,7 +452,7 @@ export default function EndpointEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Папка</label>
|
<Label className="mb-1">Папка</Label>
|
||||||
<FolderSelector
|
<FolderSelector
|
||||||
value={formData.folder_id}
|
value={formData.folder_id}
|
||||||
onChange={(value) => setFormData({ ...formData, folder_id: value })}
|
onChange={(value) => setFormData({ ...formData, folder_id: value })}
|
||||||
@@ -463,17 +471,19 @@ export default function EndpointEditor() {
|
|||||||
<label className="text-sm font-medium text-gray-700 cursor-pointer">
|
<label className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||||
Параметры запроса
|
Параметры запроса
|
||||||
{formData.parameters.length > 0 && (
|
{formData.parameters.length > 0 && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-primary-100 text-primary-700 rounded-full text-xs">
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
{formData.parameters.length}
|
{formData.parameters.length}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
(используйте $имяПараметра в QL запросе)
|
(используйте $имяПараметра в QL запросе)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newParam: EndpointParameter = {
|
const newParam: EndpointParameter = {
|
||||||
@@ -489,9 +499,9 @@ export default function EndpointEditor() {
|
|||||||
}}
|
}}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Добавить
|
Добавить
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parametersExpanded && (
|
{parametersExpanded && (
|
||||||
@@ -500,7 +510,7 @@ export default function EndpointEditor() {
|
|||||||
{formData.parameters.map((param: any, index: number) => (
|
{formData.parameters.map((param: any, index: number) => (
|
||||||
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
|
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
|
||||||
<div className="flex-1 grid grid-cols-6 gap-2">
|
<div className="flex-1 grid grid-cols-6 gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Имя"
|
placeholder="Имя"
|
||||||
value={param.name}
|
value={param.name}
|
||||||
@@ -509,7 +519,7 @@ export default function EndpointEditor() {
|
|||||||
newParams[index] = { ...newParams[index], name: e.target.value };
|
newParams[index] = { ...newParams[index], name: e.target.value };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="input text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={param.type}
|
value={param.type}
|
||||||
@@ -518,7 +528,7 @@ export default function EndpointEditor() {
|
|||||||
newParams[index] = { ...newParams[index], type: e.target.value as any };
|
newParams[index] = { ...newParams[index], type: e.target.value as any };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="input text-sm"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="string">string</option>
|
<option value="string">string</option>
|
||||||
<option value="number">number</option>
|
<option value="number">number</option>
|
||||||
@@ -532,13 +542,13 @@ export default function EndpointEditor() {
|
|||||||
newParams[index] = { ...newParams[index], in: e.target.value as any };
|
newParams[index] = { ...newParams[index], in: e.target.value as any };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="input text-sm"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="query">Query</option>
|
<option value="query">Query</option>
|
||||||
<option value="body">Body</option>
|
<option value="body">Body</option>
|
||||||
<option value="path">Path</option>
|
<option value="path">Path</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Описание"
|
placeholder="Описание"
|
||||||
value={param.description || ''}
|
value={param.description || ''}
|
||||||
@@ -547,9 +557,9 @@ export default function EndpointEditor() {
|
|||||||
newParams[index] = { ...newParams[index], description: e.target.value };
|
newParams[index] = { ...newParams[index], description: e.target.value };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="input text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Пример"
|
placeholder="Пример"
|
||||||
value={param.example_value || ''}
|
value={param.example_value || ''}
|
||||||
@@ -558,34 +568,34 @@ export default function EndpointEditor() {
|
|||||||
newParams[index] = { ...newParams[index], example_value: e.target.value };
|
newParams[index] = { ...newParams[index], example_value: e.target.value };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="input text-sm"
|
className="text-sm"
|
||||||
title="Пример значения (автоподставляется в тест)"
|
title="Пример значения (автоподставляется в тест)"
|
||||||
/>
|
/>
|
||||||
<label className="flex items-center gap-1 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={param.required}
|
checked={param.required}
|
||||||
onChange={(e) => {
|
onCheckedChange={(checked) => {
|
||||||
const newParams = [...formData.parameters];
|
const newParams = [...formData.parameters];
|
||||||
newParams[index] = { ...newParams[index], required: e.target.checked };
|
newParams[index] = { ...newParams[index], required: !!checked };
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="rounded"
|
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">Обяз.</span>
|
<span className="text-xs">Обяз.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
|
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
|
||||||
setFormData({ ...formData, parameters: newParams });
|
setFormData({ ...formData, parameters: newParams });
|
||||||
}}
|
}}
|
||||||
className="p-1 hover:bg-red-50 rounded text-red-600"
|
className="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-600"
|
||||||
title="Удалить параметр"
|
title="Удалить параметр"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -601,11 +611,11 @@ export default function EndpointEditor() {
|
|||||||
{formData.execution_type === 'aql' ? (
|
{formData.execution_type === 'aql' ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">AQL HTTP Метод</label>
|
<Label className="mb-1">AQL HTTP Метод</Label>
|
||||||
<select
|
<select
|
||||||
value={formData.aql_method}
|
value={formData.aql_method}
|
||||||
onChange={(e) => setFormData({ ...formData, aql_method: e.target.value as any })}
|
onChange={(e) => setFormData({ ...formData, aql_method: e.target.value as any })}
|
||||||
className="input w-full"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="GET">GET</option>
|
<option value="GET">GET</option>
|
||||||
<option value="POST">POST</option>
|
<option value="POST">POST</option>
|
||||||
@@ -614,21 +624,21 @@ export default function EndpointEditor() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">AQL Endpoint URL</label>
|
<Label className="mb-1">AQL Endpoint URL</Label>
|
||||||
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||||
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> для подстановки</div>
|
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> для подстановки</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.aql_endpoint}
|
value={formData.aql_endpoint}
|
||||||
onChange={(e) => setFormData({ ...formData, aql_endpoint: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, aql_endpoint: e.target.value })}
|
||||||
className="input w-full"
|
className="w-full"
|
||||||
placeholder="/view/15151180-f7f9-4ecc-a48c-25c083511907/GetFullCuidIsLink"
|
placeholder="/view/15151180-f7f9-4ecc-a48c-25c083511907/GetFullCuidIsLink"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Body (JSON)</label>
|
<Label className="mb-2">AQL Body (JSON)</Label>
|
||||||
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||||
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> в JSON для подстановки</div>
|
<div>Используйте <code className="bg-blue-100 px-1 rounded">$параметр</code> в JSON для подстановки</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,7 +650,7 @@ export default function EndpointEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">AQL Query Parameters (JSON)</label>
|
<Label className="mb-2">AQL Query Parameters (JSON)</Label>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={typeof formData.aql_query_params === 'string' ? formData.aql_query_params : JSON.stringify(formData.aql_query_params, null, 2)}
|
value={typeof formData.aql_query_params === 'string' ? formData.aql_query_params : JSON.stringify(formData.aql_query_params, null, 2)}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -658,7 +668,7 @@ export default function EndpointEditor() {
|
|||||||
</>
|
</>
|
||||||
) : formData.execution_type === 'sql' ? (
|
) : formData.execution_type === 'sql' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
|
<Label className="mb-1">SQL Запрос</Label>
|
||||||
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 space-y-1">
|
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 space-y-1">
|
||||||
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для подстановки значений.</div>
|
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для подстановки значений.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -672,11 +682,11 @@ export default function EndpointEditor() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Язык скрипта</label>
|
<Label className="mb-1">Язык скрипта</Label>
|
||||||
<select
|
<select
|
||||||
value={formData.script_language}
|
value={formData.script_language}
|
||||||
onChange={(e) => setFormData({ ...formData, script_language: e.target.value as any })}
|
onChange={(e) => setFormData({ ...formData, script_language: e.target.value as any })}
|
||||||
className="input w-full"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="javascript">JavaScript</option>
|
<option value="javascript">JavaScript</option>
|
||||||
<option value="python">Python</option>
|
<option value="python">Python</option>
|
||||||
@@ -694,14 +704,16 @@ export default function EndpointEditor() {
|
|||||||
<label className="text-sm font-medium text-gray-700 cursor-pointer">
|
<label className="text-sm font-medium text-gray-700 cursor-pointer">
|
||||||
SQL Запросы для скрипта
|
SQL Запросы для скрипта
|
||||||
{formData.script_queries.length > 0 && (
|
{formData.script_queries.length > 0 && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-primary-100 text-primary-700 rounded-full text-xs">
|
<Badge variant="secondary" className="ml-2 text-xs">
|
||||||
{formData.script_queries.length}
|
{formData.script_queries.length}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const newQueries = [...formData.script_queries, {
|
const newQueries = [...formData.script_queries, {
|
||||||
@@ -719,9 +731,9 @@ export default function EndpointEditor() {
|
|||||||
}}
|
}}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Добавить
|
Добавить
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{queriesExpanded && (
|
{queriesExpanded && (
|
||||||
formData.script_queries.length > 0 ? (
|
formData.script_queries.length > 0 ? (
|
||||||
@@ -755,25 +767,29 @@ export default function EndpointEditor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 ml-4">
|
<div className="flex gap-2 ml-4">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => setEditingQueryIndex(idx)}
|
onClick={() => setEditingQueryIndex(idx)}
|
||||||
className="p-2 hover:bg-blue-50 rounded text-blue-600"
|
className="h-8 w-8 text-blue-600 hover:bg-blue-50 hover:text-blue-600"
|
||||||
title="Редактировать запрос"
|
title="Редактировать запрос"
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Edit2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
|
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
|
||||||
setFormData({ ...formData, script_queries: newQueries });
|
setFormData({ ...formData, script_queries: newQueries });
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-red-50 rounded text-red-600"
|
className="h-8 w-8 text-red-600 hover:bg-red-50 hover:text-red-600"
|
||||||
title="Удалить запрос"
|
title="Удалить запрос"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -789,15 +805,17 @@ export default function EndpointEditor() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">Код скрипта</label>
|
<Label>Код скрипта</Label>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => setShowScriptCodeEditor(true)}
|
onClick={() => setShowScriptCodeEditor(true)}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Edit2 className="mr-1 h-4 w-4" />
|
||||||
Редактировать код
|
Редактировать код
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
{formData.script_code ? (
|
{formData.script_code ? (
|
||||||
@@ -824,10 +842,10 @@ export default function EndpointEditor() {
|
|||||||
Схема ответа 200 (Swagger)
|
Схема ответа 200 (Swagger)
|
||||||
</label>
|
</label>
|
||||||
{responseSchemaText.trim() && (
|
{responseSchemaText.trim() && (
|
||||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded-full text-xs">задана</span>
|
<Badge className="ml-1 bg-green-100 text-green-700 hover:bg-green-100 text-xs">задана</Badge>
|
||||||
)}
|
)}
|
||||||
{responseSchemaError && (
|
{responseSchemaError && (
|
||||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 rounded-full text-xs">{responseSchemaError}</span>
|
<Badge variant="destructive" className="ml-1 text-xs">{responseSchemaError}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -842,7 +860,7 @@ export default function EndpointEditor() {
|
|||||||
setResponseSchemaText(e.target.value);
|
setResponseSchemaText(e.target.value);
|
||||||
setResponseSchemaError('');
|
setResponseSchemaError('');
|
||||||
}}
|
}}
|
||||||
className="input w-full font-mono text-xs"
|
className="flex w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring font-mono text-xs"
|
||||||
rows={10}
|
rows={10}
|
||||||
placeholder={'{\n "type": "array",\n "items": {\n "type": "object",\n "properties": {\n "id": { "type": "number" },\n "name": { "type": "string" }\n }\n }\n}'}
|
placeholder={'{\n "type": "array",\n "items": {\n "type": "object",\n "properties": {\n "id": { "type": "number" },\n "name": { "type": "string" }\n }\n }\n}'}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
@@ -915,7 +933,8 @@ export default function EndpointEditor() {
|
|||||||
{saveMutation.isPending && !saveAndStay ? 'Публикация...' : 'Publish & Exit'}
|
{saveMutation.isPending && !saveAndStay ? 'Публикация...' : 'Publish & Exit'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Right column - Debug panel */}
|
{/* Right column - Debug panel */}
|
||||||
@@ -1105,42 +1124,25 @@ export default function EndpointEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gitea info */}
|
{/* Gitea Branch Workflow */}
|
||||||
{isEditing && giteaInfo?.enabled && (
|
{isEditing && giteaInfo?.enabled && (
|
||||||
<div className="card p-4">
|
<GiteaBranchPanel
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-3">
|
endpointId={id!}
|
||||||
<GitBranch size={16} />
|
endpointName={formData.name}
|
||||||
<span>Gitea</span>
|
giteaInfo={giteaInfo}
|
||||||
</div>
|
currentQuery={formData.sql_query}
|
||||||
<div className="space-y-2 text-xs">
|
currentScript={formData.script_code}
|
||||||
{giteaInfo.file_url && (
|
currentMetadata={{
|
||||||
<a
|
name: formData.name,
|
||||||
href={giteaInfo.file_url}
|
description: formData.description,
|
||||||
target="_blank"
|
method: formData.method,
|
||||||
rel="noopener noreferrer"
|
path: formData.path,
|
||||||
className="block text-primary-600 hover:text-primary-700 truncate"
|
execution_type: formData.execution_type,
|
||||||
>
|
parameters: formData.parameters,
|
||||||
View in Gitea
|
is_public: formData.is_public,
|
||||||
</a>
|
enable_logging: formData.enable_logging,
|
||||||
)}
|
}}
|
||||||
{giteaInfo.last_commit_sha && (
|
/>
|
||||||
<div className="flex items-center gap-2 text-gray-500">
|
|
||||||
<span>Last commit:</span>
|
|
||||||
<a
|
|
||||||
href={giteaInfo.commit_url || '#'}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-mono text-primary-600 hover:text-primary-700"
|
|
||||||
>
|
|
||||||
{giteaInfo.last_commit_sha.slice(0, 7)}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!giteaInfo.last_commit_sha && (
|
|
||||||
<span className="text-gray-400">Not yet synced to Gitea</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Test results */}
|
{/* Test results */}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { endpointsApi } from '@/services/api';
|
import { endpointsApi } from '@/services/api';
|
||||||
import { ImportPreviewResponse } from '@/types';
|
import { ImportPreviewResponse } from '@/types';
|
||||||
import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
|
import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import ImportEndpointModal from '@/components/ImportEndpointModal';
|
import ImportEndpointModal from '@/components/ImportEndpointModal';
|
||||||
import Dialog from '@/components/Dialog';
|
import Dialog from '@/components/Dialog';
|
||||||
|
|
||||||
@@ -49,9 +53,7 @@ export default function Endpoints() {
|
|||||||
title: 'Подтверждение',
|
title: 'Подтверждение',
|
||||||
message: 'Вы уверены, что хотите удалить этот эндпоинт?',
|
message: 'Вы уверены, что хотите удалить этот эндпоинт?',
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
onConfirm: () => {
|
onConfirm: () => deleteMutation.mutate(id),
|
||||||
deleteMutation.mutate(id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,6 +93,12 @@ export default function Endpoints() {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const methodVariant = (method: string) =>
|
||||||
|
method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||||
|
method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -106,32 +114,31 @@ export default function Endpoints() {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleImportFileSelect}
|
onChange={handleImportFileSelect}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="outline" onClick={() => fileInputRef.current?.click()}>
|
||||||
onClick={() => fileInputRef.current?.click()}
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
className="btn btn-secondary flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Upload size={20} />
|
|
||||||
Импорт
|
Импорт
|
||||||
</button>
|
</Button>
|
||||||
<button onClick={() => navigate('/endpoints/new')} className="btn btn-primary flex items-center gap-2">
|
<Button onClick={() => navigate('/endpoints/new')}>
|
||||||
<Plus size={20} />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Новый эндпоинт
|
Новый эндпоинт
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4 mb-6">
|
<Card className="mb-6">
|
||||||
<div className="flex items-center gap-3">
|
<CardContent className="pt-4 pb-4">
|
||||||
<Search size={20} className="text-gray-400" />
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<Search size={20} className="text-gray-400" />
|
||||||
type="text"
|
<Input
|
||||||
placeholder="Поиск эндпоинтов по имени, пути или SQL запросу..."
|
type="text"
|
||||||
value={search}
|
placeholder="Поиск эндпоинтов по имени, пути или SQL запросу..."
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
value={search}
|
||||||
className="flex-1 outline-none"
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
/>
|
className="border-0 shadow-none focus-visible:ring-0"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -141,68 +148,51 @@ export default function Endpoints() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{endpoints?.map((endpoint) => (
|
{endpoints?.map((endpoint) => (
|
||||||
<div key={endpoint.id} className="card p-6 hover:shadow-md transition-shadow">
|
<Card key={endpoint.id} className="hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-start justify-between">
|
<CardContent className="pt-6">
|
||||||
<div className="flex-1">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">{endpoint.name}</h3>
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
<h3 className="text-xl font-semibold text-gray-900">{endpoint.name}</h3>
|
||||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
<span className={`px-3 py-1 text-xs font-semibold rounded ${methodVariant(endpoint.method)}`}>
|
||||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
{endpoint.method}
|
||||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{endpoint.method}
|
|
||||||
</span>
|
|
||||||
{endpoint.is_public && (
|
|
||||||
<span className="px-3 py-1 text-xs font-semibold rounded bg-purple-100 text-purple-700">
|
|
||||||
Публичный
|
|
||||||
</span>
|
</span>
|
||||||
|
{endpoint.is_public && (
|
||||||
|
<Badge variant="secondary" className="bg-purple-100 text-purple-700">Публичный</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 mb-2">{endpoint.description}</p>
|
||||||
|
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800">
|
||||||
|
{endpoint.path}
|
||||||
|
</code>
|
||||||
|
{endpoint.folder_name && (
|
||||||
|
<span className="ml-2 text-sm text-gray-500">{endpoint.folder_name}</span>
|
||||||
|
)}
|
||||||
|
{endpoint.parameters && endpoint.parameters.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-xs text-gray-500">Параметры: </span>
|
||||||
|
{endpoint.parameters.map((param: any, idx: number) => (
|
||||||
|
<Badge key={idx} variant="secondary" className="mr-1 mb-1 text-xs">
|
||||||
|
{param.name} ({param.type}){param.required && '*'}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 mb-2">{endpoint.description}</p>
|
<div className="flex gap-2">
|
||||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800">
|
<Button variant="ghost" size="icon" onClick={() => handleExport(endpoint.id, endpoint.name)} title="Экспорт">
|
||||||
{endpoint.path}
|
<Download className="h-4 w-4" />
|
||||||
</code>
|
</Button>
|
||||||
{endpoint.folder_name && (
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/endpoints/${endpoint.id}`)} title="Редактировать">
|
||||||
<span className="ml-2 text-sm text-gray-500">📁 {endpoint.folder_name}</span>
|
<Edit2 className="h-4 w-4" />
|
||||||
)}
|
</Button>
|
||||||
{endpoint.parameters && endpoint.parameters.length > 0 && (
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(endpoint.id)} title="Удалить" className="hover:bg-red-50">
|
||||||
<div className="mt-2">
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
<span className="text-xs text-gray-500">Параметры: </span>
|
</Button>
|
||||||
{endpoint.parameters.map((param: any, idx: number) => (
|
</div>
|
||||||
<span key={idx} className="inline-block text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded mr-1 mb-1">
|
|
||||||
{param.name} ({param.type}){param.required && '*'}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</CardContent>
|
||||||
<button
|
</Card>
|
||||||
onClick={() => handleExport(endpoint.id, endpoint.name)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Экспорт"
|
|
||||||
>
|
|
||||||
<Download size={18} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/endpoints/${endpoint.id}`)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<Edit2 size={18} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(endpoint.id)}
|
|
||||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={18} className="text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{endpoints?.length === 0 && (
|
{endpoints?.length === 0 && (
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { foldersApi, endpointsApi } from '@/services/api';
|
import { foldersApi, endpointsApi } from '@/services/api';
|
||||||
import { Folder, Endpoint } from '@/types';
|
import { Folder, Endpoint } from '@/types';
|
||||||
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown, Search, ChevronsDownUp, ChevronsUpDown } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import Dialog from '@/components/Dialog';
|
import Dialog from '@/components/Dialog';
|
||||||
|
|
||||||
export default function Folders() {
|
export default function Folders() {
|
||||||
@@ -16,17 +21,8 @@ export default function Folders() {
|
|||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [dialog, setDialog] = useState<{
|
const [dialog, setDialog] = useState<{
|
||||||
isOpen: boolean;
|
isOpen: boolean; title: string; message: string; type: 'alert' | 'confirm'; onConfirm?: () => void;
|
||||||
title: string;
|
}>({ isOpen: false, title: '', message: '', type: 'alert' });
|
||||||
message: string;
|
|
||||||
type: 'alert' | 'confirm';
|
|
||||||
onConfirm?: () => void;
|
|
||||||
}>({
|
|
||||||
isOpen: false,
|
|
||||||
title: '',
|
|
||||||
message: '',
|
|
||||||
type: 'alert',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: folders, isLoading: foldersLoading } = useQuery({
|
const { data: folders, isLoading: foldersLoading } = useQuery({
|
||||||
queryKey: ['folders'],
|
queryKey: ['folders'],
|
||||||
@@ -63,51 +59,30 @@ export default function Folders() {
|
|||||||
setShowFolderModal(true);
|
setShowFolderModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditFolder = (folder: Folder) => {
|
const handleEditFolder = (folder: Folder) => { setEditingFolder(folder); setShowFolderModal(true); };
|
||||||
setEditingFolder(folder);
|
const handleCreateEndpoint = (folderId?: string) => navigate(folderId ? `/endpoints/new?folder=${folderId}` : '/endpoints/new');
|
||||||
setShowFolderModal(true);
|
const handleEditEndpoint = (endpoint: Endpoint) => navigate(`/endpoints/${endpoint.id}`);
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateEndpoint = (folderId?: string) => {
|
|
||||||
navigate(folderId ? `/endpoints/new?folder=${folderId}` : '/endpoints/new');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditEndpoint = (endpoint: Endpoint) => {
|
|
||||||
navigate(`/endpoints/${endpoint.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteFolder = (id: string) => {
|
const handleDeleteFolder = (id: string) => {
|
||||||
setDialog({
|
setDialog({
|
||||||
isOpen: true,
|
isOpen: true, title: 'Подтверждение',
|
||||||
title: 'Подтверждение',
|
|
||||||
message: 'Удалить папку? Все вложенные папки и эндпоинты будут перемещены в корень.',
|
message: 'Удалить папку? Все вложенные папки и эндпоинты будут перемещены в корень.',
|
||||||
type: 'confirm',
|
type: 'confirm', onConfirm: () => deleteFolderMutation.mutate(id),
|
||||||
onConfirm: () => {
|
|
||||||
deleteFolderMutation.mutate(id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEndpoint = (id: string) => {
|
const handleDeleteEndpoint = (id: string) => {
|
||||||
setDialog({
|
setDialog({
|
||||||
isOpen: true,
|
isOpen: true, title: 'Подтверждение', message: 'Удалить этот эндпоинт?',
|
||||||
title: 'Подтверждение',
|
type: 'confirm', onConfirm: () => deleteEndpointMutation.mutate(id),
|
||||||
message: 'Удалить этот эндпоинт?',
|
|
||||||
type: 'confirm',
|
|
||||||
onConfirm: () => {
|
|
||||||
deleteEndpointMutation.mutate(id);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleFolder = (folderId: string) => {
|
const toggleFolder = (folderId: string) => {
|
||||||
setExpandedFolders(prev => {
|
setExpandedFolders(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
if (newSet.has(folderId)) {
|
if (newSet.has(folderId)) newSet.delete(folderId);
|
||||||
newSet.delete(folderId);
|
else newSet.add(folderId);
|
||||||
} else {
|
|
||||||
newSet.add(folderId);
|
|
||||||
}
|
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -119,17 +94,10 @@ export default function Folders() {
|
|||||||
return ids;
|
return ids;
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => setExpandedFolders(new Set(getAllFolderIds(tree)));
|
|
||||||
const collapseAll = () => setExpandedFolders(new Set());
|
|
||||||
|
|
||||||
// Построение дерева папок
|
|
||||||
const buildTree = () => {
|
const buildTree = () => {
|
||||||
if (!folders || !endpoints) return [];
|
if (!folders || !endpoints) return [];
|
||||||
|
|
||||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||||
const tree: any[] = [];
|
const tree: any[] = [];
|
||||||
|
|
||||||
// Группируем папки по parent_id
|
|
||||||
folders.forEach(folder => {
|
folders.forEach(folder => {
|
||||||
const node: any = folderMap.get(folder.id)!;
|
const node: any = folderMap.get(folder.id)!;
|
||||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||||
@@ -138,28 +106,23 @@ export default function Folders() {
|
|||||||
tree.push(node);
|
tree.push(node);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Добавляем эндпоинты в папки
|
|
||||||
endpoints.forEach(endpoint => {
|
endpoints.forEach(endpoint => {
|
||||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||||
(folderMap.get(endpoint.folder_id) as any)!.endpoints.push(endpoint);
|
(folderMap.get(endpoint.folder_id) as any)!.endpoints.push(endpoint);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return tree;
|
return tree;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Эндпоинты без папки
|
|
||||||
const rootEndpoints = endpoints?.filter(e => !e.folder_id) || [];
|
const rootEndpoints = endpoints?.filter(e => !e.folder_id) || [];
|
||||||
|
|
||||||
const tree = buildTree();
|
const tree = buildTree();
|
||||||
|
|
||||||
// Поиск
|
const expandAll = () => setExpandedFolders(new Set(getAllFolderIds(tree)));
|
||||||
|
const collapseAll = () => setExpandedFolders(new Set());
|
||||||
|
|
||||||
const searchResults = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return null;
|
if (!searchQuery.trim()) return null;
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
|
|
||||||
// Строим карту путей папок
|
|
||||||
const folderPathMap = new Map<string, string>();
|
const folderPathMap = new Map<string, string>();
|
||||||
const buildPaths = (nodes: any[], prefix = '') => {
|
const buildPaths = (nodes: any[], prefix = '') => {
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
@@ -169,15 +132,10 @@ export default function Folders() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
buildPaths(tree);
|
buildPaths(tree);
|
||||||
|
|
||||||
const matchedEndpoints = (endpoints || []).filter(e =>
|
const matchedEndpoints = (endpoints || []).filter(e =>
|
||||||
e.name.toLowerCase().includes(q) ||
|
e.name.toLowerCase().includes(q) || e.path.toLowerCase().includes(q) || e.method.toLowerCase().includes(q)
|
||||||
e.path.toLowerCase().includes(q) ||
|
|
||||||
e.method.toLowerCase().includes(q)
|
|
||||||
).map(e => ({ ...e, folderPath: e.folder_id ? (folderPathMap.get(e.folder_id) || '') : '' }));
|
).map(e => ({ ...e, folderPath: e.folder_id ? (folderPathMap.get(e.folder_id) || '') : '' }));
|
||||||
|
|
||||||
const matchedFolders = (folders || []).filter(f => f.name.toLowerCase().includes(q));
|
const matchedFolders = (folders || []).filter(f => f.name.toLowerCase().includes(q));
|
||||||
|
|
||||||
return { endpoints: matchedEndpoints, folders: matchedFolders, folderPathMap };
|
return { endpoints: matchedEndpoints, folders: matchedFolders, folderPathMap };
|
||||||
}, [searchQuery, endpoints, folders, tree]);
|
}, [searchQuery, endpoints, folders, tree]);
|
||||||
|
|
||||||
@@ -189,14 +147,14 @@ export default function Folders() {
|
|||||||
<p className="text-gray-600">Древовидное представление папок и эндпоинтов</p>
|
<p className="text-gray-600">Древовидное представление папок и эндпоинтов</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => handleCreateEndpoint()} className="btn btn-secondary flex items-center gap-2">
|
<Button variant="outline" onClick={() => handleCreateEndpoint()}>
|
||||||
<Plus size={20} />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Новый эндпоинт
|
Новый эндпоинт
|
||||||
</button>
|
</Button>
|
||||||
<button onClick={() => handleCreateFolder()} className="btn btn-primary flex items-center gap-2">
|
<Button onClick={() => handleCreateFolder()}>
|
||||||
<Plus size={20} />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Новая папка
|
Новая папка
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,255 +164,157 @@ export default function Folders() {
|
|||||||
<p className="mt-4 text-gray-600">Загрузка...</p>
|
<p className="mt-4 text-gray-600">Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card p-6">
|
<Card>
|
||||||
{/* Toolbar: search + expand/collapse */}
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
<input
|
<Input
|
||||||
type="text"
|
placeholder="Поиск по имени, пути, методу..."
|
||||||
placeholder="Поиск по имени, пути, методу..."
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
className="pl-9 text-sm"
|
||||||
className="input w-full pl-9 text-sm"
|
/>
|
||||||
/>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={expandAll} title="Раскрыть все">
|
||||||
|
<ChevronsUpDown className="mr-1.5 h-4 w-4" />
|
||||||
|
Раскрыть все
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={collapseAll} title="Свернуть все">
|
||||||
|
<ChevronsDownUp className="mr-1.5 h-4 w-4" />
|
||||||
|
Свернуть все
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={expandAll}
|
|
||||||
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
|
|
||||||
title="Раскрыть все"
|
|
||||||
>
|
|
||||||
<ChevronsUpDown size={16} />
|
|
||||||
Раскрыть все
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={collapseAll}
|
|
||||||
className="btn btn-secondary flex items-center gap-1.5 text-sm py-1.5"
|
|
||||||
title="Свернуть все"
|
|
||||||
>
|
|
||||||
<ChevronsDownUp size={16} />
|
|
||||||
Свернуть все
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{searchResults ? (
|
{searchResults ? (
|
||||||
/* Результаты поиска */
|
<>
|
||||||
<>
|
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
|
||||||
{searchResults.endpoints.length === 0 && searchResults.folders.length === 0 ? (
|
<div className="text-center py-12 text-gray-500">
|
||||||
<div className="text-center py-12 text-gray-500">
|
<p>Ничего не найдено по запросу «{searchQuery}»</p>
|
||||||
<p>Ничего не найдено по запросу «{searchQuery}»</p>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
{searchResults.folders.map((folder: any) => (
|
||||||
{searchResults.folders.map((folder: any) => (
|
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
|
||||||
<div key={folder.id} className="flex items-center gap-2 px-3 py-2 rounded bg-yellow-50 border border-yellow-100">
|
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
<span className="text-xs text-gray-400">{searchResults.folderPathMap.get(folder.id)}</span>
|
||||||
<span className="text-xs text-gray-400">{searchResults.folderPathMap.get(folder.id)}</span>
|
<div className="flex gap-1 ml-auto">
|
||||||
<div className="flex gap-1 ml-auto">
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEditFolder(folder)} title="Редактировать">
|
||||||
<button onClick={() => handleEditFolder(folder)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
<Edit2 size={14} className="text-gray-600" />
|
</Button>
|
||||||
</button>
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-red-100" onClick={() => handleDeleteFolder(folder.id)} title="Удалить">
|
||||||
<button onClick={() => handleDeleteFolder(folder.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
|
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
||||||
<Trash2 size={14} className="text-red-600" />
|
</Button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
{searchResults.endpoints.map((endpoint: any) => (
|
||||||
{searchResults.endpoints.map((endpoint: any) => (
|
<div key={endpoint.id} className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 border border-transparent">
|
||||||
<div key={endpoint.id} className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 border border-transparent">
|
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
||||||
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex-1 min-w-0">
|
<span className="text-sm text-gray-900">{endpoint.name}</span>
|
||||||
<span className="text-sm text-gray-900">{endpoint.name}</span>
|
{endpoint.folderPath && <span className="ml-2 text-xs text-gray-400">{endpoint.folderPath}</span>}
|
||||||
{endpoint.folderPath && (
|
</div>
|
||||||
<span className="ml-2 text-xs text-gray-400">{endpoint.folderPath}</span>
|
<Badge variant="secondary" className={
|
||||||
)}
|
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||||
|
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'
|
||||||
|
}>{endpoint.method}</Badge>
|
||||||
|
<code className="text-xs text-gray-600 hidden sm:block">{endpoint.path}</code>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleEditEndpoint(endpoint)} title="Редактировать">
|
||||||
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-red-100" onClick={() => handleDeleteEndpoint(endpoint.id)} title="Удалить">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
))}
|
||||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
</>
|
||||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
)}
|
||||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
</>
|
||||||
'bg-red-100 text-red-700'
|
) : (
|
||||||
}`}>{endpoint.method}</span>
|
<>
|
||||||
<code className="text-xs text-gray-600 hidden sm:block">{endpoint.path}</code>
|
{tree.map(folder => (
|
||||||
<div className="flex gap-1">
|
<TreeNode key={folder.id} folder={folder} level={0}
|
||||||
<button onClick={() => handleEditEndpoint(endpoint)} className="p-1.5 hover:bg-gray-200 rounded" title="Редактировать">
|
expandedFolders={expandedFolders} onToggle={toggleFolder}
|
||||||
<Edit2 size={14} className="text-gray-600" />
|
onEditFolder={handleEditFolder} onDeleteFolder={handleDeleteFolder}
|
||||||
</button>
|
onCreateSubfolder={handleCreateFolder} onCreateEndpoint={handleCreateEndpoint}
|
||||||
<button onClick={() => handleDeleteEndpoint(endpoint.id)} className="p-1.5 hover:bg-red-100 rounded" title="Удалить">
|
onEditEndpoint={handleEditEndpoint} onDeleteEndpoint={handleDeleteEndpoint} />
|
||||||
<Trash2 size={14} className="text-red-600" />
|
))}
|
||||||
</button>
|
{rootEndpoints.map(endpoint => (
|
||||||
</div>
|
<EndpointNode key={endpoint.id} endpoint={endpoint} level={0}
|
||||||
</div>
|
onEdit={handleEditEndpoint} onDelete={handleDeleteEndpoint} />
|
||||||
))}
|
))}
|
||||||
</>
|
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||||
)}
|
<div className="text-center py-12 text-gray-500">
|
||||||
</>
|
<p>Нет папок и эндпоинтов.</p>
|
||||||
) : (
|
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||||
/* Обычное дерево */
|
</div>
|
||||||
<>
|
)}
|
||||||
{tree.map(folder => (
|
</>
|
||||||
<TreeNode
|
)}
|
||||||
key={folder.id}
|
</div>
|
||||||
folder={folder}
|
</CardContent>
|
||||||
level={0}
|
</Card>
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
onToggle={toggleFolder}
|
|
||||||
onEditFolder={handleEditFolder}
|
|
||||||
onDeleteFolder={handleDeleteFolder}
|
|
||||||
onCreateSubfolder={handleCreateFolder}
|
|
||||||
onCreateEndpoint={handleCreateEndpoint}
|
|
||||||
onEditEndpoint={handleEditEndpoint}
|
|
||||||
onDeleteEndpoint={handleDeleteEndpoint}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{rootEndpoints.map(endpoint => (
|
|
||||||
<EndpointNode
|
|
||||||
key={endpoint.id}
|
|
||||||
endpoint={endpoint}
|
|
||||||
level={0}
|
|
||||||
onEdit={handleEditEndpoint}
|
|
||||||
onDelete={handleDeleteEndpoint}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
<p>Нет папок и эндпоинтов.</p>
|
|
||||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFolderModal && (
|
{showFolderModal && (
|
||||||
<FolderModal
|
<FolderModal folder={editingFolder} parentId={selectedFolderId} folders={folders || []} onClose={() => setShowFolderModal(false)} />
|
||||||
folder={editingFolder}
|
|
||||||
parentId={selectedFolderId}
|
|
||||||
folders={folders || []}
|
|
||||||
onClose={() => setShowFolderModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog
|
<Dialog isOpen={dialog.isOpen} onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||||
isOpen={dialog.isOpen}
|
title={dialog.title} message={dialog.message} type={dialog.type} onConfirm={dialog.onConfirm} />
|
||||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
|
||||||
title={dialog.title}
|
|
||||||
message={dialog.message}
|
|
||||||
type={dialog.type}
|
|
||||||
onConfirm={dialog.onConfirm}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TreeNode({
|
function TreeNode({ folder, level, expandedFolders, onToggle, onEditFolder, onDeleteFolder, onCreateSubfolder, onCreateEndpoint, onEditEndpoint, onDeleteEndpoint }: any) {
|
||||||
folder,
|
|
||||||
level,
|
|
||||||
expandedFolders,
|
|
||||||
onToggle,
|
|
||||||
onEditFolder,
|
|
||||||
onDeleteFolder,
|
|
||||||
onCreateSubfolder,
|
|
||||||
onCreateEndpoint,
|
|
||||||
onEditEndpoint,
|
|
||||||
onDeleteEndpoint,
|
|
||||||
}: any) {
|
|
||||||
const isExpanded = expandedFolders.has(folder.id);
|
const isExpanded = expandedFolders.has(folder.id);
|
||||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group"
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
style={{ paddingLeft: `${level * 24 + 12}px` }}>
|
||||||
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
{hasChildren ? (
|
||||||
>
|
<button onClick={() => onToggle(folder.id)} className="p-0.5 hover:bg-gray-200 rounded">
|
||||||
{hasChildren && (
|
|
||||||
<button
|
|
||||||
onClick={() => onToggle(folder.id)}
|
|
||||||
className="p-0.5 hover:bg-gray-200 rounded"
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : <div className="w-5" />}
|
||||||
{!hasChildren && <div className="w-5" />}
|
{isExpanded ? <FolderOpen size={18} className="text-yellow-600 flex-shrink-0" /> : <FolderIcon size={18} className="text-yellow-600 flex-shrink-0" />}
|
||||||
|
|
||||||
{isExpanded ? (
|
|
||||||
<FolderOpen size={18} className="text-yellow-600 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<FolderIcon size={18} className="text-yellow-600 flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="font-medium text-gray-900 flex-1">{folder.name}</span>
|
<span className="font-medium text-gray-900 flex-1">{folder.name}</span>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">{folder.endpoints.length} эндпоинт(ов)</span>
|
||||||
{folder.endpoints.length} эндпоинт(ов)
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onCreateEndpoint(folder.id)} title="Добавить эндпоинт">
|
||||||
onClick={() => onCreateEndpoint(folder.id)}
|
<Plus className="h-3.5 w-3.5" />
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
</Button>
|
||||||
title="Добавить эндпоинт"
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onCreateSubfolder(folder.id)} title="Добавить подпапку">
|
||||||
>
|
<FolderIcon className="h-3.5 w-3.5" />
|
||||||
<Plus size={14} className="text-gray-600" />
|
</Button>
|
||||||
</button>
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEditFolder(folder)} title="Редактировать">
|
||||||
<button
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
onClick={() => onCreateSubfolder(folder.id)}
|
</Button>
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-red-100" onClick={() => onDeleteFolder(folder.id)} title="Удалить">
|
||||||
title="Добавить подпапку"
|
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
||||||
>
|
</Button>
|
||||||
<FolderIcon size={14} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onEditFolder(folder)}
|
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
<Edit2 size={14} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDeleteFolder(folder.id)}
|
|
||||||
className="p-1.5 hover:bg-red-100 rounded"
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} className="text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
{/* Подпапки */}
|
|
||||||
{folder.children.map((child: any) => (
|
{folder.children.map((child: any) => (
|
||||||
<TreeNode
|
<TreeNode key={child.id} folder={child} level={level + 1}
|
||||||
key={child.id}
|
expandedFolders={expandedFolders} onToggle={onToggle}
|
||||||
folder={child}
|
onEditFolder={onEditFolder} onDeleteFolder={onDeleteFolder}
|
||||||
level={level + 1}
|
onCreateSubfolder={onCreateSubfolder} onCreateEndpoint={onCreateEndpoint}
|
||||||
expandedFolders={expandedFolders}
|
onEditEndpoint={onEditEndpoint} onDeleteEndpoint={onDeleteEndpoint} />
|
||||||
onToggle={onToggle}
|
|
||||||
onEditFolder={onEditFolder}
|
|
||||||
onDeleteFolder={onDeleteFolder}
|
|
||||||
onCreateSubfolder={onCreateSubfolder}
|
|
||||||
onCreateEndpoint={onCreateEndpoint}
|
|
||||||
onEditEndpoint={onEditEndpoint}
|
|
||||||
onDeleteEndpoint={onDeleteEndpoint}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Эндпоинты в папке */}
|
|
||||||
{folder.endpoints.map((endpoint: Endpoint) => (
|
{folder.endpoints.map((endpoint: Endpoint) => (
|
||||||
<EndpointNode
|
<EndpointNode key={endpoint.id} endpoint={endpoint} level={level + 1}
|
||||||
key={endpoint.id}
|
onEdit={onEditEndpoint} onDelete={onDeleteEndpoint} />
|
||||||
endpoint={endpoint}
|
|
||||||
level={level + 1}
|
|
||||||
onEdit={onEditEndpoint}
|
|
||||||
onDelete={onDeleteEndpoint}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -464,53 +324,29 @@ function TreeNode({
|
|||||||
|
|
||||||
function EndpointNode({ endpoint, level, onEdit, onDelete }: any) {
|
function EndpointNode({ endpoint, level, onEdit, onDelete }: any) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group"
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
style={{ paddingLeft: `${level * 24 + 36}px` }}>
|
||||||
style={{ paddingLeft: `${level * 24 + 36}px` }}
|
|
||||||
>
|
|
||||||
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
||||||
<span className="text-sm text-gray-900 flex-1">{endpoint.name}</span>
|
<span className="text-sm text-gray-900 flex-1">{endpoint.name}</span>
|
||||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
<Badge variant="secondary" className={
|
||||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'
|
||||||
'bg-red-100 text-red-700'
|
}>{endpoint.method}</Badge>
|
||||||
}`}>
|
|
||||||
{endpoint.method}
|
|
||||||
</span>
|
|
||||||
<code className="text-xs text-gray-600">{endpoint.path}</code>
|
<code className="text-xs text-gray-600">{endpoint.path}</code>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onEdit(endpoint)} title="Редактировать">
|
||||||
onClick={() => onEdit(endpoint)}
|
<Edit2 className="h-3.5 w-3.5" />
|
||||||
className="p-1.5 hover:bg-gray-200 rounded"
|
</Button>
|
||||||
title="Редактировать"
|
<Button variant="ghost" size="icon" className="h-7 w-7 hover:bg-red-100" onClick={() => onDelete(endpoint.id)} title="Удалить">
|
||||||
>
|
<Trash2 className="h-3.5 w-3.5 text-red-600" />
|
||||||
<Edit2 size={14} className="text-gray-600" />
|
</Button>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(endpoint.id)}
|
|
||||||
className="p-1.5 hover:bg-red-100 rounded"
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} className="text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FolderModal({
|
function FolderModal({ folder, parentId, folders, onClose }: { folder: Folder | null; parentId: string | null; folders: Folder[]; onClose: () => void }) {
|
||||||
folder,
|
|
||||||
parentId,
|
|
||||||
folders,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
folder: Folder | null;
|
|
||||||
parentId: string | null;
|
|
||||||
folders: Folder[];
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: folder?.name || '',
|
name: folder?.name || '',
|
||||||
@@ -518,10 +354,9 @@ function FolderModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (data: any) =>
|
mutationFn: (data: any) => folder
|
||||||
folder
|
? foldersApi.update(folder.id, data.name, data.parent_id || undefined)
|
||||||
? foldersApi.update(folder.id, data.name, data.parent_id || undefined)
|
: foldersApi.create(data.name, data.parent_id || undefined),
|
||||||
: foldersApi.create(data.name, data.parent_id || undefined),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||||
toast.success(folder ? 'Папка обновлена' : 'Папка создана');
|
toast.success(folder ? 'Папка обновлена' : 'Папка создана');
|
||||||
@@ -530,65 +365,35 @@ function FolderModal({
|
|||||||
onError: () => toast.error('Ошибка сохранения папки'),
|
onError: () => toast.error('Ошибка сохранения папки'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const selectClasses = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||||
e.preventDefault();
|
|
||||||
saveMutation.mutate(formData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const availableFolders = folders.filter(f => f.id !== folder?.id);
|
const availableFolders = folders.filter(f => f.id !== folder?.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg max-w-md w-full">
|
<Card className="max-w-md w-full">
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900">{folder ? 'Редактировать папку' : 'Создать папку'}</h2>
|
||||||
{folder ? 'Редактировать папку' : 'Создать папку'}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(formData); }} className="p-6 space-y-4">
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Название папки</Label>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<Input required value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder="Название папки" />
|
||||||
Название папки
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="Название папки"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<div>
|
<Label>Родительская папка</Label>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<select value={formData.parent_id} onChange={(e) => setFormData({ ...formData, parent_id: e.target.value })} className={selectClasses}>
|
||||||
Родительская папка
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.parent_id}
|
|
||||||
onChange={(e) => setFormData({ ...formData, parent_id: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
>
|
|
||||||
<option value="">Корневая папка</option>
|
<option value="">Корневая папка</option>
|
||||||
{availableFolders.map((f) => (
|
{availableFolders.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
|
||||||
<option key={f.id} value={f.id}>
|
|
||||||
{f.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3 pt-4 border-t">
|
||||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
<Button type="button" variant="outline" className="flex-1" onClick={onClose}>Отмена</Button>
|
||||||
<button type="button" onClick={onClose} className="btn btn-secondary flex-1">
|
<Button type="submit" disabled={saveMutation.isPending} className="flex-1">
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary flex-1">
|
|
||||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
import { authApi } from '@/services/api';
|
import { authApi } from '@/services/api';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -33,51 +37,44 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||||
<div className="card w-full max-w-md p-8">
|
<Card className="w-full max-w-md">
|
||||||
<div className="text-center mb-8">
|
<CardHeader className="text-center">
|
||||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">KIS API Builder</h1>
|
<CardTitle className="text-3xl font-bold text-primary-600">KIS API Builder</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">Логин</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-2">
|
||||||
<div>
|
<Label htmlFor="password">Пароль</Label>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<Input
|
||||||
Логин
|
id="password"
|
||||||
</label>
|
type="password"
|
||||||
<input
|
required
|
||||||
type="text"
|
value={formData.password}
|
||||||
required
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
value={formData.username}
|
placeholder="Введите пароль"
|
||||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
/>
|
||||||
className="input w-full"
|
</div>
|
||||||
placeholder="Введите логин"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<LogIn className="mr-2 h-4 w-4" />
|
||||||
Пароль
|
{loading ? 'Вход...' : 'Войти'}
|
||||||
</label>
|
</Button>
|
||||||
<input
|
</form>
|
||||||
type="password"
|
</CardContent>
|
||||||
required
|
</Card>
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
placeholder="Введите пароль"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<LogIn size={18} />
|
|
||||||
{loading ? 'Вход...' : 'Войти'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { logsApi, endpointsApi, apiKeysApi } from '@/services/api';
|
import { logsApi, endpointsApi, apiKeysApi } from '@/services/api';
|
||||||
import { Trash2, Eye, Filter, X } from 'lucide-react';
|
import { Trash2, Eye, Filter, X } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import { toast } from 'sonner';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import Dialog from '@/components/Dialog';
|
import Dialog from '@/components/Dialog';
|
||||||
|
|
||||||
export default function Logs() {
|
export default function Logs() {
|
||||||
@@ -65,9 +69,7 @@ export default function Logs() {
|
|||||||
title: 'Подтверждение',
|
title: 'Подтверждение',
|
||||||
message: 'Вы уверены, что хотите очистить все логи?',
|
message: 'Вы уверены, что хотите очистить все логи?',
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
onConfirm: () => {
|
onConfirm: () => clearMutation.mutate({}),
|
||||||
clearMutation.mutate({});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,12 +79,12 @@ export default function Logs() {
|
|||||||
title: 'Подтверждение',
|
title: 'Подтверждение',
|
||||||
message: 'Вы уверены, что хотите очистить логи с текущими фильтрами?',
|
message: 'Вы уверены, что хотите очистить логи с текущими фильтрами?',
|
||||||
type: 'confirm',
|
type: 'confirm',
|
||||||
onConfirm: () => {
|
onConfirm: () => clearMutation.mutate(filters),
|
||||||
clearMutation.mutate(filters);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectClasses = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
@@ -92,59 +94,61 @@ export default function Logs() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{Object.values(filters).some(v => v) && (
|
{Object.values(filters).some(v => v) && (
|
||||||
<button onClick={handleClearFiltered} className="btn btn-secondary">
|
<Button variant="outline" onClick={handleClearFiltered}>
|
||||||
Очистить отфильтрованные
|
Очистить отфильтрованные
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button onClick={handleClearAll} className="btn btn-danger">
|
<Button variant="destructive" onClick={handleClearAll}>
|
||||||
Очистить все логи
|
Очистить все логи
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4 mb-6">
|
<Card className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<CardContent className="pt-4">
|
||||||
<Filter size={20} className="text-gray-400" />
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<h3 className="font-semibold">Фильтры</h3>
|
<Filter size={20} className="text-gray-400" />
|
||||||
{Object.values(filters).some(v => v) && (
|
<h3 className="font-semibold">Фильтры</h3>
|
||||||
<button
|
{Object.values(filters).some(v => v) && (
|
||||||
onClick={() => setFilters({ endpoint_id: '', api_key_id: '' })}
|
<button
|
||||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
onClick={() => setFilters({ endpoint_id: '', api_key_id: '' })}
|
||||||
>
|
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||||
<X size={16} />
|
>
|
||||||
Сбросить
|
<X size={16} />
|
||||||
</button>
|
Сбросить
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Эндпоинт</label>
|
|
||||||
<select
|
|
||||||
value={filters.endpoint_id}
|
|
||||||
onChange={(e) => setFilters({ ...filters, endpoint_id: e.target.value })}
|
|
||||||
className="input w-full"
|
|
||||||
>
|
|
||||||
<option value="">Все эндпоинты</option>
|
|
||||||
{endpoints?.map((ep) => (
|
|
||||||
<option key={ep.id} value={ep.id}>{ep.name} ({ep.method} {ep.path})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Ключ</label>
|
<div>
|
||||||
<select
|
<Label className="mb-1">Эндпоинт</Label>
|
||||||
value={filters.api_key_id}
|
<select
|
||||||
onChange={(e) => setFilters({ ...filters, api_key_id: e.target.value })}
|
value={filters.endpoint_id}
|
||||||
className="input w-full"
|
onChange={(e) => setFilters({ ...filters, endpoint_id: e.target.value })}
|
||||||
>
|
className={selectClasses}
|
||||||
<option value="">Все ключи</option>
|
>
|
||||||
{apiKeys?.map((key) => (
|
<option value="">Все эндпоинты</option>
|
||||||
<option key={key.id} value={key.id}>{key.name}</option>
|
{endpoints?.map((ep) => (
|
||||||
))}
|
<option key={ep.id} value={ep.id}>{ep.name} ({ep.method} {ep.path})</option>
|
||||||
</select>
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1">API Ключ</Label>
|
||||||
|
<select
|
||||||
|
value={filters.api_key_id}
|
||||||
|
onChange={(e) => setFilters({ ...filters, api_key_id: e.target.value })}
|
||||||
|
className={selectClasses}
|
||||||
|
>
|
||||||
|
<option value="">Все ключи</option>
|
||||||
|
{apiKeys?.map((key) => (
|
||||||
|
<option key={key.id} value={key.id}>{key.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -153,69 +157,56 @@ export default function Logs() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{logs?.map((log: any) => (
|
{logs?.map((log: any) => (
|
||||||
<div key={log.id} className="card p-4 hover:shadow-md transition-shadow">
|
<Card key={log.id} className="hover:shadow-md transition-shadow">
|
||||||
<div className="flex items-start justify-between">
|
<CardContent className="pt-4 pb-4">
|
||||||
<div className="flex-1">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex-1">
|
||||||
<span className={`px-2 py-1 text-xs font-semibold rounded ${
|
<div className="flex items-center gap-3 mb-2">
|
||||||
log.response_status >= 200 && log.response_status < 300
|
<Badge variant={
|
||||||
? 'bg-green-100 text-green-700'
|
log.response_status >= 200 && log.response_status < 300 ? 'default' :
|
||||||
: log.response_status >= 400
|
log.response_status >= 400 ? 'destructive' : 'secondary'
|
||||||
? 'bg-red-100 text-red-700'
|
} className={
|
||||||
: 'bg-yellow-100 text-yellow-700'
|
log.response_status >= 200 && log.response_status < 300 ? 'bg-green-100 text-green-700' :
|
||||||
}`}>
|
log.response_status >= 400 ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
|
||||||
{log.response_status}
|
}>
|
||||||
</span>
|
{log.response_status}
|
||||||
<span className="font-semibold text-gray-900">{log.method}</span>
|
</Badge>
|
||||||
<span className="text-gray-600">{log.path}</span>
|
<span className="font-semibold text-gray-900">{log.method}</span>
|
||||||
{log.endpoint_name && (
|
<span className="text-gray-600">{log.path}</span>
|
||||||
<span className="text-sm text-gray-500">→ {log.endpoint_name}</span>
|
{log.endpoint_name && (
|
||||||
)}
|
<span className="text-sm text-gray-500">{log.endpoint_name}</span>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
|
||||||
<span>⏱ {log.execution_time}мс</span>
|
|
||||||
<span>📅 {format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</span>
|
|
||||||
{log.api_key_name && (
|
|
||||||
<span>🔑 {log.api_key_name}</span>
|
|
||||||
)}
|
|
||||||
{log.ip_address && log.ip_address !== 'unknown' && (
|
|
||||||
<span>📍 {log.ip_address}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{log.error_message && (
|
|
||||||
<div className="mt-2 p-2 bg-red-50 rounded text-sm text-red-700">
|
|
||||||
❌ {log.error_message}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<span>{log.execution_time}ms</span>
|
||||||
|
<span>{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</span>
|
||||||
|
{log.api_key_name && <span>{log.api_key_name}</span>}
|
||||||
|
{log.ip_address && log.ip_address !== 'unknown' && <span>{log.ip_address}</span>}
|
||||||
|
</div>
|
||||||
|
{log.error_message && (
|
||||||
|
<div className="mt-2 p-2 bg-red-50 rounded text-sm text-red-700">
|
||||||
|
{log.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => setSelectedLog(log)} title="Детали">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="hover:bg-red-50" title="Удалить"
|
||||||
|
onClick={() => {
|
||||||
|
setDialog({
|
||||||
|
isOpen: true, title: 'Подтверждение', message: 'Удалить этот лог?', type: 'confirm',
|
||||||
|
onConfirm: () => deleteMutation.mutate(log.id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
</CardContent>
|
||||||
<button
|
</Card>
|
||||||
onClick={() => setSelectedLog(log)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
|
||||||
title="Детали"
|
|
||||||
>
|
|
||||||
<Eye size={18} className="text-gray-600" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDialog({
|
|
||||||
isOpen: true,
|
|
||||||
title: 'Подтверждение',
|
|
||||||
message: 'Удалить этот лог?',
|
|
||||||
type: 'confirm',
|
|
||||||
onConfirm: () => {
|
|
||||||
deleteMutation.mutate(log.id);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<Trash2 size={18} className="text-red-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{logs?.length === 0 && (
|
{logs?.length === 0 && (
|
||||||
@@ -245,53 +236,29 @@ export default function Logs() {
|
|||||||
function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
<Card className="max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
<div className="p-6 border-b flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">Детали лога</h2>
|
<h2 className="text-2xl font-bold text-gray-900">Детали лога</h2>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded">
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
<X size={24} />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-4">
|
<CardContent className="pt-6 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div><Label>Метод</Label><p className="text-gray-900">{log.method}</p></div>
|
||||||
<label className="text-sm font-medium text-gray-700">Метод</label>
|
<div><Label>Путь</Label><p className="text-gray-900">{log.path}</p></div>
|
||||||
<p className="text-gray-900">{log.method}</p>
|
<div><Label>Статус</Label><p className="text-gray-900">{log.response_status}</p></div>
|
||||||
</div>
|
<div><Label>Время выполнения</Label><p className="text-gray-900">{log.execution_time}мс</p></div>
|
||||||
<div>
|
<div><Label>Эндпоинт</Label><p className="text-gray-900">{log.endpoint_name || 'N/A'}</p></div>
|
||||||
<label className="text-sm font-medium text-gray-700">Путь</label>
|
<div><Label>API Ключ</Label><p className="text-gray-900">{log.api_key_name || 'Без ключа'}</p></div>
|
||||||
<p className="text-gray-900">{log.path}</p>
|
<div><Label>IP адрес</Label><p className="text-gray-900">{log.ip_address}</p></div>
|
||||||
</div>
|
<div><Label>Дата/время</Label><p className="text-gray-900">{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</p></div>
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Статус</label>
|
|
||||||
<p className="text-gray-900">{log.response_status}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Время выполнения</label>
|
|
||||||
<p className="text-gray-900">{log.execution_time}мс</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Эндпоинт</label>
|
|
||||||
<p className="text-gray-900">{log.endpoint_name || 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">API Ключ</label>
|
|
||||||
<p className="text-gray-900">{log.api_key_name || 'Без ключа'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">IP адрес</label>
|
|
||||||
<p className="text-gray-900">{log.ip_address}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-sm font-medium text-gray-700">Дата/время</label>
|
|
||||||
<p className="text-gray-900">{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{log.request_params && Object.keys(log.request_params).length > 0 && (
|
{log.request_params && Object.keys(log.request_params).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Параметры запроса</label>
|
<Label className="mb-2 block">Параметры запроса</Label>
|
||||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
{JSON.stringify(log.request_params, null, 2)}
|
{JSON.stringify(log.request_params, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -300,7 +267,7 @@ function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
|||||||
|
|
||||||
{log.request_body && Object.keys(log.request_body).length > 0 && (
|
{log.request_body && Object.keys(log.request_body).length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Тело запроса</label>
|
<Label className="mb-2 block">Тело запроса</Label>
|
||||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
{JSON.stringify(log.request_body, null, 2)}
|
{JSON.stringify(log.request_body, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -308,7 +275,7 @@ function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Ответ</label>
|
<Label className="mb-2 block">Ответ</Label>
|
||||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||||
{JSON.stringify(log.response_data, null, 2)}
|
{JSON.stringify(log.response_data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -316,7 +283,7 @@ function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
|||||||
|
|
||||||
{log.error_message && (
|
{log.error_message && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-red-700 mb-2 block">Ошибка</label>
|
<Label className="mb-2 block text-red-700">Ошибка</Label>
|
||||||
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
||||||
{log.error_message}
|
{log.error_message}
|
||||||
</div>
|
</div>
|
||||||
@@ -325,18 +292,16 @@ function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
|||||||
|
|
||||||
{log.user_agent && log.user_agent !== 'unknown' && (
|
{log.user_agent && log.user_agent !== 'unknown' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-gray-700">User Agent</label>
|
<Label>User Agent</Label>
|
||||||
<p className="text-sm text-gray-600">{log.user_agent}</p>
|
<p className="text-sm text-gray-600">{log.user_agent}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
|
|
||||||
<div className="p-6 border-t border-gray-200">
|
<div className="p-6 border-t">
|
||||||
<button onClick={onClose} className="btn btn-secondary">
|
<Button variant="outline" onClick={onClose}>Закрыть</Button>
|
||||||
Закрыть
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -177,6 +177,9 @@ export const giteaApi = {
|
|||||||
compareBranches: (base: string, head: string) => api.get('/gitea/compare', { params: { base, head } }),
|
compareBranches: (base: string, head: string) => api.get('/gitea/compare', { params: { base, head } }),
|
||||||
getCommitHistory: (path?: string, branch?: string) => api.get<GiteaCommit[]>('/gitea/commits', { params: { path, branch } }),
|
getCommitHistory: (path?: string, branch?: string) => api.get<GiteaCommit[]>('/gitea/commits', { params: { path, branch } }),
|
||||||
getEndpointInfo: (endpointId: string) => api.get<GiteaEndpointInfo>(`/gitea/endpoints/${endpointId}/info`),
|
getEndpointInfo: (endpointId: string) => api.get<GiteaEndpointInfo>(`/gitea/endpoints/${endpointId}/info`),
|
||||||
|
getEndpointFileContent: (endpointId: string, branch: string) => api.get(`/gitea/endpoints/${endpointId}/file-content`, { params: { branch } }),
|
||||||
|
commitEndpoint: (endpointId: string, data: { branch: string; message: string; changes: any }) => api.post(`/gitea/endpoints/${endpointId}/commit`, data),
|
||||||
|
getEndpointCommits: (endpointId: string, branch?: string) => api.get(`/gitea/endpoints/${endpointId}/commits`, { params: { branch } }),
|
||||||
createPR: (data: { title: string; head: string; base?: string; body?: string }) => api.post('/gitea/pulls', data),
|
createPR: (data: { title: string; head: string; base?: string; body?: string }) => api.post('/gitea/pulls', data),
|
||||||
mergePR: (index: number) => api.post(`/gitea/pulls/${index}/merge`),
|
mergePR: (index: number) => api.post(`/gitea/pulls/${index}/merge`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
@@ -18,6 +19,8 @@ export default {
|
|||||||
700: '#1d4ed8',
|
700: '#1d4ed8',
|
||||||
800: '#1e40af',
|
800: '#1e40af',
|
||||||
900: '#1e3a8a',
|
900: '#1e3a8a',
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
50: '#f8fafc',
|
50: '#f8fafc',
|
||||||
@@ -31,8 +34,56 @@ export default {
|
|||||||
800: '#1e293b',
|
800: '#1e293b',
|
||||||
900: '#0f172a',
|
900: '#0f172a',
|
||||||
},
|
},
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwindcss-animate")],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user