feat: client and server logic to manage announcements #3

Merged
naomi merged 8 commits from feat/announcements into main 2025-07-05 19:27:21 -07:00
26 changed files with 1994 additions and 106 deletions
Showing only changes of commit 9842f49fec - Show all commits

View File

@ -28,6 +28,7 @@
"@angular/forms": "20.0.6", "@angular/forms": "20.0.6",
"@angular/platform-browser": "20.0.6", "@angular/platform-browser": "20.0.6",
"@angular/router": "20.0.6", "@angular/router": "20.0.6",
"ngx-markdown": "20.0.0",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"tslib": "2.8.1", "tslib": "2.8.1",
"zone.js": "0.15.1" "zone.js": "0.15.1"

View File

@ -0,0 +1,38 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable } from "@angular/core";
@Injectable({
providedIn: "root",
})
export class AnnouncementsService {
public constructor() {}
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Getter for static URL.
private get url(): string {
return "http://localhost:20000/announcements";
}
public async getAnnouncements(): Promise<
Array<{
title: string;
content: string;
createdAt: string;
type: "products" | "community";
}>
> {
const response = await fetch(this.url);
if (!response.ok) {
return [];
}
return (await response.json()) as Array<{
title: string;
content: string;
createdAt: string;
type: "products" | "community";
}>;
}
}

View File

@ -0,0 +1,37 @@
hr {
width: 100%;
border: none;
border-top: 1px solid var(--foreground);
margin: 0;
}
:host ::ng-deep ul{
list-style-type: disc;
list-style-position: inside;
}
.announcement {
margin: auto;
margin-bottom: 1em;
width: 90%;
}
.tag {
display: inline-block;
padding: 0 0.5em;
border-radius: 50px;
font-size: 0.8em;
}
.products {
background-color: #e0f7fa;
color: #006064;
}
.community {
background-color: #e8f5e9;
color: #1b5e20;
}
.date {
font-style: italic;
}

View File

@ -0,0 +1,20 @@
<h1>Announcements</h1>
<p>Here are the most recent updates for our products and communities.</p>
<p>
If you want to see the full history, check out our
<a href="https://chat.nhcarrigan.com" target="_blank">chat server</a> or our
<a href="https://forum.nhcarrigan.com" target="_blank">forum</a>.
</p>
<div class="announcement" *ngFor="let announcement of announcements">
<hr />
<h2>{{ announcement.title }}</h2>
<p>
<span [class]="'tag ' + announcement.type">{{announcement.type}}</span>
<span class="date"> {{ announcement.createdAt | date: "mediumDate" }}</span>
</p>
<markdown [data]="announcement.content"></markdown>
<p class="type">Type: {{ announcement.type }}</p>
</div>
<div class="no-announcements" *ngIf="!announcements.length">
<p>There are no announcements at this time.</p>
</div>

View File

@ -0,0 +1,39 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { CommonModule, DatePipe } from "@angular/common";
import { Component, SecurityContext } from "@angular/core";
import { MarkdownComponent, provideMarkdown } from "ngx-markdown";
import { AnnouncementsService } from "../announcements.js";
@Component({
imports: [ CommonModule, DatePipe, MarkdownComponent ],
providers: [ provideMarkdown({ sanitize: SecurityContext.HTML }) ],
selector: "app-announcements",
styleUrl: "./announcements.css",
templateUrl: "./announcements.html",
})
export class Announcements {
public announcements: Array<{
title: string;
content: string;
createdAt: string;
type: "products" | "community";
}> = [];
public constructor(
private readonly announcementsService: AnnouncementsService,
) {
void this.loadAnnouncements();
}
private async loadAnnouncements(): Promise<void> {
const announcements = await this.announcementsService.getAnnouncements();
this.announcements = announcements.sort((a, b) => {
return b.createdAt > a.createdAt
? 1
: -1;
});
}
}

View File

@ -5,6 +5,7 @@
*/ */
import { Routes } from "@angular/router"; import { Routes } from "@angular/router";
import { Announcements } from "./announcements/announcements.js";
import { Home } from "./home/home.js"; import { Home } from "./home/home.js";
import { Products } from "./products/products.js"; import { Products } from "./products/products.js";
import { Soon } from "./soon/soon.js"; import { Soon } from "./soon/soon.js";
@ -12,5 +13,6 @@ import { Soon } from "./soon/soon.js";
export const routes: Routes = [ export const routes: Routes = [
{ component: Home, path: "", pathMatch: "full" }, { component: Home, path: "", pathMatch: "full" },
{ component: Products, path: "products" }, { component: Products, path: "products" },
{ component: Announcements, path: "announcements" },
{ component: Soon, path: "**" }, { component: Soon, path: "**" },
]; ];

View File

