Update.
This commit is contained in:
		
							parent
							
								
									a55d15b635
								
							
						
					
					
						commit
						3bf09f9083
					
				@ -3,6 +3,7 @@ import logging
 | 
				
			|||||||
import pathlib
 | 
					import pathlib
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
import uuid
 | 
					import uuid
 | 
				
			||||||
 | 
					from datetime import datetime
 | 
				
			||||||
from snek import snode 
 | 
					from snek import snode 
 | 
				
			||||||
from snek.view.threads import ThreadsView
 | 
					from snek.view.threads import ThreadsView
 | 
				
			||||||
import json 
 | 
					import json 
 | 
				
			||||||
@ -96,6 +97,7 @@ class Application(BaseApplication):
 | 
				
			|||||||
        self.jinja2_env.add_extension(LinkifyExtension)
 | 
					        self.jinja2_env.add_extension(LinkifyExtension)
 | 
				
			||||||
        self.jinja2_env.add_extension(PythonExtension)
 | 
					        self.jinja2_env.add_extension(PythonExtension)
 | 
				
			||||||
        self.jinja2_env.add_extension(EmojiExtension)
 | 
					        self.jinja2_env.add_extension(EmojiExtension)
 | 
				
			||||||
 | 
					        self.time_start = datetime.now()
 | 
				
			||||||
        self.ssh_host = "0.0.0.0"
 | 
					        self.ssh_host = "0.0.0.0"
 | 
				
			||||||
        self.ssh_port = 2242
 | 
					        self.ssh_port = 2242
 | 
				
			||||||
        self.setup_router()
 | 
					        self.setup_router()
 | 
				
			||||||
@ -112,7 +114,34 @@ class Application(BaseApplication):
 | 
				
			|||||||
        self.on_startup.append(self.start_user_availability_service)
 | 
					        self.on_startup.append(self.start_user_availability_service)
 | 
				
			||||||
        self.on_startup.append(self.start_ssh_server)
 | 
					        self.on_startup.append(self.start_ssh_server)
 | 
				
			||||||
        self.on_startup.append(self.prepare_database)
 | 
					        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):
 | 
					    async def start_user_availability_service(self, app):
 | 
				
			||||||
        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
 | 
					        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
 | 
				
			||||||
    async def snode_sync(self, app):
 | 
					    async def snode_sync(self, app):
 | 
				
			||||||
 | 
				
			|||||||
