Files
glm-hub/src/App.tsx
T

2298 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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>
);
}
interface QuickLaunchProps {
app: HubApp;
isFavorite: boolean;
onActivate: (app: HubApp) => void;
onFavorite: (appId: string) => void;
}
function QuickLaunch({ app, isFavorite, 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">
<span className="quick-launch__category">{app.category}</span>
<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;
onActivate: (app: HubApp) => void;
onFavorite: (appId: string) => void;
}
function DirectoryRow({ app, index, isFavorite, 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={`availability ${available ? "availability--ready" : "availability--pending"}`}>
<span aria-hidden="true" />
{available ? "Disponible" : "Enlace pendiente"}
</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 = 10;
interface HubViewProps {
user: HubUser;
data: HubData;
storageWarning: string;
onActivate: (app: HubApp) => void;
onFavorite: (appId: string) => void;
onAdmin: () => void;
onRequestAccess: (app: HubApp) => Promise<boolean>;
}
function HubView({ user, data, 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)}
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)}
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}>, 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()}>, 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 [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(() => {
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);
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}
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;