2365 lines
82 KiB
TypeScript
2365 lines
82 KiB
TypeScript
import {
|
||
ArrowLeft,
|
||
ArrowUpRight,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Home,
|
||
ImageIcon,
|
||
Link2,
|
||
LockKeyhole,
|
||
LogOut,
|
||
Plus,
|
||
Save,
|
||
Search,
|
||
SlidersHorizontal,
|
||
Sparkles,
|
||
Star,
|
||
Trash2,
|
||
Upload,
|
||
X,
|
||
} from "lucide-react";
|
||
import {
|
||
type ChangeEvent,
|
||
type DragEvent,
|
||
type FormEvent,
|
||
useCallback,
|
||
useDeferredValue,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { CATEGORY_ORDER } from "./data";
|
||
import { getSafeUrl, loadHubData, normalizeSearch } from "./storage";
|
||
import {
|
||
deleteHubApp,
|
||
fetchHubApps,
|
||
fetchHubData,
|
||
fetchMyAppAccess,
|
||
migrateLocalAppsIfNeeded,
|
||
recordRecent,
|
||
saveHubApp,
|
||
setFavorite,
|
||
subscribeToHubApps,
|
||
} from "./repository";
|
||
import { supabase } from "./supabase";
|
||
import type {
|
||
AppCategory,
|
||
AppVisibility,
|
||
HubUser,
|
||
HubApp,
|
||
HubData,
|
||
Session,
|
||
ToastMessage,
|
||
} from "./types";
|
||
|
||
type AppView = "hub" | "admin";
|
||
|
||
interface AppDraft {
|
||
name: string;
|
||
description: string;
|
||
category: AppCategory;
|
||
url: string;
|
||
iconDataUrl: string;
|
||
visibility: AppVisibility;
|
||
}
|
||
|
||
const COLLATOR = new Intl.Collator("es-DO", { sensitivity: "base" });
|
||
const AUTHORIZED_DOMAIN = "@gomezleemarketing.com";
|
||
const ICON_GENERATOR_WEBHOOK_URL = import.meta.env.VITE_ICON_GENERATOR_WEBHOOK_URL?.trim() ?? "";
|
||
const ACCESS_REQUEST_WEBHOOK_URL = import.meta.env.VITE_ACCESS_REQUEST_WEBHOOK_URL?.trim() ?? "";
|
||
|
||
interface SupabaseAuthSession {
|
||
access_token: string;
|
||
expires_at?: number;
|
||
user: {
|
||
id: string;
|
||
email?: string;
|
||
user_metadata?: Record<string, unknown>;
|
||
};
|
||
}
|
||
|
||
interface AuthorizedUserRow {
|
||
email: string;
|
||
full_name: string | null;
|
||
role: "admin" | "member";
|
||
is_active: boolean;
|
||
}
|
||
|
||
interface AuthorizationResult {
|
||
session: Session | null;
|
||
error: string;
|
||
}
|
||
|
||
function getAppUrl(hash = ""): string {
|
||
return `${import.meta.env.BASE_URL}${hash}`;
|
||
}
|
||
|
||
function getOAuthRedirectUrl(): string {
|
||
return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
|
||
}
|
||
|
||
function getAuthenticatedName(
|
||
authSession: SupabaseAuthSession,
|
||
access: AuthorizedUserRow | null,
|
||
): string {
|
||
const metadata = authSession.user.user_metadata ?? {};
|
||
const metadataName = metadata.full_name ?? metadata.name;
|
||
const email = authSession.user.email?.trim().toLocaleLowerCase("en-US") ?? "";
|
||
const emailName = email.split("@")[0] || "Usuario GLM";
|
||
|
||
return (
|
||
access?.full_name?.trim() ||
|
||
(typeof metadataName === "string" ? metadataName.trim() : "") ||
|
||
emailName
|
||
);
|
||
}
|
||
|
||
function getAuthenticatedAvatarUrl(authSession: SupabaseAuthSession): string {
|
||
const metadata = authSession.user.user_metadata ?? {};
|
||
const candidate = metadata.avatar_url ?? metadata.picture;
|
||
if (typeof candidate !== "string" || !candidate.trim()) return "";
|
||
|
||
try {
|
||
const url = new URL(candidate.trim());
|
||
return url.protocol === "https:" ? url.toString() : "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
async function authorizeSupabaseSession(authSession: SupabaseAuthSession): Promise<AuthorizationResult> {
|
||
const email = authSession.user.email?.trim().toLocaleLowerCase("en-US") ?? "";
|
||
|
||
if (!email.endsWith(AUTHORIZED_DOMAIN)) {
|
||
return {
|
||
session: null,
|
||
error: "Debes continuar con una cuenta corporativa @gomezleemarketing.com.",
|
||
};
|
||
}
|
||
|
||
// Todos los correos corporativos entran como Usuario por defecto.
|
||
// La tabla solo conserva administradores y bloqueos excepcionales.
|
||
// Se consulta como una lista limitada a una fila en lugar de usar
|
||
// `maybeSingle()`. En algunas instalaciones self-hosted de PostgREST, una
|
||
// consulta sin coincidencias puede responder como error al solicitar un
|
||
// objeto único. Una lista vacía, en cambio, representa correctamente a un
|
||
// usuario corporativo normal que no necesita registro en la tabla.
|
||
const { data, error } = await supabase
|
||
.from("glm_hub_authorized_users")
|
||
.select("email, full_name, role, is_active")
|
||
.eq("email", email)
|
||
.limit(1);
|
||
const accessRows = (data ?? []) as AuthorizedUserRow[];
|
||
const access = accessRows[0] ?? null;
|
||
|
||
if (error) {
|
||
console.error("No se pudo validar el perfil de acceso:", error);
|
||
return {
|
||
session: null,
|
||
error: "No pudimos completar el inicio de sesión. Intenta nuevamente.",
|
||
};
|
||
}
|
||
|
||
if (access && !access.is_active) {
|
||
return {
|
||
session: null,
|
||
error: "Tu acceso al GLM Hub está deshabilitado. Contacta a IT Support.",
|
||
};
|
||
}
|
||
|
||
return {
|
||
session: {
|
||
user: {
|
||
id: authSession.user.id,
|
||
name: getAuthenticatedName(authSession, access),
|
||
email,
|
||
role: access?.role === "admin" ? "admin" : "member",
|
||
avatarUrl: getAuthenticatedAvatarUrl(authSession),
|
||
},
|
||
expiresAt: (authSession.expires_at ?? Math.floor(Date.now() / 1000) + 3600) * 1000,
|
||
},
|
||
error: "",
|
||
};
|
||
}
|
||
|
||
function getInitials(name: string): string {
|
||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||
if (words.length === 0) return "GL";
|
||
if (words.length === 1) return words[0].slice(0, 2).toLocaleUpperCase("es-DO");
|
||
const surnameIndex = words.length >= 4 ? words.length - 2 : words.length - 1;
|
||
return `${words[0][0]}${words[surnameIndex][0]}`.toLocaleUpperCase("es-DO");
|
||
}
|
||
|
||
function getGreeting(): string {
|
||
const hour = new Date().getHours();
|
||
if (hour < 12) return "Buenos días";
|
||
if (hour < 19) return "Buenas tardes";
|
||
return "Buenas noches";
|
||
}
|
||
|
||
function capitalize(value: string): string {
|
||
return value ? value[0].toLocaleUpperCase("es-DO") + value.slice(1) : value;
|
||
}
|
||
|
||
function formatDateLabel(date: Date): string {
|
||
const parts = new Intl.DateTimeFormat("es-DO", {
|
||
weekday: "long",
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
}).formatToParts(date);
|
||
|
||
const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? "";
|
||
return `${capitalize(getPart("weekday"))} ${getPart("day")} de ${capitalize(getPart("month"))} de ${getPart("year")}`;
|
||
}
|
||
|
||
function useCurrentDateLabel(): string {
|
||
const [label, setLabel] = useState(() => formatDateLabel(new Date()));
|
||
|
||
useEffect(() => {
|
||
const refresh = () => setLabel(formatDateLabel(new Date()));
|
||
refresh();
|
||
const intervalId = window.setInterval(refresh, 60_000);
|
||
return () => window.clearInterval(intervalId);
|
||
}, []);
|
||
|
||
return label;
|
||
}
|
||
|
||
function makeEmptyDraft(): AppDraft {
|
||
return {
|
||
name: "",
|
||
description: "",
|
||
category: "Administración",
|
||
url: "",
|
||
iconDataUrl: "",
|
||
visibility: "published",
|
||
};
|
||
}
|
||
|
||
function appToDraft(app: HubApp): AppDraft {
|
||
return {
|
||
name: app.name,
|
||
description: app.description,
|
||
category: app.category,
|
||
url: app.url,
|
||
iconDataUrl: app.iconDataUrl,
|
||
visibility: app.visibility,
|
||
};
|
||
}
|
||
|
||
async function processIcon(file: File, enforceFileSize = true): Promise<string> {
|
||
const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp"]);
|
||
if (!allowedTypes.has(file.type)) {
|
||
throw new Error("Usa un ícono en formato PNG, JPG o WebP.");
|
||
}
|
||
if (enforceFileSize && file.size > 1024 * 1024) {
|
||
throw new Error("El ícono debe pesar menos de 1 MB.");
|
||
}
|
||
|
||
const bitmap = await createImageBitmap(file);
|
||
if (bitmap.width > 4096 || bitmap.height > 4096) {
|
||
bitmap.close();
|
||
throw new Error("El ícono no puede superar 4096 × 4096 px.");
|
||
}
|
||
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 192;
|
||
canvas.height = 192;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) {
|
||
bitmap.close();
|
||
throw new Error("Este navegador no pudo procesar el ícono.");
|
||
}
|
||
|
||
const scale = Math.min(192 / bitmap.width, 192 / bitmap.height);
|
||
const targetWidth = bitmap.width * scale;
|
||
const targetHeight = bitmap.height * scale;
|
||
const targetX = (192 - targetWidth) / 2;
|
||
const targetY = (192 - targetHeight) / 2;
|
||
context.clearRect(0, 0, 192, 192);
|
||
context.drawImage(bitmap, targetX, targetY, targetWidth, targetHeight);
|
||
bitmap.close();
|
||
return canvas.toDataURL("image/webp", 0.9);
|
||
}
|
||
|
||
async function processGeneratedIcon(dataUrl: string): Promise<string> {
|
||
if (!dataUrl.startsWith("data:image/")) throw new Error("La IA no devolvió una imagen válida.");
|
||
const response = await fetch(dataUrl);
|
||
const blob = await response.blob();
|
||
return processGeneratedIconBlob(blob);
|
||
}
|
||
|
||
async function processGeneratedIconBlob(blob: Blob): Promise<string> {
|
||
const mimeType = blob.type || "image/png";
|
||
if (!mimeType.startsWith("image/")) throw new Error("El flujo de IA no devolvió un archivo de imagen.");
|
||
const extension = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1] || "png";
|
||
const file = new File([blob], `icono-generado.${extension}`, { type: mimeType });
|
||
return processIcon(file, false);
|
||
}
|
||
|
||
function BrandLogo({ compact = false }: { compact?: boolean }) {
|
||
return (
|
||
<img
|
||
className={compact ? "brand-logo brand-logo--compact" : "brand-logo"}
|
||
src={`${import.meta.env.BASE_URL}glm-logo.png`}
|
||
alt="GomezLee Marketing"
|
||
width="350"
|
||
height="109"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function AppMark({
|
||
app,
|
||
size = "regular",
|
||
showPlaceholder = false,
|
||
}: {
|
||
app: HubApp;
|
||
size?: "small" | "regular" | "large";
|
||
showPlaceholder?: boolean;
|
||
}) {
|
||
const [imageFailed, setImageFailed] = useState(false);
|
||
const showImage = Boolean(app.iconDataUrl) && !imageFailed;
|
||
|
||
return (
|
||
<span className={`app-mark app-mark--${size}`} aria-hidden="true">
|
||
{showImage ? (
|
||
<img src={app.iconDataUrl} alt="" onError={() => setImageFailed(true)} />
|
||
) : showPlaceholder ? (
|
||
<ImageIcon className="app-mark__placeholder" size={36} strokeWidth={1.5} />
|
||
) : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function UserAvatar({ user, label }: { user: HubUser; label?: string }) {
|
||
const [imageFailed, setImageFailed] = useState(false);
|
||
const showImage = Boolean(user.avatarUrl) && !imageFailed;
|
||
|
||
return (
|
||
<span className="avatar" aria-label={label} aria-hidden={label ? undefined : true}>
|
||
{showImage ? (
|
||
<img
|
||
src={user.avatarUrl}
|
||
alt=""
|
||
referrerPolicy="no-referrer"
|
||
onError={() => setImageFailed(true)}
|
||
/>
|
||
) : (
|
||
getInitials(user.name)
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface ToastStackProps {
|
||
messages: ToastMessage[];
|
||
onDismiss: (id: string) => void;
|
||
}
|
||
|
||
function ToastStack({ messages, onDismiss }: ToastStackProps) {
|
||
return (
|
||
<div className="toast-stack" aria-live="polite" aria-relevant="additions">
|
||
{messages.map((message) => (
|
||
<div className={`toast toast--${message.kind}`} key={message.id}>
|
||
<span className="toast__signal" aria-hidden="true" />
|
||
<div>
|
||
<strong>{message.title}</strong>
|
||
{message.detail ? <p>{message.detail}</p> : null}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="icon-button icon-button--quiet"
|
||
aria-label="Cerrar mensaje"
|
||
onClick={() => onDismiss(message.id)}
|
||
>
|
||
<X size={18} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface LoginViewProps {
|
||
onLogin: () => Promise<void>;
|
||
error: string;
|
||
disabled?: boolean;
|
||
}
|
||
|
||
function LoginView({ onLogin, error, disabled = false }: LoginViewProps) {
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const handleLogin = async () => {
|
||
setSubmitting(true);
|
||
await onLogin();
|
||
setSubmitting(false);
|
||
};
|
||
|
||
return (
|
||
<main className="login-shell">
|
||
<section className="login-story" aria-labelledby="login-headline">
|
||
<div className="login-story__topline">
|
||
<BrandLogo />
|
||
</div>
|
||
|
||
<div className="login-story__content">
|
||
<p className="eyebrow">ACCESO INTERNO</p>
|
||
<h1 id="login-headline">
|
||
Todo el trabajo
|
||
<span>de GLM, a un clic.</span>
|
||
</h1>
|
||
<p className="login-story__lede">
|
||
Tu punto de partida para abrir herramientas, encontrar recursos y mantener el día en movimiento.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="login-story__type" aria-hidden="true">GLM</div>
|
||
</section>
|
||
|
||
<section className="login-panel" aria-label="Inicio de sesión">
|
||
<div className="login-panel__inner">
|
||
<div className="login-panel__heading">
|
||
<p className="section-kicker">GLM HUB</p>
|
||
<h2>Entra a tu espacio.</h2>
|
||
</div>
|
||
|
||
<div className="login-form">
|
||
{error ? (
|
||
<p className="form-error" id="login-error" role="alert">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<button
|
||
className="primary-button primary-button--full"
|
||
type="button"
|
||
disabled={disabled || submitting}
|
||
onClick={() => void handleLogin()}
|
||
>
|
||
<LockKeyhole size={18} aria-hidden="true" />
|
||
{submitting ? "Conectando…" : "Continuar con Google"}
|
||
{!submitting ? <ArrowUpRight size={18} aria-hidden="true" /> : null}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
interface SidebarProps {
|
||
user: HubUser;
|
||
view: AppView;
|
||
onNavigate: (view: AppView) => void;
|
||
onLogout: () => void;
|
||
}
|
||
|
||
function Sidebar({ user, view, onNavigate, onLogout }: SidebarProps) {
|
||
return (
|
||
<aside className="sidebar">
|
||
<div className="sidebar__brand">
|
||
<BrandLogo compact />
|
||
</div>
|
||
|
||
<nav className="sidebar__nav" aria-label="Navegación principal">
|
||
<button
|
||
type="button"
|
||
className={view === "hub" ? "is-active" : ""}
|
||
onClick={() => onNavigate("hub")}
|
||
aria-current={view === "hub" ? "page" : undefined}
|
||
>
|
||
<Home size={20} aria-hidden="true" />
|
||
<span>Aplicaciones</span>
|
||
</button>
|
||
{user.role === "admin" ? (
|
||
<button
|
||
type="button"
|
||
className={view === "admin" ? "is-active" : ""}
|
||
onClick={() => onNavigate("admin")}
|
||
aria-current={view === "admin" ? "page" : undefined}
|
||
>
|
||
<SlidersHorizontal size={20} aria-hidden="true" />
|
||
<span>Administrar</span>
|
||
</button>
|
||
) : null}
|
||
</nav>
|
||
|
||
<div className="sidebar__account">
|
||
<UserAvatar user={user} />
|
||
<span className="sidebar__account-copy">
|
||
<strong>{user.name}</strong>
|
||
<small>{user.role === "admin" ? "Administrador" : "Usuario"}</small>
|
||
</span>
|
||
<button type="button" className="icon-button" aria-label="Cerrar sesión" onClick={onLogout}>
|
||
<LogOut size={18} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
interface MobileHeaderProps {
|
||
user: HubUser;
|
||
}
|
||
|
||
function MobileHeader({ user }: MobileHeaderProps) {
|
||
return (
|
||
<header className="mobile-header">
|
||
<BrandLogo compact />
|
||
<UserAvatar user={user} label={`Sesión de ${user.name}`} />
|
||
</header>
|
||
);
|
||
}
|
||
|
||
interface MobileNavProps extends SidebarProps {}
|
||
|
||
function MobileNav({ user, view, onNavigate, onLogout }: MobileNavProps) {
|
||
return (
|
||
<nav className="mobile-nav" aria-label="Navegación móvil">
|
||
<button type="button" className={view === "hub" ? "is-active" : ""} onClick={() => onNavigate("hub")}>
|
||
<Home size={20} aria-hidden="true" />
|
||
<span>Inicio</span>
|
||
</button>
|
||
{user.role === "admin" ? (
|
||
<button type="button" className={view === "admin" ? "is-active" : ""} onClick={() => onNavigate("admin")}>
|
||
<SlidersHorizontal size={20} aria-hidden="true" />
|
||
<span>Administrar</span>
|
||
</button>
|
||
) : null}
|
||
<button type="button" onClick={onLogout}>
|
||
<LogOut size={20} aria-hidden="true" />
|
||
<span>Salir</span>
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
type AccessState = boolean | null;
|
||
|
||
function AccessIndicator({ hasAccess }: { hasAccess: AccessState }) {
|
||
const label = hasAccess === null ? "Verificando acceso" : hasAccess ? "Disponible" : "Sin acceso";
|
||
const stateClass = hasAccess === null ? "access-indicator--checking" : hasAccess ? "access-indicator--granted" : "access-indicator--denied";
|
||
|
||
return (
|
||
<span className={`access-indicator ${stateClass}`} aria-label={label}>
|
||
<span aria-hidden="true" />
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
interface QuickLaunchProps {
|
||
app: HubApp;
|
||
isFavorite: boolean;
|
||
hasAccess: AccessState;
|
||
onActivate: (app: HubApp) => void;
|
||
onFavorite: (appId: string) => void;
|
||
}
|
||
|
||
function QuickLaunch({ app, isFavorite, hasAccess, onActivate, onFavorite }: QuickLaunchProps) {
|
||
const available = Boolean(getSafeUrl(app.url));
|
||
return (
|
||
<article className="quick-launch">
|
||
<div className="quick-launch__top">
|
||
<AppMark app={app} size="regular" />
|
||
<button
|
||
type="button"
|
||
className={`favorite-button ${isFavorite ? "is-favorite" : ""}`}
|
||
onClick={() => onFavorite(app.id)}
|
||
aria-label={isFavorite ? `Quitar ${app.name} de favoritos` : `Agregar ${app.name} a favoritos`}
|
||
aria-pressed={isFavorite}
|
||
>
|
||
<Star size={18} fill={isFavorite ? "currentColor" : "none"} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div className="quick-launch__copy">
|
||
<div className="quick-launch__meta">
|
||
<span className="quick-launch__category">{app.category}</span>
|
||
<AccessIndicator hasAccess={hasAccess} />
|
||
</div>
|
||
<h3>{app.name}</h3>
|
||
<p>{app.description}</p>
|
||
</div>
|
||
<button type="button" className="quick-launch__action" onClick={() => onActivate(app)}>
|
||
<span>{available ? "Abrir aplicación" : "Enlace pendiente"}</span>
|
||
{available ? <ArrowUpRight size={18} aria-hidden="true" /> : <Link2 size={17} aria-hidden="true" />}
|
||
</button>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface DirectoryRowProps {
|
||
app: HubApp;
|
||
index: number;
|
||
isFavorite: boolean;
|
||
hasAccess: AccessState;
|
||
onActivate: (app: HubApp) => void;
|
||
onFavorite: (appId: string) => void;
|
||
}
|
||
|
||
function DirectoryRow({ app, index, isFavorite, hasAccess, onActivate, onFavorite }: DirectoryRowProps) {
|
||
const available = Boolean(getSafeUrl(app.url));
|
||
return (
|
||
<article className="directory-row">
|
||
<span className="directory-row__number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||
<AppMark app={app} size="small" />
|
||
<div className="directory-row__copy">
|
||
<h3>{app.name}</h3>
|
||
<p>{app.description}</p>
|
||
</div>
|
||
<span className="directory-row__category">{app.category}</span>
|
||
<span className="directory-row__status">
|
||
<AccessIndicator hasAccess={hasAccess} />
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={`favorite-button ${isFavorite ? "is-favorite" : ""}`}
|
||
onClick={() => onFavorite(app.id)}
|
||
aria-label={isFavorite ? `Quitar ${app.name} de favoritos` : `Agregar ${app.name} a favoritos`}
|
||
aria-pressed={isFavorite}
|
||
>
|
||
<Star size={18} fill={isFavorite ? "currentColor" : "none"} aria-hidden="true" />
|
||
</button>
|
||
<button type="button" className="directory-row__open" onClick={() => onActivate(app)}>
|
||
<span>{available ? "Abrir" : "Ver estado"}</span>
|
||
{available ? <ArrowUpRight size={17} aria-hidden="true" /> : <ChevronRight size={17} aria-hidden="true" />}
|
||
</button>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface EmptyResultsProps {
|
||
search: string;
|
||
favoritesOnly: boolean;
|
||
onClear: () => void;
|
||
}
|
||
|
||
function EmptyResults({ search, favoritesOnly, onClear }: EmptyResultsProps) {
|
||
return (
|
||
<div className="empty-state">
|
||
<span className="empty-state__mark" aria-hidden="true">0</span>
|
||
<div>
|
||
<p className="section-kicker">SIN RESULTADOS</p>
|
||
<h3>{favoritesOnly ? "Aún no tienes favoritos aquí." : `No encontramos “${search || "esa aplicación"}”.`}</h3>
|
||
<p>Prueba otro término, cambia la categoría o vuelve a ver todo el directorio.</p>
|
||
<button type="button" className="text-button" onClick={onClear}>Limpiar filtros</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type AccessRequestArea = "Todas" | "Administración" | "Recursos Humanos" | "CDC";
|
||
|
||
interface AccessRequestSectionProps {
|
||
apps: HubApp[];
|
||
onSubmit: (app: HubApp) => Promise<boolean>;
|
||
}
|
||
|
||
function AccessRequestSection({ apps, onSubmit }: AccessRequestSectionProps) {
|
||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||
const [area, setArea] = useState<AccessRequestArea>("Todas");
|
||
const [appId, setAppId] = useState("");
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const publishedApps = useMemo(
|
||
() => [...apps]
|
||
.filter((app) => app.visibility === "published")
|
||
.sort((a, b) => COLLATOR.compare(a.name, b.name)),
|
||
[apps],
|
||
);
|
||
|
||
const filteredApps = useMemo(
|
||
() => area === "Todas" ? publishedApps : publishedApps.filter((app) => app.category === area),
|
||
[area, publishedApps],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (appId && !filteredApps.some((app) => app.id === appId)) setAppId("");
|
||
}, [appId, filteredApps]);
|
||
|
||
useEffect(() => {
|
||
if (!submitting) return undefined;
|
||
const warnBeforeLeave = (event: BeforeUnloadEvent) => {
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
};
|
||
window.addEventListener("beforeunload", warnBeforeLeave);
|
||
return () => window.removeEventListener("beforeunload", warnBeforeLeave);
|
||
}, [submitting]);
|
||
|
||
const openDialog = () => {
|
||
setArea("Todas");
|
||
setAppId("");
|
||
dialogRef.current?.showModal();
|
||
};
|
||
|
||
const closeDialog = () => {
|
||
if (submitting) return;
|
||
dialogRef.current?.close();
|
||
};
|
||
|
||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const selectedApp = publishedApps.find((app) => app.id === appId);
|
||
if (!selectedApp || submitting) return;
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
const sent = await onSubmit(selectedApp);
|
||
if (sent) {
|
||
dialogRef.current?.close();
|
||
setArea("Todas");
|
||
setAppId("");
|
||
}
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="access-request-bridge" aria-label="Solicitud de acceso a aplicaciones">
|
||
<div className="access-request-bridge__number" aria-hidden="true">?</div>
|
||
<div>
|
||
<p className="section-kicker">SOLICITUD DE ACCESO</p>
|
||
<h2>¿No tienes acceso a una aplicación?</h2>
|
||
<p>Selecciona el portal que necesitas y envía la solicitud al equipo responsable.</p>
|
||
</div>
|
||
<button type="button" className="secondary-button" onClick={openDialog}>
|
||
Solicitar acceso aquí
|
||
<ArrowUpRight size={18} aria-hidden="true" />
|
||
</button>
|
||
|
||
<dialog
|
||
className="access-request-dialog"
|
||
ref={dialogRef}
|
||
onCancel={(event) => {
|
||
if (submitting) event.preventDefault();
|
||
}}
|
||
>
|
||
<button type="button" className="access-request-dialog__close" onClick={closeDialog} aria-label="Cerrar">
|
||
<X size={19} aria-hidden="true" />
|
||
</button>
|
||
<p className="section-kicker">GLM HUB</p>
|
||
<h2>Solicitar acceso</h2>
|
||
<p className="access-request-dialog__intro">
|
||
Elige el área y la aplicación. El catálogo se actualiza automáticamente con los portales publicados.
|
||
</p>
|
||
|
||
<form onSubmit={(event) => void handleSubmit(event)}>
|
||
<div className="form-field">
|
||
<label htmlFor="access-request-area">Área<RequiredMark /></label>
|
||
<select
|
||
id="access-request-area"
|
||
value={area}
|
||
onChange={(event) => setArea(event.target.value as AccessRequestArea)}
|
||
disabled={submitting}
|
||
required
|
||
>
|
||
<option value="Todas">Todas</option>
|
||
<option value="Administración">Administración</option>
|
||
<option value="Recursos Humanos">Recursos Humanos</option>
|
||
<option value="CDC">CDC</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="form-field">
|
||
<label htmlFor="access-request-app">Aplicación<RequiredMark /></label>
|
||
<select
|
||
id="access-request-app"
|
||
value={appId}
|
||
onChange={(event) => setAppId(event.target.value)}
|
||
disabled={submitting || filteredApps.length === 0}
|
||
required
|
||
>
|
||
<option value="">{filteredApps.length === 0 ? "No hay aplicaciones publicadas en esta área" : "Selecciona una aplicación"}</option>
|
||
{filteredApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="access-request-dialog__actions">
|
||
<button type="button" className="secondary-button secondary-button--quiet" onClick={closeDialog} disabled={submitting}>Cancelar</button>
|
||
<button type="submit" className="primary-button" disabled={!appId || submitting}>
|
||
{submitting ? "Enviando…" : "Enviar solicitud"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
{submitting ? (
|
||
<div className="generation-overlay" role="dialog" aria-modal="true" aria-labelledby="access-request-loading-title">
|
||
<div className="generation-overlay__card">
|
||
<span className="generation-spinner" aria-hidden="true" />
|
||
<p className="section-kicker">GLM HUB</p>
|
||
<h2 id="access-request-loading-title">Enviando la solicitud…</h2>
|
||
<p>Estamos registrando la solicitud y enviando el correo a las personas responsables.</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
type PaginationItem = number | "ellipsis-start" | "ellipsis-end";
|
||
|
||
function getPaginationItems(currentPage: number, totalPages: number, compact = false): PaginationItem[] {
|
||
const visibleLimit = compact ? 5 : 7;
|
||
if (totalPages <= visibleLimit) {
|
||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||
}
|
||
|
||
if (compact) {
|
||
if (currentPage <= 3) return [1, 2, 3, "ellipsis-end", totalPages];
|
||
if (currentPage >= totalPages - 2) {
|
||
return [1, "ellipsis-start", totalPages - 2, totalPages - 1, totalPages];
|
||
}
|
||
return [1, "ellipsis-start", currentPage, "ellipsis-end", totalPages];
|
||
}
|
||
|
||
if (currentPage <= 4) return [1, 2, 3, 4, 5, "ellipsis-end", totalPages];
|
||
if (currentPage >= totalPages - 3) {
|
||
return [
|
||
1,
|
||
"ellipsis-start",
|
||
totalPages - 4,
|
||
totalPages - 3,
|
||
totalPages - 2,
|
||
totalPages - 1,
|
||
totalPages,
|
||
];
|
||
}
|
||
return [
|
||
1,
|
||
"ellipsis-start",
|
||
currentPage - 1,
|
||
currentPage,
|
||
currentPage + 1,
|
||
"ellipsis-end",
|
||
totalPages,
|
||
];
|
||
}
|
||
|
||
interface PaginationProps {
|
||
currentPage: number;
|
||
totalPages: number;
|
||
onPageChange: (page: number) => void;
|
||
compact?: boolean;
|
||
label: string;
|
||
}
|
||
|
||
function Pagination({ currentPage, totalPages, onPageChange, compact = false, label }: PaginationProps) {
|
||
if (totalPages <= 1) return null;
|
||
const items = getPaginationItems(currentPage, totalPages, compact);
|
||
|
||
return (
|
||
<nav className={`pagination ${compact ? "pagination--compact" : ""}`} aria-label={label}>
|
||
<button
|
||
type="button"
|
||
className="pagination__arrow"
|
||
disabled={currentPage <= 1}
|
||
onClick={() => onPageChange(currentPage - 1)}
|
||
aria-label="Página anterior"
|
||
>
|
||
<ChevronLeft size={16} aria-hidden="true" />
|
||
</button>
|
||
|
||
<div className="pagination__pages">
|
||
{items.map((item) =>
|
||
typeof item === "number" ? (
|
||
<button
|
||
type="button"
|
||
className={`pagination__page ${item === currentPage ? "is-active" : ""}`}
|
||
key={item}
|
||
onClick={() => onPageChange(item)}
|
||
aria-label={`Ir a la página ${item}`}
|
||
aria-current={item === currentPage ? "page" : undefined}
|
||
>
|
||
{item}
|
||
</button>
|
||
) : (
|
||
<span className="pagination__ellipsis" key={item} aria-hidden="true">…</span>
|
||
),
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="pagination__arrow"
|
||
disabled={currentPage >= totalPages}
|
||
onClick={() => onPageChange(currentPage + 1)}
|
||
aria-label="Página siguiente"
|
||
>
|
||
<ChevronRight size={16} aria-hidden="true" />
|
||
</button>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
const HUB_ITEMS_PER_PAGE = 10;
|
||
const QUICK_ITEMS_PER_PAGE = 6;
|
||
const ADMIN_ITEMS_PER_PAGE = 12;
|
||
|
||
interface HubViewProps {
|
||
user: HubUser;
|
||
data: HubData;
|
||
accessByApp: Record<string, boolean>;
|
||
accessReady: boolean;
|
||
storageWarning: string;
|
||
onActivate: (app: HubApp) => void;
|
||
onFavorite: (appId: string) => void;
|
||
onAdmin: () => void;
|
||
onRequestAccess: (app: HubApp) => Promise<boolean>;
|
||
}
|
||
|
||
function HubView({ user, data, accessByApp, accessReady, storageWarning, onActivate, onFavorite, onAdmin, onRequestAccess }: HubViewProps) {
|
||
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({
|
||
Todas: "",
|
||
Administración: "",
|
||
"Recursos Humanos": "",
|
||
CDC: "",
|
||
});
|
||
const [category, setCategory] = useState<"Todas" | AppCategory>("Todas");
|
||
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [quickPage, setQuickPage] = useState(1);
|
||
|
||
const search = categorySearches[category] ?? "";
|
||
const setSearch = (value: string) => {
|
||
setCategorySearches((prev) => ({ ...prev, [category]: value }));
|
||
};
|
||
|
||
const deferredSearch = useDeferredValue(search);
|
||
const dateLabel = useCurrentDateLabel();
|
||
const canManageCatalog = user.role === "admin";
|
||
const favoriteIds = data.favoritesByUser[user.id] ?? [];
|
||
const recentIds = data.recentByUser[user.id] ?? [];
|
||
const favorites = useMemo(() => new Set(favoriteIds), [favoriteIds]);
|
||
|
||
const publishedApps = useMemo(
|
||
() => [...data.apps.filter((app) => app.visibility === "published" && Boolean(app.iconDataUrl.trim()))]
|
||
.sort((a, b) => COLLATOR.compare(a.name, b.name)),
|
||
[data.apps],
|
||
);
|
||
|
||
const results = useMemo(() => {
|
||
const query = normalizeSearch(deferredSearch);
|
||
return publishedApps
|
||
.filter((app) => {
|
||
if (category !== "Todas" && app.category !== category) return false;
|
||
if (favoritesOnly && !favorites.has(app.id)) return false;
|
||
if (!query) return true;
|
||
return normalizeSearch(app.name).includes(query);
|
||
})
|
||
.sort((a, b) => {
|
||
const favoriteDifference = Number(favorites.has(b.id)) - Number(favorites.has(a.id));
|
||
return favoriteDifference || COLLATOR.compare(a.name, b.name);
|
||
});
|
||
}, [category, deferredSearch, favorites, favoritesOnly, publishedApps]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(results.length / HUB_ITEMS_PER_PAGE));
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [search, category, favoritesOnly]);
|
||
|
||
useEffect(() => {
|
||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||
}, [currentPage, totalPages]);
|
||
|
||
const paginatedResults = useMemo(() => {
|
||
const startIndex = (currentPage - 1) * HUB_ITEMS_PER_PAGE;
|
||
return results.slice(startIndex, startIndex + HUB_ITEMS_PER_PAGE);
|
||
}, [currentPage, results]);
|
||
|
||
const changeDirectoryPage = (page: number) => {
|
||
setCurrentPage(Math.min(Math.max(page, 1), totalPages));
|
||
window.requestAnimationFrame(() => {
|
||
document.getElementById("directory-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
};
|
||
|
||
const quickApps = useMemo(() => {
|
||
const byId = new Map(publishedApps.map((app) => [app.id, app]));
|
||
const chosen: HubApp[] = [];
|
||
const chosenIds = new Set<string>();
|
||
const add = (id: string) => {
|
||
const app = byId.get(id);
|
||
if (!app || chosenIds.has(id)) return;
|
||
chosen.push(app);
|
||
chosenIds.add(id);
|
||
};
|
||
|
||
favoriteIds.forEach(add);
|
||
if (chosen.length < 3) {
|
||
for (const id of recentIds) {
|
||
add(id);
|
||
if (chosen.length >= 3) break;
|
||
}
|
||
}
|
||
if (chosen.length < 3) {
|
||
for (const app of publishedApps) {
|
||
add(app.id);
|
||
if (chosen.length >= 3) break;
|
||
}
|
||
}
|
||
return chosen;
|
||
}, [favoriteIds, publishedApps, recentIds]);
|
||
|
||
const quickTotalPages = Math.max(1, Math.ceil(quickApps.length / QUICK_ITEMS_PER_PAGE));
|
||
const paginatedQuickApps = useMemo(() => {
|
||
const startIndex = (quickPage - 1) * QUICK_ITEMS_PER_PAGE;
|
||
return quickApps.slice(startIndex, startIndex + QUICK_ITEMS_PER_PAGE);
|
||
}, [quickApps, quickPage]);
|
||
|
||
useEffect(() => {
|
||
if (quickPage > quickTotalPages) setQuickPage(quickTotalPages);
|
||
}, [quickPage, quickTotalPages]);
|
||
|
||
useEffect(() => {
|
||
setQuickPage(1);
|
||
}, [data.favoritesByUser, data.recentByUser, user.id]);
|
||
|
||
const changeQuickPage = (page: number) => {
|
||
setQuickPage(Math.min(Math.max(page, 1), quickTotalPages));
|
||
window.requestAnimationFrame(() => {
|
||
document.getElementById("quick-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||
});
|
||
};
|
||
|
||
const clearFilters = () => {
|
||
setCategorySearches({
|
||
Todas: "",
|
||
Administración: "",
|
||
"Recursos Humanos": "",
|
||
CDC: "",
|
||
});
|
||
setCategory("Todas");
|
||
setFavoritesOnly(false);
|
||
};
|
||
|
||
return (
|
||
<main className="page-content hub-page">
|
||
<header className="page-header">
|
||
<div>
|
||
<p className="page-header__meta">{getGreeting()}, {user.name}</p>
|
||
<h1>Tu mesa de trabajo.</h1>
|
||
</div>
|
||
<div className="page-header__aside">
|
||
<span>{dateLabel}</span>
|
||
</div>
|
||
</header>
|
||
|
||
{storageWarning ? <div className="inline-alert" role="status">{storageWarning}</div> : null}
|
||
|
||
<section className="search-stage" aria-labelledby="search-title">
|
||
<div className="search-stage__copy">
|
||
<h2 id="search-title">¿Qué necesitas abrir?</h2>
|
||
</div>
|
||
<div className="hub-search">
|
||
<Search size={21} aria-hidden="true" />
|
||
<label className="sr-only" htmlFor="hub-search-input">Buscar por nombre de proyecto</label>
|
||
<input
|
||
id="hub-search-input"
|
||
type="search"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder="Buscar por nombre de proyecto…"
|
||
/>
|
||
{search ? (
|
||
<button type="button" onClick={() => setSearch("")} aria-label="Limpiar búsqueda">
|
||
<X size={18} aria-hidden="true" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="quick-section" aria-labelledby="quick-title">
|
||
<div className="section-heading">
|
||
<div>
|
||
<p className="section-kicker">FIJADAS PARA TI</p>
|
||
<h2 id="quick-title">Acceso rápido</h2>
|
||
</div>
|
||
<p>Abre lo que más usas sin detener el ritmo.</p>
|
||
</div>
|
||
<div className="quick-grid">
|
||
{paginatedQuickApps.map((app) => (
|
||
<QuickLaunch
|
||
key={app.id}
|
||
app={app}
|
||
isFavorite={favorites.has(app.id)}
|
||
hasAccess={accessReady ? accessByApp[app.id] === true : null}
|
||
onActivate={onActivate}
|
||
onFavorite={onFavorite}
|
||
/>
|
||
))}
|
||
</div>
|
||
<Pagination
|
||
currentPage={quickPage}
|
||
totalPages={quickTotalPages}
|
||
onPageChange={changeQuickPage}
|
||
compact={quickTotalPages > 5}
|
||
label="Paginación de acceso rápido"
|
||
/>
|
||
</section>
|
||
|
||
<section className="directory-section" aria-labelledby="directory-title">
|
||
<div className="section-heading section-heading--directory">
|
||
<div>
|
||
<p className="section-kicker">DIRECTORIO GLM</p>
|
||
<h2 id="directory-title">Todas las aplicaciones</h2>
|
||
</div>
|
||
<div className="directory-count" aria-live="polite">
|
||
<strong>{results.length}</strong>
|
||
<span>{results.length === 1 ? "resultado" : "resultados"}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="filter-bar" aria-label="Filtros del directorio">
|
||
<div className="category-filters">
|
||
{(["Todas", ...CATEGORY_ORDER] as const).map((item) => (
|
||
<button
|
||
type="button"
|
||
key={item}
|
||
className={category === item ? "is-active" : ""}
|
||
onClick={() => setCategory(item)}
|
||
aria-pressed={category === item}
|
||
>
|
||
{item}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="filter-bar__search">
|
||
<Search size={16} aria-hidden="true" />
|
||
<label className="sr-only" htmlFor="directory-search-input">
|
||
{`Buscar por nombre de proyecto en ${category}`}
|
||
</label>
|
||
<input
|
||
id="directory-search-input"
|
||
type="search"
|
||
value={search}
|
||
onChange={(event) => setSearch(event.target.value)}
|
||
placeholder={
|
||
category === "Todas"
|
||
? "Buscar por nombre de proyecto…"
|
||
: `Buscar en ${category}…`
|
||
}
|
||
/>
|
||
{search ? (
|
||
<button type="button" onClick={() => setSearch("")} aria-label="Limpiar búsqueda">
|
||
<X size={15} aria-hidden="true" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className={`favorites-filter ${favoritesOnly ? "is-active" : ""}`}
|
||
onClick={() => setFavoritesOnly((current) => !current)}
|
||
aria-pressed={favoritesOnly}
|
||
>
|
||
<Star size={17} fill={favoritesOnly ? "currentColor" : "none"} aria-hidden="true" />
|
||
Solo favoritos
|
||
</button>
|
||
</div>
|
||
|
||
{results.length > 0 ? (
|
||
<>
|
||
<div className="directory-list">
|
||
{paginatedResults.map((app, index) => (
|
||
<DirectoryRow
|
||
key={app.id}
|
||
app={app}
|
||
index={(currentPage - 1) * HUB_ITEMS_PER_PAGE + index}
|
||
isFavorite={favorites.has(app.id)}
|
||
hasAccess={accessReady ? accessByApp[app.id] === true : null}
|
||
onActivate={onActivate}
|
||
onFavorite={onFavorite}
|
||
/>
|
||
))}
|
||
</div>
|
||
<Pagination
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
onPageChange={changeDirectoryPage}
|
||
compact={totalPages > 5}
|
||
label="Paginación de aplicaciones"
|
||
/>
|
||
</>
|
||
) : (
|
||
<EmptyResults search={search} favoritesOnly={favoritesOnly} onClear={clearFilters} />
|
||
)}
|
||
</section>
|
||
|
||
{canManageCatalog ? (
|
||
<section className="admin-bridge" aria-label="Acceso al panel de administración">
|
||
<div className="admin-bridge__number" aria-hidden="true">+</div>
|
||
<div>
|
||
<p className="section-kicker">CONTROL DEL CATÁLOGO</p>
|
||
<h2>¿Falta una herramienta?</h2>
|
||
<p>Adjunta su ícono, pega el enlace y publícala para todo el equipo.</p>
|
||
</div>
|
||
<button type="button" className="secondary-button" onClick={onAdmin}>
|
||
Administrar aplicaciones
|
||
<ArrowUpRight size={18} aria-hidden="true" />
|
||
</button>
|
||
</section>
|
||
) : (
|
||
<AccessRequestSection apps={publishedApps} onSubmit={onRequestAccess} />
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
interface AdminAppListProps {
|
||
apps: HubApp[];
|
||
selectedId: string | null;
|
||
onSelect: (id: string | null) => void;
|
||
onDelete: (id: string) => Promise<boolean>;
|
||
}
|
||
|
||
|
||
function AdminAppList({ apps, selectedId, onSelect, onDelete }: AdminAppListProps) {
|
||
const [query, setQuery] = useState("");
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [appToDelete, setAppToDelete] = useState<HubApp | null>(null);
|
||
const deleteModalRef = useRef<HTMLDialogElement>(null);
|
||
|
||
const filtered = useMemo(() => {
|
||
const normalized = normalizeSearch(query);
|
||
if (!normalized) return apps;
|
||
return apps.filter((app) => normalizeSearch(`${app.name} ${app.category}`).includes(normalized));
|
||
}, [apps, query]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / ADMIN_ITEMS_PER_PAGE));
|
||
|
||
useEffect(() => {
|
||
setCurrentPage(1);
|
||
}, [query]);
|
||
|
||
useEffect(() => {
|
||
if (currentPage > totalPages) {
|
||
setCurrentPage(totalPages);
|
||
}
|
||
}, [currentPage, totalPages]);
|
||
|
||
const paginatedApps = useMemo(() => {
|
||
const start = (currentPage - 1) * ADMIN_ITEMS_PER_PAGE;
|
||
return filtered.slice(start, start + ADMIN_ITEMS_PER_PAGE);
|
||
}, [filtered, currentPage]);
|
||
|
||
const handleOpenDelete = (e: React.MouseEvent, app: HubApp) => {
|
||
e.stopPropagation();
|
||
setAppToDelete(app);
|
||
deleteModalRef.current?.showModal();
|
||
};
|
||
|
||
const handleConfirmDelete = async () => {
|
||
if (!appToDelete) return;
|
||
if (await onDelete(appToDelete.id)) {
|
||
deleteModalRef.current?.close();
|
||
setAppToDelete(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<aside className="admin-list" aria-label="Aplicaciones configuradas">
|
||
<div className="admin-list__top">
|
||
<div>
|
||
<p className="section-kicker">CATÁLOGO</p>
|
||
<h2>{apps.length} {apps.length === 1 ? "aplicación" : "aplicaciones"}</h2>
|
||
</div>
|
||
<button type="button" className="icon-button icon-button--accent" onClick={() => onSelect(null)} aria-label="Crear aplicación">
|
||
<Plus size={20} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
<div className="admin-list__search">
|
||
<Search size={17} aria-hidden="true" />
|
||
<label htmlFor="admin-search" className="sr-only">Buscar en el catálogo</label>
|
||
<input id="admin-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar…" />
|
||
{query ? (
|
||
<button type="button" onClick={() => setQuery("")} aria-label="Limpiar búsqueda">
|
||
<X size={15} aria-hidden="true" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="admin-list__items">
|
||
{currentPage === 1 ? (
|
||
<button type="button" className={`admin-list__new ${selectedId === null ? "is-active" : ""}`} onClick={() => onSelect(null)}>
|
||
<span className="admin-list__new-icon"><Plus size={18} aria-hidden="true" /></span>
|
||
<span className="admin-list__item-info">
|
||
<strong>Nueva aplicación</strong>
|
||
<small>Crear y publicar</small>
|
||
</span>
|
||
</button>
|
||
) : null}
|
||
{paginatedApps.map((app) => (
|
||
<div
|
||
key={app.id}
|
||
className={`admin-list__item-row ${selectedId === app.id ? "is-active" : ""}`}
|
||
onClick={() => onSelect(app.id)}
|
||
role="button"
|
||
tabIndex={0}
|
||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(app.id); }}
|
||
>
|
||
<AppMark app={app} size="small" />
|
||
<span className="admin-list__item-info">
|
||
<strong>{app.name}</strong>
|
||
<small>{app.category}</small>
|
||
</span>
|
||
<div className="admin-list__item-actions">
|
||
<span className={`mini-status ${app.visibility === "hidden" ? "is-hidden" : ""}`}>
|
||
{app.visibility === "hidden" ? "Oculta" : getSafeUrl(app.url) ? "Lista" : "Pendiente"}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="admin-list__delete-icon"
|
||
onClick={(e) => handleOpenDelete(e, app)}
|
||
title={`Eliminar ${app.name}`}
|
||
aria-label={`Eliminar ${app.name}`}
|
||
>
|
||
<Trash2 size={15} aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<Pagination
|
||
currentPage={currentPage}
|
||
totalPages={totalPages}
|
||
onPageChange={setCurrentPage}
|
||
compact
|
||
label="Paginación del catálogo de administración"
|
||
/>
|
||
|
||
<dialog className="confirm-dialog" ref={deleteModalRef}>
|
||
<button type="button" className="confirm-dialog__close" onClick={() => deleteModalRef.current?.close()} aria-label="Cerrar">
|
||
<X size={19} aria-hidden="true" />
|
||
</button>
|
||
<span className="confirm-dialog__icon"><Trash2 size={22} aria-hidden="true" /></span>
|
||
<p className="section-kicker">ACCIÓN PERMANENTE</p>
|
||
<h2>Eliminar {appToDelete?.name}</h2>
|
||
<p>El portal y sus accesos se eliminarán permanentemente. Esta acción no se puede deshacer.</p>
|
||
<div className="confirm-dialog__actions">
|
||
<button type="button" className="secondary-button" onClick={() => deleteModalRef.current?.close()}>Conservar</button>
|
||
<button type="button" className="danger-button danger-button--solid" onClick={handleConfirmDelete}>Sí, eliminar</button>
|
||
</div>
|
||
</dialog>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
interface AppEditorProps {
|
||
app: HubApp | null;
|
||
allApps: HubApp[];
|
||
onSave: (draft: AppDraft, id: string | null) => Promise<HubApp | null>;
|
||
onDelete: (id: string) => Promise<boolean>;
|
||
onCancel: () => void;
|
||
}
|
||
|
||
function RequiredMark() {
|
||
return (
|
||
<>
|
||
<span className="required-mark" aria-hidden="true">*</span>
|
||
<span className="sr-only"> obligatorio</span>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function AppEditor({ app, allApps, onSave, onDelete, onCancel }: AppEditorProps) {
|
||
const [draft, setDraft] = useState<AppDraft>(() => (app ? appToDraft(app) : makeEmptyDraft()));
|
||
const [baseline, setBaseline] = useState(() => JSON.stringify(app ? appToDraft(app) : makeEmptyDraft()));
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
const [iconError, setIconError] = useState("");
|
||
const [processingIcon, setProcessingIcon] = useState(false);
|
||
const [generatingIcon, setGeneratingIcon] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [dragActive, setDragActive] = useState(false);
|
||
const deleteDialogRef = useRef<HTMLDialogElement>(null);
|
||
const firstFieldRef = useRef<HTMLInputElement>(null);
|
||
const isDirty = JSON.stringify(draft) !== baseline;
|
||
const isBusy = processingIcon || generatingIcon || saving;
|
||
const canGenerateIcon = draft.name.trim().length >= 2 && Boolean(draft.description.trim());
|
||
|
||
useEffect(() => {
|
||
const next = app ? appToDraft(app) : makeEmptyDraft();
|
||
setDraft(next);
|
||
setBaseline(JSON.stringify(next));
|
||
setErrors({});
|
||
setIconError("");
|
||
window.requestAnimationFrame(() => firstFieldRef.current?.focus());
|
||
}, [app]);
|
||
|
||
useEffect(() => {
|
||
if (!isDirty && !generatingIcon) return undefined;
|
||
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
|
||
window.addEventListener("beforeunload", warn);
|
||
return () => window.removeEventListener("beforeunload", warn);
|
||
}, [generatingIcon, isDirty]);
|
||
|
||
useEffect(() => {
|
||
if (!generatingIcon) return undefined;
|
||
const previousOverflow = document.body.style.overflow;
|
||
document.body.style.overflow = "hidden";
|
||
return () => {
|
||
document.body.style.overflow = previousOverflow;
|
||
};
|
||
}, [generatingIcon]);
|
||
|
||
const previewApp: HubApp = {
|
||
id: app?.id ?? "preview",
|
||
name: draft.name || "Nombre de la aplicación",
|
||
description: draft.description || "Una descripción breve ayuda al equipo a reconocerla.",
|
||
category: draft.category,
|
||
keywords: [],
|
||
url: draft.url,
|
||
mark: getInitials(draft.name || "GLM"),
|
||
accent: app?.accent ?? "#4F758B",
|
||
iconDataUrl: draft.iconDataUrl,
|
||
visibility: draft.visibility,
|
||
createdAt: app?.createdAt ?? new Date().toISOString(),
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
|
||
const setField = <K extends keyof AppDraft>(field: K, value: AppDraft[K]) => {
|
||
setDraft((current) => ({ ...current, [field]: value }));
|
||
setErrors((current) => ({ ...current, [field]: "" }));
|
||
};
|
||
|
||
const acceptIcon = async (file: File | undefined) => {
|
||
if (!file || generatingIcon) return;
|
||
setIconError("");
|
||
setProcessingIcon(true);
|
||
try {
|
||
const dataUrl = await processIcon(file);
|
||
setField("iconDataUrl", dataUrl);
|
||
} catch (error) {
|
||
setIconError(error instanceof Error ? error.message : "No pudimos procesar el ícono.");
|
||
} finally {
|
||
setProcessingIcon(false);
|
||
}
|
||
};
|
||
|
||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||
void acceptIcon(event.target.files?.[0]);
|
||
event.target.value = "";
|
||
};
|
||
|
||
const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
|
||
event.preventDefault();
|
||
setDragActive(false);
|
||
void acceptIcon(event.dataTransfer.files?.[0]);
|
||
};
|
||
|
||
const handleGenerateIcon = async () => {
|
||
const name = draft.name.trim();
|
||
const description = draft.description.trim();
|
||
const nextErrors: Record<string, string> = {};
|
||
if (name.length < 2) nextErrors.name = "Completa el nombre antes de generar el ícono.";
|
||
if (!description) nextErrors.description = "Completa la descripción antes de generar el ícono.";
|
||
if (Object.keys(nextErrors).length > 0) {
|
||
setErrors((current) => ({ ...current, ...nextErrors }));
|
||
return;
|
||
}
|
||
if (!ICON_GENERATOR_WEBHOOK_URL) {
|
||
setIconError("Falta configurar VITE_ICON_GENERATOR_WEBHOOK_URL en el archivo .env.");
|
||
return;
|
||
}
|
||
|
||
setIconError("");
|
||
setGeneratingIcon(true);
|
||
const controller = new AbortController();
|
||
const timeoutId = window.setTimeout(() => controller.abort(), 180_000);
|
||
try {
|
||
const { data: { session: authSession } } = await supabase.auth.getSession();
|
||
if (!authSession?.access_token) throw new Error("La sesión venció. Inicia sesión nuevamente.");
|
||
|
||
// El JWT viaja dentro de un cuerpo text/plain para evitar el preflight CORS
|
||
// que impedía que la solicitud llegara al webhook de n8n desde el navegador.
|
||
const response = await fetch(ICON_GENERATOR_WEBHOOK_URL, {
|
||
method: "POST",
|
||
mode: "cors",
|
||
cache: "no-store",
|
||
credentials: "omit",
|
||
signal: controller.signal,
|
||
headers: {
|
||
Accept: "image/*, application/json;q=0.9, text/plain;q=0.8",
|
||
"Content-Type": "text/plain;charset=UTF-8",
|
||
},
|
||
body: JSON.stringify({
|
||
accessToken: authSession.access_token,
|
||
name,
|
||
description,
|
||
}),
|
||
});
|
||
|
||
const contentType = response.headers.get("content-type")?.toLocaleLowerCase("en-US") ?? "";
|
||
if (response.ok && contentType.startsWith("image/")) {
|
||
const normalizedIcon = await processGeneratedIconBlob(await response.blob());
|
||
setField("iconDataUrl", normalizedIcon);
|
||
return;
|
||
}
|
||
|
||
const responseText = await response.text();
|
||
let payload: { ok?: boolean; imageDataUrl?: string; error?: string } | null = null;
|
||
if (responseText) {
|
||
try {
|
||
payload = JSON.parse(responseText) as { ok?: boolean; imageDataUrl?: string; error?: string };
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
}
|
||
|
||
// Compatibilidad con una respuesta JSON de una versión anterior del workflow.
|
||
if (response.ok && payload?.ok && payload.imageDataUrl) {
|
||
const normalizedIcon = await processGeneratedIcon(payload.imageDataUrl);
|
||
setField("iconDataUrl", normalizedIcon);
|
||
return;
|
||
}
|
||
|
||
throw new Error(
|
||
payload?.error ||
|
||
(responseText && responseText.length < 500 ? responseText : "") ||
|
||
`El flujo no pudo generar el ícono (HTTP ${response.status}).`,
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === "AbortError") {
|
||
setIconError("La generación tardó más de 3 minutos y fue cancelada. Intenta nuevamente.");
|
||
} else if (error instanceof TypeError && /failed to fetch/i.test(error.message)) {
|
||
setIconError("No se pudo conectar con el webhook de n8n. Verifica que el workflow esté activo y publicado.");
|
||
} else {
|
||
setIconError(error instanceof Error ? error.message : "No pudimos generar el ícono con IA.");
|
||
}
|
||
} finally {
|
||
window.clearTimeout(timeoutId);
|
||
setGeneratingIcon(false);
|
||
}
|
||
};
|
||
|
||
const validate = (): boolean => {
|
||
const nextErrors: Record<string, string> = {};
|
||
const name = draft.name.trim();
|
||
const description = draft.description.trim();
|
||
if (name.length < 2 || name.length > 60) nextErrors.name = "Escribe un nombre de 2 a 60 caracteres.";
|
||
if (!description) nextErrors.description = "Añade una descripción breve.";
|
||
if (description.length > 180) nextErrors.description = "La descripción no puede superar 180 caracteres.";
|
||
if (!draft.iconDataUrl) nextErrors.iconDataUrl = "Adjunta o genera un ícono antes de publicar la aplicación.";
|
||
if (draft.url.trim() && !getSafeUrl(draft.url)) {
|
||
nextErrors.url = "Usa una URL https:// válida, sin credenciales embebidas.";
|
||
}
|
||
const normalizedName = normalizeSearch(name);
|
||
const duplicate = allApps.some((candidate) => candidate.id !== app?.id && normalizeSearch(candidate.name) === normalizedName);
|
||
if (duplicate) nextErrors.name = "Ya existe una aplicación con ese nombre.";
|
||
setErrors(nextErrors);
|
||
return Object.keys(nextErrors).length === 0;
|
||
};
|
||
|
||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
if (isBusy || !validate()) {
|
||
window.requestAnimationFrame(() => document.querySelector<HTMLElement>(".field-error")?.focus?.());
|
||
return;
|
||
}
|
||
|
||
setSaving(true);
|
||
try {
|
||
const saved = await onSave(
|
||
{
|
||
...draft,
|
||
name: draft.name.trim(),
|
||
description: draft.description.trim(),
|
||
url: draft.url.trim(),
|
||
},
|
||
app?.id ?? null,
|
||
);
|
||
if (saved) {
|
||
const next = appToDraft(saved);
|
||
setDraft(next);
|
||
setBaseline(JSON.stringify(next));
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const confirmCancel = () => {
|
||
if (generatingIcon) return;
|
||
if (!isDirty || window.confirm("Hay cambios sin guardar. ¿Quieres descartarlos?")) {
|
||
const next = app ? appToDraft(app) : makeEmptyDraft();
|
||
setDraft(next);
|
||
setBaseline(JSON.stringify(next));
|
||
setErrors({});
|
||
setIconError("");
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!app || isBusy) return;
|
||
if (await onDelete(app.id)) {
|
||
deleteDialogRef.current?.close();
|
||
onCancel();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="app-editor" aria-labelledby="editor-title" aria-busy={isBusy}>
|
||
<div className="app-editor__heading">
|
||
<div>
|
||
<p className="section-kicker">{app ? "EDITAR APLICACIÓN" : "NUEVA APLICACIÓN"}</p>
|
||
<h2 id="editor-title">{app ? app.name : "Configura el nuevo acceso"}</h2>
|
||
</div>
|
||
{isDirty ? (
|
||
<span className="dirty-state is-dirty">
|
||
<span aria-hidden="true" />
|
||
Cambios sin guardar
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="editor-layout">
|
||
<form className="editor-form" onSubmit={handleSubmit} noValidate>
|
||
<section className="editor-form__section" aria-labelledby="identity-section-title">
|
||
<h3 className="editor-form__section-title" id="identity-section-title">Identidad</h3>
|
||
<div className="form-field">
|
||
<label htmlFor="app-name">Nombre de la aplicación{!app ? <RequiredMark /> : null}</label>
|
||
<input
|
||
ref={firstFieldRef}
|
||
id="app-name"
|
||
value={draft.name}
|
||
onChange={(event) => setField("name", event.target.value)}
|
||
placeholder="Ej. Media Planner"
|
||
maxLength={60}
|
||
aria-invalid={Boolean(errors.name)}
|
||
aria-describedby={errors.name ? "app-name-error" : undefined}
|
||
disabled={isBusy}
|
||
required
|
||
/>
|
||
<div className="field-meta">
|
||
{errors.name ? <span id="app-name-error" className="field-error" role="alert" tabIndex={-1}>{errors.name}</span> : <span>Como el equipo la reconoce.</span>}
|
||
<span>{draft.name.length}/60</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-field">
|
||
<label htmlFor="app-description">Descripción breve{!app ? <RequiredMark /> : null}</label>
|
||
<textarea
|
||
id="app-description"
|
||
value={draft.description}
|
||
onChange={(event) => setField("description", event.target.value)}
|
||
placeholder="Explica qué resuelve en una sola frase."
|
||
maxLength={180}
|
||
rows={3}
|
||
aria-invalid={Boolean(errors.description)}
|
||
aria-describedby={errors.description ? "app-description-error" : undefined}
|
||
disabled={isBusy}
|
||
required
|
||
/>
|
||
<div className={`field-meta ${errors.description ? "" : "field-meta--counter-only"}`}>
|
||
{errors.description ? <span id="app-description-error" className="field-error" role="alert" tabIndex={-1}>{errors.description}</span> : null}
|
||
<span>{draft.description.length}/180</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-split">
|
||
<div className="form-field">
|
||
<label htmlFor="app-category">Categoría{!app ? <RequiredMark /> : null}</label>
|
||
<select id="app-category" value={draft.category} onChange={(event) => setField("category", event.target.value as AppCategory)} disabled={isBusy} required>
|
||
{CATEGORY_ORDER.map((item) => <option key={item}>{item}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-field">
|
||
<label htmlFor="app-visibility">Visibilidad{!app ? <RequiredMark /> : null}</label>
|
||
<select id="app-visibility" value={draft.visibility} onChange={(event) => setField("visibility", event.target.value as AppVisibility)} disabled={isBusy} required>
|
||
<option value="published">Publicada</option>
|
||
<option value="hidden">Oculta</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="editor-form__section" aria-labelledby="icon-section-title">
|
||
<h3 className="editor-form__section-title" id="icon-section-title">Ícono{!app ? <RequiredMark /> : null}</h3>
|
||
<div className="icon-config">
|
||
<label
|
||
className={`icon-dropzone ${dragActive ? "is-dragging" : ""}`}
|
||
onDragEnter={(event) => { event.preventDefault(); if (!isBusy) setDragActive(true); }}
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDragLeave={() => setDragActive(false)}
|
||
onDrop={handleDrop}
|
||
>
|
||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileChange} disabled={isBusy} />
|
||
<span className="icon-dropzone__symbol"><Upload size={20} aria-hidden="true" /></span>
|
||
<span><strong>{processingIcon ? "Procesando…" : "Adjuntar ícono"}</strong><small>PNG, JPG o WebP · máximo 1 MB</small></span>
|
||
</label>
|
||
{draft.iconDataUrl ? (
|
||
<button type="button" className="text-button text-button--danger" disabled={isBusy} onClick={() => setField("iconDataUrl", "")}>Quitar ícono</button>
|
||
) : null}
|
||
</div>
|
||
<div className="ai-icon-actions">
|
||
<button
|
||
type="button"
|
||
className="secondary-button ai-icon-button"
|
||
onClick={() => void handleGenerateIcon()}
|
||
disabled={!canGenerateIcon || isBusy}
|
||
title={!canGenerateIcon ? "Completa el nombre y la descripción breve." : undefined}
|
||
>
|
||
<Sparkles size={18} aria-hidden="true" />
|
||
Generar ícono con IA
|
||
</button>
|
||
</div>
|
||
{iconError ? <p className="field-error field-error--standalone" role="alert">{iconError}</p> : null}
|
||
{errors.iconDataUrl ? (
|
||
<p className="field-error field-error--standalone" role="alert" tabIndex={-1}>{errors.iconDataUrl}</p>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="editor-form__section" aria-labelledby="destination-section-title">
|
||
<h3 className="editor-form__section-title" id="destination-section-title">Destino</h3>
|
||
<div className="form-field">
|
||
<label htmlFor="app-url">Enlace de la aplicación <span>Opcional</span></label>
|
||
<div className="url-field">
|
||
<Link2 size={18} aria-hidden="true" />
|
||
<input
|
||
id="app-url"
|
||
type="url"
|
||
value={draft.url}
|
||
onChange={(event) => setField("url", event.target.value)}
|
||
placeholder="https://aplicacion.com"
|
||
aria-invalid={Boolean(errors.url)}
|
||
aria-describedby={errors.url ? "app-url-error" : "app-url-help"}
|
||
disabled={isBusy}
|
||
/>
|
||
</div>
|
||
{errors.url ? (
|
||
<span id="app-url-error" className="field-error" role="alert" tabIndex={-1}>{errors.url}</span>
|
||
) : (
|
||
<span id="app-url-help" className="field-help">Si lo dejas vacío, aparecerá como “Enlace pendiente”.</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<div className="editor-actions">
|
||
<button type="submit" className="primary-button" disabled={isBusy}>
|
||
<Save size={18} aria-hidden="true" />
|
||
{saving ? "Guardando…" : app ? "Guardar cambios" : "Publicar aplicación"}
|
||
</button>
|
||
<button type="button" className="secondary-button secondary-button--quiet" disabled={isBusy} onClick={confirmCancel}>Cancelar</button>
|
||
{app ? (
|
||
<button type="button" className="danger-button" disabled={isBusy} onClick={() => deleteDialogRef.current?.showModal()}>
|
||
<Trash2 size={17} aria-hidden="true" />
|
||
Eliminar
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</form>
|
||
|
||
<aside className="app-preview" aria-label="Vista previa de la aplicación">
|
||
<div className="app-preview__label">
|
||
<span>VISTA PREVIA</span>
|
||
<span className="app-preview__rule" />
|
||
</div>
|
||
<div className="preview-card">
|
||
<AppMark app={previewApp} size="large" showPlaceholder={!app && !draft.iconDataUrl} />
|
||
<span className="quick-launch__category">{previewApp.category}</span>
|
||
<h3>{previewApp.name}</h3>
|
||
<p>{previewApp.description}</p>
|
||
<span className={`availability ${getSafeUrl(previewApp.url) ? "availability--ready" : "availability--pending"}`}>
|
||
<span aria-hidden="true" />
|
||
{getSafeUrl(previewApp.url) ? "Lista para abrir" : "Enlace pendiente"}
|
||
</span>
|
||
</div>
|
||
<p className="app-preview__note">Así aparecerá en el Hub para el equipo.</p>
|
||
</aside>
|
||
</div>
|
||
|
||
<dialog className="confirm-dialog" ref={deleteDialogRef}>
|
||
<button type="button" className="confirm-dialog__close" onClick={() => deleteDialogRef.current?.close()} aria-label="Cerrar">
|
||
<X size={19} aria-hidden="true" />
|
||
</button>
|
||
<span className="confirm-dialog__icon"><Trash2 size={22} aria-hidden="true" /></span>
|
||
<p className="section-kicker">ACCIÓN PERMANENTE</p>
|
||
<h2>Eliminar {app?.name}</h2>
|
||
<p>La aplicación se quitará del catálogo compartido para todo el equipo. Esta acción no se puede deshacer.</p>
|
||
<div className="confirm-dialog__actions">
|
||
<button type="button" className="secondary-button" onClick={() => deleteDialogRef.current?.close()}>Conservar</button>
|
||
<button type="button" className="danger-button danger-button--solid" onClick={() => void confirmDelete()}>Sí, eliminar</button>
|
||
</div>
|
||
</dialog>
|
||
|
||
{generatingIcon ? (
|
||
<div className="generation-overlay" role="dialog" aria-modal="true" aria-labelledby="generation-title">
|
||
<div className="generation-overlay__card">
|
||
<span className="generation-spinner" aria-hidden="true" />
|
||
<p className="section-kicker">GEMINI</p>
|
||
<h2 id="generation-title">Generando el ícono…</h2>
|
||
<p>Estamos creando una propuesta a partir del nombre y la descripción de la aplicación.</p>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
interface AdminViewProps {
|
||
data: HubData;
|
||
onSave: (draft: AppDraft, id: string | null) => Promise<HubApp | null>;
|
||
onDelete: (id: string) => Promise<boolean>;
|
||
onBack: () => void;
|
||
}
|
||
|
||
function AdminView({ data, onSave, onDelete, onBack }: AdminViewProps) {
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const selected = selectedId ? data.apps.find((app) => app.id === selectedId) ?? null : null;
|
||
|
||
const saveAndSelect = async (draft: AppDraft, id: string | null): Promise<HubApp | null> => {
|
||
const saved = await onSave(draft, id);
|
||
if (saved) setSelectedId(saved.id);
|
||
return saved;
|
||
};
|
||
|
||
const deleteAndClear = async (id: string): Promise<boolean> => {
|
||
const deleted = await onDelete(id);
|
||
if (deleted) setSelectedId(null);
|
||
return deleted;
|
||
};
|
||
|
||
return (
|
||
<main className="page-content admin-page">
|
||
<header className="page-header admin-header">
|
||
<div>
|
||
<button type="button" className="back-button" onClick={onBack}>
|
||
<ArrowLeft size={17} aria-hidden="true" />
|
||
Volver al Hub
|
||
</button>
|
||
<p className="page-header__meta">PANEL DE ADMINISTRACIÓN</p>
|
||
<h1>Controla el catálogo.</h1>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="admin-workspace">
|
||
<AdminAppList
|
||
apps={data.apps}
|
||
selectedId={selectedId}
|
||
onSelect={setSelectedId}
|
||
onDelete={deleteAndClear}
|
||
/>
|
||
<AppEditor
|
||
app={selected}
|
||
allApps={data.apps}
|
||
onSave={saveAndSelect}
|
||
onDelete={deleteAndClear}
|
||
onCancel={() => setSelectedId(null)}
|
||
/>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function CatalogLoading() {
|
||
return (
|
||
<div className="catalog-loading" role="status" aria-live="polite">
|
||
<BrandLogo compact />
|
||
<span className="generation-spinner" aria-hidden="true" />
|
||
<p>Cargando aplicaciones…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function App() {
|
||
const [initialLocalData] = useState(() => loadHubData());
|
||
const [data, setData] = useState<HubData>({
|
||
schemaVersion: 1,
|
||
apps: [],
|
||
favoritesByUser: {},
|
||
recentByUser: {},
|
||
updatedAt: new Date().toISOString(),
|
||
});
|
||
const [session, setSession] = useState<Session | null>(null);
|
||
const [view, setView] = useState<AppView>(() => "hub");
|
||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||
const [authReady, setAuthReady] = useState(false);
|
||
const [catalogReady, setCatalogReady] = useState(false);
|
||
const [catalogWarning, setCatalogWarning] = useState("");
|
||
const [accessByApp, setAccessByApp] = useState<Record<string, boolean>>({});
|
||
const [accessReady, setAccessReady] = useState(false);
|
||
const [loginError, setLoginError] = useState("");
|
||
const authorizationSequence = useRef(0);
|
||
const authorizedUserIdRef = useRef<string | null>(null);
|
||
|
||
const dismissToast = useCallback((id: string) => {
|
||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||
}, []);
|
||
|
||
const notify = useCallback((kind: ToastMessage["kind"], title: string, detail?: string) => {
|
||
const id = crypto.randomUUID();
|
||
setToasts((current) => [...current.slice(-2), { id, kind, title, detail }]);
|
||
window.setTimeout(() => {
|
||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||
}, 5200);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
|
||
const updateTokenExpiry = (authSession: SupabaseAuthSession) => {
|
||
const expiresAt = (authSession.expires_at ?? Math.floor(Date.now() / 1000) + 3600) * 1000;
|
||
setSession((current) => {
|
||
if (!current || current.user.id !== authSession.user.id || current.expiresAt === expiresAt) return current;
|
||
return { ...current, expiresAt };
|
||
});
|
||
};
|
||
|
||
const applyAuthSession = async (authSession: SupabaseAuthSession | null) => {
|
||
const sequence = ++authorizationSequence.current;
|
||
|
||
if (!authSession) {
|
||
if (!active || sequence !== authorizationSequence.current) return;
|
||
authorizedUserIdRef.current = null;
|
||
setSession(null);
|
||
setCatalogReady(false);
|
||
setAuthReady(true);
|
||
return;
|
||
}
|
||
|
||
// Supabase puede emitir SIGNED_IN o TOKEN_REFRESHED cuando una pestaña vuelve
|
||
// al primer plano. Si sigue siendo el mismo usuario, solo actualizamos la
|
||
// expiración del token y conservamos exactamente la pantalla y el formulario.
|
||
if (authorizedUserIdRef.current === authSession.user.id) {
|
||
if (!active || sequence !== authorizationSequence.current) return;
|
||
updateTokenExpiry(authSession);
|
||
setAuthReady(true);
|
||
return;
|
||
}
|
||
|
||
const authorization = await authorizeSupabaseSession(authSession);
|
||
if (!active || sequence !== authorizationSequence.current) return;
|
||
|
||
if (!authorization.session) {
|
||
authorizedUserIdRef.current = null;
|
||
setSession(null);
|
||
setLoginError(authorization.error);
|
||
setCatalogReady(false);
|
||
setAuthReady(true);
|
||
await supabase.auth.signOut({ scope: "local" });
|
||
return;
|
||
}
|
||
|
||
const nextSession = authorization.session;
|
||
authorizedUserIdRef.current = nextSession.user.id;
|
||
setLoginError("");
|
||
setSession(nextSession);
|
||
setAuthReady(true);
|
||
|
||
if (window.location.hash !== "#hub" && window.location.hash !== "#admin") {
|
||
window.history.replaceState(null, "", getAppUrl("#hub"));
|
||
}
|
||
};
|
||
|
||
void supabase.auth.getSession().then(({ data: { session: currentSession } }: { data: { session: SupabaseAuthSession | null } }) => {
|
||
void applyAuthSession(currentSession);
|
||
});
|
||
|
||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: string, nextSession: SupabaseAuthSession | null) => {
|
||
if (event === "INITIAL_SESSION") return;
|
||
|
||
if (event === "TOKEN_REFRESHED" && nextSession && authorizedUserIdRef.current === nextSession.user.id) {
|
||
updateTokenExpiry(nextSession);
|
||
return;
|
||
}
|
||
|
||
window.setTimeout(() => {
|
||
void applyAuthSession(nextSession);
|
||
}, 0);
|
||
});
|
||
|
||
return () => {
|
||
active = false;
|
||
subscription.unsubscribe();
|
||
};
|
||
}, []);
|
||
|
||
const activeUser = session?.user ?? null;
|
||
|
||
useEffect(() => {
|
||
if (!activeUser) return undefined;
|
||
let active = true;
|
||
let loadingApps = false;
|
||
|
||
const refreshApps = async () => {
|
||
if (loadingApps) return;
|
||
loadingApps = true;
|
||
try {
|
||
const apps = await fetchHubApps();
|
||
if (!active) return;
|
||
setData((current) => ({ ...current, apps, updatedAt: new Date().toISOString() }));
|
||
} catch (error) {
|
||
console.error("No se pudo refrescar el catálogo compartido:", error);
|
||
} finally {
|
||
loadingApps = false;
|
||
}
|
||
};
|
||
|
||
const initializeCatalog = async () => {
|
||
setCatalogReady(false);
|
||
setCatalogWarning("");
|
||
try {
|
||
const migratedCount = await migrateLocalAppsIfNeeded(activeUser, initialLocalData.data.apps);
|
||
const remoteData = await fetchHubData(activeUser.id);
|
||
if (!active) return;
|
||
setData(remoteData);
|
||
setCatalogReady(true);
|
||
if (migratedCount > 0) {
|
||
notify(
|
||
"success",
|
||
"Catálogo compartido activado",
|
||
`${migratedCount} ${migratedCount === 1 ? "aplicación fue migrada" : "aplicaciones fueron migradas"} a Supabase.`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
if (!active) return;
|
||
const message = error instanceof Error ? error.message : "No se pudo cargar el catálogo compartido.";
|
||
setCatalogWarning(message);
|
||
setCatalogReady(true);
|
||
notify("error", "No se pudo cargar el catálogo", message);
|
||
}
|
||
};
|
||
|
||
void initializeCatalog();
|
||
const unsubscribe = subscribeToHubApps(() => void refreshApps());
|
||
return () => {
|
||
active = false;
|
||
unsubscribe();
|
||
};
|
||
}, [activeUser?.id, activeUser?.role, initialLocalData.data.apps, notify]);
|
||
|
||
useEffect(() => {
|
||
if (!activeUser) {
|
||
setAccessByApp({});
|
||
setAccessReady(false);
|
||
return undefined;
|
||
}
|
||
|
||
let active = true;
|
||
let loading = false;
|
||
|
||
const refreshAccess = async () => {
|
||
if (loading) return;
|
||
loading = true;
|
||
try {
|
||
const accessMap = await fetchMyAppAccess();
|
||
if (!active) return;
|
||
setAccessByApp(accessMap);
|
||
setAccessReady(true);
|
||
} catch (error) {
|
||
console.warn("No se pudo verificar el acceso a las aplicaciones:", error);
|
||
if (active) setAccessReady(false);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
};
|
||
|
||
void refreshAccess();
|
||
const intervalId = window.setInterval(() => void refreshAccess(), 60_000);
|
||
const handleFocus = () => void refreshAccess();
|
||
window.addEventListener("focus", handleFocus);
|
||
|
||
return () => {
|
||
active = false;
|
||
window.clearInterval(intervalId);
|
||
window.removeEventListener("focus", handleFocus);
|
||
};
|
||
}, [activeUser?.id]);
|
||
|
||
useEffect(() => {
|
||
const syncViewWithHash = () => {
|
||
const requestedView: AppView = window.location.hash === "#admin" ? "admin" : "hub";
|
||
if (requestedView === "admin" && session?.user.role !== "admin") {
|
||
setView("hub");
|
||
window.history.replaceState(null, "", "#hub");
|
||
return;
|
||
}
|
||
setView(requestedView);
|
||
};
|
||
|
||
syncViewWithHash();
|
||
window.addEventListener("hashchange", syncViewWithHash);
|
||
return () => window.removeEventListener("hashchange", syncViewWithHash);
|
||
}, [session?.user.role]);
|
||
|
||
const navigate = (next: AppView) => {
|
||
if (next === "admin" && session?.user.role !== "admin") {
|
||
notify("error", "Acceso restringido", "Necesitas permisos de administrador para abrir este panel.");
|
||
return;
|
||
}
|
||
setView(next);
|
||
window.history.replaceState(null, "", getAppUrl(next === "admin" ? "#admin" : "#hub"));
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
};
|
||
|
||
const handleFavorite = async (appId: string) => {
|
||
if (!session) return;
|
||
const userId = session.user.id;
|
||
const current = data.favoritesByUser[userId] ?? [];
|
||
const wasFavorite = current.includes(appId);
|
||
const nextFavorites = wasFavorite ? current.filter((id) => id !== appId) : [appId, ...current];
|
||
|
||
setData((currentData) => ({
|
||
...currentData,
|
||
favoritesByUser: { ...currentData.favoritesByUser, [userId]: nextFavorites },
|
||
updatedAt: new Date().toISOString(),
|
||
}));
|
||
|
||
try {
|
||
await setFavorite(userId, appId, !wasFavorite);
|
||
notify("success", wasFavorite ? "Quitada de favoritos" : "Agregada a favoritos");
|
||
} catch (error) {
|
||
setData((currentData) => ({
|
||
...currentData,
|
||
favoritesByUser: { ...currentData.favoritesByUser, [userId]: current },
|
||
}));
|
||
notify("error", "No se actualizó el favorito", error instanceof Error ? error.message : undefined);
|
||
}
|
||
};
|
||
|
||
const handleActivate = (app: HubApp) => {
|
||
if (!session) return;
|
||
const safeUrl = getSafeUrl(app.url);
|
||
if (!safeUrl) {
|
||
notify("info", "Enlace pendiente", `${app.name} todavía no tiene un enlace configurado.`);
|
||
return;
|
||
}
|
||
|
||
const opened = window.open(safeUrl, "_blank", "noopener,noreferrer");
|
||
if (opened) opened.opener = null;
|
||
|
||
const userId = session.user.id;
|
||
const currentRecents = data.recentByUser[userId] ?? [];
|
||
const nextRecents = [app.id, ...currentRecents.filter((id) => id !== app.id)].slice(0, 30);
|
||
setData((currentData) => ({
|
||
...currentData,
|
||
recentByUser: { ...currentData.recentByUser, [userId]: nextRecents },
|
||
updatedAt: new Date().toISOString(),
|
||
}));
|
||
void recordRecent(userId, app.id).catch((error) => console.warn("No se guardó el acceso reciente:", error));
|
||
};
|
||
|
||
const handleSaveApp = async (draft: AppDraft, id: string | null): Promise<HubApp | null> => {
|
||
if (!session || session.user.role !== "admin") return null;
|
||
const existing = id ? data.apps.find((app) => app.id === id) ?? null : null;
|
||
try {
|
||
const saved = await saveHubApp(draft, existing, session.user.id);
|
||
setData((current) => ({
|
||
...current,
|
||
apps: existing
|
||
? current.apps.map((candidate) => (candidate.id === saved.id ? saved : candidate))
|
||
: [saved, ...current.apps],
|
||
updatedAt: new Date().toISOString(),
|
||
}));
|
||
notify("success", existing ? "Cambios guardados" : "Aplicación publicada", `${saved.name} ya está disponible en el catálogo compartido.`);
|
||
return saved;
|
||
} catch (error) {
|
||
notify("error", "No se guardó la aplicación", error instanceof Error ? error.message : undefined);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const handleDeleteApp = async (id: string): Promise<boolean> => {
|
||
if (!session || session.user.role !== "admin") return false;
|
||
const app = data.apps.find((candidate) => candidate.id === id);
|
||
if (!app) return false;
|
||
|
||
try {
|
||
await deleteHubApp(app);
|
||
const stripId = (groups: Record<string, string[]>) =>
|
||
Object.fromEntries(Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((candidate) => candidate !== id)]));
|
||
setData((current) => ({
|
||
...current,
|
||
apps: current.apps.filter((candidate) => candidate.id !== id),
|
||
favoritesByUser: stripId(current.favoritesByUser),
|
||
recentByUser: stripId(current.recentByUser),
|
||
updatedAt: new Date().toISOString(),
|
||
}));
|
||
notify("success", "Aplicación eliminada", `${app.name} salió del catálogo compartido.`);
|
||
return true;
|
||
} catch (error) {
|
||
notify("error", "No se eliminó la aplicación", error instanceof Error ? error.message : undefined);
|
||
return false;
|
||
}
|
||
};
|
||
|
||
const handleAccessRequest = async (app: HubApp): Promise<boolean> => {
|
||
if (!session || session.user.role === "admin") return false;
|
||
if (!ACCESS_REQUEST_WEBHOOK_URL) {
|
||
notify("error", "No se pudo enviar la solicitud", "La función de solicitudes no está disponible en este momento.");
|
||
return false;
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeoutId = window.setTimeout(() => controller.abort(), 120_000);
|
||
try {
|
||
const { data: { session: authSession } } = await supabase.auth.getSession();
|
||
if (!authSession?.access_token) throw new Error("La sesión venció. Cierra sesión e inicia nuevamente.");
|
||
|
||
const response = await fetch(ACCESS_REQUEST_WEBHOOK_URL, {
|
||
method: "POST",
|
||
mode: "cors",
|
||
cache: "no-store",
|
||
credentials: "omit",
|
||
signal: controller.signal,
|
||
headers: {
|
||
Accept: "application/json, text/plain;q=0.9",
|
||
"Content-Type": "text/plain;charset=UTF-8",
|
||
},
|
||
body: JSON.stringify({
|
||
accessToken: authSession.access_token,
|
||
appId: app.id,
|
||
}),
|
||
});
|
||
|
||
const responseText = await response.text();
|
||
let payload: { ok?: boolean; message?: string; error?: string } | null = null;
|
||
if (responseText) {
|
||
try {
|
||
payload = JSON.parse(responseText) as { ok?: boolean; message?: string; error?: string };
|
||
} catch {
|
||
payload = null;
|
||
}
|
||
}
|
||
|
||
if (!response.ok || !payload?.ok) {
|
||
const rawMessage = String(payload?.error || "").toLowerCase();
|
||
let friendlyMessage = "No pudimos enviar la solicitud en este momento. Intenta nuevamente.";
|
||
|
||
if (rawMessage.includes("pendiente")) {
|
||
friendlyMessage = "Ya tienes una solicitud pendiente para esta aplicación.";
|
||
} else if (rawMessage.includes("sesión") || response.status === 401) {
|
||
friendlyMessage = "Tu sesión venció. Cierra sesión e inicia nuevamente.";
|
||
} else if (rawMessage.includes("ya no está publicada") || rawMessage.includes("no está disponible")) {
|
||
friendlyMessage = "La aplicación seleccionada ya no está disponible.";
|
||
} else if (rawMessage.includes("no está autorizado") || response.status === 403) {
|
||
friendlyMessage = "Tu cuenta no está habilitada para enviar esta solicitud.";
|
||
}
|
||
|
||
console.error("Error interno al enviar la solicitud de acceso", {
|
||
status: response.status,
|
||
responseText,
|
||
});
|
||
throw new Error(friendlyMessage);
|
||
}
|
||
|
||
notify(
|
||
"success",
|
||
"Solicitud enviada",
|
||
payload.message || `La solicitud para ${app.name} fue enviada correctamente.`,
|
||
);
|
||
return true;
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === "AbortError") {
|
||
notify("error", "La solicitud tardó demasiado", "Intenta nuevamente en unos minutos.");
|
||
} else if (error instanceof TypeError && /failed to fetch/i.test(error.message)) {
|
||
console.error("No se pudo contactar el servicio de solicitudes", error);
|
||
notify("error", "No se pudo enviar la solicitud", "Intenta nuevamente en unos minutos.");
|
||
} else {
|
||
notify(
|
||
"error",
|
||
"No se pudo enviar la solicitud",
|
||
error instanceof Error ? error.message : "Intenta nuevamente en unos minutos.",
|
||
);
|
||
}
|
||
return false;
|
||
} finally {
|
||
window.clearTimeout(timeoutId);
|
||
}
|
||
};
|
||
|
||
const handleGoogleLogin = async () => {
|
||
setLoginError("");
|
||
const { error } = await supabase.auth.signInWithOAuth({
|
||
provider: "google",
|
||
options: {
|
||
redirectTo: getOAuthRedirectUrl(),
|
||
queryParams: {
|
||
hd: "gomezleemarketing.com",
|
||
prompt: "select_account",
|
||
},
|
||
},
|
||
});
|
||
|
||
if (error) {
|
||
console.error("No se pudo iniciar el acceso con Google:", error);
|
||
setLoginError("No pudimos abrir el acceso con Google. Intenta nuevamente.");
|
||
}
|
||
};
|
||
|
||
const handleLogout = async () => {
|
||
const { error } = await supabase.auth.signOut();
|
||
if (error) {
|
||
notify("error", "No se pudo cerrar la sesión", error.message);
|
||
return;
|
||
}
|
||
setSession(null);
|
||
setCatalogReady(false);
|
||
setAccessByApp({});
|
||
setAccessReady(false);
|
||
setView("hub");
|
||
window.history.replaceState(null, "", getAppUrl());
|
||
};
|
||
|
||
if (!authReady || !session) {
|
||
return (
|
||
<>
|
||
<LoginView
|
||
onLogin={handleGoogleLogin}
|
||
error={loginError}
|
||
disabled={!authReady}
|
||
/>
|
||
<ToastStack messages={toasts} onDismiss={dismissToast} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (!catalogReady) {
|
||
return (
|
||
<>
|
||
<CatalogLoading />
|
||
<ToastStack messages={toasts} onDismiss={dismissToast} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="app-shell">
|
||
<Sidebar user={session.user} view={view} onNavigate={navigate} onLogout={handleLogout} />
|
||
<MobileHeader user={session.user} />
|
||
{view === "admin" && session.user.role === "admin" ? (
|
||
<AdminView
|
||
data={data}
|
||
onSave={handleSaveApp}
|
||
onDelete={handleDeleteApp}
|
||
onBack={() => navigate("hub")}
|
||
/>
|
||
) : (
|
||
<HubView
|
||
user={session.user}
|
||
data={data}
|
||
accessByApp={accessByApp}
|
||
accessReady={accessReady}
|
||
storageWarning={catalogWarning}
|
||
onActivate={handleActivate}
|
||
onFavorite={(appId) => void handleFavorite(appId)}
|
||
onAdmin={() => navigate("admin")}
|
||
onRequestAccess={handleAccessRequest}
|
||
/>
|
||
)}
|
||
<MobileNav user={session.user} view={view} onNavigate={navigate} onLogout={handleLogout} />
|
||
<ToastStack messages={toasts} onDismiss={dismissToast} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|