@ -66,7 +66,7 @@ export class Chat extends EventHandler {
 | 
				
			|||||||
    return new Promise((resolve) => {
 | 
					    return new Promise((resolve) => {
 | 
				
			||||||
      this._waitConnect = resolve;
 | 
					      this._waitConnect = resolve;
 | 
				
			||||||
      console.debug("Connecting..");
 | 
					      console.debug("Connecting..");
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        this._socket = new WebSocket(this._url);
 | 
					        this._socket = new WebSocket(this._url);
 | 
				
			||||||
      } catch (e) {
 | 
					      } catch (e) {
 | 
				
			||||||
@ -196,7 +196,9 @@ export class App extends EventHandler {
 | 
				
			|||||||
    this.ws.addEventListener("connected", (data) => {
 | 
					    this.ws.addEventListener("connected", (data) => {
 | 
				
			||||||
      this.ping("online");
 | 
					      this.ping("online");
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    this.ws.addEventListener("reconnecting", (data) => {
 | 
				
			||||||
 | 
					        this.starField?.showNotify("Connecting..","#CC0000")
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
    this.ws.addEventListener("channel-message", (data) => {
 | 
					    this.ws.addEventListener("channel-message", (data) => {
 | 
				
			||||||
      me.emit("channel-message", data);
 | 
					      me.emit("channel-message", data);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
				
			|||||||
@ -1,42 +1,178 @@
 | 
				
			|||||||
 | 
					    :root {
 | 
				
			||||||
 | 
					      --star-color: white;
 | 
				
			||||||
 | 
					      --background-color: black;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.star {
 | 
					    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;
 | 
					  position: absolute;
 | 
				
			||||||
  width: 2px;
 | 
					  font-size: 12px;
 | 
				
			||||||
  height: 2px;
 | 
					  color: white;
 | 
				
			||||||
  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;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    50% {
 | 
					 | 
				
			||||||
	box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    100% {
 | 
					 | 
				
			||||||
	box-shadow: 0 0 5px --star-color;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.star-glow {
 | 
					 | 
				
			||||||
    animation: star-glow-frames 1s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.content {
 | 
					 | 
				
			||||||
  position: relative;
 | 
					 | 
				
			||||||
  z-index: 1;
 | 
					 | 
				
			||||||
  color: var(--star-content-color, #eee);
 | 
					 | 
				
			||||||
  font-family: sans-serif;
 | 
					  font-family: sans-serif;
 | 
				
			||||||
  text-align: center;
 | 
					  pointer-events: none;
 | 
				
			||||||
  top: 40%;
 | 
					  z-index: 9999;
 | 
				
			||||||
  transform: translateY(-40%);
 | 
					  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 0 rgba(255, 255, 150, 0);
 | 
				
			||||||
 | 
					    transform: scale(1);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.demo-highlight {
 | 
				
			||||||
 | 
					  animation: demoPulse 1.5s ease-out;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  z-index: 9999;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.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,7 +95,8 @@ export class Socket extends EventHandler {
 | 
				
			|||||||
    if (this.shouldReconnect)
 | 
					    if (this.shouldReconnect)
 | 
				
			||||||
      setTimeout(() => {
 | 
					      setTimeout(() => {
 | 
				
			||||||
        console.log("Reconnecting");
 | 
					        console.log("Reconnecting");
 | 
				
			||||||
        return this.connect();
 | 
					        this.emit("reconnecting");
 | 
				
			||||||
 | 
					          return this.connect();
 | 
				
			||||||
      }, 0);
 | 
					      }, 0);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,504 +1,392 @@
 | 
				
			|||||||
 | 
					<div id="star-tooltip" class="star-tooltip"></div>
 | 
				
			||||||
 | 
					<div id="star-popup" class="star-popup"></div>
 | 
				
			||||||
<script type="module">
 | 
					<script type="module">
 | 
				
			||||||
import { app } from "/app.js";
 | 
					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 {
 | 
					class StarField {
 | 
				
			||||||
  constructor(container = document.body, options = {}) {
 | 
					  constructor({ count = 200, container = document.body } = {}) {
 | 
				
			||||||
    this.container = container;
 | 
					    this.container = container;
 | 
				
			||||||
 | 
					    this.starCount = count;
 | 
				
			||||||
    this.stars = [];
 | 
					    this.stars = [];
 | 
				
			||||||
    this.setOptions(options);
 | 
					    this.positionMap = {};
 | 
				
			||||||
 | 
					    this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
 | 
				
			||||||
 | 
					    this._createStars();
 | 
				
			||||||
 | 
					    window.stars = this.positionMap;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setOptions({
 | 
					  _getStarPosition(star) {
 | 
				
			||||||
    starCount = 200,
 | 
					    const left = parseFloat(star.style.left);
 | 
				
			||||||
    minSize = 1,
 | 
					    const top = parseFloat(star.style.top);
 | 
				
			||||||
    maxSize = 3,
 | 
					    if (top < 40 && left >= 40 && left <= 60) return "North";
 | 
				
			||||||
    speed = 5,
 | 
					    if (top > 60 && left >= 40 && left <= 60) return "South";
 | 
				
			||||||
    color = "white"
 | 
					    if (left < 40 && top >= 40 && top <= 60) return "West";
 | 
				
			||||||
  }) {
 | 
					    if (left > 60 && top >= 40 && top <= 60) return "East";
 | 
				
			||||||
    this.options = { starCount, minSize, maxSize, speed, color };
 | 
					    if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
 | 
				
			||||||
 | 
					    return "Corner or Edge";
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  clear() {
 | 
					  _createStars() {
 | 
				
			||||||
    this.stars.forEach(star => star.remove());
 | 
					    for (let i = 0; i < this.starCount; i++) {
 | 
				
			||||||
    this.stars = [];
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  generate() {
 | 
					 | 
				
			||||||
    this.clear();
 | 
					 | 
				
			||||||
    const { starCount, minSize, maxSize, speed, color } = this.options;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (let i = 0; i < starCount; i++) {
 | 
					 | 
				
			||||||
      const star = document.createElement("div");
 | 
					      const star = document.createElement("div");
 | 
				
			||||||
      star.classList.add("star");
 | 
					      star.classList.add("star");
 | 
				
			||||||
      const size = Math.random() * (maxSize - minSize) + minSize;
 | 
					      this._randomizeStar(star);
 | 
				
			||||||
 | 
					      this._placeStar(star);
 | 
				
			||||||
      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.container.appendChild(star);
 | 
					      this.container.appendChild(star);
 | 
				
			||||||
      this.stars.push(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,
 | 
					  renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
 | 
				
			||||||
  minSize: 1,
 | 
					    const canvas = document.createElement("canvas");
 | 
				
			||||||
  maxSize: 3,
 | 
					    const ctx = canvas.getContext("2d");
 | 
				
			||||||
  speed: 5,
 | 
					    canvas.width = this.container.clientWidth;
 | 
				
			||||||
  color: "white"    
 | 
					    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>
 | 
					</script>
 | 
				
			||||||
 | 
				
			|||||||
@ -37,7 +37,21 @@
 | 
				
			|||||||
            showHelp();
 | 
					            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
 | 
					        const textBox = document.querySelector("chat-input").textarea
 | 
				
			||||||
        textBox.addEventListener("paste", async (e) => {
 | 
					        textBox.addEventListener("paste", async (e) => {
 | 
				
			||||||
            try {
 | 
					            try {
 | 
				
			||||||
 | 
				
			|||||||
@ -9,7 +9,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import json
 | 
					import json
 | 
				
			||||||
import traceback
 | 
					import traceback
 | 
				
			||||||
 | 
					import asyncio
 | 
				
			||||||
from aiohttp import web
 | 
					from aiohttp import web
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from snek.system.model import now
 | 
					from snek.system.model import now
 | 
				
			||||||
@ -21,13 +21,29 @@ import logging
 | 
				
			|||||||
logger = logging.getLogger(__name__)
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class RPCView(BaseView):
 | 
					class RPCView(BaseView):
 | 
				
			||||||
 | 
					 | 
				
			||||||
    class RPCApi:
 | 
					    class RPCApi:
 | 
				
			||||||
        def __init__(self, view, ws):
 | 
					        def __init__(self, view, ws):
 | 
				
			||||||
            self.view = view
 | 
					            self.view = view
 | 
				
			||||||
            self.app = self.view.app
 | 
					            self.app = self.view.app
 | 
				
			||||||
            self.services = self.app.services
 | 
					            self.services = self.app.services
 | 
				
			||||||
            self.ws = ws
 | 
					            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):
 | 
					        async def db_insert(self, table_name, record):
 | 
				
			||||||
            self._require_login()
 | 
					            self._require_login()
 | 
				
			||||||
@ -323,6 +339,11 @@ class RPCView(BaseView):
 | 
				
			|||||||
                async for record in self.services.channel.get_users(channel_uid)
 | 
					                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):
 | 
					        async def ping(self, callId, *args):
 | 
				
			||||||
            if self.user_uid:
 | 
					            if self.user_uid:
 | 
				
			||||||
                user = await self.services.user.get(uid=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)
 | 
					                await self.services.user.save(user)
 | 
				
			||||||
            return {"pong": args}
 | 
					            return {"pong": args}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async def get(self):
 | 
					    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()
 | 
					        ws = web.WebSocketResponse()
 | 
				
			||||||
        await ws.prepare(self.request)
 | 
					        await ws.prepare(self.request)
 | 
				
			||||||
        if self.request.session.get("logged_in"):
 | 
					        if self.request.session.get("logged_in"):
 | 
				
			||||||
@ -343,6 +371,16 @@ class RPCView(BaseView):
 | 
				
			|||||||
                await self.services.socket.subscribe(
 | 
					                await self.services.socket.subscribe(
 | 
				
			||||||
                    ws, subscription["channel_uid"], self.request.session.get("uid")
 | 
					                    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)
 | 
					        rpc = RPCView.RPCApi(self, ws)
 | 
				
			||||||
        async for msg in ws:
 | 
					        async for msg in ws:
 | 
				
			||||||
            if msg.type == web.WSMsgType.TEXT:
 | 
					            if msg.type == web.WSMsgType.TEXT:
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user