small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:02:36 +02:00
parent 08bd0c6e5c
commit 08cb5754f3
638 changed files with 57332 additions and 34706 deletions
+631
View File
@@ -0,0 +1,631 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Railway Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
railway: {
bg: '#0d0d12',
sidebar: '#0a0a0f',
card: '#15151d',
cardHover: '#1a1a24',
border: '#232330',
accent: '#9d4edd',
accentHover: '#7b2cbf',
success: '#22c55e',
muted: '#6b7280',
text: '#f3f4f6',
textMuted: '#9ca3af'
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* {
font-family: 'Inter', sans-serif;
}
.connection-line {
position: absolute;
border: 2px dashed #3d3d55;
pointer-events: none;
}
.connection-line.horizontal {
height: 2px;
border-top: 2px dashed #3d3d55;
border-left: none;
border-right: none;
border-bottom: none;
}
.status-dot {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.service-card {
transition: all 0.2s ease;
}
.service-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
}
.group-container {
transition: all 0.3s ease;
}
.dropdown-menu {
animation: dropdownIn 0.15s ease-out;
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: scale(0.95) translateY(-5px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.sidebar-icon {
transition: all 0.15s ease;
}
.sidebar-icon:hover {
background-color: rgba(157, 78, 221, 0.1);
color: #9d4edd;
}
.add-btn {
transition: all 0.2s ease;
}
.add-btn:hover {
transform: scale(1.02);
box-shadow: 0 4px 15px rgba(157, 78, 221, 0.3);
}
/* Modal styles */
.modal-backdrop {
backdrop-filter: blur(4px);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0a0a0f;
}
::-webkit-scrollbar-thumb {
background: #3d3d55;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4d4d66;
}
</style>
</head>
<body class="bg-railway-bg text-railway-text h-screen overflow-hidden">
<div class="flex h-full">
<!-- Sidebar -->
<aside class="w-16 bg-railway-sidebar border-r border-railway-border flex flex-col items-center py-4 flex-shrink-0">
<!-- Logo -->
<div class="mb-6">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-railway-accent to-railway-accentHover flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
</div>
</div>
<!-- Nav Icons -->
<nav class="flex flex-col gap-2 flex-1">
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-accent bg-railway-card">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path>
</svg>
</button>
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-textMuted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</button>
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-textMuted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg>
</button>
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-textMuted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</nav>
<!-- Bottom Icons -->
<div class="flex flex-col gap-2 mt-auto">
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-textMuted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
<button class="sidebar-icon w-10 h-10 rounded-lg flex items-center justify-center text-railway-textMuted">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<header class="h-14 border-b border-railway-border flex items-center justify-between px-6 bg-railway-bg/50 backdrop-blur-sm">
<div class="flex items-center gap-3">
<button class="flex items-center gap-2 text-railway-text hover:text-white transition-colors">
<span class="font-semibold">dazzling-curiosity</span>
<svg class="w-4 h-4 text-railway-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<span class="text-railway-muted">/</span>
<button class="flex items-center gap-2 text-railway-textMuted hover:text-white transition-colors text-sm">
<span>production</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
</div>
<div class="flex items-center gap-3">
<button class="add-btn flex items-center gap-2 bg-railway-card hover:bg-railway-cardHover border border-railway-border px-3 py-1.5 rounded-lg text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Add
</button>
<div class="w-px h-6 bg-railway-border mx-1"></div>
<button class="w-8 h-8 rounded-lg flex items-center justify-center text-railway-textMuted hover:text-white hover:bg-railway-card transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"></path>
</svg>
</button>
<button class="w-8 h-8 rounded-lg flex items-center justify-center text-railway-textMuted hover:text-white hover:bg-railway-card transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</button>
</div>
</header>
<!-- Dashboard Content -->
<div class="flex-1 overflow-auto p-6">
<div id="groups-container" class="flex flex-col gap-6 max-w-7xl mx-auto">
<!-- Groups will be rendered here -->
</div>
</div>
</main>
</div>
<!-- Add Service Modal -->
<div id="add-service-modal" class="fixed inset-0 z-50 hidden">
<div class="modal-backdrop absolute inset-0 bg-black/60" onclick="closeModal()"></div>
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="bg-railway-card border border-railway-border rounded-xl w-full max-w-md p-6 shadow-2xl">
<h3 class="text-lg font-semibold mb-4">Add New Service</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-railway-textMuted mb-1">Service Name</label>
<input type="text" id="service-name" class="w-full bg-railway-bg border border-railway-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-railway-accent" placeholder="e.g., MyApp">
</div>
<div>
<label class="block text-sm text-railway-textMuted mb-1">Domain</label>
<input type="text" id="service-domain" class="w-full bg-railway-bg border border-railway-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-railway-accent" placeholder="e.g., myapp.example.com">
</div>
<div>
<label class="block text-sm text-railway-textMuted mb-1">Group</label>
<select id="service-group" class="w-full bg-railway-bg border border-railway-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-railway-accent">
<option value="homelab">Homelab</option>
<option value="competition">Competition</option>
<option value="apis">API's</option>
<option value="new">+ New Group</option>
</select>
</div>
<div id="new-group-input" class="hidden">
<label class="block text-sm text-railway-textMuted mb-1">New Group Name</label>
<input type="text" id="new-group-name" class="w-full bg-railway-bg border border-railway-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-railway-accent" placeholder="Group name">
</div>
<div>
<label class="block text-sm text-railway-textMuted mb-1">Icon</label>
<div class="flex gap-2 flex-wrap">
<button onclick="selectIcon('server')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="server">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 01-2 2v4a2 2 0 012 2h14a2 2 0 012-2v-4a2 2 0 01-2-2m-2-4h.01M17 16h.01"></path></svg>
</button>
<button onclick="selectIcon('database')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="database">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>
</button>
<button onclick="selectIcon('cloud')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="cloud">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"></path></svg>
</button>
<button onclick="selectIcon('code')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="code">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
</button>
<button onclick="selectIcon('mail')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="mail">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
</button>
<button onclick="selectIcon('globe')" class="icon-select w-10 h-10 rounded-lg bg-railway-bg border border-railway-border flex items-center justify-center hover:border-railway-accent" data-icon="globe">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button>
</div>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button onclick="closeModal()" class="px-4 py-2 text-sm text-railway-textMuted hover:text-white transition-colors">Cancel</button>
<button onclick="addService()" class="px-4 py-2 bg-railway-accent hover:bg-railway-accentHover text-white rounded-lg text-sm font-medium transition-colors">Add Service</button>
</div>
</div>
</div>
</div>
<script>
// Initial data
let groups = [
{
id: 'homelab',
name: 'Homelab',
icon: 'home',
services: [
{
id: 'koffan',
name: 'Koffan',
domain: 'shopping.tdvorak.dev',
status: 'online',
volume: 'koffan-volume',
icon: 'server'
}
]
},
{
id: 'competition',
name: 'Competition',
icon: 'trophy',
services: [
{
id: 'insightful-optimism',
name: 'insightful-optimism',
domain: 'co-back.tdvorak.dev',
status: 'online',
connections: ['postgres'],
icon: 'github'
},
{
id: 'postgres',
name: 'Postgres',
domain: '',
status: 'online',
volume: 'postgres-volume-89XR',
icon: 'database'
}
]
},
{
id: 'apis',
name: "API's",
icon: 'code',
services: [
{
id: 'sendmail',
name: 'SendMail',
domain: 'sendmail.tdvorak.dev',
status: 'online',
icon: 'mail'
},
{
id: 'youtubescraper',
name: 'YoutubeScraper',
domain: 'youtube.tdvorak.dev',
status: 'online',
icon: 'youtube'
}
]
}
];
let selectedIcon = 'server';
let dropdownOpen = null;
// Icon SVGs
const icons = {
server: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 01-2 2v4a2 2 0 012 2h14a2 2 0 012-2v-4a2 2 0 01-2-2m-2-4h.01M17 16h.01"></path></svg>',
database: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path></svg>',
cloud: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"></path></svg>',
code: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>',
mail: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>',
globe: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>',
home: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>',
trophy: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path></svg>',
github: '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>',
youtube: '<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
};
function renderIcon(name) {
return icons[name] || icons.server;
}
function getGroupIcon(icon) {
const groupIcons = {
home: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path></svg>',
trophy: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 21h8m-4-4v4m-5-16l2.5 9h7L19 5H4zM4 5v6a2 2 0 002 2h2V5H4zm14 0v8h2a2 2 0 002-2V5h-4z"></path></svg>',
code: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>'
};
return groupIcons[icon] || groupIcons.home;
}
function toggleDropdown(groupId, event) {
event.stopPropagation();
if (dropdownOpen === groupId) {
closeDropdown();
return;
}
closeDropdown();
dropdownOpen = groupId;
const dropdown = document.getElementById(`dropdown-${groupId}`);
if (dropdown) {
dropdown.classList.remove('hidden');
}
}
function closeDropdown() {
if (dropdownOpen) {
const dropdown = document.getElementById(`dropdown-${dropdownOpen}`);
if (dropdown) {
dropdown.classList.add('hidden');
}
}
dropdownOpen = null;
}
function deleteGroup(groupId) {
groups = groups.filter(g => g.id !== groupId);
renderGroups();
closeDropdown();
}
function deleteService(groupId, serviceId) {
const group = groups.find(g => g.id === groupId);
if (group) {
group.services = group.services.filter(s => s.id !== serviceId);
renderGroups();
}
}
function renderConnection() {
// Connection line between insightful-optimism and postgres
return `
<div class="hidden md:flex items-center justify-center w-16 relative">
<div class="absolute h-0.5 border-t-2 border-dashed border-railway-border w-full"></div>
<div class="absolute -left-1 w-2 h-2 rounded-full bg-railway-border"></div>
<div class="absolute -right-1 w-2 h-2 rounded-full bg-railway-border"></div>
<svg class="w-4 h-4 text-railway-muted absolute right-0 transform translate-x-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</div>
`;
}
function renderServiceCard(service, groupId) {
const hasVolume = service.volume ? `
<div class="flex items-center gap-2 text-xs text-railway-textMuted mt-3 pt-3 border-t border-railway-border/50">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
${service.volume}
</div>
` : '';
return `
<div class="service-card bg-railway-card border border-railway-border rounded-lg p-4 min-w-[220px] max-w-[280px] relative group">
<button onclick="deleteService('${groupId}', '${service.id}')" class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-railway-textMuted hover:text-red-400 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<div class="flex items-start gap-3 mb-2">
<div class="w-10 h-10 rounded-lg bg-railway-bg flex items-center justify-center text-railway-text shrink-0">
${renderIcon(service.icon)}
</div>
<div class="min-w-0">
<h4 class="font-semibold text-sm truncate">${service.name}</h4>
<p class="text-xs text-railway-textMuted truncate">${service.domain || 'Internal Service'}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-railway-success status-dot"></span>
<span class="text-xs text-railway-success font-medium">Online</span>
</div>
${hasVolume}
</div>
`;
}
function renderGroup(group) {
const servicesHtml = group.services.map((service, index) => {
const card = renderServiceCard(service, group.id);
// Add connection line if this is the first service and there are 2 services
if (group.id === 'competition' && index === 0 && group.services.length > 1) {
return card + renderConnection();
}
return card;
}).join('');
return `
<div class="group-container">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<div class="text-railway-accent">
${getGroupIcon(group.icon)}
</div>
<h3 class="font-semibold text-railway-text">${group.name}</h3>
</div>
<button onclick="toggleDropdown('${group.id}', event)" class="relative p-1 text-railway-textMuted hover:text-white transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"></path>
</svg>
<div id="dropdown-${group.id}" class="dropdown-menu hidden absolute right-0 top-full mt-1 w-32 bg-railway-card border border-railway-border rounded-lg shadow-xl z-10">
<button onclick="deleteGroup('${group.id}')" class="w-full text-left px-3 py-2 text-sm text-red-400 hover:bg-railway-bg rounded-lg">Delete Group</button>
</div>
</button>
</div>
<div class="flex flex-wrap gap-4">
${servicesHtml}
<button onclick="openModal('${group.id}')" class="border-2 border-dashed border-railway-border rounded-lg p-4 min-w-[220px] max-w-[280px] flex items-center justify-center text-railway-textMuted hover:text-railway-accent hover:border-railway-accent/50 transition-all group">
<div class="flex flex-col items-center gap-2">
<svg class="w-6 h-6 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span class="text-sm font-medium">Add Service</span>
</div>
</button>
</div>
</div>
`;
}
function renderGroups() {
const container = document.getElementById('groups-container');
container.innerHTML = groups.map(renderGroup).join('');
}
function openModal(preselectedGroup = null) {
const modal = document.getElementById('add-service-modal');
modal.classList.remove('hidden');
if (preselectedGroup) {
document.getElementById('service-group').value = preselectedGroup;
}
// Reset form
document.getElementById('service-name').value = '';
document.getElementById('service-domain').value = '';
document.getElementById('new-group-name').value = '';
document.getElementById('new-group-input').classList.add('hidden');
selectIcon('server');
}
function closeModal() {
const modal = document.getElementById('add-service-modal');
modal.classList.add('hidden');
}
function selectIcon(icon) {
selectedIcon = icon;
document.querySelectorAll('.icon-select').forEach(btn => {
if (btn.dataset.icon === icon) {
btn.classList.add('border-railway-accent', 'text-railway-accent');
btn.classList.remove('border-railway-border');
} else {
btn.classList.remove('border-railway-accent', 'text-railway-accent');
btn.classList.add('border-railway-border');
}
});
}
function addService() {
const name = document.getElementById('service-name').value.trim();
const domain = document.getElementById('service-domain').value.trim();
const groupSelect = document.getElementById('service-group').value;
const newGroupName = document.getElementById('new-group-name').value.trim();
if (!name) {
alert('Please enter a service name');
return;
}
let groupId = groupSelect;
if (groupSelect === 'new') {
if (!newGroupName) {
alert('Please enter a group name');
return;
}
groupId = 'group-' + Date.now();
groups.push({
id: groupId,
name: newGroupName,
icon: 'home',
services: []
});
}
const service = {
id: 'service-' + Date.now(),
name: name,
domain: domain,
status: 'online',
icon: selectedIcon
};
const group = groups.find(g => g.id === groupId);
if (group) {
group.services.push(service);
}
renderGroups();
closeModal();
}
// Event listeners
document.addEventListener('click', closeDropdown);
document.getElementById('service-group').addEventListener('change', function() {
const newGroupInput = document.getElementById('new-group-input');
if (this.value === 'new') {
newGroupInput.classList.remove('hidden');
} else {
newGroupInput.classList.add('hidden');
}
});
document.querySelector('.add-btn').addEventListener('click', () => openModal());
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
closeDropdown();
}
});
// Initial render
renderGroups();
</script>
</body>
</html>