generated from nhcarrigan/template
### 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:
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
main {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
main {
|
||||
margin-top: 120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<app-nav></app-nav>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
img {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.ng-fa-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
img {
|
||||
width: 500px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user