Update.
This commit is contained in:
		
							parent
							
								
									a55d15b635
								
							
						
					
					
						commit
						3bf09f9083
					
				@ -3,6 +3,7 @@ import logging
 | 
			
		||||
import pathlib
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from snek import snode 
 | 
			
		||||
from snek.view.threads import ThreadsView
 | 
			
		||||
import json 
 | 
			
		||||
@ -96,6 +97,7 @@ class Application(BaseApplication):
 | 
			
		||||
        self.jinja2_env.add_extension(LinkifyExtension)
 | 
			
		||||
        self.jinja2_env.add_extension(PythonExtension)
 | 
			
		||||
        self.jinja2_env.add_extension(EmojiExtension)
 | 
			
		||||
        self.time_start = datetime.now()
 | 
			
		||||
        self.ssh_host = "0.0.0.0"
 | 
			
		||||
        self.ssh_port = 2242
 | 
			
		||||
        self.setup_router()
 | 
			
		||||
@ -113,6 +115,33 @@ class Application(BaseApplication):
 | 
			
		||||
        self.on_startup.append(self.start_ssh_server)
 | 
			
		||||
        self.on_startup.append(self.prepare_database)
 | 
			
		||||
       
 | 
			
		||||
    @property
 | 
			
		||||
    def uptime_seconds(self):
 | 
			
		||||
        return (datetime.now() - self.time_start).total_seconds()
 | 
			
		||||
 | 
			
		||||
    @property 
 | 
			
		||||
    def uptime(self):
 | 
			
		||||
        return self._format_uptime(self.uptime_seconds)
 | 
			
		||||
    
 | 
			
		||||
    def _format_uptime(self,seconds):
 | 
			
		||||
        seconds = int(seconds)
 | 
			
		||||
        days, seconds = divmod(seconds, 86400)
 | 
			
		||||
        hours, seconds = divmod(seconds, 3600)
 | 
			
		||||
        minutes, seconds = divmod(seconds, 60)
 | 
			
		||||
 | 
			
		||||
        parts = []
 | 
			
		||||
        if days > 0:
 | 
			
		||||
            parts.append(f"{days} day{'s' if days != 1 else ''}")
 | 
			
		||||
        if hours > 0:
 | 
			
		||||
            parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
 | 
			
		||||
        if minutes > 0:
 | 
			
		||||
            parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
 | 
			
		||||
        if seconds > 0 or not parts:
 | 
			
		||||
            parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
 | 
			
		||||
 | 
			
		||||
        return ", ".join(parts)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    async def start_user_availability_service(self, app):
 | 
			
		||||
        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
 | 
			
		||||
    async def snode_sync(self, app):
 | 
			
		||||
 | 
			
		||||
