feat: versión inicial de GLM Hub
This commit is contained in:
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user