Skip to content
This repository has been archived by the owner on Aug 13, 2024. It is now read-only.

Commit

Permalink
feat: Highlight Cards UI Generation (frontend-only) (#36)
Browse files Browse the repository at this point in the history
* feat: get highlight data function

* feat: setup highlight testing

* feat: data interfaces

* refractor: split card codes

* feat: highlight cards code structure

* refractor: template shared folder

* refractor: card footer component

* refractor: separate o ut card style setup

* feat: highlights UI complete

* lint
  • Loading branch information
nightknighto committed May 2, 2023
1 parent 58c00b5 commit 138a847
Show file tree
Hide file tree
Showing 19 changed files with 293 additions and 53 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run test --config test/jest-e2e.json",
"test:local:user": "npx ts-node test/local-dev/UserCards",
"test:local:highlight": "npx ts-node test/local-dev/HighlightCards",
"docs": "npx compodoc -p tsconfig.json --hideGenerator --disableDependencies -d ./dist/documentation ./src",
"docs:serve": "npm run docs -- --serve"
},
Expand Down
6 changes: 4 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import { clc } from "@nestjs/common/utils/cli-colors.util";
import ApiConfig from "./config/api.config";
import GitHubConfig from "./config/github.config";
import DigitalOceanConfig from "./config/digital-ocean.config";
import { SocialCardModule } from "./social-card/social-card.module";
import { UserCardModule } from "./social-card/user-card/user-card.module";
import { S3FileStorageModule } from "./s3-file-storage/s3-file-storage.module";
import { HighlightCardModule } from "./social-card/highlight-card/highlight-card.module";

@Module({
imports: [
Expand Down Expand Up @@ -46,8 +47,9 @@ import { S3FileStorageModule } from "./s3-file-storage/s3-file-storage.module";
}),
TerminusModule,
HttpModule,
SocialCardModule,
S3FileStorageModule,
UserCardModule,
HighlightCardModule,
],
controllers: [],
providers: [],
Expand Down
11 changes: 11 additions & 0 deletions src/social-card/highlight-card/highlight-card.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";
import { GithubModule } from "../../github/github.module";
import { S3FileStorageModule } from "../../s3-file-storage/s3-file-storage.module";
import { HighlightCardService } from "../highlight-card/highlight-card.service";

@Module({
imports: [HttpModule, GithubModule, S3FileStorageModule],
providers: [HighlightCardService],
})
export class HighlightCardModule {}
113 changes: 113 additions & 0 deletions src/social-card/highlight-card/highlight-card.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { Injectable, Logger } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { Resvg } from "@resvg/resvg-js";
import { Repository, Language } from "@octokit/graphql-schema";
import fs from "node:fs/promises";

import { GithubService } from "../../github/github.service";
import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service";
import userLangs from "../templates/shared/user-langs";
import userProfileRepos from "../templates/shared/user-repos";
import tailwindConfig from "../templates/tailwind.config";
import { firstValueFrom } from "rxjs";
import highlightCardTemplate from "../templates/highlight-card.template";

interface HighlightCardData {
title: string,
body: string,
reactions: number,
avatarUrl: string,
repos: Repository[],
langTotal: number,
langs: (Language & {
size: number,
})[],
}

