feat: versión inicial de GLM Hub

This commit is contained in:
2026-07-29 12:17:56 -04:00
commit 1c3d534322
43 changed files with 10381 additions and 0 deletions
+2235
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import type { HubApp, HubData } from "./types";
const SEED_DATE = "2026-07-16T12:00:00.000Z";
export const CATEGORY_ORDER = [
"Administración",
"Recursos Humanos",
"CDC",
] as const;
export const SEED_APPS: HubApp[] = [];
export function createSeedData(): HubData {
return {
schemaVersion: 1,
apps: SEED_APPS.map((app) => ({ ...app, keywords: [...app.keywords] })),
favoritesByUser: {},
recentByUser: {},
updatedAt: SEED_DATE,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
const GLM_FAVICON_URL =
"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3";
function ensureGlmFavicon(): void {
document.querySelectorAll<HTMLLinkElement>('link[rel="icon"], link[rel="shortcut icon"]').forEach((link) => {
link.remove();
});
const icon = document.createElement("link");
icon.rel = "icon";
icon.type = "image/png";
icon.sizes = "512x512";
icon.href = GLM_FAVICON_URL;
document.head.appendChild(icon);
const shortcut = document.createElement("link");
shortcut.rel = "shortcut icon";
shortcut.type = "image/png";
shortcut.href = GLM_FAVICON_URL;
document.head.appendChild(shortcut);
}
ensureGlmFavicon();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+270
View File
@@ -0,0 +1,270 @@
import { supabase } from "./supabase";
import type { AppCategory, AppVisibility, HubApp, HubData, HubUser } from "./types";
const APPS_TABLE = "glm_hub_apps";
const FAVORITES_TABLE = "glm_hub_favorites";
const RECENTS_TABLE = "glm_hub_recent_apps";
const ICON_BUCKET = "glm-hub-icons";
const LOCAL_MIGRATION_MARKER = "glm-hub:supabase-catalog-migrated:v1";
interface HubAppRow {
id: string;
name: string;
description: string;
category: AppCategory;
url: string | null;
icon_url: string;
visibility: AppVisibility;
created_at: string;
updated_at: string;
}
export interface SaveHubAppInput {
name: string;
description: string;
category: AppCategory;
url: string;
iconDataUrl: string;
visibility: AppVisibility;
}
function rowToApp(row: HubAppRow): HubApp {
return {
id: row.id,
name: row.name,
description: row.description,
category: row.category,
keywords: [],
url: row.url ?? "",
mark: "",
accent: "#4F758B",
iconDataUrl: row.icon_url,
visibility: row.visibility,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function dataUrlToBlob(dataUrl: string): { blob: Blob; mimeType: string; extension: string } {
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/.exec(dataUrl);
if (!match) throw new Error("El ícono no tiene un formato válido.");
const mimeType = match[1];
const binary = atob(match[2]);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return {
blob: new Blob([bytes], { type: mimeType }),
mimeType,
extension: mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1],
};
}
function storagePathFromPublicUrl(url: string): string | null {
try {
const parsed = new URL(url);
const marker = `/storage/v1/object/public/${ICON_BUCKET}/`;
const markerIndex = parsed.pathname.indexOf(marker);
if (markerIndex < 0) return null;
return decodeURIComponent(parsed.pathname.slice(markerIndex + marker.length));
} catch {
return null;
}
}
async function uploadIcon(appId: string, iconDataUrl: string): Promise<string> {
if (!iconDataUrl.startsWith("data:image/")) return iconDataUrl;
const { blob, mimeType, extension } = dataUrlToBlob(iconDataUrl);
const path = `apps/${appId}/${Date.now()}-${crypto.randomUUID()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from(ICON_BUCKET)
.upload(path, blob, { cacheControl: "31536000", contentType: mimeType, upsert: false });
if (uploadError) throw new Error(`No se pudo subir el ícono a Supabase: ${uploadError.message}`);
const { data } = supabase.storage.from(ICON_BUCKET).getPublicUrl(path);
const publicUrl = data?.publicUrl as string | undefined;
if (!publicUrl) throw new Error("Supabase no devolvió la URL pública del ícono.");
return publicUrl;
}
async function removeStoredIcon(url: string): Promise<void> {
const path = storagePathFromPublicUrl(url);
if (!path) return;
const { error } = await supabase.storage.from(ICON_BUCKET).remove([path]);
if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error);
}
export async function fetchHubApps(): Promise<HubApp[]> {
const { data, error } = await supabase
.from(APPS_TABLE)
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.order("name", { ascending: true });
if (error) throw new Error(`No se pudo cargar el catálogo compartido: ${error.message}`);
return ((data ?? []) as HubAppRow[]).map(rowToApp);
}
async function fetchFavoriteIds(userId: string): Promise<string[]> {
const { data, error } = await supabase
.from(FAVORITES_TABLE)
.select("app_id,created_at")
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (error) throw new Error(`No se pudieron cargar los favoritos: ${error.message}`);
return (data ?? []).map((row: { app_id: string }) => row.app_id);
}
async function fetchRecentIds(userId: string): Promise<string[]> {
const { data, error } = await supabase
.from(RECENTS_TABLE)
.select("app_id,last_opened_at")
.eq("user_id", userId)
.order("last_opened_at", { ascending: false })
.limit(30);
if (error) throw new Error(`No se pudieron cargar los accesos recientes: ${error.message}`);
return (data ?? []).map((row: { app_id: string }) => row.app_id);
}
export async function fetchHubData(userId: string): Promise<HubData> {
const [apps, favorites, recents] = await Promise.all([
fetchHubApps(),
fetchFavoriteIds(userId),
fetchRecentIds(userId),
]);
return {
schemaVersion: 1,
apps,
favoritesByUser: { [userId]: favorites },
recentByUser: { [userId]: recents },
updatedAt: new Date().toISOString(),
};
}
export async function saveHubApp(
input: SaveHubAppInput,
existing: HubApp | null,
userId: string,
): Promise<HubApp> {
const appId = existing?.id ?? crypto.randomUUID();
const iconUrl = await uploadIcon(appId, input.iconDataUrl);
const payload = {
name: input.name,
description: input.description,
category: input.category,
url: input.url || null,
icon_url: iconUrl,
visibility: input.visibility,
updated_by: userId,
};
let result: { data: unknown; error: { message: string } | null };
if (existing) {
result = await supabase
.from(APPS_TABLE)
.update(payload)
.eq("id", appId)
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.single();
} else {
result = await supabase
.from(APPS_TABLE)
.insert({ id: appId, ...payload, created_by: userId })
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.single();
}
if (result.error) {
if (iconUrl !== input.iconDataUrl) await removeStoredIcon(iconUrl);
throw new Error(`No se pudo guardar la aplicación: ${result.error.message}`);
}
if (existing && existing.iconDataUrl !== iconUrl) await removeStoredIcon(existing.iconDataUrl);
return rowToApp(result.data as HubAppRow);
}
export async function deleteHubApp(app: HubApp): Promise<void> {
const { error } = await supabase.from(APPS_TABLE).delete().eq("id", app.id);
if (error) throw new Error(`No se pudo eliminar la aplicación: ${error.message}`);
await removeStoredIcon(app.iconDataUrl);
}
export async function setFavorite(userId: string, appId: string, favorite: boolean): Promise<void> {
if (favorite) {
const { error } = await supabase
.from(FAVORITES_TABLE)
.upsert({ user_id: userId, app_id: appId }, { onConflict: "user_id,app_id" });
if (error) throw new Error(`No se pudo guardar el favorito: ${error.message}`);
return;
}
const { error } = await supabase
.from(FAVORITES_TABLE)
.delete()
.eq("user_id", userId)
.eq("app_id", appId);
if (error) throw new Error(`No se pudo quitar el favorito: ${error.message}`);
}
export async function recordRecent(userId: string, appId: string): Promise<void> {
const { error } = await supabase
.from(RECENTS_TABLE)
.upsert(
{ user_id: userId, app_id: appId, last_opened_at: new Date().toISOString() },
{ onConflict: "user_id,app_id" },
);
if (error) throw new Error(`No se pudo registrar el acceso reciente: ${error.message}`);
}
export async function migrateLocalAppsIfNeeded(user: HubUser, localApps: HubApp[]): Promise<number> {
if (user.role !== "admin" || localApps.length === 0) return 0;
if (localStorage.getItem(LOCAL_MIGRATION_MARKER) === "completed") return 0;
const remoteApps = await fetchHubApps();
const remoteNames = new Set(remoteApps.map((app) => app.name.trim().toLocaleLowerCase("es-DO")));
const missingApps = localApps.filter((app) => {
const normalizedName = app.name.trim().toLocaleLowerCase("es-DO");
return Boolean(app.iconDataUrl.trim()) && !remoteNames.has(normalizedName);
});
let migratedCount = 0;
for (const app of missingApps) {
await saveHubApp(
{
name: app.name,
description: app.description,
category: app.category,
url: app.url,
iconDataUrl: app.iconDataUrl,
visibility: app.visibility,
},
null,
user.id,
);
remoteNames.add(app.name.trim().toLocaleLowerCase("es-DO"));
migratedCount += 1;
}
localStorage.setItem(LOCAL_MIGRATION_MARKER, "completed");
return migratedCount;
}
export function subscribeToHubApps(onChange: () => void): () => void {
const channel = supabase
.channel("glm-hub-apps-shared")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: APPS_TABLE },
() => onChange(),
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}
+171
View File
@@ -0,0 +1,171 @@
import { createSeedData } from "./data";
import type { HubApp, HubData } from "./types";
const DATA_KEY = "glm-hub:state:v1";
export interface LoadDataResult {
data: HubData;
warning: string;
}
function isHubApp(value: unknown): value is HubApp {
if (!value || typeof value !== "object") return false;
const app = value as Partial<HubApp>;
return (
typeof app.id === "string" &&
typeof app.name === "string" &&
typeof app.description === "string" &&
typeof app.category === "string" &&
Array.isArray(app.keywords) &&
typeof app.url === "string" &&
typeof app.mark === "string" &&
typeof app.accent === "string" &&
typeof app.iconDataUrl === "string" &&
(app.visibility === "published" || app.visibility === "hidden")
);
}
function isHubData(value: unknown): value is HubData {
if (!value || typeof value !== "object") return false;
const data = value as Partial<HubData>;
return (
data.schemaVersion === 1 &&
Array.isArray(data.apps) &&
data.apps.every(isHubApp) &&
!!data.favoritesByUser &&
typeof data.favoritesByUser === "object" &&
!!data.recentByUser &&
typeof data.recentByUser === "object"
);
}
function keepExistingAppIds(groups: Record<string, string[]>, appIds: Set<string>): Record<string, string[]> {
return Object.fromEntries(
Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((id) => appIds.has(id))]),
);
}
function migrateCategory(category: string): HubApp["category"] {
switch (category) {
case "Administración":
case "Recursos Humanos":
case "CDC":
return category;
case "Recursos":
return "Recursos Humanos";
case "Clientes":
case "Creatividad":
return "CDC";
case "Operaciones":
case "Analítica":
default:
return "Administración";
}
}
function migrateAppCategories(data: HubData): HubData {
let changed = false;
const apps = data.apps.map((app) => {
const category = migrateCategory(app.category);
if (category === app.category) return app;
changed = true;
return { ...app, category };
});
return changed
? { ...data, apps, updatedAt: new Date().toISOString() }
: data;
}
function removeAppsWithoutLogo(data: HubData): HubData {
const apps = data.apps.filter((app) => Boolean(app.iconDataUrl.trim()));
if (apps.length === data.apps.length) return data;
const appIds = new Set(apps.map((app) => app.id));
return {
...data,
apps,
favoritesByUser: keepExistingAppIds(data.favoritesByUser, appIds),
recentByUser: keepExistingAppIds(data.recentByUser, appIds),
updatedAt: new Date().toISOString(),
};
}
export function loadHubData(): LoadDataResult {
const fallback = createSeedData();
try {
const raw = localStorage.getItem(DATA_KEY);
if (!raw) {
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return { data: fallback, warning: "" };
}
const parsed: unknown = JSON.parse(raw);
if (!isHubData(parsed)) {
localStorage.setItem(`${DATA_KEY}:backup`, raw);
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return {
data: fallback,
warning: "Los datos locales no eran válidos. Restauramos el catálogo inicial.",
};
}
const migrated = migrateAppCategories(parsed);
const cleaned = removeAppsWithoutLogo(migrated);
if (cleaned !== parsed) {
localStorage.setItem(DATA_KEY, JSON.stringify(cleaned));
}
return { data: cleaned, warning: "" };
} catch {
return {
data: fallback,
warning: "No pudimos leer los datos guardados. Estás viendo el catálogo inicial.",
};
}
}
export function saveHubData(data: HubData): { ok: boolean; error: string } {
try {
localStorage.setItem(DATA_KEY, JSON.stringify(data));
return { ok: true, error: "" };
} catch (error) {
const isQuota =
error instanceof DOMException &&
(error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
return {
ok: false,
error: isQuota
? "El almacenamiento está lleno. Prueba con un logo más liviano."
: "No pudimos guardar los cambios en este navegador.",
};
}
}
export function resetHubData(): HubData {
const next = createSeedData();
localStorage.setItem(DATA_KEY, JSON.stringify(next));
return next;
}
export function normalizeSearch(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase("es-DO")
.trim();
}
export function getSafeUrl(value: string): string | null {
if (!value.trim()) return null;
try {
const url = new URL(value.trim());
const isLocalHttp =
url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
if (url.protocol !== "https:" && !isLocalHttp) return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
}
+2700
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
type SupabaseBrowserLibrary = {
createClient: (url: string, key: string, options?: unknown) => any;
};
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY?.trim();
const browserLibrary = (globalThis as typeof globalThis & { supabase?: SupabaseBrowserLibrary }).supabase;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Faltan VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY en el archivo .env.");
}
if (!browserLibrary) {
throw new Error("No se pudo cargar la librería de Supabase.");
}
export const supabase = browserLibrary.createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
flowType: "pkce",
},
});
+51
View File
@@ -0,0 +1,51 @@
export type UserRole = "admin" | "member";
export type AppCategory =
| "Administración"
| "Recursos Humanos"
| "CDC";
export type AppVisibility = "published" | "hidden";
export interface HubApp {
id: string;
name: string;
description: string;
category: AppCategory;
keywords: string[];
url: string;
mark: string;
accent: string;
iconDataUrl: string;
visibility: AppVisibility;
createdAt: string;
updatedAt: string;
}
export interface HubUser {
id: string;
name: string;
email: string;
role: UserRole;
avatarUrl?: string;
}
export interface Session {
user: HubUser;
expiresAt: number;
}
export interface HubData {
schemaVersion: 1;
apps: HubApp[];
favoritesByUser: Record<string, string[]>;
recentByUser: Record<string, string[]>;
updatedAt: string;
}
export interface ToastMessage {
id: string;
kind: "success" | "info" | "error";
title: string;
detail?: string;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_ICON_GENERATOR_WEBHOOK_URL: string;
readonly VITE_ACCESS_REQUEST_WEBHOOK_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}