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; }; } 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 { 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 { 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 { 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 { 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 ( GomezLee Marketing ); } 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 ( ); } function UserAvatar({ user, label }: { user: HubUser; label?: string }) { const [imageFailed, setImageFailed] = useState(false); const showImage = Boolean(user.avatarUrl) && !imageFailed; return ( {showImage ? ( setImageFailed(true)} /> ) : ( getInitials(user.name) )} ); } interface ToastStackProps { messages: ToastMessage[]; onDismiss: (id: string) => void; } function ToastStack({ messages, onDismiss }: ToastStackProps) { return (
{messages.map((message) => (
))}
); } interface LoginViewProps { onLogin: () => Promise; 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 (

ACCESO INTERNO

Todo el trabajo de GLM, a un clic.

Tu punto de partida para abrir herramientas, encontrar recursos y mantener el día en movimiento.

GLM HUB

Entra a tu espacio.

{error ? ( ) : null}
); } interface SidebarProps { user: HubUser; view: AppView; onNavigate: (view: AppView) => void; onLogout: () => void; } function Sidebar({ user, view, onNavigate, onLogout }: SidebarProps) { return ( ); } interface MobileHeaderProps { user: HubUser; } function MobileHeader({ user }: MobileHeaderProps) { return (
); } interface MobileNavProps extends SidebarProps {} function MobileNav({ user, view, onNavigate, onLogout }: MobileNavProps) { return ( ); } 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 ( ); } 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 (
{app.category}

{app.name}

{app.description}

); } 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 (

{app.name}

{app.description}

{app.category}
); } interface EmptyResultsProps { search: string; favoritesOnly: boolean; onClear: () => void; } function EmptyResults({ search, favoritesOnly, onClear }: EmptyResultsProps) { return (

SIN RESULTADOS

{favoritesOnly ? "Aún no tienes favoritos aquí." : `No encontramos “${search || "esa aplicación"}”.`}

Prueba otro término, cambia la categoría o vuelve a ver todo el directorio.

); } type AccessRequestArea = "Todas" | "Administración" | "Recursos Humanos" | "CDC"; interface AccessRequestSectionProps { apps: HubApp[]; onSubmit: (app: HubApp) => Promise; } function AccessRequestSection({ apps, onSubmit }: AccessRequestSectionProps) { const dialogRef = useRef(null); const [area, setArea] = useState("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) => { 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 (

SOLICITUD DE ACCESO

¿No tienes acceso a una aplicación?

Selecciona el portal que necesitas y envía la solicitud al equipo responsable.

{ if (submitting) event.preventDefault(); }} >

GLM HUB

Solicitar acceso

Elige el área y la aplicación. El catálogo se actualiza automáticamente con los portales publicados.

void handleSubmit(event)}>
{submitting ? (
) : null}
); } 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 ( ); } 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; accessReady: boolean; storageWarning: string; onActivate: (app: HubApp) => void; onFavorite: (appId: string) => void; onAdmin: () => void; onRequestAccess: (app: HubApp) => Promise; } function HubView({ user, data, accessByApp, accessReady, storageWarning, onActivate, onFavorite, onAdmin, onRequestAccess }: HubViewProps) { const [categorySearches, setCategorySearches] = useState>({ 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(); 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 (

{getGreeting()}, {user.name}

Tu mesa de trabajo.

{dateLabel}
{storageWarning ?
{storageWarning}
: null}

¿Qué necesitas abrir?

FIJADAS PARA TI

Acceso rápido

Abre lo que más usas sin detener el ritmo.

{paginatedQuickApps.map((app) => ( ))}
5} label="Paginación de acceso rápido" />

DIRECTORIO GLM

Todas las aplicaciones

{results.length} {results.length === 1 ? "resultado" : "resultados"}
{(["Todas", ...CATEGORY_ORDER] as const).map((item) => ( ))}
{results.length > 0 ? ( <>
{paginatedResults.map((app, index) => ( ))}
5} label="Paginación de aplicaciones" /> ) : ( )}
{canManageCatalog ? (

CONTROL DEL CATÁLOGO

¿Falta una herramienta?

Adjunta su ícono, pega el enlace y publícala para todo el equipo.

) : ( )}
); } interface AdminAppListProps { apps: HubApp[]; selectedId: string | null; onSelect: (id: string | null) => void; onDelete: (id: string) => Promise; } function AdminAppList({ apps, selectedId, onSelect, onDelete }: AdminAppListProps) { const [query, setQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const [appToDelete, setAppToDelete] = useState(null); const deleteModalRef = useRef(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 ( ); } interface AppEditorProps { app: HubApp | null; allApps: HubApp[]; onSave: (draft: AppDraft, id: string | null) => Promise; onDelete: (id: string) => Promise; onCancel: () => void; } function RequiredMark() { return ( <> obligatorio ); } function AppEditor({ app, allApps, onSave, onDelete, onCancel }: AppEditorProps) { const [draft, setDraft] = useState(() => (app ? appToDraft(app) : makeEmptyDraft())); const [baseline, setBaseline] = useState(() => JSON.stringify(app ? appToDraft(app) : makeEmptyDraft())); const [errors, setErrors] = useState>({}); 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(null); const firstFieldRef = useRef(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 = (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) => { void acceptIcon(event.target.files?.[0]); event.target.value = ""; }; const handleDrop = (event: DragEvent) => { 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 = {}; 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 = {}; 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) => { event.preventDefault(); if (isBusy || !validate()) { window.requestAnimationFrame(() => document.querySelector(".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 (

{app ? "EDITAR APLICACIÓN" : "NUEVA APLICACIÓN"}

{app ? app.name : "Configura el nuevo acceso"}

{isDirty ? ( ) : null}

Identidad

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 />
{errors.name ? {errors.name} : Como el equipo la reconoce.} {draft.name.length}/60