@Injectable()
export class HighlightCardService {
private readonly logger = new Logger(this.constructor.name);

constructor (
private readonly httpService: HttpService,
private readonly githubService: GithubService,
private readonly s3FileStorageService: S3FileStorageService,
) {}

private async getHighlightData (highlightId: number): Promise<HighlightCardData> {
const langs: Record<string, Language & {
size: number,
}> = {};
const today = (new Date);
const today30daysAgo = new Date((new Date).setDate(today.getDate() - 30));

const highlightReq = await firstValueFrom(this.httpService.get<DbHighlight>(`https://api.opensauced.pizza/v1/user/highlights/${highlightId}`));
const { login, title, highlight: body } = highlightReq.data;

const reactionsReq = await firstValueFrom(this.httpService.get<DbReaction[]>(`https://api.opensauced.pizza/v1/highlights/${highlightId}/reactions`));
const reactions = reactionsReq.data.reduce<number>( (acc, curr) => acc + Number(curr.reaction_count), 0);

const user = await this.githubService.getUser(login);
const langRepos = user.repositories.nodes?.filter(repo => new Date(String(repo?.pushedAt)) > today30daysAgo) as Repository[];
let langTotal = 0;

langRepos.forEach(repo => {
repo.languages?.edges?.forEach(edge => {
if (edge?.node.id) {
langTotal += edge.size;

if (!Object.keys(langs).includes(edge.node.id)) {
langs[edge.node.id] = {
...edge.node,
size: edge.size,
};
} else {
langs[edge.node.id].size += edge.size;
}
}
});
});

return {
title,
body,
reactions,
avatarUrl: `${String(user.avatarUrl)}&size=150`,
langs: Array.from(Object.values(langs)).sort((a, b) => b.size - a.size),
langTotal,
repos: user.topRepositories.nodes?.filter(repo => !repo?.isPrivate && repo?.owner.login !== login) as Repository[],
};
}

// public only to be used in local scripts. Not for controller direct use.
async generateCardBuffer (highlightId: number, highlightData?: HighlightCardData) {
const { html } = await import("satori-html");
const satori = (await import("satori")).default;

const { title, body, reactions, avatarUrl, repos, langs, langTotal } = highlightData ? highlightData : await this.getHighlightData(highlightId);

const template = html(highlightCardTemplate(avatarUrl, title, body, userLangs(langs, langTotal), userProfileRepos(repos, 2), reactions));

const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff");

const svg = await satori(template, {
width: 1200,
height: 627,
fonts: [
{
name: "Inter",
data: interArrayBuffer,
weight: 400,
style: "normal",
},
],
tailwindConfig,
});

const resvg = new Resvg(svg, { background: "rgba(238, 235, 230, .9)" });

const pngData = resvg.render();

return { png: pngData.asPng(), svg };
}
}
14 changes: 0 additions & 14 deletions src/social-card/social-card.module.ts

This file was deleted.

30 changes: 30 additions & 0 deletions src/social-card/templates/highlight-card.template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import cardFooter from "./shared/card-footer";
import cardStyleSetup from "./shared/card-style-setup";

const highlightCardTemplate = (avatarUrl: string, title: string, body: string, langs: string, repos: string, reactions: number): string => `
${cardStyleSetup}
<div tw="flex-col justify-between w-1200px h-627px bg-white rounded-2xl p-32px pt-48px">
<div style="gap: 16px;">
<div tw="p-2.5 pt-5" style="gap: 10px;">
<img tw="w-132px h-132px border border-sauced-orange rounded-full" src="${avatarUrl}"/>
</div>
<div tw="w-906px flex-col flex-nowrap" style="gap: -10px;">
<h1 tw="font-medium text-72px leading-72px text-zinc-900 tracking-tight" style="width: 926px;">
${title}
</h1>
<p tw="font-normal text-48px text-light-slate-11 tracking-tight">
${body.length > 108 ? `${body.slice(0, 108)}...` : body}
</p>
</div>
<div>
<img tw="w-46px h-46px border border-white rounded" src="https://github.com/open-sauced/assets/d9a0d5a317036084aa3f5f4e20cdfbe58dc37377/svgs/slice-Orange-Gradient.svg"/>
</div>
</div>
${cardFooter(langs, repos, reactions)}
</div>`;

export default highlightCardTemplate;
31 changes: 31 additions & 0 deletions src/social-card/templates/shared/card-footer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@

