This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
@@ -1,219 +1,219 @@
|
||||
export interface HabboFont {
|
||||
name: string;
|
||||
styleName: string;
|
||||
hasNumbers?: boolean;
|
||||
name: string;
|
||||
styleName: string;
|
||||
hasNumbers?: boolean;
|
||||
}
|
||||
|
||||
export interface HabboFontGroup {
|
||||
groupName: string;
|
||||
fonts: HabboFont[];
|
||||
groupName: string;
|
||||
fonts: HabboFont[];
|
||||
}
|
||||
|
||||
export const HABBO_FONT_GROUPS: HabboFontGroup[] = [
|
||||
{
|
||||
groupName: "Habbo (related) logo",
|
||||
fonts: [
|
||||
{ name: "Habbo new", styleName: "habbo_new", hasNumbers: true },
|
||||
{ name: "Habbo old", styleName: "habbo_big", hasNumbers: true },
|
||||
{ name: "Habbo new big", styleName: "habbo_new_big", hasNumbers: true },
|
||||
{ name: "Habbo old big", styleName: "habbo_old_big", hasNumbers: true },
|
||||
{ name: "Habbo", styleName: "habbo", hasNumbers: true },
|
||||
{ name: "Habbo Tiny", styleName: "habbo_tiny" },
|
||||
{ name: "Habbo oldschool", styleName: "habbo_oldschool" },
|
||||
{ name: "Habbo blocky", styleName: "habbo_blocky" },
|
||||
{ name: "Habjoy", styleName: "habjoy" },
|
||||
{ name: "Habjoy light", styleName: "habjoy_light" },
|
||||
{ name: "Paradise", styleName: "paradise" },
|
||||
{ name: "Paradise 2", styleName: "paradise_rounded" },
|
||||
{ name: "Habbo Clicker", styleName: "habbo_clicker" },
|
||||
{ name: "Reception", styleName: "reception" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Generic fonts",
|
||||
fonts: [
|
||||
{ name: "Accessoires", styleName: "accessoires" },
|
||||
{ name: "Africa", styleName: "africa" },
|
||||
{ name: "Arctic", styleName: "arctic", hasNumbers: true },
|
||||
{ name: "Asian", styleName: "asian" },
|
||||
{ name: "Asian small", styleName: "asian_small" },
|
||||
{ name: "Basic", styleName: "basic", hasNumbers: true },
|
||||
{ name: "Base", styleName: "base", hasNumbers: true },
|
||||
{ name: "Bathroom", styleName: "bathroom", hasNumbers: true },
|
||||
{ name: "Battle Banzai", styleName: "battlebanzai" },
|
||||
{ name: "Bensalem", styleName: "bensalem", hasNumbers: true },
|
||||
{ name: "Bling", styleName: "bling", hasNumbers: true },
|
||||
{ name: "Bling Gold", styleName: "bling_gold" },
|
||||
{ name: "Bones", styleName: "bones" },
|
||||
{ name: "Bots", styleName: "bots" },
|
||||
{ name: "Bubble", styleName: "bubble" },
|
||||
{ name: "Bubble blue", styleName: "bubble_blue" },
|
||||
{ name: "Bubble orange", styleName: "bubble_orange" },
|
||||
{ name: "Bunny run", styleName: "bunny_run" },
|
||||
{ name: "Bunny run blue", styleName: "bunny_run_blue" },
|
||||
{ name: "Buttons", styleName: "buttons" },
|
||||
{ name: "Candy", styleName: "candy" },
|
||||
{ name: "Collectibles", styleName: "collectibles" },
|
||||
{ name: "Cubie", styleName: "cubie" },
|
||||
{ name: "Disco", styleName: "disco" },
|
||||
{ name: "Dreams flat", styleName: "dreams_flat" },
|
||||
{ name: "Duckets", styleName: "duckets" },
|
||||
{ name: "Emo", styleName: "emo" },
|
||||
{ name: "Executive", styleName: "executive" },
|
||||
{ name: "Explore", styleName: "explore" },
|
||||
{ name: "Explore big", styleName: "explore_big" },
|
||||
{ name: "Explore small", styleName: "explore_small" },
|
||||
{ name: "F.A.Q.", styleName: "faq" },
|
||||
{ name: "F.BOMB", styleName: "fbomb" },
|
||||
{ name: "Festival", styleName: "festival" },
|
||||
{ name: "Flags", styleName: "flags" },
|
||||
{ name: "Freehand", styleName: "freehand" },
|
||||
{ name: "Gaming", styleName: "gaming" },
|
||||
{ name: "Gallery", styleName: "gallery" },
|
||||
{ name: "Golden temple", styleName: "goldentemple" },
|
||||
{
|
||||
groupName: "Habbo (related) logo",
|
||||
fonts: [
|
||||
{ name: "Habbo new", styleName: "habbo_new", hasNumbers: true },
|
||||
{ name: "Habbo old", styleName: "habbo_big", hasNumbers: true },
|
||||
{ name: "Habbo new big", styleName: "habbo_new_big", hasNumbers: true },
|
||||
{ name: "Habbo old big", styleName: "habbo_old_big", hasNumbers: true },
|
||||
{ name: "Habbo", styleName: "habbo", hasNumbers: true },
|
||||
{ name: "Habbo Tiny", styleName: "habbo_tiny" },
|
||||
{ name: "Habbo oldschool", styleName: "habbo_oldschool" },
|
||||
{ name: "Habbo blocky", styleName: "habbo_blocky" },
|
||||
{ name: "Habjoy", styleName: "habjoy" },
|
||||
{ name: "Habjoy light", styleName: "habjoy_light" },
|
||||
{ name: "Paradise", styleName: "paradise" },
|
||||
{ name: "Paradise 2", styleName: "paradise_rounded" },
|
||||
{ name: "Habbo Clicker", styleName: "habbo_clicker" },
|
||||
{ name: "Reception", styleName: "reception" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Generic fonts",
|
||||
fonts: [
|
||||
{ name: "Accessoires", styleName: "accessoires" },
|
||||
{ name: "Africa", styleName: "africa" },
|
||||
{ name: "Arctic", styleName: "arctic", hasNumbers: true },
|
||||
{ name: "Asian", styleName: "asian" },
|
||||
{ name: "Asian small", styleName: "asian_small" },
|
||||
{ name: "Basic", styleName: "basic", hasNumbers: true },
|
||||
{ name: "Base", styleName: "base", hasNumbers: true },
|
||||
{ name: "Bathroom", styleName: "bathroom", hasNumbers: true },
|
||||
{ name: "Battle Banzai", styleName: "battlebanzai" },
|
||||
{ name: "Bensalem", styleName: "bensalem", hasNumbers: true },
|
||||
{ name: "Bling", styleName: "bling", hasNumbers: true },
|
||||
{ name: "Bling Gold", styleName: "bling_gold" },
|
||||
{ name: "Bones", styleName: "bones" },
|
||||
{ name: "Bots", styleName: "bots" },
|
||||
{ name: "Bubble", styleName: "bubble" },
|
||||
{ name: "Bubble blue", styleName: "bubble_blue" },
|
||||
{ name: "Bubble orange", styleName: "bubble_orange" },
|
||||
{ name: "Bunny run", styleName: "bunny_run" },
|
||||
{ name: "Bunny run blue", styleName: "bunny_run_blue" },
|
||||
{ name: "Buttons", styleName: "buttons" },
|
||||
{ name: "Candy", styleName: "candy" },
|
||||
{ name: "Collectibles", styleName: "collectibles" },
|
||||
{ name: "Cubie", styleName: "cubie" },
|
||||
{ name: "Disco", styleName: "disco" },
|
||||
{ name: "Dreams flat", styleName: "dreams_flat" },
|
||||
{ name: "Duckets", styleName: "duckets" },
|
||||
{ name: "Emo", styleName: "emo" },
|
||||
{ name: "Executive", styleName: "executive" },
|
||||
{ name: "Explore", styleName: "explore" },
|
||||
{ name: "Explore big", styleName: "explore_big" },
|
||||
{ name: "Explore small", styleName: "explore_small" },
|
||||
{ name: "F.A.Q.", styleName: "faq" },
|
||||
{ name: "F.BOMB", styleName: "fbomb" },
|
||||
{ name: "Festival", styleName: "festival" },
|
||||
{ name: "Flags", styleName: "flags" },
|
||||
{ name: "Freehand", styleName: "freehand" },
|
||||
{ name: "Gaming", styleName: "gaming" },
|
||||
{ name: "Gallery", styleName: "gallery" },
|
||||
{ name: "Golden temple", styleName: "goldentemple" },
|
||||
|
||||
{ name: "HMA", styleName: "hma" },
|
||||
{ name: "Habbo gold", styleName: "gold_big" },
|
||||
{ name: "Habbo news", styleName: "habbo_news" },
|
||||
{ name: "Habbo Runway", styleName: "habbo_runway" },
|
||||
{ name: "Habbid red", styleName: "habbid_red" },
|
||||
{ name: "Habbid blue", styleName: "habbid_blue" },
|
||||
{ name: "Habbolife", styleName: "habbolife" },
|
||||
{ name: "Habbowood", styleName: "habbowood" },
|
||||
{ name: "Habbowood big", styleName: "habbowood_big" },
|
||||
{ name: "Hands", styleName: "hands" },
|
||||
{ name: "Hello", styleName: "hello" },
|
||||
{ name: "Horse", styleName: "horse" },
|
||||
{ name: "Ice", styleName: "ice" },
|
||||
{ name: "Iced", styleName: "iced" },
|
||||
{ name: "Iron", styleName: "iron" },
|
||||
{ name: "Japan", styleName: "japan" },
|
||||
{ name: "Love", styleName: "love" },
|
||||
{ name: "Marketplace red", styleName: "marketplace_red" },
|
||||
{ name: "Monkey", styleName: "monkey" },
|
||||
{ name: "Neon blue", styleName: "neon_blue", hasNumbers: true },
|
||||
{ name: "Neon green", styleName: "neon_green", hasNumbers: true },
|
||||
{ name: "Neon pink", styleName: "neon_pink", hasNumbers: true },
|
||||
{ name: "Occasional", styleName: "occasional" },
|
||||
{ name: "Palooza", styleName: "palooza" },
|
||||
{ name: "Palooza blue", styleName: "palooza_blue" },
|
||||
{ name: "Pets", styleName: "pets" },
|
||||
{ name: "Pillows Orange", styleName: "pillow_orange" },
|
||||
{ name: "Pillows Pink", styleName: "pillow_pink" },
|
||||
{ name: "Pipes", styleName: "pipes" },
|
||||
{ name: "Plants", styleName: "plants" },
|
||||
{ name: "Plastic", styleName: "plastic" },
|
||||
{ name: "Recycle", styleName: "recycle" },
|
||||
{ name: "Retropixel", styleName: "retropixel" },
|
||||
{ name: "Rugs", styleName: "rugs" },
|
||||
{ name: "Sale", styleName: "sale" },
|
||||
{ name: "Shalimar", styleName: "shalimar", hasNumbers: true },
|
||||
{ name: "Shalimar big", styleName: "shalimar_big", hasNumbers: true },
|
||||
{ name: "Shalimar small", styleName: "shalimar_small", hasNumbers: true },
|
||||
{ name: "Soccer", styleName: "soccer" },
|
||||
{ name: "Soccer red", styleName: "soccer_red" },
|
||||
{ name: "Soccer yellow", styleName: "soccer_yellow" },
|
||||
{ name: "Space Cafe", styleName: "space" },
|
||||
{ name: "Spaces", styleName: "spaces" },
|
||||
{ name: "Special offers", styleName: "special_offers" },
|
||||
{ name: "StrayPixels", styleName: "straypixels" },
|
||||
{ name: "Summer", styleName: "summer" },
|
||||
{ name: "Teleport", styleName: "teleport", hasNumbers: true },
|
||||
{ name: "Theater lights", styleName: "theater_lights" },
|
||||
{ name: "Tribe", styleName: "tribe" },
|
||||
{ name: "Trophies", styleName: "trophies" },
|
||||
{ name: "USVA", styleName: "usva" },
|
||||
{ name: "Unknown blue", styleName: "unknown_blue" },
|
||||
{ name: "Unknown metal", styleName: "unknown_metal" },
|
||||
{ name: "Unknown white", styleName: "unknown_white" },
|
||||
{ name: "Unknown pillow", styleName: "unknown_pillow_1" },
|
||||
{ name: "Unknown pillow", styleName: "unknown_pillow_2" },
|
||||
{ name: "Valentine", styleName: "valentine" },
|
||||
{ name: "Western", styleName: "western" },
|
||||
{ name: "Woods", styleName: "wood" },
|
||||
{ name: "Yellow text", styleName: "yellow_text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Custom creations",
|
||||
fonts: [{ name: "Jurrasic", styleName: "jurrasic" }],
|
||||
},
|
||||
{
|
||||
groupName: "Halloween fonts",
|
||||
fonts: [
|
||||
{ name: "Halloween", styleName: "halloween" },
|
||||
{ name: "Halloween 2", styleName: "halloween_2" },
|
||||
{ name: "Halloween 3", styleName: "halloween_3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Group fonts",
|
||||
fonts: [
|
||||
{ name: "Plastic playful", styleName: "plastic_playful" },
|
||||
{ name: "Dinner blue", styleName: "dinner_blue", hasNumbers: true },
|
||||
{ name: "Dinner green", styleName: "dinner_green", hasNumbers: true },
|
||||
{ name: "Dinner red", styleName: "dinner_red", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Games",
|
||||
fonts: [
|
||||
{ name: "Battleball", styleName: "battleball" },
|
||||
{ name: "Lido", styleName: "lido", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Habbo club",
|
||||
fonts: [
|
||||
{ name: "Habbo Club", styleName: "hc" },
|
||||
{ name: "HC Big", styleName: "hc_big" },
|
||||
{ name: "HC Small", styleName: "hc_small" },
|
||||
{ name: "HC Compact", styleName: "hc_compact", hasNumbers: true },
|
||||
{ name: "HC gold", styleName: "hc_gold" },
|
||||
{ name: "Habbo Club 2", styleName: "habboclub", hasNumbers: true },
|
||||
{ name: "VIP", styleName: "habbovip", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Uncategorised",
|
||||
fonts: [
|
||||
{ name: "Agenda", styleName: "agenda", hasNumbers: true },
|
||||
{ name: "Mini white", styleName: "mini_white" },
|
||||
{ name: "Volter goldfish", styleName: "volter" },
|
||||
{ name: "Volter shadow", styleName: "volter_shadow" },
|
||||
{ name: "Lucky goldfish", styleName: "volter_modern" },
|
||||
{ name: "Goldfish small", styleName: "volter_modern_small" },
|
||||
{ name: "Black", styleName: "black" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Windows",
|
||||
fonts: [
|
||||
{ name: "Windows", styleName: "windows" },
|
||||
{ name: "HMA", styleName: "hma" },
|
||||
{ name: "Habbo gold", styleName: "gold_big" },
|
||||
{ name: "Habbo news", styleName: "habbo_news" },
|
||||
{ name: "Habbo Runway", styleName: "habbo_runway" },
|
||||
{ name: "Habbid red", styleName: "habbid_red" },
|
||||
{ name: "Habbid blue", styleName: "habbid_blue" },
|
||||
{ name: "Habbolife", styleName: "habbolife" },
|
||||
{ name: "Habbowood", styleName: "habbowood" },
|
||||
{ name: "Habbowood big", styleName: "habbowood_big" },
|
||||
{ name: "Hands", styleName: "hands" },
|
||||
{ name: "Hello", styleName: "hello" },
|
||||
{ name: "Horse", styleName: "horse" },
|
||||
{ name: "Ice", styleName: "ice" },
|
||||
{ name: "Iced", styleName: "iced" },
|
||||
{ name: "Iron", styleName: "iron" },
|
||||
{ name: "Japan", styleName: "japan" },
|
||||
{ name: "Love", styleName: "love" },
|
||||
{ name: "Marketplace red", styleName: "marketplace_red" },
|
||||
{ name: "Monkey", styleName: "monkey" },
|
||||
{ name: "Neon blue", styleName: "neon_blue", hasNumbers: true },
|
||||
{ name: "Neon green", styleName: "neon_green", hasNumbers: true },
|
||||
{ name: "Neon pink", styleName: "neon_pink", hasNumbers: true },
|
||||
{ name: "Occasional", styleName: "occasional" },
|
||||
{ name: "Palooza", styleName: "palooza" },
|
||||
{ name: "Palooza blue", styleName: "palooza_blue" },
|
||||
{ name: "Pets", styleName: "pets" },
|
||||
{ name: "Pillows Orange", styleName: "pillow_orange" },
|
||||
{ name: "Pillows Pink", styleName: "pillow_pink" },
|
||||
{ name: "Pipes", styleName: "pipes" },
|
||||
{ name: "Plants", styleName: "plants" },
|
||||
{ name: "Plastic", styleName: "plastic" },
|
||||
{ name: "Recycle", styleName: "recycle" },
|
||||
{ name: "Retropixel", styleName: "retropixel" },
|
||||
{ name: "Rugs", styleName: "rugs" },
|
||||
{ name: "Sale", styleName: "sale" },
|
||||
{ name: "Shalimar", styleName: "shalimar", hasNumbers: true },
|
||||
{ name: "Shalimar big", styleName: "shalimar_big", hasNumbers: true },
|
||||
{ name: "Shalimar small", styleName: "shalimar_small", hasNumbers: true },
|
||||
{ name: "Soccer", styleName: "soccer" },
|
||||
{ name: "Soccer red", styleName: "soccer_red" },
|
||||
{ name: "Soccer yellow", styleName: "soccer_yellow" },
|
||||
{ name: "Space Cafe", styleName: "space" },
|
||||
{ name: "Spaces", styleName: "spaces" },
|
||||
{ name: "Special offers", styleName: "special_offers" },
|
||||
{ name: "StrayPixels", styleName: "straypixels" },
|
||||
{ name: "Summer", styleName: "summer" },
|
||||
{ name: "Teleport", styleName: "teleport", hasNumbers: true },
|
||||
{ name: "Theater lights", styleName: "theater_lights" },
|
||||
{ name: "Tribe", styleName: "tribe" },
|
||||
{ name: "Trophies", styleName: "trophies" },
|
||||
{ name: "USVA", styleName: "usva" },
|
||||
{ name: "Unknown blue", styleName: "unknown_blue" },
|
||||
{ name: "Unknown metal", styleName: "unknown_metal" },
|
||||
{ name: "Unknown white", styleName: "unknown_white" },
|
||||
{ name: "Unknown pillow", styleName: "unknown_pillow_1" },
|
||||
{ name: "Unknown pillow", styleName: "unknown_pillow_2" },
|
||||
{ name: "Valentine", styleName: "valentine" },
|
||||
{ name: "Western", styleName: "western" },
|
||||
{ name: "Woods", styleName: "wood" },
|
||||
{ name: "Yellow text", styleName: "yellow_text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Custom creations",
|
||||
fonts: [{ name: "Jurrasic", styleName: "jurrasic" }],
|
||||
},
|
||||
{
|
||||
groupName: "Halloween fonts",
|
||||
fonts: [
|
||||
{ name: "Halloween", styleName: "halloween" },
|
||||
{ name: "Halloween 2", styleName: "halloween_2" },
|
||||
{ name: "Halloween 3", styleName: "halloween_3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Group fonts",
|
||||
fonts: [
|
||||
{ name: "Plastic playful", styleName: "plastic_playful" },
|
||||
{ name: "Dinner blue", styleName: "dinner_blue", hasNumbers: true },
|
||||
{ name: "Dinner green", styleName: "dinner_green", hasNumbers: true },
|
||||
{ name: "Dinner red", styleName: "dinner_red", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Games",
|
||||
fonts: [
|
||||
{ name: "Battleball", styleName: "battleball" },
|
||||
{ name: "Lido", styleName: "lido", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Habbo club",
|
||||
fonts: [
|
||||
{ name: "Habbo Club", styleName: "hc" },
|
||||
{ name: "HC Big", styleName: "hc_big" },
|
||||
{ name: "HC Small", styleName: "hc_small" },
|
||||
{ name: "HC Compact", styleName: "hc_compact", hasNumbers: true },
|
||||
{ name: "HC gold", styleName: "hc_gold" },
|
||||
{ name: "Habbo Club 2", styleName: "habboclub", hasNumbers: true },
|
||||
{ name: "VIP", styleName: "habbovip", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Uncategorised",
|
||||
fonts: [
|
||||
{ name: "Agenda", styleName: "agenda", hasNumbers: true },
|
||||
{ name: "Mini white", styleName: "mini_white" },
|
||||
{ name: "Volter goldfish", styleName: "volter" },
|
||||
{ name: "Volter shadow", styleName: "volter_shadow" },
|
||||
{ name: "Lucky goldfish", styleName: "volter_modern" },
|
||||
{ name: "Goldfish small", styleName: "volter_modern_small" },
|
||||
{ name: "Black", styleName: "black" },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Windows",
|
||||
fonts: [
|
||||
{ name: "Windows", styleName: "windows" },
|
||||
|
||||
{ name: "Blue", styleName: "windows_blue", hasNumbers: true },
|
||||
{ name: "Green", styleName: "windows_green", hasNumbers: true },
|
||||
{ name: "Pink", styleName: "windows_pink", hasNumbers: true },
|
||||
{ name: "Red", styleName: "windows_red", hasNumbers: true },
|
||||
{ name: "Dark red", styleName: "windows_red_dark", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Beta",
|
||||
fonts: [
|
||||
{ name: "Beta big", styleName: "beta_big" },
|
||||
{ name: "Beta small", styleName: "beta_small" },
|
||||
{ name: "Beta old", styleName: "beta_small_old" },
|
||||
{ name: "Beta", styleName: "beta", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Special fonts",
|
||||
fonts: [{ name: "Relax", styleName: "relax" }],
|
||||
},
|
||||
{ name: "Blue", styleName: "windows_blue", hasNumbers: true },
|
||||
{ name: "Green", styleName: "windows_green", hasNumbers: true },
|
||||
{ name: "Pink", styleName: "windows_pink", hasNumbers: true },
|
||||
{ name: "Red", styleName: "windows_red", hasNumbers: true },
|
||||
{ name: "Dark red", styleName: "windows_red_dark", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Beta",
|
||||
fonts: [
|
||||
{ name: "Beta big", styleName: "beta_big" },
|
||||
{ name: "Beta small", styleName: "beta_small" },
|
||||
{ name: "Beta old", styleName: "beta_small_old" },
|
||||
{ name: "Beta", styleName: "beta", hasNumbers: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: "Special fonts",
|
||||
fonts: [{ name: "Relax", styleName: "relax" }],
|
||||
},
|
||||
];
|
||||
|
||||
export function buildFontUrl(styleName: string, text: string): string {
|
||||
const t = text.toLowerCase().replace(/\s/g, "+");
|
||||
return `/api/font/${styleName}/${t}`;
|
||||
const t = text.toLowerCase().replace(/\s/g, "+");
|
||||
return `/api/font/${styleName}/${t}`;
|
||||
}
|
||||
@@ -1,249 +1,320 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useTransition,
|
||||
} from "react";
|
||||
import { saveLogo } from "@/actions/save-logo";
|
||||
import { HABBO_FONT_GROUPS } from "@/components/public/habbo-fonts";
|
||||
import { getFontInfo, getMissingChars, renderToCanvas } from "@/components/public/sprite-font";
|
||||
import {
|
||||
getFontInfo,
|
||||
getMissingChars,
|
||||
renderToCanvas,
|
||||
} from "@/components/public/sprite-font";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
|
||||
export default function LogoGenerator() {
|
||||
const [text, setText] = useState("Atom");
|
||||
const [styleName, setStyleName] = useState("habbo");
|
||||
const [search, setSearch] = useState("");
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "done" | "error">("idle");
|
||||
const [fontLoaded, setFontLoaded] = useState(false);
|
||||
const [fontError, setFontError] = useState(false);
|
||||
const [missingChars, setMissingChars] = useState<Set<string>>(new Set());
|
||||
const [, startTransition] = useTransition();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [text, setText] = useState("Atom");
|
||||
const [styleName, setStyleName] = useState("habbo");
|
||||
const [search, setSearch] = useState("");
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
"idle" | "saving" | "done" | "error"
|
||||
>("idle");
|
||||
const [fontLoaded, setFontLoaded] = useState(false);
|
||||
const [fontError, setFontError] = useState(false);
|
||||
const [missingChars, setMissingChars] = useState<Set<string>>(new Set());
|
||||
const [, startTransition] = useTransition();
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const safeText = text.trim() || "Your hotel";
|
||||
const safeText = text.trim() || "Your hotel";
|
||||
|
||||
const currentFont = useMemo(
|
||||
() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts).find((f) => f.styleName === styleName),
|
||||
[styleName],
|
||||
);
|
||||
const currentFont = useMemo(
|
||||
() =>
|
||||
HABBO_FONT_GROUPS.flatMap((g) => g.fonts).find(
|
||||
(f) => f.styleName === styleName,
|
||||
),
|
||||
[styleName],
|
||||
);
|
||||
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
search
|
||||
? HABBO_FONT_GROUPS.map((g) => ({
|
||||
...g,
|
||||
fonts: g.fonts.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
f.styleName.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
})).filter((g) => g.fonts.length > 0)
|
||||
: HABBO_FONT_GROUPS,
|
||||
[search],
|
||||
);
|
||||
const filteredGroups = useMemo(
|
||||
() =>
|
||||
search
|
||||
? HABBO_FONT_GROUPS.map((g) => ({
|
||||
...g,
|
||||
fonts: g.fonts.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
f.styleName.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
})).filter((g) => g.fonts.length > 0)
|
||||
: HABBO_FONT_GROUPS,
|
||||
[search],
|
||||
);
|
||||
|
||||
// Load font and render preview
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Load font and render preview
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
getFontInfo(styleName)
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
const canvas = renderToCanvas(info.chars, info.h, info.top, info.sheet, safeText, info.avgW);
|
||||
if (canvas && canvasRef.current) {
|
||||
canvasRef.current.width = canvas.width;
|
||||
canvasRef.current.height = canvas.height;
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
ctx?.drawImage(canvas, 0, 0);
|
||||
}
|
||||
setMissingChars(getMissingChars(info.chars, safeText));
|
||||
setFontLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFontError(true);
|
||||
});
|
||||
getFontInfo(styleName)
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
const canvas = renderToCanvas(
|
||||
info.chars,
|
||||
info.h,
|
||||
info.top,
|
||||
info.sheet,
|
||||
safeText,
|
||||
info.avgW,
|
||||
);
|
||||
if (canvas && canvasRef.current) {
|
||||
canvasRef.current.width = canvas.width;
|
||||
canvasRef.current.height = canvas.height;
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
ctx?.drawImage(canvas, 0, 0);
|
||||
}
|
||||
setMissingChars(getMissingChars(info.chars, safeText));
|
||||
setFontLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFontError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styleName, safeText]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [styleName, safeText]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}, "image/png");
|
||||
}, [safeText]);
|
||||
const handleDownload = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}.png`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}, "image/png");
|
||||
}, [safeText]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSaveStatus("saving");
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
setSaveStatus("error");
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], "logo.png", { type: "image/png" });
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
startTransition(async () => {
|
||||
const result = await saveLogo(fd);
|
||||
setSaveStatus(result.success ? "done" : "error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
});
|
||||
}, "image/png");
|
||||
}, [startTransition]);
|
||||
const handleSave = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
setSaveStatus("saving");
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
setSaveStatus("error");
|
||||
return;
|
||||
}
|
||||
const file = new File([blob], "logo.png", { type: "image/png" });
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
startTransition(async () => {
|
||||
const result = await saveLogo(fd);
|
||||
setSaveStatus(result.success ? "done" : "error");
|
||||
setTimeout(() => setSaveStatus("idle"), 3000);
|
||||
});
|
||||
}, "image/png");
|
||||
}, []);
|
||||
|
||||
const [zipping, setZipping] = useState(false);
|
||||
const allFonts = useMemo(() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts), []);
|
||||
const [zipping, setZipping] = useState(false);
|
||||
const allFonts = useMemo(() => HABBO_FONT_GROUPS.flatMap((g) => g.fonts), []);
|
||||
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (zipping) return;
|
||||
setZipping(true);
|
||||
try {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(safeText.replace(/[^a-z0-9]+/gi, "_") || "logo");
|
||||
if (!folder) throw new Error("Failed to create zip folder");
|
||||
let fail = 0;
|
||||
const handleDownloadAll = useCallback(async () => {
|
||||
if (zipping) return;
|
||||
setZipping(true);
|
||||
try {
|
||||
const JSZip = (await import("jszip")).default;
|
||||
const zip = new JSZip();
|
||||
const folder = zip.folder(
|
||||
safeText.replace(/[^a-z0-9]+/gi, "_") || "logo",
|
||||
);
|
||||
if (!folder) throw new Error("Failed to create zip folder");
|
||||
let fail = 0;
|
||||
|
||||
for (const font of allFonts) {
|
||||
try {
|
||||
const info = await getFontInfo(font.styleName);
|
||||
const canvas = renderToCanvas(info.chars, info.h, info.top, info.sheet, safeText, info.avgW);
|
||||
if (!canvas) {
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
const blob = await new Promise<Blob | null>((r) => canvas.toBlob((b) => r(b), "image/png"));
|
||||
if (blob) folder.file(`${font.styleName}.png`, blob);
|
||||
else fail++;
|
||||
} catch {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
for (const font of allFonts) {
|
||||
try {
|
||||
const info = await getFontInfo(font.styleName);
|
||||
const canvas = renderToCanvas(
|
||||
info.chars,
|
||||
info.h,
|
||||
info.top,
|
||||
info.sheet,
|
||||
safeText,
|
||||
info.avgW,
|
||||
);
|
||||
if (!canvas) {
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
const blob = await new Promise<Blob | null>((r) =>
|
||||
canvas.toBlob((b) => r(b), "image/png"),
|
||||
);
|
||||
if (blob) folder.file(`${font.styleName}.png`, blob);
|
||||
else fail++;
|
||||
} catch {
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(content);
|
||||
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}-all-fonts.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
if (fail > 0) alert(`${allFonts.length - fail}/${allFonts.length} fonts (${fail} failed)`);
|
||||
} catch {
|
||||
alert("Failed to create ZIP.");
|
||||
} finally {
|
||||
setZipping(false);
|
||||
}
|
||||
}, [safeText, allFonts, zipping]);
|
||||
const content = await zip.generateAsync({ type: "blob" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(content);
|
||||
a.download = `${safeText.replace(/[^a-z0-9]+/gi, "_") || "logo"}-all-fonts.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
if (fail > 0)
|
||||
alert(
|
||||
`${allFonts.length - fail}/${allFonts.length} fonts (${fail} failed)`,
|
||||
);
|
||||
} catch {
|
||||
alert("Failed to create ZIP.");
|
||||
} finally {
|
||||
setZipping(false);
|
||||
}
|
||||
}, [safeText, allFonts, zipping]);
|
||||
|
||||
return (
|
||||
<div className="card-grid md-2" style={{ alignItems: "start" }}>
|
||||
<ContentCard icon="⚙️" title="Design" subtitle="Pick a Habbo font and type your text.">
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>Hotel name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Your hotel"
|
||||
maxLength={40}
|
||||
/>
|
||||
</label>
|
||||
return (
|
||||
<div className="card-grid md-2" style={{ alignItems: "start" }}>
|
||||
<ContentCard
|
||||
icon="⚙️"
|
||||
title="Design"
|
||||
subtitle="Pick a Habbo font and type your text."
|
||||
>
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
|
||||
Hotel name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Your hotel"
|
||||
maxLength={40}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>Search font</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to filter fonts…"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
|
||||
Search font
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Type to filter fonts…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
|
||||
Font —{" "}
|
||||
<span className="muted">
|
||||
{currentFont?.name ?? styleName} ({styleName})
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={styleName}
|
||||
onChange={(e) => setStyleName(e.target.value)}
|
||||
style={{ maxHeight: 320 }}
|
||||
size={Math.min(filteredGroups.flatMap((g) => g.fonts).length + filteredGroups.length, 12)}
|
||||
>
|
||||
{filteredGroups.map((group) => (
|
||||
<optgroup key={group.groupName} label={group.groupName}>
|
||||
{group.fonts.map((f) => (
|
||||
<option key={f.styleName} value={f.styleName}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "0.3rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.85rem" }}>
|
||||
Font —{" "}
|
||||
<span className="muted">
|
||||
{currentFont?.name ?? styleName} ({styleName})
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
value={styleName}
|
||||
onChange={(e) => setStyleName(e.target.value)}
|
||||
style={{ maxHeight: 320 }}
|
||||
size={Math.min(
|
||||
filteredGroups.flatMap((g) => g.fonts).length +
|
||||
filteredGroups.length,
|
||||
12,
|
||||
)}
|
||||
>
|
||||
{filteredGroups.map((group) => (
|
||||
<optgroup key={group.groupName} label={group.groupName}>
|
||||
{group.fonts.map((f) => (
|
||||
<option key={f.styleName} value={f.styleName}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="button" className="btn btn-primary" onClick={handleDownload} disabled={!fontLoaded}>
|
||||
⬇️ Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving" || !fontLoaded}
|
||||
>
|
||||
{saveStatus === "saving"
|
||||
? "⏳ Saving..."
|
||||
: saveStatus === "done"
|
||||
? "✅ Saved!"
|
||||
: saveStatus === "error"
|
||||
? "❌ Error"
|
||||
: "💾 Save as site logo"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleDownloadAll} disabled={zipping}>
|
||||
{zipping ? "⏳ Creating ZIP..." : "📦 Download all fonts"}
|
||||
</button>
|
||||
</div>
|
||||
</ContentCard>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleDownload}
|
||||
disabled={!fontLoaded}
|
||||
>
|
||||
⬇️ Download PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === "saving" || !fontLoaded}
|
||||
>
|
||||
{saveStatus === "saving"
|
||||
? "⏳ Saving..."
|
||||
: saveStatus === "done"
|
||||
? "✅ Saved!"
|
||||
: saveStatus === "error"
|
||||
? "❌ Error"
|
||||
: "💾 Save as site logo"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={zipping}
|
||||
>
|
||||
{zipping ? "⏳ Creating ZIP..." : "📦 Download all fonts"}
|
||||
</button>
|
||||
</div>
|
||||
</ContentCard>
|
||||
|
||||
<ContentCard icon="👁️" title="Preview" subtitle="Rendered locally from sprite sheets.">
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
minHeight: 220,
|
||||
padding: "1.25rem",
|
||||
borderRadius: "12px",
|
||||
border: "1px dashed color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
|
||||
overflow: "auto",
|
||||
background:
|
||||
"repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
|
||||
}}
|
||||
>
|
||||
{fontError ? (
|
||||
<p className="muted">Failed to load font sprite.</p>
|
||||
) : fontLoaded ? (
|
||||
<>
|
||||
<canvas ref={canvasRef} style={{ maxWidth: "100%", height: "auto" }} />
|
||||
{missingChars.size > 0 && (
|
||||
<p className="muted" style={{ marginTop: 8, fontSize: "0.8rem" }}>
|
||||
Note: this font does not have characters: {[...missingChars].join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Loading font…</p>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
</div>
|
||||
);
|
||||
<ContentCard
|
||||
icon="👁️"
|
||||
title="Preview"
|
||||
subtitle="Rendered locally from sprite sheets."
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
minHeight: 220,
|
||||
padding: "1.25rem",
|
||||
borderRadius: "12px",
|
||||
border:
|
||||
"1px dashed color-mix(in srgb, var(--color-text-muted) 30%, transparent)",
|
||||
overflow: "auto",
|
||||
background:
|
||||
"repeating-conic-gradient(color-mix(in srgb, var(--color-text-muted) 14%, transparent) 0% 25%, transparent 0% 50%) 50% / 22px 22px",
|
||||
}}
|
||||
>
|
||||
{fontError ? (
|
||||
<p className="muted">Failed to load font sprite.</p>
|
||||
) : fontLoaded ? (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ maxWidth: "100%", height: "auto" }}
|
||||
/>
|
||||
{missingChars.size > 0 && (
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: 8, fontSize: "0.8rem" }}
|
||||
>
|
||||
Note: this font does not have characters:{" "}
|
||||
{[...missingChars].join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="muted">Loading font…</p>
|
||||
)}
|
||||
</div>
|
||||
</ContentCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,153 +12,157 @@ import { useCallback, useEffect, useState } from "react";
|
||||
* this component carries no prisma/i18n dependency.
|
||||
*/
|
||||
export type LightboxPhoto = {
|
||||
id: string;
|
||||
url: string;
|
||||
alt: string;
|
||||
date: string;
|
||||
user: string;
|
||||
id: string;
|
||||
url: string;
|
||||
alt: string;
|
||||
date: string;
|
||||
user: string;
|
||||
};
|
||||
|
||||
export function PhotoLightbox({ photos }: { photos: LightboxPhoto[] }) {
|
||||
// Index of the photo shown in the lightbox, or null when closed.
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
|
||||
const active = openIndex !== null ? photos[openIndex] : null;
|
||||
// Index of the photo shown in the lightbox, or null when closed.
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
// eslint-disable-next-line security/detect-object-injection -- openIndex is numeric array index, guarded by null check
|
||||
const active = openIndex !== null ? photos[openIndex] : null;
|
||||
|
||||
const close = useCallback(() => setOpenIndex(null), []);
|
||||
const close = useCallback(() => setOpenIndex(null), []);
|
||||
|
||||
// Esc-to-close + lock background scroll while the overlay is open.
|
||||
useEffect(() => {
|
||||
if (active == null) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [active, close]);
|
||||
// Esc-to-close + lock background scroll while the overlay is open.
|
||||
useEffect(() => {
|
||||
if (active == null) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") close();
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [active, close]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setOpenIndex(i)}
|
||||
className="card hover"
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
border: 0,
|
||||
background: "var(--color-surface)",
|
||||
cursor: "zoom-in",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label={p.alt}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={p.url} alt={p.alt} style={{ width: "100%", height: "auto", display: "block" }} />
|
||||
<div style={{ padding: "0.7rem 0.85rem" }}>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{p.date}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{p.user}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<div className="card-grid sm-2 md-3 lg-4" style={{ padding: "1rem" }}>
|
||||
{photos.map((p, i) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setOpenIndex(i)}
|
||||
className="card hover"
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
border: 0,
|
||||
background: "var(--color-surface)",
|
||||
cursor: "zoom-in",
|
||||
textAlign: "left",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
}}
|
||||
aria-label={p.alt}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.alt}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
/>
|
||||
<div style={{ padding: "0.7rem 0.85rem" }}>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{p.date}
|
||||
</p>
|
||||
<p className="muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||||
{p.user}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={active.alt}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1.5rem",
|
||||
background: "rgba(0, 0, 0, 0.82)",
|
||||
backdropFilter: "blur(8px)",
|
||||
cursor: "zoom-out",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "1rem",
|
||||
right: "1rem",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: 0,
|
||||
background: "rgba(255, 255, 255, 0.16)",
|
||||
color: "#fff",
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* Stop propagation so clicking the image itself doesn't close. */}
|
||||
<figure
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: "min(92vw, 1100px)",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={active.url}
|
||||
alt={active.alt}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "78vh",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "var(--radius-md)",
|
||||
boxShadow: "var(--shadow-card-hover)",
|
||||
}}
|
||||
/>
|
||||
<figcaption
|
||||
style={{
|
||||
color: "rgba(255, 255, 255, 0.85)",
|
||||
fontSize: "0.9rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{active.date} · {active.user}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
{active ? (
|
||||
<div
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={active.alt}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 1000,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "1.5rem",
|
||||
background: "rgba(0, 0, 0, 0.82)",
|
||||
backdropFilter: "blur(8px)",
|
||||
cursor: "zoom-out",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "1rem",
|
||||
right: "1rem",
|
||||
width: "2.5rem",
|
||||
height: "2.5rem",
|
||||
borderRadius: "999px",
|
||||
border: 0,
|
||||
background: "rgba(255, 255, 255, 0.16)",
|
||||
color: "#fff",
|
||||
fontSize: "1.4rem",
|
||||
lineHeight: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{/* Stop propagation so clicking the image itself doesn't close. */}
|
||||
<figure
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
margin: 0,
|
||||
maxWidth: "min(92vw, 1100px)",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
cursor: "default",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={active.url}
|
||||
alt={active.alt}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "78vh",
|
||||
width: "auto",
|
||||
height: "auto",
|
||||
display: "block",
|
||||
borderRadius: "var(--radius-md)",
|
||||
boxShadow: "var(--shadow-card-hover)",
|
||||
}}
|
||||
/>
|
||||
<figcaption
|
||||
style={{
|
||||
color: "rgba(255, 255, 255, 0.85)",
|
||||
fontSize: "0.9rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{active.date} · {active.user}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,22 +12,22 @@ import RadioPlayer from "./radio-player";
|
||||
* /api/radio/config, which lets staff toggle the radio without a redeploy.
|
||||
*/
|
||||
export default async function RadioPlayerGate() {
|
||||
let enabled: boolean;
|
||||
let streamUrl: string;
|
||||
try {
|
||||
const [enabledRaw, urlRaw] = await Promise.all([
|
||||
siteSettings.get("radio_enabled", "0"),
|
||||
siteSettings.get("radio_stream_url", ""),
|
||||
]);
|
||||
const flag = (enabledRaw ?? "0").trim().toLowerCase();
|
||||
enabled = flag === "1" || flag === "true";
|
||||
streamUrl = (urlRaw ?? "").trim();
|
||||
} catch {
|
||||
// Settings unavailable — fail closed (no widget).
|
||||
return null;
|
||||
}
|
||||
let enabled: boolean;
|
||||
let streamUrl: string;
|
||||
try {
|
||||
const [enabledRaw, urlRaw] = await Promise.all([
|
||||
siteSettings.get("radio_enabled", "0"),
|
||||
siteSettings.get("radio_stream_url", ""),
|
||||
]);
|
||||
const flag = (enabledRaw ?? "0").trim().toLowerCase();
|
||||
enabled = flag === "1" || flag === "true";
|
||||
streamUrl = (urlRaw ?? "").trim();
|
||||
} catch {
|
||||
// Settings unavailable — fail closed (no widget).
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!enabled || !streamUrl) return null;
|
||||
if (!enabled || !streamUrl) return null;
|
||||
|
||||
return <RadioPlayer />;
|
||||
return <RadioPlayer />;
|
||||
}
|
||||
@@ -24,36 +24,36 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
const POLL_MS = 15000;
|
||||
|
||||
type RadioConfig = {
|
||||
enabled: boolean;
|
||||
streamUrl: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
streamUrl: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
// ── Defensive parsing of the loosely-typed provider passthroughs ──────────────
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function asString(v: unknown): string {
|
||||
return typeof v === "string" ? v : v == null ? "" : String(v);
|
||||
return typeof v === "string" ? v : v == null ? "" : String(v);
|
||||
}
|
||||
|
||||
function isTruthyFlag(v: unknown): boolean {
|
||||
const s = asString(v).trim().toLowerCase();
|
||||
return s === "1" || s === "true" || s === "yes" || s === "on";
|
||||
const s = asString(v).trim().toLowerCase();
|
||||
return s === "1" || s === "true" || s === "yes" || s === "on";
|
||||
}
|
||||
|
||||
/** Parse /api/radio/config (flat radio_* settings map) into the bits we need. */
|
||||
function parseConfig(data: unknown): RadioConfig {
|
||||
const c = isRecord(data) ? data : {};
|
||||
// radio_enabled defaults to off when the key is absent.
|
||||
const enabled = "radio_enabled" in c ? isTruthyFlag(c.radio_enabled) : false;
|
||||
return {
|
||||
enabled,
|
||||
streamUrl: asString(c.radio_stream_url).trim(),
|
||||
name: asString(c.radio_name).trim() || "Radio",
|
||||
};
|
||||
const c = isRecord(data) ? data : {};
|
||||
// radio_enabled defaults to off when the key is absent.
|
||||
const enabled = "radio_enabled" in c ? isTruthyFlag(c.radio_enabled) : false;
|
||||
return {
|
||||
enabled,
|
||||
streamUrl: asString(c.radio_stream_url).trim(),
|
||||
name: asString(c.radio_name).trim() || "Radio",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,295 +62,336 @@ function parseConfig(data: unknown): RadioConfig {
|
||||
* nesting our proxy may surface. Falls back to a generic label.
|
||||
*/
|
||||
function parseNowPlaying(data: unknown): string {
|
||||
if (!isRecord(data)) {
|
||||
const s = asString(data).trim();
|
||||
return s || "Live radio";
|
||||
}
|
||||
if (!isRecord(data)) {
|
||||
const s = asString(data).trim();
|
||||
return s || "Live radio";
|
||||
}
|
||||
|
||||
const joinSongArtist = (song: unknown, artist: unknown): string | null => {
|
||||
const s = asString(song).trim();
|
||||
const a = asString(artist).trim();
|
||||
if (s && a) return `${a} — ${s}`;
|
||||
if (s) return s;
|
||||
return null;
|
||||
};
|
||||
const joinSongArtist = (song: unknown, artist: unknown): string | null => {
|
||||
const s = asString(song).trim();
|
||||
const a = asString(artist).trim();
|
||||
if (s && a) return `${a} — ${s}`;
|
||||
if (s) return s;
|
||||
return null;
|
||||
};
|
||||
|
||||
// Our proxy's raw-text fallback shape: { nowPlaying: "…" }.
|
||||
const np = asString(data.nowPlaying).trim();
|
||||
if (np) return np;
|
||||
// Our proxy's raw-text fallback shape: { nowPlaying: "…" }.
|
||||
const np = asString(data.nowPlaying).trim();
|
||||
if (np) return np;
|
||||
|
||||
// { song, artist } / { title, artist } at the top level.
|
||||
const top = joinSongArtist(data.song, data.artist) ?? joinSongArtist(data.title, data.artist);
|
||||
if (top) return top;
|
||||
// { song, artist } / { title, artist } at the top level.
|
||||
const top =
|
||||
joinSongArtist(data.song, data.artist) ??
|
||||
joinSongArtist(data.title, data.artist);
|
||||
if (top) return top;
|
||||
|
||||
// AzureCast: { now_playing: { song: { title, artist, text } } }.
|
||||
if (isRecord(data.now_playing)) {
|
||||
const inner = data.now_playing;
|
||||
if (isRecord(inner.song)) {
|
||||
const s = inner.song;
|
||||
const text = asString(s.text).trim();
|
||||
if (text) return text;
|
||||
const made = joinSongArtist(s.title, s.artist);
|
||||
if (made) return made;
|
||||
}
|
||||
const made = joinSongArtist(inner.song, inner.artist) ?? joinSongArtist(inner.title, inner.artist);
|
||||
if (made) return made;
|
||||
}
|
||||
// AzureCast: { now_playing: { song: { title, artist, text } } }.
|
||||
if (isRecord(data.now_playing)) {
|
||||
const inner = data.now_playing;
|
||||
if (isRecord(inner.song)) {
|
||||
const s = inner.song;
|
||||
const text = asString(s.text).trim();
|
||||
if (text) return text;
|
||||
const made = joinSongArtist(s.title, s.artist);
|
||||
if (made) return made;
|
||||
}
|
||||
const made =
|
||||
joinSongArtist(inner.song, inner.artist) ??
|
||||
joinSongArtist(inner.title, inner.artist);
|
||||
if (made) return made;
|
||||
}
|
||||
|
||||
return "Live radio";
|
||||
return "Live radio";
|
||||
}
|
||||
|
||||
/** Extract the current DJ / show name when the provider exposes one. */
|
||||
function parseDj(data: unknown): string {
|
||||
if (!isRecord(data)) return "";
|
||||
const candidates: unknown[] = [data.dj, data.show_name, data.streamer, data.show];
|
||||
if (isRecord(data.live)) {
|
||||
candidates.push(data.live.streamer_name, data.live.streamer);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
if (isRecord(c)) {
|
||||
const name = asString(c.username).trim() || asString(c.show_name).trim() || asString(c.name).trim();
|
||||
if (name) return name;
|
||||
} else {
|
||||
const s = asString(c).trim();
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
if (!isRecord(data)) return "";
|
||||
const candidates: unknown[] = [
|
||||
data.dj,
|
||||
data.show_name,
|
||||
data.streamer,
|
||||
data.show,
|
||||
];
|
||||
if (isRecord(data.live)) {
|
||||
candidates.push(data.live.streamer_name, data.live.streamer);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
if (isRecord(c)) {
|
||||
const name =
|
||||
asString(c.username).trim() ||
|
||||
asString(c.show_name).trim() ||
|
||||
asString(c.name).trim();
|
||||
if (name) return name;
|
||||
} else {
|
||||
const s = asString(c).trim();
|
||||
if (s) return s;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Extract the listener count from /api/radio/listeners ({ listeners }). */
|
||||
function parseListeners(data: unknown): number | null {
|
||||
if (!isRecord(data)) return null;
|
||||
const v = data.listeners;
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
return null;
|
||||
if (!isRecord(data)) return null;
|
||||
const v = data.listeners;
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function RadioPlayer() {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(50); // 0–100
|
||||
const [nowPlaying, setNowPlaying] = useState("Loading…");
|
||||
const [dj, setDj] = useState("");
|
||||
const [listeners, setListeners] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(50); // 0–100
|
||||
const [nowPlaying, setNowPlaying] = useState("Loading…");
|
||||
const [dj, setDj] = useState("");
|
||||
const [listeners, setListeners] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// Poll config + now-playing + listeners every POLL_MS; clear on unmount.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function getJson(url: string): Promise<unknown | null> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function getJson(url: string): Promise<unknown | null> {
|
||||
try {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [cfg, np, lst] = await Promise.all([
|
||||
getJson("/api/radio/config"),
|
||||
getJson("/api/radio/now-playing"),
|
||||
getJson("/api/radio/listeners"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
async function refresh() {
|
||||
const [cfg, np, lst] = await Promise.all([
|
||||
getJson("/api/radio/config"),
|
||||
getJson("/api/radio/now-playing"),
|
||||
getJson("/api/radio/listeners"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
if (cfg !== null) setConfig(parseConfig(cfg));
|
||||
if (np !== null) {
|
||||
setNowPlaying(parseNowPlaying(np));
|
||||
setDj(parseDj(np));
|
||||
}
|
||||
if (lst !== null) setListeners(parseListeners(lst));
|
||||
}
|
||||
if (cfg !== null) setConfig(parseConfig(cfg));
|
||||
if (np !== null) {
|
||||
setNowPlaying(parseNowPlaying(np));
|
||||
setDj(parseDj(np));
|
||||
}
|
||||
if (lst !== null) setListeners(parseListeners(lst));
|
||||
}
|
||||
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
void refresh();
|
||||
const id = setInterval(() => void refresh(), POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep the <audio> volume in sync with the slider.
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume / 100;
|
||||
}, [volume]);
|
||||
// Keep the <audio> volume in sync with the slider.
|
||||
useEffect(() => {
|
||||
if (audioRef.current) audioRef.current.volume = volume / 100;
|
||||
}, [volume]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
const streamUrl = config?.streamUrl;
|
||||
if (!audio || !streamUrl) return;
|
||||
const toggle = useCallback(async () => {
|
||||
const audio = audioRef.current;
|
||||
const streamUrl = config?.streamUrl;
|
||||
if (!audio || !streamUrl) return;
|
||||
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
if (playing) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
// (Re)point at the live stream each time we start so we never replay a
|
||||
// buffered segment of a continuous broadcast.
|
||||
if (audio.src !== streamUrl) audio.src = streamUrl;
|
||||
audio.volume = volume / 100;
|
||||
try {
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setError("Couldn't start playback. Check your browser settings.");
|
||||
setPlaying(false);
|
||||
}
|
||||
}, [config?.streamUrl, playing, volume]);
|
||||
setError(null);
|
||||
// (Re)point at the live stream each time we start so we never replay a
|
||||
// buffered segment of a continuous broadcast.
|
||||
if (audio.src !== streamUrl) audio.src = streamUrl;
|
||||
audio.volume = volume / 100;
|
||||
try {
|
||||
await audio.play();
|
||||
setPlaying(true);
|
||||
} catch {
|
||||
setError("Couldn't start playback. Check your browser settings.");
|
||||
setPlaying(false);
|
||||
}
|
||||
}, [config?.streamUrl, playing, volume]);
|
||||
|
||||
// Disabled or unconfigured → render nothing (and ship no widget UI).
|
||||
if (!config || !config.enabled || !config.streamUrl) return null;
|
||||
// Disabled or unconfigured → render nothing (and ship no widget UI).
|
||||
if (!config?.enabled || !config.streamUrl) return null;
|
||||
|
||||
const surface = "var(--color-surface)";
|
||||
const primary = "var(--color-primary)";
|
||||
const text = "var(--color-text)";
|
||||
const muted = "var(--color-text-muted)";
|
||||
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
|
||||
const surface = "var(--color-surface)";
|
||||
const primary = "var(--color-primary)";
|
||||
const text = "var(--color-text)";
|
||||
const muted = "var(--color-text-muted)";
|
||||
const border = "color-mix(in srgb, var(--color-text-muted) 18%, transparent)";
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`${config.name} player`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
zIndex: 9999,
|
||||
width: "min(20rem, calc(100vw - 2rem))",
|
||||
background: surface,
|
||||
color: text,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: "14px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
|
||||
padding: "0.7rem 0.85rem",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-label={playing ? "Pause radio" : "Play radio"}
|
||||
aria-pressed={playing}
|
||||
style={{
|
||||
flex: "none",
|
||||
width: "38px",
|
||||
height: "38px",
|
||||
borderRadius: "999px",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: primary,
|
||||
color: "var(--button-text-color-readable, var(--button-text-color))",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{playing ? "⏸" : "▶"}</span>
|
||||
</button>
|
||||
return (
|
||||
<section
|
||||
aria-label={`${config.name} player`}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: "1rem",
|
||||
right: "1rem",
|
||||
zIndex: 9999,
|
||||
width: "min(20rem, calc(100vw - 2rem))",
|
||||
background: surface,
|
||||
color: text,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: "14px",
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)",
|
||||
padding: "0.7rem 0.85rem",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggle()}
|
||||
aria-label={playing ? "Pause radio" : "Play radio"}
|
||||
aria-pressed={playing}
|
||||
style={{
|
||||
flex: "none",
|
||||
width: "38px",
|
||||
height: "38px",
|
||||
borderRadius: "999px",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: primary,
|
||||
color:
|
||||
"var(--button-text-color-readable, var(--button-text-color))",
|
||||
fontSize: "1rem",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{playing ? "⏸" : "▶"}</span>
|
||||
</button>
|
||||
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: primary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "999px",
|
||||
background: playing ? "#16a34a" : muted,
|
||||
boxShadow: playing ? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)" : "none",
|
||||
}}
|
||||
/>
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{config.name}
|
||||
{dj ? ` · ${dj}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
title={nowPlaying}
|
||||
style={{
|
||||
marginTop: "0.1rem",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{nowPlaying}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
color: primary,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "999px",
|
||||
background: playing ? "#16a34a" : muted,
|
||||
boxShadow: playing
|
||||
? "0 0 0 3px color-mix(in srgb, #16a34a 22%, transparent)"
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{config.name}
|
||||
{dj ? ` · ${dj}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
title={nowPlaying}
|
||||
style={{
|
||||
marginTop: "0.1rem",
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{nowPlaying}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
aria-label={listeners != null ? `${listeners.toLocaleString()} listeners` : "Listeners"}
|
||||
style={{
|
||||
flex: "none",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
color: muted,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>👥</span>
|
||||
<span aria-hidden>{listeners != null ? listeners.toLocaleString() : "--"}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
aria-label={
|
||||
listeners != null
|
||||
? `${listeners.toLocaleString()} listeners`
|
||||
: "Listeners"
|
||||
}
|
||||
style={{
|
||||
flex: "none",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 700,
|
||||
color: muted,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>👥</span>
|
||||
<span aria-hidden>
|
||||
{listeners != null ? listeners.toLocaleString() : "--"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginTop: "0.55rem" }}>
|
||||
<span aria-hidden style={{ fontSize: "0.85rem", color: muted, lineHeight: 1 }}>
|
||||
🔈
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
aria-label="Volume"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "4px",
|
||||
cursor: "pointer",
|
||||
accentColor: "var(--color-primary)",
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
marginTop: "0.55rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ fontSize: "0.85rem", color: muted, lineHeight: 1 }}
|
||||
>
|
||||
🔈
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
aria-label="Volume"
|
||||
style={{
|
||||
flex: 1,
|
||||
height: "4px",
|
||||
cursor: "pointer",
|
||||
accentColor: "var(--color-primary)",
|
||||
padding: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{ margin: "0.45rem 0 0", fontSize: "0.72rem", color: "var(--color-danger)" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
margin: "0.45rem 0 0",
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--color-danger)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Hidden stream element. preload="none" so nothing loads until the user
|
||||
{/* Hidden stream element. preload="none" so nothing loads until the user
|
||||
hits play (and browsers block autoplay with sound regardless). */}
|
||||
<audio ref={audioRef} preload="none" />
|
||||
</section>
|
||||
);
|
||||
<audio ref={audioRef} preload="none" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +1,192 @@
|
||||
const CHAR_ORDER = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
interface CharInfo {
|
||||
char: string;
|
||||
x: number;
|
||||
w: number;
|
||||
char: string;
|
||||
x: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
const fontCache = new Map<
|
||||
string,
|
||||
{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }
|
||||
string,
|
||||
{
|
||||
chars: CharInfo[];
|
||||
h: number;
|
||||
top: number;
|
||||
sheet: HTMLImageElement;
|
||||
avgW: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function isTransparent(data: Uint8ClampedArray, x: number, y: number, w: number): boolean {
|
||||
return data[(y * w + x) * 4 + 3] === 0;
|
||||
function isTransparent(
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
): boolean {
|
||||
return data[(y * w + x) * 4 + 3] === 0;
|
||||
}
|
||||
|
||||
function colEmpty(data: Uint8ClampedArray, x: number, w: number, h: number): boolean {
|
||||
for (let y = 0; y < h; y++) {
|
||||
if (!isTransparent(data, x, y, w)) return false;
|
||||
}
|
||||
return true;
|
||||
function colEmpty(
|
||||
data: Uint8ClampedArray,
|
||||
x: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): boolean {
|
||||
for (let y = 0; y < h; y++) {
|
||||
if (!isTransparent(data, x, y, w)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scanCharWidths(data: Uint8ClampedArray, sw: number, sh: number): CharInfo[] {
|
||||
const chars: CharInfo[] = [];
|
||||
let ci = 0;
|
||||
let x = 0;
|
||||
function scanCharWidths(
|
||||
data: Uint8ClampedArray,
|
||||
sw: number,
|
||||
sh: number,
|
||||
): CharInfo[] {
|
||||
const chars: CharInfo[] = [];
|
||||
let ci = 0;
|
||||
let x = 0;
|
||||
|
||||
while (x < sw && ci < CHAR_ORDER.length) {
|
||||
// skip transparent columns
|
||||
while (x < sw && colEmpty(data, x, sw, sh)) x++;
|
||||
if (x >= sw) break;
|
||||
while (x < sw && ci < CHAR_ORDER.length) {
|
||||
// skip transparent columns
|
||||
while (x < sw && colEmpty(data, x, sw, sh)) x++;
|
||||
if (x >= sw) break;
|
||||
|
||||
const start = x;
|
||||
// find end of character
|
||||
while (x < sw && !colEmpty(data, x, sw, sh)) x++;
|
||||
const end = x;
|
||||
const start = x;
|
||||
// find end of character
|
||||
while (x < sw && !colEmpty(data, x, sw, sh)) x++;
|
||||
const end = x;
|
||||
|
||||
const w = end - start;
|
||||
if (w > 0) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
||||
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
||||
ci++;
|
||||
}
|
||||
}
|
||||
const w = end - start;
|
||||
if (w > 0) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- ci increments within CHAR_ORDER bounds
|
||||
chars.push({ char: CHAR_ORDER[ci], x: start, w });
|
||||
ci++;
|
||||
}
|
||||
}
|
||||
|
||||
return chars;
|
||||
return chars;
|
||||
}
|
||||
|
||||
export async function getFontInfo(
|
||||
font: string,
|
||||
): Promise<{ chars: CharInfo[]; h: number; top: number; sheet: HTMLImageElement; avgW: number }> {
|
||||
const cached = fontCache.get(font);
|
||||
if (cached) return cached;
|
||||
export async function getFontInfo(font: string): Promise<{
|
||||
chars: CharInfo[];
|
||||
h: number;
|
||||
top: number;
|
||||
sheet: HTMLImageElement;
|
||||
avgW: number;
|
||||
}> {
|
||||
const cached = fontCache.get(font);
|
||||
if (cached) return cached;
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`));
|
||||
img.src = `/assets/fonts/sheets/${font}.gif`;
|
||||
});
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error(`Failed to load sprite for ${font}`));
|
||||
img.src = `/assets/fonts/sheets/${font}.gif`;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not get 2D context");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not get 2D context");
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
|
||||
const chars = scanCharWidths(imageData.data, canvas.width, canvas.height);
|
||||
|
||||
// measure total height by finding first/last non-empty row
|
||||
let top = canvas.height,
|
||||
bottom = 0;
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
if (!isTransparent(imageData.data, x, y, canvas.width)) {
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const h = bottom - top + 1;
|
||||
// measure total height by finding first/last non-empty row
|
||||
let top = canvas.height,
|
||||
bottom = 0;
|
||||
for (let y = 0; y < canvas.height; y++) {
|
||||
for (let x = 0; x < canvas.width; x++) {
|
||||
if (!isTransparent(imageData.data, x, y, canvas.width)) {
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const h = bottom - top + 1;
|
||||
|
||||
const avgW = chars.length > 0 ? Math.round(chars.reduce((s, c) => s + c.w, 0) / chars.length) : 12;
|
||||
const avgW =
|
||||
chars.length > 0
|
||||
? Math.round(chars.reduce((s, c) => s + c.w, 0) / chars.length)
|
||||
: 12;
|
||||
|
||||
const result = { chars, h, top, sheet: img, avgW };
|
||||
fontCache.set(font, result);
|
||||
return result;
|
||||
const result = { chars, h, top, sheet: img, avgW };
|
||||
fontCache.set(font, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function measureText(chars: CharInfo[], text: string, avgW: number): { totalW: number } {
|
||||
const map = new Map(chars.map((c) => [c.char, c]));
|
||||
let totalW = 0;
|
||||
for (const ch of text.toLowerCase()) {
|
||||
if (ch === " ") {
|
||||
totalW += avgW;
|
||||
} else {
|
||||
const info = map.get(ch);
|
||||
totalW += info?.w ?? 0;
|
||||
}
|
||||
}
|
||||
return { totalW };
|
||||
export function measureText(
|
||||
chars: CharInfo[],
|
||||
text: string,
|
||||
avgW: number,
|
||||
): { totalW: number } {
|
||||
const map = new Map(chars.map((c) => [c.char, c]));
|
||||
let totalW = 0;
|
||||
for (const ch of text.toLowerCase()) {
|
||||
if (ch === " ") {
|
||||
totalW += avgW;
|
||||
} else {
|
||||
const info = map.get(ch);
|
||||
totalW += info?.w ?? 0;
|
||||
}
|
||||
}
|
||||
return { totalW };
|
||||
}
|
||||
|
||||
export function getMissingChars(chars: CharInfo[], text: string): Set<string> {
|
||||
const available = new Set(chars.map((c) => c.char));
|
||||
const missing = new Set<string>();
|
||||
for (const ch of text.toLowerCase()) {
|
||||
if (ch !== " " && !available.has(ch)) {
|
||||
missing.add(ch);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
const available = new Set(chars.map((c) => c.char));
|
||||
const missing = new Set<string>();
|
||||
for (const ch of text.toLowerCase()) {
|
||||
if (ch !== " " && !available.has(ch)) {
|
||||
missing.add(ch);
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function renderToCanvas(
|
||||
chars: CharInfo[],
|
||||
h: number,
|
||||
top: number,
|
||||
sheet: HTMLImageElement,
|
||||
text: string,
|
||||
avgW: number,
|
||||
dpr = 1,
|
||||
chars: CharInfo[],
|
||||
h: number,
|
||||
top: number,
|
||||
sheet: HTMLImageElement,
|
||||
text: string,
|
||||
avgW: number,
|
||||
dpr = 1,
|
||||
): HTMLCanvasElement | null {
|
||||
const map = new Map(chars.map((c) => [c.char, c]));
|
||||
const safeText = text.toLowerCase();
|
||||
const map = new Map(chars.map((c) => [c.char, c]));
|
||||
const safeText = text.toLowerCase();
|
||||
|
||||
let totalW = 0;
|
||||
for (const ch of safeText) {
|
||||
totalW += ch === " " ? avgW : (map.get(ch)?.w ?? 0);
|
||||
}
|
||||
let totalW = 0;
|
||||
for (const ch of safeText) {
|
||||
totalW += ch === " " ? avgW : (map.get(ch)?.w ?? 0);
|
||||
}
|
||||
|
||||
if (totalW === 0) return null;
|
||||
if (totalW === 0) return null;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(totalW * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.scale(dpr, dpr);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(totalW * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
let x = 0;
|
||||
for (const ch of safeText) {
|
||||
if (ch === " ") {
|
||||
x += avgW;
|
||||
} else {
|
||||
const info = map.get(ch);
|
||||
if (info) {
|
||||
ctx.drawImage(sheet, info.x, top, info.w, h, x, 0, info.w, h);
|
||||
x += info.w;
|
||||
}
|
||||
}
|
||||
}
|
||||
let x = 0;
|
||||
for (const ch of safeText) {
|
||||
if (ch === " ") {
|
||||
x += avgW;
|
||||
} else {
|
||||
const info = map.get(ch);
|
||||
if (info) {
|
||||
ctx.drawImage(sheet, info.x, top, info.w, h, x, 0, info.w, h);
|
||||
x += info.w;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
return canvas;
|
||||
}
|
||||
+135
-91
@@ -9,118 +9,162 @@ import type { ReactNode } from "react";
|
||||
*/
|
||||
|
||||
export function ContentCard({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
padded = true,
|
||||
}: {
|
||||
icon?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
padded?: boolean;
|
||||
icon?: string;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
action?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
const hasHead = title != null || icon != null || subtitle != null || action != null;
|
||||
return (
|
||||
<div className={`content-card${className ? ` ${className}` : ""}`}>
|
||||
{hasHead ? (
|
||||
<div className="content-card-head">
|
||||
{icon ? (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="content-card-head-text">
|
||||
{title != null ? <p className="content-card-title">{title}</p> : null}
|
||||
{subtitle != null ? <p className="content-card-subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="content-card-action">{action}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={padded ? "content-card-body" : undefined}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
const hasHead =
|
||||
title != null || icon != null || subtitle != null || action != null;
|
||||
return (
|
||||
<div className={`content-card${className ? ` ${className}` : ""}`}>
|
||||
{hasHead ? (
|
||||
<div className="content-card-head">
|
||||
{icon ? (
|
||||
<span className="content-card-icon" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="content-card-head-text">
|
||||
{title != null ? (
|
||||
<p className="content-card-title">{title}</p>
|
||||
) : null}
|
||||
{subtitle != null ? (
|
||||
<p className="content-card-subtitle">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ? <div className="content-card-action">{action}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={padded ? "content-card-body" : undefined}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A value + label tile (profile stats, rare-value summaries, …). */
|
||||
export function StatBlock({ value, label, icon }: { value: ReactNode; label: string; icon?: string }) {
|
||||
return (
|
||||
<div className="stat-block">
|
||||
<div className="stat-block-value">
|
||||
{icon ? <span aria-hidden>{icon} </span> : null}
|
||||
{value}
|
||||
</div>
|
||||
<div className="stat-block-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
export function StatBlock({
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
}: {
|
||||
value: ReactNode;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-block">
|
||||
<div className="stat-block-value">
|
||||
{icon ? <span aria-hidden>{icon} </span> : null}
|
||||
{value}
|
||||
</div>
|
||||
<div className="stat-block-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Friendly empty state with an icon and a muted line. */
|
||||
export function EmptyState({ icon = "📭", children }: { icon?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
export function EmptyState({
|
||||
icon = "📭",
|
||||
children,
|
||||
}: {
|
||||
icon?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon" aria-hidden>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
{children}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Online / offline pill with a status dot. */
|
||||
export function OnlineBadge({ online }: { online: boolean }) {
|
||||
return (
|
||||
<span className={`online-badge${online ? " online" : ""}`}>
|
||||
<span className="online-dot" aria-hidden />
|
||||
{online ? "Online" : "Offline"}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className={`online-badge${online ? " online" : ""}`}>
|
||||
<span className="online-dot" aria-hidden />
|
||||
{online ? "Online" : "Offline"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Leaderboard rank chip — medals for the top three, number otherwise. */
|
||||
export function RankBadge({ position }: { position: number }) {
|
||||
const medal = position === 1 ? "🥇" : position === 2 ? "🥈" : position === 3 ? "🥉" : null;
|
||||
return (
|
||||
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>{medal ?? `#${position}`}</span>
|
||||
);
|
||||
const medal =
|
||||
position === 1
|
||||
? "🥇"
|
||||
: position === 2
|
||||
? "🥈"
|
||||
: position === 3
|
||||
? "🥉"
|
||||
: null;
|
||||
return (
|
||||
<span className={`rank-badge rank-${position <= 3 ? position : "n"}`}>
|
||||
{medal ?? `#${position}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Skeleton loaders ────────────────────────────────────────── */
|
||||
|
||||
export function Skeleton({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: "color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
borderRadius: "6px",
|
||||
animation: "shimmer 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
export function Skeleton({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
borderRadius: "6px",
|
||||
animation: "shimmer 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3 }: { lines?: number }) {
|
||||
return (
|
||||
<div className="content-card" style={{ padding: "1rem" }}>
|
||||
<div className="content-card-head" style={{ margin: "-1rem -1rem 1rem" }}>
|
||||
<Skeleton style={{ width: "44px", height: "44px", borderRadius: "999px" }} />
|
||||
<div style={{ flex: 1, display: "grid", gap: "0.35rem" }}>
|
||||
<Skeleton style={{ height: "16px", width: "60%" }} />
|
||||
<Skeleton style={{ height: "12px", width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton key={i} style={{ height: "12px", width: `${85 - i * 10}%`, marginBottom: "0.6rem" }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="content-card" style={{ padding: "1rem" }}>
|
||||
<div className="content-card-head" style={{ margin: "-1rem -1rem 1rem" }}>
|
||||
<Skeleton
|
||||
style={{ width: "44px", height: "44px", borderRadius: "999px" }}
|
||||
/>
|
||||
<div style={{ flex: 1, display: "grid", gap: "0.35rem" }}>
|
||||
<Skeleton style={{ height: "16px", width: "60%" }} />
|
||||
<Skeleton style={{ height: "12px", width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
style={{
|
||||
height: "12px",
|
||||
width: `${85 - i * 10}%`,
|
||||
marginBottom: "0.6rem",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user