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: { - /** - * T​h​i​s​ ​u​s​e​r​ ​h​a​s​ ​n​o​t​ ​p​l​a​y​e​d​ ​a​n​y​ ​g​a​m​e​s - */ - noGamesPlayed: string - /** - * {​u​s​e​r​}​ ​i​s​ ​l​e​a​d​e​r​b​o​a​r​d​ ​r​a​n​k​ ​#​{​r​a​n​k​}​ ​w​i​t​h​ ​r​a​t​i​n​g​ ​{​r​a​t​i​n​g​} - * @param {string} rank - * @param {string} rating - * @param {string} user - */ - description: RequiredParams<'rank' | 'rating' | 'user'> - /** - * {​u​s​e​r​}​ ​h​a​s​ ​n​o​t​ ​p​l​a​y​e​d​ ​o​n​ ​{​a​q​u​a​d​x​} - * @param {string} aquadx - * @param {string} user - */ - descriptionNoGame: RequiredParams<'aquadx' | 'user'> + rankings: { + /** + * {​g​a​m​e​}​ ​R​a​n​k​i​n​g​s + * @param {string} game + */ + description: RequiredParams<'game'> + } + profile: { + rating: { + /** + * R​a​t​i​n​g​ + */ + a: string + /** + * {​r​a​t​i​n​g​} + * @param {string} rating + */ + b: RequiredParams<'rating'> + /** + * ​r​a​n​k​ + */ + c: string + /** + * {​r​a​n​k​} + * @param {string} rank + */ + d: RequiredParams<'rank'> + } + /** + * {​u​s​e​r​}​ ​i​s​ ​a​n​ ​{​a​q​u​a​d​x​}​ ​u​s​e​r​ ​w​i​t​h​ ​r​a​t​i​n​g​ ​{​r​a​t​i​n​g​}​ ​a​n​d​ ​l​e​a​d​e​r​b​o​a​r​d​ ​r​a​n​k​ ​#​{​r​a​n​k​} + * @param {string} aquadx + * @param {string} rank + * @param {string} rating + * @param {string} user + */ + description: RequiredParams<'aquadx' | 'rank' | 'rating' | 'user'> + /** + * {​u​s​e​r​}​ ​i​s​ ​a​n​ ​{​a​q​u​a​d​x​}​ ​u​s​e​r​. + * @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 @@ }; -
diff --git a/src/routes/(app)/(nonuser)/rankings/[[game=game]]/+page.svelte b/src/routes/(app)/(nonuser)/rankings/[[game=game]]/+page.svelte index d6d1b5d1..730b30ed 100644 --- a/src/routes/(app)/(nonuser)/rankings/[[game=game]]/+page.svelte +++ b/src/routes/(app)/(nonuser)/rankings/[[game=game]]/+page.svelte @@ -5,6 +5,7 @@ import LL from "$lib/i18n/i18n-svelte" import LeaderboardPage from "./LeaderboardPage.svelte" import Cap from "./Cap.svelte" + import Embed from "$lib/components/Embed.svelte" const { data } = $props() @@ -15,9 +16,14 @@ $effect(() => { earliestPage = data.page loadedPages = [ data.pageRankings ] - }) + }); + +
\ 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" +