diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6596881 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 [creations.works] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 48d4c50..81bfda2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,87 @@ -# Cool little discord profile page +# Discord Profile Page -E +A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS. + +## Prerequisite: Lanyard Backend + +This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data. + +Make sure Lanyard is running and accessible before using this profile page. + +--- + +## Getting Started + +### 1. Clone & Install + +```bash +git clone https://git.creations.works/creations/profilePage.git +cd profilePage +bun install +``` + +### 2. Configure Environment + +Copy the example environment file and update it: + +```bash +cp .env.example .env +``` + +#### Required `.env` Variables + +| Variable | Description | +|--------------------|--------------------------------------------------| +| `HOST` | Host to bind the Bun server (default: `0.0.0.0`) | +| `PORT` | Port to run the server on (default: `8080`) | +| `LANYARD_USER_ID` | Your Discord user ID | +| `LANYARD_INSTANCE` | Lanyard WebSocket endpoint URL | + +#### Optional Lanyard KV Vars (per-user customization) + +These are expected to be defined in Lanyard's KV store: + +| Variable | Description | +|-----------|-------------------------------------------------------------| +| `snow` | Enables snow background effect (`true`) | +| `rain` | Enables rain background effect (`true`) | +| `readme` | URL to a README file displayed on your profile | +| `colors` | Enables avatar-based color theme (uses `node-vibrant`) | + +--- + +### 3. Start the App + +```bash +bun run start +``` + +Then open `http://localhost:8080` in your browser. + +--- + +## Docker Support + +### Build & Start with Docker Compose + +```bash +docker compose up -d --build +``` + +Make sure your `.env` file is correctly configured before starting. + +--- + +## Tech Stack + +- Bun – Runtime +- EJS – Templating +- CSS – Styling +- node-vibrant – Avatar color extraction +- Biome.js – Linting and formatting + +--- + +## License + +[MIT](/LICENSE) diff --git a/biome.json b/biome.json index 921a7a5..fa06b32 100644 --- a/biome.json +++ b/biome.json @@ -17,6 +17,12 @@ "organizeImports": { "enabled": true }, + "css": { + "formatter": { + "indentStyle": "tab", + "lineEnding": "lf" + } + }, "linter": { "enabled": true, "rules": { diff --git a/package.json b/package.json index 793fe71..4627118 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "start": "bun run src/index.ts", "dev": "bun run --hot src/index.ts --dev", - "lint": "bunx biome check", + "lint": "bunx biome ci . --verbose", "lint:fix": "bunx biome check --fix", "cleanup": "rm -rf logs node_modules bun.lockdb" }, diff --git a/public/css/index.css b/public/css/index.css index 1d8ad00..ac5f70f 100644 --- a/public/css/index.css +++ b/public/css/index.css @@ -1,34 +1,3 @@ -:root { - --background: #0e0e10; - --card-bg: #1e1f22; - --card-hover-bg: #2a2a2d; - --border-color: #2e2e30; - - --text-color: #ffffff; - --text-subtle: #bbb; - --text-secondary: #b5bac1; - --text-muted: #888; - --link-color: #00b0f4; - - --status-online: #23a55a; - --status-idle: #f0b232; - --status-dnd: #e03e3e; - --status-offline: #747f8d; - --status-streaming: #b700ff; - - --progress-bg: #f23f43; - --progress-fill: #5865f2; - - --button-bg: #5865f2; - --button-hover-bg: #4752c4; - --button-disabled-bg: #2d2e31; - - --blockquote-color: #aaa; - --code-bg: #2e2e30; - - --readme-bg: #1a1a1d; -} - body { font-family: system-ui, sans-serif; background-color: var(--background); diff --git a/src/helpers/colors.ts b/src/helpers/colors.ts new file mode 100644 index 0000000..43a74e7 --- /dev/null +++ b/src/helpers/colors.ts @@ -0,0 +1,49 @@ +import { fetch } from "bun"; +import { Vibrant } from "node-vibrant/node"; + +export async function getImageColors( + url: string, + hex?: boolean, +): Promise { + if (!url) return null; + + if (typeof url !== "string" || !url.startsWith("http")) return null; + + let res: Response; + try { + res = await fetch(url); + } catch { + return null; + } + + if (!res.ok) return null; + + const type: string | null = res.headers.get("content-type"); + if (!type?.startsWith("image/")) return null; + + const buffer: Buffer = Buffer.from(await res.arrayBuffer()); + const base64: string = buffer.toString("base64"); + const colors: Palette = await Vibrant.from(buffer).getPalette(); + + return { + img: `data:${type};base64,${base64}`, + colors: hex + ? { + Muted: rgbToHex(safeRgb(colors.Muted)), + LightVibrant: rgbToHex(safeRgb(colors.LightVibrant)), + Vibrant: rgbToHex(safeRgb(colors.Vibrant)), + LightMuted: rgbToHex(safeRgb(colors.LightMuted)), + DarkVibrant: rgbToHex(safeRgb(colors.DarkVibrant)), + DarkMuted: rgbToHex(safeRgb(colors.DarkMuted)), + } + : colors, + }; +} + +function safeRgb(swatch: Swatch | null | undefined): number[] { + return Array.isArray(swatch?.rgb) ? (swatch.rgb ?? [0, 0, 0]) : [0, 0, 0]; +} + +export function rgbToHex(rgb: number[]): string { + return `#${rgb.map((c) => Math.round(c).toString(16).padStart(2, "0")).join("")}`; +} diff --git a/src/routes/api/colors.ts b/src/routes/api/colors.ts index 9e05bd3..ef97012 100644 --- a/src/routes/api/colors.ts +++ b/src/routes/api/colors.ts @@ -1,7 +1,4 @@ -import { fetch } from "bun"; -import { Vibrant } from "node-vibrant/node"; - -type Palette = Awaited>; +import { getImageColors } from "@helpers/colors"; const routeDef: RouteDef = { method: "GET", @@ -12,46 +9,21 @@ const routeDef: RouteDef = { async function handler(request: ExtendedRequest): Promise { const { url } = request.query; - if (!url) { - return Response.json({ error: "URL is required" }, { status: 400 }); + const result: ImageColorResult | null = await getImageColors(url, true); + await getImageColors(url); + + if (!result) { + return new Response("Invalid URL", { + status: 400, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": "*", + }, + }); } - if (typeof url !== "string" || !url.startsWith("http")) { - return Response.json({ error: "Invalid URL" }, { status: 400 }); - } - - let res: Response; - try { - res = await fetch(url); - } catch { - return Response.json({ error: "Failed to fetch image" }, { status: 500 }); - } - - if (!res.ok) { - return Response.json( - { error: "Image fetch returned error" }, - { status: res.status }, - ); - } - - const type: string | null = res.headers.get("content-type"); - if (!type?.startsWith("image/")) { - return Response.json({ error: "Not an image" }, { status: 400 }); - } - - const buffer: Buffer = Buffer.from(await res.arrayBuffer()); - const base64: string = buffer.toString("base64"); - const colors: Palette = await Vibrant.from(buffer).getPalette(); - - const payload: { - img: string; - colors: Palette; - } = { - img: `data:${type};base64,${base64}`, - colors, - }; - - const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(payload)); + const compressed: Uint8Array = Bun.gzipSync(JSON.stringify(result)); return new Response(compressed, { headers: { diff --git a/src/routes/index.ts b/src/routes/index.ts index a15fa81..b0d6a61 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,4 @@ +import { getImageColors } from "@/helpers/colors"; import { lanyardConfig } from "@config/environment"; import { renderEjsTemplate } from "@helpers/ejs"; import { getLanyardData, handleReadMe } from "@helpers/lanyard"; @@ -37,6 +38,14 @@ async function handler(): Promise { status = presence.discord_status; } + let colors: ImageColorResult | null = null; + if (presence.kv.colors === "true") { + const avatar: string = presence.discord_user.avatar + ? `https://cdn.discordapp.com/avatars/${presence.discord_user.id}/${presence.discord_user.avatar}` + : `https://cdn.discordapp.com/embed/avatars/${presence.discord_user.discriminator || 1 % 5}`; + colors = await getImageColors(avatar, true); + } + const ejsTemplateData: EjsTemplateData = { title: presence.discord_user.global_name || presence.discord_user.username, username: @@ -53,6 +62,7 @@ async function handler(): Promise { readme, allowSnow: presence.kv.snow === "true", allowRain: presence.kv.rain === "true", + colors: colors?.colors ?? {}, }; return await renderEjsTemplate("index", ejsTemplateData); diff --git a/src/views/index.ejs b/src/views/index.ejs index 77fadd4..0db4f4b 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -24,6 +24,8 @@ +<%- include('partial/style.ejs') %> +
diff --git a/src/views/partial/style.ejs b/src/views/partial/style.ejs new file mode 100644 index 0000000..cccf3a2 --- /dev/null +++ b/src/views/partial/style.ejs @@ -0,0 +1,31 @@ + diff --git a/types/routes.d.ts b/types/routes.d.ts index 9d9d809..6af6a4b 100644 --- a/types/routes.d.ts +++ b/types/routes.d.ts @@ -13,3 +13,10 @@ type RouteModule = { ) => Promise | Response; routeDef: RouteDef; }; + +type Palette = Awaited>; +type Swatch = Awaited>; +type ImageColorResult = { + img: string; + colors: Palette | Record; +};