12 Commits

Author SHA1 Message Date
2103a02a84 chore: naomi fucked it up
All checks were successful
Node.js CI / Lint and Test (pull_request) Successful in 1m48s
2025-07-05 19:24:18 -07:00
cee061c950 chore: only log server live when in prod
All checks were successful
Node.js CI / Lint and Test (pull_request) Successful in 1m44s
2025-07-05 19:23:49 -07:00
4fbd5c5d76 fix: cors logic
All checks were successful
Node.js CI / Lint and Test (pull_request) Successful in 1m10s
2025-07-05 19:22:19 -07:00
3b2d6729f3 fix: resolve linter errors
All checks were successful
Node.js CI / Lint and Test (pull_request) Successful in 1m11s
2025-07-05 19:18:10 -07:00
64cbb5ee9d fix: generate db schema in workflow
Some checks failed
Node.js CI / Lint and Test (pull_request) Failing after 49s
2025-07-05 19:12:01 -07:00
9842f49fec feat: render announcements on dashboard
Some checks failed
Node.js CI / Lint and Test (pull_request) Failing after 53s
2025-07-05 19:09:05 -07:00
4ca9042bcd feat: add cache to block IPs from brute force attempts 2025-07-05 16:09:36 -07:00
42bad8c6c8 feat: set up announcement and cors logic for server 2025-07-05 15:43:23 -07:00
a12f2b0315 feat: routerlinks
All checks were successful
Node.js CI / Lint and Test (push) Successful in 56s
2025-07-04 20:21:39 -07:00
13dfd66800 fix: file extension?
All checks were successful
Node.js CI / Lint and Test (push) Successful in 59s
2025-07-04 20:15:35 -07:00
6e8c048e25 feat: build out project dashboard (#2)
All checks were successful
Node.js CI / Lint and Test (push) Successful in 57s
### Explanation

This creates an interactive product directory to help potential consumers discover our works.

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #2
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2025-07-04 20:05:20 -07:00
6b19de55f2 feat: scaffold application (#1)
All checks were successful
Node.js CI / Lint and Test (push) Successful in 56s
### Explanation

_No response_

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

_No response_

Reviewed-on: #1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
2025-07-04 13:38:32 -07:00
63 changed files with 12546 additions and 11 deletions

41
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,41 @@
name: Node.js CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint and Test
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v24
uses: actions/setup-node@v4
with:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install Dependencies
run: pnpm install
- name: Generate Database Schema
run: cd server && pnpm prisma generate
- name: Lint Source Files
run: pnpm run lint
- name: Verify Build
run: pnpm run build
- name: Run Tests
run: pnpm run test

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
**/node_modules
/node_modules
.turbo

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"],
}

View File

@ -1,20 +1,17 @@
# New Repository Template # Hikari
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository. Hikari is our centralised platform for managing things like:
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose. - Your user account and information
- Your subscriptions to our products
- Your licenses for our products
- Configurations for some of our products (such as our Discord bots)
## Readme She also offers a paid AI agent to assist with our products and support queries.
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
<!-- # Project Name
Project Description
## Live Version ## Live Version
This page is currently deployed. [View the live website.] This page is currently deployed. [View the live website.](https://hikari.nhcarrigan.com)
## Feedback and Bugs ## Feedback and Bugs

17
client/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
client/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

4
client/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
client/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
client/.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
client/README.md Normal file
View File

@ -0,0 +1,59 @@
# Client
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.0.4.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

76
client/angular.json Normal file
View File

@ -0,0 +1,76 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/client",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
}
}
}
},
"cli": {
"analytics": "9b43c6dc-600f-45e2-9c3a-bcdd024a3346"
}
}

22
client/eslint.config.js Normal file
View File

@ -0,0 +1,22 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig,
{
rules: {
"no-console": "off",
"new-cap": "off",
"@typescript-eslint/naming-convention": "off",
"jsdoc/require-jsdoc": "off",
"jsdoc/require-param": "off",
"jsdoc/require-returns": "off",
"@typescript-eslint/no-useless-constructor": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/consistent-type-assertions": "off",
"@typescript-eslint/no-extraneous-class": "off",
"stylistic/no-multi-spaces": "off",
"unicorn/filename-case": "off",
"@typescript-eslint/consistent-type-imports": "off",
},
},
];

49
client/package.json Normal file
View File

@ -0,0 +1,49 @@
{
"name": "client",
"version": "0.0.0",
"type": "module",
"scripts": {
"ng": "ng",
"dev": "ng dev",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "echo 'No tests yet' && exit 0",
"lint": "eslint ./src --max-warnings 0"
},
"prettier": {
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "20.0.6",
"@angular/compiler": "20.0.6",
"@angular/core": "20.0.6",
"@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"
},
"devDependencies": {
"@angular/build": "20.0.5",
"@angular/cli": "20.0.5",
"@angular/compiler-cli": "20.0.6",
"@types/jasmine": "5.1.8",
"jasmine-core": "5.8.0",
"karma": "6.4.4",
"karma-chrome-launcher": "3.2.0",
"karma-coverage": "2.2.1",
"karma-jasmine": "5.1.0",
"karma-jasmine-html-reporter": "2.1.0",
"typescript": "5.8.3"
}
}

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

