generated from nhcarrigan/template
feat: client and server logic to manage announcements #3
@ -28,6 +28,7 @@
|
||||
"@angular/forms": "20.0.6",
|
||||
"@angular/platform-browser": "20.0.6",
|
||||
"@angular/router": "20.0.6",
|
||||
"ngx-markdown": "20.0.0",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1",
|
||||
"zone.js": "0.15.1"
|
||||
|
38
client/src/app/announcements.ts
Normal file
38
client/src/app/announcements.ts
Normal 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";
|
||||
}>;
|
||||
}
|
||||
}
|
37
client/src/app/announcements/announcements.css
Normal file
37
client/src/app/announcements/announcements.css
Normal 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;
|
||||
}
|
20
client/src/app/announcements/announcements.html
Normal file
20
client/src/app/announcements/announcements.html
Normal 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>
|
39
client/src/app/announcements/announcements.ts
Normal file
39
client/src/app/announcements/announcements.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Routes } from "@angular/router";
|
||||
import { Announcements } from "./announcements/announcements.js";
|
||||
import { Home } from "./home/home.js";
|
||||
import { Products } from "./products/products.js";
|
||||
import { Soon } from "./soon/soon.js";
|
||||
@ -12,5 +13,6 @@ import { Soon } from "./soon/soon.js";
|
||||
export const routes: Routes = [
|
||||
{ component: Home, path: "", pathMatch: "full" },
|
||||
{ component: Products, path: "products" },
|
||||
{ component: Announcements, path: "announcements" },
|
||||
{ component: Soon, path: "**" },
|
||||
];
|
||||
|
@ -4,6 +4,11 @@ ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::ng-deep main{
|
||||
overflow: hidden !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#one {
|
||||
transform: translateY(-200vh);
|
||||
animation: slide-down 2s forwards;
|
||||
@ -35,9 +40,14 @@ ul {
|
||||
animation: slide-right 2s forwards 10s;
|
||||
}
|
||||
|
||||
#seven {
|
||||
transform: translateX(-200vw);
|
||||
animation: slide-left 2s forwards 12s;
|
||||
}
|
||||
|
||||
#fade {
|
||||
opacity: 0;
|
||||
animation: fade-in 2s forwards 12s;
|
||||
animation: fade-in 2s forwards 14s;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
|
@ -7,12 +7,14 @@
|
||||
<p id="one">How may I help you today?</p>
|
||||
<p id="two">I can assist you with:</p>
|
||||
<ul>
|
||||
<li id="three">Finding a product to suit your needs</li>
|
||||
<li id="four">Manage your account, subscriptions, and licenses</li>
|
||||
<li id="five">Modifying settings for individual products</li>
|
||||
<li id="six">Answering your specific questions with a chat assistant</li>
|
||||
<li id="three">Checking the latest updates.</li>
|
||||
<li id="four">Finding a product to suit your needs</li>
|
||||
<li id="five">Manage your account, subscriptions, and licenses</li>
|
||||
<li id="six">Modifying settings for individual products</li>
|
||||
<li id="seven">Answering your specific questions with a chat assistant</li>
|
||||
</ul>
|
||||
<div id="fade">
|
||||
<a routerLink="/announcements" class="btn">View Announcements</a>
|
||||
<a routerLink="/products" class="btn">Browse Products</a>
|
||||
<a routerLink="/account" class="btn">Manage Account</a>
|
||||
<a routerLink="/settings" class="btn">Modify Settings</a>
|
||||
|
@ -5,6 +5,8 @@
|
||||
></a
|
||||
>
|
||||
<div [class]="dropdownClass">
|
||||
<a routerLink="/announcements" class="nav-link">Announcements</a>
|
||||
<hr />
|
||||
<a routerLink="/products" class="nav-link">Products</a>
|
||||
<hr />
|
||||
<a routerLink="/account" class="nav-link">Account</a>
|
||||
|
1191
pnpm-lock.yaml
generated
1191
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -16,6 +16,7 @@
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "11.0.1",
|
||||
"@nhcarrigan/logger": "1.0.0",
|
||||
"@prisma/client": "6.11.1",
|
||||
"fastify": "5.4.0"
|
||||
|
@ -17,11 +17,13 @@ export const corsHook: onRequestHookHandler = async(request, response) => {
|
||||
if (!request.url.startsWith("/submit")) {
|
||||
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.
|
||||
status(403).
|
||||
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;
|
||||
|
@ -4,6 +4,7 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import cors from "@fastify/cors";
|
||||
import fastify from "fastify";
|
||||
import { corsHook } from "./hooks/cors.js";
|
||||
import { ipHook } from "./hooks/ips.js";
|
||||
@ -15,6 +16,17 @@ const server = fastify({
|
||||
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", ipHook);
|
||||
|
||||
|
@ -28,7 +28,14 @@ export const announcementRoutes: FastifyPluginAsync = async(server) => {
|
||||
take: 10,
|
||||
});
|
||||
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.
|
||||
|
Reference in New Issue
Block a user