diff --git a/src/lib/components/Embed.svelte b/src/lib/components/Embed.svelte
new file mode 100644
index 00000000..ca31bac3
--- /dev/null
+++ b/src/lib/components/Embed.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {#if layout && url && description && thumbnailURL}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{ /if}
+
\ No newline at end of file
diff --git a/src/lib/components/profile/ProfileThumbnailEmbed.svelte b/src/lib/components/profile/ProfileThumbnailEmbed.svelte
deleted file mode 100644
index a772bff7..00000000
--- a/src/lib/components/profile/ProfileThumbnailEmbed.svelte
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/lib/i18n/en/index.ts b/src/lib/i18n/en/index.ts
index 9e67a291..7b43ed86 100644
--- a/src/lib/i18n/en/index.ts
+++ b/src/lib/i18n/en/index.ts
@@ -24,9 +24,19 @@ const en = {
wacca: "Wacca"
},
embed: {
- noGamesPlayed: "This user has not played any games",
- description: "{user:string} is leaderboard rank #{rank:string} with rating {rating:string}",
- descriptionNoGame: "{user:string} has not played on {aquadx:string}"
+ rankings: {
+ description: "{game:string} Rankings"
+ },
+ profile: {
+ rating: {
+ a: "Rating ",
+ b: "{rating:string}",
+ c: " rank ",
+ d: "{rank:string}"
+ },
+ description: "{user:string} is an {aquadx:string} user with rating {rating:string} and leaderboard rank #{rank:string}",
+ descriptionNoGame: "{user:string} is an {aquadx:string} user."
+ }
},
errors: {
generic: {
diff --git a/src/lib/i18n/i18n-types.ts b/src/lib/i18n/i18n-types.ts
index 7bb82ba7..ff78fb41 100644
--- a/src/lib/i18n/i18n-types.ts
+++ b/src/lib/i18n/i18n-types.ts
@@ -78,23 +78,49 @@ type RootTranslation = {
wacca: string
}
embed: {
- /**
- * This user has not played any games
- */
- noGamesPlayed: string
- /**
- * {user} is leaderboard rank #{rank} with rating {rating}
- * @param {string} rank
- * @param {string} rating
- * @param {string} user
- */
- description: RequiredParams<'rank' | 'rating' | 'user'>
- /**
- * {user} has not played on {aquadx}
- * @param {string} aquadx
- * @param {string} user
- */
- descriptionNoGame: RequiredParams<'aquadx' | 'user'>
+ rankings: {
+ /**
+ * {game} Rankings
+ * @param {string} game
+ */
+ description: RequiredParams<'game'>
+ }
+ profile: {
+ rating: {
+ /**
+ * Rating
+ */
+ a: string
+ /**
+ * {rating}
+ * @param {string} rating
+ */
+ b: RequiredParams<'rating'>
+ /**
+ * rank
+ */
+ c: string
+ /**
+ * {rank}
+ * @param {string} rank
+ */
+ d: RequiredParams<'rank'>
+ }
+ /**
+ * {user} is an {aquadx} user with rating {rating} and leaderboard rank #{rank}
+ * @param {string} aquadx
+ * @param {string} rank
+ * @param {string} rating
+ * @param {string} user
+ */
+ description: RequiredParams<'aquadx' | 'rank' | 'rating' | 'user'>
+ /**
+ * {user} is an {aquadx} user.
+ * @param {string} aquadx
+ * @param {string} user
+ */
+ descriptionNoGame: RequiredParams<'aquadx' | 'user'>
+ }
}
errors: {
generic: {
@@ -840,18 +866,40 @@ export type TranslationFunctions = {
wacca: () => LocalizedString
}
embed: {
- /**
- * This user has not played any games
- */
- noGamesPlayed: () => LocalizedString
- /**
- * {user} is leaderboard rank #{rank} with rating {rating}
- */
- description: (arg: { rank: string, rating: string, user: string }) => LocalizedString
- /**
- * {user} has not played on {aquadx}
- */
- descriptionNoGame: (arg: { aquadx: string, user: string }) => LocalizedString
+ rankings: {
+ /**
+ * {game} Rankings
+ */
+ description: (arg: { game: string }) => LocalizedString
+ }
+ profile: {
+ rating: {
+ /**
+ * Rating
+ */
+ a: () => LocalizedString
+ /**
+ * {rating}
+ */
+ b: (arg: { rating: string }) => LocalizedString
+ /**
+ * rank
+ */
+ c: () => LocalizedString
+ /**
+ * {rank}
+ */
+ d: (arg: { rank: string }) => LocalizedString
+ }
+ /**
+ * {user} is an {aquadx} user with rating {rating} and leaderboard rank #{rank}
+ */
+ description: (arg: { aquadx: string, rank: string, rating: string, user: string }) => LocalizedString
+ /**
+ * {user} is an {aquadx} user.
+ */
+ descriptionNoGame: (arg: { aquadx: string, user: string }) => LocalizedString
+ }
}
errors: {
generic: {
diff --git a/src/lib/thumb/component.ts b/src/lib/thumb/component.ts
deleted file mode 100644
index 55633be5..00000000
--- a/src/lib/thumb/component.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { CanvasRenderingContext2D } from "skia-canvas";
-
-export abstract class Component {
- constructor(context: CanvasRenderingContext2D) {
- this.context = context;
- this.width = context.canvas.width;
- this.height = context.canvas.height;
- }
- abstract draw(options: Record): Promise;
-
- protected context: CanvasRenderingContext2D | undefined;
- protected width: number = 0;
- protected height: number = 0;
-}
\ No newline at end of file
diff --git a/src/lib/thumb/component/background.ts b/src/lib/thumb/component/background.ts
deleted file mode 100644
index 2137dfec..00000000
--- a/src/lib/thumb/component/background.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { CanvasRenderingContext2D } from "skia-canvas";
-import { Component } from "../component";
-
-const backgroundColor = "#0F131A";
-
-export class BackgroundComponent extends Component {
- async draw(options: Record = {}) {
- if (!this.context) return;
-
- this.context.fillStyle = backgroundColor;
- this.context.fillRect(0, 0, this.width, this.height);
- };
-}
\ No newline at end of file
diff --git a/src/lib/thumb/component/composition.ts b/src/lib/thumb/component/composition.ts
deleted file mode 100644
index c7ef7074..00000000
--- a/src/lib/thumb/component/composition.ts
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Component } from "../component";
-import { align } from "../utilities/align";
-import { drawText, measureText, strokeText } from "../utilities/text";
-
-const rankColors = {
- "sss+": "#c3eacc",
- "sss": "#9AD1A6",
- "ss+": "#99b5d9",
- "ss": "#7594BD",
- "s+": "#e38989",
- "s": "#DD6060",
- "aaa": "#BABEC5",
-} as Record;
-
-export class CompositionComponent extends Component {
- async draw(options: Record = {}) {
- if (!this.context) return;
-
- let totalCount = 0;
- for (const key of Object.keys(options.composition))
- totalCount += options.composition[key];
-
- let totalWidth = 0;
- for (const key of Object.keys(rankColors)) {
- if (options.composition[key]) {
- let count = options.composition[key];
- let width = (count / totalCount) * this.width;
-
- this.context.fillStyle = rankColors[key];
- this.context.fillRect(totalWidth, this.height - 32, width, 32);
-
- let rankText = key.toUpperCase();
- let x = align(width, measureText(rankText, "600 20", this.context)) + totalWidth;
- let y = this.height - 8;
-
- strokeText(
- rankText,
- x, y,
- "#fff", "600 20",
- this.context
- );
- drawText(
- rankText,
- x, y,
- "#000", "600 20",
- this.context
- );
-
- totalWidth += width;
- }
- }
- }
-};
\ No newline at end of file
diff --git a/src/lib/thumb/component/header.ts b/src/lib/thumb/component/header.ts
deleted file mode 100644
index 5c3188ff..00000000
--- a/src/lib/thumb/component/header.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { formatWithCommas } from "$lib/app/formatting";
-import { Component } from "../component";
-import { drawText, sequenceText } from "../utilities/text";
-
-// TODO: swap out for local file
-const baseURL = `http://localhost:5173/net/asset/net/portrait/`;
-
-export class HeaderComponent extends Component {
- async draw(options: Record = {}) {
- if (!this.context) return;
-
- let x = options.x || 0;
- let y = options.y || 0;
-
- if (options.game)
- drawText(options.game, x, y, "#9B9494", "600 20", this.context);
- drawText(options.name, x - 5, y + 60, "#FFF", "700 56", this.context);
- drawText(options.tag, x, y + 90, "#9B9494", "600 20", this.context);
-
- if (options.leaderboardRanking && options.rating) {
- sequenceText([
- {
- text: `#${formatWithCommas(options.leaderboardRanking)}`,
- color: "#FFF",
- size: "600 30"
- },
- {
- text: "with",
- color: "#9B9494",
- size: "400 30"
- },
- {
- text: options.rating,
- color: "#FFF",
- size: "600 30"
- },
- {
- text: "rating",
- color: "#9B9494",
- size: "400 30"
- }
- ], x, y + 130, this.context);
- } else if (options.subtext)
- drawText(options.subtext, x, y + 130, "#9b9494", "400 24", this.context);
- }
-}
\ No newline at end of file
diff --git a/src/lib/thumb/component/profilePicture.ts b/src/lib/thumb/component/profilePicture.ts
deleted file mode 100644
index afe9f8bf..00000000
--- a/src/lib/thumb/component/profilePicture.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { Image } from "skia-canvas";
-import { Component } from "../component";
-import { getImage, round } from "../utilities/image";
-
-import { real, align } from "../utilities/align";
-
-// TODO: swap out for local file
-const baseURL = `http://localhost:5173/net/asset/net/portrait/`;
-
-export class ProfilePictureComponent extends Component {
- async draw(options: Record = {}) {
- if (!this.context) return;
-
- const image = await getImage(options.file);
-
- if (!image) return;
-
- const x = options.x;
- const y = options.y;
- const size = options.size;
-
- round(
- x, y,
- size, size, size,
- this.context
- );
- this.context.drawImage(
- image,
- x, y,
- size, size
- );
- this.context.restore();
- }
-}
\ No newline at end of file
diff --git a/src/lib/thumb/draw.ts b/src/lib/thumb/draw.ts
deleted file mode 100644
index ec439df8..00000000
--- a/src/lib/thumb/draw.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { Canvas, type Image } from "skia-canvas";
-
-import { align } from "./utilities/align";
-import "./utilities/text";
-
-import { BackgroundComponent } from "./component/background";
-import { ProfilePictureComponent } from "./component/profilePicture";
-import { HeaderComponent } from "./component/header";
-import { CompositionComponent } from "./component/composition";
-import { getImage } from "./utilities/image";
-import { getProfilePictureUrl } from "$lib/app/assets";
-
-const width = 1024;
-const height = 512;
-
-const margin = 50;
-
-export interface ProfileEmbedThumbnailOptions {
- user: {
- userName: string,
- displayName?: string,
- profilePicture: string,
-
- gameName?: string,
- ranking?: number,
- rating?: string,
-
- subtext?: string
- },
- app: {
- baseUrl: string,
- },
- game?: string,
- composition?: Record
-}
-
-let canvas = new Canvas(width, height);
-let context = canvas.getContext("2d");
-
-let background = new BackgroundComponent(context);
-let profilePicture = new ProfilePictureComponent(context);
-let header = new HeaderComponent(context);
-let composition = new CompositionComponent(context);
-
-let logo: Image | undefined;
-
-export async function getProfileEmbedThumbnail(options: ProfileEmbedThumbnailOptions): Promise {
- await background.draw();
- await profilePicture.draw({
- x: width - margin - 256,
- y: align(height, 256),
- size: 256,
- file: options.app.baseUrl + options.user.profilePicture
- });
-
- // putting this into it's own component is a waste
- if (!logo)
- logo = await getImage(`${options.app.baseUrl}/asset/logo.svg`);
- if (logo)
- context.drawImage(
- logo,
- width - 15 - 48,
- 15, 48, 48
- );
-
- await header.draw({
- x: margin,
- y: align(height, 130),
- game: options.game,
- name: options.user.displayName || options.user.gameName,
- tag: options.user.displayName ? `${options.user.gameName ?? ""} (@${options.user.userName})` : `@${options.user.userName}`,
- leaderboardRanking: options.user.ranking,
- rating: options.user.rating,
- subtext: options.user.subtext
- })
- if (options.composition)
- await composition.draw({
- composition: options.composition
- });
-
- return canvas.toBufferSync("png")
-}
diff --git a/src/lib/thumb/fonts/Inter-Black.ttf b/src/lib/thumb/fonts/Inter-Black.ttf
deleted file mode 100644
index 69762d79..00000000
Binary files a/src/lib/thumb/fonts/Inter-Black.ttf and /dev/null differ
diff --git a/src/lib/thumb/fonts/Inter-Bold.ttf b/src/lib/thumb/fonts/Inter-Bold.ttf
deleted file mode 100644
index 9fb9b751..00000000
Binary files a/src/lib/thumb/fonts/Inter-Bold.ttf and /dev/null differ
diff --git a/src/lib/thumb/fonts/Inter-Light.ttf b/src/lib/thumb/fonts/Inter-Light.ttf
deleted file mode 100644
index 2e073931..00000000
Binary files a/src/lib/thumb/fonts/Inter-Light.ttf and /dev/null differ
diff --git a/src/lib/thumb/fonts/Inter-Regular.ttf b/src/lib/thumb/fonts/Inter-Regular.ttf
deleted file mode 100644
index b7aaca8d..00000000
Binary files a/src/lib/thumb/fonts/Inter-Regular.ttf and /dev/null differ
diff --git a/src/lib/thumb/fonts/Inter-SemiBold.ttf b/src/lib/thumb/fonts/Inter-SemiBold.ttf
deleted file mode 100644
index 47f8ab1d..00000000
Binary files a/src/lib/thumb/fonts/Inter-SemiBold.ttf and /dev/null differ
diff --git a/src/lib/thumb/utilities/align.ts b/src/lib/thumb/utilities/align.ts
deleted file mode 100644
index 2d6b0508..00000000
--- a/src/lib/thumb/utilities/align.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export function real(value: number, size: number) {
- return value * size;
-}
-export function align(axis: number, size: number) {
- return (axis / 2) - (size / 2);
-}
\ No newline at end of file
diff --git a/src/lib/thumb/utilities/image.ts b/src/lib/thumb/utilities/image.ts
deleted file mode 100644
index 5f4d32e9..00000000
--- a/src/lib/thumb/utilities/image.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Image, type CanvasRenderingContext2D } from "skia-canvas";
-
-export function getImage(url: string): Promise {
- return new Promise((resolve, reject) => {
- const image = new Image();
- image.onload = () => {
- resolve(image);
- };
- image.onerror = () => {
- reject();
- };
- image.src = url;
- });
-}
-export async function round(x: number, y: number, w: number, h: number, r: number, context: CanvasRenderingContext2D, drawFunction?: () => void) {
- context.save();
- context.beginPath();
- context.arc(x + (w / 2), y + (h / 2), r / 2, 0, Math.PI * 2, true);
- context.closePath();
- context.clip();
-
- if (drawFunction) {
- drawFunction();
- context.restore();
- };
-};
\ No newline at end of file
diff --git a/src/lib/thumb/utilities/text.ts b/src/lib/thumb/utilities/text.ts
deleted file mode 100644
index f00b83ad..00000000
--- a/src/lib/thumb/utilities/text.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import path from "path";
-import { type CanvasRenderingContext2D, FontLibrary } from "skia-canvas";
-
-FontLibrary.use("Inter", [
- "src/lib/thumb/fonts/Inter-Regular.ttf",
- "src/lib/thumb/fonts/Inter-Bold.ttf",
- "src/lib/thumb/fonts/Inter-SemiBold.ttf",
- "src/lib/thumb/fonts/Inter-Black.ttf",
- "src/lib/thumb/fonts/Inter-Light.ttf",
-]);
-
-export function drawText(text: string, x: number, y: number, color: string, size: number | string, context: CanvasRenderingContext2D) {
- context.fillStyle = color;
- context.font = `${size}px Inter`;
- context.fillText(text, x, y);
-}
-export function strokeText(text: string, x: number, y: number, color: string, size: number | string, context: CanvasRenderingContext2D) {
- context.strokeStyle = color;
- context.font = `${size}px Inter`;
- context.strokeText(text, x, y);
-}
-
-export function measureText(text: string, size: number | string, context: CanvasRenderingContext2D): number {
- context.font = `${size}px Inter`;
- return context.measureText(text).width;
-}
-
-export function sequenceText(texts: {
- text: string;
- color: string;
- size: number | string;
-}[], x: number, y: number, context: CanvasRenderingContext2D) {
- let totalTextWidth = 0;
- for (const text of texts) {
- context.fillStyle = text.color;
- context.font = `${text.size}px Inter`;
- context.fillText(text.text, x + totalTextWidth, y);
- totalTextWidth += context.measureText(text.text).width + 5;
- };
-}
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/box.ts b/src/lib/thumbnail/components/box.ts
new file mode 100644
index 00000000..19869813
--- /dev/null
+++ b/src/lib/thumbnail/components/box.ts
@@ -0,0 +1,24 @@
+import { ThumbnailBaseComponent, type ThumbnailComponentDescription } from "..";
+import { type CanvasRenderingContext2D } from "skia-canvas"
+import { convert, type Color } from "./word";
+
+export class BoxComponent extends ThumbnailBaseComponent {
+ constructor(color: Color, description?: ThumbnailComponentDescription) {
+ super(description);
+ this.color = color;
+ }
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ if (!this.color) return;
+
+ const { top = 0, left = 0 } = this.position;
+ const { width = 0, height = 0 } = this.size;
+
+ context.fillStyle = convert(context, [
+ left, top, width, height
+ ], this.color!);
+ context.fillRect(
+ left, top, width, height
+ );
+ }
+ color: Color | undefined;
+};
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/container.ts b/src/lib/thumbnail/components/container.ts
new file mode 100644
index 00000000..8bd53bc3
--- /dev/null
+++ b/src/lib/thumbnail/components/container.ts
@@ -0,0 +1,117 @@
+import { alignToPixel, ThumbnailBaseComponent, type ThumbnailComponentSize } from "..";
+import { type CanvasRenderingContext2D, Path2D } from "skia-canvas";
+
+export interface ContainerAlignment {
+ top: number | string,
+ left: number | string
+};
+
+export class ContainerComponent extends ThumbnailBaseComponent {
+ constructor(children: ThumbnailBaseComponent[], alignment?: ContainerAlignment, margin?: ThumbnailComponentSize, inline?: boolean) {
+ super({});
+ this.children = children;
+ if (alignment)
+ this.alignment = alignment;
+ if (margin)
+ this.margin = margin;
+ if (typeof(inline) == "boolean")
+ this.inline = inline;
+ }
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ // TODO: determine if we can simplify
+ if (this.alignment)
+ this.position = alignToPixel(this.alignment, this.getSize(context, scaling));
+
+ let layers: ThumbnailBaseComponent[][] = [[]];
+ for (const child of this.children) {
+ if (!child.inline)
+ layers.push([]);
+ layers[layers.length - 1].push(child);
+ };
+
+ let top = (this.margin.height ?? 0) + (this.position.top ?? 0);
+ for (const layer of layers) {
+ let left = (this.margin.width ?? 0) + (this.position.left ?? 0);
+ let layerHeight = 0;
+
+ for (const child of layer) {
+ // this is a little redundant but it should technically give us the
+ // option to align to lower half of layer if we wanna
+ const size = child.getSize(context, scaling);
+ if (size.height && size.height > layerHeight)
+ layerHeight = size.height;
+ }
+ for (const child of layer) {
+ const size = child.getSize(context, scaling);
+ child.position = {
+ left, top
+ }
+ await child.draw(context, scaling);
+ left += (size.width ?? 0);
+ };
+
+ top += layerHeight;
+ }
+ };
+ getSize = (context: CanvasRenderingContext2D, scaling: number) => {
+ let layers: ThumbnailBaseComponent[][] = [[]];
+ for (const child of this.children) {
+ if (!child.inline)
+ layers.push([]);
+ layers[layers.length - 1].push(child);
+ };
+
+ let width = (this.margin.width ?? 0) * 2;
+ let height = (this.margin.height ?? 0) * 2;
+
+ for (const layer of layers) {
+ let layerWidth = 0;
+ let layerHeight = 0;
+
+ for (const child of layer) {
+ const size = child.getSize(context, scaling)
+ layerWidth += size.width ?? 0;
+ if (size.height && size.height > layerHeight)
+ layerHeight = size.height;
+ }
+
+ if (layerWidth > width)
+ width = layerWidth;
+ height += layerHeight;
+ };
+
+ return {width, height};
+ };
+ children: ThumbnailBaseComponent[] = [];
+ alignment: ContainerAlignment | undefined;
+}
+
+export interface ComponentOffset {
+ leftPercent: number,
+ topPercent: number,
+ component: ThumbnailBaseComponent
+};
+
+export class OffsetComponent extends ThumbnailBaseComponent {
+ constructor(size: ThumbnailComponentSize, children: ComponentOffset[], margin?: ThumbnailComponentSize, inline?: boolean) {
+ super({size});
+ this.children = children;
+ if (margin)
+ this.margin = margin;
+ if (typeof(inline) == "boolean")
+ this.inline = inline;
+ };
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ for (const componentOffset of this.children) {
+ const component = componentOffset.component;
+ const size = component.getSize(context, scaling);
+
+ component.position = {
+ left: this.position.left! + (this.size.width! * (componentOffset.leftPercent / 100)) - (size.width! * (componentOffset.leftPercent / 100)),
+ top: this.position.top! + (this.size.height! * (componentOffset.topPercent / 100)) - (size.height! * (componentOffset.topPercent / 100))
+ };
+ await component.draw(context, scaling);
+ }
+ };
+ children: ComponentOffset[] = [];
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/image.ts b/src/lib/thumbnail/components/image.ts
new file mode 100644
index 00000000..a1a203fc
--- /dev/null
+++ b/src/lib/thumbnail/components/image.ts
@@ -0,0 +1,23 @@
+import { ThumbnailBaseComponent, type ThumbnailComponentDescription } from "..";
+import { Image, type CanvasRenderingContext2D } from "skia-canvas";
+
+export class ImageComponent extends ThumbnailBaseComponent {
+ constructor(src: string, description?: ThumbnailComponentDescription) {
+ super(description);
+ this.image = new Image();
+ this.image.src = src;
+ }
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ if (!this.image) return;
+ await this.image.decode()
+
+ const { top = 0, left = 0 } = this.position;
+ const { width = 0, height = 0 } = this.size;
+
+ context.drawImage(
+ this.image,
+ left, top, width, height
+ );
+ }
+ image: Image | undefined;
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/logo.ts b/src/lib/thumbnail/components/logo.ts
new file mode 100644
index 00000000..61e989c2
--- /dev/null
+++ b/src/lib/thumbnail/components/logo.ts
@@ -0,0 +1,9 @@
+import { type ThumbnailComponentDescription } from "..";
+import { Image, type CanvasRenderingContext2D } from "skia-canvas";
+import { ImageComponent } from "./image";
+
+export class LogoComponent extends ImageComponent {
+ constructor(description?: ThumbnailComponentDescription) {
+ super(`src/lib/components/logo.svg`, description);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/word.ts b/src/lib/thumbnail/components/word.ts
new file mode 100644
index 00000000..952c19d2
--- /dev/null
+++ b/src/lib/thumbnail/components/word.ts
@@ -0,0 +1,111 @@
+import { ThumbnailBaseComponent, type ThumbnailComponentSize } from "..";
+import { FontLibrary, type CanvasRenderingContext2D, type CanvasGradient } from "skia-canvas";
+
+export type Color = string | string[];
+export interface WordStyle {
+ color?: Color,
+ size?: number,
+ extra?: string
+}
+
+export function convert(context: CanvasRenderingContext2D, bounds: number[], color: Color) : string | CanvasGradient {
+ if (Array.isArray(color)) {
+ let gradient = context.createLinearGradient(bounds[0], bounds[1], bounds[2], bounds[3]);
+ gradient.addColorStop(0, color[0]);
+ gradient.addColorStop(1, color[1]);
+ return gradient;
+ } else
+ return color;
+}
+
+FontLibrary.use("Font", [
+ "static/asset/font/Mona-Sans.woff2"
+]);
+
+export class WordComponent extends ThumbnailBaseComponent {
+ constructor(text: string, style?: WordStyle, inline?: boolean) {
+ super({});
+ this.text = text;
+ if (style)
+ this.style = style;
+ if (typeof(inline) == "boolean")
+ this.inline = inline;
+ }
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ this.font(context, scaling);
+ if (!this.text) return;
+
+ let size = this.getSize(context, scaling);
+ const { top = 0, left = 0 } = this.position;
+ const { width = 0, height = 0 } = size;
+
+ context.fillStyle = convert(context, [
+ left, top, width + left, height + top
+ ], this.style.color!) ?? "black";
+ context.fillText(
+ this.text,
+ left, top + height
+ );
+ }
+ getSize = (context: CanvasRenderingContext2D, scaling: number) => {
+ this.font(context, scaling);
+ if (!this.text) return {};
+
+ let size = context.measureText(this.text);
+ return {
+ width: size.width,
+ /*
+ this is probably the wrong way to do this but it looks the best
+
+ example: http://localhost:5173/net/embed/profile/harv/chu3
+ using actual bounding boxes looks odd because of the cross i think
+ using this adjusted box size looks correct with the cross
+ */
+ height: (size.fontBoundingBoxAscent + size.fontBoundingBoxDescent) / 1.5
+ }
+ }
+ font = (context: CanvasRenderingContext2D, scaling: number) => {
+ context.font = `${this.style.extra ? this.style.extra : "normal"} ${scaling * (this.style.size ?? 1)}px Font`
+ }
+ private text: string | undefined;
+ private style: WordStyle = {
+ color: "black",
+ size: 1
+ }
+}
+
+export class RichWordComponent extends ThumbnailBaseComponent {
+ constructor(words: ThumbnailBaseComponent[]) {
+ super({});
+ this.words = words;
+ };
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ const componentSize = this.getSize(context, scaling);
+ let offset = 0;
+ for (const word of this.words) {
+ const size = word.getSize(context, scaling);
+ word.position = {
+ left: offset + (this.position.left ?? 0),
+ top: (componentSize.height - (size.height ?? 0)) + (this.position.top ?? 0),
+ }
+ offset += size.width ?? 0
+ await word.draw(context, scaling);
+ };
+ }
+ getSize = (context: CanvasRenderingContext2D, scaling: number) => {
+ let componentSize = {
+ width: 0,
+ height: 0
+ } satisfies ThumbnailComponentSize;
+
+ for (const word of this.words) {
+ const size = word.getSize(context, scaling);
+ if (size.height! > componentSize.height)
+ componentSize.height = size.height ?? 0;
+ componentSize.width += size.width ?? 0;
+ }
+
+ return componentSize;
+ }
+ words: ThumbnailBaseComponent[] = [];
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/components/wrapper.ts b/src/lib/thumbnail/components/wrapper.ts
new file mode 100644
index 00000000..bff61b6c
--- /dev/null
+++ b/src/lib/thumbnail/components/wrapper.ts
@@ -0,0 +1,101 @@
+import type { CanvasRenderingContext2D } from "skia-canvas";
+import { sizeToPixel, ThumbnailBaseComponent, type ThumbnailComponentDescription, type ThumbnailComponentSize } from "..";
+
+export class Wrapper extends ThumbnailBaseComponent {
+ constructor(component: ThumbnailBaseComponent, descriptor?: ThumbnailComponentDescription) {
+ super(descriptor);
+ this.component = component;
+ };
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ if (!this.component) return;
+ this.component.position = this.position;
+ await this.component.draw(context, scaling);
+ };
+ getSize = (context: CanvasRenderingContext2D, scaling: number) => {
+ if (!this.component) return {};
+ return this.component?.getSize(context, scaling);
+ }
+ component: ThumbnailBaseComponent | undefined;
+}
+
+export class TransformWrapper extends Wrapper {
+ constructor(component: ThumbnailBaseComponent, rotation: number, pivot: ThumbnailComponentSize) {
+ super(component);
+ this.rotation = rotation;
+ this.pivot = pivot;
+ };
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ if (!this.component) return;
+
+ // to be honest, i don't know if i actually implemented this correctly
+ // i think it works correctly but i can't really say for sure
+
+ const size = this.component.getSize(context, scaling);
+ this.pivot = {
+ width: ((this.pivot.widthPercent ?? 0) / 100) * (size.width ?? 0),
+ height: ((this.pivot.heightPercent ?? 0) / 100) * (size.height ?? 0)
+ }
+
+ if (this.rotation != 0) {
+ const { left = 0, top = 0 } = this.position;
+ const { width = 0, height = 0 } = this.pivot;
+
+ context.save();
+ context.translate(left, top);
+ context.rotate(this.rotation * (Math.PI / 4));
+ this.component.position = {
+ left: -width, top: -height
+ }
+ await this.component.draw(context, scaling);
+ context.restore()
+ } else {
+ const size = this.component.getSize(context, scaling);
+ // probably just using it as a size bypass
+ this.component.position = {
+ left: (this.position.left ?? 0) - (this.pivot.width ?? 0),
+ top: (this.position.top ?? 0) - (this.pivot.height ?? 0),
+ };
+ await this.component.draw(context, scaling);
+ }
+ };
+ getSize = (context: CanvasRenderingContext2D, scaling: number) => {
+ return {
+ width: 0,
+ height: 0
+ }
+ }
+ pivot: ThumbnailComponentSize = {};
+ rotation: number = 0;
+}
+
+export class CornerWrapper extends Wrapper {
+ constructor(component: ThumbnailBaseComponent, a: number, b: number, c: number, d: number) {
+ super(component);
+ this.a = a;
+ this.b = b;
+ this.c = c;
+ this.d = d;
+ };
+ async draw(context: CanvasRenderingContext2D, scaling: number) {
+ if (!this.component) return;
+ this.component.position = this.position;
+
+ const { width = 0, height = 0 } = this.component.getSize(context, scaling);
+ const { left = 0, top = 0 } = this.position;
+
+ context.save();
+
+ context.beginPath();
+ context.roundRect(left, top, width, height, [this.a, this.b, this.c, this.d]);
+ context.clip();
+
+ await this.component.draw(context, scaling);
+
+ context.restore();
+ };
+
+ private a: number = 0;
+ private b: number = 0;
+ private c: number = 0;
+ private d: number = 0;
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/embeds/generic/index.ts b/src/lib/thumbnail/embeds/generic/index.ts
new file mode 100644
index 00000000..39ea3e52
--- /dev/null
+++ b/src/lib/thumbnail/embeds/generic/index.ts
@@ -0,0 +1,44 @@
+import { Thumbnail } from "$lib/thumbnail";
+
+import { ContainerComponent, OffsetComponent } from "$lib/thumbnail/components/container";
+import { LogoComponent } from "$lib/thumbnail/components/logo";
+import { WordComponent } from "$lib/thumbnail/components/word";
+
+import { colorPalette } from "$lib/thumbnail/palette";
+
+export class GenericThumbnail extends Thumbnail {
+ constructor(options: {
+ aquadx: string
+ }) {
+ super([
+ new ContainerComponent([
+ new OffsetComponent({
+ // note that this *is* relative to the size of the aquadx text
+ widthPercent: 45
+ }, [
+ {
+ leftPercent: 0,
+ topPercent: 50,
+ component: new LogoComponent({
+ size: {
+ heightPercent: 30,
+ aspect: 1
+ }
+ })
+ },
+ {
+ leftPercent: 100,
+ topPercent: 50,
+ component: new WordComponent(options.aquadx, {
+ size: 3,
+ color: colorPalette.TextPrimary
+ }),
+ }
+ ])
+ ], {
+ top: "50%",
+ left: "50%"
+ })
+ ]);
+ };
+};
\ No newline at end of file
diff --git a/src/lib/thumbnail/embeds/profile/index.ts b/src/lib/thumbnail/embeds/profile/index.ts
new file mode 100644
index 00000000..a42b0bc4
--- /dev/null
+++ b/src/lib/thumbnail/embeds/profile/index.ts
@@ -0,0 +1,144 @@
+import { AQUADX_URL, DATA_HOST } from "$env/static/private";
+import type { UserGameSummary } from "$lib/api/game";
+import type { User } from "$lib/api/user";
+import { formatRatingForGame, formatWithCommas, fromSegacode, type Game } from "$lib/app/formatting";
+import type { TranslationFunctions } from "$lib/i18n/i18n-types";
+
+import { Thumbnail } from "$lib/thumbnail";
+
+import { ContainerComponent, OffsetComponent } from "$lib/thumbnail/components/container";
+import { ImageComponent } from "$lib/thumbnail/components/image";
+import { LogoComponent } from "$lib/thumbnail/components/logo";
+import { RichWordComponent, WordComponent } from "$lib/thumbnail/components/word";
+import { SongComponent } from "./song";
+
+import { CornerWrapper } from "$lib/thumbnail/components/wrapper";
+import { colorPalette } from "$lib/thumbnail/palette";
+
+export class ProfileThumbnail extends Thumbnail {
+ constructor(options: {
+ summary?: UserGameSummary,
+ user?: User,
+ game?: Game,
+ locale: TranslationFunctions
+ }) {
+ super([
+ // main contents
+ new ContainerComponent([
+ new ContainerComponent([
+ new WordComponent(options.game ? options.locale.games[options.game as keyof typeof options.locale.games]() : options.locale.profile.noGamesPlayed(), {
+ size: 1,
+ color: colorPalette.TextTertinary,
+ extra: "bold"
+ })
+ ], undefined, {
+ height: 5
+ }),
+ new ContainerComponent([
+ new WordComponent(options.user?.displayName ? options.user?.displayName : fromSegacode(options.summary?.name!) ?? "", {
+ size: 3,
+ color: colorPalette.TextPrimary,
+ extra: "bold"
+ }),
+ new ContainerComponent([
+ ...(options?.user?.username ? [
+ new WordComponent(`${options.user?.displayName ? fromSegacode(options.summary?.name!) ?? "" : ""} (@${options?.user?.username})`, {
+ size: 1,
+ color: colorPalette.TextTertinary,
+ extra: "bold"
+ })
+ ] : [])
+ ], undefined, {
+ height: 5
+ }),
+ ]),
+ new ContainerComponent([
+ new RichWordComponent([
+ new WordComponent(options.locale.embed.profile.rating.a(), {
+ size: 1.25,
+ color: colorPalette.TextSecondary
+ }, true),
+ new WordComponent(options.locale.embed.profile.rating.b({rating: formatRatingForGame(options.game as Game, options?.summary?.rating ?? 0)!}), {
+ size: 1.25,
+ color: colorPalette.TextPrimary,
+ extra: "bold"
+ }, true),
+ new WordComponent(options.locale.embed.profile.rating.c(), {
+ size: 1.25,
+ color: colorPalette.TextSecondary
+ }, true),
+ new WordComponent(options.locale.embed.profile.rating.d({rank: "#" +formatWithCommas(options.summary?.serverRank ?? 0)}), {
+ size: 1.25,
+ color: colorPalette.TextPrimary,
+ extra: "bold"
+ }, true),
+ ])
+ ], undefined, {
+ height: 5
+ }),
+ // TODO: replace with bests?
+ ...(options.summary?.recent && options.summary.recent.length >= 3 ?
+ [new ContainerComponent(
+ [0, 1, 2, 3].map(idx => new ContainerComponent([
+ new SongComponent(options.summary?.recent[idx]!, options.game!)
+ ], undefined, {
+ height: 15
+ }, true))
+ )] : []
+ )
+ ], {
+ top: "50%",
+ left: 0
+ }, {
+ width: 30
+ }),
+ ...(options.user?.profilePicture ? [
+ new ContainerComponent([
+ new CornerWrapper(new ImageComponent(`${AQUADX_URL}uploads/net/portrait/${options.user?.profilePicture}`, {
+ size: {
+ heightPercent: 50
+ }
+ }), 100, 100, 100, 100)
+ ], {
+ top: "50%",
+ left: "100%"
+ }, {
+ width: -30
+ })
+ ] : []),
+ new ContainerComponent(
+ [
+ new OffsetComponent({
+ heightPercent: 10,
+ widthPercent: 17.5
+ }, [
+ {
+ leftPercent: 0,
+ topPercent: 50,
+ component: new LogoComponent({
+ size: {
+ heightPercent: 10
+ }
+ })
+ },
+ {
+ leftPercent: 100,
+ topPercent: 50,
+ component: new WordComponent(options.locale.meta.aquadx(), {
+ color: colorPalette.TextPrimary,
+ size: 1.25
+ })
+ }
+ ])
+ ], {
+ top: 0,
+ left: "100%"
+ },
+ {
+ width: -20,
+ height: 10 // offset comppnennt height makes up for this
+ }
+ ),
+ ]);
+ };
+};
\ No newline at end of file
diff --git a/src/lib/thumbnail/embeds/profile/song.ts b/src/lib/thumbnail/embeds/profile/song.ts
new file mode 100644
index 00000000..7eebae83
--- /dev/null
+++ b/src/lib/thumbnail/embeds/profile/song.ts
@@ -0,0 +1,83 @@
+import { DATA_HOST } from "$env/static/private";
+
+import type { SongServerRecent } from "$lib/api/game";
+import type { Game } from "$lib/app/formatting";
+import { gameScoringData, getGameRank } from "$lib/app/scoring.svelte";
+
+import { BoxComponent } from "$lib/thumbnail/components/box";
+import { ContainerComponent, OffsetComponent } from "$lib/thumbnail/components/container";
+import { ImageComponent } from "$lib/thumbnail/components/image";
+import { WordComponent } from "$lib/thumbnail/components/word";
+import { CornerWrapper, TransformWrapper } from "$lib/thumbnail/components/wrapper";
+import { difficultyColors, scoreColors } from "$lib/thumbnail/palette";
+
+export class SongComponent extends ContainerComponent {
+ constructor(play: SongServerRecent, game: Game) {
+ super([
+ new OffsetComponent({
+ heightPercent: 35,
+ aspect: (30 / 35)
+ }, [
+ {
+ leftPercent: 0,
+ topPercent: 0,
+ component: new CornerWrapper(new BoxComponent(difficultyColors[
+ gameScoringData[game].difficulties[play.level].toLowerCase()
+ ] ?? "white", {
+ size: {
+ heightPercent: 30
+ }
+ }), 4, 4, 4, 4)
+ },
+ {
+ leftPercent: 0,
+ topPercent: 0,
+ component: new CornerWrapper(new ImageComponent(
+ `${DATA_HOST}${game}/music/${play.musicId.toString().substring(play.musicId.toString().length - 4).padStart(6, "0")}.png`,
+ {
+ size: {
+ heightPercent: 26
+ }
+ }
+ ), 4, 0, 4, 0)
+ },
+ {
+ leftPercent: 100,
+ topPercent: 75,
+ component: new TransformWrapper(
+ new WordComponent(
+ gameScoringData[game].difficulties[play.level],
+ {
+ color: "white",
+ extra: "bold",
+ size: 0.875
+ }
+ ),
+ 270, {
+ widthPercent: 0,
+ heightPercent: 100
+ }
+ )
+ },
+ {
+ leftPercent: 50, // i don't think this is correct i'm just bullshitting
+ topPercent: 95,
+ component: new TransformWrapper(
+ new WordComponent(`${getGameRank(game, play.achievement / 10000).toString()} ${(play.achievement / 10000).toFixed(2)}%`, {
+ color: [
+ scoreColors[getGameRank(game, play.achievement / 10000).toString().toLowerCase().substring(0, 1)],
+ difficultyColors[gameScoringData[game].difficulties[play.level].toLowerCase()]
+ ]
+ }),
+ 0, {
+ widthPercent: 50,
+ heightPercent: 0
+ }
+ )
+ }
+ ], {
+ width: 5
+ })
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/src/lib/thumbnail/embeds/rankings/index.ts b/src/lib/thumbnail/embeds/rankings/index.ts
new file mode 100644
index 00000000..dfa3ceba
--- /dev/null
+++ b/src/lib/thumbnail/embeds/rankings/index.ts
@@ -0,0 +1,137 @@
+import { AQUADX_URL } from "$env/static/private";
+import type { RankedUser } from "$lib/api/game";
+import { formatRatingForGame, fromSegacode, type Game } from "$lib/app/formatting";
+import type { TranslationFunctions } from "$lib/i18n/i18n-types";
+import { Thumbnail } from "$lib/thumbnail";
+import { BoxComponent } from "$lib/thumbnail/components/box";
+
+import { ContainerComponent, OffsetComponent } from "$lib/thumbnail/components/container";
+import { ImageComponent } from "$lib/thumbnail/components/image";
+import { LogoComponent } from "$lib/thumbnail/components/logo";
+import { WordComponent } from "$lib/thumbnail/components/word";
+import { CornerWrapper, TransformWrapper } from "$lib/thumbnail/components/wrapper";
+
+import { colorPalette } from "$lib/thumbnail/palette";
+
+export class RankingsThumbnail extends Thumbnail {
+ constructor(options: {
+ top: RankedUser[],
+ game: Game,
+ locale: TranslationFunctions,
+ profileImages: Record
+ }) {
+ super([
+ new ContainerComponent(
+ [
+ new WordComponent(options.locale.embed.rankings.header({game: options.locale.games[options.game as keyof typeof options.locale.games]()}), {
+ color: colorPalette.TextPrimary,
+ size: 1.5
+ })
+ ], {
+ top: 0,
+ left: 0
+ }, {
+ width: 20,
+ height: 20,
+ }
+ ),
+ new ContainerComponent(
+ [
+ new OffsetComponent({
+ heightPercent: 10,
+ widthPercent: 17.5
+ }, [
+ {
+ leftPercent: 0,
+ topPercent: 50,
+ component: new LogoComponent({
+ size: {
+ heightPercent: 10
+ }
+ })
+ },
+ {
+ leftPercent: 100,
+ topPercent: 50,
+ component: new WordComponent(options.locale.meta.aquadx(), {
+ color: colorPalette.TextPrimary,
+ size: 1.25
+ })
+ }
+ ])
+ ], {
+ top: 0,
+ left: "100%"
+ },
+ {
+ width: -20,
+ height: 10 // offset comppnennt height makes up for this
+ }
+ ),
+ new ContainerComponent(
+ [1, 0, 2].map(idx =>
+ new OffsetComponent({
+ heightPercent: 100,
+ aspect: 0.5
+ }, [
+ {
+ leftPercent: 50,
+ topPercent: 100,
+ component: new CornerWrapper(
+ new BoxComponent(colorPalette.TextTertinary, {
+ size: {
+ widthPercent: 20,
+ heightPercent: 70 - (20 * idx)
+ }
+ }), 8, 8, 0, 0
+ )
+ },
+ {
+ leftPercent: 50,
+ topPercent: 52.5 + (15 * idx),
+ component: new WordComponent((idx + 1).toString(), {
+ color: colorPalette.TextSecondary,
+ extra: "bold",
+ size: 2
+ })
+ },
+ {
+ leftPercent: 50,
+ topPercent: 97.5,
+ component: new WordComponent(formatRatingForGame(options.game, options.top[idx].rating)!, {
+ color: colorPalette.TextSecondary
+ })
+ },
+ {
+ leftPercent: 50,
+ topPercent: 90,
+ component: new WordComponent(fromSegacode(options.top[idx].name), {
+ color: colorPalette.TextPrimary
+ })
+ },
+ {
+ leftPercent: 50,
+ topPercent: 25 + (20 * idx),
+ component: new TransformWrapper(
+ new CornerWrapper(
+ new ImageComponent(options.profileImages[options.top[idx].username] ?
+ `${AQUADX_URL}uploads/net/portrait/${options.profileImages[options.top[idx].username]}` : `static/asset/rei.png`, {
+ size: {
+ heightPercent: 20
+ }
+ }
+ ), 100, 100, 100, 100
+ ), 0, {
+ widthPercent: 50,
+ heightPercent: 50
+ }
+ )
+ }
+ ], undefined, true)
+ ), {
+ left: "50%",
+ top: "50%"
+ })
+ ]);
+ };
+};
\ No newline at end of file
diff --git a/src/lib/thumbnail/index.ts b/src/lib/thumbnail/index.ts
new file mode 100644
index 00000000..fbb34a79
--- /dev/null
+++ b/src/lib/thumbnail/index.ts
@@ -0,0 +1,123 @@
+/*
+
+This can be cleaned up if you'd like.
+Some portions of this was not thought too hard on before implementation, while others may have been over-engineered.
+
+Some notes:
+ - Margins are ONLY handled inside of containers
+ - When drawing in a component, ALWAYS re-initialize context-specific settings such as fillStyle, strokeStyle, etc.
+
+*/
+
+import { Canvas, CanvasGradient, type CanvasRenderingContext2D } from "skia-canvas";
+import type { ContainerAlignment } from "./components/container";
+import { colorPalette } from "./palette";
+
+export interface ThumbnailComponentSize {
+ width?: number,
+ height?: number,
+
+ widthPercent?: number,
+ heightPercent?: number,
+ aspect?: number
+};
+export interface ThumbnailComponentPosition {
+ top?: number,
+ left?: number
+}
+export interface ThumbnailComponentDescription {
+ position?: ThumbnailComponentPosition,
+ size?: ThumbnailComponentSize,
+ margin?: ThumbnailComponentSize
+}
+interface CanvasSize {
+ width: number,
+ height: number,
+ scaling: number
+};
+
+const thumbnailScale: CanvasSize = {
+ width: 650,
+ height: 300,
+ scaling: 15
+}
+
+export function alignToPixel(alignment: ContainerAlignment, selfSize: ThumbnailComponentSize) {
+ return {
+ top: typeof(alignment.top) == "string" ?
+ ((parseFloat(alignment.top) / 100) * (thumbnailScale.height ?? 0)) - ((selfSize.height ?? 0) * (parseFloat(alignment.top) / 100))
+ : alignment.top,
+ left: typeof(alignment.left) == "string" ?
+ ((parseFloat(alignment.left) / 100) * (thumbnailScale.width ?? 0)) - ((selfSize.width ?? 0) * (parseFloat(alignment.left) / 100))
+ : alignment.left,
+ } satisfies ThumbnailComponentPosition;
+}
+export function sizeToPixel(originalSize: ThumbnailComponentSize) {
+ let width = (((originalSize.widthPercent ?? 0) / 100) * (thumbnailScale.width ?? 0));
+ let height = (((originalSize.heightPercent ?? 0) / 100) * (thumbnailScale.height ?? 0));
+ return {
+ width: originalSize.widthPercent ? width : (height ?? 0) * (originalSize.aspect ?? 1),
+ height: originalSize.heightPercent ? height : (width ?? 0) / (originalSize.aspect ?? 1)
+ } satisfies ThumbnailComponentSize;
+}
+
+export abstract class ThumbnailBaseComponent {
+ constructor(description?: ThumbnailComponentDescription) {
+ if (description) {
+ if (description.size)
+ this.size = sizeToPixel(description.size);
+ if (description.position)
+ this.position = description.position;
+ if (description.margin)
+ this.margin = description.margin;
+ }
+ }
+ abstract draw(context: CanvasRenderingContext2D, scaling: number): Promise;
+ getSize: (context: CanvasRenderingContext2D, scaling: number) => ThumbnailComponentSize = () => {
+ return {
+ width: (this.size.width ?? 0) + ((this.margin.width ?? 0) * 2),
+ height: (this.size.height ?? 0) + ((this.margin.height ?? 0) * 2)
+ }
+ };
+
+ margin: ThumbnailComponentSize = {};
+ size: ThumbnailComponentSize = {}; // read from getSize only
+ position: ThumbnailComponentPosition = {};
+
+ inline: boolean = false;
+};
+
+export class Thumbnail {
+ constructor(initialComponents?: ThumbnailBaseComponent[]) {
+ if (initialComponents)
+ this.components = initialComponents;
+ this.canvas = new Canvas(
+ thumbnailScale.width,
+ thumbnailScale.height
+ );
+ this.context = this.canvas.getContext("2d");
+ this.context.imageSmoothingEnabled = true;
+
+ this.background = this.context.createLinearGradient(0, 0, 0, this.canvas.height);
+ this.background.addColorStop(1, colorPalette.Primary);
+ this.background.addColorStop(0, colorPalette.Secondary);
+ };
+ components: ThumbnailBaseComponent[] = [];
+ async get(): Promise {
+ if (!this.context || !this.canvas || !this.background) return;
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+
+ this.context.fillStyle = this.background;
+ this.context.fillRect(0, 0, this.canvas.width, this.canvas.height);
+
+ for (const component of this.components)
+ await component.draw(this.context!, thumbnailScale.scaling)
+
+ return await this.canvas.toBuffer("png");
+ }
+
+ private canvas: Canvas | undefined;
+ private context: CanvasRenderingContext2D | undefined;
+
+ private background: CanvasGradient | undefined;
+};
\ No newline at end of file
diff --git a/src/lib/thumbnail/palette.ts b/src/lib/thumbnail/palette.ts
new file mode 100644
index 00000000..2b8630c6
--- /dev/null
+++ b/src/lib/thumbnail/palette.ts
@@ -0,0 +1,37 @@
+export enum ColorPalette {
+ Primary,
+ Secondary,
+ Accent,
+ TextPrimary,
+ TextSecondary,
+ TextTertinary
+};
+export const colorPalette: Record = {
+ // Based on dark theme
+
+ Primary: "#0B0E14",
+ Secondary: "#171A23",
+ TextPrimary: "#fafafc",
+ TextSecondary: "#cdcdcd",
+ TextTertinary: "#666672",
+ Accent: "#6e83c0"
+};
+
+export const difficultyColors: Record = {
+ "normal": "#83d96d",
+ "advanced": "#db9a59",
+ "master": "#8150a1",
+ "expert": "#f07878",
+ "ultima": "#9a9da3",
+ "worldsend": "#d874c9",
+ "inferno": "#892323",
+ "remaster": "#de8fd2"
+}
+
+export const scoreColors: Record = {
+ "s": "#ffee94",
+ "a": "#e57a7a",
+ "b": "5b9fd7",
+ "c": "#cdd3dd",
+ "d": "#cdd3dd"
+}
\ No newline at end of file
diff --git a/src/routes/(app)/(nonuser)/home/+page.svelte b/src/routes/(app)/(nonuser)/home/+page.svelte
index 54f4b902..1bdb71b3 100644
--- a/src/routes/(app)/(nonuser)/home/+page.svelte
+++ b/src/routes/(app)/(nonuser)/home/+page.svelte
@@ -1,6 +1,6 @@
-
+
{
diff --git a/src/routes/(app)/(nonuser)/profile/[username]/[[game=game]]/+page.svelte b/src/routes/(app)/(nonuser)/profile/[username]/[[game=game]]/+page.svelte
index 3aecfd44..0314125d 100644
--- a/src/routes/(app)/(nonuser)/profile/[username]/[[game=game]]/+page.svelte
+++ b/src/routes/(app)/(nonuser)/profile/[username]/[[game=game]]/+page.svelte
@@ -13,8 +13,8 @@
import { gameScoringData, getGameRank } from "$lib/app/scoring.svelte.js"
import SongList from "$lib/components/generic/SongList.svelte"
import Song from "$lib/components/generic/Song.svelte"
- import ProfileThumbnailEmbed from "$lib/components/profile/ProfileThumbnailEmbed.svelte"
import { markdown } from "$lib/app/md.js"
+ import Embed from "$lib/components/Embed.svelte"
import { fadeLoad } from "$lib/components/loadAnimations.js"
let { data } = $props();
@@ -31,16 +31,17 @@
};
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/src/routes/(onboarding)/+layout.svelte b/src/routes/(onboarding)/+layout.svelte
index ca566881..3ad8ac48 100644
--- a/src/routes/(onboarding)/+layout.svelte
+++ b/src/routes/(onboarding)/+layout.svelte
@@ -6,14 +6,21 @@
children: Snippet,
data: LayoutData
}
- const { children }: Props = $props()
+ const { children, data }: Props = $props()
import "@fontsource-variable/inter";
import "../../app.scss"
import "../../themes.scss"
import logo from "$lib/components/logo.svg?raw"
import type { LayoutData } from "../$types"
+
+ import Embed from "$lib/components/Embed.svelte"
+