@ -196,7 +196,9 @@ export class App extends EventHandler {
 | 
			
		||||
    this.ws.addEventListener("connected", (data) => {
 | 
			
		||||
      this.ping("online");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.ws.addEventListener("reconnecting", (data) => {
 | 
			
		||||
        this.starField?.showNotify("Connecting..","#CC0000")
 | 
			
		||||
    })
 | 
			
		||||
    this.ws.addEventListener("channel-message", (data) => {
 | 
			
		||||
      me.emit("channel-message", data);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -1,42 +1,178 @@
 | 
			
		||||
 | 
			
		||||
.star {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  width: 2px;
 | 
			
		||||
  height: 2px;
 | 
			
		||||
  background: var(--star-color, #fff);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: background 0.5s ease;
 | 
			
		||||
  animation: twinkle ease-in-out infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes twinkle {
 | 
			
		||||
  0%, 100% { opacity: 0; }
 | 
			
		||||
  50%      { opacity: 1; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes star-glow-frames {
 | 
			
		||||
    0% {
 | 
			
		||||
	box-shadow: 0 0 5px --star-color;
 | 
			
		||||
    :root {
 | 
			
		||||
      --star-color: white;
 | 
			
		||||
      --background-color: black;
 | 
			
		||||
    }
 | 
			
		||||
    50% {
 | 
			
		||||
	box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
 | 
			
		||||
 | 
			
		||||
    body.day {
 | 
			
		||||
      --star-color: #444;
 | 
			
		||||
      --background-color: #e6f0ff;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    body.night {
 | 
			
		||||
      --star-color: white;
 | 
			
		||||
      --background-color: black;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    body {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      overflow: hidden;
 | 
			
		||||
      background-color: var(--background-color);
 | 
			
		||||
      transition: background-color 0.5s;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .star {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      border-radius: 50%;
 | 
			
		||||
      background-color: var(--star-color);
 | 
			
		||||
      animation: twinkle 2s infinite ease-in-out;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @keyframes twinkle {
 | 
			
		||||
      0%, 100% { opacity: 0.8; transform: scale(1); }
 | 
			
		||||
      50% { opacity: 1; transform: scale(1.2); }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #themeToggle {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 10px;
 | 
			
		||||
      left: 10px;
 | 
			
		||||
      padding: 8px 12px;
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
      z-index: 1000;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
.star.special {
 | 
			
		||||
  box-shadow: 0 0 10px 3px gold;
 | 
			
		||||
  transform: scale(1.4);
 | 
			
		||||
  z-index: 10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-tooltip {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  text-shadow: 1px 1px 2px black;
 | 
			
		||||
  display: none;
 | 
			
		||||
  padding: 2px 6px;
 | 
			
		||||
}
 | 
			
		||||
.star-popup {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  z-index: 10000;
 | 
			
		||||
  text-shadow: 1px 1px 3px black;
 | 
			
		||||
  display: none;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-radius: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.star:hover {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
  background: white;
 | 
			
		||||
  color: black;
 | 
			
		||||
  padding: 15px;
 | 
			
		||||
  border-radius: 12px;
 | 
			
		||||
  box-shadow: 0 8px 20px rgba(0,0,0,0.3);
 | 
			
		||||
  z-index: 10000;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup h3 {
 | 
			
		||||
  margin: 0 0 5px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup button {
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.demo-overlay {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  font-size: 3em;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-family: 'Segoe UI', sans-serif;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  text-shadow: 0 0 20px rgba(0,0,0,0.8);
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: opacity 0.6s ease;
 | 
			
		||||
  max-width: 80vw;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes demoFadeIn {
 | 
			
		||||
  from {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translate(-50%, -60%) scale(0.95);
 | 
			
		||||
  }
 | 
			
		||||
  to {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
    transform: translate(-50%, -50%) scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes demoPulse {
 | 
			
		||||
  0% {
 | 
			
		||||
    box-shadow: 0 0 0 rgba(255, 255, 150, 0);
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
  30% {
 | 
			
		||||
    box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
 | 
			
		||||
    transform: scale(1.05);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
	box-shadow: 0 0 5px --star-color;
 | 
			
		||||
    box-shadow: 0 0 0 rgba(255, 255, 150, 0);
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-glow {
 | 
			
		||||
    animation: star-glow-frames 1s;
 | 
			
		||||
.demo-highlight {
 | 
			
		||||
  animation: demoPulse 1.5s ease-out;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
 | 
			
		||||
  position: relative;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
  color: var(--star-content-color, #eee);
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  top: 40%;
 | 
			
		||||
  transform: translateY(-40%);
 | 
			
		||||
.star-notify-container {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50px;
 | 
			
		||||
  right: 20px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-notify {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  text-shadow: 0 0 10px rgba(0,0,0,0.7);
 | 
			
		||||
  transition: opacity 0.5s ease, transform 0.5s ease;
 | 
			
		||||
  transform: translateY(-10px);
 | 
			
		||||
  font-family: 'Segoe UI', sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -95,6 +95,7 @@ export class Socket extends EventHandler {
 | 
			
		||||
    if (this.shouldReconnect)
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        console.log("Reconnecting");
 | 
			
		||||
        this.emit("reconnecting");
 | 
			
		||||
          return this.connect();
 | 
			
		||||
      }, 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -1,504 +1,392 @@
 | 
			
		||||
 | 
			
		||||
<div id="star-tooltip" class="star-tooltip"></div>
 | 
			
		||||
<div id="star-popup" class="star-popup"></div>
 | 
			
		||||
<script type="module">
 | 
			
		||||
import { app } from "/app.js";
 | 
			
		||||
 | 
			
		||||
const STAR_COUNT = 200;
 | 
			
		||||
const body = document.body;
 | 
			
		||||
 | 
			
		||||
function getStarPosition(star) {
 | 
			
		||||
    const leftPercent = parseFloat(star.style.left);
 | 
			
		||||
    const topPercent = parseFloat(star.style.top);
 | 
			
		||||
 | 
			
		||||
    let position;
 | 
			
		||||
 | 
			
		||||
    if (topPercent < 40 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'North';
 | 
			
		||||
    } else if (topPercent > 60 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'South';
 | 
			
		||||
    } else if (leftPercent < 40 && topPercent >= 40 && topPercent <= 60) {
 | 
			
		||||
      position = 'West';
 | 
			
		||||
    } else if (leftPercent > 60 && topPercent >= 40 && topPercent <= 60) {
 | 
			
		||||
      position = 'East';
 | 
			
		||||
    } else if (topPercent >= 40 && topPercent <= 60 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'Center';
 | 
			
		||||
    } else {
 | 
			
		||||
      position = 'Corner or Edge';
 | 
			
		||||
    }
 | 
			
		||||
    return position 
 | 
			
		||||
}
 | 
			
		||||
let stars = {}
 | 
			
		||||
window.stars = stars
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function createStar() {
 | 
			
		||||
  const star = document.createElement('div');
 | 
			
		||||
  star.classList.add('star');
 | 
			
		||||
  star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
  star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
  star.shuffle = () => {
 | 
			
		||||
    star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
 
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
 }
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
 | 
			
		||||
function moveStarToPosition(star, position) {
 | 
			
		||||
  let top, left;
 | 
			
		||||
 | 
			
		||||
  switch (position) {
 | 
			
		||||
    case 'North':
 | 
			
		||||
      top = `${Math.random() * 20}%`;
 | 
			
		||||
      left = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'South':
 | 
			
		||||
      top = `${80 + Math.random() * 10}%`;
 | 
			
		||||
      left = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'West':
 | 
			
		||||
      top = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      left = `${Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'East':
 | 
			
		||||
      top = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      left = `${80 + Math.random() * 10}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'Center':
 | 
			
		||||
      top = `${45 + Math.random() * 10}%`;
 | 
			
		||||
      left = `${45 + Math.random() * 10}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    default: // 'Corner or Edge' fallback
 | 
			
		||||
      top = `${Math.random() * 100}%`;
 | 
			
		||||
      left = `${Math.random() * 100}%`;
 | 
			
		||||
      break;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  star.style.top = top;
 | 
			
		||||
  star.style.left = left;
 | 
			
		||||
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  if(!stars[star.position])
 | 
			
		||||
      stars[star.position] = []
 | 
			
		||||
  stars[star.position].push(star)
 | 
			
		||||
  const size = Math.random() * 2 + 1;
 | 
			
		||||
  star.style.width = `${size}px`;
 | 
			
		||||
  star.style.height = `${size}px`;
 | 
			
		||||
  const duration = Math.random() * 3 + 2;
 | 
			
		||||
  const delay = Math.random() * 5;
 | 
			
		||||
  star.style.animationDuration = `${duration}s`;
 | 
			
		||||
  star.style.animationDelay = `${delay}s`;
 | 
			
		||||
  body.appendChild(star);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Array.from({ length: STAR_COUNT }, createStar);
 | 
			
		||||
 | 
			
		||||
function lightenColor(hex, percent) {
 | 
			
		||||
  const num = parseInt(hex.replace("#", ""), 16);
 | 
			
		||||
  let r = (num >> 16) + Math.round(255 * percent / 100);
 | 
			
		||||
  let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100);
 | 
			
		||||
  let b = (num & 0x0000FF) + Math.round(255 * percent / 100);
 | 
			
		||||
  r = Math.min(255, r);
 | 
			
		||||
  g = Math.min(255, g);
 | 
			
		||||
  b = Math.min(255, b);
 | 
			
		||||
  return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
 | 
			
		||||
 | 
			
		||||
function glowCSSVariable(varName, glowColor, duration = 500) {
 | 
			
		||||
  const root = document.documentElement;
 | 
			
		||||
 | 
			
		||||
  //igetComputedStyle(root).getPropertyValue(varName).trim();
 | 
			
		||||
  glowColor = lightenColor(glowColor, 10);
 | 
			
		||||
  root.style.setProperty(varName, glowColor);
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    root.style.setProperty(varName, originalColor);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateStarColorDelayed(color) {
 | 
			
		||||
  glowCSSVariable('--star-color', color, 2500);
 | 
			
		||||
}
 | 
			
		||||
app.updateStarColor = updateStarColorDelayed;
 | 
			
		||||
app.ws.addEventListener("set_typing", (data) => {
 | 
			
		||||
  updateStarColorDelayed(data.color);
 | 
			
		||||
});
 | 
			
		||||
window.createAvatar = () => {
 | 
			
		||||
    let avatar = document.createElement("avatar-face")
 | 
			
		||||
    document.querySelector("main").appendChild(avatar)
 | 
			
		||||
    return avatar
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  
 | 
			
		||||
    class AvatarFace extends HTMLElement {
 | 
			
		||||
      static get observedAttributes(){
 | 
			
		||||
        return ['emotion','face-color','eye-color','text','balloon-color','text-color'];
 | 
			
		||||
      }
 | 
			
		||||
      constructor(){
 | 
			
		||||
        super();
 | 
			
		||||
        this._shadow = this.attachShadow({mode:'open'});
 | 
			
		||||
        this._shadow.innerHTML = `
 | 
			
		||||
          <style>
 | 
			
		||||
            :host { display:block; position:relative; }
 | 
			
		||||
            canvas { width:100%; height:100%; display:block; }
 | 
			
		||||
          </style>
 | 
			
		||||
          <canvas></canvas>
 | 
			
		||||
        `;
 | 
			
		||||
        this._c   = this._shadow.querySelector('canvas');
 | 
			
		||||
        this._ctx = this._c.getContext('2d');
 | 
			
		||||
 | 
			
		||||
        // state
 | 
			
		||||
        this._mouse      = {x:0,y:0};
 | 
			
		||||
        this._blinkTimer = 0;
 | 
			
		||||
        this._blinking   = false;
 | 
			
		||||
        this._lastTime   = 0;
 | 
			
		||||
 | 
			
		||||
        // defaults
 | 
			
		||||
        this._emotion      = 'neutral';
 | 
			
		||||
        this._faceColor    = '#ffdfba';
 | 
			
		||||
        this._eyeColor     = '#000';
 | 
			
		||||
        this._text         = '';
 | 
			
		||||
        this._balloonColor = '#fff';
 | 
			
		||||
        this._textColor    = '#000';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      attributeChangedCallback(name,_old,newV){
 | 
			
		||||
        if (name==='emotion')       this._emotion      = newV||'neutral';
 | 
			
		||||
        else if (name==='face-color')   this._faceColor    = newV||'#ffdfba';
 | 
			
		||||
        else if (name==='eye-color')    this._eyeColor     = newV||'#000';
 | 
			
		||||
        else if (name==='text')         this._text         = newV||'';
 | 
			
		||||
        else if (name==='balloon-color')this._balloonColor = newV||'#fff';
 | 
			
		||||
        else if (name==='text-color')   this._textColor    = newV||'#000';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      connectedCallback(){
 | 
			
		||||
        // watch size so canvas buffer matches display
 | 
			
		||||
        this._ro = new ResizeObserver(entries=>{
 | 
			
		||||
          for(const ent of entries){
 | 
			
		||||
            const w = ent.contentRect.width;
 | 
			
		||||
            const h = ent.contentRect.height;
 | 
			
		||||
            const dpr = devicePixelRatio||1;
 | 
			
		||||
            this._c.width  = w*dpr;
 | 
			
		||||
            this._c.height = h*dpr;
 | 
			
		||||
            this._ctx.scale(dpr,dpr);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        this._ro.observe(this);
 | 
			
		||||
 | 
			
		||||
        // track mouse so eyes follow
 | 
			
		||||
        this._shadow.addEventListener('mousemove', e=>{
 | 
			
		||||
          const r = this._c.getBoundingClientRect();
 | 
			
		||||
          this._mouse.x = e.clientX - r.left;
 | 
			
		||||
          this._mouse.y = e.clientY - r.top;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this._lastTime = performance.now();
 | 
			
		||||
        this._raf      = requestAnimationFrame(t=>this._loop(t));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      disconnectedCallback(){
 | 
			
		||||
        cancelAnimationFrame(this._raf);
 | 
			
		||||
        this._ro.disconnect();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _updateBlink(dt){
 | 
			
		||||
        this._blinkTimer -= dt;
 | 
			
		||||
        if (this._blinkTimer<=0){
 | 
			
		||||
          this._blinking = !this._blinking;
 | 
			
		||||
          this._blinkTimer = this._blinking
 | 
			
		||||
            ? 0.1
 | 
			
		||||
            : 2 + Math.random()*3;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _roundRect(x,y,w,h,r){
 | 
			
		||||
        const ctx = this._ctx;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.moveTo(x+r,y);
 | 
			
		||||
        ctx.lineTo(x+w-r,y);
 | 
			
		||||
        ctx.quadraticCurveTo(x+w,y, x+w,y+r);
 | 
			
		||||
        ctx.lineTo(x+w,y+h-r);
 | 
			
		||||
        ctx.quadraticCurveTo(x+w,y+h, x+w-r,y+h);
 | 
			
		||||
        ctx.lineTo(x+r,y+h);
 | 
			
		||||
        ctx.quadraticCurveTo(x,y+h, x,y+h-r);
 | 
			
		||||
        ctx.lineTo(x,y+r);
 | 
			
		||||
        ctx.quadraticCurveTo(x,y, x+r,y);
 | 
			
		||||
        ctx.closePath();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _draw(ts){
 | 
			
		||||
        const ctx = this._ctx;
 | 
			
		||||
        const W = this._c.clientWidth;
 | 
			
		||||
        const H = this._c.clientHeight;
 | 
			
		||||
        ctx.clearRect(0,0,W,H);
 | 
			
		||||
 | 
			
		||||
        // HEAD + BOB
 | 
			
		||||
        const cx = W/2;
 | 
			
		||||
        const cy = H/2 + Math.sin(ts*0.002)*8;
 | 
			
		||||
        const R  = Math.min(W,H)*0.25;
 | 
			
		||||
 | 
			
		||||
        // SPEECH BALLOON
 | 
			
		||||
        if (this._text){
 | 
			
		||||
          const pad = 6;
 | 
			
		||||
          ctx.font = `${R*0.15}px sans-serif`;
 | 
			
		||||
          const m = ctx.measureText(this._text);
 | 
			
		||||
          const tw = m.width, th = R*0.18;
 | 
			
		||||
          const bw = tw + pad*2, bh = th + pad*2;
 | 
			
		||||
          const bx = cx - bw/2, by = cy - R - bh - 10;
 | 
			
		||||
          // bubble
 | 
			
		||||
          ctx.fillStyle = this._balloonColor;
 | 
			
		||||
          this._roundRect(bx,by,bw,bh,6);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          ctx.strokeStyle = '#888';
 | 
			
		||||
          ctx.lineWidth = 1.2;
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
          // tail
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.moveTo(cx-6, by+bh);
 | 
			
		||||
          ctx.lineTo(cx+6, by+bh);
 | 
			
		||||
          ctx.lineTo(cx, cy-R+4);
 | 
			
		||||
          ctx.closePath();
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
          // text
 | 
			
		||||
          ctx.fillStyle = this._textColor;
 | 
			
		||||
          ctx.textBaseline = 'top';
 | 
			
		||||
          ctx.fillText(this._text, bx+pad, by+pad);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // FACE
 | 
			
		||||
        ctx.fillStyle = this._faceColor;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(cx,cy,R,0,2*Math.PI);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        // EYES
 | 
			
		||||
        const eyeY = cy - R*0.2;
 | 
			
		||||
        const eyeX = R*0.4;
 | 
			
		||||
        const eyeR= R*0.12;
 | 
			
		||||
        const pupR= eyeR*0.5;
 | 
			
		||||
 | 
			
		||||
        for(let i=0;i<2;i++){
 | 
			
		||||
          const ex = cx + (i? eyeX:-eyeX);
 | 
			
		||||
          const ey = eyeY;
 | 
			
		||||
          // eyeball
 | 
			
		||||
          ctx.fillStyle = '#fff';
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(ex,ey,eyeR,0,2*Math.PI);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          // pupil follows
 | 
			
		||||
          let dx = this._mouse.x - ex;
 | 
			
		||||
          let dy = this._mouse.y - ey;
 | 
			
		||||
          const d = Math.hypot(dx,dy);
 | 
			
		||||
          const max = eyeR - pupR - 2;
 | 
			
		||||
          if (d>max){ dx=dx/d*max; dy=dy/d*max; }
 | 
			
		||||
          if (this._blinking){
 | 
			
		||||
            ctx.strokeStyle='#000';
 | 
			
		||||
            ctx.lineWidth=3;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.moveTo(ex-eyeR,ey);
 | 
			
		||||
            ctx.lineTo(ex+eyeR,ey);
 | 
			
		||||
            ctx.stroke();
 | 
			
		||||
          } else {
 | 
			
		||||
            ctx.fillStyle = this._eyeColor;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.arc(ex+dx,ey+dy,pupR,0,2*Math.PI);
 | 
			
		||||
            ctx.fill();
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // ANGRY BROWS
 | 
			
		||||
        if (this._emotion==='angry'){
 | 
			
		||||
          ctx.strokeStyle='#000';
 | 
			
		||||
          ctx.lineWidth=4;
 | 
			
		||||
          [[-eyeX,1],[ eyeX,-1]].forEach(([off,dir])=>{
 | 
			
		||||
            const sx = cx+off - eyeR;
 | 
			
		||||
            const sy = eyeY - eyeR*1.3;
 | 
			
		||||
            const ex = cx+off + eyeR;
 | 
			
		||||
            const ey2= sy + dir*6;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.moveTo(sx,sy);
 | 
			
		||||
            ctx.lineTo(ex,ey2);
 | 
			
		||||
            ctx.stroke();
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // MOUTH by emotion
 | 
			
		||||
        const mw = R*0.6;
 | 
			
		||||
        const my = cy + R*0.25;
 | 
			
		||||
        ctx.strokeStyle='#a33';
 | 
			
		||||
        ctx.lineWidth=4;
 | 
			
		||||
 | 
			
		||||
        if (this._emotion==='surprised'){
 | 
			
		||||
          ctx.fillStyle='#a33';
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw*0.3,0,2*Math.PI);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
        }
 | 
			
		||||
        else if (this._emotion==='sad'){
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw/2,1.15*Math.PI,1.85*Math.PI,true);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
        else if (this._emotion==='angry'){
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.moveTo(cx-mw/2,my+2);
 | 
			
		||||
          ctx.lineTo(cx+mw/2,my-2);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
          const s = this._emotion==='happy'? 0.15*Math.PI:0.2*Math.PI;
 | 
			
		||||
          const e = this._emotion==='happy'? 0.85*Math.PI:0.8*Math.PI;
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw/2,s,e);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _loop(ts){
 | 
			
		||||
        const dt = (ts - this._lastTime)/1000;
 | 
			
		||||
        this._lastTime = ts;
 | 
			
		||||
        this._updateBlink(dt);
 | 
			
		||||
        this._draw(ts);
 | 
			
		||||
        this._raf = requestAnimationFrame(t=>this._loop(t));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    customElements.define('avatar-face', AvatarFace);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  class AvatarReplacer {
 | 
			
		||||
    constructor(target, opts={}){
 | 
			
		||||
      this.target = target;
 | 
			
		||||
      // record original inline styles so we can restore
 | 
			
		||||
      this._oldVis = target.style.visibility || '';
 | 
			
		||||
      this._oldPos = target.style.position || '';
 | 
			
		||||
      // hide the target
 | 
			
		||||
      target.style.visibility = 'hidden';
 | 
			
		||||
      // measure
 | 
			
		||||
      const rect = target.getBoundingClientRect();
 | 
			
		||||
      // create avatar
 | 
			
		||||
      this.avatar = document.createElement('avatar-face');
 | 
			
		||||
      // copy all supported opts into attributes
 | 
			
		||||
      ['emotion','faceColor','eyeColor','text','balloonColor','textColor']
 | 
			
		||||
        .forEach(k => {
 | 
			
		||||
          const attr = k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase());
 | 
			
		||||
          if (opts[k] != null) this.avatar.setAttribute(attr, opts[k]);
 | 
			
		||||
        });
 | 
			
		||||
      // position absolutely
 | 
			
		||||
      const scrollX = window.pageXOffset;
 | 
			
		||||
      const scrollY = window.pageYOffset;
 | 
			
		||||
      Object.assign(this.avatar.style, {
 | 
			
		||||
        position: 'absolute',
 | 
			
		||||
        left: (rect.left + scrollX) + 'px',
 | 
			
		||||
        top:  (rect.top  + scrollY) + 'px',
 | 
			
		||||
        width:  rect.width  + 'px',
 | 
			
		||||
        height: rect.height + 'px',
 | 
			
		||||
        zIndex: 9999
 | 
			
		||||
      });
 | 
			
		||||
      document.body.appendChild(this.avatar);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    detach(){
 | 
			
		||||
      // remove avatar and restore target
 | 
			
		||||
      if (this.avatar && this.avatar.parentNode) {
 | 
			
		||||
        this.avatar.parentNode.removeChild(this.avatar);
 | 
			
		||||
        this.avatar = null;
 | 
			
		||||
      }
 | 
			
		||||
      this.target.style.visibility = this._oldVis;
 | 
			
		||||
      this.target.style.position   = this._oldPos;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // static convenience method
 | 
			
		||||
    static attach(target, opts){
 | 
			
		||||
      return new AvatarReplacer(target, opts);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
/*
 | 
			
		||||
  // DEMO wiring
 | 
			
		||||
  const btnGo = document.getElementById('go');
 | 
			
		||||
  const btnReset = document.getElementById('reset');
 | 
			
		||||
  let repl1, repl2;
 | 
			
		||||
 | 
			
		||||
  btnGo.addEventListener('click', ()=>{
 | 
			
		||||
    // replace #one with a happy avatar saying "Hi!"
 | 
			
		||||
    repl1 = AvatarReplacer.attach(
 | 
			
		||||
      document.getElementById('one'),
 | 
			
		||||
      {emotion:'happy', text:'Hi!', balloonColor:'#fffbdd', textColor:'#333'}
 | 
			
		||||
    );
 | 
			
		||||
    // replace #two with a surprised avatar
 | 
			
		||||
    repl2 = AvatarReplacer.attach(
 | 
			
		||||
      document.getElementById('two'),
 | 
			
		||||
      {emotion:'surprised', faceColor:'#eeffcc', text:'Wow!', balloonColor:'#ddffdd'}
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  btnReset.addEventListener('click', ()=>{
 | 
			
		||||
    if (repl1) repl1.detach();
 | 
			
		||||
    if (repl2) repl2.detach();
 | 
			
		||||
  });
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
class StarField {
 | 
			
		||||
  constructor(container = document.body, options = {}) {
 | 
			
		||||
  constructor({ count = 200, container = document.body } = {}) {
 | 
			
		||||
    this.container = container;
 | 
			
		||||
    this.starCount = count;
 | 
			
		||||
    this.stars = [];
 | 
			
		||||
    this.setOptions(options);
 | 
			
		||||
    this.positionMap = {};
 | 
			
		||||
    this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
 | 
			
		||||
    this._createStars();
 | 
			
		||||
    window.stars = this.positionMap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setOptions({
 | 
			
		||||
    starCount = 200,
 | 
			
		||||
    minSize = 1,
 | 
			
		||||
    maxSize = 3,
 | 
			
		||||
    speed = 5,
 | 
			
		||||
    color = "white"
 | 
			
		||||
  }) {
 | 
			
		||||
    this.options = { starCount, minSize, maxSize, speed, color };
 | 
			
		||||
  _getStarPosition(star) {
 | 
			
		||||
    const left = parseFloat(star.style.left);
 | 
			
		||||
    const top = parseFloat(star.style.top);
 | 
			
		||||
    if (top < 40 && left >= 40 && left <= 60) return "North";
 | 
			
		||||
    if (top > 60 && left >= 40 && left <= 60) return "South";
 | 
			
		||||
    if (left < 40 && top >= 40 && top <= 60) return "West";
 | 
			
		||||
    if (left > 60 && top >= 40 && top <= 60) return "East";
 | 
			
		||||
    if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
 | 
			
		||||
    return "Corner or Edge";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clear() {
 | 
			
		||||
    this.stars.forEach(star => star.remove());
 | 
			
		||||
    this.stars = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generate() {
 | 
			
		||||
    this.clear();
 | 
			
		||||
    const { starCount, minSize, maxSize, speed, color } = this.options;
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < starCount; i++) {
 | 
			
		||||
  _createStars() {
 | 
			
		||||
    for (let i = 0; i < this.starCount; i++) {
 | 
			
		||||
      const star = document.createElement("div");
 | 
			
		||||
      star.classList.add("star");
 | 
			
		||||
      const size = Math.random() * (maxSize - minSize) + minSize;
 | 
			
		||||
 | 
			
		||||
      Object.assign(star.style, {
 | 
			
		||||
        left: `${Math.random() * 100}%`,
 | 
			
		||||
        top: `${Math.random() * 100}%`,
 | 
			
		||||
        width: `${size}px`,
 | 
			
		||||
        height: `${size}px`,
 | 
			
		||||
        backgroundColor: color,
 | 
			
		||||
        position: "absolute",
 | 
			
		||||
        borderRadius: "50%",
 | 
			
		||||
        opacity: "0.8",
 | 
			
		||||
        animation: `twinkle ${speed}s ease-in-out infinite`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this._randomizeStar(star);
 | 
			
		||||
      this._placeStar(star);
 | 
			
		||||
      this.container.appendChild(star);
 | 
			
		||||
      this.stars.push(star);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _randomizeStar(star) {
 | 
			
		||||
    star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.width = `${Math.random() * 2 + 1}px`;
 | 
			
		||||
    star.style.height = `${Math.random() * 2 + 1}px`;
 | 
			
		||||
    star.style.animationDelay = `${Math.random() * 2}s`;
 | 
			
		||||
    star.style.position = "absolute";
 | 
			
		||||
    star.style.transition = "top 1s ease, left 1s ease, opacity 1s ease";
 | 
			
		||||
 | 
			
		||||
    star.shuffle = () => this._randomizeStar(star);
 | 
			
		||||
    star.position = this._getStarPosition(star);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _placeStar(star) {
 | 
			
		||||
    const pos = star.position;
 | 
			
		||||
    if (!this.positionMap[pos]) this.positionMap[pos] = [];
 | 
			
		||||
    this.positionMap[pos].push(star);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  shuffleAll(duration = 1000) {
 | 
			
		||||
    this.stars.forEach(star => {
 | 
			
		||||
      star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`;
 | 
			
		||||
      star.style.filter = "drop-shadow(0 0 2px white)";
 | 
			
		||||
      const left = Math.random() * 100;
 | 
			
		||||
      const top = Math.random() * 100;
 | 
			
		||||
      star.style.left = `${left}%`;
 | 
			
		||||
      star.style.top = `${top}%`;
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        star.style.filter = "";
 | 
			
		||||
        star.position = this._getStarPosition(star);
 | 
			
		||||
      }, duration);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  glowColor(tempColor, duration = 2500) {
 | 
			
		||||
    const lighten = (hex, percent) => {
 | 
			
		||||
      const num = parseInt(hex.replace("#", ""), 16);
 | 
			
		||||
      const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
 | 
			
		||||
      const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
 | 
			
		||||
      const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
 | 
			
		||||
      return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const glow = lighten(tempColor, 10);
 | 
			
		||||
    document.documentElement.style.setProperty("--star-color", glow);
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      document.documentElement.style.setProperty("--star-color", this.originalColor);
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showNotify(text, { duration = 3000, color = "white", fontSize = "1.2em" } = {}) {
 | 
			
		||||
  // Create container if needed
 | 
			
		||||
  if (!this._notifyContainer) {
 | 
			
		||||
    this._notifyContainer = document.createElement("div");
 | 
			
		||||
    this._notifyContainer.className = "star-notify-container";
 | 
			
		||||
    document.body.appendChild(this._notifyContainer);
 | 
			
		||||
  }
 | 
			
		||||
    const messages = document.querySelectorAll('.star-notify');
 | 
			
		||||
 | 
			
		||||
const count = Array.from(messages).filter(el => el.textContent.trim() === text).length;
 | 
			
		||||
 | 
			
		||||
  if (count) return;
 | 
			
		||||
  const note = document.createElement("div");
 | 
			
		||||
  note.className = "star-notify";
 | 
			
		||||
  note.textContent = text;
 | 
			
		||||
  note.style.color = color;
 | 
			
		||||
  note.style.fontSize = fontSize;
 | 
			
		||||
 | 
			
		||||
  this._notifyContainer.appendChild(note);
 | 
			
		||||
 | 
			
		||||
  // Trigger animation
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    note.style.opacity = 1;
 | 
			
		||||
    note.style.transform = "translateY(0)";
 | 
			
		||||
  }, 10);
 | 
			
		||||
 | 
			
		||||
  // Remove after duration
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    note.style.opacity = 0;
 | 
			
		||||
    note.style.transform = "translateY(-10px)";
 | 
			
		||||
    setTimeout(() => note.remove(), 500);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const starField = new StarField(document.body, {
 | 
			
		||||
  starCount: 200,
 | 
			
		||||
  minSize: 1,
 | 
			
		||||
  maxSize: 3,
 | 
			
		||||
  speed: 5,
 | 
			
		||||
  color: "white"    
 | 
			
		||||
});
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
  renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
 | 
			
		||||
    const canvas = document.createElement("canvas");
 | 
			
		||||
    const ctx = canvas.getContext("2d");
 | 
			
		||||
    canvas.width = this.container.clientWidth;
 | 
			
		||||
    canvas.height = this.container.clientHeight / 3;
 | 
			
		||||
 | 
			
		||||
    const fontSize = Math.floor(canvas.height / 2.5);
 | 
			
		||||
    ctx.font = `${fontSize}px sans-serif`;
 | 
			
		||||
    ctx.fillStyle = "black";
 | 
			
		||||
    ctx.textAlign = "center";
 | 
			
		||||
    ctx.textBaseline = "middle";
 | 
			
		||||
    ctx.fillText(word, canvas.width / 2, canvas.height / 2);
 | 
			
		||||
 | 
			
		||||
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
 | 
			
		||||
    const targetPositions = [];
 | 
			
		||||
 | 
			
		||||
    for (let y = 0; y < canvas.height; y += resolution) {
 | 
			
		||||
      for (let x = 0; x < canvas.width; x += resolution) {
 | 
			
		||||
        const i = (y * canvas.width + x) * 4;
 | 
			
		||||
        if (imageData[i + 3] > 128) {
 | 
			
		||||
          targetPositions.push([
 | 
			
		||||
            (x / canvas.width) * 100,
 | 
			
		||||
            (y / canvas.height) * 100,
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    targetPositions.sort(() => Math.random() - 0.5);
 | 
			
		||||
    const used = targetPositions.slice(0, this.stars.length);
 | 
			
		||||
 | 
			
		||||
    this.stars.forEach((star, i) => {
 | 
			
		||||
      star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease, background-color 1s ease`;
 | 
			
		||||
      if (i < used.length) {
 | 
			
		||||
        const [left, top] = used[i];
 | 
			
		||||
        star.style.left = `${left}%`;
 | 
			
		||||
        star.style.top = `${top}%`;
 | 
			
		||||
        star.style.opacity = 1;
 | 
			
		||||
        if (rainbow) {
 | 
			
		||||
          const hue = (i / used.length) * 360;
 | 
			
		||||
          star.style.backgroundColor = `hsl(${hue}, 100%, 70%)`;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        star.style.opacity = 0;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.stars.forEach(star => {
 | 
			
		||||
        star.position = this._getStarPosition(star);
 | 
			
		||||
        if (rainbow) star.style.backgroundColor = "";
 | 
			
		||||
      });
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  explodeAndReturn(duration = 1000) {
 | 
			
		||||
    const originalPositions = this.stars.map(star => ({
 | 
			
		||||
      left: star.style.left,
 | 
			
		||||
      top: star.style.top
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    this.stars.forEach(star => {
 | 
			
		||||
      const angle = Math.random() * 2 * Math.PI;
 | 
			
		||||
      const radius = Math.random() * 200;
 | 
			
		||||
      const x = 50 + Math.cos(angle) * radius;
 | 
			
		||||
      const y = 50 + Math.sin(angle) * radius;
 | 
			
		||||
      star.style.transition = `top ${duration / 2}ms ease-out, left ${duration / 2}ms ease-out`;
 | 
			
		||||
      star.style.left = `${x}%`;
 | 
			
		||||
      star.style.top = `${y}%`;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.stars.forEach((star, i) => {
 | 
			
		||||
        star.style.transition = `top ${duration}ms ease-in, left ${duration}ms ease-in`;
 | 
			
		||||
        star.style.left = originalPositions[i].left;
 | 
			
		||||
        star.style.top = originalPositions[i].top;
 | 
			
		||||
      });
 | 
			
		||||
    }, duration / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  startColorCycle() {
 | 
			
		||||
    let hue = 0;
 | 
			
		||||
    if (this._colorInterval) clearInterval(this._colorInterval);
 | 
			
		||||
    this._colorInterval = setInterval(() => {
 | 
			
		||||
      hue = (hue + 2) % 360;
 | 
			
		||||
      this.stars.forEach((star, i) => {
 | 
			
		||||
        star.style.backgroundColor = `hsl(${(hue + i * 3) % 360}, 100%, 75%)`;
 | 
			
		||||
      });
 | 
			
		||||
    }, 100);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  stopColorCycle() {
 | 
			
		||||
    if (this._colorInterval) {
 | 
			
		||||
      clearInterval(this._colorInterval);
 | 
			
		||||
      this._colorInterval = null;
 | 
			
		||||
      this.stars.forEach(star => star.style.backgroundColor = "");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addSpecialStar({ title, content, category = "Info", color = "gold", onClick }) {
 | 
			
		||||
    const star = this.stars.find(s => !s._dataAttached);
 | 
			
		||||
    if (!star) return;
 | 
			
		||||
 | 
			
		||||
    star.classList.add("special");
 | 
			
		||||
    star.style.backgroundColor = color;
 | 
			
		||||
    star._dataAttached = true;
 | 
			
		||||
    star._specialData = { title, content, category, color, onClick };
 | 
			
		||||
 | 
			
		||||
    const tooltip = document.getElementById("star-tooltip");
 | 
			
		||||
    const showTooltip = (e) => {
 | 
			
		||||
      tooltip.innerText = `${title} (${category})`;
 | 
			
		||||
      tooltip.style.display = "block";
 | 
			
		||||
      tooltip.style.left = `${e.clientX + 10}px`;
 | 
			
		||||
      tooltip.style.top = `${e.clientY + 10}px`;
 | 
			
		||||
    };
 | 
			
		||||
    const hideTooltip = () => tooltip.style.display = "none";
 | 
			
		||||
 | 
			
		||||
    star.addEventListener("mouseenter", showTooltip);
 | 
			
		||||
    star.addEventListener("mouseleave", hideTooltip);
 | 
			
		||||
    star.addEventListener("mousemove", showTooltip);
 | 
			
		||||
 | 
			
		||||
    const showPopup = (e) => {
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this._showPopup(star, star._specialData);
 | 
			
		||||
      if (onClick) onClick(star._specialData);
 | 
			
		||||
    };
 | 
			
		||||
    star.addEventListener("click", showPopup);
 | 
			
		||||
    star.addEventListener("touchend", showPopup);
 | 
			
		||||
 | 
			
		||||
    return star;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _showPopup(star, data) {
 | 
			
		||||
    const popup = document.getElementById("star-popup");
 | 
			
		||||
    popup.innerHTML = `<strong>${data.title}</strong><br><small>${data.category}</small><div style="margin-top: 5px;">${data.content}</div>`;
 | 
			
		||||
    popup.style.display = "block";
 | 
			
		||||
 | 
			
		||||
    const rect = star.getBoundingClientRect();
 | 
			
		||||
    popup.style.left = `${rect.left + window.scrollX + 10}px`;
 | 
			
		||||
    popup.style.top = `${rect.top + window.scrollY + 10}px`;
 | 
			
		||||
 | 
			
		||||
    const closeHandler = () => {
 | 
			
		||||
      popup.style.display = "none";
 | 
			
		||||
      document.removeEventListener("click", closeHandler);
 | 
			
		||||
    };
 | 
			
		||||
    setTimeout(() => document.addEventListener("click", closeHandler), 100);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeSpecialStar(star) {
 | 
			
		||||
    if (!star._specialData) return;
 | 
			
		||||
 | 
			
		||||
    star.classList.remove("special");
 | 
			
		||||
    delete star._specialData;
 | 
			
		||||
    star._dataAttached = false;
 | 
			
		||||
 | 
			
		||||
    const clone = star.cloneNode(true);
 | 
			
		||||
    star.replaceWith(clone);
 | 
			
		||||
 | 
			
		||||
    const index = this.stars.indexOf(star);
 | 
			
		||||
    if (index >= 0) this.stars[index] = clone;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSpecialStars() {
 | 
			
		||||
    return this.stars
 | 
			
		||||
      .filter(star => star._specialData)
 | 
			
		||||
      .map(star => ({
 | 
			
		||||
        star,
 | 
			
		||||
        data: star._specialData
 | 
			
		||||
      }));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const starField = new StarField({starCount: 200});
 | 
			
		||||
app.starField = starField;
 | 
			
		||||
 | 
			
		||||
class DemoSequence {
 | 
			
		||||
  constructor(steps = []) {
 | 
			
		||||
    this.steps = steps;
 | 
			
		||||
    this.overlay = document.createElement("div");
 | 
			
		||||
    this.overlay.className = "demo-overlay";
 | 
			
		||||
    document.body.appendChild(this.overlay);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  start() {
 | 
			
		||||
    this._runStep(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
_runStep(index) {
 | 
			
		||||
  if (index >= this.steps.length) {
 | 
			
		||||
    this._clearOverlay();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { target, text, duration = 3000 } = this.steps[index];
 | 
			
		||||
  this._clearHighlights();
 | 
			
		||||
  this._showOverlay(text);
 | 
			
		||||
 | 
			
		||||
  if (target) {
 | 
			
		||||
    const el = typeof target === 'string' ? document.querySelector(target) : target;
 | 
			
		||||
    if (el) {
 | 
			
		||||
      el.classList.remove("demo-highlight");
 | 
			
		||||
      void el.offsetWidth; // force reflow to reset animation
 | 
			
		||||
      el.classList.add("demo-highlight");
 | 
			
		||||
      el.scrollIntoView({ behavior: "smooth", block: "center" });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    this._hideOverlay();
 | 
			
		||||
    this._runStep(index + 1);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
_showOverlay(text) {
 | 
			
		||||
  this.overlay.innerText = text;
 | 
			
		||||
  this.overlay.style.animation = "demoFadeIn 0.8s ease forwards";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  _hideOverlay() {
 | 
			
		||||
    this.overlay.style.opacity = 0;
 | 
			
		||||
    this._clearHighlights();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _clearOverlay() {
 | 
			
		||||
    this.overlay.remove();
 | 
			
		||||
    this._clearHighlights();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _clearHighlights() {
 | 
			
		||||
    document.querySelectorAll(".demo-highlight").forEach(el => {
 | 
			
		||||
      el.classList.remove("demo-highlight");
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
const demo = new DemoSequence([
 | 
			
		||||
  {
 | 
			
		||||
    text: "💬 Welcome to the Snek Developer Community!",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".channels-list", 
 | 
			
		||||
    text: "🔗 Channels help you organize conversations.",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".user-icon",
 | 
			
		||||
    text: "👥 Invite team members here.",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".chat-input",
 | 
			
		||||
    text: "⌨️ Type your message and hit Enter!",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  }
 | 
			
		||||
]);*/
 | 
			
		||||
 | 
			
		||||
// Start when ready (e.g., after load or user action)
 | 
			
		||||
//demo.start();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -37,7 +37,21 @@
 | 
			
		||||
            showHelp();
 | 
			
		||||
        }
 | 
			
		||||
     }
 | 
			
		||||
    
 | 
			
		||||
     app.ws.addEventListener("refresh", (data) => {
 | 
			
		||||
         app.starField.showNotify(data.message);
 | 
			
		||||
         setTimeout(() => {
 | 
			
		||||
             window.location.reload();
 | 
			
		||||
         },4000)
 | 
			
		||||
     })
 | 
			
		||||
    app.ws.addEventListener("deployed", (data) => {
 | 
			
		||||
         app.starField.renderWord("Deployed",{"rainbow":true,"resolution":8});
 | 
			
		||||
         setTimeout(() => {
 | 
			
		||||
             app.starField.shuffleAll(5000);
 | 
			
		||||
         },10000)
 | 
			
		||||
     })    
 | 
			
		||||
     app.ws.addEventListener("starfield.render_word", (data) => {
 | 
			
		||||
         app.starField.renderWord(data.word,data);
 | 
			
		||||
     })    
 | 
			
		||||
        const textBox = document.querySelector("chat-input").textarea
 | 
			
		||||
        textBox.addEventListener("paste", async (e) => {
 | 
			
		||||
            try {
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,7 @@
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import traceback
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
from aiohttp import web
 | 
			
		||||
 | 
			
		||||
from snek.system.model import now
 | 
			
		||||
@ -21,13 +21,29 @@ import logging
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
class RPCView(BaseView):
 | 
			
		||||
 | 
			
		||||
    class RPCApi:
 | 
			
		||||
        def __init__(self, view, ws):
 | 
			
		||||
            self.view = view
 | 
			
		||||
            self.app = self.view.app
 | 
			
		||||
            self.services = self.app.services
 | 
			
		||||
            self.ws = ws
 | 
			
		||||
            self.user_session = {}
 | 
			
		||||
 | 
			
		||||
        async def _session_ensure(self):
 | 
			
		||||
            uid = await self.view.session_get("uid")
 | 
			
		||||
            if not uid in self.user_session:
 | 
			
		||||
                self.user_session[uid] = {
 | 
			
		||||
                    "said_hello": False,
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
        async def session_get(self, key, default):
 | 
			
		||||
            await self._session_ensure()
 | 
			
		||||
            return self.user_session[self.user_uid].get(key, default)
 | 
			
		||||
 | 
			
		||||
        async def session_set(self, key, value):
 | 
			
		||||
            await self._session_ensure()
 | 
			
		||||
            self.user_session[self.user_uid][key] = value
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        async def db_insert(self, table_name, record):
 | 
			
		||||
            self._require_login()
 | 
			
		||||
@ -323,6 +339,11 @@ class RPCView(BaseView):
 | 
			
		||||
                async for record in self.services.channel.get_users(channel_uid)
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
        async def _schedule(self, uid, seconds, call):
 | 
			
		||||
            await asyncio.sleep(seconds)
 | 
			
		||||
            await self.services.socket.send_to_user(uid, call)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        async def ping(self, callId, *args):
 | 
			
		||||
            if self.user_uid:
 | 
			
		||||
                user = await self.services.user.get(uid=self.user_uid)
 | 
			
		||||
@ -330,7 +351,14 @@ class RPCView(BaseView):
 | 
			
		||||
                await self.services.user.save(user)
 | 
			
		||||
            return {"pong": args}
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    async def get(self):
 | 
			
		||||
        async def schedule(uid, seconds, call):
 | 
			
		||||
            await asyncio.sleep(seconds)
 | 
			
		||||
            await self.services.socket.send_to_user(uid, call)
 | 
			
		||||
 | 
			
		||||
        ws = web.WebSocketResponse()
 | 
			
		||||
        await ws.prepare(self.request)
 | 
			
		||||
        if self.request.session.get("logged_in"):
 | 
			
		||||
@ -343,6 +371,16 @@ class RPCView(BaseView):
 | 
			
		||||
                await self.services.socket.subscribe(
 | 
			
		||||
                    ws, subscription["channel_uid"], self.request.session.get("uid")
 | 
			
		||||
                )
 | 
			
		||||
        if self.request.app.uptime_seconds < 10:
 | 
			
		||||
            await schedule(self.request.session.get("uid"),1,{"event":"refresh", "data": {
 | 
			
		||||
                    "message": "Finishing deployment"}
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
            await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": {
 | 
			
		||||
                "uptime": self.request.app.uptime}
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        
 | 
			
		||||
        rpc = RPCView.RPCApi(self, ws)
 | 
			
		||||
        async for msg in ws:
 | 
			
		||||
            if msg.type == web.WSMsgType.TEXT:
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user