Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
Loading items

Target

Select target project
  • creations/profilePage
1 result
Select Git revision
Loading items
Show changes

Commits on Source 51

......@@ -2,6 +2,18 @@
HOST=0.0.0.0
PORT=8080
REDIS_URL=redis://dragonfly:6379
REDIS_TTL=3600 # seconds
# this is only the default value if non is give in /id
LANYARD_USER_ID=id-here
LANYARD_INSTANCE=https://lanyard.rest
# Required if you want to enable badges
BADGE_API_URL=http://localhost:8081
# https://www.steamgriddb.com/api/v2, if you want games to have images
STEAMGRIDDB_API_KEY=steamgrid_api_key
# https://plausible.io
PLAUSIBLE_SCRIPT_HTML=''
......@@ -2,3 +2,4 @@
bun.lock
.env
logs/
.vscode/
{
"github-enterprise.uri": "https://git.creations.works"
}
# use the official Bun image
# see all versions at https://hub.docker.com/r/oven/bun/tags
FROM oven/bun:latest AS base
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
# install with --production (exclude devDependencies)
RUN mkdir -p /temp/prod
COPY package.json bun.lock /temp/prod/
RUN cd /temp/prod && bun install --frozen-lockfile --production
# copy node_modules from temp directory
# then copy all (non-ignored) project files into the image
FROM base AS prerelease
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
# [optional] tests & build
ENV NODE_ENV=production
# copy production dependencies and source code into final image
FROM base AS release
COPY --from=install /temp/prod/node_modules node_modules
COPY --from=prerelease /usr/src/app/src ./src
COPY --from=prerelease /usr/src/app/public ./public
COPY --from=prerelease /usr/src/app/package.json .
COPY --from=prerelease /usr/src/app/tsconfig.json .
COPY --from=prerelease /usr/src/app/config ./config
COPY --from=prerelease /usr/src/app/types ./types
RUN mkdir -p /usr/src/app/logs && chown bun:bun /usr/src/app/logs
USER bun
ENTRYPOINT [ "bun", "run", "start" ]
MIT License
BSD 3-Clause License
Copyright (c) 2025 [creations.works]
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:
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
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.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# Discord Profile Page
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun and EJS.
A cool little web app that shows your Discord profile, current activity, and more. Built with Bun.
## Prerequisite: Lanyard Backend
# Preview
https://creations.works
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance for Discord presence data.
---
## Requirements
This project depends on the following services to function properly:
### 1. Lanyard Backend
This project depends on a self-hosted or public [Lanyard](https://github.com/Phineas/lanyard) instance to fetch real-time Discord presence data.
Make sure the Lanyard instance is running and accessible before using this.
### 2. Redis Instance
Make sure Lanyard is running and accessible before using this profile page.
A Redis-compatible key-value store is required to cache third-party data (e.g., SteamGridDB icons).
I recommend [Dragonfly](https://www.dragonflydb.io/), a high-performance drop-in replacement for Redis.
### 3. Badge API
A lightweight API to render Discord-style badges.
>Only needed if you want to show badges on profiles:
https://git.creations.works/creations/badgeAPI
### 4. SteamGridDB
>Only needed if you want to fetch game icons that Discord doesn’t provide:
https://www.steamgriddb.com/api/v2
---
......@@ -28,35 +52,60 @@ Copy the example environment file and update it:
cp .env.example .env
```
#### Required `.env` Variables
#### `.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 |
| `REDIS_URL` | Redis connection string |
| `LANYARD_USER_ID` | Your Discord user ID, for the default page |
| `LANYARD_INSTANCE` | Endpoint of the Lanyard instance |
| `BADGE_API_URL` | Badge API URL ([badgeAPI](https://git.creations.works/creations/badgeAPI)) |
| `STEAMGRIDDB_API_KEY` | SteamGridDB API key for fetching game icons |
#### Optional Lanyard KV Vars (per-user customization)
#### Optional Lanyard KV Variables (per-user customization)
These are expected to be defined in Lanyard's KV store:
These can be defined in Lanyard's KV store to customize the page:
| 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`) |
|-----------|--------------------------------------------------------------------|
| `snow` | Enables snow background (`true` / `false`) |
| `rain` | Enables rain background (`true` / `false`) |
| `stars` | Enables starfield background (`true` / `false`) |
| `badges` | Enables badge fetching (`true` / `false`) |
| `readme` | URL to a README displayed on the profile (`.md` or `.html`) |
| `css` | URL to a css to change styles on the page, no import or require allowed |
---
### 3. Start the App
### 3. Start the Instance
```bash
bun run start
```
Then open `http://localhost:8080` in your browser.
---
## Optional: Analytics with Plausible
You can enable [Plausible Analytics](https://plausible.io) tracking by setting a script snippet in your environment.
### `.env` Variable
| Variable | Description |
|-------------------------|------------------------------------------------------------------------|
| `PLAUSIBLE_SCRIPT_HTML` | Full `<script>` tag(s) to inject into the `<head>` for analytics |
#### Example
```env
PLAUSIBLE_SCRIPT_HTML='<script defer data-domain="example.com" src="https://plausible.example.com/js/script.js"></script><script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>'
```
- The script will only be injected if this variable is set.
- Plausible provides the correct script when you add a domain.
- Be sure to wrap it in single quotes (`'`) so it works in `.env`.
---
......@@ -68,20 +117,23 @@ Then open `http://localhost:8080` in your browser.
docker compose up -d --build
```
Make sure your `.env` file is correctly configured before starting.
Make sure the `.env` file is configured correctly before starting the container.
---
## Tech Stack
## Routes
These are the main public routes exposed by the server:
| Route | Description |
|---------|-----------------------------------------------------------------------------|
| `/` | Loads the profile page for the default Discord user defined in `.env` (`LANYARD_USER_ID`) |
| `/[id]` | Loads the profile page for a specific Discord user ID passed in the URL |
- Bun – Runtime
- EJS – Templating
- CSS – Styling
- node-vibrant – Avatar color extraction
- Biome.js – Linting and formatting
> Example: `https://creations.works/209830981060788225` shows the profile of that specific user.
---
## License
[MIT](/LICENSE)
[BSD 3](LICENSE)
......@@ -26,7 +26,10 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"correctness": {
"noUnusedImports": "error"
}
}
},
"javascript": {
......
services:
profile-page:
container_name: profilePage
build:
context: .
restart: unless-stopped
ports:
- "${PORT:-6600}:${PORT:-6600}"
env_file:
- .env
networks:
- profilePage-network
dragonfly:
image: 'docker.dragonflydb.io/dragonflydb/dragonfly'
restart: unless-stopped
ulimits:
memlock: -1
volumes:
- dragonflydata:/data
networks:
- profilePage-network
volumes:
dragonflydata:
networks:
profilePage-network:
driver: bridge
......@@ -5,7 +5,18 @@ export const environment: Environment = {
process.env.NODE_ENV === "development" || process.argv.includes("--dev"),
};
export const redisTtl: number = process.env.REDIS_TTL
? Number.parseInt(process.env.REDIS_TTL, 10)
: 60 * 60 * 1; // 1 hour
export const lanyardConfig: LanyardConfig = {
userId: process.env.LANYARD_USER_ID || "",
instance: process.env.LANYARD_INSTANCE || "https://api.lanyard.rest",
};
export const badgeApi: string | null = process.env.BADGE_API_URL || null;
export const steamGridDbKey: string | undefined =
process.env.STEAMGRIDDB_API_KEY;
export const plausibleScript: string | null =
process.env.PLAUSIBLE_SCRIPT_HTML?.trim() || null;
......@@ -12,16 +12,13 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/bun": "^1.2.8",
"@types/ejs": "^3.1.5",
"globals": "^16.0.0"
},
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
"ejs": "^3.1.10",
"isomorphic-dompurify": "^2.23.0",
"marked": "^15.0.7",
"node-vibrant": "^4.0.3"
"@creations.works/logger": "^1.0.3",
"marked": "^15.0.7"
}
}
public/assets/favicon.ico

15 KiB | W: 48px | H: 48px

public/assets/favicon.ico

168 KiB | W: 256px | H: 256px

public/assets/favicon.ico
public/assets/favicon.ico
public/assets/favicon.ico
public/assets/favicon.ico
  • 2-up
  • Swipe
  • Onion skin
public/assets/favicon.png

9.04 KiB

<svg viewBox="0 0 212 212" xmlns="http://www.w3.org/2000/svg">
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<rdf:RDF>
<cc:Work rdf:about="https://codeberg.org/forgejo/meta/src/branch/readme/branding#logo">
<dc:title>Forgejo logo</dc:title>
<cc:creator rdf:resource="https://caesarschinas.com/"><cc:attributionName>Caesar Schinas</cc:attributionName></cc:creator>
<cc:license rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/"/>
</cc:Work>
</rdf:RDF>
</metadata>
<style type="text/css">
circle {
fill: none;
stroke: #000;
stroke-width: 15;
}
path {
fill: none;
stroke: #000;
stroke-width: 25;
}
.orange {
stroke:#ff6600;
}
.red {
stroke:#d40000;
}
</style>
<g transform="translate(6,6)">
<path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" class="orange"/>
<path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" class="red"/>
<circle cx="142" cy="20" r="18" class="orange"/>
<circle cx="142" cy="88" r="18" class="red"/>
<circle cx="58" cy="180" r="18" class="red"/>
</g>
</svg>
\ No newline at end of file
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star,
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
}
.star {
animation: twinkle ease-in-out infinite alternate;
}
.shooting-star {
position: absolute;
background: linear-gradient(90deg, white, transparent);
width: 100px;
height: 2px;
opacity: 0.8;
border-radius: 2px;
transform-origin: left center;
}
@keyframes twinkle {
from {
opacity: 0.3;
transform: scale(1);
}
to {
opacity: 1;
transform: scale(1.2);
}
}
#loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
transition: opacity 0.5s ease;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid var(--border-color);
border-top: 5px solid var(--progress-fill);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* actual styles below */
body {
font-family: system-ui, sans-serif;
background-color: var(--background);
......@@ -9,24 +85,37 @@ body {
align-items: center;
}
.snowflake {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
main {
width: 100%;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.raindrop {
position: absolute;
background-color: white;
border-radius: 50%;
pointer-events: none;
z-index: 1;
.open-source-logo {
width: 2rem;
height: 2rem;
margin: 0;
padding: 0;
cursor: pointer;
position: fixed;
bottom: 1rem;
right: 0.5rem;
z-index: 1000;
opacity: 0.5;
transition: opacity 0.3s ease;
&:hover {
opacity: 1 !important;
}
}
.hidden {
display: none;
display: none !important;
}
.activity-header.hidden {
......@@ -38,14 +127,17 @@ body {
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
max-width: 600px;
max-width: 700px;
width: 100%;
}
.avatar-status-wrapper {
display: flex;
align-items: center;
gap: 1.5rem;
gap: 2rem;
width: fit-content;
max-width: 700px;
}
.avatar-wrapper {
......@@ -60,6 +152,29 @@ body {
border-radius: 50%;
}
.badges {
max-width: 700px;
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: 0.3rem;
flex-wrap: wrap;
margin-top: 0.5rem;
padding: 0.5rem;
background-color: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--border-color);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.badge {
width: 26px;
height: 26px;
border-radius: 50%;
}
.decoration {
position: absolute;
top: -18px;
......@@ -104,11 +219,30 @@ body {
.platform-icon.mobile-only {
position: absolute;
bottom: 4px;
bottom: 0;
right: 4px;
width: 30px;
height: 30px;
pointer-events: none;
background-color: var(--background);
padding: 0.3rem 0.1rem;
border-radius: 8px;
}
.platform-icon.mobile-only.dnd {
fill: var(--status-dnd);
}
.platform-icon.mobile-only.idle {
fill: var(--status-idle);
}
.platform-icon.mobile-only.online {
fill: var(--status-online);
}
.platform-icon.mobile-only.offline {
fill: var(--status-offline);
}
.platform-icon.mobile-only.streaming {
fill: var(--status-streaming);
}
.user-info {
......@@ -116,6 +250,56 @@ body {
flex-direction: column;
}
.user-info-inner {
display: flex;
flex-direction: row;
align-items: center;
text-align: center;
gap: 0.5rem;
}
.user-info-inner a {
text-decoration: none;
color: var(--link-color);
}
.user-info-inner h1 {
font-size: 2rem;
margin: 0;
}
.clan-badge {
width: fit-content;
height: fit-content;
border-radius: 8px;
background-color: var(--card-bg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem 0.5rem;
text-align: center;
align-items: center;
justify-content: center;
}
.clan-badge img {
width: 20px;
height: 20px;
margin: 0;
padding: 0;
}
.clan-badge span {
font-size: 0.9rem;
color: var(--text-color);
margin: 0;
font-weight: 600;
}
h1 {
font-size: 2.5rem;
margin: 0;
......@@ -152,7 +336,26 @@ ul {
list-style: none;
padding: 0;
width: 100%;
max-width: 600px;
max-width: 700px;
}
.activities-section {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 700px;
box-sizing: border-box;
padding: 0;
margin: 0;
}
.activities-section .activity-block-header {
margin: 1rem 0 .5rem;
font-size: 2rem;
font-weight: 600;
text-align: center;
}
.activities {
......@@ -160,7 +363,8 @@ ul {
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 600px;
max-width: 700px;
box-sizing: border-box;
padding: 0;
margin: 0;
}
......@@ -173,11 +377,13 @@ ul {
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid var(--border-color);
}
.activity:hover {
transition: background-color 0.3s ease;
&:hover {
background: var(--card-hover-bg);
}
}
.activity-wrapper {
display: flex;
......@@ -197,6 +403,10 @@ ul {
height: 80px;
}
.no-asset {
display: none !important;
}
.activity-image-small {
width: 25px;
height: 25px;
......@@ -204,6 +414,7 @@ ul {
object-fit: cover;
flex-shrink: 0;
border-color: var(--card-bg);
background-color: var(--card-bg);
border-width: 2px;
border-style: solid;
......@@ -304,7 +515,7 @@ ul {
text-transform: uppercase;
font-weight: 600;
color: var(--blockquote-color);
margin-bottom: 0.50rem;
margin-bottom: 0.5rem;
display: block;
}
......@@ -335,18 +546,18 @@ ul {
text-decoration: none;
transition: background-color 0.2s ease;
display: inline-block;
}
.activity-button:hover {
&:hover {
background-color: var(--button-hover-bg);
text-decoration: none;
}
.activity-button:disabled {
&:disabled {
background-color: var(--button-disabled-bg);
cursor: not-allowed;
opacity: 0.8;
}
}
@media (max-width: 600px) {
html {
......@@ -362,6 +573,16 @@ ul {
.user-card {
width: 100%;
align-items: center;
margin-top: 2rem;
}
.badges {
max-width: 100%;
border-radius: 0;
border: none;
background-color: transparent;
margin-top: 0;
box-shadow: none;
}
.avatar-status-wrapper {
......@@ -494,14 +715,16 @@ ul {
/* readme :p */
.readme {
max-width: 700px;
max-width: fit-content;
min-width: 700px;
overflow: hidden;
width: 100%;
background: var(--readme-bg);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
margin-top: 2rem;
margin-top: 1rem;
box-sizing: border-box;
overflow: hidden;
......@@ -579,7 +802,8 @@ ul {
@media (max-width: 600px) {
.readme {
width: 100%;
max-width: 100%;
min-width: 100%;
padding: 1rem;
margin-top: 1rem;
......
:root {
--background: #0e0e10;
--readme-bg: #1a1a1d;
--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;
--button-bg: #5865f2;
--button-hover-bg: #4752c4;
--button-disabled-bg: #2d2e31;
--progress-bg: #f23f43;
--progress-fill: #5865f2;
--status-online: #23a55a;
--status-idle: #f0b232;
--status-dnd: #e03e3e;
--status-offline: #747f8d;
--status-streaming: #b700ff;
--blockquote-color: #aaa;
--code-bg: #2e2e30;
}
const head = document.querySelector("head");
const userId = head?.dataset.userId;
const activityProgressMap = new Map();
let instanceUri = head?.dataset.instanceUri;
let badgeURL = head?.dataset.badgeUrl;
let socket;
let badgesLoaded = false;
function formatTime(ms) {
const totalSecs = Math.floor(ms / 1000);
const hours = Math.floor(totalSecs / 3600);
......@@ -78,56 +85,14 @@ function updateElapsedAndProgress() {
}
}
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);
const head = document.querySelector("head");
const userId = head?.dataset.userId;
let instanceUri = head?.dataset.instanceUri;
if (userId && instanceUri) {
if (!instanceUri.startsWith("http")) {
instanceUri = `https://${instanceUri}`;
}
const wsUri = instanceUri
.replace(/^http:/, "ws:")
.replace(/^https:/, "wss:")
.replace(/\/$/, "");
function loadEffectScript(effect) {
const existing = document.querySelector(`script[data-effect="${effect}"]`);
if (existing) return;
const socket = new WebSocket(`${wsUri}/socket`);
let heartbeatInterval = null;
socket.addEventListener("open", () => {});
socket.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
if (payload.op === 1 && payload.d?.heartbeat_interval) {
heartbeatInterval = setInterval(() => {
socket.send(JSON.stringify({ op: 3 }));
}, payload.d.heartbeat_interval);
socket.send(
JSON.stringify({
op: 2,
d: {
subscribe_to_id: userId,
},
}),
);
}
if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
updatePresence(payload.d);
requestAnimationFrame(() => updateElapsedAndProgress());
}
});
socket.addEventListener("close", () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
});
const script = document.createElement("script");
script.src = `/public/js/${effect}.js`;
script.dataset.effect = effect;
document.head.appendChild(script);
}
function resolveActivityImage(img, applicationId) {
......@@ -146,6 +111,11 @@ function resolveActivityImage(img, applicationId) {
return `https://i.scdn.co/image/${img.split(":")[1]}`;
}
if (img.startsWith("twitch:")) {
const username = img.split(":")[1];
return `https://static-cdn.jtvnw.net/previews-ttv/live_user_${username}-440x248.jpg`;
}
return `https://cdn.discordapp.com/app-assets/${applicationId}/${img}.png`;
}
......@@ -201,10 +171,7 @@ function buildActivityHTML(activity) {
</div>`
: "";
const activityButtons =
activity.buttons && activity.buttons.length > 0
? `<div class="activity-buttons">
${activity.buttons
const buttons = (activity.buttons || [])
.map((button, index) => {
const label = typeof button === "string" ? button : button.label;
let url = null;
......@@ -217,9 +184,22 @@ function buildActivityHTML(activity) {
? `<a href="${url}" class="activity-button" target="_blank" rel="noopener noreferrer">${label}</a>`
: null;
})
.filter(Boolean)
.join("")}
</div>`
.filter(Boolean);
if (!buttons.length && activity.name === "Twitch" && activity.url) {
buttons.push(
`<a href="${activity.url}" class="activity-button" target="_blank" rel="noopener noreferrer">Watch on Twitch</a>`,
);
}
if (activity.name === "Spotify" && activity.sync_id) {
buttons.push(
`<a href="https://open.spotify.com/track/${activity.sync_id}" class="activity-button" target="_blank" rel="noopener noreferrer">Listen on Spotify</a>`,
);
}
const activityButtons = buttons.length
? `<div class="activity-buttons">${buttons.join("")}</div>`
: "";
const progressBar =
......@@ -239,12 +219,15 @@ function buildActivityHTML(activity) {
const secondaryLine = isMusic ? activity.state : activity.details;
const tertiaryLine = isMusic ? activity.assets?.large_text : activity.state;
const activityArt = art
? `<div class="activity-image-wrapper">
<img class="activity-image" src="${art}" alt="Art" ${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}>
${smallArt ? `<img class="activity-image-small" src="${smallArt}" alt="Small Art" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>` : ""}
</div>`
: "";
const activityArt = `<div class="activity-image-wrapper ${art ?? "no-asset"}">
<img
class="activity-image${!art ? " no-asset" : ""}"
src="${art ?? ""}"
data-name="${activity.name}"
${activity.assets?.large_text ? `title="${activity.assets.large_text}"` : ""}
/>
${`<img class="activity-image-small ${smallArt ?? "no-asset"}" src="${smallArt ?? ""}" ${activity.assets?.small_text ? `title="${activity.assets.small_text}"` : ""}>`}
</div>`;
return `
<li class="activity">
......@@ -276,13 +259,170 @@ function buildActivityHTML(activity) {
`;
}
function updatePresence(data) {
async function loadBadges(userId, options = {}) {
const {
services = [],
seperated = false,
cache = true,
targetId = "badges",
serviceOrder = [],
} = options;
const params = new URLSearchParams();
if (services.length) params.set("services", services.join(","));
if (seperated) params.set("seperated", "true");
if (!cache) params.set("cache", "false");
const url = `${badgeURL}${userId}?${params.toString()}`;
const target = document.getElementById(targetId);
if (!target) return;
target.classList.add("hidden");
try {
const res = await fetch(url);
const json = await res.json();
if (
!res.ok ||
!json.badges ||
Object.values(json.badges).every(
(arr) => !Array.isArray(arr) || arr.length === 0,
)
) {
target.textContent = "Failed to load badges.";
return;
}
target.innerHTML = "";
const badgesByService = json.badges;
const renderedServices = new Set();
const renderBadges = (badges) => {
for (const badge of badges) {
const img = document.createElement("img");
img.src = badge.badge;
img.alt = badge.tooltip;
img.title = badge.tooltip;
img.className = "badge";
target.appendChild(img);
}
};
for (const serviceName of serviceOrder) {
const badges = badgesByService[serviceName];
if (Array.isArray(badges) && badges.length) {
renderBadges(badges);
renderedServices.add(serviceName);
}
}
for (const [serviceName, badges] of Object.entries(badgesByService)) {
if (renderedServices.has(serviceName)) continue;
if (Array.isArray(badges) && badges.length) {
renderBadges(badges);
}
}
target.classList.remove("hidden");
} catch (err) {
console.error(err);
target.innerHTML = "";
target.classList.add("hidden");
}
}
async function populateReadme(data) {
const readmeSection = document.querySelector(".readme");
if (readmeSection && data.kv?.readme) {
const url = data.kv.readme;
try {
const res = await fetch(`/api/readme?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error("Failed to fetch readme");
const text = await res.text();
readmeSection.innerHTML = `<div class="markdown-body">${text}</div>`;
readmeSection.classList.remove("hidden");
} catch (err) {
console.error("Failed to load README", err);
readmeSection.classList.add("hidden");
}
} else if (readmeSection) {
readmeSection.classList.add("hidden");
}
}
async function updatePresence(data) {
const cssLink = data.kv?.css;
if (cssLink) {
try {
const res = await fetch(`/api/css?url=${encodeURIComponent(cssLink)}`);
if (!res.ok) throw new Error("Failed to fetch CSS");
const cssText = await res.text();
const style = document.createElement("style");
style.textContent = cssText;
document.head.appendChild(style);
} catch (err) {
console.error("Failed to load CSS", err);
}
}
if (!badgesLoaded && data && data.kv.badges !== "false") {
loadBadges(userId, {
services: [],
seperated: true,
cache: true,
targetId: "badges",
serviceOrder: ["discord", "equicord", "reviewdb", "vencord"],
});
badgesLoaded = true;
}
const avatarWrapper = document.querySelector(".avatar-wrapper");
const statusIndicator = avatarWrapper?.querySelector(".status-indicator");
const mobileIcon = avatarWrapper?.querySelector(".platform-icon.mobile-only");
const avatarImg = avatarWrapper?.querySelector(".avatar");
const usernameEl = document.querySelector(".username");
if (!data.discord_user) {
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.innerHTML = `
<div class="error-message">
<p>Failed to load user data.</p>
</div>
`;
loadingOverlay.style.opacity = "1";
avatarWrapper.classList.add("hidden");
avatarImg.classList.add("hidden");
usernameEl.classList.add("hidden");
document.title = "Error";
}
return;
}
const userInfo = document.querySelector(".user-info");
const customStatus = userInfo?.querySelector(".custom-status");
if (avatarImg && data.discord_user?.avatar) {
const newAvatarUrl = `https://cdn.discordapp.com/avatars/${data.discord_user.id}/${data.discord_user.avatar}`;
avatarImg.src = newAvatarUrl;
avatarImg.classList.remove("hidden");
const siteIcon = document.getElementById("site-icon");
if (siteIcon) {
siteIcon.href = newAvatarUrl;
}
}
if (usernameEl) {
const username =
data.discord_user.global_name || data.discord_user.username;
usernameEl.innerHTML = `<a href="https://discord.com/users/${data.discord_user.id}" target="_blank" rel="noopener noreferrer">${username}</a>`;
document.title = username;
}
updateClanBadge(data);
const platform = {
mobile: data.active_on_discord_mobile,
......@@ -297,37 +437,60 @@ function updatePresence(data) {
status = data.discord_status;
}
if (statusIndicator) {
statusIndicator.className = `status-indicator ${status}`;
for (const el of avatarWrapper.querySelectorAll(".platform-icon")) {
const platformType = ["mobile-only", "desktop-only", "web-only"].find(
(type) => el.classList.contains(type),
);
if (!platformType) continue;
const active =
(platformType === "mobile-only" && platform.mobile) ||
(platformType === "desktop-only" && platform.desktop) ||
(platformType === "web-only" && platform.web);
if (!active) {
el.remove();
} else {
el.setAttribute("class", `platform-icon ${platformType} ${status}`);
}
}
if (platform.mobile && !mobileIcon) {
avatarWrapper.innerHTML += `
<svg class="platform-icon mobile-only" viewBox="0 0 1000 1500" fill="#43a25a" aria-label="Mobile" width="17" height="17">
if (
platform.mobile &&
!avatarWrapper.querySelector(".platform-icon.mobile-only")
) {
const mobileIcon = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
mobileIcon.setAttribute("class", `platform-icon mobile-only ${status}`);
mobileIcon.setAttribute("viewBox", "0 0 1000 1500");
mobileIcon.setAttribute("fill", "#43a25a");
mobileIcon.setAttribute("aria-label", "Mobile");
mobileIcon.setAttribute("width", "17");
mobileIcon.setAttribute("height", "17");
mobileIcon.innerHTML = `
<path d="M 187 0 L 813 0 C 916.277 0 1000 83.723 1000 187 L 1000 1313 C 1000 1416.277 916.277 1500 813 1500 L 187 1500 C 83.723 1500 0 1416.277 0 1313 L 0 187 C 0 83.723 83.723 0 187 0 Z M 125 1000 L 875 1000 L 875 250 L 125 250 Z M 500 1125 C 430.964 1125 375 1180.964 375 1250 C 375 1319.036 430.964 1375 500 1375 C 569.036 1375 625 1319.036 625 1250 C 625 1180.964 569.036 1125 500 1125 Z"/>
</svg>
`;
} else if (!platform.mobile && mobileIcon) {
mobileIcon.remove();
avatarWrapper.innerHTML += `<div class="status-indicator ${status}"></div>`;
avatarWrapper.appendChild(mobileIcon);
}
const custom = data.activities?.find((a) => a.type === 4);
if (customStatus && custom) {
let emojiHTML = "";
const emoji = custom.emoji;
if (emoji?.id) {
const emojiUrl = `https://cdn.discordapp.com/emojis/${emoji.id}.${emoji.animated ? "gif" : "png"}`;
emojiHTML = `<img src="${emojiUrl}" alt="${emoji.name}" class="custom-emoji">`;
} else if (emoji?.name) {
emojiHTML = `${emoji.name} `;
}
customStatus.innerHTML = `
${emojiHTML}
${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
`;
const updatedStatusIndicator =
avatarWrapper.querySelector(".status-indicator");
if (!updatedStatusIndicator) {
const statusDiv = document.createElement("div");
statusDiv.className = `status-indicator ${status}`;
avatarWrapper.appendChild(statusDiv);
} else {
updatedStatusIndicator.className = `status-indicator ${status}`;
}
const custom = data.activities?.find((a) => a.type === 4);
updateCustomStatus(custom);
populateReadme(data);
const filtered = data.activities
?.filter((a) => a.type !== 4)
?.sort((a, b) => {
......@@ -338,7 +501,7 @@ function updatePresence(data) {
});
const activityList = document.querySelector(".activities");
const activitiesTitle = document.querySelector(".activity-header");
const activitiesTitle = document.querySelector(".activity-block-header");
if (activityList && activitiesTitle) {
if (filtered?.length) {
......@@ -349,5 +512,162 @@ function updatePresence(data) {
activitiesTitle.classList.add("hidden");
}
updateElapsedAndProgress();
getAllNoAsset();
}
if (data.kv?.snow === "true") loadEffectScript("snow");
if (data.kv?.rain === "true") loadEffectScript("rain");
if (data.kv?.stars === "true") loadEffectScript("stars");
const loadingOverlay = document.getElementById("loading-overlay");
if (loadingOverlay) {
loadingOverlay.style.opacity = "0";
setTimeout(() => loadingOverlay.remove(), 500);
}
}
function updateCustomStatus(custom) {
const userInfoInner = document.querySelector(".user-info");
const customStatus = userInfoInner?.querySelector(".custom-status");
if (!userInfoInner) return;
if (custom) {
let emojiHTML = "";
if (custom.emoji?.id) {
const emojiUrl = `https://cdn.discordapp.com/emojis/${custom.emoji.id}.${custom.emoji.animated ? "gif" : "png"}`;
emojiHTML = `<img src="${emojiUrl}" alt="${custom.emoji.name}" class="custom-emoji">`;
} else if (custom.emoji?.name) {
emojiHTML = `${custom.emoji.name} `;
}
const html = `
<p class="custom-status">
${emojiHTML}${custom.state ? `<span class="custom-status-text">${custom.state}</span>` : ""}
</p>
`;
if (customStatus) {
customStatus.outerHTML = html;
} else {
userInfoInner.insertAdjacentHTML("beforeend", html);
}
} else if (customStatus) {
customStatus.remove();
}
}
async function getAllNoAsset() {
const noAssetImages = document.querySelectorAll(
"img.activity-image.no-asset",
);
for (const img of noAssetImages) {
const name = img.dataset.name;
if (!name) continue;
try {
const res = await fetch(`/api/art/${encodeURIComponent(name)}`);
if (!res.ok) continue;
const { icon } = await res.json();
if (icon) {
img.src = icon;
img.classList.remove("no-asset");
img.parentElement.classList.remove("no-asset");
}
} catch (err) {
console.warn(`Failed to fetch fallback icon for "${name}"`, err);
}
}
}
function updateClanBadge(data) {
const userInfoInner = document.querySelector(".user-info-inner");
if (!userInfoInner) return;
const clan = data?.discord_user?.clan;
if (!clan || !clan.tag || !clan.identity_guild_id || !clan.badge) return;
const existing = userInfoInner.querySelector(".clan-badge");
if (existing) existing.remove();
const wrapper = document.createElement("div");
wrapper.className = "clan-badge";
const img = document.createElement("img");
img.src = `https://cdn.discordapp.com/clan-badges/${clan.identity_guild_id}/${clan.badge}`;
img.alt = "Clan Badge";
const span = document.createElement("span");
span.className = "clan-name";
span.textContent = clan.tag;
wrapper.appendChild(img);
wrapper.appendChild(span);
const usernameEl = userInfoInner.querySelector(".username");
if (usernameEl) {
usernameEl.insertAdjacentElement("afterend", wrapper);
} else {
userInfoInner.appendChild(wrapper);
}
}
if (instanceUri) {
if (!instanceUri.startsWith("http")) {
instanceUri = `https://${instanceUri}`;
}
const wsUri = instanceUri
.replace(/^http:/, "ws:")
.replace(/^https:/, "wss:")
.replace(/\/$/, "");
socket = new WebSocket(`${wsUri}/socket`);
}
if (badgeURL && badgeURL !== "null" && userId) {
if (!badgeURL.startsWith("http")) {
badgeURL = `https://${badgeURL}`;
}
if (!badgeURL.endsWith("/")) {
badgeURL += "/";
}
}
if (userId && instanceUri) {
let heartbeatInterval = null;
socket.addEventListener("message", (event) => {
const payload = JSON.parse(event.data);
if (payload.op === 1 && payload.d?.heartbeat_interval) {
heartbeatInterval = setInterval(() => {
socket.send(JSON.stringify({ op: 3 }));
}, payload.d.heartbeat_interval);
socket.send(
JSON.stringify({
op: 2,
d: {
subscribe_to_id: userId,
},
}),
);
}
if (payload.t === "INIT_STATE" || payload.t === "PRESENCE_UPDATE") {
updatePresence(payload.d);
requestAnimationFrame(() => updateElapsedAndProgress());
}
});
socket.addEventListener("close", () => {
if (heartbeatInterval) clearInterval(heartbeatInterval);
});
}
updateElapsedAndProgress();
setInterval(updateElapsedAndProgress, 1000);
......@@ -25,24 +25,29 @@ const getRaindropColor = () => {
const createRaindrop = () => {
if (raindrops.length >= maxRaindrops) {
const oldestRaindrop = raindrops.shift();
rainContainer.removeChild(oldestRaindrop);
const oldest = raindrops.shift();
rainContainer.removeChild(oldest);
}
const raindrop = document.createElement("div");
raindrop.classList.add("raindrop");
raindrop.style.position = "absolute";
const height = Math.random() * 10 + 10;
raindrop.style.width = "2px";
raindrop.style.height = `${Math.random() * 10 + 10}px`;
raindrop.style.height = `${height}px`;
raindrop.style.background = getRaindropColor();
raindrop.style.borderRadius = "1px";
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
raindrop.style.opacity = Math.random() * 0.5 + 0.3;
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.speed = Math.random() * 6 + 4;
raindrop.directionX = (Math.random() - 0.5) * 0.2;
raindrop.directionY = Math.random() * 0.5 + 0.8;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
raindrops.push(raindrop);
rainContainer.appendChild(raindrop);
};
......@@ -51,23 +56,29 @@ setInterval(createRaindrop, 50);
function updateRaindrops() {
raindrops.forEach((raindrop, index) => {
const rect = raindrop.getBoundingClientRect();
const height = Number.parseFloat(raindrop.style.height);
raindrop.x += raindrop.directionX * raindrop.speed;
raindrop.y += raindrop.directionY * raindrop.speed;
raindrop.style.left = `${rect.left + raindrop.directionX * raindrop.speed}px`;
raindrop.style.top = `${rect.top + raindrop.directionY * raindrop.speed}px`;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
if (rect.top + rect.height >= window.innerHeight) {
if (raindrop.y > window.innerHeight) {
rainContainer.removeChild(raindrop);
raindrops.splice(index, 1);
return;
}
if (
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
raindrop.x > window.innerWidth ||
raindrop.y > window.innerHeight ||
raindrop.x < 0
) {
raindrop.style.left = `${Math.random() * window.innerWidth}px`;
raindrop.style.top = `-${raindrop.style.height}`;
raindrop.x = Math.random() * window.innerWidth;
raindrop.y = -height;
raindrop.style.left = `${raindrop.x}px`;
raindrop.style.top = `${raindrop.y}px`;
}
});
......
document.addEventListener("DOMContentLoaded", () => {
const snowContainer = document.createElement("div");
snowContainer.style.position = "fixed";
snowContainer.style.top = "0";
......@@ -26,17 +25,22 @@ document.addEventListener("DOMContentLoaded", () => {
const snowflake = document.createElement("div");
snowflake.classList.add("snowflake");
snowflake.style.position = "absolute";
snowflake.style.width = `${Math.random() * 3 + 2}px`;
snowflake.style.height = snowflake.style.width;
const size = Math.random() * 3 + 2;
snowflake.style.width = `${size}px`;
snowflake.style.height = `${size}px`;
snowflake.style.background = "white";
snowflake.style.borderRadius = "50%";
snowflake.style.opacity = Math.random();
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.speed = Math.random() * 3 + 2;
snowflake.directionX = (Math.random() - 0.5) * 0.5;
snowflake.directionY = Math.random() * 0.5 + 0.5;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
snowflakes.push(snowflake);
snowContainer.appendChild(snowflake);
};
......@@ -45,10 +49,12 @@ document.addEventListener("DOMContentLoaded", () => {
function updateSnowflakes() {
snowflakes.forEach((snowflake, index) => {
const rect = snowflake.getBoundingClientRect();
const size = Number.parseFloat(snowflake.style.width);
const centerX = snowflake.x + size / 2;
const centerY = snowflake.y + size / 2;
const dx = rect.left + rect.width / 2 - mouse.x;
const dy = rect.top + rect.height / 2 - mouse.y;
const dx = centerX - mouse.x;
const dy = centerY - mouse.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 30) {
......@@ -59,21 +65,27 @@ document.addEventListener("DOMContentLoaded", () => {
snowflake.directionY += (Math.random() - 0.5) * 0.01;
}
snowflake.style.left = `${rect.left + snowflake.directionX * snowflake.speed}px`;
snowflake.style.top = `${rect.top + snowflake.directionY * snowflake.speed}px`;
snowflake.x += snowflake.directionX * snowflake.speed;
snowflake.y += snowflake.directionY * snowflake.speed;
if (rect.top + rect.height >= window.innerHeight) {
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
if (snowflake.y > window.innerHeight) {
snowContainer.removeChild(snowflake);
snowflakes.splice(index, 1);
return;
}
if (
rect.left > window.innerWidth ||
rect.top > window.innerHeight ||
rect.left < 0
snowflake.x > window.innerWidth ||
snowflake.y > window.innerHeight ||
snowflake.x < 0
) {
snowflake.style.left = `${Math.random() * window.innerWidth}px`;
snowflake.style.top = `-${snowflake.style.height}`;
snowflake.x = Math.random() * window.innerWidth;
snowflake.y = -size;
snowflake.style.left = `${snowflake.x}px`;
snowflake.style.top = `${snowflake.y}px`;
}
});
......@@ -81,4 +93,3 @@ document.addEventListener("DOMContentLoaded", () => {
}
updateSnowflakes();
});
const container = document.createElement("div");
container.style.position = "fixed";
container.style.top = "0";
container.style.left = "0";
container.style.width = "100vw";
container.style.height = "100vh";
container.style.pointerEvents = "none";
container.style.overflow = "hidden";
container.style.zIndex = "9999";
document.body.appendChild(container);
for (let i = 0; i < 60; i++) {
const star = document.createElement("div");
star.className = "star";
const size = Math.random() * 2 + 1;
star.style.width = `${size}px`;
star.style.height = `${size}px`;
star.style.opacity = Math.random();
star.style.top = `${Math.random() * 100}vh`;
star.style.left = `${Math.random() * 100}vw`;
star.style.animationDuration = `${Math.random() * 3 + 2}s`;
container.appendChild(star);
}
function createShootingStar() {
const star = document.createElement("div");
star.className = "shooting-star";
star.x = Math.random() * window.innerWidth * 0.8;
star.y = Math.random() * window.innerHeight * 0.3;
const angle = (Math.random() * Math.PI) / 6 + Math.PI / 8;
const speed = 10;
const totalFrames = 60;
let frame = 0;
const deg = angle * (180 / Math.PI);
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.transform = `rotate(${deg}deg)`;
container.appendChild(star);
function animate() {
star.x += Math.cos(angle) * speed;
star.y += Math.sin(angle) * speed;
star.style.left = `${star.x}px`;
star.style.top = `${star.y}px`;
star.style.opacity = `${1 - frame / totalFrames}`;
frame++;
if (frame < totalFrames) {
requestAnimationFrame(animate);
} else if (star.parentNode === container) {
container.removeChild(star);
}
}
animate();
}
setInterval(() => {
if (Math.random() < 0.3) createShootingStar();
}, 1000);
export function timestampToReadable(timestamp?: number): string {
const date: Date =
timestamp && !Number.isNaN(timestamp) ? new Date(timestamp) : new Date();
if (Number.isNaN(date.getTime())) return "Invalid Date";
return date.toISOString().replace("T", " ").replace("Z", "");
}