initial
This commit is contained in:
319
ts_web/elements/dees-screensaver/dees-screensaver.ts
Normal file
319
ts_web/elements/dees-screensaver/dees-screensaver.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
customElement,
|
||||
DeesElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
css,
|
||||
cssManager,
|
||||
state,
|
||||
} from '@design.estate/dees-element';
|
||||
import { zIndexLayers } from '../00zindex.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'dees-screensaver': DeesScreensaver;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle shadcn-inspired color palette
|
||||
const colors = [
|
||||
'hsl(0 0% 98%)', // zinc-50
|
||||
'hsl(240 5% 65%)', // zinc-400
|
||||
'hsl(240 4% 46%)', // zinc-500
|
||||
'hsl(240 5% 34%)', // zinc-600
|
||||
'hsl(217 91% 60%)', // blue-500
|
||||
'hsl(142 71% 45%)', // green-500
|
||||
];
|
||||
|
||||
@customElement('dees-screensaver')
|
||||
export class DeesScreensaver extends DeesElement {
|
||||
public static demo = () => html`<dees-screensaver .active=${true}></dees-screensaver>`;
|
||||
|
||||
// Instance management
|
||||
private static instance: DeesScreensaver | null = null;
|
||||
|
||||
public static async show(): Promise<DeesScreensaver> {
|
||||
if (DeesScreensaver.instance) {
|
||||
DeesScreensaver.instance.active = true;
|
||||
return DeesScreensaver.instance;
|
||||
}
|
||||
|
||||
const screensaver = new DeesScreensaver();
|
||||
screensaver.active = true;
|
||||
document.body.appendChild(screensaver);
|
||||
DeesScreensaver.instance = screensaver;
|
||||
return screensaver;
|
||||
}
|
||||
|
||||
public static hide(): void {
|
||||
if (DeesScreensaver.instance) {
|
||||
DeesScreensaver.instance.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
public static destroy(): void {
|
||||
if (DeesScreensaver.instance) {
|
||||
DeesScreensaver.instance.remove();
|
||||
DeesScreensaver.instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Styles
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: ${zIndexLayers.overlay.screensaver};
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease;
|
||||
}
|
||||
|
||||
:host([active]) {
|
||||
pointer-events: all;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: hsl(240 10% 4%);
|
||||
}
|
||||
|
||||
.time-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 96px;
|
||||
font-weight: 200;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
transition: color 1.5s ease;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
transition: color 1.5s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.time {
|
||||
font-size: 48px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
accessor active = false;
|
||||
|
||||
@state()
|
||||
accessor currentTime = '';
|
||||
|
||||
@state()
|
||||
accessor currentDate = '';
|
||||
|
||||
@state()
|
||||
accessor currentColor = colors[0];
|
||||
|
||||
// Animation state - non-reactive for smooth animation
|
||||
private posX = 100;
|
||||
private posY = 100;
|
||||
private velocityX = 0.3;
|
||||
private velocityY = 0.2;
|
||||
private animationId: number | null = null;
|
||||
private timeUpdateInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private colorIndex = 0;
|
||||
private elementWidth = 280;
|
||||
private elementHeight = 140;
|
||||
private hasBounced = false;
|
||||
private timeContainerEl: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.updateTime();
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="backdrop" @click=${this.handleClick}></div>
|
||||
<div class="time-container">
|
||||
<span class="time" style="color: ${this.currentColor};">${this.currentTime}</span>
|
||||
<span class="date" style="color: ${this.currentColor};">${this.currentDate}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public firstUpdated(): void {
|
||||
this.timeContainerEl = this.shadowRoot?.querySelector('.time-container') as HTMLElement;
|
||||
}
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
await super.connectedCallback();
|
||||
this.startAnimation();
|
||||
this.startTimeUpdate();
|
||||
}
|
||||
|
||||
async disconnectedCallback(): Promise<void> {
|
||||
await super.disconnectedCallback();
|
||||
this.stopAnimation();
|
||||
this.stopTimeUpdate();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, unknown>): void {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('active')) {
|
||||
if (this.active) {
|
||||
this.startAnimation();
|
||||
this.startTimeUpdate();
|
||||
} else {
|
||||
this.stopAnimation();
|
||||
this.stopTimeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateTime(): void {
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
this.currentTime = `${hours}:${minutes}`;
|
||||
|
||||
// Format date like "Monday, January 6"
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
this.currentDate = now.toLocaleDateString('en-US', options);
|
||||
}
|
||||
|
||||
private startTimeUpdate(): void {
|
||||
if (this.timeUpdateInterval) return;
|
||||
this.updateTime();
|
||||
this.timeUpdateInterval = setInterval(() => this.updateTime(), 1000);
|
||||
}
|
||||
|
||||
private stopTimeUpdate(): void {
|
||||
if (this.timeUpdateInterval) {
|
||||
clearInterval(this.timeUpdateInterval);
|
||||
this.timeUpdateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
if (this.animationId) return;
|
||||
|
||||
// Initialize position randomly
|
||||
const maxX = window.innerWidth - this.elementWidth;
|
||||
const maxY = window.innerHeight - this.elementHeight;
|
||||
this.posX = Math.random() * Math.max(0, maxX);
|
||||
this.posY = Math.random() * Math.max(0, maxY);
|
||||
|
||||
// Randomize initial direction - very slow, elegant movement
|
||||
this.velocityX = (Math.random() > 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.15);
|
||||
this.velocityY = (Math.random() > 0.5 ? 1 : -1) * (0.15 + Math.random() * 0.1);
|
||||
|
||||
// Reset bounce state
|
||||
this.hasBounced = false;
|
||||
|
||||
const animate = () => {
|
||||
if (!this.active) {
|
||||
this.animationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxX = window.innerWidth - this.elementWidth;
|
||||
const maxY = window.innerHeight - this.elementHeight;
|
||||
|
||||
// Update position
|
||||
this.posX += this.velocityX;
|
||||
this.posY += this.velocityY;
|
||||
|
||||
// Track if we're currently at a boundary
|
||||
let atBoundary = false;
|
||||
|
||||
// Bounce off walls
|
||||
if (this.posX <= 0) {
|
||||
this.posX = 0;
|
||||
this.velocityX = Math.abs(this.velocityX);
|
||||
atBoundary = true;
|
||||
} else if (this.posX >= maxX) {
|
||||
this.posX = maxX;
|
||||
this.velocityX = -Math.abs(this.velocityX);
|
||||
atBoundary = true;
|
||||
}
|
||||
|
||||
if (this.posY <= 0) {
|
||||
this.posY = 0;
|
||||
this.velocityY = Math.abs(this.velocityY);
|
||||
atBoundary = true;
|
||||
} else if (this.posY >= maxY) {
|
||||
this.posY = maxY;
|
||||
this.velocityY = -Math.abs(this.velocityY);
|
||||
atBoundary = true;
|
||||
}
|
||||
|
||||
// Change color only once per bounce (when entering boundary, not while at it)
|
||||
if (atBoundary && !this.hasBounced) {
|
||||
this.hasBounced = true;
|
||||
this.colorIndex = (this.colorIndex + 1) % colors.length;
|
||||
this.currentColor = colors[this.colorIndex];
|
||||
} else if (!atBoundary) {
|
||||
this.hasBounced = false;
|
||||
}
|
||||
|
||||
// Direct DOM manipulation for smooth position updates (no re-render)
|
||||
if (this.timeContainerEl) {
|
||||
this.timeContainerEl.style.transform = `translate(${this.posX}px, ${this.posY}px)`;
|
||||
}
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
private stopAnimation(): void {
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
this.animationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClick(): void {
|
||||
this.dispatchEvent(new CustomEvent('screensaver-click'));
|
||||
// Optionally hide on click
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user