@ -0,0 +1,21 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
provideZoneChangeDetection,
} from "@angular/core";
import { provideRouter } from "@angular/router";
import { routes } from "./app.routes.js";
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
],
};

0
client/src/app/app.css Normal file
View File

4
client/src/app/app.html Normal file
View File

@ -0,0 +1,4 @@
<app-nav></app-nav>
<main>
<router-outlet></router-outlet>
</main>

View File

@ -0,0 +1,18 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
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";
export const routes: Routes = [
{ component: Home, path: "", pathMatch: "full" },
{ component: Products, path: "products" },
{ component: Announcements, path: "announcements" },
{ component: Soon, path: "**" },
];

19
client/src/app/app.ts Normal file
View File

@ -0,0 +1,19 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component } from "@angular/core";
import { RouterOutlet } from "@angular/router";
import { Nav } from "./nav/nav.js";
@Component({
imports: [ RouterOutlet, Nav ],
selector: "app-root",
styleUrl: "./app.css",
templateUrl: "./app.html",
})
export class App {
protected title = "client";
}

View File

@ -0,0 +1,473 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable stylistic/max-len -- we are going to have long descriptions here. */
/* eslint-disable max-lines -- Big ol' config!*/
export const products: Array<{
name: string;
description: string;
url: string | null;
wip: boolean;
category: "community" | "websites" | "apps";
premium: boolean;
avatar: string | null;
}> = [
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/rosalia.png",
category: "websites",
description:
"Our global logging server, which pipes logs from all of our apps into a Discord webhook and our email inbox.",
name: "Rosalia Nightsong",
premium: false,
url: "https://rosalia.nhcarrigan.com",
wip: false,
},
{
avatar: null,
category: "websites",
description:
"Our self-hosted LibreTranslate instance, which powers some of our apps and is available for subscribers.",
name: "Translation Service",
premium: true,
url: "https://trans.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/aria.png",
category: "community",
description:
"A user-installable bot that allows you to translate any message into your preferred language.",
name: "Aria Iuvo",
premium: true,
url: "https://aria.nhcarrigan.com/",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/becca.png",
category: "community",
description:
"A user-installable Discord app that facilitates a solo Dungeons and Dragons experience in private messages.",
name: "Becca Lyria",
premium: true,
url: "https://becca.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/cordelia.png",
category: "community",
description:
"A user-installable Discord app that allows you to ask questions, generate alt text for images, evaluate code, and more.",
name: "Cordelia Taryne",
premium: true,
url: "https://cordelia.nhcarrigan.com/",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/gwen.png",
category: "community",
description: "A ticketing system for Discord servers.",
name: "Gwen Abalise",
premium: true,
url: "https://gwen.nhcarrigan.com/",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/maylin.png",
category: "community",
description: "A helpful and supportive Discord bot that allows you to have conversations with a virtual friend in private messages.",
name: "Maylin Taryne",
premium: true,
url: "https://maylin.nhcarrigan.com/",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/melody.png",
category: "community",
description: "A user-installable task management application for Discord.",
name: "Melody Iuvo",
premium: true,
url: "https://melody.nhcarrigan.com/",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/beccalia.png",
category: "apps",
description: "Originally planned as the story of Becca and Rosalia growing up, this game was only released as a demo.",
name: "Beccalia: Origins",
premium: false,
url: "https://beccalia.nhcarrigan.com/origins",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/beccalia.png",
category: "apps",
description: "An introductory story that sets the stage for the Beccalia universe, featuring Becca and Rosalia.",
name: "Beccalia: Prologue",
premium: false,
url: "https://beccalia.nhcarrigan.com/prologue",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/profile.png",
category: "apps",
description: "A quick game that introduces who Naomi is, and provides a glimpse into her life.",
name: "Life of a Naomi",
premium: false,
url: "https://loan.nhcarrigan.com",
wip: false,
},
{
avatar: null,
category: "apps",
description: "A game developed for our friend Ruu's game jam.",
name: "Ruu's Goblin Quest",
premium: false,
url: "https://goblin.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/profile.png",
category: "websites",
description: "The personal musings of our founder, Naomi Carrigan.",
name: "Naomi's Blog",
premium: false,
url: "https://blog.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/nymira.png",
category: "websites",
description: "A service that allows you to claim a custom <username>.naomi.party username for Bluesky.",
name: "Nymira",
premium: true,
url: "https://naomi.party",
wip: true,
},
{
avatar: null,
category: "websites",
description: "A website outlining our policies, legal agreements, community rules, and product information.",
name: "NHCarrigan Documentation",
premium: false,
url: "https://docs.nhcarrigan.com",
wip: true,
},
{
avatar: null,
category: "websites",
description: "A self-hosted Discourse instance for our community.",
name: "Fourm",
premium: false,
url: "https://forum.nhcarrigan.com",
wip: false,
},
{
avatar: null,
category: "websites",
description: "A self-hosted Gitea instance to hold all of our source code.",
name: "Gitea",
premium: false,
url: "https://git.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/hikari.png",
category: "websites",
description: "This dashboard!",
name: "Hikari",
premium: false,
url: "https://hikari.nhcarrigan.com",
wip: true,
},
{
avatar: null,
category: "community",
description: "A Discord, Slack, and Bluesky bot that provides you motherly love and encouragement.",
name: "Mommy Bot",
premium: false,
url: "https://mommy-bot.nhcarrigan.com",
wip: false,
},
{
avatar: null,
category: "websites",
description: "A quick web app that provides you motherly love and encouragements.",
name: "Mommy",
premium: false,
url: "https://mommy.nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/lucinda.png",
category: "websites",
description: "A kanban-style task management site.",
name: "Lucinda",
premium: false,
url: "https://lucinda.nhcarrigan.com",
wip: false,
},
{
avatar: null,
category: "websites",
description: "Our homepage and marketing landing.",
name: "NHCarrigan",
premium: false,
url: "https://nhcarrigan.com",
wip: false,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/vitalia.png",
category: "websites",
description: "A full-featured nutrition tracker with community-driven nutrient data.",
name: "Vitalia",
premium: true,
url: "https://vitalia.nhcarrigan.com",
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/octavia.png",
category: "apps",
description: "Linux-native music player application with a focus on handling large libraries with minimal memory.",
name: "Octavia",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/maribelle.png",
category: "community",
description: "A Discord bot that allows you to configure daily progress huddle reminders for your server members.",
name: "Maribelle",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/sorielle.png",
category: "community",
description: "A Discord bot that allows servers to specify a venting channel for automatic deletion.",
name: "Sorielle",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/verena.png",
category: "community",
description: "A Discord bot that allows identity and age verification.",
name: "Verena",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/thalassa.png",
category: "apps",
description: "A rich presence application for Linux.",
name: "Thalassa",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/aeris.png",
category: "websites",
description: "An authentication service featuring magic links and support for multiple social media platforms",
name: "Aeris",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/liora.png",
category: "community",
description: "A Discord bot that allows your server members to specify 'highlight' words, which they'll get pinged on if a message contains that word.",
name: "Liora",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/thessalia.png",
category: "community",
description: "An RPG game on Discord",
name: "Thessalia",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/callista.png",
category: "community",
description: "A user-installable Discord bot that allows you to bookmark messages and save a link and copy in your DMs.",
name: "Callista",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/isolda.png",
category: "apps",
description: "Modern, sleek email client for the web or desktop",
name: "Isolda",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/meliora.png",
category: "websites",
description: "Embeddable chat widget, comment section, and full support flow utility.",
name: "Meliora",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/aurelia.png",
category: "websites",
description: "Blogging platform with markdown editor",
name: "Aurelia",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/eirene.png",
category: "community",
description: "Website and Discord activity that allows you to participate in code challenges competitively or collaboratively",
name: "Eirene",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/amirei.png",
category: "websites",
description: "A quick social link aggregator for 'link in bio' pages.",
name: "Amirei",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/zephra.png",
category: "websites",
description: "Microblogging social media platform.",
name: "Zephra",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/oriana.png",
category: "websites",
description: "Uptime monitoring tool with status pages",
name: "Oriana",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/lyra.png",
category: "websites",
description: "A web-based API mocking tool, allowing you to create temporary endpoints for a front-end to hit, test webhook payloads, and more!",
name: "Lyra",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/selene.png",
category: "apps",
description: "A local-only privacy-focused REST API client.",
name: "Selene",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/sybil.png",
category: "community",
description: "A Discord bot that syndicates forum threads to an indexable website and generates help articles based on resolved conversations.",
name: "Sybil",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/calenelle.png",
category: "websites",
description: "A group coordination app with event scheduling and such.",
name: "Calenelle",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/rowena.png",
category: "websites",
description: "Web app that allows you to create and share forms, and track responses in a user friendly table.",
name: "Rowena",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/alouette.png",
category: "websites",
description: "A web server that allows you to set up arbitrary webhooks and format them to post on Discord.",
name: "Alouette",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/clarion.png",
category: "community",
description: "A Discord bot with dashboard that allows server mangers to post and edit announcements, rules, and similar.",
name: "Clarion",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/elowyn.png",
category: "websites",
description: "A quick website that helps you format text.",
name: "Elowyn",
premium: false,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/evangeline.png",
category: "community",
description: "A Discord bot that allows you to configure canned replies, retrieve them anywhere on discord, and easily copy + paste them into chat.",
name: "Evangeline",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/theodora.png",
category: "community",
description: "A Discord bot that generates 100 days of code reminders.",
name: "Theodora",
premium: true,
url: null,
wip: true,
},
{
avatar: "https://cdn.nhcarrigan.com/new-avatars/vivienne.png",
category: "websites",
description: "An RSS feed reader/management site.",
name: "Vivienne",
premium: true,
url: null,
wip: true,
},
];

