Skip to content

Commit 8c1b739

Browse files
committed
notification service
1 parent ed81a81 commit 8c1b739

13 files changed

Lines changed: 1054 additions & 0 deletions

File tree

.vscode/settings.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
"cSpell.words": [
33
"Cloaker",
44
"dbparams",
5+
"notif",
6+
"notifs",
57
"unmarshall",
68
"unmarshalled"
79
]

notifications/notif_template.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"expires": "1761937199000",
3+
"title": "Notification Title",
4+
"body": "<h2>Notification Body</h2><p>This is a sample notification body.</p>",
5+
"cta": {
6+
"text": "Click Here",
7+
"url": "https://example.com"
8+
}
9+
}

notifications/notification.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"expires": "1761937199000",
3+
"title": "Halloween Game Jam Announcement!",
4+
"body": "<h2>Join the Halloween Game Jam!</h2><p>Create a spooky game! The theme is <strong>Halloween</strong>. Submit your game for a chance to be featured on CCPorted, with a link to your chosen destination (within reason). Three winners will be selected!</p><ul><li>Build a web game</li><li>Theme: Halloween</li><li>Single player</li><li>Three winners</li></ul><p>Submit your game by <strong>October 31 at midnight</strong>.<br><a href=\"https://forms.gle/wvqsjndYHvwvq41BA\">Submit your game here</a></p>",
5+
"cta": {
6+
"text": "Submit Your Game",
7+
"url": "https://forms.gle/wvqsjndYHvwvq41BA"
8+
}
9+
}

notifications/push_notification.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
const notificationJSONFilePath = process.argv[2] || "notification.json";
2+
3+
4+
import * as fsp from 'fs/promises';
5+
import * as fs from 'fs';
6+
import { exec, execSync } from 'child_process';
7+
8+
9+
async function main () {
10+
const file = notificationJSONFilePath;
11+
12+
const content = await fsp.readFile(file, { encoding: "utf8" });
13+
const notification = JSON.parse(content);
14+
15+
16+
const {
17+
title, body, expires, cta
18+
} = notification;
19+
20+
const id = uuid();
21+
22+
23+
const command = (id: string, title: string, body: string, expires: number, cta: any): string => {
24+
const ctaText = typeof cta === 'object' && cta.text ? cta.text : (typeof cta === 'string' ? cta : '');
25+
const ctaLink = typeof cta === 'object' && cta.url ? cta.url : '';
26+
27+
fs.writeFileSync("values.json", JSON.stringify({
28+
notification_id: { "S": id },
29+
title: { "S": title },
30+
body: { "S": body },
31+
expires: { "N": `${expires}` },
32+
ctaText: { "S": ctaText },
33+
ctaLink: { "S": ctaLink }
34+
}))
35+
return `aws dynamodb put-item
36+
--table-name ccported_notifs
37+
--item file://values.json`
38+
}
39+
40+
let c = command(id, title, body, expires, cta);
41+
console.log(">>>>", c);
42+
execSync(c.split("\n").map(s => s.trim()).join(" "));
43+
console.log("Finished", title);
44+
}
45+
46+
47+
function uuid() {
48+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
49+
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
50+
return v.toString(16);
51+
});
52+
}
53+
54+
main().then(() => {
55+
console.log("\n\nDone")
56+
}).catch(console.error)

notifications/values.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"notification_id":{"S":"a5033035-bec3-4443-88e0-bc0f5f6adaae"},"title":{"S":"Halloween Game Jam Announcement!"},"body":{"S":"<h2>Join the Halloween Game Jam!</h2><p>Create a spooky game! The theme is <strong>Halloween</strong>. Submit your game for a chance to be featured on CCPorted, with a link to your chosen destination (within reason). Three winners will be selected!</p><ul><li>Build a web game</li><li>Theme: Halloween</li><li>Single player</li><li>Three winners</li></ul><p>Submit your game by <strong>October 31 at midnight</strong>.<br><a href=\"https://forms.gle/wvqsjndYHvwvq41BA\">Submit your game here</a></p>"},"expires":{"N":"1761937199000"},"ctaText":{"S":"Submit Your Game"},"ctaLink":{"S":"https://forms.gle/wvqsjndYHvwvq41BA"}}

