1+ <!doctype html>
2+ < html lang ="pt-PT ">
3+ < head >
4+ < meta charset ="utf-8 " />
5+ < meta name ="viewport " content ="width=device-width, initial-scale=1 " />
6+ < title > RSVP Admin</ title >
7+ < link rel ="stylesheet " href ="https://unpkg.com/bulma@0.8.0/css/bulma.min.css " />
8+ < script src ="https://kit.fontawesome.com/2828f7885a.js " crossorigin ="anonymous "> </ script >
9+
10+ < style >
11+ body { background : # 0f0f14 ; }
12+ .wrap { padding : 28px 14px ; }
13+ .cardish {
14+ background : rgba (255 , 255 , 255 , 0.06 );
15+ border : 1px solid rgba (255 , 255 , 255 , 0.14 );
16+ box-shadow : 0 18px 50px rgba (0 , 0 , 0 , 0.28 );
17+ border-radius : 18px ;
18+ padding : 18px ;
19+ }
20+ .title , .label , .help , .table { color : rgba (255 , 255 , 255 , .92 ); }
21+ .help { color : rgba (255 , 255 , 255 , .65 ); }
22+ .input {
23+ background : rgba (255 , 255 , 255 , 0.08 );
24+ border-color : rgba (255 , 255 , 255 , 0.18 );
25+ color : rgba (255 , 255 , 255 , .92 );
26+ }
27+ .input ::placeholder { color : rgba (255 , 255 , 255 , .45 ); }
28+ .table thead th { color : rgba (255 , 255 , 255 , .85 ); }
29+ .table td , .table th { border-color : rgba (255 , 255 , 255 , .10 ); }
30+ .tag .is-yes { background : rgba (72 , 199 , 142 , 0.2 ); color : # dff7ec ; border : 1px solid rgba (72 , 199 , 142 , 0.35 ); }
31+ .tag .is-no { background : rgba (241 , 70 , 104 , 0.2 ); color : # ffe2e8 ; border : 1px solid rgba (241 , 70 , 104 , 0.35 ); }
32+ .muted { color : rgba (255 , 255 , 255 , .65 ); }
33+ .mono { font-family : ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono" , monospace; }
34+ .table-container { overflow-x : auto; }
35+ </ style >
36+ </ head >
37+
38+ < body >
39+ < section class ="wrap ">
40+ < div class ="container ">
41+ < div class ="columns is-centered ">
42+ < div class ="column is-12 is-10-desktop ">
43+
44+ < div class ="cardish ">
45+ < h1 class ="title is-4 "> RSVP Admin</ h1 >
46+ < p class ="help ">
47+ This page loads RSVPs from your Worker. Keep your admin token private.
48+ </ p >
49+
50+ < div class ="columns is-variable is-3 " style ="margin-top: 12px; ">
51+ < div class ="column is-7 ">
52+ < div class ="field ">
53+ < label class ="label "> Admin token</ label >
54+ < div class ="control has-icons-left ">
55+ < input id ="token " class ="input " type ="password " placeholder ="Paste token here (not stored) ">
56+ < span class ="icon is-small is-left "> < i class ="fas fa-key "> </ i > </ span >
57+ </ div >
58+ </ div >
59+ </ div >
60+
61+ < div class ="column is-5 ">
62+ < div class ="field ">
63+ < label class ="label "> Actions</ label >
64+ < div class ="buttons ">
65+ < button id ="load " class ="button is-link is-rounded ">
66+ < span class ="icon "> < i class ="fas fa-sync "> </ i > </ span >
67+ < span > Load</ span >
68+ </ button >
69+ < button id ="export " class ="button is-light is-rounded " disabled >
70+ < span class ="icon "> < i class ="fas fa-file-csv "> </ i > </ span >
71+ < span > Export CSV</ span >
72+ </ button >
73+ </ div >
74+ </ div >
75+ </ div >
76+ </ div >
77+
78+ < p id ="status " class ="muted "> </ p >
79+
80+ < div class ="table-container " style ="margin-top: 14px; ">
81+ < table class ="table is-fullwidth is-hoverable ">
82+ < thead >
83+ < tr >
84+ < th > ID</ th >
85+ < th > When</ th >
86+ < th > Name</ th >
87+ < th > Email</ th >
88+ < th > Attending</ th >
89+ < th > Guests</ th >
90+ < th > Notes</ th >
91+ </ tr >
92+ </ thead >
93+ < tbody id ="rows "> </ tbody >
94+ </ table >
95+ </ div >
96+
97+ < p class ="help mono " style ="margin-top: 10px; ">
98+ Tip: bookmark this page. If you lose the token, generate a new one and update the Worker secret.
99+ </ p >
100+ </ div >
101+
102+ </ div >
103+ </ div >
104+ </ div >
105+ </ section >
106+
107+ < script >
108+ // Replace this with your deployed Worker URL:
109+ // Example: https://rsvp-worker.yourname.workers.dev/admin/rsvps?limit=400
110+ const API_ADMIN_URL = "https://wedding.tiagoalfredo-rocha.workers.dev/admin/rsvps?limit=400" ;
111+
112+ const tokenEl = document . getElementById ( "token" ) ;
113+ const loadBtn = document . getElementById ( "load" ) ;
114+ const exportBtn = document . getElementById ( "export" ) ;
115+ const statusEl = document . getElementById ( "status" ) ;
116+ const rowsEl = document . getElementById ( "rows" ) ;
117+
118+ let lastResults = [ ] ;
119+
120+ function esc ( s ) {
121+ return String ( s ?? "" ) . replace ( / [ & < > " ' ] / g, c => ( {
122+ "&" :"&" , "<" :"<" , ">" :">" , '"' :""" , "'" :"'"
123+ } [ c ] ) ) ;
124+ }
125+
126+ function toCsv ( rows ) {
127+ const header = [ "id" , "created_at" , "name" , "email" , "attending" , "guests" , "notes" ] ;
128+ const lines = [ header . join ( "," ) ] ;
129+
130+ for ( const r of rows ) {
131+ const vals = [
132+ r . id ,
133+ r . created_at ,
134+ r . name ,
135+ r . email ?? "" ,
136+ r . attending ? "yes" : "no" ,
137+ r . guests ,
138+ ( r . notes ?? "" ) . replace ( / \r ? \n / g, " " )
139+ ] . map ( v => {
140+ const str = String ( v ?? "" ) ;
141+ return `"${ str . replace ( / " / g, '""' ) } "` ;
142+ } ) ;
143+ lines . push ( vals . join ( "," ) ) ;
144+ }
145+ return lines . join ( "\n" ) ;
146+ }
147+
148+ function download ( filename , text ) {
149+ const blob = new Blob ( [ text ] , { type : "text/csv;charset=utf-8" } ) ;
150+ const url = URL . createObjectURL ( blob ) ;
151+ const a = document . createElement ( "a" ) ;
152+ a . href = url ;
153+ a . download = filename ;
154+ document . body . appendChild ( a ) ;
155+ a . click ( ) ;
156+ a . remove ( ) ;
157+ URL . revokeObjectURL ( url ) ;
158+ }
159+
160+ async function loadRsvps ( ) {
161+ rowsEl . innerHTML = "" ;
162+ statusEl . textContent = "Loading…" ;
163+ exportBtn . disabled = true ;
164+
165+ const token = tokenEl . value . trim ( ) ;
166+ if ( ! token ) {
167+ statusEl . textContent = "Missing admin token." ;
168+ return ;
169+ }
170+
171+ const res = await fetch ( API_ADMIN_URL , {
172+ headers : { "Authorization" : `Bearer ${ token } ` }
173+ } ) ;
174+
175+ const out = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
176+
177+ if ( ! res . ok ) {
178+ statusEl . textContent = out . error || "Failed to load RSVPs." ;
179+ return ;
180+ }
181+
182+ lastResults = out . results || [ ] ;
183+ statusEl . textContent = `Loaded ${ lastResults . length } RSVPs.` ;
184+ exportBtn . disabled = lastResults . length === 0 ;
185+
186+ rowsEl . innerHTML = lastResults . map ( r => `
187+ <tr>
188+ <td>${ esc ( r . id ) } </td>
189+ <td class="mono">${ esc ( r . created_at ) } </td>
190+ <td>${ esc ( r . name ) } </td>
191+ <td>${ esc ( r . email ) } </td>
192+ <td>${ r . attending ? '<span class="tag is-yes">Yes</span>' : '<span class="tag is-no">No</span>' } </td>
193+ <td>${ esc ( r . guests ) } </td>
194+ <td>${ esc ( r . notes ) } </td>
195+ </tr>
196+ ` ) . join ( "" ) ;
197+ }
198+
199+ loadBtn . addEventListener ( "click" , loadRsvps ) ;
200+
201+ exportBtn . addEventListener ( "click" , ( ) => {
202+ const csv = toCsv ( lastResults ) ;
203+ const ts = new Date ( ) . toISOString ( ) . slice ( 0 , 19 ) . replace ( / [: T ] / g, "-" ) ;
204+ download ( `rsvps-${ ts } .csv` , csv ) ;
205+ } ) ;
206+ </ script >
207+ </ body >
208+ </ html >
0 commit comments