View File

@ -0,0 +1,104 @@
ul {
list-style: none;
padding: 0;
margin: 0;
}
::ng-deep main{
overflow: hidden !important;
max-width: 100%;
}
#one {
transform: translateY(-200vh);
animation: slide-down 2s forwards;
font-size: 1.3rem;
}
#two {
transform: translateY(200vh);
animation: slide-up 2s forwards 2s;
}
#three {
transform: translateX(-200vw);
animation: slide-left 2s forwards 4s;
}
#four {
transform: translateX(200vw);
animation: slide-right 2s forwards 6s;
}
#five {
transform: translateX(-200vw);
animation: slide-left 2s forwards 8s;
}
#six {
transform: translateX(200vw);
animation: slide-right 2s forwards 10s;
}
#seven {
transform: translateX(-200vw);
animation: slide-left 2s forwards 12s;
}
#fade {
opacity: 0;
animation: fade-in 2s forwards 14s;
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
flex-wrap: wrap;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: var(--foreground);
color: var(--background);
text-decoration: none;
border-radius: 50px;
border: 2px solid white;
}
.btn:hover {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s, color 0.3s;
}
@keyframes slide-left {
100% { transform: translateX(0%); }
}
@keyframes slide-right {
100% { transform: translateX(0%); }
}
@keyframes slide-up {
100% { transform: translateY(0%); }
}
@keyframes slide-down {
100% { transform: translateY(0%); }
}
@keyframes fade-in {
100% { opacity: 1; }
}
@keyframes background-color {
0% { background-color: var(--foreground); }
100% { background-color: var(--background); }
}
@media screen and (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transform: none !important;
}
}

