320 lines
8.2 KiB
TypeScript
320 lines
8.2 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|