feat(decorations): 530 new avatar decorations, thumbnail-first picker
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s
Adds 530 decorations in 19 new categories (631 total). Decorations that contain Discord branding (the Clyde-visor helmets) are excluded. The decorations are ~1 MB animated PNGs, so a picker that rendered every one would pull ~590 MB while scrolling. The picker now: - shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN) and loads the animated file only on hover, focus or selection, with a fallback to the full file if a thumbnail is missing; - mounts one category at a time behind tabs, plus a name search across all categories. scripts/makeDecorationThumbs.py builds the thumbnails from the busiest frame of each animation (many start on an empty frame). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4323babfb9
commit
78b1a2ff6f
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build static picker thumbnails for avatar decorations.
|
||||||
|
|
||||||
|
The decorations are animated APNGs (~1 MB each). The settings picker shows
|
||||||
|
static WebP thumbnails instead and only loads the animated file on hover,
|
||||||
|
focus or selection, so browsing the catalog costs a few MB, not hundreds.
|
||||||
|
|
||||||
|
Usage: python3 scripts/makeDecorationThumbs.py <dir-of-slug.png> <out-dir>
|
||||||
|
Then upload <out-dir>/*.webp to `${DECORATION_CDN}/thumbs/`.
|
||||||
|
A missing thumbnail is harmless: the picker falls back to the full PNG.
|
||||||
|
|
||||||
|
Requires Pillow (pip install pillow).
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageSequence
|
||||||
|
|
||||||
|
SIZE = 144 # 2x the 72px picker cell
|
||||||
|
|
||||||
|
|
||||||
|
def best_frame(im: Image.Image) -> Image.Image:
|
||||||
|
# Many animations start (or loop through) an empty frame, so pick the frame
|
||||||
|
# with the most visible pixels rather than frame 0.
|
||||||
|
best, best_score = None, -1
|
||||||
|
frames = list(ImageSequence.Iterator(im))
|
||||||
|
step = max(1, len(frames) // 24)
|
||||||
|
for frame in frames[::step]:
|
||||||
|
rgba = frame.convert('RGBA')
|
||||||
|
score = sum(rgba.getchannel('A').histogram()[33:])
|
||||||
|
if score > best_score:
|
||||||
|
best, best_score = rgba, score
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def main(src: str, out: str) -> None:
|
||||||
|
out_dir = Path(out)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for png in sorted(Path(src).glob('*.png')):
|
||||||
|
with Image.open(png) as im:
|
||||||
|
thumb = best_frame(im).resize((SIZE, SIZE), Image.LANCZOS)
|
||||||
|
thumb.save(out_dir / f'{png.stem}.webp', 'WEBP', quality=82, method=6)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if len(sys.argv) != 3:
|
||||||
|
sys.exit(__doc__)
|
||||||
|
main(sys.argv[1], sys.argv[2])
|
||||||
@@ -182,6 +182,650 @@ export const DECORATION_CATEGORIES: DecorationCategory[] = [
|
|||||||
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
|
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'reactions',
|
||||||
|
label: 'Reactions',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'shy', name: 'Shy' },
|
||||||
|
{ slug: 'shy_in_love', name: 'Shy (In Love)' },
|
||||||
|
{ slug: 'rumbling', name: 'Rumbling' },
|
||||||
|
{ slug: 'angry', name: 'Angry' },
|
||||||
|
{ slug: 'angry_pink', name: 'Angry (Pink)' },
|
||||||
|
{ slug: 'angry_yellow', name: 'Angry (Yellow)' },
|
||||||
|
{ slug: 'radiating_energy', name: 'Radiating Energy' },
|
||||||
|
{ slug: 'radiating_energy_yellow', name: 'Radiating Energy (Yellow)' },
|
||||||
|
{ slug: 'radiating_energy_blue', name: 'Radiating Energy (Blue)' },
|
||||||
|
{ slug: 'sweat_drops', name: 'Sweat Drops' },
|
||||||
|
{ slug: 'sweat_drops_cyan', name: 'Sweat Drops (Cyan)' },
|
||||||
|
{ slug: 'sweat_drops_pink', name: 'Sweat Drops (Pink)' },
|
||||||
|
{ slug: 'soul_leaving_body', name: 'Soul Leaving Body' },
|
||||||
|
{ slug: 'soul_leaving_body_blue', name: 'Soul Leaving Body (Blue)' },
|
||||||
|
{ slug: 'starry_eyed', name: 'Starry Eyed' },
|
||||||
|
{ slug: 'starry_eyed_green', name: 'Starry Eyed (Green)' },
|
||||||
|
{ slug: 'starry_eyed_pink', name: 'Starry Eyed (Pink)' },
|
||||||
|
{ slug: 'shocked', name: 'Shocked' },
|
||||||
|
{ slug: 'shocked_green', name: 'Shocked (Green)' },
|
||||||
|
{ slug: 'shocked_yellow', name: 'Shocked (Yellow)' },
|
||||||
|
{ slug: 'in_love', name: 'In Love' },
|
||||||
|
{ slug: 'in_love_blue', name: 'In Love (Blue)' },
|
||||||
|
{ slug: 'in_love_green', name: 'In Love (Green)' },
|
||||||
|
{ slug: 'ki_energy_green', name: 'Ki Energy (Green)' },
|
||||||
|
{ slug: 'ki_energy_cyan', name: 'Ki Energy (Cyan)' },
|
||||||
|
{ slug: 'ki_energy_blue', name: 'Ki Energy (Blue)' },
|
||||||
|
{ slug: 'ki_energy_fuchsia', name: 'Ki Energy (Fuchsia)' },
|
||||||
|
{ slug: 'heartbloom', name: 'Heartbloom' },
|
||||||
|
{ slug: 'heartbloom_purple', name: 'Heartbloom (Purple)' },
|
||||||
|
{ slug: 'heartbloom_blue', name: 'Heartbloom (Blue)' },
|
||||||
|
{ slug: 'heartbloom_yellow', name: 'Heartbloom (Yellow)' },
|
||||||
|
{ slug: 'heartbloom_green', name: 'Heartbloom (Green)' },
|
||||||
|
{ slug: 'dismay', name: 'Dismay' },
|
||||||
|
{ slug: 'dismay_purple', name: 'Dismay (Purple)' },
|
||||||
|
{ slug: 'dismay_pink', name: 'Dismay (Pink)' },
|
||||||
|
{ slug: 'dismay_yellow', name: 'Dismay (Yellow)' },
|
||||||
|
{ slug: 'dismay_green', name: 'Dismay (Green)' },
|
||||||
|
{ slug: 'in_tears', name: 'In Tears' },
|
||||||
|
{ slug: 'in_tears_purple', name: 'In Tears (Purple)' },
|
||||||
|
{ slug: 'in_tears_pink', name: 'In Tears (Pink)' },
|
||||||
|
{ slug: 'in_tears_yellow', name: 'In Tears (Yellow)' },
|
||||||
|
{ slug: 'in_tears_green', name: 'In Tears (Green)' },
|
||||||
|
{ slug: 'rage', name: 'Rage' },
|
||||||
|
{ slug: 'rage_green', name: 'Rage (Green)' },
|
||||||
|
{ slug: 'rage_blue', name: 'Rage (Blue)' },
|
||||||
|
{ slug: 'rage_purple', name: 'Rage (Purple)' },
|
||||||
|
{ slug: 'rage_red', name: 'Rage (Red)' },
|
||||||
|
{ slug: 'copium', name: 'Copium' },
|
||||||
|
{ slug: 'trash', name: 'Trash' },
|
||||||
|
{ slug: 'grass_toucher', name: 'Grass Toucher' },
|
||||||
|
{ slug: 'touch_grass', name: 'Touch Grass' },
|
||||||
|
{ slug: 'press_f_black', name: 'Press F (Black)' },
|
||||||
|
{ slug: 'press_f_white', name: 'Press F (White)' },
|
||||||
|
{ slug: 'git_gud', name: 'Git Gud' },
|
||||||
|
{ slug: 'ggez', name: 'GGEZ' },
|
||||||
|
{ slug: 'questionable', name: 'Questionable' },
|
||||||
|
{ slug: 'buffering', name: 'Buffering' },
|
||||||
|
{ slug: 'toilet_lid', name: 'Toilet Lid' },
|
||||||
|
{ slug: 'feeling_cute', name: 'Feeling Cute' },
|
||||||
|
{ slug: 'im_a_clown', name: "I'm a Clown" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'critters',
|
||||||
|
label: 'Critters',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'cat_1', name: 'Lazy Cat (Ginger)' },
|
||||||
|
{ slug: 'cat_2', name: 'Lazy Cat (Tabby)' },
|
||||||
|
{ slug: 'cat_3', name: 'Lazy Cat (Calico)' },
|
||||||
|
{ slug: 'cat_4', name: 'Lazy Cat (Siamese)' },
|
||||||
|
{ slug: 'dog_1', name: 'Dogs (Tan)' },
|
||||||
|
{ slug: 'dog_2', name: 'Dogs (Spotted)' },
|
||||||
|
{ slug: 'dog_3', name: 'Dogs (Brown)' },
|
||||||
|
{ slug: 'frog_1', name: 'Frog' },
|
||||||
|
{ slug: 'frog_derpy', name: 'Derpy Frog' },
|
||||||
|
{ slug: 'frog_3', name: 'Sleepy Frog' },
|
||||||
|
{ slug: 'frog_angry', name: 'Angry Frog' },
|
||||||
|
{ slug: 'mushroom_1', name: 'Mushroom (Moss)' },
|
||||||
|
{ slug: 'mushroom_2', name: 'Mushroom (Amber)' },
|
||||||
|
{ slug: 'mushroom_3', name: 'Mushroom (Rust)' },
|
||||||
|
{ slug: 'mushroom_4', name: 'Mushroom (Neon)' },
|
||||||
|
{ slug: 'hoppy_day', name: 'Hoppy Day' },
|
||||||
|
{ slug: 'hoppy_boi', name: 'Hoppy Boi' },
|
||||||
|
{ slug: 'cow_glider', name: 'Cow Glider' },
|
||||||
|
{ slug: 'moomoo_hood', name: 'Moomoo Hood' },
|
||||||
|
{ slug: 'cosy_deer', name: 'Cosy Deer' },
|
||||||
|
{ slug: 'berry_bunny', name: 'Berry Bunny' },
|
||||||
|
{ slug: 'mochi_bunny_pink', name: 'Mochi Bunny (Pink)' },
|
||||||
|
{ slug: 'mochi_bunny_blue', name: 'Mochi Bunny (Blue)' },
|
||||||
|
{ slug: 'nibbles', name: 'Nibbles' },
|
||||||
|
{ slug: 'hammy', name: 'Hammy' },
|
||||||
|
{ slug: 'hammy_blue', name: 'Hammy (Blue)' },
|
||||||
|
{ slug: 'hammy_green', name: 'Hammy (Green)' },
|
||||||
|
{ slug: 'hammy_pink', name: 'Hammy (Pink)' },
|
||||||
|
{ slug: 'shroomling', name: 'Shroomling' },
|
||||||
|
{ slug: 'shroomling_blue', name: 'Shroomling (Blue)' },
|
||||||
|
{ slug: 'shroomling_brown', name: 'Shroomling (Brown)' },
|
||||||
|
{ slug: 'shroomling_pink', name: 'Shroomling (Pink)' },
|
||||||
|
{ slug: 'shroomling_purple', name: 'Shroomling (Purple)' },
|
||||||
|
{ slug: 'sleepy_bee', name: 'Sleepy Bee' },
|
||||||
|
{ slug: 'baker_bear', name: 'Baker Bear' },
|
||||||
|
{ slug: 'dewdrop', name: 'Dewdrop' },
|
||||||
|
{ slug: 'gone_fishin', name: "Gone Fishin'" },
|
||||||
|
{ slug: 'happy_harvest', name: 'Happy Harvest' },
|
||||||
|
{ slug: 'cat_onesie', name: 'Cat Onesie' },
|
||||||
|
{ slug: 'cat_onesie_pink', name: 'Cat Onesie (Pink)' },
|
||||||
|
{ slug: 'cat_onesie_black', name: 'Cat Onesie (Black)' },
|
||||||
|
{ slug: 'cat_ears_yellow', name: 'Cat Ears (Yellow)' },
|
||||||
|
{ slug: 'cat_ears_green', name: 'Cat Ears (Green)' },
|
||||||
|
{ slug: 'cat_ears_blue', name: 'Cat Ears (Blue)' },
|
||||||
|
{ slug: 'cat_ears_purple', name: 'Cat Ears (Purple)' },
|
||||||
|
{ slug: 'greenhouse_cat', name: 'Greenhouse Cat' },
|
||||||
|
{ slug: 'graveyard_cat', name: 'Graveyard Cat' },
|
||||||
|
{ slug: 'forest_frolic', name: 'Forest Frolic' },
|
||||||
|
{ slug: 'a_real_fungi', name: 'A Real Fungi' },
|
||||||
|
{ slug: 'mokoko', name: 'Mokoko' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monsters',
|
||||||
|
label: 'Little Monsters',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'beamchop', name: 'Beamchop' },
|
||||||
|
{ slug: 'chuck', name: 'Chuck' },
|
||||||
|
{ slug: 'winkle', name: 'Winkle' },
|
||||||
|
{ slug: 'chewbert', name: 'Chewbert' },
|
||||||
|
{ slug: 'doodlezard', name: 'Doodlezard' },
|
||||||
|
{ slug: 'glop', name: 'Glop' },
|
||||||
|
{ slug: 'gawblehop', name: 'Gawblehop' },
|
||||||
|
{ slug: 'stinkums', name: 'Stinkums' },
|
||||||
|
{ slug: 'goblin_stinkums', name: 'Goblin Stinkums' },
|
||||||
|
{ slug: 'chomp_chomp', name: 'Chomp Chomp' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snacks',
|
||||||
|
label: 'Snacks',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'pancakes', name: 'Pancakes' },
|
||||||
|
{ slug: 'blueberry_jam', name: 'Blueberry Jam' },
|
||||||
|
{ slug: 'donut', name: 'Donut' },
|
||||||
|
{ slug: 'fried_egg', name: 'Fried Egg' },
|
||||||
|
{ slug: 'green_fried_egg', name: 'Green Fried Egg' },
|
||||||
|
{ slug: 'toast', name: 'Toast' },
|
||||||
|
{ slug: 'burnt_toast', name: 'Burnt Toast' },
|
||||||
|
{ slug: 'morning_coffee', name: 'Morning Coffee' },
|
||||||
|
{ slug: 'sushi_roll', name: 'Sushi Roll' },
|
||||||
|
{ slug: 'ramen_bowl', name: 'Ramen Bowl' },
|
||||||
|
{ slug: 'ramen_bowl_toppings', name: 'Ramen Bowl (Toppings)' },
|
||||||
|
{ slug: 'donut_cow', name: 'Donut Cow' },
|
||||||
|
{ slug: 'donut_cat', name: 'Donut Cat' },
|
||||||
|
{ slug: 'donut_bear', name: 'Donut Bear' },
|
||||||
|
{ slug: 'bread_doggy', name: 'Bread Doggy' },
|
||||||
|
{ slug: 'ice_cream_frog', name: 'Ice Cream Frog' },
|
||||||
|
{ slug: 'jelly_bear', name: 'Jelly Bear' },
|
||||||
|
{ slug: 'nom_kitty', name: 'Nom Kitty' },
|
||||||
|
{ slug: 'egg', name: 'Egg' },
|
||||||
|
{ slug: 'chicken_nugget', name: 'Chicken Nugget' },
|
||||||
|
{ slug: 'sippy_juice', name: 'Sippy Juice' },
|
||||||
|
{ slug: 'bubble_tea', name: 'Bubble Tea' },
|
||||||
|
{ slug: 'freshly_picked', name: 'Freshly Picked' },
|
||||||
|
{ slug: 'strawberry_vine', name: 'Strawberry Vine' },
|
||||||
|
{ slug: 'a_hint_of_clove', name: 'A Hint of Clove' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scene',
|
||||||
|
label: 'Scene',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'neon_cat_hoodie', name: 'Neon Cat Hoodie' },
|
||||||
|
{ slug: 'neon_cat_hoodie_blue', name: 'Neon Cat Hoodie (Blue)' },
|
||||||
|
{ slug: 'neon_cat_hoodie_pink', name: 'Neon Cat Hoodie (Pink)' },
|
||||||
|
{ slug: 'neon_cat_hoodie_black', name: 'Neon Cat Hoodie (Black)' },
|
||||||
|
{ slug: 'neon_decora_hair', name: 'Neon Decora Hair' },
|
||||||
|
{ slug: 'neon_spike_choker', name: 'Neon Spike Choker' },
|
||||||
|
{ slug: 'neon_spike_choker_black', name: 'Neon Spike Choker (Black)' },
|
||||||
|
{ slug: 'mischievous_kitties', name: 'Mischievous Kitties' },
|
||||||
|
{ slug: 'mischievous_kitties_hearts', name: 'Mischievous Kitties (Hearts)' },
|
||||||
|
{ slug: 'mischievous_kitties_blue', name: 'Mischievous Kitties (Blue)' },
|
||||||
|
{ slug: 'skully_charms', name: 'Skully Charms' },
|
||||||
|
{ slug: 'rawr_xd', name: 'Rawr xD' },
|
||||||
|
{ slug: 'magical_girl', name: 'Magical Girl' },
|
||||||
|
{ slug: 'sakura_scholar', name: 'Sakura Scholar' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dreamy',
|
||||||
|
label: 'Dreamy',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'jet_ring', name: 'Jet Ring' },
|
||||||
|
{ slug: 'pondering_portal', name: 'Pondering Portal' },
|
||||||
|
{ slug: 'magic_mists', name: 'Magic Mists' },
|
||||||
|
{ slug: 'infinite_swirl', name: 'Infinite Swirl' },
|
||||||
|
{ slug: 'blue_gyroscope', name: 'Blue Gyroscope' },
|
||||||
|
{ slug: 'pink_gyroscope', name: 'Pink Gyroscope' },
|
||||||
|
{ slug: 'green_gyroscope', name: 'Green Gyroscope' },
|
||||||
|
{ slug: 'saw', name: 'Saw' },
|
||||||
|
{ slug: 'blue_smoke', name: 'Blue Smoke' },
|
||||||
|
{ slug: 'pink_smoke', name: 'Pink Smoke' },
|
||||||
|
{ slug: 'green_smoke', name: 'Green Smoke' },
|
||||||
|
{ slug: 'starlight_whales', name: 'Starlight Whales' },
|
||||||
|
{ slug: 'head_in_the_clouds', name: 'Head In the Clouds' },
|
||||||
|
{ slug: 'head_in_the_clouds_sunset', name: 'Head In the Clouds (Sunset)' },
|
||||||
|
{ slug: 'mirage', name: 'Mirage' },
|
||||||
|
{ slug: 'mirage_void', name: 'Mirage (Void)' },
|
||||||
|
{ slug: 'mirage_twilight', name: 'Mirage (Twilight)' },
|
||||||
|
{ slug: 'mirage_nightshade', name: 'Mirage (Nightshade)' },
|
||||||
|
{ slug: 'lava_blobs', name: 'Lava Blobs' },
|
||||||
|
{ slug: 'lava_blobs_pink', name: 'Lava Blobs (Pink)' },
|
||||||
|
{ slug: 'lava_blobs_blue', name: 'Lava Blobs (Blue)' },
|
||||||
|
{ slug: 'lava_blobs_slime', name: 'Lava Blobs (Slime)' },
|
||||||
|
{ slug: 'luminescent_lotus', name: 'Luminescent Lotus' },
|
||||||
|
{ slug: 'woolgathering', name: 'Woolgathering' },
|
||||||
|
{ slug: 'travel_ring', name: 'Travel Ring' },
|
||||||
|
{ slug: 'dream_dive_stars', name: 'Dream Dive Stars' },
|
||||||
|
{ slug: 'rift_butterfly', name: 'Rift Butterfly' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'celestial',
|
||||||
|
label: 'Celestial',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'lunar_flowers', name: 'Lunar Flowers' },
|
||||||
|
{ slug: 'starlight_tiara', name: 'Starlight Tiara' },
|
||||||
|
{ slug: 'astral_aura', name: 'Astral Aura' },
|
||||||
|
{ slug: 'luna_moths', name: 'Luna Moths' },
|
||||||
|
{ slug: 'moonlit_charms', name: 'Moonlit Charms' },
|
||||||
|
{ slug: 'liquid_moon', name: 'Liquid Moon' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'garden',
|
||||||
|
label: 'Garden',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'terrarium', name: 'Terrarium' },
|
||||||
|
{ slug: 'mushcaps', name: 'Mushcaps' },
|
||||||
|
{ slug: 'blossomburst', name: 'Blossomburst' },
|
||||||
|
{ slug: 'rainy_canopy', name: 'Rainy Canopy' },
|
||||||
|
{ slug: 'snowdrops', name: 'Snowdrops' },
|
||||||
|
{ slug: 'afternoon_breeze', name: 'Afternoon Breeze' },
|
||||||
|
{ slug: 'shower_stroll', name: 'Shower Stroll' },
|
||||||
|
{ slug: 'sakura_gyoiko', name: 'Gyoiko Sakura' },
|
||||||
|
{ slug: 'sakura_ukon', name: 'Ukon Sakura' },
|
||||||
|
{ slug: 'forest', name: 'Forest' },
|
||||||
|
{ slug: 'fall_leaves_woodland', name: 'Woodland Leaves' },
|
||||||
|
{ slug: 'autumn_foliage', name: 'Autumn Foliage' },
|
||||||
|
{ slug: 'flower_clouds', name: 'Flower Clouds' },
|
||||||
|
{ slug: 'the_petal_pack', name: 'The Petal Pack' },
|
||||||
|
{ slug: 'chrysanthemums_morning', name: 'Morning Chrysanthemums' },
|
||||||
|
{ slug: 'chrysanthemums_twilight', name: 'Twilight Chrysanthemums' },
|
||||||
|
{ slug: 'dusk_and_dawn', name: 'Dusk and Dawn' },
|
||||||
|
{ slug: 'floral_harmony', name: 'Floral Harmony' },
|
||||||
|
{ slug: 'floral_harmony_sunburst', name: 'Floral Harmony (Sunburst)' },
|
||||||
|
{ slug: 'autumns_arbor', name: "Autumn's Arbor" },
|
||||||
|
{ slug: 'autumns_arbor_aurora', name: "Autumn's Arbor (Aurora)" },
|
||||||
|
{ slug: 'autumn_crown', name: 'Autumn Crown' },
|
||||||
|
{ slug: 'faces_of_the_moon', name: 'Faces of the Moon' },
|
||||||
|
{ slug: 'lover_girl_autumn', name: 'Lover Girl Autumn' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'summer',
|
||||||
|
label: 'Summer',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'sunflowers', name: 'Sunflowers' },
|
||||||
|
{ slug: 'beach_hat', name: 'Beach Hat' },
|
||||||
|
{ slug: 'a_duck', name: 'A Duck' },
|
||||||
|
{ slug: 'bubble', name: 'Bubble' },
|
||||||
|
{ slug: 'fireflies', name: 'Fireflies' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'love',
|
||||||
|
label: 'Love',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'rose_bearer_pink', name: 'Rose Bearer (Pink)' },
|
||||||
|
{ slug: 'rose_bearer_blue', name: 'Rose Bearer (Blue)' },
|
||||||
|
{ slug: 'angel', name: 'Angel' },
|
||||||
|
{ slug: 'devil', name: 'Devil' },
|
||||||
|
{ slug: 'aim_for_love', name: 'Aim for Love' },
|
||||||
|
{ slug: 'lovestruck', name: 'Lovestruck' },
|
||||||
|
{ slug: 'ring_of_roses_red', name: 'Ring of Roses (Red)' },
|
||||||
|
{ slug: 'ring_of_roses_blue', name: 'Ring of Roses (Blue)' },
|
||||||
|
{ slug: 'heartstrings_red', name: 'Heartstrings (Red)' },
|
||||||
|
{ slug: 'heartstrings_blue', name: 'Heartstrings (Blue)' },
|
||||||
|
{ slug: 'heart_to_heart', name: 'Heart-to-Heart' },
|
||||||
|
{ slug: 'ruby_hearts', name: 'Ruby Hearts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'winter',
|
||||||
|
label: 'Winter',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'snowfall', name: 'Snowfall' },
|
||||||
|
{ slug: 'santa_cat_ears', name: 'Holiday Cat Ears' },
|
||||||
|
{ slug: 'string_lights', name: 'String Lights' },
|
||||||
|
{ slug: 'string_lights_aurora', name: 'String Lights (Aurora)' },
|
||||||
|
{ slug: 'string_lights_ember', name: 'String Lights (Ember)' },
|
||||||
|
{ slug: 'string_lights_dusk', name: 'String Lights (Dusk)' },
|
||||||
|
{ slug: 'string_lights_mix', name: 'String Lights (Mix)' },
|
||||||
|
{ slug: 'snowglobe', name: 'Snowglobe' },
|
||||||
|
{ slug: 'snowglobe_wood', name: 'Snowglobe (Wood)' },
|
||||||
|
{ slug: 'snowglobe_pink', name: 'Snowglobe (Pink)' },
|
||||||
|
{ slug: 'snowglobe_blue', name: 'Snowglobe (Blue)' },
|
||||||
|
{ slug: 'snowglobe_green', name: 'Snowglobe (Green)' },
|
||||||
|
{ slug: 'fresh_pine', name: 'Fresh Pine' },
|
||||||
|
{ slug: 'fresh_pine_cinnamon', name: 'Fresh Pine (Cinnamon)' },
|
||||||
|
{ slug: 'fresh_pine_ribbon', name: 'Fresh Pine (Ribbon)' },
|
||||||
|
{ slug: 'blanket_green', name: 'Green Blanket' },
|
||||||
|
{ slug: 'blanket_orange', name: 'Orange Blanket' },
|
||||||
|
{ slug: 'blanket_pink', name: 'Pink Blanket' },
|
||||||
|
{ slug: 'blanket_purple', name: 'Purple Blanket' },
|
||||||
|
{ slug: 'box_white_blue', name: 'Gift Box (White)' },
|
||||||
|
{ slug: 'box_blue_yellow', name: 'Gift Box (Blue)' },
|
||||||
|
{ slug: 'box_green_red', name: 'Gift Box (Green)' },
|
||||||
|
{ slug: 'box_red_white', name: 'Gift Box (Red)' },
|
||||||
|
{ slug: 'box_red_white_blue', name: 'Gift Box (Red, White & Blue)' },
|
||||||
|
{ slug: 'ice_cube', name: 'Ice Cube' },
|
||||||
|
{ slug: 'icicle_gleaming', name: 'Gleaming Icicle' },
|
||||||
|
{ slug: 'icicle_snowing', name: 'Snowing Icicle' },
|
||||||
|
{ slug: 'teacup_blue', name: 'Blue Teacup' },
|
||||||
|
{ slug: 'teacup_orange', name: 'Orange Teacup' },
|
||||||
|
{ slug: 'teacup_pink', name: 'Pink Teacup' },
|
||||||
|
{ slug: 'teacup_red', name: 'Red Teacup' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'celebrate',
|
||||||
|
label: 'Celebrate',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'new_year_2024', name: '2024 Balloons' },
|
||||||
|
{ slug: 'new_year_2025', name: '2025 Balloons' },
|
||||||
|
{ slug: 'confetti_festive', name: 'Festive Confetti' },
|
||||||
|
{ slug: 'confetti_fire', name: 'Fire Confetti' },
|
||||||
|
{ slug: 'confetti_ice', name: 'Ice Confetti' },
|
||||||
|
{ slug: 'confetti_mint', name: 'Mint Confetti' },
|
||||||
|
{ slug: 'confetti_star', name: 'Star Confetti' },
|
||||||
|
{ slug: 'confetti_vaporwave', name: 'Vaporwave Confetti' },
|
||||||
|
{ slug: 'snakes_hug', name: "Snake's Hug" },
|
||||||
|
{ slug: 'red_lantern', name: 'Red Lantern' },
|
||||||
|
{ slug: 'fan_flourish', name: 'Fan Flourish' },
|
||||||
|
{ slug: 'lucky_envelopes', name: 'Lucky Envelopes' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'steampunk',
|
||||||
|
label: 'Steampunk',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'steampunk_cat_ears', name: 'Steampunk Cat Ears' },
|
||||||
|
{ slug: 'mech_flora', name: 'Mech Flora' },
|
||||||
|
{ slug: 'bowler_hat', name: 'Bowler Hat' },
|
||||||
|
{ slug: 'brass_beats', name: 'Brass Beats' },
|
||||||
|
{ slug: 'timekeepers_clock', name: "Timekeeper's Clock" },
|
||||||
|
{ slug: 'flux_alchemy', name: 'Flux Alchemy' },
|
||||||
|
{ slug: 'magic_portal_purple', name: 'Magic Portal (Purple)' },
|
||||||
|
{ slug: 'magic_portal_blue', name: 'Magic Portal (Blue)' },
|
||||||
|
{ slug: 'fairy_sprites', name: 'Fairy Sprites' },
|
||||||
|
{ slug: 'fairy_sprites_pink', name: 'Fairy Sprites (Pink)' },
|
||||||
|
{ slug: 'fairy_sprites_blue', name: 'Fairy Sprites (Blue)' },
|
||||||
|
{ slug: 'wizard_hat_purple', name: 'Wizard Hat (Purple)' },
|
||||||
|
{ slug: 'wizard_hat_blue', name: 'Wizard Hat (Blue)' },
|
||||||
|
{ slug: 'magical_wand_purple', name: 'Magical Wand (Purple)' },
|
||||||
|
{ slug: 'magical_wand_green', name: 'Magical Wand (Green)' },
|
||||||
|
{ slug: 'mooncaps_pink', name: 'Mooncaps (Pink)' },
|
||||||
|
{ slug: 'mooncaps_blue', name: 'Mooncaps (Blue)' },
|
||||||
|
{ slug: 'cottage_home', name: 'Cottage Home' },
|
||||||
|
{ slug: 'treasure_and_key', name: 'Treasure and Key' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pirates',
|
||||||
|
label: 'Pirates',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'pirate_captain', name: 'Pirate Captain' },
|
||||||
|
{ slug: 'scallywag', name: 'Scallywag' },
|
||||||
|
{ slug: 'good_ol_pepper', name: "Good Ol' Pepper" },
|
||||||
|
{ slug: 'crossbones', name: 'Crossbones' },
|
||||||
|
{ slug: 'cannon_fire', name: 'Cannon Fire' },
|
||||||
|
{ slug: 'helmsman', name: 'Helmsman' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'neo_tokyo',
|
||||||
|
label: 'Neo Tokyo',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'bonsai', name: 'Bonsai' },
|
||||||
|
{ slug: 'sakura_city', name: 'Sakura City' },
|
||||||
|
{ slug: 'hologram_dragon', name: 'Hologram Dragon' },
|
||||||
|
{ slug: 'cyber_katana', name: 'Cyber Katana' },
|
||||||
|
{ slug: 'o_n_i', name: 'O.N.I' },
|
||||||
|
{ slug: 'jingasa', name: 'Jingasa' },
|
||||||
|
{ slug: 'white_mana', name: 'White Mana' },
|
||||||
|
{ slug: 'blue_mana', name: 'Blue Mana' },
|
||||||
|
{ slug: 'black_mana', name: 'Black Mana' },
|
||||||
|
{ slug: 'red_mana', name: 'Red Mana' },
|
||||||
|
{ slug: 'green_mana', name: 'Green Mana' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lofi',
|
||||||
|
label: 'Lofi',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'lofi_girl_outfit', name: 'Lofi Girl Outfit' },
|
||||||
|
{ slug: 'playful_lofi_cat', name: 'Playful Lofi Cat' },
|
||||||
|
{ slug: 'sleepy_chilledcow', name: 'Sleepy ChilledCow' },
|
||||||
|
{ slug: 'study_session', name: 'Study Session' },
|
||||||
|
{ slug: 'group_hug', name: 'Group Hug' },
|
||||||
|
{ slug: 'cozy_post_it', name: 'Cozy Post-It' },
|
||||||
|
{ slug: 'cozy_post_it_festive', name: 'Cozy Post-It (Festive)' },
|
||||||
|
{ slug: 'cat_ear_headset', name: 'Cat Ear Headset' },
|
||||||
|
{ slug: 'bunny_zzzs', name: 'Bunny Zzzs' },
|
||||||
|
{ slug: 'sproutling', name: 'Sproutling' },
|
||||||
|
{ slug: 'bloomling', name: 'Bloomling' },
|
||||||
|
{ slug: 'neon_nibbles', name: 'Neon Nibbles' },
|
||||||
|
{ slug: 'uwu_xp', name: 'UwU XP' },
|
||||||
|
{ slug: 'dancing_fairies', name: 'Dancing Fairies' },
|
||||||
|
{ slug: 'crystal_elk', name: 'Crystal Elk' },
|
||||||
|
{ slug: 'mermaid_serenade', name: 'Mermaid Serenade' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'game_night',
|
||||||
|
label: 'Game Night',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'hugh_the_rainbow', name: 'Hugh the Rainbow' },
|
||||||
|
{ slug: 'imagination', name: 'Imagination' },
|
||||||
|
{ slug: 'depresso', name: 'Depresso' },
|
||||||
|
{ slug: 'selyne', name: 'Selyne' },
|
||||||
|
{ slug: 'cattiva', name: 'Cattiva' },
|
||||||
|
{ slug: 'gold_laurel_wreath', name: 'Gold Laurel Wreath' },
|
||||||
|
{ slug: 'golden_hex', name: 'Golden Hex' },
|
||||||
|
{ slug: 'terrain_tiles', name: 'Terrain Tiles' },
|
||||||
|
{ slug: 'hex_tiles', name: 'Hex Tiles' },
|
||||||
|
{ slug: 'city_walls', name: 'City Walls' },
|
||||||
|
{ slug: 'next_turn_button', name: 'Next Turn Button' },
|
||||||
|
{ slug: 'armamenter', name: 'Armamenter' },
|
||||||
|
{ slug: 'e_d_hacker', name: 'E.D Hacker' },
|
||||||
|
{ slug: 'got_xenoglossy', name: 'Got Xenoglossy' },
|
||||||
|
{ slug: 'port_of_soul', name: 'Port of Soul' },
|
||||||
|
{ slug: 'rocket_puncher', name: 'Rocket Puncher' },
|
||||||
|
{ slug: 'lights_camera_action_orange', name: 'Lights, Camera, Action! (Orange)' },
|
||||||
|
{ slug: 'lights_camera_action_teal', name: 'Lights, Camera, Action! (Teal)' },
|
||||||
|
{ slug: 'victory_crown', name: 'Fortnite Victory Crown' },
|
||||||
|
{ slug: 'shield_potion', name: 'Shield Potion' },
|
||||||
|
{ slug: 'slurp_barrel', name: 'Slurp Barrel' },
|
||||||
|
{ slug: 'supply_llama', name: 'Supply Llama' },
|
||||||
|
{ slug: 'bush_camper', name: 'Bush Camper' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crossover_screen',
|
||||||
|
label: 'Movies & TV',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'lightsabers_blue_and_red', name: 'Lightsabers (Blue and Red)' },
|
||||||
|
{ slug: 'lightsabers_green_and_red', name: 'Lightsabers (Green and Red)' },
|
||||||
|
{ slug: 'r2_d2_on_tatooine', name: 'R2-D2 on Tatooine' },
|
||||||
|
{ slug: 'curious_bb_8', name: 'Curious BB-8' },
|
||||||
|
{ slug: 'yoda_on_dagobah', name: 'Yoda on Dagobah' },
|
||||||
|
{ slug: 'millennium_falcon_hyperdrive', name: 'Millennium Falcon Hyperdrive' },
|
||||||
|
{ slug: 'space_battle', name: 'Space Battle' },
|
||||||
|
{ slug: 'the_thing', name: 'The Thing' },
|
||||||
|
{ slug: 'invisible_woman', name: 'Invisible Woman' },
|
||||||
|
{ slug: 'mister_fantastic', name: 'Mister Fantastic' },
|
||||||
|
{ slug: 'human_torch', name: 'Human Torch' },
|
||||||
|
{ slug: 'silver_surfer', name: 'Silver Surfer' },
|
||||||
|
{ slug: 'the_fantastic_four', name: 'The Fantastic Four' },
|
||||||
|
{ slug: 'dragon_balls', name: 'Dragon Balls' },
|
||||||
|
{ slug: 'gomah', name: 'Gomah' },
|
||||||
|
{ slug: 'shenron', name: 'Shenron' },
|
||||||
|
{ slug: 'mini_goku', name: 'Mini Goku' },
|
||||||
|
{ slug: 'mini_vegeta', name: 'Mini Vegeta' },
|
||||||
|
{ slug: 'glorio', name: 'Glorio' },
|
||||||
|
{ slug: 'mini_supreme_kai', name: 'Mini Supreme Kai' },
|
||||||
|
{ slug: 'mini_piccolo', name: 'Mini Piccolo' },
|
||||||
|
{ slug: 'panzy', name: 'Panzy' },
|
||||||
|
{ slug: 'izuku_midoriya', name: 'Izuku Midoriya' },
|
||||||
|
{ slug: 'katsuki_bakugo', name: 'Katsuki Bakugo' },
|
||||||
|
{ slug: 'ochaco_uraraka', name: 'Ochaco Uraraka' },
|
||||||
|
{ slug: 'shoto_todoroki', name: 'Shoto Todoroki' },
|
||||||
|
{ slug: 'endeavor', name: 'Endeavor' },
|
||||||
|
{ slug: 'hawks', name: 'Hawks' },
|
||||||
|
{ slug: 'all_might', name: 'All Might' },
|
||||||
|
{ slug: 'tomura_shigaraki', name: 'Tomura Shigaraki' },
|
||||||
|
{ slug: 'spongebob', name: 'SpongeBob' },
|
||||||
|
{ slug: 'patrick_star', name: 'Patrick Star' },
|
||||||
|
{ slug: 'gary_the_snail', name: 'Gary the Snail' },
|
||||||
|
{ slug: 'sandy_cheeks', name: 'Sandy Cheeks' },
|
||||||
|
{ slug: 'musclebob', name: 'MuscleBob' },
|
||||||
|
{ slug: 'skibidi_toilet', name: 'Skibidi Toilet' },
|
||||||
|
{ slug: 'g_toilet', name: 'G-Toilet' },
|
||||||
|
{ slug: 'cameraman_glitch', name: 'Cameraman Glitch' },
|
||||||
|
{ slug: 'speakerwoman', name: 'Speakerwoman' },
|
||||||
|
{ slug: 'tv_woman', name: 'TV Woman' },
|
||||||
|
{ slug: 'juggernaut_astro', name: 'Juggernaut Astro' },
|
||||||
|
{ slug: 'the_anomaly', name: 'The Anomaly' },
|
||||||
|
{ slug: 'the_mark', name: 'The Mark' },
|
||||||
|
{ slug: 'the_monster_you_created', name: 'The Monster You Created' },
|
||||||
|
{ slug: 'the_atlas_gauntlets', name: 'The Atlas Gauntlets' },
|
||||||
|
{ slug: 'flame_chompers', name: 'Flame Chompers' },
|
||||||
|
{ slug: 'fishbones', name: 'FISHBONES!' },
|
||||||
|
{ slug: 'the_hexcore', name: 'The Hexcore' },
|
||||||
|
{ slug: 'powered_by_shimmer', name: 'Powered by Shimmer' },
|
||||||
|
{ slug: 'minions', name: 'Minions' },
|
||||||
|
{ slug: 'daredevil', name: 'Daredevil' },
|
||||||
|
{ slug: 'jean_grey_phoenix', name: 'Phoenix' },
|
||||||
|
{ slug: 'emma_frost', name: 'Emma Frost' },
|
||||||
|
{ slug: 'ultron', name: 'Ultron' },
|
||||||
|
{ slug: 'marvel_snap_venom', name: 'Marvel Snap Venom' },
|
||||||
|
{ slug: 'jeff_the_land_shark', name: 'Jeff the Land Shark' },
|
||||||
|
{ slug: 'hank_hill', name: 'Hank Hill' },
|
||||||
|
{ slug: 'how_to_train_your_dragon', name: 'How to Train Your Dragon' },
|
||||||
|
{ slug: 'jurassic_world_rebirth', name: 'Jurassic World Rebirth Trailer' },
|
||||||
|
{ slug: 'mission_impossible', name: 'Mission: Impossible' },
|
||||||
|
{ slug: 'bad_guys_2', name: 'The Bad Guys 2 Trailer' },
|
||||||
|
{ slug: 'm3gan_2_0', name: 'M3GAN 2.0' },
|
||||||
|
{ slug: '28_years_later', name: '28 Years Later' },
|
||||||
|
{ slug: 'black_phone_2', name: 'Black Phone 2' },
|
||||||
|
{ slug: 'the_conjuring_last_rites', name: 'The Conjuring: Last Rites' },
|
||||||
|
{ slug: 'ballerina', name: 'Ballerina' },
|
||||||
|
{ slug: 'wendys_x_wednesday', name: "Wendy's x Wednesday" },
|
||||||
|
{ slug: 'batarang', name: 'Batarang' },
|
||||||
|
{ slug: 'espn', name: 'ESPN' },
|
||||||
|
{ slug: 'aespa_fanlight', name: 'aespa Fanlight' },
|
||||||
|
{ slug: 'bing_bong', name: 'Bing Bong' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'crossover_games',
|
||||||
|
label: 'Game Crossovers',
|
||||||
|
decorations: [
|
||||||
|
{ slug: 'trick_pumpkin', name: 'Trick Pumpkin' },
|
||||||
|
{ slug: 'treat_pumpkin', name: 'Treat Pumpkin' },
|
||||||
|
{ slug: 'trick_spider', name: 'Trick Spider' },
|
||||||
|
{ slug: 'treat_spider', name: 'Treat Spider' },
|
||||||
|
{ slug: 'trick_skull', name: 'Trick Skull' },
|
||||||
|
{ slug: 'treat_skull', name: 'Treat Skull' },
|
||||||
|
{ slug: 'trick_ghost', name: 'Trick Ghost' },
|
||||||
|
{ slug: 'treat_ghost', name: 'Treat Ghost' },
|
||||||
|
{ slug: 'the_final_peel', name: 'The Final Peel' },
|
||||||
|
{ slug: 'warframe_clem', name: 'Warframe Clem' },
|
||||||
|
{ slug: 'dart_monkey', name: 'Dart Monkey' },
|
||||||
|
{ slug: 'kom', name: 'KOM' },
|
||||||
|
{ slug: 'mr_m', name: 'Mr. M' },
|
||||||
|
{ slug: 'fc_26_icon', name: 'FC 26 Icon' },
|
||||||
|
{ slug: 'skate', name: 'Skate' },
|
||||||
|
{ slug: 'airona_fireworks', name: 'Airona Fireworks' },
|
||||||
|
{ slug: 'champions_tactibear', name: 'Champions Tactibear' },
|
||||||
|
{ slug: 'cab_monster', name: 'Cab Monster' },
|
||||||
|
{ slug: 'atsus_mask', name: "Atsu's Mask" },
|
||||||
|
{ slug: 'tactibear_flex', name: 'Tactibear Flex' },
|
||||||
|
{ slug: 'angela_avatar', name: 'Angela Avatar' },
|
||||||
|
{ slug: 'echo_4', name: 'Echo 4' },
|
||||||
|
{ slug: 'nba_2k26', name: 'NBA 2K26' },
|
||||||
|
{ slug: 'eye_of_prophesy', name: 'Eye of Prophesy' },
|
||||||
|
{ slug: 'inzoi_psycat', name: 'inZOI Psycat' },
|
||||||
|
{ slug: 'bf_soldier_helmet', name: 'BF Soldier Helmet' },
|
||||||
|
{ slug: 'the_entity', name: 'The Entity' },
|
||||||
|
{ slug: 'descendant', name: 'Descendant' },
|
||||||
|
{ slug: 'sweet_tooth', name: 'Sweet Tooth' },
|
||||||
|
{ slug: 'call_of_duty_mobile', name: 'Call of Duty: Mobile' },
|
||||||
|
{ slug: 'thps_half_pipe', name: 'THPS Half Pipe' },
|
||||||
|
{ slug: 'mecha_break', name: 'Mecha BREAK' },
|
||||||
|
{ slug: 'valorant_summer_kickoff', name: 'VALORANT Summer Kickoff' },
|
||||||
|
{ slug: 'palia', name: 'Palia' },
|
||||||
|
{ slug: 'supercell', name: 'SuperCell' },
|
||||||
|
{ slug: 'i_love_repo', name: 'I Love R.E.P.O.' },
|
||||||
|
{ slug: 'lego_fortnite', name: 'LEGO® Fortnite' },
|
||||||
|
{ slug: 'r6_siege_x_avatar', name: 'R6 Siege X Avatar' },
|
||||||
|
{ slug: 'towerborne_play', name: 'Towerborne Play' },
|
||||||
|
{ slug: 'starlight_revolver', name: 'Starlight Revolver' },
|
||||||
|
{ slug: 'open_beta', name: 'Open Beta' },
|
||||||
|
{ slug: 'fortnite_galactic_battle', name: 'Fortnite Galactic Battle' },
|
||||||
|
{ slug: 'shield_saw', name: 'Shield Saw' },
|
||||||
|
{ slug: 'friend_of_dex', name: 'Friend of Dex' },
|
||||||
|
{ slug: 'hackclaw', name: 'Hackclaw' },
|
||||||
|
{ slug: 'signal_from_tau_ceti', name: 'Signal from Tau Ceti' },
|
||||||
|
{ slug: 'face_of_corruption', name: 'Face of Corruption' },
|
||||||
|
{ slug: 'clicker', name: 'Clicker' },
|
||||||
|
{ slug: 'gallica', name: 'Gallica' },
|
||||||
|
{ slug: 'khazan', name: 'Khazan' },
|
||||||
|
{ slug: 'split', name: 'Split' },
|
||||||
|
{ slug: 'pathojen', name: 'Pathojen' },
|
||||||
|
{ slug: 'big_dill_chain', name: 'Big Dill Chain' },
|
||||||
|
{ slug: 'exoborne', name: 'Exoborne' },
|
||||||
|
{ slug: 'scout', name: 'Scout' },
|
||||||
|
{ slug: 'fortnite_boogie_bomb', name: 'Fortnite Boogie Bomb' },
|
||||||
|
{ slug: 'fuchsia_agent', name: 'Fuchsia Agent' },
|
||||||
|
{ slug: 'wingmans_got_it', name: "WINGMAN'S GOT IT" },
|
||||||
|
{ slug: 'rec_room_lightning', name: 'Rec Room Lightning' },
|
||||||
|
{ slug: 'shadow', name: 'Shadow' },
|
||||||
|
{ slug: 'tga_controller', name: 'TGA Controller' },
|
||||||
|
{ slug: 'wallach_spaceport', name: 'Wallach IX Spaceport' },
|
||||||
|
{ slug: 'wolf_morph', name: 'Wolf Morph' },
|
||||||
|
{ slug: 'street_fighter_6_battle_field', name: 'Street Fighter 6 Battle Field' },
|
||||||
|
{ slug: 'torgal_puppy', name: 'Torgal Puppy' },
|
||||||
|
{ slug: 'bunny', name: 'Bunny Mech' },
|
||||||
|
{ slug: 'hailey', name: 'Hailey' },
|
||||||
|
{ slug: 'los_santos', name: 'Los Santos' },
|
||||||
|
{ slug: 'wingman_boba', name: 'Wingman Boba' },
|
||||||
|
{ slug: 'freezer_bunny_lovebug', name: 'Freezer Bunny Lovebug' },
|
||||||
|
{ slug: 'im_dancin', name: "I'm Dancin'!" },
|
||||||
|
{ slug: 'finger_gun', name: 'Finger Gun' },
|
||||||
|
{ slug: 'bandit_mask', name: 'Bandit Mask' },
|
||||||
|
{ slug: 'mad_moxxi', name: 'Mad Moxxi' },
|
||||||
|
{ slug: 'elpis', name: 'Elpis' },
|
||||||
|
{ slug: 'loot_lightshow', name: 'Loot Lightshow' },
|
||||||
|
{ slug: 'spirit_blossom_zed', name: 'Spirit Blossom Zed' },
|
||||||
|
{ slug: 'spirit_blossom_springs_sett', name: 'Spirit Blossom Springs Sett' },
|
||||||
|
{ slug: 'spirit_blossom_morgana', name: 'Spirit Blossom Morgana' },
|
||||||
|
{ slug: 'spirit_blossom_karma', name: 'Spirit Blossom Karma' },
|
||||||
|
{ slug: 'spirit_blossom_springs', name: 'Spirit Blossom Springs' },
|
||||||
|
{ slug: 'spirit_blossom_springs_ahri', name: 'Spirit Blossom Springs Ahri' },
|
||||||
|
{ slug: 'yunara', name: 'Yunara' },
|
||||||
|
{ slug: 'gelatinous_cube_green', name: 'Green Gelatinous Cube' },
|
||||||
|
{ slug: 'gelatinous_cube_blue', name: 'Blue Gelatinous Cube' },
|
||||||
|
{ slug: 'spooky_cat_ears_midnight', name: 'Spooky Cat Ears (Midnight)' },
|
||||||
|
{ slug: 'candlelight_dark', name: 'Dark Candlelight' },
|
||||||
|
{ slug: 'zombie_food_purple', name: 'Zombie Food (Purple)' },
|
||||||
|
{ slug: 'bloodthirsty_green', name: 'Bloodthirsty (Green)' },
|
||||||
|
{ slug: 'ryu', name: 'Ryu' },
|
||||||
|
{ slug: 'chun_li', name: 'Chun-Li' },
|
||||||
|
{ slug: 'ken', name: 'Ken' },
|
||||||
|
{ slug: 'akuma', name: 'Akuma' },
|
||||||
|
{ slug: 'cammy', name: 'Cammy' },
|
||||||
|
{ slug: 'guile', name: 'Guile' },
|
||||||
|
{ slug: 'juri', name: 'Juri' },
|
||||||
|
{ slug: 'm_bison', name: 'M. Bison' },
|
||||||
|
{ slug: 'valorant_champions_2024', name: 'VALORANT Champions 2024' },
|
||||||
|
{ slug: 'yoru_dimensional_drift', name: 'Yoru Dimensional Drift' },
|
||||||
|
{ slug: 'viper_poison_cloud', name: 'Viper Poison Cloud' },
|
||||||
|
{ slug: 'cypher_neural_theft', name: 'Cypher Neural Theft' },
|
||||||
|
{ slug: 'omens_cowl', name: "Omen's Cowl" },
|
||||||
|
{ slug: 'reynas_leer', name: "Reyna's Leer" },
|
||||||
|
{ slug: 'frag_out', name: 'FRAG OUT' },
|
||||||
|
{ slug: 'blade_storm', name: 'Blade Storm' },
|
||||||
|
{ slug: 'chillet', name: 'Chillet' },
|
||||||
|
{ slug: 'pal_sphere', name: 'Pal Sphere' },
|
||||||
|
{ slug: 'lamball', name: 'Lamball' },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
|
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
|
||||||
@@ -204,3 +848,14 @@ export function isValidDecorationSlug(slug: string): boolean {
|
|||||||
export function decorationUrl(slug: string): string {
|
export function decorationUrl(slug: string): string {
|
||||||
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small static WebP preview (first busy frame, 144px) for the picker. The full
|
||||||
|
* decorations are ~1 MB animated PNGs, so the picker shows these and only
|
||||||
|
* loads `decorationUrl` on hover/focus/selection. Built by
|
||||||
|
* scripts/makeDecorationThumbs.py; callers fall back to `decorationUrl` if a
|
||||||
|
* thumbnail is missing.
|
||||||
|
*/
|
||||||
|
export function decorationThumbUrl(slug: string): string {
|
||||||
|
return `${RESOLVED_DECORATION_CDN}/thumbs/${slug}.webp`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Button, Text, Spinner, color } from 'folds';
|
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||||
import { Method } from 'matrix-js-sdk';
|
import { Method } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
import {
|
||||||
|
ALL_DECORATIONS,
|
||||||
|
DECORATION_CATEGORIES,
|
||||||
|
decorationThumbUrl,
|
||||||
|
decorationUrl,
|
||||||
|
} from '../../lotus/avatarDecorations';
|
||||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
@@ -21,6 +26,12 @@ function DecorationPreviewCell({
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: (slug: string) => void;
|
onSelect: (slug: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
// The full decorations are ~1 MB animated PNGs. Show the small static
|
||||||
|
// thumbnail and only pull the animated file while the cell is hovered,
|
||||||
|
// focused or selected. A missing thumbnail falls back to the full file.
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [thumbFailed, setThumbFailed] = useState(false);
|
||||||
|
const animate = active || selected || thumbFailed;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -28,6 +39,10 @@ function DecorationPreviewCell({
|
|||||||
aria-label={name}
|
aria-label={name}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
onClick={() => onSelect(slug)}
|
onClick={() => onSelect(slug)}
|
||||||
|
onMouseEnter={() => setActive(true)}
|
||||||
|
onMouseLeave={() => setActive(false)}
|
||||||
|
onFocus={() => setActive(true)}
|
||||||
|
onBlur={() => setActive(false)}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: CELL_SIZE,
|
width: CELL_SIZE,
|
||||||
@@ -44,10 +59,11 @@ function DecorationPreviewCell({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={decorationUrl(slug)}
|
src={animate ? decorationUrl(slug) : decorationThumbUrl(slug)}
|
||||||
alt={name}
|
alt={name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
|
onError={() => setThumbFailed(true)}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
@@ -61,6 +77,12 @@ function DecorationPreviewCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function categoryOf(slug: string | null): string {
|
||||||
|
const found =
|
||||||
|
slug && DECORATION_CATEGORIES.find((c) => c.decorations.some((d) => d.slug === slug));
|
||||||
|
return found ? found.id : DECORATION_CATEGORIES[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileDecoration() {
|
export function ProfileDecoration() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
@@ -75,6 +97,16 @@ export function ProfileDecoration() {
|
|||||||
// mount-time fetch below clobbering a fresh selection if it resolves late
|
// mount-time fetch below clobbering a fresh selection if it resolves late
|
||||||
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
|
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
|
const [activeCategory, setActiveCategory] = useState(DECORATION_CATEGORIES[0].id);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
query
|
||||||
|
? ALL_DECORATIONS.filter((d) => d.name.toLowerCase().includes(query))
|
||||||
|
: (DECORATION_CATEGORIES.find((c) => c.id === activeCategory)?.decorations ?? []),
|
||||||
|
[query, activeCategory],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchDecoration = useCallback(() => {
|
const fetchDecoration = useCallback(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -92,6 +124,7 @@ export function ProfileDecoration() {
|
|||||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
setCurrent(val);
|
setCurrent(val);
|
||||||
setSelected(val);
|
setSelected(val);
|
||||||
|
setActiveCategory(categoryOf(val));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -196,9 +229,7 @@ export function ProfileDecoration() {
|
|||||||
{loadError
|
{loadError
|
||||||
? 'Failed to load'
|
? 'Failed to load'
|
||||||
: selected
|
: selected
|
||||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
? (ALL_DECORATIONS.find((d) => d.slug === selected)?.name ?? selected)
|
||||||
(d) => d.slug === selected,
|
|
||||||
)?.name ?? selected)
|
|
||||||
: 'None'}
|
: 'None'}
|
||||||
</Text>
|
</Text>
|
||||||
{selected && !loadError && (
|
{selected && !loadError && (
|
||||||
@@ -256,42 +287,72 @@ export function ProfileDecoration() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category grid */}
|
{/* Search + category tabs. Only one category (or the search results)
|
||||||
|
is mounted at a time so the picker never loads the whole catalog. */}
|
||||||
|
<Input
|
||||||
|
aria-label="Search decorations"
|
||||||
|
placeholder="Search decorations"
|
||||||
|
value={search}
|
||||||
|
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
|
||||||
|
variant="Secondary"
|
||||||
|
size="400"
|
||||||
|
radii="300"
|
||||||
|
before={<Icon size="100" src={Icons.Search} />}
|
||||||
|
/>
|
||||||
|
{!query && (
|
||||||
|
<Box gap="100" wrap="Wrap" role="tablist" aria-label="Decoration categories">
|
||||||
|
{DECORATION_CATEGORIES.map((category) => (
|
||||||
|
<Chip
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={category.id === activeCategory}
|
||||||
|
variant={category.id === activeCategory ? 'Primary' : 'Secondary'}
|
||||||
|
fill="Soft"
|
||||||
|
outlined={category.id === activeCategory}
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => setActiveCategory(category.id)}
|
||||||
|
>
|
||||||
|
<Text as="span" size="B300">
|
||||||
|
{category.label}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
|
role={query ? undefined : 'tabpanel'}
|
||||||
style={{
|
style={{
|
||||||
maxHeight: 480,
|
maxHeight: 360,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
overflowX: 'hidden',
|
overflowX: 'hidden',
|
||||||
paddingRight: 4,
|
paddingRight: 4,
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 24,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{DECORATION_CATEGORIES.map((category) => (
|
{visible.length === 0 ? (
|
||||||
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<Text size="T300" priority="300">
|
||||||
<Text size="L400" priority="400">
|
No decorations match “{search.trim()}”.
|
||||||
{category.label}
|
</Text>
|
||||||
</Text>
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||||
gap: 20,
|
gap: 20,
|
||||||
}}
|
padding: 2,
|
||||||
>
|
}}
|
||||||
{category.decorations.map((d) => (
|
>
|
||||||
<DecorationPreviewCell
|
{visible.map((d) => (
|
||||||
key={d.slug}
|
<DecorationPreviewCell
|
||||||
slug={d.slug}
|
key={d.slug}
|
||||||
name={d.name}
|
slug={d.slug}
|
||||||
selected={selected === d.slug}
|
name={d.name}
|
||||||
onSelect={handleSelect}
|
selected={selected === d.slug}
|
||||||
/>
|
onSelect={handleSelect}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
</SettingTile>
|
</SettingTile>
|
||||||
|
|||||||
Reference in New Issue
Block a user