@ -4,6 +4,11 @@ ul {
margin: 0; margin: 0;
} }
::ng-deep main{
overflow: hidden !important;
max-width: 100%;
}
#one { #one {
transform: translateY(-200vh); transform: translateY(-200vh);
animation: slide-down 2s forwards; animation: slide-down 2s forwards;
@ -35,9 +40,14 @@ ul {
animation: slide-right 2s forwards 10s; animation: slide-right 2s forwards 10s;
} }
#seven {
transform: translateX(-200vw);
animation: slide-left 2s forwards 12s;
}
#fade { #fade {
opacity: 0; opacity: 0;
animation: fade-in 2s forwards 12s; animation: fade-in 2s forwards 14s;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-evenly; justify-content: space-evenly;

View File

@ -7,12 +7,14 @@
<p id="one">How may I help you today?</p> <p id="one">How may I help you today?</p>
<p id="two">I can assist you with:</p> <p id="two">I can assist you with:</p>
<ul> <ul>
<li id="three">Finding a product to suit your needs</li> <li id="three">Checking the latest updates.</li>
<li id="four">Manage your account, subscriptions, and licenses</li> <li id="four">Finding a product to suit your needs</li>
<li id="five">Modifying settings for individual products</li> <li id="five">Manage your account, subscriptions, and licenses</li>
<li id="six">Answering your specific questions with a chat assistant</li> <li id="six">Modifying settings for individual products</li>
<li id="seven">Answering your specific questions with a chat assistant</li>
</ul> </ul>
<div id="fade"> <div id="fade">
<a routerLink="/announcements" class="btn">View Announcements</a>
<a routerLink="/products" class="btn">Browse Products</a> <a routerLink="/products" class="btn">Browse Products</a>
<a routerLink="/account" class="btn">Manage Account</a> <a routerLink="/account" class="btn">Manage Account</a>
<a routerLink="/settings" class="btn">Modify Settings</a> <a routerLink="/settings" class="btn">Modify Settings</a>

View File

@ -5,6 +5,8 @@
></a ></a
> >
<div [class]="dropdownClass"> <div [class]="dropdownClass">
<a routerLink="/announcements" class="nav-link">Announcements</a>
<hr />
<a routerLink="/products" class="nav-link">Products</a> <a routerLink="/products" class="nav-link">Products</a>
<hr /> <hr />
<a routerLink="/account" class="nav-link">Account</a> <a routerLink="/account" class="nav-link">Account</a>

1191
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"license": "ISC", "license": "ISC",
"packageManager": "pnpm@10.12.3", "packageManager": "pnpm@10.12.3",
"dependencies": { "dependencies": {
"@fastify/cors": "11.0.1",
"@nhcarrigan/logger": "1.0.0", "@nhcarrigan/logger": "1.0.0",
"@prisma/client": "6.11.1", "@prisma/client": "6.11.1",
"fastify": "5.4.0" "fastify": "5.4.0"

View File

@ -17,11 +17,13 @@ export const corsHook: onRequestHookHandler = async(request, response) => {
if (!request.url.startsWith("/submit")) { if (!request.url.startsWith("/submit")) {
return undefined; return undefined;
} }
if (request.headers.origin !== "https://forms.nhcarrigan.com") { if (request.headers.origin !== "http://localhost:4200"
&& request.headers.origin !== "https://hikari.nhcarrigan.com") {
console.log(request);
return await response. return await response.
status(403). status(403).
send({ send({
error: "Forms can only be submitted through our website. Thanks.", error: "This route is only accessible from our dashboard at https://hikari.nhcarrigan.com.",
}); });
} }
return undefined; return undefined;

View File

@ -4,6 +4,7 @@
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import cors from "@fastify/cors";
import fastify from "fastify"; import fastify from "fastify";
import { corsHook } from "./hooks/cors.js"; import { corsHook } from "./hooks/cors.js";
import { ipHook } from "./hooks/ips.js"; import { ipHook } from "./hooks/ips.js";
@ -15,6 +16,17 @@ const server = fastify({
logger: false, logger: false,
}); });
/**
* This needs to be first, to ensure all requests have CORS configured.
* Our CORS settings allow for any origin, because we have a custom hook
* that guards specific routes from CORS requests.
* This is to allow our uptime monitor to access the health check route, for example.
* @see routesWithoutCors.ts
*/
server.register(cors, {
origin: "*",
});
server.addHook("preHandler", corsHook); server.addHook("preHandler", corsHook);
server.addHook("preHandler", ipHook); server.addHook("preHandler", ipHook);

View File

@ -28,7 +28,14 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
take: 10, take: 10,
}); });
return await reply.status(200).type("application/json"). return await reply.status(200).type("application/json").
send(announcements); send(announcements.map((announcement) => {
return {
content: announcement.content,
createdAt: announcement.createdAt,
title: announcement.title,
type: announcement.type,
};
}));
}); });
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body. // eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body.