View File

@ -0,0 +1,22 @@
<h1>Hi there, I'm Hikari~!</h1>
<img
src="https://cdn.nhcarrigan.com/new-avatars/hikari-full.png"
alt="Hikari"
height="250"
/>
<p id="one">How may I help you today?</p>
<p id="two">I can assist you with:</p>
<ul>
<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>
<a routerLink="/chat" class="btn">Chat with Hikari</a>
</div>

View File

@ -0,0 +1,18 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
@Component({
imports: [ RouterModule ],
selector: "app-home",
styleUrl: "./home.css",
templateUrl: "./home.html",
})
export class Home {
}

View File

@ -0,0 +1,65 @@
nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 40px;
color: var(--foreground);
background-color: var(--background);
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 15px;
padding-right: 15px;
}
nav a:not(#logo) {
text-decoration: none;
padding: 0 15px;
font-size: 1.2rem;
}
nav a:hover:not(#logo) {
text-decoration: underline;
color: var(--background);
background-color: var(--foreground);
}
img {
height: 30px;
width: auto;
margin-right: 15px;
}
hr {
width: 100%;
border: none;
border-top: 1px solid var(--foreground);
margin: 0;
}
.dropdown {
display: none;
}
.dropdown.open {
display: flex;
flex-direction: column;
position: absolute;
right: 0;
top: 40px;
background-color: var(--background);
color: var(--foreground);
border: 1px solid var(--foreground);
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
#logo {
display: flex;
align-items: center;
justify-content: center;
cursor: url('https://cdn.nhcarrigan.com/cursors/pointer.cur'), pointer;
text-decoration: none;
font-size: 2rem;
}

View File

@ -0,0 +1,21 @@
<nav>
<a href="/" id="logo"
><img src="https://cdn.nhcarrigan.com/logo.png" alt="Logo" /><span id="name"
>Hikari</span
></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>
<hr />
<a routerLink="/settings" class="nav-link">Settings</a>
<hr />
<a routerLink="/chat" class="nav-link">Chat</a>
<hr />
</div>
<i class="fa-solid fa-bars" *ngIf="!navOpen" (click)="toggleNav()"></i>
<i class="fa-solid fa-times" *ngIf="navOpen" (click)="toggleNav()"></i>
</nav>

29
client/src/app/nav/nav.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
@Component({
imports: [ CommonModule, RouterModule ],
selector: "app-nav",
styleUrl: "./nav.css",
templateUrl: "./nav.html",
})
export class Nav {
public navOpen = false;
public dropdownClass = "dropdown";
public toggleNav(): void {
this.navOpen = !this.navOpen;
if (this.navOpen) {
this.dropdownClass = "dropdown open";
} else {
this.dropdownClass = "dropdown";
}
}
}

View File

@ -0,0 +1,85 @@
a.product {
text-decoration: none;
}
a.product:hover {
background-color: var(--background);
color: var(--foreground);
}
.product:not(a) {
cursor: default;
border: 2px dashed grey;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: var(--foreground);
color: var(--background);
text-decoration: none;
border-radius: 50px;
border: 2px solid white;
font-family: 'OpenDyslexic', monospace;
}
.btn:disabled {
background-color: var(--background);
color: var(--foreground);
}
.btn:hover {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s, color 0.3s;
}
.product {
display: grid;
grid-template-areas: "logo title icon" "logo description icon";
grid-template-columns: 100px 1fr auto;
background-color: var(--foreground);
color: var(--background);
border: 2px solid white;
border-radius: 50px;
margin-left: 10px;
margin-right: 10px;
margin-bottom: 10px;
padding-right: 20px;
align-items: center;
}
.icons {
grid-area: icon;
font-size: 2rem;
display: grid;
grid-template-columns: repeat(2, auto);
gap: 10px;
}
.title {
grid-area: title;
font-size: 1.5rem;
font-weight: bold;
}
.description {
grid-area: description;
font-size: 1rem;
margin-top: 10px;
}
.logo {
grid-area: logo;
width: 100px;
height: 100px;
border-radius: 50%;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
flex-wrap: wrap;
}

View File