const heartIconData = `data:image/svg+xml,%3csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.73649 2.5C3.82903 2.5 1 5.052 1 8.51351C1 12.3318 3.80141 15.5735 6.38882 17.7763C7.70549 18.8973 9.01844 19.7929 10.0004 20.4077C10.4922 20.7157 10.9029 20.9544 11.1922 21.1169C11.4093 21.2388 11.5582 21.318 11.6223 21.3516C11.7407 21.4132 11.8652 21.4527 12 21.4527C12.1193 21.4527 12.2378 21.4238 12.3438 21.3693C12.5003 21.2886 12.6543 21.2031 12.8078 21.1169C13.0971 20.9544 13.5078 20.7157 13.9996 20.4077C14.9816 19.7929 16.2945 18.8973 17.6112 17.7763C20.1986 15.5735 23 12.3318 23 8.51351C23 5.052 20.171 2.5 17.2635 2.5C14.9702 2.5 13.1192 3.72621 12 5.60482C10.8808 3.72621 9.02981 2.5 6.73649 2.5ZM6.73649 4C4.65746 4 2.5 5.88043 2.5 8.51351C2.5 11.6209 4.8236 14.4738 7.36118 16.6342C8.60701 17.6948 9.85656 18.5479 10.7965 19.1364C11.2656 19.4301 11.6557 19.6567 11.9269 19.8091L12 19.85L12.0731 19.8091C12.3443 19.6567 12.7344 19.4301 13.2035 19.1364C14.1434 18.5479 15.393 17.6948 16.6388 16.6342C19.1764 14.4738 21.5 11.6209 21.5 8.51351C21.5 5.88043 19.3425 4 17.2635 4C15.1581 4 13.4627 5.38899 12.7115 7.64258C12.6094 7.94883 12.3228 8.15541 12 8.15541C11.6772 8.15541 11.3906 7.94883 11.2885 7.64258C10.5373 5.38899 8.84185 4 6.73649 4Z' fill='%2324292F'/%3e%3c/svg%3e`;

const cardFooter = (langs: string, repos: string, reactions?: number) => `
<div tw="flex-col" style="gap: 8px;">
<div tw="h-48px ${!reactions ? "items-center" : "justify-between"}" style="gap: 8px;">
<div tw="h-48px items-center" style="gap: 8px;">
${repos}
</div>
${reactions
? `
<div tw="h-48px items-center" style="gap: 12px;">
<img tw="w-32px h-32px" width="1" height="1" src="${heartIconData}"/>
<span tw="font-medium text-32px text-black">
${reactions} Reactions
</span>
</div>
`
: ""}
</div>
<div tw="flex-col h-18px justify-center overflow-hidden">
<div tw="h-12px" style="gap: 4px;">
${langs}
</div>
</div>
</div>
`;

export default cardFooter;
10 changes: 10 additions & 0 deletions src/social-card/templates/shared/card-style-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

const cardStyleSetup = `
<style>
div {
display: flex;
}
</style>
`;

export default cardStyleSetup;
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import repoIconWithName from "./repo-icon-with-name";
import { Repository } from "@octokit/graphql-schema";

