diff --git a/src/app/actualizar/page.tsx b/src/app/actualizar/page.tsx index e98033a..58cce48 100644 --- a/src/app/actualizar/page.tsx +++ b/src/app/actualizar/page.tsx @@ -4,9 +4,11 @@ import { useState } from "react"; import SamShell from "@/components/SamShell"; import { refreshModules, useModules, useMe } from "@/lib/store" import { + assignColorsForModules, displayName, envOf, getModuleMeta, + isValidHexColor, itMenu, } from "@/lib/sam"; @@ -65,6 +67,47 @@ export default function Actualizar() { } }; + // Rellena el color de los módulos ya importados que aún no lo tienen + // guardado: lo genera de forma autoritaria y lo persiste en el deployer. + const missingColor = modules.filter((m) => !isValidHexColor(m.color)); + const backfillColors = async () => { + if (missingColor.length === 0) return; + setBusy("color:backfill"); + setMsg(null); + try { + const assigned = assignColorsForModules(modules); + let ok = 0; + const failed: string[] = []; + for (const m of missingColor) { + try { + const res = await fetch( + `/api/deployer/modules/${encodeURIComponent(m.name)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ color: assigned[m.name] }), + cache: "no-store", + } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + ok++; + } catch { + failed.push(m.name); + } + } + refreshModules(); + setMsg({ + ok: failed.length === 0, + text: + failed.length === 0 + ? `Colores generados y guardados en ${ok} módulo${ok !== 1 ? "s" : ""}.` + : `Guardados ${ok}, fallaron: ${failed.join(", ")}.`, + }); + } finally { + setBusy(null); + } + }; + return ( + {missingColor.length > 0 && ( +
+ + + Hay {missingColor.length} módulo{missingColor.length !== 1 ? "s" : ""} sin + color guardado ({missingColor.map((m) => m.name).join(", ")}). + + +
+ )}
diff --git a/src/app/globals.css b/src/app/globals.css index 1395458..0fed1c6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -102,6 +102,32 @@ body { background-color: #f8faff; } +/* ============================================================ + Color identidad por módulo (solo índice sam.devops): + franja + sombra a la izquierda con el color guardado de la + aplicación. Se activa con .module-card y las variables + --module-color / --module-rgb ("r, g, b"). + ============================================================ */ +.app-card.module-card { + border-left: 4px solid var(--module-color, transparent); + box-shadow: + -6px 2px 14px rgba(var(--module-rgb, 0, 40, 93), 0.22), + 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.app-card.module-card:hover { + transform: translateY(-3px); + background-color: #fff; + box-shadow: + -8px 4px 22px rgba(var(--module-rgb, 0, 40, 93), 0.38), + 0 6px 18px rgba(0, 0, 0, 0.1); +} + +.app-card .module-icon { + border: none !important; + box-shadow: 0 3px 10px rgba(var(--module-rgb, 0, 40, 93), 0.45); +} + .app-card a { font-weight: 500; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 87b201d..a50bf9b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,10 @@ import Image from "next/image"; import Link from "next/link"; +import { useMemo, type CSSProperties } from "react"; import { useModules, refreshModules } from "@/lib/store"; import { + assignColorsForModules, boardHref, displayName, envOf, @@ -22,7 +24,15 @@ export default function Home() { const loading = data === null && error === null; const fetchModules = refreshModules; - const modules = visibleModules(data?.modules ?? []); + const modules = useMemo( + () => visibleModules(data?.modules ?? []), + [data] + ); + + // Colores efectivos de lo ya importado: el guardado manda y al resto + // se le genera uno sin repetir, así cada tarjeta queda identificada + // aunque el deployer aún no tenga `color` persistido. + const colorMap = useMemo(() => assignColorsForModules(modules), [modules]); const bySection: Record = {}; modules.forEach((m) => { @@ -32,7 +42,8 @@ export default function Home() { }); const renderModuleCard = (m: ModuleState) => { - const meta = getModuleMeta(m.name); + // El guardado en BD manda; si aún no tiene, el generado para la lista. + const meta = getModuleMeta(m.name, colorMap[m.name] ?? m.color); const status = getStatusInfo(m); const env = envOf(m.branch); const url = moduleUrl(m); @@ -62,13 +73,22 @@ export default function Home() { ); return (
-
+
diff --git a/src/app/upload/page.tsx b/src/app/upload/page.tsx index 36708f9..dd38fde 100644 --- a/src/app/upload/page.tsx +++ b/src/app/upload/page.tsx @@ -1,10 +1,10 @@ "use client"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import Link from "next/link"; import SamShell from "@/components/SamShell"; import { useModules, useMe } from "@/lib/store" -import { itMenu } from "@/lib/sam"; +import { itMenu, assignModuleColor } from "@/lib/sam"; import type { VerifyCheck, VerifyResult } from "@/lib/verify"; import { failedChecks, verifySummary } from "@/lib/verify"; @@ -110,6 +110,12 @@ export default function UploadPage() { const name = form.name.trim().toLowerCase(); setModuleName(name); + // Color identidad: lo asigna el sistema de forma autoritaria y se + // guarda en el deployer. Pinta la tarjeta del índice sam.devops. + const assignedColor = assignModuleColor( + name, + (data?.modules ?? []).map((m) => m.color) + ); const payload: Record = { name, repoUrl: form.repoUrl.trim(), @@ -117,6 +123,7 @@ export default function UploadPage() { category: form.category, kind: form.kind, visible: form.visible, + color: assignedColor, }; if (form.port) payload.port = parseInt(form.port, 10); if (form.subdomain) payload.subdomain = form.subdomain.trim(); @@ -169,6 +176,16 @@ export default function UploadPage() { }; const busy = phase === "deploying" || phase === "verifying"; + + // Vista previa del color que el sistema asignará al integrar. + const previewColor = useMemo(() => { + const n = form.name.trim().toLowerCase(); + if (!n) return "#6c757d"; + return assignModuleColor( + n, + (data?.modules ?? []).map((m) => m.color) + ); + }, [form.name, data]); const failed = failedChecks(verify); const verifyFailed = phase === "done" && verify && !verify.ok; @@ -489,6 +506,27 @@ export default function UploadPage() { +
+ + Color asignado + +
+ + {previewColor} +
+
+ Lo asigna el sistema al integrar (sombra izquierda en sam.devops). +
+
+
= { + academico: "#2563eb", + profesores: "#d97706", + profesor: "#d97706", + empresas: "#0891b2", + portal: "#00285d", + sam: "#00285d", + ventas: "#059669", + comercial: "#0f766e", + pagos: "#ea580c", + contabilidad: "#dc2626", + finanzas: "#dc2626", + "academico-empresas": "#1d4ed8", + "sam-api": "#7c3aed", + "sam-deployer": "#4d7c0f", + "sam-postgres": "#336791", + "sam-redis": "#b91c1c", +}; + +function hashName(name: string): number { + let h = 5381; + const s = name.toLowerCase(); + for (let i = 0; i < s.length; i++) { + h = ((h << 5) + h + s.charCodeAt(i)) | 0; + } + return Math.abs(h); +} + +export function isValidHexColor(v: unknown): v is string { + return typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v.trim()); +} + +export function normalizeHexColor(v: unknown, fallback = "#6c757d"): string { + return isValidHexColor(v) ? (v as string).trim().toLowerCase() : fallback; +} + +// Asignación autoritaria del color al integrar. +// 1. Nombre curado conocido -> color fijo. +// 2. Si no, primera paleta libre (evita repetir con los ya usados). +// 3. Si todo está ocupado, hash determinista por nombre. +export function assignModuleColor(name: string, taken: Array = []): string { + const key = (name || "").toLowerCase(); + if (MODULE_COLORS[key]) return MODULE_COLORS[key]; + for (const fam of Object.keys(MODULE_COLORS)) { + if (fam.length >= 4 && key.includes(fam)) return MODULE_COLORS[fam]; + } + if (!key) return "#6c757d"; + const used = new Set( + taken + .filter(isValidHexColor) + .map((c) => (c as string).trim().toLowerCase()) + ); + const free = MODULE_PALETTE.find((c) => !used.has(c)); + if (free) return free; + return MODULE_PALETTE[hashName(key) % MODULE_PALETTE.length]; +} + +// Color efectivo: el guardado en BD manda, si no el asignado. +export function getModuleColor(name: string, stored?: string | null): string { + if (isValidHexColor(stored)) return (stored as string).trim().toLowerCase(); + return assignModuleColor(name); +} + +// Genera colores para los módulos ya importados que aún no tienen +// `color` en BD, sin repetir con los guardados ni entre sí. +// Devuelve mapa nombre -> color efectivo (guardado o generado). +export function assignColorsForModules( + modules: T[] +): Record { + const taken: string[] = modules + .map((m) => m.color) + .filter(isValidHexColor) + .map((c) => (c as string).trim().toLowerCase()); + // Reserva también los fijos curados para no duplicarlos al generar. + for (const m of modules) { + const key = (m.name || "").toLowerCase(); + if (MODULE_COLORS[key] && !taken.includes(MODULE_COLORS[key])) + taken.push(MODULE_COLORS[key]); + } + const result: Record = {}; + for (const m of modules) { + if (isValidHexColor(m.color)) { + result[m.name] = (m.color as string).trim().toLowerCase(); + } else { + const c = assignModuleColor(m.name, taken); + result[m.name] = c; + taken.push(c); + } + } + return result; +} + +export function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace("#", "").trim(); + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h; + const n = parseInt(full, 16); + if (Number.isNaN(n)) return [108, 117, 125]; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function rgbOf(hex: string): string { + return hexToRgb(hex).join(", "); +} + +// Texto legible sobre el color del módulo (iconos con fondo). +export function onModuleColor(hex: string): string { + const [r, g, b] = hexToRgb(hex); + // Luminancia relativa aproximada. + const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return lum > 0.62 ? "#212529" : "#ffffff"; +} + const A = ( label: string, icon: string, @@ -213,8 +357,10 @@ const iconMap: Record = { default: { icon: "bx-cube", bg: "bg-secondary" }, }; -export function getModuleMeta(name: string) { - return iconMap[name.toLowerCase()] || iconMap.default; +export function getModuleMeta(name: string, storedColor?: string | null) { + const meta = iconMap[name.toLowerCase()] || iconMap.default; + const color = getModuleColor(name, storedColor); + return { ...meta, color, rgb: rgbOf(color), onColor: onModuleColor(color) }; } export function getStatusInfo(m: ModuleState) {