this repo has no description

Added Discord webhook

+498
+2
fresh.gen.ts
··· 8 8 import * as $api_fetch_calendar_svg from "./routes/api/fetch-calendar-svg.ts"; 9 9 import * as $api_svg_to_png from "./routes/api/svg-to-png.ts"; 10 10 import * as $index from "./routes/index.tsx"; 11 + import * as $DiscordWebhook from "./islands/DiscordWebhook.tsx"; 11 12 import * as $EventFormatter from "./islands/EventFormatter.tsx"; 12 13 import * as $FormContainer from "./islands/FormContainer.tsx"; 13 14 import * as $LocalStorageInput from "./islands/LocalStorageInput.tsx"; ··· 26 27 "./routes/index.tsx": $index, 27 28 }, 28 29 islands: { 30 + "./islands/DiscordWebhook.tsx": $DiscordWebhook, 29 31 "./islands/EventFormatter.tsx": $EventFormatter, 30 32 "./islands/FormContainer.tsx": $FormContainer, 31 33 "./islands/LocalStorageInput.tsx": $LocalStorageInput,
+493
islands/DiscordWebhook.tsx
··· 1 + import { useState } from "preact/hooks"; 2 + import LocalStorageInput from "./LocalStorageInput.tsx"; 3 + 4 + interface ICalEvent { 5 + start: Date; 6 + end: Date; 7 + summary?: string; 8 + location?: string; 9 + description?: string; 10 + timezone?: string; 11 + } 12 + 13 + interface DiscordWebhookProps { 14 + svgData: string | null; 15 + events: ICalEvent[] | null; 16 + } 17 + 18 + export default function DiscordWebhook({ svgData, events }: DiscordWebhookProps) { 19 + const [webhookUrl, setWebhookUrl] = useState<string>(""); 20 + const [urlError, setUrlError] = useState<string | null>(null); 21 + const [showComposer, setShowComposer] = useState<boolean>(false); 22 + const [messageContent, setMessageContent] = useState<string>(""); 23 + const [isSubmitting, setIsSubmitting] = useState<boolean>(false); 24 + const [submitStatus, setSubmitStatus] = useState<string | null>(null); 25 + 26 + const isValidDiscordWebhook = (url: string): boolean => { 27 + return url.includes("discord.com/api/webhooks/") || url.includes("discordapp.com/api/webhooks/"); 28 + }; 29 + 30 + const generateEmbedFields = () => { 31 + if (!events || events.length === 0) return []; 32 + 33 + // Group events by location 34 + const eventsByLocation: { [key: string]: ICalEvent[] } = {}; 35 + 36 + events.forEach(event => { 37 + const location = event.location?.toLowerCase() || "other"; 38 + if (!eventsByLocation[location]) { 39 + eventsByLocation[location] = []; 40 + } 41 + eventsByLocation[location].push(event); 42 + }); 43 + 44 + // Create fields array with custom ordering 45 + const fields = []; 46 + const locations = Object.keys(eventsByLocation); 47 + 48 + // Add "twitch" first if it exists 49 + if (eventsByLocation["twitch"]) { 50 + const eventsText = eventsByLocation["twitch"] 51 + .map(event => { 52 + const unixTimestamp = Math.floor(new Date(event.start).getTime() / 1000); 53 + const summary = event.summary || "Untitled Event"; 54 + const description = event.description ? ` | ${event.description}` : ""; 55 + return `<t:${unixTimestamp}:F> ${summary}${description}`; 56 + }) 57 + .join("\n"); 58 + 59 + fields.push({ 60 + name: "***twitch streams***", 61 + value: eventsText.length > 1024 ? eventsText.substring(0, 1021) + "..." : eventsText, 62 + inline: false 63 + }); 64 + } 65 + 66 + // Add other locations (except "twitch" and "other") 67 + locations 68 + .filter(location => location !== "twitch" && location !== "other") 69 + .forEach(location => { 70 + const eventsText = eventsByLocation[location] 71 + .map(event => { 72 + const unixTimestamp = Math.floor(new Date(event.start).getTime() / 1000); 73 + const summary = event.summary || "Untitled Event"; 74 + const description = event.description ? ` | ${event.description}` : ""; 75 + return `<t:${unixTimestamp}:F> ${summary}${description}`; 76 + }) 77 + .join("\n"); 78 + 79 + fields.push({ 80 + name: `***${location}***`, 81 + value: eventsText.length > 1024 ? eventsText.substring(0, 1021) + "..." : eventsText, 82 + inline: false 83 + }); 84 + }); 85 + 86 + // Add "other" last if it exists 87 + if (eventsByLocation["other"]) { 88 + const eventsText = eventsByLocation["other"] 89 + .map(event => { 90 + const unixTimestamp = Math.floor(new Date(event.start).getTime() / 1000); 91 + const summary = event.summary || "Untitled Event"; 92 + const description = event.description ? ` | ${event.description}` : ""; 93 + return `<t:${unixTimestamp}:F> ${summary}${description}`; 94 + }) 95 + .join("\n"); 96 + 97 + fields.push({ 98 + name: "***other***", 99 + value: eventsText.length > 1024 ? eventsText.substring(0, 1021) + "..." : eventsText, 100 + inline: false 101 + }); 102 + } 103 + 104 + return fields; 105 + }; 106 + 107 + const handleUrlChange = (url: string) => { 108 + setWebhookUrl(url); 109 + 110 + // Validate Discord webhook URL 111 + if (url && !url.match(/^https?:\/\//i)) { 112 + setUrlError("URL must begin with https:// or http://"); 113 + } else if (url && !isValidDiscordWebhook(url)) { 114 + setUrlError("URL must be a valid Discord webhook URL"); 115 + } else { 116 + setUrlError(null); 117 + } 118 + }; 119 + 120 + const handlePostClick = () => { 121 + if (!webhookUrl || urlError || !svgData) return; 122 + setShowComposer(true); 123 + setMessageContent(""); 124 + setSubmitStatus(null); 125 + }; 126 + 127 + const handleCloseComposer = () => { 128 + setShowComposer(false); 129 + setMessageContent(""); 130 + setSubmitStatus(null); 131 + }; 132 + 133 + const handleSubmit = async (e: Event) => { 134 + e.preventDefault(); 135 + if (!svgData || !webhookUrl) return; 136 + 137 + setIsSubmitting(true); 138 + setSubmitStatus(null); 139 + 140 + try { 141 + // First, convert SVG to PNG 142 + const svgResponse = await fetch("/api/svg-to-png", { 143 + method: "POST", 144 + body: svgData, 145 + headers: { 146 + "Content-Type": "image/svg+xml", 147 + }, 148 + }); 149 + 150 + if (!svgResponse.ok) { 151 + throw new Error("Failed to convert SVG to PNG"); 152 + } 153 + 154 + const pngBlob = await svgResponse.blob(); 155 + 156 + // Create FormData for Discord webhook 157 + const formData = new FormData(); 158 + 159 + // Add the message payload 160 + const payload = { 161 + content: messageContent.trim() || undefined, // Only include content if not empty 162 + embeds: [{ 163 + title: "weekly schedule", 164 + color: 0x406435, 165 + image: { 166 + url: "attachment://schedule.png" 167 + }, 168 + fields: generateEmbedFields(), 169 + //timestamp: new Date().toISOString() 170 + }] 171 + }; 172 + 173 + formData.append("payload_json", JSON.stringify(payload)); 174 + formData.append("file", pngBlob, "schedule.png"); 175 + 176 + // Send to Discord webhook 177 + const webhookResponse = await fetch(webhookUrl, { 178 + method: "POST", 179 + body: formData, 180 + }); 181 + 182 + if (!webhookResponse.ok) { 183 + const errorText = await webhookResponse.text(); 184 + throw new Error(`Discord webhook failed: ${errorText}`); 185 + } 186 + 187 + setSubmitStatus("Message sent successfully!"); 188 + setTimeout(() => { 189 + handleCloseComposer(); 190 + }, 1500); 191 + 192 + } catch (error) { 193 + console.error("Error sending to Discord:", error); 194 + setSubmitStatus(`Failed to send: ${error instanceof Error ? error.message : "Unknown error"}`); 195 + } finally { 196 + setIsSubmitting(false); 197 + } 198 + }; 199 + 200 + const canPost = webhookUrl && !urlError && svgData; 201 + 202 + return ( 203 + <div className="discord-webhook-container"> 204 + <h3>Post to Discord</h3> 205 + 206 + <LocalStorageInput 207 + id="discord-webhook-input" 208 + label="Discord Webhook URL:" 209 + value={webhookUrl} 210 + onChange={handleUrlChange} 211 + placeholder="https://discord.com/api/webhooks/..." 212 + storageKey="discordWebhookUrlCurrent" 213 + historyStorageKey="discordWebhookUrlHistory" 214 + maxHistoryItems={5} 215 + className="discord-webhook-input-wrapper" 216 + /> 217 + 218 + {urlError && ( 219 + <p className="error-message">{urlError}</p> 220 + )} 221 + 222 + <button 223 + onClick={handlePostClick} 224 + className="post-button" 225 + disabled={!canPost} 226 + title={!svgData ? "No schedule data available" : !webhookUrl ? "Enter Discord webhook URL" : ""} 227 + > 228 + Post to Discord Webhook 229 + </button> 230 + 231 + {showComposer && ( 232 + <div className="composer-overlay"> 233 + <div className="composer-modal"> 234 + <div className="composer-header"> 235 + <h4>Compose Discord Message</h4> 236 + <button 237 + type="button" 238 + className="close-button" 239 + onClick={handleCloseComposer} 240 + disabled={isSubmitting} 241 + > 242 + 243 + </button> 244 + </div> 245 + 246 + <form onSubmit={handleSubmit} className="composer-form"> 247 + <div className="input-group"> 248 + <label htmlFor="message-content">Message:</label> 249 + <textarea 250 + id="message-content" 251 + value={messageContent} 252 + onInput={(e) => setMessageContent((e.target as HTMLTextAreaElement).value)} 253 + placeholder="Enter your message content... (optional)" 254 + className="message-textarea" 255 + rows={4} 256 + maxLength={2000} 257 + disabled={isSubmitting} 258 + /> 259 + <div className="character-count"> 260 + {messageContent.length}/2000 261 + </div> 262 + </div> 263 + 264 + {submitStatus && ( 265 + <div className={`status-message ${submitStatus.includes("success") ? "success" : "error"}`}> 266 + {submitStatus} 267 + </div> 268 + )} 269 + 270 + <div className="composer-actions"> 271 + <button 272 + type="button" 273 + className="cancel-button" 274 + onClick={handleCloseComposer} 275 + disabled={isSubmitting} 276 + > 277 + Cancel 278 + </button> 279 + <button 280 + type="submit" 281 + className="submit-button" 282 + disabled={isSubmitting} 283 + > 284 + {isSubmitting ? "Sending..." : "Submit"} 285 + </button> 286 + </div> 287 + </form> 288 + </div> 289 + </div> 290 + )} 291 + 292 + <style> 293 + {` 294 + .discord-webhook-container { 295 + margin-top: 20px; 296 + padding: 15px; 297 + border: 1px solid #e0e0e0; 298 + border-radius: 4px; 299 + background-color: #f5f5f5; 300 + } 301 + 302 + .error-message { 303 + color: #d32f2f; 304 + font-size: 14px; 305 + margin: 5px 0; 306 + } 307 + 308 + .post-button { 309 + margin-top: 10px; 310 + padding: 10px 16px; 311 + background-color: #5865F2; 312 + color: white; 313 + border: none; 314 + border-radius: 4px; 315 + cursor: pointer; 316 + font-size: 14px; 317 + font-weight: 500; 318 + transition: background-color 0.2s; 319 + } 320 + 321 + .post-button:hover:not(:disabled) { 322 + background-color: #4752C4; 323 + } 324 + 325 + .post-button:disabled { 326 + background-color: #cccccc; 327 + cursor: not-allowed; 328 + } 329 + 330 + .composer-overlay { 331 + position: fixed; 332 + top: 0; 333 + left: 0; 334 + right: 0; 335 + bottom: 0; 336 + background-color: rgba(0, 0, 0, 0.5); 337 + display: flex; 338 + align-items: center; 339 + justify-content: center; 340 + z-index: 1000; 341 + } 342 + 343 + .composer-modal { 344 + background: white; 345 + border-radius: 8px; 346 + width: 90%; 347 + max-width: 500px; 348 + max-height: 80vh; 349 + overflow: hidden; 350 + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); 351 + } 352 + 353 + .composer-header { 354 + display: flex; 355 + justify-content: space-between; 356 + align-items: center; 357 + padding: 16px 20px; 358 + border-bottom: 1px solid #e0e0e0; 359 + background-color: #f8f9fa; 360 + } 361 + 362 + .composer-header h4 { 363 + margin: 0; 364 + color: #333; 365 + } 366 + 367 + .close-button { 368 + background: none; 369 + border: none; 370 + font-size: 18px; 371 + cursor: pointer; 372 + padding: 4px; 373 + color: #666; 374 + border-radius: 4px; 375 + transition: background-color 0.2s; 376 + } 377 + 378 + .close-button:hover:not(:disabled) { 379 + background-color: #e0e0e0; 380 + } 381 + 382 + .close-button:disabled { 383 + opacity: 0.5; 384 + cursor: not-allowed; 385 + } 386 + 387 + .composer-form { 388 + padding: 20px; 389 + } 390 + 391 + .input-group { 392 + margin-bottom: 16px; 393 + } 394 + 395 + .input-group label { 396 + display: block; 397 + margin-bottom: 6px; 398 + font-weight: 500; 399 + color: #333; 400 + } 401 + 402 + .message-textarea { 403 + width: 100%; 404 + padding: 10px; 405 + border: 1px solid #ddd; 406 + border-radius: 4px; 407 + font-family: inherit; 408 + font-size: 14px; 409 + resize: vertical; 410 + min-height: 80px; 411 + box-sizing: border-box; 412 + } 413 + 414 + .message-textarea:focus { 415 + outline: none; 416 + border-color: #5865F2; 417 + box-shadow: 0 0 0 2px rgba(88, 101, 242, 0.2); 418 + } 419 + 420 + .message-textarea:disabled { 421 + background-color: #f5f5f5; 422 + cursor: not-allowed; 423 + } 424 + 425 + .character-count { 426 + font-size: 12px; 427 + color: #666; 428 + text-align: right; 429 + margin-top: 4px; 430 + } 431 + 432 + .status-message { 433 + padding: 8px 12px; 434 + border-radius: 4px; 435 + margin-bottom: 16px; 436 + font-size: 14px; 437 + } 438 + 439 + .status-message.success { 440 + background-color: #d4edda; 441 + color: #155724; 442 + border: 1px solid #c3e6cb; 443 + } 444 + 445 + .status-message.error { 446 + background-color: #f8d7da; 447 + color: #721c24; 448 + border: 1px solid #f5c6cb; 449 + } 450 + 451 + .composer-actions { 452 + display: flex; 453 + gap: 10px; 454 + justify-content: flex-end; 455 + } 456 + 457 + .cancel-button, .submit-button { 458 + padding: 8px 16px; 459 + border: none; 460 + border-radius: 4px; 461 + cursor: pointer; 462 + font-size: 14px; 463 + font-weight: 500; 464 + transition: background-color 0.2s; 465 + } 466 + 467 + .cancel-button { 468 + background-color: #6c757d; 469 + color: white; 470 + } 471 + 472 + .cancel-button:hover:not(:disabled) { 473 + background-color: #5a6268; 474 + } 475 + 476 + .submit-button { 477 + background-color: #5865F2; 478 + color: white; 479 + } 480 + 481 + .submit-button:hover:not(:disabled) { 482 + background-color: #4752C4; 483 + } 484 + 485 + .cancel-button:disabled, .submit-button:disabled { 486 + background-color: #cccccc; 487 + cursor: not-allowed; 488 + } 489 + `} 490 + </style> 491 + </div> 492 + ); 493 + }
+3
islands/ResultsDisplay.tsx
··· 1 1 import { useState } from "preact/hooks"; 2 2 import EventFormatter from "./EventFormatter.tsx"; 3 + import DiscordWebhook from "./DiscordWebhook.tsx"; 3 4 4 5 interface ResultsDisplayProps { 5 6 svgData: string | null; ··· 114 115 <div dangerouslySetInnerHTML={{ __html: svgData }} /> 115 116 </div> 116 117 )} 118 + 119 + {svgData && <DiscordWebhook svgData={svgData} events={events} />} 117 120 118 121 <style> 119 122 {`