Add package dependencies system and project settings page
This commit is contained in:
338
frontend/src/components/DependencyGraph.css
Normal file
338
frontend/src/components/DependencyGraph.css
Normal file
@@ -0,0 +1,338 @@
|
||||
/* Dependency Graph Modal */
|
||||
.dependency-graph-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.dependency-graph-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dependency-graph-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dependency-graph-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dependency-graph-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dependency-graph-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.zoom-level {
|
||||
margin-left: auto;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.dependency-graph-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(90deg, var(--border-primary) 1px, transparent 1px),
|
||||
linear-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
background-position: center center;
|
||||
}
|
||||
|
||||
.graph-canvas {
|
||||
padding: 40px;
|
||||
min-width: 100%;
|
||||
min-height: 100%;
|
||||
transform-origin: center center;
|
||||
transition: transform 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* Graph Nodes */
|
||||
.graph-node-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.graph-node {
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
min-width: 200px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-node:hover {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.graph-node--root {
|
||||
background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.graph-node--hovered {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.graph-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.graph-node__name {
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.graph-node__toggle {
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.graph-node__toggle:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.graph-node__details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-node__version {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.graph-node__size {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Graph Children / Tree Structure */
|
||||
.graph-children {
|
||||
display: flex;
|
||||
padding-left: 24px;
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-connector {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 50%;
|
||||
width: 12px;
|
||||
border-left: 2px solid var(--border-primary);
|
||||
border-bottom: 2px solid var(--border-primary);
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
|
||||
.graph-children-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-children-list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 20px;
|
||||
bottom: 20px;
|
||||
border-left: 2px solid var(--border-primary);
|
||||
}
|
||||
|
||||
.graph-children-list > .graph-node-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.graph-children-list > .graph-node-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 20px;
|
||||
width: 12px;
|
||||
border-top: 2px solid var(--border-primary);
|
||||
}
|
||||
|
||||
/* Loading, Error, Empty States */
|
||||
.graph-loading,
|
||||
.graph-error,
|
||||
.graph-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.graph-loading .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-primary);
|
||||
border-top-color: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.graph-error {
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.graph-error svg {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.graph-error p {
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.graph-tooltip {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
font-size: 0.8125rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.graph-tooltip strong {
|
||||
display: block;
|
||||
color: var(--accent-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.graph-tooltip div {
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tooltip-hint {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dependency-graph-modal {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dependency-graph-content {
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.dependency-graph-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dependency-graph-info {
|
||||
flex-basis: 100%;
|
||||
order: 3;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
323
frontend/src/components/DependencyGraph.tsx
Normal file
323
frontend/src/components/DependencyGraph.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResolvedArtifact, DependencyResolutionResponse, Dependency } from '../types';
|
||||
import { resolveDependencies, getArtifactDependencies } from '../api';
|
||||
import './DependencyGraph.css';
|
||||
|
||||
interface DependencyGraphProps {
|
||||
projectName: string;
|
||||
packageName: string;
|
||||
tagName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
project: string;
|
||||
package: string;
|
||||
version: string | null;
|
||||
size: number;
|
||||
depth: number;
|
||||
children: GraphNode[];
|
||||
isRoot?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function DependencyGraph({ projectName, packageName, tagName, onClose }: DependencyGraphProps) {
|
||||
const navigate = useNavigate();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resolution, setResolution] = useState<DependencyResolutionResponse | null>(null);
|
||||
const [graphRoot, setGraphRoot] = useState<GraphNode | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Set<string>>(new Set());
|
||||
|
||||
// Build graph structure from resolution data
|
||||
const buildGraph = useCallback(async (resolutionData: DependencyResolutionResponse) => {
|
||||
const artifactMap = new Map<string, ResolvedArtifact>();
|
||||
resolutionData.resolved.forEach(artifact => {
|
||||
artifactMap.set(artifact.artifact_id, artifact);
|
||||
});
|
||||
|
||||
// Fetch dependencies for each artifact to build the tree
|
||||
const depsMap = new Map<string, Dependency[]>();
|
||||
|
||||
for (const artifact of resolutionData.resolved) {
|
||||
try {
|
||||
const deps = await getArtifactDependencies(artifact.artifact_id);
|
||||
depsMap.set(artifact.artifact_id, deps.dependencies);
|
||||
} catch {
|
||||
depsMap.set(artifact.artifact_id, []);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root artifact (the requested one)
|
||||
const rootArtifact = resolutionData.resolved.find(
|
||||
a => a.project === resolutionData.requested.project &&
|
||||
a.package === resolutionData.requested.package
|
||||
);
|
||||
|
||||
if (!rootArtifact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build tree recursively
|
||||
const visited = new Set<string>();
|
||||
|
||||
const buildNode = (artifact: ResolvedArtifact, depth: number): GraphNode => {
|
||||
const nodeId = `${artifact.project}/${artifact.package}`;
|
||||
visited.add(artifact.artifact_id);
|
||||
|
||||
const deps = depsMap.get(artifact.artifact_id) || [];
|
||||
const children: GraphNode[] = [];
|
||||
|
||||
for (const dep of deps) {
|
||||
// Find the resolved artifact for this dependency
|
||||
const childArtifact = resolutionData.resolved.find(
|
||||
a => a.project === dep.project && a.package === dep.package
|
||||
);
|
||||
|
||||
if (childArtifact && !visited.has(childArtifact.artifact_id)) {
|
||||
children.push(buildNode(childArtifact, depth + 1));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: nodeId,
|
||||
project: artifact.project,
|
||||
package: artifact.package,
|
||||
version: artifact.version || artifact.tag,
|
||||
size: artifact.size,
|
||||
depth,
|
||||
children,
|
||||
isRoot: depth === 0,
|
||||
};
|
||||
};
|
||||
|
||||
return buildNode(rootArtifact, 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await resolveDependencies(projectName, packageName, tagName);
|
||||
setResolution(result);
|
||||
|
||||
const graph = await buildGraph(result);
|
||||
setGraphRoot(graph);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
// Check if it's a resolution error
|
||||
try {
|
||||
const errorData = JSON.parse(err.message);
|
||||
if (errorData.error === 'circular_dependency') {
|
||||
setError(`Circular dependency detected: ${errorData.cycle?.join(' → ')}`);
|
||||
} else if (errorData.error === 'dependency_conflict') {
|
||||
setError(`Dependency conflict: ${errorData.message}`);
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
} catch {
|
||||
setError(err.message);
|
||||
}
|
||||
} else {
|
||||
setError('Failed to load dependency graph');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [projectName, packageName, tagName, buildGraph]);
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
navigate(`/project/${node.project}/${node.package}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleNodeToggle = (node: GraphNode, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedNodes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.id)) {
|
||||
next.delete(node.id);
|
||||
} else {
|
||||
next.add(node.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setZoom(z => Math.max(0.25, Math.min(2, z + delta)));
|
||||
};
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current || (e.target as HTMLElement).classList.contains('graph-canvas')) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging) {
|
||||
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const resetView = () => {
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const renderNode = (node: GraphNode, index: number = 0): JSX.Element => {
|
||||
const isCollapsed = collapsedNodes.has(node.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
|
||||
return (
|
||||
<div key={`${node.id}-${index}`} className="graph-node-container">
|
||||
<div
|
||||
className={`graph-node ${node.isRoot ? 'graph-node--root' : ''} ${hoveredNode?.id === node.id ? 'graph-node--hovered' : ''}`}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
onMouseEnter={() => setHoveredNode(node)}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
>
|
||||
<div className="graph-node__header">
|
||||
<span className="graph-node__name">{node.project}/{node.package}</span>
|
||||
{hasChildren && (
|
||||
<button
|
||||
className="graph-node__toggle"
|
||||
onClick={(e) => handleNodeToggle(node, e)}
|
||||
title={isCollapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{isCollapsed ? '+' : '-'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="graph-node__details">
|
||||
{node.version && <span className="graph-node__version">@ {node.version}</span>}
|
||||
<span className="graph-node__size">{formatBytes(node.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren && !isCollapsed && (
|
||||
<div className="graph-children">
|
||||
<div className="graph-connector"></div>
|
||||
<div className="graph-children-list">
|
||||
{node.children.map((child, i) => renderNode(child, i))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dependency-graph-modal" onClick={onClose}>
|
||||
<div className="dependency-graph-content" onClick={e => e.stopPropagation()}>
|
||||
<div className="dependency-graph-header">
|
||||
<h2>Dependency Graph</h2>
|
||||
<div className="dependency-graph-info">
|
||||
<span>{projectName}/{packageName} @ {tagName}</span>
|
||||
{resolution && (
|
||||
<span className="graph-stats">
|
||||
{resolution.artifact_count} packages • {formatBytes(resolution.total_size)} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="close-btn" onClick={onClose} title="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dependency-graph-toolbar">
|
||||
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.min(2, z + 0.25))}>
|
||||
Zoom In
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={() => setZoom(z => Math.max(0.25, z - 0.25))}>
|
||||
Zoom Out
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-small" onClick={resetView}>
|
||||
Reset View
|
||||
</button>
|
||||
<span className="zoom-level">{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="dependency-graph-container"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="graph-loading">
|
||||
<div className="spinner"></div>
|
||||
<span>Resolving dependencies...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="graph-error">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
) : graphRoot ? (
|
||||
<div
|
||||
className="graph-canvas"
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
}}
|
||||
>
|
||||
{renderNode(graphRoot)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="graph-empty">No dependencies to display</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hoveredNode && (
|
||||
<div className="graph-tooltip">
|
||||
<strong>{hoveredNode.project}/{hoveredNode.package}</strong>
|
||||
{hoveredNode.version && <div>Version: {hoveredNode.version}</div>}
|
||||
<div>Size: {formatBytes(hoveredNode.size)}</div>
|
||||
<div className="tooltip-hint">Click to navigate</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DependencyGraph;
|
||||
@@ -193,7 +193,6 @@ function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<a href="/docs">Documentation</a>
|
||||
<a href="/api/v1">API</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user