@ -0,0 +1,150 @@
<h1>Products</h1>
<img
src="https://cdn.nhcarrigan.com/new-avatars/hikari-thinking-full.png"
alt="Hikari"
height="250"
/>
<p>Excellent! What sort of product are you looking for?</p>
<div class="row">
<button
class="btn"
(click)="selectCategory('community')"
[disabled]="view === 'community' ? true : false"
>
Community Tooling and Integrations
</button>
<button
class="btn"
(click)="selectCategory('websites')"
[disabled]="view === 'websites' ? true : false"
>
Websites and APIs
</button>
<button
class="btn"
(click)="selectCategory('apps')"
[disabled]="view === 'apps' ? true : false"
>
Apps and Games
</button>
<button
class="btn"
(click)="selectCategory('all')"
[disabled]="view === 'all' ? true : false"
>
Show Me Everything!
</button>
</div>
<p>And would you like to apply a filter?</p>
<div class="row">
<button class="btn" (click)="toggleFilter('wip')">
<span *ngIf="filters.wip">Hide</span
><span *ngIf="!filters.wip">Show</span> WIP
</button>
<button class="btn" (click)="toggleFilter('prod')">
<span *ngIf="filters.prod">Hide</span
><span *ngIf="!filters.prod">Show</span> Production
</button>
<button class="btn" (click)="toggleFilter('paid')">
<span *ngIf="filters.paid">Hide</span
><span *ngIf="!filters.paid">Show</span> Paid
</button>
<button class="btn" (click)="toggleFilter('free')">
<span *ngIf="filters.free">Hide</span
><span *ngIf="!filters.free">Show</span> Free
</button>
</div>
<hr />
<p *ngIf="products.length === 0">
Oh dear, it appears there are no products in this category yet! Please check
back later.
</p>
<div id="products">
<div *ngFor="let product of products">
<!-- Render as <a> if product has a URL -->
<a
*ngIf="product.url"
[class]="product.wip ? 'product wip' : 'product'"
[href]="product.url"
target="_blank"
>
<h2 class="title">{{ product.name }}</h2>
<img
class="logo"
[src]="product.avatar ?? 'https://cdn.nhcarrigan.com/logo.png'"
alt="{{ product.name }} Logo"
/>
<p class="description">{{ product.description }}</p>
<div class="icons">
<i
title="Under construction"
*ngIf="product.wip"
class="fa-solid fa-wrench"
style="color: rgb(141, 23, 23)"
></i>
<i
title="Production Ready"
*ngIf="!product.wip"
class="fa-solid fa-check"
style="color: rgb(31, 117, 19)"
></i>
<i
title="Requires Subscription"
*ngIf="product.premium"
class="fa-solid fa-money-bill-1-wave"
style="color: rgb(145, 129, 40)"
></i>
<i
title="Free to Use"
*ngIf="!product.premium"
class="fa-solid fa-piggy-bank"
style="color: rgb(116, 37, 206)"
></i>
</div>
</a>
<!-- Render as <div> if no URL -->
<div *ngIf="!product.url" [class]="product.wip ? 'product wip' : 'product'">
<h2 class="title">{{ product.name }}</h2>
<img
class="logo"
[src]="product.avatar ?? 'https://cdn.nhcarrigan.com/logo.png'"
alt="{{ product.name }} Logo"
/>
<p class="description">{{ product.description }}</p>
<div *ngIf="product.wip || product.premium" class="icons">
<i
title="Under construction"
*ngIf="product.wip"
class="fa-solid fa-wrench"
style="color: rgb(141, 23, 23)"
></i>
<i
title="Production Ready"
*ngIf="!product.wip"
class="fa-solid fa-check"
style="color: rgb(31, 117, 19)"
></i>
<i
title="Requires Subscription"
*ngIf="product.premium"
class="fa-solid fa-money-bill-1-wave"
style="color: rgb(145, 129, 40)"
></i>
<i
title="Free to Use"
*ngIf="!product.premium"
class="fa-solid fa-piggy-bank"
style="color: rgb(116, 37, 206)"
></i>
</div>
</div>
</div>
</div>
<hr />
<a
href="https://forms.nhcarrigan.com/form/XRlQjeu8CbMrTA-v0IPOxlUPEPitLKXTWg70UUCIORA"
target="_blank"
class="btn"
>I want something custom...</a
>

View File

@ -0,0 +1,78 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { products } from "../config/products.js";
@Component({
imports: [ CommonModule ],
selector: "app-products",
styleUrl: "./products.css",
templateUrl: "./products.html",
})
export class Products {
public view: (typeof products)[number]["category"] | "all"
= "all";
public products: typeof products = [];
public readonly filters: {
wip: boolean;
prod: boolean;
paid: boolean;
free: boolean;
} = {
free: true,
paid: true,
prod: true,
wip: true,
};
public constructor() {
this.selectCategory("all");
}
public selectCategory(
category: (typeof products)[number]["category"] | "all",
): void {
this.view = category;
const sortedProducts = products.sort((a, b) => {
return a.name.localeCompare(b.name);
});
if (this.view === "all") {
this.products = sortedProducts;
return;
}
this.products = sortedProducts.filter((product) => {
return product.category === this.view;
});
}
public toggleFilter(
filter: "wip" | "prod" | "paid" | "free",
): void {
this.filters[filter] = !this.filters[filter];
this.applyFilters();
}
private applyFilters(): void {
this.selectCategory(this.view);
this.products = this.products.filter((product) => {
if (!this.filters.wip && product.wip) {
return false;
}
if (!this.filters.prod && !product.wip) {
return false;
}
if (!this.filters.paid && product.premium) {
return false;
}
if (!this.filters.free && !product.premium) {
return false;
}
return true;
});
}
}