const userProfileRepos = (repos: Repository[]): string => {
const userProfileRepos = (repos: Repository[], limit: number): string => {
const repoList = repos.map(({ name, owner: { avatarUrl } }) =>
repoIconWithName(`${name.substring(0, 15).replace(/\.+$/, "")}${name.length > 15 ? "..." : ""}`, `${String(avatarUrl)}&size=40`));

return `${repoList.slice(0, 4).join("")}${repoList.length > 4
? `<h2 tw="m-0 font-medium text-32px leading-32px text-zinc-900">+${repoList.length - 2}</h2>`
return `${repoList.slice(0, limit).join("")}${repoList.length > limit
? `<h2 tw="m-0 font-medium text-32px leading-32px text-zinc-900">+${repoList.length - limit}</h2>`
: ``}`;
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
const userProfileCard = (avatarUrl: string, name: string, langs: string, repos: string): string => `
<style>
div {
display: flex;
}
</style>
import cardFooter from "./shared/card-footer";
import cardStyleSetup from "./shared/card-style-setup";

const userProfileCardTemplate = (avatarUrl: string, name: string, langs: string, repos: string): string => `
${cardStyleSetup}
<div tw="flex-col justify-between w-1200px h-627px bg-white rounded-2xl p-32px pt-48px">
<div style="gap: 16px;">
Expand All @@ -16,17 +15,7 @@ const userProfileCard = (avatarUrl: string, name: string, langs: string, repos:
<img tw="w-48px h-48px border border-white rounded" src="https://github.com/open-sauced/assets/d9a0d5a317036084aa3f5f4e20cdfbe58dc37377/svgs/slice-Orange-Gradient.svg"/>
</div>
<div tw="flex-col" style="gap: 8px;">
<div tw="h-48px items-center" style="gap: 8px;">
${repos}
</div>
<div tw="flex-col h-18px justify-center overflow-hidden">
<div tw="h-12px" style="gap: 4px;">
${langs}
</div>
</div>
</div>
${cardFooter(langs, repos)}
</div>`;

export default userProfileCard;
export default userProfileCardTemplate;
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ import {
} from "@nestjs/swagger";
import { FastifyReply } from "fastify";

import { SocialCardService } from "./social-card.service";
import { UserCardService } from "./user-card.service";

@Controller("users")
@ApiTags("User social cards")
export class SocialCardController {
export class UserCardController {
constructor (
private readonly socialCardService: SocialCardService,
private readonly userCardService: UserCardService,
) {}

@Get("/:username")
Expand All @@ -33,13 +33,13 @@ export class SocialCardController {
@Res({ passthrough: true }) res: FastifyReply,
): Promise<void> {
const sanitizedUsername = username.toLowerCase();
const { fileUrl, hasFile, needsUpdate } = await this.socialCardService.checkRequiresUpdate(sanitizedUsername);
const { fileUrl, hasFile, needsUpdate } = await this.userCardService.checkRequiresUpdate(sanitizedUsername);

if (hasFile && !needsUpdate) {
return res.status(HttpStatus.FOUND).redirect(fileUrl);
}

const url = await this.socialCardService.getUserCard(sanitizedUsername);
const url = await this.userCardService.getUserCard(sanitizedUsername);

return res.status(HttpStatus.FOUND).redirect(url);
}
Expand All @@ -57,7 +57,7 @@ export class SocialCardController {
@Res({ passthrough: true }) res: FastifyReply,
): Promise<void> {
const sanitizedUsername = username.toLowerCase();
const { fileUrl, hasFile, needsUpdate, lastModified } = await this.socialCardService.checkRequiresUpdate(sanitizedUsername);
const { fileUrl, hasFile, needsUpdate, lastModified } = await this.userCardService.checkRequiresUpdate(sanitizedUsername);

return res
.headers({
Expand Down
14 changes: 14 additions & 0 deletions src/social-card/user-card/user-card.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { HttpModule } from "@nestjs/axios";

import { UserCardService } from "./user-card.service";
import { UserCardController } from "./user-card.controller";
import { GithubModule } from "../../github/github.module";
import { S3FileStorageModule } from "../../s3-file-storage/s3-file-storage.module";

@Module({
imports: [HttpModule, GithubModule, S3FileStorageModule],
providers: [UserCardService],
controllers: [UserCardController],
})
export class UserCardModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import { Repository, Language, User } from "@octokit/graphql-schema";
import fs from "node:fs/promises";


import { GithubService } from "../github/github.service";
import { S3FileStorageService } from "../s3-file-storage/s3-file-storage.service";
import userLangs from "./templates/user-langs";
import userProfileRepos from "./templates/user-profile-repos";
import userProfileCard from "./templates/user-profile-card";
import tailwindConfig from "./templates/tailwind.config";
import { GithubService } from "../../github/github.service";
import { S3FileStorageService } from "../../s3-file-storage/s3-file-storage.service";
import userLangs from "../templates/shared/user-langs";
import userProfileRepos from "../templates/shared/user-repos";
import userProfileCardTemplate from "../templates/user-profile-card.template";
import tailwindConfig from "../templates/tailwind.config";

interface RequiresUpdateMeta {
fileUrl: string,
Expand All @@ -32,7 +32,7 @@ interface UserCardData {


@Injectable()
export class SocialCardService {
export class UserCardService {
private readonly logger = new Logger(this.constructor.name);

constructor (
Expand Down Expand Up @@ -85,7 +85,7 @@ export class SocialCardService {

const { avatarUrl, repos, langs, langTotal } = userData ? userData : await this.getUserData(username);

const template = html(userProfileCard(avatarUrl, username, userLangs(langs, langTotal), userProfileRepos(repos)));
const template = html(userProfileCardTemplate(avatarUrl, username, userLangs(langs, langTotal), userProfileRepos(repos, 4)));

const interArrayBuffer = await fs.readFile("node_modules/@fontsource/inter/files/inter-all-400-normal.woff");

Expand Down
Loading

0 comments on commit 138a847

Please sign in to comment.