feat: initial site setup (#1)
Node.js CI / Lint and Test (push) Successful in 48s

### 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: nhcarrigan/yurigpt#1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #1.
This commit is contained in:
2025-07-15 19:12:05 -07:00
committed by Naomi Carrigan
parent e03fa186a2
commit 23243278e4
39 changed files with 9200 additions and 20 deletions
View File
+19
View File
@@ -0,0 +1,19 @@
<h1>YuriGPT</h1>
<p>
YuriGPT is a webcomic about two girls in an online relationship, and all of
the zany antics that stem from that.
</p>
<p>
Because of this, our comics take the format of online chat conversations,
rather than a traditional comic. This may feel strange to you, but we are
hopeful that you come to love the story we tell.
</p>
<p>
We publish a new comic every Monday, and (will soon!) offer an RSS feed so you can use your
favourite application to stay updated.
</p>
<p>
If you love our comic, hate our comic, want to complain about something, or
are just looking for your own companion, we invite you to
<a href="https://chat.nhcarrigan.com" target="_blank">join our community</a>.
</p>
+17
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-about",
styleUrl: "./about.css",
templateUrl: "./about.html",
})
export class About {
}
+21
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, withComponentInputBinding } from "@angular/router";
import { routes } from "./app.routes";
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
],
};
+9
View File
@@ -0,0 +1,9 @@
main {
margin-top: 60px;
}
@media screen and (max-width: 650px) {
main {
margin-top: 120px;
}
}
+4
View File
@@ -0,0 +1,4 @@
<app-nav></app-nav>
<main>
<router-outlet></router-outlet>
</main>
+22
View File
@@ -0,0 +1,22 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Routes } from "@angular/router";
import { About } from "./about/about.js";
import { Archive } from "./archive/archive.js";
import { Characters } from "./characters/characters.js";
import { Comic } from "./comic/comic.js";
import { Home } from "./home/home.js";
export const routes: Routes = [
{ component: Home, path: "", pathMatch: "full" },
{ component: Comic, path: "comic/:id" },
// This line is necessary to handle the fallback when no ID is provided.
{ component: Comic, path: "comic" },
{ component: About, path: "about" },
{ component: Archive, path: "archive" },
{ component: Characters, path: "characters" },
];
+22
View File
@@ -0,0 +1,22 @@
/**
* @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";
/**
* The root component of the application.
*/
@Component({
imports: [ RouterOutlet, Nav ],
selector: "app-root",
styleUrl: "./app.css",
templateUrl: "./app.html",
})
export class App {
protected title = "yurigpt";
}
View File
+11
View File
@@ -0,0 +1,11 @@
<h1>Archive</h1>
<p>Start browsing our comics from the very beginning, or find a comic from a specific day!</p>
<div *ngIf="error">
<p>Error loading comics: {{ error }}</p>
</div>
<div *ngIf="comics.length !== 0">
<div *ngFor="let comic of comics">
<h2><a [routerLink]="['/comic', comic.number]"> {{ comic.title }}</a></h2>
<p>Published on: {{ comic.date }}</p>
</div>
+37
View File
@@ -0,0 +1,37 @@
/**
* @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";
import { ComicService } from "../comic-service.js";
@Component({
imports: [ CommonModule, RouterModule ],
selector: "app-archive",
styleUrl: "./archive.css",
templateUrl: "./archive.html",
})
export class Archive {
public comics: Array<{ number: number; title: string; date: string }> = [];
public error: string | undefined;
public constructor(private readonly comicService: ComicService) {
this.comicService.
loadComics().
then(() => {
this.comics = this.comicService.getComics();
this.error = undefined;
}).
catch((error: unknown) => {
this.error = `Failed to load comics: ${
error instanceof Error
? error.message
: "Please check the browser console for more details."
}`;
console.error("Error loading comics:", error);
});
}
}
+26
View File
@@ -0,0 +1,26 @@
.character {
display: grid;
grid-template-areas:
"image name"
"image description";
grid-template-columns: 250px auto;
}
img {
grid-area: image;
width: 250px;
height: auto;
}
h2 {
grid-area: name;
}
p {
grid-area: description;
}
hr {
border: 2px solid var(--foreground);
margin: 10px 0;
}
+18
View File
@@ -0,0 +1,18 @@
<h1>Characters</h1>
<div class="character">
<h2>Emi</h2>
<img src="https://cdn.yurigpt.com/avatars/emi.png" alt="Emi's avatar">
<p>Our main character, Emi has always struggled to make friends in person. But she thrives online, where she feels she can be her true self.</p>
</div>
<hr />
<div class="character">
<h2>Mira</h2>
<img src="https://cdn.yurigpt.com/avatars/mira.png" alt="Mira's avatar">
<p>Mira is Emi's online girlfriend. They are in a long-distance relationship, so they have not met. This works out great for Emi, but maybe not all is as it seems?</p>
</div>
<hr />
<div class="character">
<h2>Kaede</h2>
<img src="https://cdn.yurigpt.com/avatars/kaede.png" alt="Mira's avatar">
<p>Kaede is Emi's one real IRL friend. Despite being popular, Kaede made time during middle school to really get to know Emi. Since then, the two have had a steadfast friendship.</p>
</div>
+17
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-characters",
styleUrl: "./characters.css",
templateUrl: "./characters.html",
})
export class Characters {
}
+97
View File
@@ -0,0 +1,97 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Injectable } from "@angular/core";
const oneDay = 1000 * 60 * 60 * 24;
@Injectable({
providedIn: "root",
})
export class ComicService {
// eslint-disable-next-line @typescript-eslint/class-literal-property-style -- I should turn this rule off.
private readonly comicsUrl: string = "https://cdn.yurigpt.com/comics.json";
private ttl = 0;
private comics: Array<{
number: number;
title: string;
date: string;
}> = [];
public constructor() {
void this.loadComics();
}
private static isObject(object: unknown): object is Record<string, unknown> {
return typeof object === "object" && object !== null;
}
private static validateComics(
comics: unknown,
): comics is Array<{ number: number; title: string; date: string }> {
if (!Array.isArray(comics)) {
return false;
}
return comics.every((comic) => {
if (!ComicService.isObject(comic)) {
return false;
}
return (
"number" in comic
&& "title" in comic
&& "date" in comic
&& typeof comic["number"] === "number"
&& typeof comic["title"] === "string"
&& typeof comic["date"] === "string"
);
});
}
public async loadComics(): Promise<void> {
if (this.ttl > Date.now()) {
return;
}
this.comics = [];
const response = await fetch(this.comicsUrl);
const data: unknown = await response.json();
if (ComicService.validateComics(data)) {
this.comics = data.sort((a, b) => {
return a.number - b.number;
});
this.ttl = Date.now() + oneDay;
} else {
console.error("Invalid comics data format:", data);
}
}
public getComics(): Array<{ number: number; title: string; date: string }> {
return this.comics;
}
public getComicById(
id: string,
): { number: number; title: string; date: string } | undefined {
const comicId = Number.parseInt(id, 10);
return this.comics.find((comic) => {
return comic.number === comicId;
});
}
public getLatestComic():
| { number: number; title: string; date: string }
| undefined {
return this.comics.at(-1);
}
public getLatestComicId(): string | undefined {
const latestComic = this.getLatestComic();
return latestComic
? latestComic.number.toString()
: undefined;
}
}
+7
View File
@@ -0,0 +1,7 @@
img {
width: 500px;
}
.ng-fa-icon {
font-size: 2rem;
}
+12
View File
@@ -0,0 +1,12 @@
<div *ngIf="comic">
<h2>{{ comic!.title }}</h2>
<p>Published on: {{ comic!.date }}</p>
<img [src]="`https://cdn.yurigpt.com/comics/${comic!.number}.png`" alt="{{ comic!.title }}" />
</div>
<div *ngIf="error">
<p>Error loading comic: {{ error }}</p>
</div>
<div id="buttons">
<a *ngIf="previousComicId" [routerLink]="['/comic', previousComicId]"><fa-icon [icon]="backIcon"></fa-icon></a>
<a *ngIf="nextComicId" [routerLink]="['/comic', nextComicId]"><fa-icon [icon]="forwardIcon"></fa-icon></a>
</div>
+68
View File
@@ -0,0 +1,68 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { CommonModule } from "@angular/common";
import { Component, inject, input, signal } from "@angular/core";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { FontAwesomeModule } from "@fortawesome/angular-fontawesome";
import { faLeftLong, faRightLong } from "@fortawesome/free-solid-svg-icons";
import { ComicService } from "../comic-service.js";
@Component({
imports: [ CommonModule, RouterModule, FontAwesomeModule ],
selector: "app-comic",
styleUrl: "./comic.css",
templateUrl: "./comic.html",
})
export class Comic {
public comicId = signal("");
public readonly id = input<string | undefined>("id");
// eslint-disable-next-line stylistic/max-len -- I dunno.
public comic: { number: number; title: string; date: string } | undefined;
public error: string | undefined;
public previousComicId: string | undefined;
public nextComicId: string | undefined;
public readonly backIcon = faLeftLong;
public readonly forwardIcon = faRightLong;
private readonly activatedRoute = inject(ActivatedRoute);
public constructor(private readonly comicService: ComicService) {
this.comicService.
loadComics().
then(() => {
this.activatedRoute.params.subscribe((parameters_) => {
const parameters: Record<string, string> = parameters_;
console.log(`FoundID: ${parameters["id"]}`);
this.comicId.set(
parameters["id"] ?? this.comicService.getLatestComicId(),
);
this.previousComicId = this.comicService.
getComicById(String(Number.parseInt(this.comicId(), 10) - 1))?.
number.toString();
this.nextComicId = this.comicService.
getComicById(String(Number.parseInt(this.comicId(), 10) + 1))?.
number.toString();
// Load the comic data for the new ID
this.comic = this.comicService.getComicById(this.comicId());
if (this.comic) {
// Clear any previous error
this.error = undefined;
} else {
this.error = `Cannot find comic with ID ${this.comicId()}.`;
}
});
}).
catch((error: unknown) => {
this.error = `Failed to load comics: ${
error instanceof Error
? error.message
: "Please check the browser console for more details."
}`;
console.error("Error loading comics:", error);
});
}
}
+12
View File
@@ -0,0 +1,12 @@
img {
width: 500px;
border-radius: 50%;
}
h2 {
font-size: 2rem;
}
p {
font-size: 1.5rem;
}
+3
View File
@@ -0,0 +1,3 @@
<h1>YuriGPT</h1>
<img src="https://cdn.yurigpt.com/yurigpt.png" alt="YuriGPT Logo" />
<p>A webcomic about two girls in love.</p>
+17
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-home",
styleUrl: "./home.css",
templateUrl: "./home.html",
})
export class Home {
}
+44
View File
@@ -0,0 +1,44 @@
nav {
width: 100%;
height: 50px;
position: fixed;
background: var(--background);
color: var(--foreground);
top: 0;
}
ul {
display: flex;
justify-content: space-around;
align-items: center;
list-style: none;
margin: 0;
padding: 0;
flex-wrap: nowrap;
height: 50px;
}
li {
margin: 0 10px;
padding: 0;
}
img {
height: 50px;
}
#highlight {
background: var(--foreground);
color: var(--background);
padding: 5px 10px;
border-radius: 5px;
}
@media screen and (max-width: 650px) {
nav {
height: 100px;
}
ul {
flex-wrap: wrap;
}
}
+9
View File
@@ -0,0 +1,9 @@
<nav>
<ul>
<li style="height: 50px;"><a routerLink="/" style="height: 0; display: inline-block;"><img src="https://cdn.yurigpt.com/yurigpt.png" alt="YuriGPT Logo" /></a></li>
<li><a routerLink="/about">About</a></li>
<li id="highlight"><a routerLink="/comic">Start Reading</a></li>
<li><a routerLink="/characters">Characters</a></li>
<li><a routerLink="/archive">Archive</a></li>
</ul>
</nav>
+18
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-nav",
styleUrl: "./nav.css",
templateUrl: "./nav.html",
})
export class Nav {
}