View File

@ -0,0 +1,15 @@
.btn {
display: inline-block;
padding: 10px 20px;
background-color: var(--foreground);
color: var(--background);
text-decoration: none;
border-radius: 50px;
border: 2px solid white;
}
.btn:hover {
background-color: var(--background);
color: var(--foreground);
transition: background-color 0.3s, color 0.3s;
}

View File

@ -0,0 +1,16 @@
<h1>Oh dear~!</h1>
<img
src="https://cdn.nhcarrigan.com/new-avatars/hikari-cry-full.png"
alt="Hikari"
height="250"
/>
<p>You appear to have become lost!</p>
<p>
Either this feature is still under construction, or you have tried to go
somewhere that does not exist.
</p>
<p>Do not worry, I can guide you back. Where would you like to go?</p>
<div id="fade">
<a href="javascript:history.back()" class="btn">Take me back!</a>
<a href="/" class="btn">Take me home!</a>
</div>

View File

@ -0,0 +1,17 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Component } from "@angular/core";
@Component({
imports: [],
selector: "app-soon",
styleUrl: "./soon.css",
templateUrl: "./soon.html",
})
export class Soon {
}

24
client/src/index.html Normal file
View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hikari</title>
<meta
name="description"
content="Dashboard and account management platform for NHCarrigan's products."
/>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
<script src="https://cdn.nhcarrigan.com/headers/index.js"></script>
<script>
const styleElement = document.getElementById("nhcarrigan-global-styles");
if (styleElement) {
styleElement.remove();
}
</script>
</html>

15
client/src/main.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { bootstrapApplication } from "@angular/platform-browser";
import { appConfig } from "./app/app.config.js";
import { App } from "./app/app.js";
bootstrapApplication(App, appConfig).
// eslint-disable-next-line unicorn/prefer-top-level-await -- Angular wonky
catch((error: unknown) => {
console.error(error);
});

102
client/src/styles.css Normal file
View File

@ -0,0 +1,102 @@
@font-face {
font-family: 'OpenDyslexic';
src: url('https://cdn.nhcarrigan.com/fonts/OpenDyslexicMono-Regular.otf') format('opentype');
}
:root {
--foreground: #2a0a18;
--background: #ffb6c1bb;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: 'OpenDyslexic', monospace;
cursor: url('https://cdn.nhcarrigan.com/cursors/cursor.cur'), auto;
min-height: 100vh;
min-width: 100vw;
}
body::before {
background: url(https://cdn.nhcarrigan.com/background.png);
background-size: cover;
background-position: center;
width: 100%;
height: 100%;
z-index: -1;
content: "";
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 1;
pointer-events: none;
}
main {
color: var(--foreground);
background-color: var(--background);
text-align: center;
border-radius: 10px;
width: 100vw;
margin-bottom: 85px;
margin-top: 50px;
min-height: calc(100vh - 85px - 50px);
}
footer {
width: 100%;
color: var(--foreground);
background-color: var(--background);
position: fixed;
bottom: 0;
height: 75px;
padding: 0 10px;
}
#footer-inner-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 75px;
}
#footer-badge-container {
display: grid;
grid-template-columns: repeat(8, 1fr);
align-items: center;
justify-content: space-around;
}
#audio-theme-button, #theme-select-button {
background: none;
border: none;
cursor: url('https://cdn.nhcarrigan.com/cursors/pointer.cur'), pointer;
color: var(--foreground);
}
a {
color: unset;
cursor: url('https://cdn.nhcarrigan.com/cursors/pointer.cur'), pointer;
}
.btn:not(:disabled) {
cursor: url('https://cdn.nhcarrigan.com/cursors/pointer.cur'), pointer;
}
#tree-nation-offset-website {
display: flex;
align-items: center;
}
.is-dark {
--foreground: #ffb6c1;
--background: #2a0a18bb;
}
@media screen and (max-width: 625px) {
#tree-nation-offset-website {
display: none;
}
footer, #footer-inner-container {
height: 50px;
justify-content: space-around;
}
main {
margin-bottom: 60px;
}
}

27
client/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "hikari",
"version": "0.0.0",
"description": "Dashboard and account management for NHCarrigan",
"main": "index.js",
"type": "module",
"scripts": {
"lint": "turbo lint",
"build": "turbo build",
"dev": "turbo dev",
"test": "turbo test"
},
"keywords": [],
"author": "Naomi Carrigan",
"license": "See license in LICENSE.md",
"packageManager": "pnpm@10.12.3",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"eslint": "9.30.1",
"turbo": "2.5.4",
"typescript": "5.8.3"
}
}

10127
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- client
- server

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
prod

5
server/dev.env Normal file
View File

@ -0,0 +1,5 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri"
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token"
FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key"
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"

18
server/eslint.config.js Normal file
View File

@ -0,0 +1,18 @@
import NaomisConfig from '@nhcarrigan/eslint-config';
export default [
...NaomisConfig,
{
files: ["src/routes/*.ts"],
rules: {
"max-lines-per-function": "off",
}
},
{
files: ["src/routes/*.ts"],
rules: {
// We turn this off so we can use the async plugin syntax without needing to await.
"@typescript-eslint/require-await": "off",
}
}
]