src/lib/checkNotifications.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { ScanCommand } from "@aws-sdk/client-dynamodb";
2+
import { initializeTooling, SessionState, State } from "./state.js";
3+
import type { CNotification } from "./types/notification.js";
4+
import { createModal } from "./modal.js"; // Assume you have a modal utility
5+
6+
export async function checkNotifications() {
7+
8+
const table = "push_notifications";
9+
10+
const now = Date.now();
11+
12+
const documentClient = SessionState.dynamoDBClient;
13+
14+
if (!documentClient || !SessionState.awsReady) {
15+
await initializeTooling();
16+
}
17+
18+
19+
20+
const params = {
21+
TableName: table,
22+
FilterExpression: "expires > :now",
23+
ExpressionAttributeValues: {
24+
":now": { N: now.toString() }
25+
}
26+
};
27+
28+
const command = new ScanCommand(params);
29+
const data = await documentClient?.send(command);
30+
31+
if (data && data.Items && data.Items.length > 0) {
32+
const notifications = data.Items.map(item => {
33+
return {
34+
notification_id: item.id?.S || "",
35+
title: item.title?.S || "",
36+
body: item.body?.S || "",
37+
expires: parseInt(item.expires?.N || "0"),
38+
ctaText: item.cta_text?.S,
39+
ctaLink: item.cta_link?.S
40+
};
41+
});
42+
43+
// Sort by expires ascending
44+
notifications.sort((a, b) => a.expires - b.expires);
45+
46+
handleNotifs(notifications);
47+
}
48+
49+
50+
}
51+
52+
function handleNotifs(notifications: CNotification[]) {
53+
notifications.forEach(notif => {
54+
if (State.seenNotifications.includes(notif.notification_id)) {
55+
return; // Already seen
56+
}
57+
State.seenNotifications.push(notif.notification_id);
58+
createModal({
59+
title: notif.title,
60+
content: notif.body,
61+
actions: [
62+
...(notif.ctaText && notif.ctaLink
63+
? [{
64+
label: notif.ctaText,
65+
onClick: () => window.open(notif.ctaLink, "_blank")
66+
}]
67+
: []),
68+
{ label: "Close", onClick: (modal) => modal.close() },
69+
],
70+
});
71+
});
72+
}

src/lib/components/Navigation.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import { page } from "$app/state";
33
let links = [
4+
["Notifications", "/notifications"],
45
["Tab Cloaker", "/tab-cloaker"],
56
[
67
"Master Doc",

src/lib/modal.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
export interface ModalAction {
2+
label: string;
3+
onClick: (modalApi: { close: () => void }) => void;
4+
}
5+
6+
export interface ModalOptions {
7+
title: string;
8+
content: string;
9+
actions: ModalAction[];
10+
}
11+
export function createModal(options: ModalOptions): { close: () => void } {
12+
// Create the overlay
13+
const overlay = document.createElement("div");
14+
overlay.classList.add("modal-overlay");
15+
16+
// Create the modal
17+
const modal = document.createElement("div");
18+
modal.classList.add("modal");
19+
20+
const title = document.createElement("h2");
21+
title.textContent = options.title;
22+
modal.appendChild(title);
23+
24+
const content = document.createElement("p");
25+
// Use innerHTML to allow HTML content
26+
content.innerHTML = options.content;
27+
modal.appendChild(content);
28+
29+
const actionsContainer = document.createElement("div");
30+
actionsContainer.classList.add("modal-actions");
31+
options.actions.forEach((action, index) => {
32+
const button = document.createElement("button");
33+
button.textContent = action.label;
34+
// Add primary class to first button, secondary to others
35+
button.classList.add(index === 0 ? "primary" : "secondary");
36+
button.onclick = () => action.onClick(modalApi);
37+
actionsContainer.appendChild(button);
38+
});
39+
modal.appendChild(actionsContainer);
40+
41+
// Append modal to overlay, then overlay to body
42+
overlay.appendChild(modal);
43+
document.body.appendChild(overlay);
44+
45+
// Close modal when clicking overlay (but not the modal itself)
46+
overlay.addEventListener('click', (e) => {
47+
if (e.target === overlay) {
48+
modalApi.close();
49+
}
50+
});
51+
52+
// Close modal on Escape key
53+
const handleEscape = (e: KeyboardEvent) => {
54+
if (e.key === 'Escape') {
55+
modalApi.close();
56+
}
57+
};
58+
document.addEventListener('keydown', handleEscape);
59+
60+
const modalApi = {
61+
close: () => {
62+
document.removeEventListener('keydown', handleEscape);
63+
overlay.remove();
64+
}
65+
};
66+
67+
return modalApi;
68+
}

src/lib/state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type StateType = {
3131
currentServer: Server;
3232
homeView: "grid" | "list";
3333
pinnedGames: string[];
34+
seenNotifications: string[];
3435
games: Game[];
3536
isAHost: () => boolean;
3637
localPlays: number;
@@ -64,6 +65,7 @@ export const State = createState({
6465
homeView: "grid",
6566
pinnedGames: [],
6667
games: [],
68+
seenNotifications: [],
6769
isAHost: () => (AHosts.some((h): boolean => browser && h.hostname === new URL(page.url).hostname)),
6870
localPlays: 0
6971
});

src/lib/types/notification.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export interface CNotification {
2+
notification_id: string;
3+
expires: number;
4+
title: string;
5+
body: string;
6+
ctaText?: string;
7+
ctaLink?: string;
8+
}

0 commit comments

Comments
 (0)