Repository navigation
Expand file tree
/
Copy pathmenu.ts
More file actions
115 lines (100 loc) · 3.22 KB
/
Copy pathmenu.ts
File metadata and controls
115 lines (100 loc) · 3.22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import { PUBLIC_API_BASE } from "@/config/api";
export interface MenuItem {
id: string;
label: string;
icon: string;
url: string;
}
const MENU_URL = `${PUBLIC_API_BASE}/menu`;
const CACHE_KEY = "ytclip.sidebar-menu.v1";
/** Guard rails on anything the server sends. */
const MAX_ITEMS = 8;
const MAX_LABEL_CHARS = 32;
/**
* Shipped with the build and shown on first run, offline, or whenever the API
* returns nothing usable — the sidebar must never render empty.
*/
export const DEFAULT_MENU_ITEMS: MenuItem[] = [
{ id: "topup", label: "Topup AI Credit", icon: "coins", url: "https://ai.ytclip.org" },
{
id: "tutorial",
label: "Video Tutorial",
icon: "youtube",
url: "https://www.youtube.com/playlist?list=PLXvNtebci7kQ",
},
{ id: "discord", label: "Discord Server", icon: "messages-square", url: "https://s.id/ytsdc" },
];
function sanitizeItem(raw: unknown): MenuItem | null {
if (!raw || typeof raw !== "object") return null;
const { id, label, icon, url } = raw as Record<string, unknown>;
if (typeof id !== "string" || !id.trim()) return null;
if (typeof label !== "string" || !label.trim()) return null;
if (typeof url !== "string") return null;
// https only. These URLs are handed to the OS browser, so the menu endpoint
// is a channel that can send every user anywhere — refuse anything else.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
if (parsed.protocol !== "https:") return null;
return {
id: id.trim(),
label: label.trim().slice(0, MAX_LABEL_CHARS),
icon: typeof icon === "string" ? icon.trim().toLowerCase() : "",
url: parsed.toString(),
};
}
/**
* Keep the good items and drop the bad ones — one malformed entry should not
* cost the user their whole menu.
*/
export function sanitizeMenu(raw: unknown): MenuItem[] {
const items = (raw as { items?: unknown })?.items;
if (!Array.isArray(items)) return [];
const seen = new Set<string>();
const clean: MenuItem[] = [];
for (const entry of items) {
const item = sanitizeItem(entry);
if (!item || seen.has(item.id)) continue;
seen.add(item.id);
clean.push(item);
if (clean.length >= MAX_ITEMS) break;
}
return clean;
}
/** Last response that passed validation, so the sidebar is right on first paint. */
export function readCachedMenu(): MenuItem[] | null {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const items = sanitizeMenu({ items: JSON.parse(cached) });
return items.length ? items : null;
} catch {
return null;
}
}
function writeCachedMenu(items: MenuItem[]): void {
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(items));
} catch {
// Cache is an optimisation; a full or disabled store is not an error.
}
}
/**
* Fetch the menu. Returns null on any failure so the caller keeps showing what
* it already has — cached items, or the built-in defaults.
*/
export async function fetchMenu(): Promise<MenuItem[] | null> {
try {
const res = await fetch(MENU_URL);
if (!res.ok) return null;
const items = sanitizeMenu(await res.json());
if (!items.length) return null;
writeCachedMenu(items);
return items;
} catch {
return null;
}
}