29
server/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "server",
"version": "0.0.0",
"description": "",
"main": "prod/index.js",
"type": "module",
"scripts": {
"lint": "eslint ./src --max-warnings 0",
"dev": "NODE_ENV=dev op run --env-file=./dev.env -- tsx watch ./src/index.ts",
"build": "tsc",
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
"test": "echo 'No tests yet' && exit 0"
},
"keywords": [],
"author": "",
"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"
},
"devDependencies": {
"@types/node": "24.0.10",
"prisma": "6.11.1",
"tsx": "4.20.3"
}
}

View File

@ -0,0 +1,19 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGO_URI")
}
model Announcements {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content String
type String
createdAt DateTime @default(now()) @unique
}

5
server/prod.env Normal file
View File

@ -0,0 +1,5 @@
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri"
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token"
FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key"
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"

7
server/src/cache/blockedIps.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const blockedIps: Array<{ ip: string; ttl: Date }> = [];

View File

@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* If you want a route to allow any origin for CORS, add
* the full path to this array.
*/
export const routesWithoutCors = [
"/",
"/announcement",
"/health",
];

24
server/src/db/database.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
class Database {
private readonly instance: PrismaClient;
public constructor() {
this.instance = new PrismaClient();
void this.instance.$connect();
}
public getInstance(): PrismaClient {
return this.instance;
}
}
const database = new Database();
export { database };

42
server/src/hooks/cors.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { routesWithoutCors } from "../config/routesWithoutCors.js";
import type { onRequestHookHandler } from "fastify";
const isValidOrigin = (origin: string | undefined): boolean => {
if (origin === undefined) {
// We do not allow server-to-server requests.
return false;
}
if (process.env.NODE_ENV === "dev" && origin === "http://localhost:4200") {
// We allow the client to access the server when both are running locally.
return true;
}
// Otherwise, we only allow requests from our web application.
return origin === "https://hikari.nhcarrigan.com";
};
/**
* Ensures that form submissions only come from our web application.
* @param request - The request payload from the server.
* @param response - The reply handler from Fastify.
* @returns A Fastify reply if the request is invalid, otherwise undefined.
*/
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- For reasons I cannot comprehend, Fastify seems to require us to return a request?
export const corsHook: onRequestHookHandler = async(request, response) => {
if (routesWithoutCors.includes(request.url)) {
return undefined;
}
if (!isValidOrigin(request.headers.origin)) {
return await response.status(403).send({
error:
// eslint-disable-next-line stylistic/max-len -- This is a long error message.
"This route is only accessible from our dashboard at https://hikari.nhcarrigan.com.",
});
}
return undefined;
};

36
server/src/hooks/ips.ts Normal file
View File

@ -0,0 +1,36 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { blockedIps } from "../cache/blockedIps.js";
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
import type { onRequestHookHandler } from "fastify";
/**
* Ensures that form submissions only come from our web application.
* @param request - The request payload from the server.
* @param response - The reply handler from Fastify.
* @returns A Fastify reply if the request is invalid, otherwise undefined.
*/
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- For reasons I cannot comprehend, Fastify seems to require us to return a request?
export const ipHook: onRequestHookHandler = async(request, response) => {
const ip = getIpFromRequest(request);
const ipRecord = blockedIps.find(
(record) => {
return record.ip === ip && record.ttl > new Date();
},
);
if (ipRecord && ipRecord.ttl > new Date()) {
return await response.
status(403).
send({
error: `Your IP address (${ipRecord.ip}) has been blocked until ${ipRecord.ttl.toISOString()}, to protect our API against brute-force attacks.`,
});
}
if (ipRecord && ipRecord.ttl <= new Date()) {
blockedIps.splice(blockedIps.indexOf(ipRecord), 1);
}
return undefined;
};

44
server/src/index.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import cors from "@fastify/cors";
import fastify from "fastify";
import { corsHook } from "./hooks/cors.js";
import { ipHook } from "./hooks/ips.js";
import { announcementRoutes } from "./routes/announcement.js";
import { baseRoutes } from "./routes/base.js";
import { logger } from "./utils/logger.js";
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);
server.register(baseRoutes);
server.register(announcementRoutes);
server.listen({ port: 20_000 }, (error) => {
if (error) {
void logger.error("instantiate server", error);
return;
}
if (process.env.NODE_ENV !== "dev") {
void logger.log("debug", "Server listening on port 20000.");
}
});

View File

