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