jaynesmax commited on
Commit
ba1e29d
Β·
verified Β·
1 Parent(s): 0a94955

Upload 12 files

Browse files
admin (1).html ADDED
@@ -0,0 +1,518 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="sw">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <meta name="robots" content="noindex,nofollow">
7
+ <meta name="theme-color" content="#0e1116">
8
+ <title>JAYNES MAX β€” Admin</title>
9
+ <style>
10
+ :root{--bg:#0e1116;--s1:#151a22;--s2:#1c222c;--ln:#262d38;--tx:#f2f4f7;--mu:#98a2b3;--rd:#e50914;--lv:#c7b8ff;--ok:#4ade80;--wn:#fbbf24;--er:#ff7b72}
11
+ *{box-sizing:border-box;margin:0;padding:0}
12
+ html{scroll-padding-top:90px}
13
+ body{background:var(--bg);color:var(--tx);font:15px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;min-height:100vh;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
14
+ button,input,select,textarea{font:inherit;color:inherit}
15
+ :focus-visible{outline:2px solid var(--lv);outline-offset:2px}
16
+ [hidden]{display:none!important}
17
+ .brand{font-weight:800;font-size:18px;letter-spacing:.2px}.brand b{color:var(--rd)}
18
+ /* gate */
19
+ #gate{min-height:100vh;display:grid;place-items:center;padding:20px}
20
+ .gbox{width:100%;max-width:380px;background:var(--s1);border:1px solid var(--ln);border-radius:18px;padding:28px 24px}
21
+ .mark{width:52px;height:52px;border-radius:14px;background:var(--rd);display:grid;place-items:center;margin-bottom:18px}
22
+ .gbox h1{font-size:22px;margin-bottom:4px}.gbox p{color:var(--mu);font-size:13.5px;margin-bottom:18px}
23
+ /* shell */
24
+ header{position:sticky;top:0;z-index:20;background:rgba(14,17,22,.92);backdrop-filter:blur(10px);border-bottom:1px solid var(--ln);padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:10px}
25
+ .hr{display:flex;gap:8px;align-items:center}
26
+ .lang{display:flex;border:1px solid var(--ln);border-radius:10px;overflow:hidden}
27
+ .lang button{background:none;border:0;padding:6px 11px;font-weight:700;font-size:12.5px;color:var(--mu);cursor:pointer}
28
+ .lang button.on{background:var(--rd);color:#fff}
29
+ .shell{max-width:1120px;margin:0 auto;padding:14px 16px 60px;display:grid;gap:16px}
30
+ nav{display:flex;gap:6px;overflow-x:auto;scrollbar-width:none;padding-bottom:2px}
31
+ nav button{white-space:nowrap;background:none;border:1px solid transparent;border-radius:10px;padding:9px 14px;color:var(--mu);font-weight:600;cursor:pointer;text-align:left}
32
+ nav button.on{background:var(--s2);color:var(--tx);border-color:var(--ln);box-shadow:inset 3px 0 0 var(--rd)}
33
+ @media(min-width:900px){.shell{grid-template-columns:200px 1fr;align-items:start}nav{flex-direction:column;position:sticky;top:76px}}
34
+ h2{font-size:18px;margin-bottom:12px}
35
+ .card{background:var(--s1);border:1px solid var(--ln);border-radius:14px;padding:16px;margin-bottom:14px}
36
+ .card h3{font-size:14.5px;margin-bottom:10px}
37
+ .g2{display:grid;grid-template-columns:1fr 1fr;gap:10px 12px}.full{grid-column:1/-1}
38
+ @media(max-width:560px){.g2{grid-template-columns:1fr}}
39
+ .f{margin-bottom:10px}.f label{display:block;font-size:12.5px;color:var(--mu);margin-bottom:5px}
40
+ input[type=text],input[type=password],input[type=url],input[type=number],textarea,select{width:100%;background:var(--bg);border:1px solid var(--ln);border-radius:9px;padding:10px 12px;outline:none}
41
+ input:focus,textarea:focus,select:focus{border-color:var(--lv)}
42
+ textarea{resize:vertical;min-height:70px}
43
+ .chk{display:flex;gap:9px;align-items:center;font-size:14px;margin:4px 0 12px}.chk input{width:18px;height:18px;accent-color:var(--rd)}
44
+ .hint{font-size:12px;color:var(--mu);margin-top:4px}
45
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 16px;border-radius:9px;border:1px solid var(--ln);background:var(--s2);font-weight:700;font-size:14px;cursor:pointer}
46
+ .btn.p{background:var(--rd);border-color:var(--rd);color:#fff}.btn.d{color:var(--er);background:#2a1618;border-color:#4a2125}.btn.s{padding:6px 10px;font-size:12.5px}
47
+ .btn:disabled{opacity:.5;cursor:default}.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
48
+ .pill{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:99px;background:var(--s2);color:var(--mu);border:1px solid var(--ln)}
49
+ .pill.ok{color:var(--ok);border-color:#1f5a37}.pill.wn{color:var(--wn);border-color:#6b5316}.pill.er{color:var(--er);border-color:#5c2a27}
50
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:14px}
51
+ .stat{background:var(--s1);border:1px solid var(--ln);border-radius:12px;padding:14px}.stat b{font-size:26px;display:block;line-height:1.1}.stat span{font-size:12.5px;color:var(--mu)}
52
+ .stat.hot b{color:var(--rd)}
53
+ .tw{overflow-x:auto}table{width:100%;border-collapse:collapse;font-size:13.5px}th{text-align:left;color:var(--mu);font-weight:600;font-size:12.5px;padding:8px;border-bottom:1px solid var(--ln);white-space:nowrap}td{padding:9px 8px;border-bottom:1px solid var(--ln);vertical-align:middle}
54
+ .item{display:flex;gap:12px;background:var(--s2);border:1px solid var(--ln);border-radius:12px;padding:10px;margin-bottom:10px}
55
+ .item img{width:56px;height:78px;object-fit:cover;border-radius:7px;background:#000;flex:none}.item .in{flex:1;min-width:0}.item .tt{font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
56
+ .mu{color:var(--mu);font-size:13px}.er{color:var(--er)}.ok{color:var(--ok)}.empty{color:var(--mu);text-align:center;padding:26px 10px}
57
+ .tabs{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:6px}.tabs button{padding:10px 6px;border:1px solid var(--ln);background:var(--bg);border-radius:10px;font-weight:600;cursor:pointer}.tabs button.on{background:var(--rd);border-color:var(--rd);color:#fff}
58
+ .prev{background:var(--s2);border-radius:14px;padding:12px;margin-top:14px}.prev .r{display:flex;gap:12px;align-items:center}.prev i{flex:none;width:40px;height:40px;border-radius:10px;background:#000;color:var(--rd);display:grid;place-items:center;font:800 14px system-ui;font-style:normal}.prev b{display:block;font-size:14px}.prev img{display:block;width:100%;max-height:170px;object-fit:cover;border-radius:10px;margin-top:10px}
59
+ .toast{position:fixed;left:50%;bottom:calc(18px + env(safe-area-inset-bottom));transform:translateX(-50%);background:var(--s2);border:1px solid var(--ln);padding:10px 16px;border-radius:10px;font-size:13.5px;z-index:99;opacity:0;pointer-events:none;transition:opacity .2s;max-width:90vw}.toast.on{opacity:1}
60
+ .pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px;max-height:430px;overflow-y:auto}
61
+ .pcard{background:var(--bg);border:1px solid var(--ln);border-radius:10px;padding:0 0 8px;text-align:left;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;gap:2px;color:inherit}
62
+ .pcard img,.pimg{width:100%;aspect-ratio:2/3;object-fit:cover;background:#000;display:block}
63
+ .pcard b{font-size:12.5px;padding:4px 8px 0;line-height:1.25;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
64
+ .pcard small{padding:0 8px;color:var(--mu);font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
65
+ .pcard:active{border-color:var(--rd)}
66
+ @media(prefers-reduced-motion:reduce){.toast{transition:none}}
67
+ </style>
68
+ </head>
69
+ <body>
70
+
71
+ <div id="app">
72
+ <header>
73
+ <div class="brand">JAYNES <b>MAX</b> <span class="mu" style="font-weight:600;font-size:14px">Admin</span></div>
74
+ <div class="hr">
75
+ <div class="lang"><button data-l="sw">SW</button><button data-l="en">EN</button></div>
76
+ </div>
77
+ </header>
78
+
79
+ <div class="shell">
80
+ <nav id="nav">
81
+ <button data-p="ov" data-i="nOv"></button><button data-p="mv" data-i="nMv"></button><button data-p="us" data-i="nUs"></button>
82
+ <button data-p="pu" data-i="nPu"></button><button data-p="an" data-i="nAn"></button><button data-p="hm" data-i="nHm"></button>
83
+ </nav>
84
+ <main>
85
+
86
+ <!-- OVERVIEW -->
87
+ <section id="p-ov"><h2 data-i="nOv"></h2><div id="ovArea" class="mu" data-i="loading"></div></section>
88
+
89
+ <!-- MOVIES -->
90
+ <section id="p-mv" hidden>
91
+ <h2 data-i="nMv"></h2>
92
+ <div class="row" style="margin-bottom:12px">
93
+ <input type="text" id="mvSearch" data-ph="search" style="flex:1;min-width:180px">
94
+ <button class="btn" id="mvReload" data-i="refresh"></button>
95
+ <button class="btn p" id="mvNew" data-i="mvNew"></button>
96
+ </div>
97
+ <div class="card" id="mvForm" hidden>
98
+ <h3 id="mvFormT"></h3>
99
+ <div class="g2">
100
+ <div class="f full"><label data-i="mTitle"></label><input type="text" id="m_title"></div>
101
+ <div class="f"><label data-i="mType"></label><select id="m_type"><option value="movie" data-i="tMovie"></option><option value="series" data-i="tSeries"></option></select></div>
102
+ <div class="f"><label>DJ</label><input type="text" id="m_dj"></div>
103
+ <div class="f"><label data-i="mStatus"></label><select id="m_status"><option value="draft">Draft</option><option value="public">Public</option></select></div>
104
+ <div class="f"><label>Active</label><select id="m_active"><option value="true" data-i="yes"></option><option value="false" data-i="no"></option></select></div>
105
+ <div class="f full"><label>Poster URL</label><input type="url" id="m_poster" placeholder="https://"></div>
106
+ <div class="f full"><label>Thumbnail URL</label><input type="url" id="m_thumb" placeholder="https://"></div>
107
+ </div>
108
+ <div class="f" id="movieUrlField"><label data-i="mUrls"></label><div id="epList"></div><button class="btn s" data-a="addPart" type="button" data-i="mAddPart"></button>
109
+ <details style="margin-top:10px"><summary class="mu" style="cursor:pointer" data-i="mBulk"></summary><textarea id="bulkUrls" rows="5" data-ph="mBulkPh" style="margin-top:8px"></textarea><button class="btn s" style="margin-top:8px" data-a="bulk" type="button" data-i="mSplit"></button></details>
110
+ </div>
111
+ <div class="f" id="seasonsField" hidden><label data-i="mSeasons"></label><div id="seasonsList"></div><button class="btn s" data-a="addSeason" type="button" data-i="mAddSeason"></button></div>
112
+ <div class="row"><button class="btn p" id="mvSave"></button><button class="btn" id="mvCancel" data-i="cancel"></button></div>
113
+ </div>
114
+ <div id="mvList" class="mu"></div>
115
+ </section>
116
+
117
+ <!-- USERS -->
118
+ <section id="p-us" hidden>
119
+ <h2 data-i="nUs"></h2>
120
+ <div class="row" style="margin-bottom:12px"><input type="text" id="usSearch" data-ph="searchEmail" style="flex:1;min-width:180px"><button class="btn" id="usReload" data-i="refresh"></button></div>
121
+ <div id="usArea" class="mu"></div>
122
+ </section>
123
+
124
+ <!-- PUSH -->
125
+ <section id="p-pu" hidden>
126
+ <h2 data-i="nPu"></h2>
127
+ <div class="card">
128
+ <div class="tabs" id="kinds"><button data-k="live" class="on" data-i="kLive"></button><button data-k="vod" data-i="kVod"></button><button data-k="gen" data-i="kGen"></button></div>
129
+ <div class="f" style="margin-top:12px"><label id="puNameL"></label><input type="text" id="puName" maxlength="80"></div>
130
+ <div class="f"><label data-i="msg"></label><textarea id="puMsg" maxlength="240"></textarea></div>
131
+ <div class="f"><label data-i="img"></label><input type="url" id="puImg" placeholder="https://.../picha.jpg"><div class="hint" data-i="imgH"></div></div>
132
+ <div class="f"><label data-i="target"></label><select id="puTarget"></select><input type="text" id="puTid" style="margin-top:8px" hidden placeholder="ID"></div>
133
+ <label class="chk"><input type="checkbox" id="puSave" checked><span><span data-i="saveIn"></span><br><small class="mu" data-i="saveInH"></small></span></label>
134
+ <button class="btn p" style="width:100%" id="puSend" data-i="send"></button>
135
+ <div id="puOut" class="hint" style="margin-top:10px;min-height:1.2em"></div>
136
+ </div>
137
+ <div class="prev"><div class="r"><i>JM</i><div><b id="pvT"></b><span class="mu" id="pvM"></span></div></div><img id="pvI" alt="" hidden><div class="hint" id="pvL" style="word-break:break-all"></div></div>
138
+ </section>
139
+
140
+ <!-- ANNOUNCEMENTS -->
141
+ <section id="p-an" hidden>
142
+ <h2 data-i="nAn"></h2>
143
+ <div class="card">
144
+ <div class="g2">
145
+ <div class="f"><label data-i="aKind"></label><select id="anKind"><option value="notification" data-i="aNotif"></option><option value="update" data-i="aUpd"></option></select></div>
146
+ <div class="f" id="anVerF" hidden><label data-i="aVer"></label><input type="text" id="anVer" placeholder="1.1"></div>
147
+ </div>
148
+ <div class="f"><label data-i="aTitle"></label><input type="text" id="anTitle"></div>
149
+ <div class="f"><label data-i="msg"></label><textarea id="anMsg"></textarea></div>
150
+ <div class="f"><label data-i="aLink"></label><input type="url" id="anUrl" placeholder="https://"></div>
151
+ <label class="chk"><input type="checkbox" id="anActive" checked><span data-i="aActive"></span></label>
152
+ <button class="btn p" id="anPost" data-i="aPost"></button>
153
+ </div>
154
+ <div id="anList" class="mu"></div>
155
+ </section>
156
+
157
+ <!-- HOME -->
158
+ <section id="p-hm" hidden><h2 data-i="nHm"></h2><div id="hmArea" class="mu"></div></section>
159
+
160
+ </main>
161
+ </div>
162
+ </div>
163
+ <div class="toast" id="toast"></div>
164
+
165
+ <script>
166
+ /* ───────── Config ───────── */
167
+ const USERS_FN={url:'https://ybtklpzhacaazulntzyh.supabase.co/functions/v1/admin-users',anon:'sb_publishable_kTp9GWPaSozKbigB61iq3Q_DVjChLyP'};
168
+ const HOME_FN={url:'https://ooqvsqmsnwjqvuzbhczx.supabase.co/functions/v1/home-config',anon:'sb_publishable_-B4goS_nTpi2UvK8bFyCiA_CV5UH4Ic'};
169
+ const CONTENT_FN={url:'https://ooqvsqmsnwjqvuzbhczx.supabase.co/functions/v1/admin-content',anon:'sb_publishable_-B4goS_nTpi2UvK8bFyCiA_CV5UH4Ic'};
170
+ const PUSH_URL='https://ooqvsqmsnwjqvuzbhczx.supabase.co/functions/v1/send-push';
171
+ const WEBHOOK_SECRET='89cbf19e2ae535da463bcb554e7980ed64b01d6ad5cf96da';
172
+
173
+ /* ───────── i18n (ongeza lugha mpya hapa) ───────── */
174
+ const T={
175
+ sw:{gSub:'Ingia kusimamia app, watumiaji na maudhui.',gPw:'Password',gBtn:'Ingia',gBad:'Password si sahihi.',gHttps:'Fungua ukurasa huu kupitia https.',logout:'Toka',loading:'Inapakia...',refresh:'Onyesha upya',save:'Hifadhi',cancel:'Ghairi',yes:'Ndiyo',no:'Hapana',del:'Futa',none:'Hakuna kitu bado.',err:'Hitilafu: ',saved:'Imehifadhiwa.',deleted:'Imefutwa.',
176
+ nOv:'Muhtasari',nMv:'Filamu',nUs:'Watumiaji',nPu:'Push',nAn:'Matangazo',nHm:'Home',nSt:'Mipangilio',search:'Tafuta kwa jina au DJ...',searchEmail:'Tafuta kwa email...',
177
+ oUsers:'Jumla ya watumiaji',oNew:'Wapya wiki hii',oActive:'Waliingia wiki hii',oWl:'Vitu vya Watchlist',oPin:'PIN ya Wazazi imewashwa',oBan:'Wamezuiwa',oMv:'Filamu',oAn:'Matangazo active',oNote:'Takwimu za watumiaji zinatoka kwenye data ya wingu (user_profiles); wasio na akaunti hawaonekani.',
178
+ mType:'Aina',tMovie:'Movie (sehemu moja)',tSeries:'Series (episode nyingi)',mUrls:'URL za movie (unaweza kuweka sehemu nyingi)',mAddPart:'+ Ongeza sehemu',mBulk:'Bandika URL nyingi kwa pamoja',mBulkPh:'URL moja kila mstari (mstari 1 = Sehemu 1, mstari 2 = Sehemu 2...)',mSplit:'Gawanya kuwa sehemu',mSeasons:'Seasons na episodes',mAddSeason:'+ Ongeza season',delSeason:'Futa season',addEp:'+ Ongeza episode',needUrl:'Weka URL angalau moja.',partsSet:'Sehemu {n} zimewekwa.',epsToParts:'Episodes {n} zimekuwa sehemu {n}.',partsB:'Sehemu',mvNew:'Filamu mpya',mvAdd:'Ongeza filamu / series',mvEdit:'Hariri: ',mvSave:'Hifadhi filamu',mvUpd:'Sasisha filamu',mTitle:'Jina (title)',mYear:'Mwaka',mCat:'Kategoria',cNormal:'Kawaida',cAdult:'Wakubwa',mPrice:'Bei (TZS)',mStatus:'Hali',mDesc:'Maelezo',mCast:'Waigizaji (tenganisha kwa koma)',mEps:'Episode / Video URLs',mAddEp:'+ Ongeza episode',edit:'Hariri',needTitle:'Jaza jina la filamu.',connect:'Function URL ya filamu haipatikani. Weka config.js kwenye folda moja na admin.html.',confirmMv:'Futa filamu hii? Haiwezi kurudishwa.',noMv:'Hakuna filamu zinazolingana.',
179
+ email:'Email',joined:'Amejiunga',lastIn:'Mwisho kuingia',wl:'Watchlist',locked:'Locked',pin:'PIN Wazazi',state:'Hali',act:'Vitendo',banned:'Amezuiwa',normal:'Kawaida',ban:'Zuia',unban:'Fungua',noUs:'Hakuna watumiaji.',confirmBan:'Kumzuia mtumiaji huyu?',confirmUser:'KUFUTA akaunti ya {x}? Hatua hii haiwezi kurudishwa.',
180
+ kLive:'πŸ“Ί Live TV',kVod:'🎬 VOD',kGen:'πŸ”” Jumla',nLive:'Jina la channel',nVod:'Jina la filamu / kipindi',nGen:'Kichwa',msg:'Ujumbe',img:'Picha (URL)',imgH:'Hiari. https, uwiano 2:1 (mf. 1024Γ—512) unaonekana vizuri.',target:'Ikibonyezwa, fungua app kwenye',saveIn:'Hifadhi pia ndani ya app',saveInH:'Inaonekana kwenye kengele ya notifications.',send:'Tuma sasa',sending:'Inatuma...',fill:'Jaza jina na ujumbe.',confirmP:'Tuma kwa watumiaji wote?',okSave:'Imehifadhiwa na push imetumwa kwa wote.',okPush:'Push imetumwa: ',badSecret:'Neno la siri si sahihi.',liveT:'πŸ“Ί Live TV: {n}',liveM:'Inaonyeshwa sasa. Bonyeza kutazama!',vodT:'🎬 Mpya: {n}',vodM:'Sasa inapatikana kwenye JAYNES MAX. Tazama sasa!',pvT:'Kichwa',pvM:'Ujumbe utaonekana hapa',needId:'Weka ID ya ukurasa unaotaka kufungua.',badImg:'Picha lazima ianze na https://',
181
+ tg:{home:'🏠 Nyumbani',live:'πŸ“Ί Live TV (orodha)',movie:'🎬 Filamu (orodha)',translated:'πŸ—£ Zilizotafsiriwa',sports:'⚽ Michezo',title:'🎬 Filamu/kipindi maalum (ID)',chan:'πŸ“Ί Channel maalum (ID)',match:'⚽ Mechi maalum (ID)'},
182
+ aKind:'Aina',aNotif:'Arifa ya kawaida',aUpd:'Sasisho la app',aVer:'Toleo jipya',aTitle:'Kichwa',aLink:'Kiungo (hiari)',aActive:'Ionekane sasa',aPost:'Tuma tangazo',aOn:'Washa',aOff:'Zima',aActiveP:'Active',aOffP:'Imezimwa',aUpdP:'Update v',aNotifP:'Arifa',aNoTitle:'(hakuna kichwa)',needAnn:'Weka kichwa au ujumbe.',confirmAnn:'Futa tangazo hili?',
183
+ hOrder:'Mpangilio wa sehemu (juu β†’ chini)',hBanner:'Bango la juu',hShow:'Onyesha',hText:'Maandishi',hLink:'Kiungo (hiari)',hMatches:'⚽ Mechi Live',hTv:'πŸ“Ί Live TV',hMovies:'🎬 Filamu',hLeagues:'Ligi zinazoruhusiwa (moja kwa mstari)',hMax:'Idadi ya ligi kuonyesha',hTitle:'Kichwa cha sehemu',hChan:'Chaneli (moja kwa mstari, kwa mpangilio)',hTrend:'Onyesha "Zinazotamba"',hRails:'Idadi ya rails za filamu (0–10)',hPick:'Chagua kutoka kwenye maudhui',pTr:'Zilizotafsiriwa',pMv:'Movie',pSe:'Series',pAn:'Animation',pLv:'Live TV',pMt:'Mechi',slAdded:'Imeongezwa kwenye slider.',slDup:'Tayari ipo kwenye slider.',noImg:'Hii haina picha. Tumia "+ Ongeza slide" na weka URL ya picha.',hSlider:'πŸ–Ό Slider (picha zinazotelezesha)',hTrans:'πŸ—£ Zilizotafsiriwa',hInterval:'Sekunde kati ya slide',hSlImg:'Picha ya slide (URL ya https)',hSlTitle:'Kichwa (hiari)',hSlLink:'Kiungo (hiari): https://... au ./#/title/ID',hAddSlide:'+ Ongeza slide',hSlHint:'Slide zisizo na picha hazihifadhiwi.',hTMax:'Idadi ya vipengele kuonyesha',hSave:'Hifadhi Home',hReset:'Rudisha chaguo-msingi',hSaved:'Home imehifadhiwa.',confirmReset:'Rudisha Home kuwa chaguo-msingi?',
184
+ sAdmin:'Admin API',sTok:'Admin token (hiari)',sTokH:'Ukiacha wazi, inatumika thamani ya msingi (jaynes-max-tv).',sMovie:'Filamu (Supabase)',sHint:'Zinahifadhiwa kwenye kivinjari hiki tu. URL na key ya filamu zinasomwa kiotomatiki kutoka config.js; hapa unabadilisha tu ukihitaji.',sClear:'Futa taarifa hizi'},
185
+ en:{gSub:'Sign in to manage the app, users and content.',gPw:'Password',gBtn:'Sign in',gBad:'Wrong password.',gHttps:'Open this page over https.',logout:'Sign out',loading:'Loading...',refresh:'Refresh',save:'Save',cancel:'Cancel',yes:'Yes',no:'No',del:'Delete',none:'Nothing here yet.',err:'Error: ',saved:'Saved.',deleted:'Deleted.',
186
+ nOv:'Overview',nMv:'Movies',nUs:'Users',nPu:'Push',nAn:'Announcements',nHm:'Home',nSt:'Settings',search:'Search by title or DJ...',searchEmail:'Search by email...',
187
+ oUsers:'Total users',oNew:'New this week',oActive:'Signed in this week',oWl:'Watchlist items',oPin:'Parental PIN on',oBan:'Banned',oMv:'Movies',oAn:'Active announcements',oNote:'User stats come from cloud profiles (user_profiles); people without an account are not counted.',
188
+ mType:'Type',tMovie:'Movie (single part)',tSeries:'Series (many episodes)',mUrls:'Movie URLs (you can add several parts)',mAddPart:'+ Add part',mBulk:'Paste many URLs at once',mBulkPh:'One URL per line (line 1 = Part 1, line 2 = Part 2...)',mSplit:'Split into parts',mSeasons:'Seasons and episodes',mAddSeason:'+ Add season',delSeason:'Delete season',addEp:'+ Add episode',needUrl:'Enter at least one URL.',partsSet:'{n} parts set.',epsToParts:'{n} episodes became {n} parts.',partsB:'parts',mvNew:'New movie',mvAdd:'Add movie / series',mvEdit:'Edit: ',mvSave:'Save movie',mvUpd:'Update movie',mTitle:'Title',mYear:'Year',mCat:'Category',cNormal:'Regular',cAdult:'Adults',mPrice:'Price (TZS)',mStatus:'Status',mDesc:'Description',mCast:'Cast (comma separated)',mEps:'Episode / video URLs',mAddEp:'+ Add episode',edit:'Edit',needTitle:'Enter the movie title.',connect:'Movies Function URL not found. Put config.js in the same folder as admin.html.',confirmMv:'Delete this movie? This cannot be undone.',noMv:'No matching movies.',
189
+ email:'Email',joined:'Joined',lastIn:'Last sign-in',wl:'Watchlist',locked:'Locked',pin:'Parental PIN',state:'Status',act:'Actions',banned:'Banned',normal:'Active',ban:'Ban',unban:'Unban',noUs:'No users.',confirmBan:'Ban this user?',confirmUser:'DELETE the account of {x}? This cannot be undone.',
190
+ kLive:'πŸ“Ί Live TV',kVod:'🎬 VOD',kGen:'πŸ”” General',nLive:'Channel name',nVod:'Movie / show title',nGen:'Title',msg:'Message',img:'Image (URL)',imgH:'Optional. Use https; a 2:1 ratio (e.g. 1024Γ—512) looks best.',target:'When tapped, open the app on',saveIn:'Also save inside the app',saveInH:'Shows in the in-app notification bell.',send:'Send now',sending:'Sending...',fill:'Fill in the name and message.',confirmP:'Send to all users?',okSave:'Saved and push sent to all subscribers.',okPush:'Push sent: ',badSecret:'Invalid secret.',liveT:'πŸ“Ί Live TV: {n}',liveM:'Live now. Tap to watch!',vodT:'🎬 New: {n}',vodM:'Now available on JAYNES MAX. Watch now!',pvT:'Title',pvM:'Message will appear here',needId:'Enter the ID of the page to open.',badImg:'Image must start with https://',
191
+ tg:{home:'🏠 Home',live:'πŸ“Ί Live TV (list)',movie:'🎬 Movies (list)',translated:'πŸ—£ Translated',sports:'⚽ Sports',title:'🎬 Specific movie/show (ID)',chan:'πŸ“Ί Specific channel (ID)',match:'⚽ Specific match (ID)'},
192
+ aKind:'Type',aNotif:'Regular notice',aUpd:'App update',aVer:'New version',aTitle:'Title',aLink:'Link (optional)',aActive:'Visible now',aPost:'Publish announcement',aOn:'Turn on',aOff:'Turn off',aActiveP:'Active',aOffP:'Off',aUpdP:'Update v',aNotifP:'Notice',aNoTitle:'(no title)',needAnn:'Enter a title or message.',confirmAnn:'Delete this announcement?',
193
+ hOrder:'Section order (top β†’ bottom)',hBanner:'Top banner',hShow:'Show',hText:'Text',hLink:'Link (optional)',hMatches:'⚽ Live matches',hTv:'πŸ“Ί Live TV',hMovies:'🎬 Movies',hLeagues:'Allowed leagues (one per line)',hMax:'Max leagues to show',hTitle:'Section title',hChan:'Channels (one per line, in display order)',hTrend:'Show "Trending"',hRails:'Number of movie rails (0–10)',hPick:'Pick from content',pTr:'Translated',pMv:'Movies',pSe:'Series',pAn:'Animation',pLv:'Live TV',pMt:'Matches',slAdded:'Added to slider.',slDup:'Already in the slider.',noImg:'This has no image. Use "+ Add slide" and enter an image URL.',hSlider:'πŸ–Ό Slider (swipeable banners)',hTrans:'πŸ—£ Translated',hInterval:'Seconds between slides',hSlImg:'Slide image (https URL)',hSlTitle:'Title (optional)',hSlLink:'Link (optional): https://... or ./#/title/ID',hAddSlide:'+ Add slide',hSlHint:'Slides without an image are not saved.',hTMax:'Number of items to show',hSave:'Save Home',hReset:'Restore defaults',hSaved:'Home saved.',confirmReset:'Restore Home to defaults?',
194
+ sAdmin:'Admin API',sTok:'Admin token (optional)',sTokH:'If empty, the default value (jaynes-max-tv) is used.',sMovie:'Movies (Supabase)',sHint:'Stored in this browser only. The movies URL and key are read automatically from config.js; change them here only if needed.',sClear:'Clear these settings'}
195
+ };
196
+ let lang=(()=>{try{return localStorage.getItem('jm_lang')}catch(e){}})()||((navigator.language||'').startsWith('sw')?'sw':'en');
197
+ const t=(k,r)=>{let s=(T[lang]&&T[lang][k]);if(s==null)s=T.en[k];if(s==null)return k;if(r)for(const x in r)s=s.replace('{'+x+'}',r[x]);return s};
198
+ const $=id=>document.getElementById(id);
199
+ const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
200
+ const sget=k=>{try{return localStorage.getItem(k)||''}catch(e){return''}};
201
+ const sset=(k,v)=>{try{v?localStorage.setItem(k,v):localStorage.removeItem(k)}catch(e){}};
202
+ const fdate=i=>{if(!i)return'β€”';const d=new Date(i),l=lang==='sw'?'sw-TZ':'en-GB';return d.toLocaleDateString(l)+' '+d.toLocaleTimeString(l,{hour:'2-digit',minute:'2-digit'})};
203
+ let toastT;const toast=m=>{const e=$('toast');e.textContent=m;e.classList.add('on');clearTimeout(toastT);toastT=setTimeout(()=>e.classList.remove('on'),3000)};
204
+ const fail=e=>toast(t('err')+(e&&e.message||e));
205
+
206
+ /* ══ Mipangilio yote iko hapa (hakuna sehemu ya Settings) ══ */
207
+ const PW='jaynes-max-tv'; // admin token (users, matangazo, home)
208
+ // Muunganisho wa filamu (jaynes-max-tv-zilizotafsiriwa)
209
+ const MOVIE_FN='https://kqqdsumvvbuvyoneanku.supabase.co/functions/v1/jaynes-max-tv-zilizotafsiriwa';
210
+ const MOVIE_ANON='sb_publishable_0XQ9PQ0CRJnC0A_Tz18oGg_fSONkxB3';
211
+ const MOVIE_KEY='jaynes'; // x-admin-key
212
+ const CONTENT_URL='https://ooqvsqmsnwjqvuzbhczx.supabase.co'; // backend ya app (filamu, live TV, mechi)
213
+ const CONTENT_KEY=HOME_FN.anon;
214
+
215
+ /* ───────── Language & nav ───────── */
216
+ function applyLang(){
217
+ document.documentElement.lang=lang;
218
+ document.querySelectorAll('[data-i]').forEach(e=>e.textContent=t(e.dataset.i));
219
+ document.querySelectorAll('[data-ph]').forEach(e=>e.placeholder=t(e.dataset.ph));
220
+ document.querySelectorAll('.lang button').forEach(b=>b.classList.toggle('on',b.dataset.l===lang));
221
+ [...$('puTarget').options].forEach(o=>o.textContent=t('tg')[o.value]);
222
+ mvFormTitle();pushRender();
223
+ if(PW){renderOv();renderUsers();renderMovies();renderAnn();renderHome()}
224
+ }
225
+ document.querySelectorAll('.lang button').forEach(b=>b.onclick=()=>{lang=b.dataset.l;sset('jm_lang',lang);applyLang()});
226
+ function show(p){document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('on',b.dataset.p===p));document.querySelectorAll('main section').forEach(s=>s.hidden=s.id!=='p-'+p)}
227
+ document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>show(b.dataset.p));
228
+
229
+ /* ───────── API ───────── */
230
+ const token=()=>PW;
231
+ async function callFn(fn,action,args={}){
232
+ const r=await fetch(fn.url,{method:'POST',headers:{'Content-Type':'application/json',apikey:fn.anon,Authorization:'Bearer '+fn.anon,'x-admin-token':token()},body:JSON.stringify({action,args})});
233
+ const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.error||('HTTP '+r.status));return d;
234
+ }
235
+ const mh=x=>Object.assign({'x-admin-key':MOVIE_KEY,Authorization:'Bearer '+MOVIE_ANON,apikey:MOVIE_ANON},x||{});
236
+ const emsg=e=>typeof e==='string'?e:(e.message||JSON.stringify(e));
237
+ async function mreq(method,body){const r=await fetch(MOVIE_FN,{method,headers:mh(body?{'Content-Type':'application/json'}:{}),body:body?JSON.stringify(body):undefined});return{r,d:await r.json().catch(()=>({}))}}
238
+
239
+ /* ───────── Overview ───────── */
240
+ let users=[],movies=[],anns=[];
241
+ function renderOv(){
242
+ const now=Date.now(),wk=now-6048e5,isB=u=>u.banned_until&&new Date(u.banned_until)>new Date();
243
+ const c=[[users.length,'oUsers',1],[users.filter(u=>new Date(u.created_at)>wk).length,'oNew'],[users.filter(u=>u.last_sign_in_at&&new Date(u.last_sign_in_at)>wk).length,'oActive'],
244
+ [users.reduce((s,u)=>s+(u.profile?u.profile.watchlist_count:0),0),'oWl'],[users.filter(u=>u.profile&&u.profile.parental_enabled).length,'oPin'],[users.filter(isB).length,'oBan'],
245
+ [movies.length,'oMv'],[anns.filter(a=>a.active).length,'oAn']];
246
+ $('ovArea').className='';
247
+ $('ovArea').innerHTML=`<div class="stats">${c.map(x=>`<div class="stat${x[2]?' hot':''}"><b>${x[0]}</b><span>${t(x[1])}</span></div>`).join('')}</div><div class="hint">${t('oNote')}</div>`;
248
+ }
249
+
250
+ /* ───────── Users ───────── */
251
+ const banned=u=>u.banned_until&&new Date(u.banned_until)>new Date();
252
+ async function loadUsers(){
253
+ $('usArea').textContent=t('loading');
254
+ try{users=(await callFn(USERS_FN,'listUsers',{perPage:500})).users||[]}catch(e){$('usArea').innerHTML=`<div class="er">${esc(t('err')+e.message)}</div>`;return}
255
+ renderUsers();renderOv();
256
+ }
257
+ function renderUsers(){
258
+ const q=$('usSearch').value.trim().toLowerCase(),l=users.filter(u=>!q||(u.email||'').toLowerCase().includes(q)),a=$('usArea');
259
+ if(!l.length){a.className='empty';a.textContent=t('noUs');return}
260
+ a.className='tw';
261
+ a.innerHTML=`<table><thead><tr><th>${t('email')}</th><th>${t('joined')}</th><th>${t('lastIn')}</th><th>${t('wl')}</th><th>${t('locked')}</th><th>${t('pin')}</th><th>${t('state')}</th><th>${t('act')}</th></tr></thead><tbody>${l.map(u=>`<tr>
262
+ <td>${esc(u.email)}</td><td>${fdate(u.created_at)}</td><td>${fdate(u.last_sign_in_at)}</td>
263
+ <td>${u.profile?u.profile.watchlist_count:'β€”'}</td><td>${u.profile?u.profile.locked_content_count:'β€”'}</td>
264
+ <td>${u.profile&&u.profile.parental_enabled?`<span class="pill ok">${t('yes')}</span>`:`<span class="pill">${t('no')}</span>`}</td>
265
+ <td>${banned(u)?`<span class="pill er">${t('banned')}</span>`:`<span class="pill ok">${t('normal')}</span>`}</td>
266
+ <td><div class="row">${banned(u)?`<button class="btn s" data-a="unban" data-id="${esc(u.id)}">${t('unban')}</button>`:`<button class="btn s" data-a="ban" data-id="${esc(u.id)}">${t('ban')}</button>`}<button class="btn s d" data-a="delUser" data-id="${esc(u.id)}">${t('del')}</button></div></td></tr>`).join('')}</tbody></table>`;
267
+ }
268
+ $('usReload').onclick=loadUsers;$('usSearch').oninput=renderUsers;
269
+
270
+ /* ───────── Movies ───────── */
271
+ let editId=null;
272
+ const isSer=v=>Object.values(v||{}).some(x=>x&&typeof x==='object');
273
+ const countEps=v=>{let n=0;Object.values(v||{}).forEach(x=>n+=x&&typeof x==='object'?Object.keys(x).length:1);return n};
274
+ function mvFormTitle(){$('mvFormT').textContent=editId?t('mvEdit')+($('m_title').value||''):t('mvAdd');$('mvSave').textContent=editId?t('mvUpd'):t('mvSave')}
275
+ function addRow(w,k,u,ph){const r=document.createElement('div');r.className='row ep';r.style.marginBottom='6px';r.innerHTML=`<input type="text" class="ek" placeholder="${esc(ph)}" value="${esc(k)}" style="flex:1;min-width:80px;max-width:120px"><input type="url" class="eu" placeholder="https://" value="${esc(u)}" style="flex:3;min-width:140px"><button class="btn s" data-a="rowDel" type="button">βœ•</button>`;w.appendChild(r)}
276
+ function setParts(v){const w=$('epList');w.innerHTML='';const k=Object.keys(v||{});if(!k.length){addRow(w,'Sehemu 1','','Sehemu 1');return}k.forEach((x,i)=>addRow(w,x==='main'?'Sehemu 1':x,v[x],'Sehemu '+(i+1)))}
277
+ function addSeason(name,eps){
278
+ const w=$('seasonsList'),n=w.children.length+1,b=document.createElement('div');b.className='card season';b.style.background='var(--s2)';
279
+ b.innerHTML=`<div class="row" style="margin-bottom:8px"><input type="text" class="sn" value="${esc(name||('Season '+n))}" style="flex:1"><button class="btn s d" data-a="delSeason" type="button">${t('delSeason')}</button></div><div class="seps"></div><button class="btn s" data-a="addEpS" type="button">${t('addEp')}</button>`;
280
+ w.appendChild(b);const sw=b.querySelector('.seps'),l=eps&&Object.keys(eps).length?eps:{'Episode 1':''};Object.keys(l).forEach(k=>addRow(sw,k,l[k],'Episode'));
281
+ }
282
+ function applyType(v){$('m_type').value=v;$('movieUrlField').hidden=v==='series';$('seasonsField').hidden=v!=='series'}
283
+ $('m_type').onchange=e=>{
284
+ const ser=e.target.value==='series';
285
+ if(ser){const eps={};let n=0;document.querySelectorAll('#epList .ep').forEach(r=>{const v=r.querySelector('.eu').value.trim();if(v){n++;eps['Episode '+n]=v}});$('seasonsList').innerHTML='';addSeason('Season 1',n?eps:undefined)}
286
+ else{const p={};let n=0;document.querySelectorAll('#seasonsList .seps .ep').forEach(r=>{const v=r.querySelector('.eu').value.trim();if(v){n++;p['Sehemu '+n]=v}});setParts(p);if(n>1)toast(t('epsToParts',{n}))}
287
+ applyType(e.target.value);
288
+ };
289
+ function bulk(){const u=$('bulkUrls').value.split(/\r?\n/).map(x=>x.trim()).filter(Boolean);if(!u.length){toast(t('needUrl'));return}const o={};u.forEach((x,i)=>o['Sehemu '+(i+1)]=x);setParts(o);$('bulkUrls').value='';toast(t('partsSet',{n:u.length}))}
290
+ function collect(){
291
+ if($('m_type').value!=='series'){
292
+ const p=[];document.querySelectorAll('#epList .ep').forEach((r,i)=>{const v=r.querySelector('.eu').value.trim();if(!v)return;p.push([(r.querySelector('.ek').value||('Sehemu '+(i+1))).trim(),v])});
293
+ if(!p.length)return{};if(p.length===1)return{main:p[0][1]};
294
+ const o={};p.forEach(([k,v])=>{let key=k,c=2;while(o[key])key=k+' ('+(c++)+')';o[key]=v});return o;
295
+ }
296
+ const o={};document.querySelectorAll('#seasonsList .season').forEach(b=>{const sn=b.querySelector('.sn').value.trim()||'Season',e={};
297
+ b.querySelectorAll('.seps .ep').forEach((r,i)=>{const k=(r.querySelector('.ek').value||('Episode '+(i+1))).trim(),v=r.querySelector('.eu').value.trim();if(v)e[k]=v});
298
+ if(Object.keys(e).length)o[sn]=e});return o;
299
+ }
300
+ function mvReset(open){
301
+ editId=null;['title','dj','poster','thumb'].forEach(f=>$('m_'+f).value='');$('m_status').value='draft';$('m_active').value='true';
302
+ applyType('movie');$('seasonsList').innerHTML='';$('bulkUrls').value='';setParts({});$('mvForm').hidden=!open;mvFormTitle();
303
+ }
304
+ async function loadMovies(){
305
+ $('mvList').className='mu';$('mvList').textContent=t('loading');
306
+ try{const{d}=await mreq('GET');if(d&&d.error)throw new Error(emsg(d.error));movies=Array.isArray(d)?d:(d.movies||[])}catch(e){$('mvList').innerHTML=`<div class="er">${esc(t('err')+e.message)}</div>`;return}
307
+ renderMovies();renderOv();
308
+ }
309
+ function renderMovies(){
310
+ const q=$('mvSearch').value.toLowerCase(),a=$('mvList'),l=movies.filter(m=>(m.title||'').toLowerCase().includes(q)||(m.dj||'').toLowerCase().includes(q));
311
+ if(!l.length){a.className='empty';a.textContent=t('noMv');return}
312
+ a.className='';
313
+ a.innerHTML=l.map(m=>{const vu=m.video_urls||{},ser=isSer(vu)||m.category==='series',parts=ser?0:Object.keys(vu).length;
314
+ return`<div class="item"><img src="${esc(m.thumb_url||m.poster_url||m.thumb||m.poster||'')}" alt="" onerror="this.style.display='none'"><div class="in"><div class="tt">${esc(m.title||'β€”')}</div>
315
+ <div style="margin:4px 0 8px;display:flex;gap:5px;flex-wrap:wrap"><span class="pill ${ser?'wn':''}">${ser?'Series':'Movie'}</span><span class="pill ${m.status==='public'?'ok':'wn'}">${esc(m.status||'draft')}</span><span class="pill">${esc(m.dj||'β€”')}</span>${ser?`<span class="pill">${m.episodes||0} EP</span>`:(parts>1?`<span class="pill">${parts} ${t('partsB')}</span>`:'')}</div>
316
+ <div class="row"><button class="btn s" data-a="mvEdit" data-id="${esc(m.id)}">${t('edit')}</button><button class="btn s d" data-a="mvDel" data-id="${esc(m.id)}">${t('del')}</button></div></div></div>`}).join('');
317
+ }
318
+ function mvFill(m){
319
+ mvReset(true);editId=m.id;const vids=m.video_urls||{},ser=isSer(vids),set=(i,v)=>$('m_'+i).value=v==null?'':v;
320
+ set('title',m.title);set('dj',m.dj);set('status',m.status||'draft');set('active',m.active===false?'false':'true');set('poster',m.poster_url||m.poster);set('thumb',m.thumb_url||m.thumb);
321
+ applyType(ser?'series':'movie');
322
+ if(ser){const n=Object.keys(vids);n.length?n.forEach(x=>addSeason(x,vids[x])):addSeason('Season 1')}else setParts(vids);
323
+ mvFormTitle();$('mvForm').scrollIntoView({behavior:'smooth',block:'start'});
324
+ }
325
+ $('mvNew').onclick=()=>mvReset(true);$('mvCancel').onclick=()=>mvReset(false);
326
+ $('mvReload').onclick=loadMovies;$('mvSearch').oninput=renderMovies;$('m_title').oninput=mvFormTitle;
327
+ $('mvSave').onclick=async()=>{
328
+ const title=$('m_title').value.trim();if(!title){toast(t('needTitle'));return}
329
+ const vu=collect(),g=i=>$('m_'+i).value.trim();
330
+ const p={title,dj:g('dj'),category:g('type')==='series'?'series':'kawaida',status:g('status'),active:g('active')==='true',poster_url:g('poster'),thumb_url:g('thumb'),video_urls:vu,episodes:countEps(vu)};
331
+ try{if(editId)p.id=editId;const{d}=await mreq(editId?'PATCH':'POST',p);if(d&&d.error)throw new Error(emsg(d.error));toast(t('saved'));mvReset(false);loadMovies()}catch(e){fail(e)}
332
+ };
333
+
334
+ /* ───────── Push ───────── */
335
+ let kind='live',msgTouched=false,tgTouched=false;
336
+ const tg=$('puTarget');tg.innerHTML=['home','live','movie','translated','sports','title','chan','match'].map(v=>`<option value="${v}"></option>`).join('');tg.value='live';
337
+ function deepLink(){const v=tg.value,id=encodeURIComponent($('puTid').value.trim());
338
+ const m={home:'./#/',live:'./#/live',movie:'./#/movie',translated:'./#/translated',sports:'./#/match'};
339
+ if(m[v])return m[v];if(v==='title')return id?'./#/title/'+id:'';if(v==='chan')return id?'./#/live/'+id:'';if(v==='match')return id?'./#/match/'+id:'';return'./'}
340
+ function pushTitle(){const n=$('puName').value.trim()||(kind==='live'?'Azam Sports 1':kind==='vod'?'Movie':'');return kind==='live'?t('liveT',{n}):kind==='vod'?t('vodT',{n}):$('puName').value.trim()}
341
+ const defMsg=()=>kind==='live'?t('liveM'):kind==='vod'?t('vodM'):'';
342
+ function pushRender(){
343
+ $('puNameL').textContent=t(kind==='live'?'nLive':kind==='vod'?'nVod':'nGen');
344
+ $('puTid').hidden=!/^(title|chan|match)$/.test(tg.value);
345
+ const im=$('puImg').value.trim(),ok=/^https:\/\//.test(im);$('pvI').hidden=!ok;if(ok)$('pvI').src=im;
346
+ $('pvL').textContent='β†’ '+(deepLink()||'#/');
347
+ if(!msgTouched)$('puMsg').value=defMsg();
348
+ document.querySelectorAll('#kinds button').forEach(b=>b.classList.toggle('on',b.dataset.k===kind));
349
+ $('pvT').textContent=pushTitle()||t('pvT');$('pvM').textContent=$('puMsg').value||t('pvM');
350
+ }
351
+ document.querySelectorAll('#kinds button').forEach(b=>b.onclick=()=>{kind=b.dataset.k;msgTouched=false;if(!tgTouched)tg.value=kind==='live'?'live':kind==='vod'?'movie':'home';pushRender()});
352
+ tg.onchange=()=>{tgTouched=true;pushRender()};['puTid','puImg','puName'].forEach(i=>$(i).oninput=pushRender);$('puMsg').oninput=()=>{msgTouched=true;pushRender()};
353
+ $('puSend').onclick=async()=>{
354
+ const name=$('puName').value.trim(),message=$('puMsg').value.trim(),save=$('puSave').checked,o=$('puOut'),image_url=$('puImg').value.trim(),url=deepLink();
355
+ const bad=m=>{o.className='hint er';o.textContent=m};
356
+ if(!name||!message)return bad(t('fill'));
357
+ if(image_url&&!/^https:\/\//.test(image_url))return bad(t('badImg'));
358
+ if(!url)return bad(t('needId'));
359
+ if(!confirm(t('confirmP')))return;
360
+ const title=kind==='gen'?name:pushTitle();
361
+ $('puSend').disabled=true;o.className='hint';o.textContent=t('sending');
362
+ try{
363
+ const r=await fetch(PUSH_URL,{method:'POST',headers:{'Content-Type':'application/json','x-webhook-secret':WEBHOOK_SECRET},body:JSON.stringify(save?{save:true,title,message,image_url:image_url||null,url}:{record:{title,message,image_url:image_url||null,url}})});
364
+ const x=await r.text();if(!r.ok)throw new Error(r.status===401?t('badSecret'):x.slice(0,200));
365
+ o.className='hint ok';o.textContent=save?t('okSave'):t('okPush')+x;
366
+ $('puName').value='';$('puImg').value='';$('puTid').value='';msgTouched=false;pushRender();
367
+ }catch(e){bad(t('err')+e.message)}finally{$('puSend').disabled=false}
368
+ };
369
+
370
+ /* ───────── Announcements ───────── */
371
+ $('anKind').onchange=e=>$('anVerF').hidden=e.target.value!=='update';
372
+ $('anPost').onclick=async()=>{
373
+ const v=i=>$(i).value.trim(),a={kind:$('anKind').value,title:v('anTitle'),message:v('anMsg'),url:v('anUrl'),version:v('anVer'),active:$('anActive').checked};
374
+ if(!a.title&&!a.message){toast(t('needAnn'));return}
375
+ try{await callFn(CONTENT_FN,'createAnnouncement',a);toast(t('saved'));['anTitle','anMsg','anUrl','anVer'].forEach(i=>$(i).value='');loadAnn()}catch(e){fail(e)}
376
+ };
377
+ async function loadAnn(){
378
+ $('anList').textContent=t('loading');
379
+ try{anns=(await callFn(CONTENT_FN,'listAnnouncements')).rows||[]}catch(e){$('anList').innerHTML=`<div class="er">${esc(t('err')+e.message)}</div>`;return}
380
+ renderAnn();renderOv();
381
+ }
382
+ function renderAnn(){
383
+ const a=$('anList');if(!anns.length){a.className='empty';a.textContent=t('none');return}a.className='';
384
+ a.innerHTML=anns.map(r=>`<div class="card"><div class="row" style="justify-content:space-between;align-items:flex-start"><div><span class="pill ${r.kind==='update'?'wn':''}">${r.kind==='update'?t('aUpdP')+esc(r.version||''):t('aNotifP')}</span> <b>${esc(r.title||t('aNoTitle'))}</b></div><span class="pill ${r.active?'ok':''}">${r.active?t('aActiveP'):t('aOffP')}</span></div>
385
+ <div class="mu" style="margin-top:6px">${esc(r.message||'')}</div>${r.url?`<div class="hint">πŸ”— ${esc(r.url)}</div>`:''}<div class="hint">${fdate(r.created_at)}</div>
386
+ <div class="row" style="margin-top:10px"><button class="btn s" data-a="annToggle" data-id="${esc(r.id)}" data-v="${r.active?0:1}">${r.active?t('aOff'):t('aOn')}</button><button class="btn s d" data-a="annDel" data-id="${esc(r.id)}">${t('del')}</button></div></div>`).join('');
387
+ }
388
+
389
+ /* ───────── Home ───────── */
390
+ let hc=null;
391
+ async function loadHome(){try{hc=normHome((await callFn(HOME_FN,'get')).config);renderHome()}catch(e){$('hmArea').innerHTML=`<div class="er">${esc(t('err')+e.message)}</div>`}}
392
+ const HN=k=>t({slider:'hSlider',matches:'hMatches',tv:'hTv',translated:'hTrans',movies:'hMovies'}[k]);
393
+ function normHome(c){
394
+ c=c||{};c.slider=c.slider||{enabled:false,interval:5,slides:[]};c.slider.slides=c.slider.slides||[];
395
+ c.translated=c.translated||{enabled:true,title:'πŸ—£ Zilizotafsiriwa',max:12};
396
+ const o=Array.isArray(c.order)?c.order.slice():['matches','tv','movies'];
397
+ if(!o.includes('slider'))o.unshift('slider');
398
+ if(!o.includes('translated')){const i=o.indexOf('movies');i<0?o.push('translated'):o.splice(i,0,'translated')}
399
+ c.order=o;return c;
400
+ }
401
+ function renderHome(){
402
+ if(!hc)return;const c=hc,a=$('hmArea');a.className='';
403
+ const ck=(id,v,l)=>`<label class="chk"><input type="checkbox" id="${id}" ${v?'checked':''}><span>${l}</span></label>`;
404
+ a.innerHTML=`<div class="card"><h3>${t('hOrder')}</h3>${c.order.map((k,i)=>`<div class="row" style="margin-bottom:6px"><span style="flex:1">${i+1}. ${HN(k)}</span><button class="btn s" data-a="hm" data-id="${i}" data-d="-1">↑</button><button class="btn s" data-a="hm" data-id="${i}" data-d="1">↓</button></div>`).join('')}</div>
405
+ <div class="card"><h3>${HN('slider')}</h3>${ck('hSlEn',c.slider.enabled,t('hShow'))}<div class="f"><label>${t('hInterval')}</label><input type="number" id="hSlInt" min="2" max="30" value="${c.slider.interval||5}"></div>
406
+ <div id="slRows">${c.slider.slides.map((s,i)=>`<div class="item" style="flex-direction:column;gap:8px"><input type="url" class="sl-i" placeholder="${t('hSlImg')}" value="${esc(s.image)}"><input type="text" class="sl-t" placeholder="${t('hSlTitle')}" value="${esc(s.title)}"><input type="text" class="sl-u" placeholder="${t('hSlLink')}" value="${esc(s.url)}"><div class="row"><button class="btn s" data-a="slUp" data-id="${i}" type="button">↑</button><button class="btn s" data-a="slDn" data-id="${i}" type="button">↓</button><button class="btn s d" data-a="slDel" data-id="${i}" type="button">${t('del')}</button></div></div>`).join('')}</div>
407
+ <div class="row"><button class="btn s p" data-a="pickToggle" type="button">${t('hPick')}</button><button class="btn s" data-a="slAdd" type="button">${t('hAddSlide')}</button></div><div id="pickBox"></div><div class="hint" style="margin-top:6px">${t('hSlHint')}</div></div>
408
+ <div class="card"><h3>${HN('translated')}</h3>${ck('hZEn',c.translated.enabled,t('hShow'))}<div class="f"><label>${t('hTitle')}</label><input type="text" id="hZTitle" value="${esc(c.translated.title)}"></div><div class="f"><label>${t('hTMax')}</label><input type="number" id="hZMax" min="1" max="50" value="${c.translated.max||12}"></div></div>
409
+ <div class="card"><h3>${t('hBanner')}</h3>${ck('hBanEn',c.banner.enabled,t('hShow'))}<div class="f"><label>${t('hText')}</label><input type="text" id="hBanText" value="${esc(c.banner.text)}"></div><div class="f"><label>${t('hLink')}</label><input type="url" id="hBanUrl" value="${esc(c.banner.url)}"></div></div>
410
+ <div class="card"><h3>${HN('matches')}</h3>${ck('hMEn',c.matches.enabled,t('hShow'))}<div class="f"><label>${t('hLeagues')}</label><textarea id="hMLeagues" rows="6">${esc(c.matches.leagues.join('\n'))}</textarea></div><div class="f"><label>${t('hMax')}</label><input type="number" id="hMMax" value="${c.matches.max_rails}"></div></div>
411
+ <div class="card"><h3>${HN('tv')}</h3>${ck('hTEn',c.tv.enabled,t('hShow'))}<div class="f"><label>${t('hTitle')}</label><input type="text" id="hTTitle" value="${esc(c.tv.title)}"></div><div class="f"><label>${t('hChan')}</label><textarea id="hTChan" rows="8">${esc(c.tv.channels.join('\n'))}</textarea></div></div>
412
+ <div class="card"><h3>${HN('movies')}</h3>${ck('hVEn',c.movies.enabled,t('hShow'))}${ck('hVTr',c.movies.trending,t('hTrend'))}<div class="f"><label>${t('hRails')}</label><input type="number" id="hVRails" min="0" max="10" value="${c.movies.rails}"></div></div>
413
+ <div class="row"><button class="btn p" data-a="hmSave">${t('hSave')}</button><button class="btn d" data-a="hmReset">${t('hReset')}</button></div>`;
414
+ renderPicker();
415
+ }
416
+ /* ── Picker: chagua maudhui yaingie kwenye slider ── */
417
+ const PICK_SRC=[['tr','pTr'],['mv','pMv'],['se','pSe'],['an','pAn'],['lv','pLv'],['mt','pMt']];
418
+ let pick={open:false,src:'tr',q:'',list:[]};const PC={};
419
+ async function pickMain(){
420
+ if(PC._main)return PC._main;
421
+ const call=(op,args)=>fetch(CONTENT_URL+'/functions/v1/jaynes-max-tv-api-',{method:'POST',headers:{'Content-Type':'application/json',apikey:CONTENT_KEY},body:JSON.stringify({op,args})}).then(r=>r.json()).catch(()=>({}));
422
+ const [home,tabs]=await Promise.all([call('movies.home'),call('movies.tabs')]);
423
+ let raw=[];const td=tabs&&tabs.data;
424
+ if(Array.isArray(td))raw=td;else if(td&&Array.isArray(td.subTabs))raw=td.subTabs;else if(td&&Array.isArray(td.bottomTabs))td.bottomTabs.forEach(b=>(b.subTabs||[]).forEach(x=>raw.push(x)));
425
+ const tr=await Promise.all(raw.slice(0,12).map(x=>call('movies.tab',{tabId:x.tabId||x.id})));
426
+ const out=new Map();
427
+ const add=el=>{const o=el&&el.subject?el.subject:el;if(!o||typeof o!=='object')return;const id=o.id||o.subjectId;if(!id||out.has(String(id)))return;
428
+ const genre=o.genre||(Array.isArray(o.genres)?o.genres.join(', '):'');
429
+ out.set(String(id),{id,title:o.title||o.name||'',img:(o.cover&&o.cover.url)||o.poster||o.thumbnailUrl||o.logo||'',sub:[genre,o.year].filter(Boolean).join(' Β· '),ser:o.subjectType===2||/series/i.test(String(o.type||'')),anim:/animation/i.test(genre),url:'./#/title/'+id})};
430
+ const walk=d=>{if(!d)return;(d.heroes||[]).forEach(add);if(d.hero)add(d.hero);(d.trending||[]).forEach(add);(d.rails||d.items||[]).forEach(sec=>{if(sec&&sec.type==='BANNER')return;((sec&&(sec.items||sec.subjects))||[]).forEach(add)})};
431
+ walk(home.data);tr.forEach(r=>walk(r.data));
432
+ return PC._main=[...out.values()];
433
+ }
434
+ async function pickLoad(src){
435
+ if(PC[src])return PC[src];let items=[];
436
+ if(src==='tr'){
437
+ let L=movies;if(!L.length){const{d}=await mreq('GET');L=Array.isArray(d)?d:(d.movies||[])}
438
+ items=L.filter(m=>(m.status||'public')==='public'&&m.active!==false).map(m=>({id:m.id,title:m.title,img:m.thumb_url||m.poster_url||m.thumb||m.poster||'',sub:(m.dj?m.dj+' Β· ':'')+((isSer(m.video_urls)||m.category==='series')?'Series':'Movie'),url:'./#/translated/'+m.id}));
439
+ }else if(src==='mv'||src==='se'||src==='an'){
440
+ const all=await pickMain();items=all.filter(x=>src==='se'?x.ser:src==='an'?x.anim:!x.ser);
441
+ }else if(src==='lv'){
442
+ const h={Authorization:'Bearer '+CONTENT_KEY,apikey:CONTENT_KEY};
443
+ const [ch,po,px]=await Promise.all([
444
+ fetch(CONTENT_URL+'/functions/v1/jaynes-max-tv/api/content/channels',{headers:h}).then(r=>r.json()).catch(()=>({})),
445
+ fetch(CONTENT_URL+'/rest/v1/channel_posters?select=channel_id,poster_url',{headers:h}).then(r=>r.json()).catch(()=>[]),
446
+ fetch(CONTENT_URL+'/functions/v1/pixtvmax-channels',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},h),body:'{}'}).then(r=>r.json()).catch(()=>({}))]);
447
+ const pm={};(Array.isArray(po)?po:[]).forEach(x=>{if(x.poster_url)pm[String(x.channel_id)]=x.poster_url});
448
+ (ch.channels||[]).forEach(c=>{if(c.id!=null)items.push({id:c.id,title:c.name||('Channel '+c.id),img:pm[String(c.id)]||'',sub:'Live TV',url:'./#/live/'+encodeURIComponent(c.id)})});
449
+ (px.channels||[]).forEach(c=>{if(c.name&&c.url)items.push({id:'px:'+c.name,title:c.name,img:c.logo||'',sub:c.category||'Live TV',url:'./#/live/'+encodeURIComponent('px:'+c.name)})});
450
+ }else{
451
+ const d=await fetch(CONTENT_URL+'/functions/v1/fawa-live/matches',{headers:{Authorization:'Bearer '+CONTENT_KEY}}).then(r=>r.json());
452
+ (d.matches||[]).forEach(m=>items.push({id:m.slug,title:m.title,img:m.logo||'',sub:m.info||'',url:'./#/match/'+encodeURIComponent(m.slug)}));
453
+ }
454
+ if(!items.length)throw new Error(t('none'));
455
+ return PC[src]=items;
456
+ }
457
+ function renderPicker(){
458
+ const b=$('pickBox');if(!b)return;if(!pick.open){b.innerHTML='';return}
459
+ b.innerHTML=`<div class="card" style="background:var(--s2);margin:10px 0 0"><div class="row" style="margin-bottom:10px">${PICK_SRC.map(([k,l])=>`<button type="button" class="btn s ${pick.src===k?'p':''}" data-a="pickSrc" data-id="${k}">${t(l)}</button>`).join('')}</div><input type="text" id="pickQ" placeholder="${t('search')}" value="${esc(pick.q)}"><div id="pickGrid" class="mu" style="margin-top:10px"></div></div>`;
460
+ $('pickQ').oninput=e=>{pick.q=e.target.value;fillGrid()};fillGrid();
461
+ }
462
+ async function fillGrid(){
463
+ const g=$('pickGrid');if(!g)return;const src=pick.src;if(!PC[src]){g.className='mu';g.textContent=t('loading')}
464
+ let items;try{items=await pickLoad(src)}catch(e){if(src===pick.src&&$('pickGrid')){$('pickGrid').className='er';$('pickGrid').textContent=t('err')+e.message}return}
465
+ const gg=$('pickGrid');if(!gg||src!==pick.src)return;
466
+ const q=pick.q.trim().toLowerCase();pick.list=items.filter(x=>!q||(x.title||'').toLowerCase().includes(q));
467
+ if(!pick.list.length){gg.className='empty';gg.textContent=t('none');return}
468
+ gg.className='pgrid';
469
+ gg.innerHTML=pick.list.slice(0,80).map((x,i)=>`<button type="button" class="pcard" data-a="pickAdd" data-id="${i}">${x.img?`<img src="${esc(x.img)}" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'">`:'<span class="pimg"></span>'}<b>${esc(x.title)}</b><small>${esc(x.sub||'')}</small></button>`).join('');
470
+ }
471
+ const lines=id=>$(id).value.split('\n').map(x=>x.trim()).filter(Boolean);
472
+ function readHome(){
473
+ if(!$('hBanEn'))return;const g=$,c=hc;
474
+ c.slider={enabled:g('hSlEn').checked,interval:Math.min(30,Math.max(2,parseInt(g('hSlInt').value,10)||5)),slides:[...document.querySelectorAll('#slRows .item')].map(r=>({image:r.querySelector('.sl-i').value.trim(),title:r.querySelector('.sl-t').value.trim(),url:r.querySelector('.sl-u').value.trim()})).filter(x=>x.image)};
475
+ c.translated={enabled:g('hZEn').checked,title:g('hZTitle').value.trim(),max:Math.min(50,Math.max(1,parseInt(g('hZMax').value,10)||12))};
476
+ c.banner={enabled:g('hBanEn').checked,text:g('hBanText').value.trim(),url:g('hBanUrl').value.trim()};
477
+ c.matches={enabled:g('hMEn').checked,leagues:lines('hMLeagues'),max_rails:parseInt(g('hMMax').value,10)||6};
478
+ c.tv={enabled:g('hTEn').checked,title:g('hTTitle').value.trim(),channels:lines('hTChan')};
479
+ c.movies={enabled:g('hVEn').checked,trending:g('hVTr').checked,rails:Math.min(10,Math.max(0,parseInt(g('hVRails').value,10)||0))};
480
+ }
481
+
482
+ /* ───────── Actions (event delegation) ───────── */
483
+ const run=async(fn,ok)=>{try{await fn();if(ok)toast(ok)}catch(e){fail(e)}};
484
+ const A={
485
+ ban:id=>confirm(t('confirmBan'))&&run(async()=>{await callFn(USERS_FN,'banUser',{id});loadUsers()},t('banned')),
486
+ unban:id=>run(async()=>{await callFn(USERS_FN,'unbanUser',{id});loadUsers()},t('unban')),
487
+ delUser:id=>{const u=users.find(x=>x.id===id);confirm(t('confirmUser',{x:u?u.email:''}))&&run(async()=>{await callFn(USERS_FN,'deleteUser',{id});loadUsers()},t('deleted'))},
488
+ mvEdit:id=>{const m=movies.find(x=>String(x.id)===id);if(m)mvFill(m)},
489
+ mvDel:id=>confirm(t('confirmMv'))&&run(async()=>{const{d}=await mreq('DELETE',{id});if(d&&d.error)throw new Error(emsg(d.error));loadMovies()},t('deleted')),
490
+ rowDel:(id,b)=>{const p=b.parentElement,w=p.parentElement;p.remove();if(w.id==='epList'&&!w.children.length)setParts({})},
491
+ addPart:()=>{const w=$('epList'),n=w.children.length+1;addRow(w,'Sehemu '+n,'','Sehemu '+n)},
492
+ bulk:()=>bulk(),addSeason:()=>addSeason(),
493
+ delSeason:(id,b)=>b.closest('.season').remove(),
494
+ addEpS:(id,b)=>{const w=b.previousElementSibling;addRow(w,'Episode '+(w.children.length+1),'','Episode')},
495
+ annToggle:(id,b)=>run(async()=>{await callFn(CONTENT_FN,'updateAnnouncement',{id,active:b.dataset.v==='1'});loadAnn()}),
496
+ annDel:id=>confirm(t('confirmAnn'))&&run(async()=>{await callFn(CONTENT_FN,'deleteAnnouncement',{id});loadAnn()},t('deleted')),
497
+ hm:(id,b)=>{readHome();const o=hc.order,i=+id,j=i+ +b.dataset.d;if(j<0||j>=o.length)return;[o[i],o[j]]=[o[j],o[i]];renderHome()},
498
+ pickToggle:()=>{pick.open=!pick.open;renderPicker()},
499
+ pickSrc:id=>{pick.src=id;pick.q='';renderPicker()},
500
+ pickAdd:id=>{const x=pick.list[+id];if(!x)return;
501
+ if(!/^https?:\/\//.test(x.img||''))return toast(t('noImg'));
502
+ readHome();if(hc.slider.slides.some(s=>s.url===x.url))return toast(t('slDup'));
503
+ hc.slider.slides.push({image:x.img,title:x.title,url:x.url});hc.slider.enabled=true;renderHome();toast(t('slAdded'))},
504
+ slUp:id=>{readHome();const a=hc.slider.slides,i=+id;if(i>0){[a[i-1],a[i]]=[a[i],a[i-1]];renderHome()}},
505
+ slDn:id=>{readHome();const a=hc.slider.slides,i=+id;if(i<a.length-1){[a[i+1],a[i]]=[a[i],a[i+1]];renderHome()}},
506
+ slAdd:()=>{readHome();hc.slider.slides.push({image:'',title:'',url:''});renderHome();},
507
+ slDel:id=>{readHome();hc.slider.slides.splice(+id,1);renderHome()},
508
+ hmSave:()=>run(async()=>{readHome();hc=normHome((await callFn(HOME_FN,'save',{config:hc})).config);renderHome()},t('hSaved')),
509
+ hmReset:()=>confirm(t('confirmReset'))&&run(async()=>{hc=normHome((await callFn(HOME_FN,'reset')).config);renderHome()},t('saved'))
510
+ };
511
+ document.addEventListener('click',e=>{const b=e.target.closest('[data-a]');if(b&&A[b.dataset.a])A[b.dataset.a](b.dataset.id,b)});
512
+
513
+ /* ───────── Init ───────── */
514
+ function init(){mvReset(false);loadUsers();loadAnn();loadHome();loadMovies()}
515
+ applyLang();show('ov');init();
516
+ </script>
517
+ </body>
518
+ </html>
apple-touch-icon (1).png ADDED
config (1).js ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ══════════════════════════════════════════════════════════════
2
+ // JAYNES MAX β€” CONFIG (mahali pamoja pa URL na funguo)
3
+ // Badilisha thamani hapa tu; index.html haina funguo tena.
4
+ // TAHADHARI: faili hili linapakuliwa na kila mtumiaji (ni JS ya wazi).
5
+ // Weka HAPA funguo za "sb_publishable_…" (anon) PEKE YAKE.
6
+ // KAMWE usiweke "service_role" / "sb_secret_…" hapa.
7
+ // ══════════════════════════════════════════════════════════════
8
+ window.JM_CONFIG = Object.freeze({
9
+ // Mradi wa maudhui (edge functions: search, live TV, fawa, trailer, mux, home-config, announcements)
10
+ CONTENT_URL: 'https://ooqvsqmsnwjqvuzbhczx.supabase.co',
11
+ CONTENT_KEY: 'sb_publishable_-B4goS_nTpi2UvK8bFyCiA_CV5UH4Ic',
12
+
13
+ // Mradi wa akaunti na profile (Auth + user_profiles)
14
+ PROFILE_URL: 'https://ybtklpzhacaazulntzyh.supabase.co',
15
+ PROFILE_KEY: 'sb_publishable_kTp9GWPaSozKbigB61iq3Q_DVjChLyP',
16
+
17
+ // Mradi wa Movies Zilizotafsiriwa (DJ movies/series β€” tab ya "Zilizotafsiriwa")
18
+ // KUMBUKA: jina la function lililo-deploy ni "movie-zilizotafsriwa" (bila "i" baada ya "tafs")
19
+ TRANSLATED_URL: 'https://kqqdsumvvbuvyoneanku.supabase.co',
20
+ TRANSLATED_KEY: 'sb_publishable_0XQ9PQ0CRJnC0A_Tz18oGg_fSONkxB3',
21
+ TRANSLATED_FN: 'movie-zilizotafsriwa',
22
+
23
+ // Web Push (VAPID) β€” funguo YA UMMA tu. Private key iwe Supabase secrets pekee.
24
+ VAPID_PUBLIC_KEY: 'BCgKqA0rqp_xKw90XP0kGwNuTm5nPA1BAOaqiDM3be-lDoeBW7hKRhnEi7sJ8VcCQ5TdJ-6rZ0WVGBGKV7bwfFw',
25
+
26
+ // Matangazo (Adsterra). Zima yote kwa kubadilisha kuwa false β€” hakuna haja ya kugusa index.html.
27
+ ADS_ENABLED: true,
28
+ });
dl-sw (1).js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // dl-sw.js β€” downloads za background (Background Fetch) kwa JAYNES MAX
2
+ // Weka mstari huu JUU kabisa kwenye sw.js yako: importScripts('dl-sw.js');
3
+ const JM_OFFLINE_DIR = 'jm-offline'; // lazima iwe sawa na OFFLINE_DIR kwenye index.html
4
+
5
+ async function jmDir(){ const root = await navigator.storage.getDirectory(); return root.getDirectoryHandle(JM_OFFLINE_DIR, { create: true }); }
6
+ async function jmReadMeta(dir, oid){
7
+ try { const fh = await dir.getFileHandle(oid + '.json'); return JSON.parse(await (await fh.getFile()).text()); } catch (_) { return null; }
8
+ }
9
+ async function jmWriteMeta(dir, oid, obj){
10
+ const fh = await dir.getFileHandle(oid + '.json', { create: true });
11
+ const w = await fh.createWritable(); await w.write(JSON.stringify(obj)); await w.close();
12
+ }
13
+ async function jmBroadcast(msg){
14
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
15
+ all.forEach(c => c.postMessage(msg));
16
+ }
17
+ async function jmOpenDownloads(){
18
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
19
+ for (const c of all) {
20
+ if ('focus' in c) { try { await c.focus(); } catch (_) {} c.postMessage({ type: 'jm-open-downloads' }); return; }
21
+ }
22
+ await self.clients.openWindow('./?nav=downloads');
23
+ }
24
+
25
+ // Ukurasa unauliza "je dl-sw.js ipo?"
26
+ self.addEventListener('message', (e) => {
27
+ if (e.data && e.data.type === 'jm-ping' && e.source) e.source.postMessage({ type: 'jm-pong' });
28
+ });
29
+
30
+ // Download imekamilika (hata app ikiwa imefungwa): hamisha faili kwenda OPFS
31
+ self.addEventListener('backgroundfetchsuccess', (event) => {
32
+ const bgf = event.registration;
33
+ event.waitUntil((async () => {
34
+ const oid = bgf.id.replace(/^jm-/, '');
35
+ const dir = await jmDir();
36
+ const meta = (await jmReadMeta(dir, oid)) || { id: oid, title: 'Video' };
37
+ let ok = false;
38
+ try {
39
+ const records = await bgf.matchAll();
40
+ const res = await records[0].responseReady;
41
+ const ct = (res.headers.get('content-type') || '').toLowerCase();
42
+ if (!res.ok || !res.body || /mpegurl|text\/|html|json/.test(ct)) throw new Error('not_direct_video');
43
+ const fh = await dir.getFileHandle(oid + '.bin', { create: true });
44
+ const w = await fh.createWritable();
45
+ await res.body.pipeTo(w); // stream β€” haitumii RAM
46
+ const size = (await fh.getFile()).size;
47
+ await jmWriteMeta(dir, oid, { ...meta, state: 'done', size });
48
+ ok = true;
49
+ } catch (e) {
50
+ try { await dir.removeEntry(oid + '.bin'); } catch (_) {}
51
+ await jmWriteMeta(dir, oid, { ...meta, state: 'failed', reason: String((e && e.message) || e) });
52
+ }
53
+ try { event.updateUI({ title: (meta.title || 'Video') + (ok ? ' β€” imehifadhiwa βœ”' : ' β€” imeshindwa') }); } catch (_) {}
54
+ await jmBroadcast({ type: 'jm-bg-update', bgId: bgf.id });
55
+ })());
56
+ });
57
+
58
+ async function jmMarkFailed(bgf, reason){
59
+ const oid = bgf.id.replace(/^jm-/, '');
60
+ const dir = await jmDir();
61
+ const meta = (await jmReadMeta(dir, oid)) || { id: oid };
62
+ await jmWriteMeta(dir, oid, { ...meta, state: 'failed', reason });
63
+ await jmBroadcast({ type: 'jm-bg-update', bgId: bgf.id });
64
+ }
65
+ self.addEventListener('backgroundfetchfail', (e) => { e.waitUntil(jmMarkFailed(e.registration, e.registration.failureReason || 'failed')); });
66
+ self.addEventListener('backgroundfetchabort', (e) => { e.waitUntil(jmMarkFailed(e.registration, 'aborted')); });
67
+ self.addEventListener('backgroundfetchclick', (e) => { e.waitUntil(jmOpenDownloads()); });
68
+
69
+ // Bonyeza notification ya "xx%" / "Imekamilika" (njia ya ndani ya ukurasa) β†’ fungua Downloads
70
+ self.addEventListener('notificationclick', (e) => {
71
+ if (!/^jm-dl-/.test((e.notification && e.notification.tag) || '')) return;
72
+ e.notification.close();
73
+ e.waitUntil(jmOpenDownloads());
74
+ });
icon-192 (1).png ADDED
icon-maskable-512 (1).png ADDED
index.html ADDED
The diff for this file is too large to render. See raw diff
 
manifest.webmanifest.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "JAYNES MAX β€” Filamu, Series na TV Live",
3
+ "short_name": "JAYNES MAX",
4
+ "description": "Tazama filamu, series na chaneli za TV live kwa Kiswahili.",
5
+ "lang": "sw",
6
+ "dir": "ltr",
7
+ "start_url": "./",
8
+ "scope": "./",
9
+ "id": "./",
10
+ "display": "standalone",
11
+ "orientation": "portrait",
12
+ "background_color": "#0e1116",
13
+ "theme_color": "#0e1116",
14
+ "categories": ["entertainment", "video"],
15
+ "icons": [
16
+ { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
17
+ { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
18
+ { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
19
+ ],
20
+ "shortcuts": [
21
+ { "name": "Live TV", "url": "./#/live", "icons": [{ "src": "icon-192.png", "sizes": "192x192" }] },
22
+ { "name": "Michezo", "url": "./#/match", "icons": [{ "src": "icon-192.png", "sizes": "192x192" }] },
23
+ { "name": "Orodha Yangu", "url": "./#/watchlist", "icons": [{ "src": "icon-192.png", "sizes": "192x192" }] }
24
+ ]
25
+ }
og-image (1).jpg ADDED
reset (1).html ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="sw">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0e1116">
7
+ <meta name="robots" content="noindex">
8
+ <title>Weka Nenosiri Jipya β€” JAYNES MAX</title>
9
+ <style>
10
+ :root{--bg:#0e1116;--card:#161a21;--text:#f5f6f8;--muted:#9aa3af;--accent:#e50914;--accent2:#c7b8ff;--border:#242a33;}
11
+ *{box-sizing:border-box;margin:0;padding:0;}
12
+ html,body{height:100%;}
13
+ body{background:var(--bg);color:var(--text);font-family:-apple-system,Roboto,Segoe UI,Helvetica,Arial,sans-serif;
14
+ display:flex;align-items:center;justify-content:center;padding:20px;}
15
+ .card{width:100%;max-width:400px;background:var(--card);border:1px solid var(--border);border-radius:18px;padding:26px 22px;}
16
+ .brand{font-weight:800;font-size:18px;text-align:center;margin-bottom:6px;}
17
+ .brand span{color:var(--accent);}
18
+ h1{font-size:19px;text-align:center;margin-bottom:6px;}
19
+ .sub{font-size:13px;color:var(--muted);text-align:center;margin-bottom:20px;line-height:1.45;}
20
+ label{display:block;font-size:12px;color:var(--muted);margin:0 0 6px 2px;}
21
+ .field{position:relative;margin-bottom:14px;}
22
+ input{width:100%;padding:13px 44px 13px 14px;border-radius:12px;border:1px solid var(--border);
23
+ background:var(--bg);color:var(--text);font-size:15px;outline:none;}
24
+ input:focus{border-color:var(--accent2);}
25
+ .eye{position:absolute;right:6px;bottom:6px;width:36px;height:36px;border:none;background:none;color:var(--muted);font-size:17px;cursor:pointer;}
26
+ .track{height:4px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden;margin:-6px 0 4px;}
27
+ .fill{height:100%;width:0;border-radius:999px;background:#ff5252;transition:width .25s,background .25s;}
28
+ .strength{font-size:11px;color:var(--muted);text-align:right;min-height:1.3em;margin-bottom:12px;}
29
+ .btn{width:100%;padding:13px;border:none;border-radius:999px;background:var(--accent2);color:#1b1230;
30
+ font-weight:700;font-size:15px;cursor:pointer;display:block;text-align:center;text-decoration:none;}
31
+ .btn:disabled{opacity:.55;cursor:default;}
32
+ .msg{font-size:13px;text-align:center;margin-top:14px;min-height:1.3em;line-height:1.4;}
33
+ .err{color:#ff8a80;} .ok{color:#5ee6a0;}
34
+ .big{font-size:44px;text-align:center;margin-bottom:10px;}
35
+ .hide{display:none;}
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div class="card">
40
+ <div class="brand">JAYNES <span>MAX</span></div>
41
+
42
+ <!-- Hali: kiungo kibaya / kimeisha muda -->
43
+ <div id="bad" class="hide">
44
+ <div class="big">⚠️</div>
45
+ <h1>Kiungo hakifanyi kazi</h1>
46
+ <p class="sub" id="badText">Kiungo hiki si sahihi au kimeisha muda. Omba kingine kupitia "Umesahau nenosiri?" kwenye app.</p>
47
+ <a class="btn" href="index.html">Rudi kwenye app</a>
48
+ </div>
49
+
50
+ <!-- Fomu -->
51
+ <div id="form" class="hide">
52
+ <h1>Weka nenosiri jipya</h1>
53
+ <p class="sub">Andika nenosiri jipya la akaunti yako.</p>
54
+ <div class="field">
55
+ <label for="p1">Nenosiri jipya</label>
56
+ <input id="p1" type="password" autocomplete="new-password" placeholder="Angalau herufi 8">
57
+ <button class="eye" type="button" data-for="p1" aria-label="Onyesha nenosiri">πŸ‘</button>
58
+ </div>
59
+ <div class="track"><div class="fill" id="fill"></div></div>
60
+ <div class="strength" id="strength"></div>
61
+ <div class="field">
62
+ <label for="p2">Rudia nenosiri</label>
63
+ <input id="p2" type="password" autocomplete="new-password" placeholder="Rudia nenosiri">
64
+ <button class="eye" type="button" data-for="p2" aria-label="Onyesha nenosiri">πŸ‘</button>
65
+ </div>
66
+ <button class="btn" id="save">Badilisha nenosiri</button>
67
+ <div class="msg" id="msg"></div>
68
+ </div>
69
+
70
+ <!-- Imefanikiwa -->
71
+ <div id="done" class="hide">
72
+ <div class="big">βœ…</div>
73
+ <h1>Nenosiri limebadilishwa</h1>
74
+ <p class="sub">Sasa unaweza kuingia kwa nenosiri lako jipya.</p>
75
+ <a class="btn" href="index.html">Ingia kwenye JAYNES MAX</a>
76
+ </div>
77
+ </div>
78
+
79
+ <script src="config.js"></script>
80
+ <script>
81
+ (function(){
82
+ const $ = id => document.getElementById(id);
83
+ const AUTH = JM_CONFIG.PROFILE_URL + '/auth/v1';
84
+ const KEY = JM_CONFIG.PROFILE_KEY;
85
+ const show = id => { ['bad','form','done'].forEach(x => $(x).classList.toggle('hide', x !== id)); };
86
+
87
+ // Supabase huweka token kwenye hash ya kiungo: #access_token=...&type=recovery
88
+ const params = new URLSearchParams(location.hash.replace(/^#/, ''));
89
+ const token = params.get('access_token');
90
+ const type = params.get('type');
91
+ const errCode = params.get('error_code');
92
+ try { history.replaceState(null, '', location.pathname); } catch (_) {} // ficha token kwenye URL
93
+
94
+ if (errCode === 'otp_expired') {
95
+ $('badText').textContent = 'Kiungo kimeisha muda au kimeshatumika. Omba kingine kupitia "Umesahau nenosiri?" kwenye app.';
96
+ show('bad');
97
+ } else if (!token || type !== 'recovery') {
98
+ show('bad');
99
+ } else {
100
+ show('form');
101
+ }
102
+
103
+ document.querySelectorAll('.eye').forEach(b => b.onclick = () => {
104
+ const i = $(b.dataset.for); i.type = i.type === 'password' ? 'text' : 'password';
105
+ });
106
+
107
+ $('p1').addEventListener('input', () => {
108
+ const v = $('p1').value;
109
+ let s = 0;
110
+ if (v.length >= 8) s++;
111
+ if (v.length >= 12) s++;
112
+ if (/[a-z]/.test(v) && /[A-Z]/.test(v)) s++;
113
+ if (/\d/.test(v)) s++;
114
+ if (/[^A-Za-z0-9]/.test(v)) s++;
115
+ if (!v) s = 0;
116
+ const lv = [
117
+ ['0%', '#ff5252', ''], ['25%', '#ff5252', 'Dhaifu'], ['45%', '#ffb74d', 'Wastani'],
118
+ ['70%', '#ffd54f', 'Nzuri'], ['90%', '#9ccc65', 'Imara'], ['100%', '#5ee6a0', 'Imara sana']
119
+ ][s];
120
+ $('fill').style.width = lv[0]; $('fill').style.background = lv[1]; $('strength').textContent = lv[2];
121
+ });
122
+
123
+ const msg = (t, cls) => { $('msg').textContent = t; $('msg').className = 'msg ' + (cls || ''); };
124
+
125
+ $('save').onclick = async () => {
126
+ const a = $('p1').value, b = $('p2').value;
127
+ if (a.length < 8) return msg('Nenosiri liwe angalau herufi 8.', 'err');
128
+ if (a !== b) return msg('Manenosiri hayalingani.', 'err');
129
+ $('save').disabled = true; msg('Subiri…');
130
+ try {
131
+ const res = await fetch(AUTH + '/user', {
132
+ method: 'PUT',
133
+ headers: { 'Content-Type': 'application/json', 'apikey': KEY, 'Authorization': 'Bearer ' + token },
134
+ body: JSON.stringify({ password: a })
135
+ });
136
+ const d = await res.json().catch(() => ({}));
137
+ if (!res.ok) throw new Error(d.msg || d.error_description || d.error_code || 'Imeshindikana');
138
+ show('done');
139
+ } catch (e) {
140
+ const m = String(e.message || '');
141
+ msg(/same|different/i.test(m) ? 'Nenosiri jipya lazima lisiwe sawa na la zamani.'
142
+ : /expired|invalid|jwt/i.test(m) ? 'Kiungo kimeisha muda. Omba kingine kwenye app.'
143
+ : 'Imeshindikana: ' + m, 'err');
144
+ $('save').disabled = false;
145
+ }
146
+ };
147
+ [$('p1'), $('p2')].forEach(i => i.addEventListener('keydown', e => { if (e.key === 'Enter') $('save').click(); }));
148
+ })();
149
+ </script>
150
+ </body>
151
+ </html>
sw (1).js ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // JAYNES MAX β€” Service Worker
2
+ // Hifadhi "app shell" + maktaba za Shaka/hls ili app ifunguke hata bila mtandao.
3
+ // API, video na streams HAZIHIFADHIWI (zinahitaji mtandao). Movie zilizopakuliwa ziko OPFS ndani ya app.
4
+ const VERSION = 'jm-v9';
5
+ const RUNTIME = 'jm-cdn-v1';
6
+ const SHELL = ['./', './index.html', './manifest.webmanifest', './config.js', './icon-192.png', './icon-512.png', './icon-maskable-512.png', './apple-touch-icon.png'];
7
+ const CDN_HOST = 'cdnjs.cloudflare.com';
8
+ const CDN = [
9
+ 'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.13/hls.min.js',
10
+ 'https://cdnjs.cloudflare.com/ajax/libs/shaka-player/4.10.0/shaka-player.compiled.js',
11
+ ];
12
+
13
+ self.addEventListener('install', (e) => {
14
+ e.waitUntil((async () => {
15
+ // Kila faili peke yake: faili moja likikosekana, install HAIFEI (addAll ingefeli yote)
16
+ const shell = await caches.open(VERSION);
17
+ await Promise.all(SHELL.map((u) => shell.add(u).catch(() => {})));
18
+ const rt = await caches.open(RUNTIME);
19
+ await Promise.all(CDN.map((u) => fetch(u, { mode: 'no-cors' }).then((r) => rt.put(u, r)).catch(() => {})));
20
+ await self.skipWaiting();
21
+ })());
22
+ });
23
+
24
+ self.addEventListener('activate', (e) => {
25
+ e.waitUntil(
26
+ caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== VERSION && k !== RUNTIME).map((k) => caches.delete(k))))
27
+ .then(() => self.clients.claim())
28
+ );
29
+ });
30
+
31
+ function shellFallback() {
32
+ return caches.match('./index.html', { ignoreSearch: true })
33
+ .then((r) => r || caches.match('./', { ignoreSearch: true }));
34
+ }
35
+
36
+ self.addEventListener('fetch', (e) => {
37
+ const req = e.request;
38
+ if (req.method !== 'GET') return;
39
+ const url = new URL(req.url);
40
+
41
+ // Maktaba za CDN (hls/shaka): cache-first ili zipatikane offline na zipakie haraka
42
+ if (url.hostname === CDN_HOST) {
43
+ e.respondWith(
44
+ caches.match(req).then((hit) => hit || fetch(req).then((res) => {
45
+ if (res && (res.ok || res.type === 'opaque')) { const copy = res.clone(); caches.open(RUNTIME).then((c) => c.put(req, copy)); }
46
+ return res;
47
+ }))
48
+ );
49
+ return;
50
+ }
51
+
52
+ // Usiguse chochote kingine nje ya asili yetu (Supabase API, video, picha za CDN n.k.)
53
+ if (url.origin !== self.location.origin) return;
54
+ // Range requests (video/sauti) β€” acha mtandao ushughulikie
55
+ if (req.headers.has('range')) return;
56
+
57
+ // Ukurasa mkuu: network-first, lakini mtandao ukichelewa (>3.5s) au ukikosekana β†’ cache.
58
+ // Hii inazuia app "kuning'inia" ukiwa na mtandao dhaifu / bila bando.
59
+ if (req.mode === 'navigate') {
60
+ e.respondWith(new Promise((resolve) => {
61
+ let settled = false;
62
+ const settle = (r) => { if (!settled && r) { settled = true; resolve(r); } };
63
+ const timer = setTimeout(() => { shellFallback().then(settle); }, 3500);
64
+ fetch(req).then((res) => {
65
+ clearTimeout(timer);
66
+ if (res && res.ok) { const copy = res.clone(); caches.open(VERSION).then((c) => c.put('./index.html', copy)); }
67
+ settle(res);
68
+ }).catch(() => {
69
+ clearTimeout(timer);
70
+ shellFallback().then((r) => { if (!settled) { settled = true; resolve(r || Response.error()); } });
71
+ });
72
+ }));
73
+ return;
74
+ }
75
+
76
+ // config.js: network-first (funguo/URL zikibadilishwa zifike mara moja), cache ikiwa offline
77
+ if (url.pathname.endsWith('/config.js')) {
78
+ e.respondWith(
79
+ fetch(req).then((res) => { if (res.ok) { const copy = res.clone(); caches.open(VERSION).then((c) => c.put(req, copy)); } return res; })
80
+ .catch(() => caches.match(req, { ignoreSearch: true }))
81
+ );
82
+ return;
83
+ }
84
+
85
+ // Faili tuli (icons, manifest): cache-first
86
+ e.respondWith(
87
+ caches.match(req).then((hit) => hit || fetch(req).then((res) => {
88
+ if (res.ok) { const copy = res.clone(); caches.open(VERSION).then((c) => c.put(req, copy)); }
89
+ return res;
90
+ }))
91
+ );
92
+ });
93
+
94
+ // ── Web Push (inatoka Supabase Edge Function 'send-push') ──
95
+ self.addEventListener('push', (e) => {
96
+ let d = {};
97
+ try { d = e.data ? e.data.json() : {}; } catch (_) { d = { body: e.data && e.data.text() }; }
98
+ const opts = {
99
+ body: d.body || '',
100
+ icon: './icon-192.png',
101
+ badge: './icon-192.png',
102
+ tag: d.id ? 'jm-ann-' + d.id : undefined,
103
+ data: { url: d.url || './' },
104
+ };
105
+ if (d.image) opts.image = d.image; // picha kubwa (Android/Chrome)
106
+ e.waitUntil(self.registration.showNotification(d.title || 'JAYNES MAX', opts));
107
+ });
108
+
109
+ // Kubonyeza notification: fungua app moja kwa moja kwenye ukurasa ulioainishwa (#/title/ID, #/live, ...)
110
+ self.addEventListener('notificationclick', (e) => {
111
+ e.notification.close();
112
+ const raw = (e.notification.data && e.notification.data.url) || './';
113
+ const target = new URL(raw, self.registration.scope).href;
114
+ e.waitUntil((async () => {
115
+ const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
116
+ for (const c of all) {
117
+ if (c.url.startsWith(self.registration.scope)) {
118
+ try { c.postMessage({ type: 'jm-open', url: target }); } catch (_) {}
119
+ if ('focus' in c) await c.focus();
120
+ return;
121
+ }
122
+ }
123
+ await self.clients.openWindow(target);
124
+ })());
125
+ });