feat(13): Create LinkedAppsSection component for showing linked apps

This commit is contained in:
Juan Denis committed 2026-01-19 10:45:28 -05:00
1 parent 60ba8967b8
commit 92df579228
3 files changed
+372

No files matched your search

@@ -0,0 +1,151 @@
import React from 'react';
import { GitBranch, Link2, Unlink, ExternalLink, Server } from 'lucide-react';
const LinkedAppsSection = ({
app,
linkedApps,
onLink,
onUnlink,
onNavigate,
loading
}) => {
const envLabels = {
production: 'Production',
development: 'Development',
staging: 'Staging',
standalone: 'Standalone'
};
const envColors = {
production: 'env-production',
development: 'env-development',
staging: 'env-staging'
};
const getStatusClass = (status) => {
switch (status) {
case 'running': return 'status-active';
case 'stopped': return 'status-stopped';
case 'error': return 'status-error';
default: return 'status-warning';
}
};
return (
<div className="card linked-apps-section">
<div className="linked-apps-header">
<h3>
<GitBranch size={18} />
Environment Linking
</h3>
{!app.has_linked_app && (
<button
className="btn btn-secondary btn-sm"
onClick={onLink}
disabled={loading}
>
<Link2 size={14} />
Link App
</button>
)}
</div>
<div className="linked-apps-content">
{/* Current app environment */}
<div className="current-environment">
<span className="env-label">This app is:</span>
<span className={`env-badge ${envColors[app.environment_type] || ''}`}>
{envLabels[app.environment_type] || 'Standalone'}
</span>
</div>
{/* Linked apps list */}
{linkedApps && linkedApps.length > 0 ? (
<div className="linked-apps-list">
<div className="linked-apps-divider">
<span>Linked to</span>
</div>
{linkedApps.map(linkedApp => (
<div key={linkedApp.id} className="linked-app-item">
<div className="linked-app-info">
<div className="linked-app-icon">
<Server size={16} />
</div>
<div className="linked-app-details">
<span className="linked-app-name">{linkedApp.name}</span>
<div className="linked-app-meta">
<span className={`env-badge ${envColors[linkedApp.environment_type] || ''}`}>
{envLabels[linkedApp.environment_type] || linkedApp.environment_type}
</span>
<span className={`status-badge-sm ${getStatusClass(linkedApp.status)}`}>
<span className="status-dot" />
{linkedApp.status}
</span>
{linkedApp.port && (
<span className="linked-app-port">:{linkedApp.port}</span>
)}
</div>
</div>
</div>
<div className="linked-app-actions">
<button
className="btn btn-secondary btn-sm btn-icon"
onClick={() => onNavigate(linkedApp.id)}
title="View app"
>
<ExternalLink size={14} />
</button>
<button
className="btn btn-secondary btn-sm btn-icon"
onClick={onUnlink}
disabled={loading}
title="Unlink apps"
>
<Unlink size={14} />
</button>
</div>
</div>
))}
</div>
) : app.environment_type !== 'standalone' ? (
<div className="linked-apps-empty">
<p>No linked apps. Link another app to share database resources.</p>
<button
className="btn btn-secondary btn-sm"
onClick={onLink}
disabled={loading}
>
<Link2 size={14} />
Link App
</button>
</div>
) : (
<div className="linked-apps-info">
<p>
Link this app to another to create a production/development pair.
Linked apps can share database credentials with different table prefixes.
</p>
</div>
)}
{/* Shared config info */}
{app.shared_config && app.shared_config.db_credentials_propagated && (
<div className="shared-config-info">
<div className="shared-config-badge">
<Server size={12} />
Shared Database
</div>
{app.shared_config.shared_db && (
<div className="shared-config-details">
<span>Host: {app.shared_config.shared_db.db_host}</span>
<span>Table prefix: {app.shared_config.shared_db.target_prefix || app.shared_config.shared_db.source_prefix}</span>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default LinkedAppsSection;
@@ -0,0 +1,220 @@
// ============================================
// LINKED APPS SECTION
// ============================================
.linked-apps-section {
margin-top: @space-4;
}
.linked-apps-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: @space-4;
h3 {
display: flex;
align-items: center;
gap: @space-2;
margin: 0;
font-size: @font-size-base;
font-weight: @font-weight-semibold;
color: @text-primary;
}
}
.linked-apps-content {
display: flex;
flex-direction: column;
gap: @space-4;
}
.current-environment {
display: flex;
align-items: center;
gap: @space-3;
padding: @space-3;
background: @bg-hover;
border-radius: @radius-md;
.env-label {
font-size: @font-size-sm;
color: @text-secondary;
}
}
.linked-apps-divider {
display: flex;
align-items: center;
gap: @space-3;
color: @text-tertiary;
font-size: @font-size-sm;
&::before,
&::after {
content: '';
flex: 1;
height: 1px;
background: @border-subtle;
}
}
.linked-apps-list {
display: flex;
flex-direction: column;
gap: @space-3;
}
.linked-app-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: @space-3;
background: @bg-card;
border: 1px solid @border-subtle;
border-radius: @radius-md;
transition: border-color @transition-fast;
&:hover {
border-color: @border-default;
}
}
.linked-app-info {
display: flex;
align-items: center;
gap: @space-3;
}
.linked-app-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: @accent-glow;
color: @accent-primary;
border-radius: @radius-md;
}
.linked-app-details {
display: flex;
flex-direction: column;
gap: @space-1;
}
.linked-app-name {
font-weight: @font-weight-medium;
color: @text-primary;
}
.linked-app-meta {
display: flex;
align-items: center;
gap: @space-2;
font-size: @font-size-sm;
}
.linked-app-port {
color: @text-tertiary;
font-family: @font-family-mono;
font-size: @font-size-xs;
}
.linked-app-actions {
display: flex;
gap: @space-2;
}
.linked-apps-empty,
.linked-apps-info {
padding: @space-4;
text-align: center;
background: @bg-hover;
border-radius: @radius-md;
p {
margin: 0 0 @space-3;
color: @text-secondary;
font-size: @font-size-sm;
}
.btn {
margin-top: @space-2;
}
}
.linked-apps-info p {
margin: 0;
}
// Shared config info
.shared-config-info {
padding: @space-3;
background: fade(@accent-primary, 10%);
border: 1px solid fade(@accent-primary, 20%);
border-radius: @radius-md;
}
.shared-config-badge {
display: inline-flex;
align-items: center;
gap: @space-1;
font-size: @font-size-sm;
font-weight: @font-weight-medium;
color: @accent-primary;
margin-bottom: @space-2;
}
.shared-config-details {
display: flex;
flex-wrap: wrap;
gap: @space-3;
font-size: @font-size-xs;
color: @text-secondary;
font-family: @font-family-mono;
span {
background: @bg-card;
padding: @space-1 @space-2;
border-radius: @radius-sm;
}
}
// Small status badge variant
.status-badge-sm {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: @radius-full;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
&.status-active {
background: @success-bg;
color: @success;
}
&.status-stopped {
background: fade(@text-tertiary, 10%);
color: @text-tertiary;
}
&.status-error {
background: @danger-bg;
color: @danger;
}
&.status-warning {
background: @warning-bg;
color: @warning;
}
}
+1
View File
@@ -49,6 +49,7 @@
@import 'components/_deploy';
@import 'components/_build';
@import 'components/_users';
@import 'components/_linked-apps';
// --------------------------------------------
// 5. PAGE-SPECIFIC STYLES