@ -0,0 +1,65 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
const channelIds = {
community: "1386105484313886820",
products: "1386105452881776661",
} as const;
const roleIds = {
community: "1386107941224054895",
products: "1386107909699666121",
} as const;
/**
* Forwards an announcement to our Discord server.
* @param title - The title of the announcement.
* @param content - The main body of the announcement.
* @param type - Whether the announcement is for a product or community.
* @returns A message indicating the success or failure of the operation.
*/
export const announceOnDiscord = async(
title: string,
content: string,
type: "products" | "community",
): Promise<string> => {
const messageRequest = await fetch(
`https://discord.com/api/v10/channels/${channelIds[type]}/messages`,
{
body: JSON.stringify({
allowed_mentions: { parse: [ "users", "roles" ] },
content: `# ${title}\n\n${content}\n-# <@&${roleIds[type]}>`,
}),
headers: {
"Authorization": `Bot ${process.env.DISCORD_TOKEN ?? ""}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (messageRequest.status !== 200) {
return "Failed to send message to Discord.";
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- fetch does not accept generics.
const message = await messageRequest.json() as { id?: string };
if (message.id === undefined) {
return "Failed to parse message ID, cannot crosspost.";
}
const crosspostRequest = await fetch(
`https://discord.com/api/v10/channels/${channelIds[type]}/messages/${message.id}/crosspost`,
{
headers: {
"Authorization": `Bot ${process.env.DISCORD_TOKEN ?? ""}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!crosspostRequest.ok) {
return "Failed to crosspost message to Discord.";
}
return "Successfully sent and published message to Discord.";
};

View File

@ -0,0 +1,40 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
/**
* Forwards an announcement to our Discord server.
* @param title - The title of the announcement.
* @param content - The main body of the announcement.
* @param type - Whether the announcement is for a product or community.
* @returns A message indicating the success or failure of the operation.
*/
export const announceOnForum = async(
title: string,
content: string,
type: "products" | "community",
): Promise<string> => {
const forumRequest = await fetch(
`https://forum.nhcarrigan.com/posts.json`,
{
body: JSON.stringify({
category: 14,
raw: content,
tags: [ type ],
title: title,
}),
headers: {
"Api-Key": process.env.FORUM_API_KEY ?? "",
"Api-Username": "Hikari",
"Content-Type": "application/json",
},
method: "POST",
},
);
if (forumRequest.status !== 200) {
return "Failed to send message to forum.";
}
return "Successfully sent message to forum.";
};

View File

@ -0,0 +1,25 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyRequest } from "fastify";
/**
* Parses an IP address from a request, first looking for the
* Cloudflare headers, then falling back to the request IP.
* @param request - The Fastify request object.
* @returns The IP address as a string.
*/
export const getIpFromRequest = (request: FastifyRequest): string => {
const header
= request.headers["X-Forwarded-For"] ?? request.headers["Cf-Connecting-IP"];
if (typeof header === "string") {
return header;
}
if (Array.isArray(header)) {
return header[0] ?? header.join(", ");
}
return request.ip;
};

View File

@ -0,0 +1,110 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { blockedIps } from "../cache/blockedIps.js";
import { database } from "../db/database.js";
import { announceOnDiscord } from "../modules/announceOnDiscord.js";
import { announceOnForum } from "../modules/announceOnForum.js";
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
import type { FastifyPluginAsync } from "fastify";
const oneDay = 24 * 60 * 60 * 1000;
/**
* Mounts the entry routes for the application. These routes
* should not require CORS, as they are used by external services
* such as our uptime monitor.
* @param server - The Fastify server instance.
*/
export const announcementRoutes: FastifyPluginAsync = async(server) => {
server.get("/announcements", async(_request, reply) => {
const announcements = await database.getInstance().announcements.findMany({
orderBy: {
createdAt: "desc",
},
take: 10,
});
return await reply.status(200).type("application/json").
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.
server.post<{ Body: { title: string; content: string; type: string } }>(
"/announcement",
// eslint-disable-next-line complexity -- This is a complex route, but it is necessary to validate the announcement.
async(request, reply) => {
const token = request.headers.authorization;
if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) {
blockedIps.push({
ip: getIpFromRequest(request),
ttl: new Date(Date.now() + oneDay),
});
return await reply.status(401).send({
error:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"This endpoint requires a special auth token. If you believe you should have access, please contact Naomi. To protect our services, your IP has been blocked from all routes for 24 hours.",
});
}
const { title, content, type } = request.body;
if (
typeof title !== "string"
|| typeof content !== "string"
|| typeof type !== "string"
|| title.length === 0
|| content.length === 0
|| type.length === 0
) {
return await reply.status(400).send({
error: "Missing required fields.",
});
}
if (title.length < 20) {
return await reply.status(400).send({
error:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Title must be at least 20 characters long so that it may be posted on our forum.",
});
}
if (content.length < 50) {
return await reply.status(400).send({
error:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Content must be at least 50 characters long so that it may be posted on our forum.",
});
}
if (type !== "products" && type !== "community") {
return await reply.status(400).send({
error: "Invalid announcement type.",
});
}
await database.getInstance().announcements.create({
data: {
content,
title,
type,
},
});
const discord = await announceOnDiscord(title, content, type);
const forum = await announceOnForum(title, content, type);
return await reply.status(201).send({
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}`,
});
},
);
};

23
server/src/routes/base.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
/**
* Mounts the entry routes for the application. These routes
* should not require CORS, as they are used by external services
* such as our uptime monitor.
* @param server - The Fastify server instance.
*/
export const baseRoutes: FastifyPluginAsync = async(server) => {
server.get("/", async(_request, reply) => {
return await reply.redirect("https://hikari.nhcarrigan.com");
});
server.get("/health", async(_request, reply) => {
return await reply.status(200).send("OK~!");
});
};

View File

@ -0,0 +1,12 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Logger } from "@nhcarrigan/logger";
export const logger = new Logger(
"Hikari API",
process.env.LOG_TOKEN ?? "",
);

7
server/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod",
}
}

19
turbo.json Normal file
View File

@ -0,0 +1,19 @@
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^lint", "^test"],
"outputs": ["dist/**", "prod/**"]
},
"test": {
"dependsOn": []
},
"lint": {
"dependsOn": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}