diff --git a/src/components/AppGrid.ts b/src/components/AppGrid.ts index 30c8ba7..c2da182 100644 --- a/src/components/AppGrid.ts +++ b/src/components/AppGrid.ts @@ -14,26 +14,52 @@ export class AppGrid extends LitElement { name: string; }[] = []; + override connectedCallback() { + super.connectedCallback(); + this.style.setProperty('--icons-count', this.apps.length.toString()); + } + static override styles = [ css` :host { display: grid; grid-template-columns: 80px; justify-content: space-around; - gap: 20px; - padding: 30px; + gap: 28px; + padding: 42px 30px; margin-top: 10px; - + --stagger-delay: calc(var(--animation-duration) * 2 / var(--icons-count)); + @media (min-width: 500px) { grid-template-columns: repeat(2, 80px); + grid-auto-rows: minmax(120px, auto); } @media (min-width: 768px) { grid-template-columns: repeat(3, 80px); + gap: 32px 42px; } @media (min-width: 1024px) { grid-template-columns: repeat(4, 80px); + grid-auto-flow: dense; + } + } + + arx-app-icon { + opacity: 0; + transform: translateY(20px) scale(0.8); + animation: float-in var(--transition) forwards; + } + + @keyframes float-in { + 0% { + opacity: 0; + transform: translateY(20px) scale(0.8); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); } } `, @@ -43,12 +69,13 @@ export class AppGrid extends LitElement { return html` ${map( this.apps, - (app) => html` + (app, index) => html` `, )} diff --git a/src/components/AppIcon.ts b/src/components/AppIcon.ts index 30f6990..ee32067 100644 --- a/src/components/AppIcon.ts +++ b/src/components/AppIcon.ts @@ -24,37 +24,44 @@ export class AppIcon extends LitElement { css` :host { display: flex; - --animation-speed: 0.25s; + --shadow-opacity: 0.2; + --hover-lift: -6px; + --tap-scale: 0.92; } .app-icon { display: flex; flex-direction: column; align-items: center; - gap: 8px; + gap: 10px; text-decoration: none; - padding: 6px; + padding: 8px; border-radius: 18px; - transition: transform var(--animation-speed) - cubic-bezier(0.175, 0.885, 0.32, 1.275); + transition: transform var(--transition); + position: relative; + will-change: transform; + perspective: 800px; } .app-icon:hover { - transform: translateY(-2px); + transform: translateY(var(--hover-lift)); } .icon { width: 96px; height: 96px; border-radius: 24px; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12); + box-shadow: 0 3px 8px rgba(0, 0, 0, var(--shadow-opacity)); display: flex; justify-content: center; align-items: center; border: 1px solid rgba(255, 255, 255, 0.2); overflow: hidden; position: relative; - transition: all var(--animation-speed) ease-out; + transition: + transform var(--overshoot-transition), + box-shadow var(--overshoot-transition); + transform-style: preserve-3d; &::before { content: ""; position: absolute; @@ -66,7 +73,7 @@ export class AppIcon extends LitElement { ); pointer-events: none; opacity: 1; - transition: opacity var(--animation-speed) ease-out; + transition: opacity var(--transition); } &::after { @@ -74,13 +81,14 @@ export class AppIcon extends LitElement { position: absolute; inset: 0; background: linear-gradient( - var(--gradient-angle), - rgba(255, 255, 255, 0.25) 20%, - rgba(255, 255, 255, 0) 60% + var(--gradient-angle, 145deg), + rgba(255, 255, 255, 0.35) 10%, + rgba(255, 255, 255, 0) 70% ); pointer-events: none; opacity: 0; - transition: opacity var(--animation-speed) ease-out; + transition: opacity var(--undershoot-transition); + z-index: 1; } &:hover::before { @@ -95,14 +103,17 @@ export class AppIcon extends LitElement { .icon-svg { color: white; filter: drop-shadow( - calc(cos(var(--gradient-angle)) * 2px) - calc(sin(var(--gradient-angle)) * 2px) rgba(0, 0, 0, 0.2) + calc(cos(var(--gradient-angle, 145deg)) * 3px) + calc(sin(var(--gradient-angle, 145deg)) * 3px) + rgba(0, 0, 0, 0.25) ); display: flex; justify-content: center; align-items: center; width: 100%; height: 100%; + transition: transform var(--overshoot-transition); + z-index: 2; } .app-name { @@ -114,16 +125,36 @@ export class AppIcon extends LitElement { color: var(--color-base-content); white-space: nowrap; text-decoration: none; - transition: all var(--animation-speed) ease-out; + transition: all var(--transition); + transform: translateZ(0); + opacity: 0.85; } .app-icon:hover .icon { - transform: scale(1.08); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.25); + transform: scale(1.05) translateZ(10px) rotateX(var(--rotate-x, 0deg)) rotateY(var(--rotate-y, 0deg)); + box-shadow: + 0 10px 20px -5px rgba(0, 0, 0, calc(var(--shadow-opacity) * 1.5)), + 0 0 0 1px rgba(255, 255, 255, 0.15); + } + + .app-icon:hover .app-name { + opacity: 1; + transform: translateY(2px) scale(1.02); + } + + .app-icon:hover .icon-svg { + transform: scale(1.08) translateZ(15px); } .app-icon:active .icon { - transform: scale(0.96); + transform: scale(var(--tap-scale)) translateZ(0); + box-shadow: 0 2px 5px rgba(0, 0, 0, calc(var(--shadow-opacity) * 0.8)); + transition: all var(--transition); + } + + .app-icon:active .app-name { + transform: translateY(0) scale(0.98); + transition: all var(--transition); } `, ]; @@ -132,6 +163,7 @@ export class AppIcon extends LitElement { this.iconElement = this.shadowRoot?.querySelector('.icon') as HTMLElement; if (this.iconElement) { this.iconElement.addEventListener('mousemove', this.handleMouseMove); + this.iconElement.addEventListener('mouseleave', this.handleMouseLeave); } } @@ -139,6 +171,7 @@ export class AppIcon extends LitElement { super.disconnectedCallback(); if (this.iconElement) { this.iconElement.removeEventListener('mousemove', this.handleMouseMove); + this.iconElement.removeEventListener('mouseleave', this.handleMouseLeave); } } @@ -153,7 +186,32 @@ export class AppIcon extends LitElement { const dy = e.clientY - centerY; const angle = Math.atan2(dy, dx) * (180 / Math.PI); + const maxTilt = 20; + const percentX = (e.clientX - rect.left) / rect.width - 0.5; + const percentY = (e.clientY - rect.top) / rect.height - 0.5; + + const tiltX = -percentY * maxTilt * 1.2; + const tiltY = percentX * maxTilt; + this.iconElement.style.setProperty('--gradient-angle', `${angle}deg`); + this.iconElement.style.setProperty('--rotate-x', `${tiltX}deg`); + this.iconElement.style.setProperty('--rotate-y', `${tiltY}deg`); + }; + + handleMouseLeave = () => { + if (!this.iconElement) return; + + this.iconElement.style.transition = 'transform var(--transition)'; + this.iconElement.style.setProperty('--rotate-x', '0deg'); + this.iconElement.style.setProperty('--rotate-y', '0deg'); + + this.iconElement.addEventListener( + 'transitionend', + () => { + this.iconElement!.style.transition = ''; + }, + { once: true }, + ); }; override render() { @@ -162,12 +220,13 @@ export class AppIcon extends LitElement {
${when( this.icon, - () => html``, + () => + html``, )}
${this.name} diff --git a/src/components/Calendar/CalendarEventDialog.ts b/src/components/Calendar/CalendarEventDialog.ts index b2bfcae..e655369 100644 --- a/src/components/Calendar/CalendarEventDialog.ts +++ b/src/components/Calendar/CalendarEventDialog.ts @@ -1,11 +1,13 @@ import { LitElement, css, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; +import { type Ref, createRef, ref } from 'lit/directives/ref.js'; import { when } from 'lit/directives/when.js'; import '@components/General/Button'; import { StyledInput } from '@components/General/Input'; import { StyledTextarea } from '@components/General/Textarea'; import { StyledToggle } from '@components/General/Toggle'; +import type { EveDialog } from '../General/Dialog'; interface InputEvent extends Event { detail: { @@ -18,6 +20,8 @@ export class CalendarEventDialog extends LitElement { @property({ type: Boolean }) open = false; + dialogRef: Ref = createRef(); + @state() private newEvent = { title: '', @@ -49,7 +53,8 @@ export class CalendarEventDialog extends LitElement { `; private handleClose() { - this.dispatchEvent(new CustomEvent('close')); + this.dialogRef.value?.close(false); + this.dispatchEvent(new CustomEvent('close', { bubbles: false })); } private handleInputChange(e: Event) { @@ -77,7 +82,7 @@ export class CalendarEventDialog extends LitElement { if (!this.open) return html``; return html` - + ) { - if (changedProps.has('open') && this.open) { - this._previousFocus = document.activeElement as HTMLElement; - // Focus the dialog container after rendering - setTimeout(() => { - const container = this.shadowRoot?.querySelector('.dialog-container') as HTMLElement; - if (container) container.focus(); - }, 50); - } else if (changedProps.has('open') && !this.open && this._previousFocus) { - this._previousFocus.focus(); + if (changedProps.has('open')) { + if (this.open) this.show(); + else this.close(); } } @@ -129,24 +139,36 @@ export class EveDialog extends LitElement { } private _handleOverlayClick(e: MouseEvent) { - if (this.closeOnOverlayClick && e.target === e.currentTarget) { - this.close(); - } + if (this.closeOnOverlayClick && e.target === e.currentTarget) this.close(); } show() { this.open = true; + this.style.display = 'block'; + this.previousScrollTop = this.rootWindow?.scrollTop ?? 0; + this.style.height = `${this.rootWindow!.clientHeight * 2}px`; // this is a hack to prevent the overlay background from showing through + this.rootWindow?.style.setProperty('overflow', 'hidden', 'important'); + this.rootWindow?.scrollTo({ + top: 0, + behavior: 'smooth', + }); } - close() { + close(triggerEvent = true) { this.open = false; - this.dispatchEvent(new CustomEvent('close')); + this.style.display = 'none'; + this.rootWindow?.style.setProperty('overflow', 'auto', 'important'); + this.rootWindow?.scrollTo({ + top: this.previousScrollTop, + behavior: 'smooth', + }); + if (triggerEvent) this.dispatchEvent(new CustomEvent('close')); } override render() { return html`
diff --git a/src/routes/Settings.ts b/src/routes/Settings.ts index ab245d3..eb146c9 100644 --- a/src/routes/Settings.ts +++ b/src/routes/Settings.ts @@ -62,6 +62,7 @@ export class EveSettings extends LitElement { @state() private profile: NDKUserProfile | undefined; @state() private error: string | undefined; @state() private darkMode = false; + @state() private pageTransitions = true; @state() private relayStatus: { running: boolean; pid: number | null; logs: string[] } = { running: false, pid: null, @@ -77,6 +78,7 @@ export class EveSettings extends LitElement { try { this.profile = await getUserProfile(); this.darkMode = localStorage.getItem('darkMode') === 'true'; + this.pageTransitions = localStorage.getItem('pageTransitions') !== 'false'; this.updateRelayStatus(); this.loading = false; } catch (err) { @@ -117,6 +119,12 @@ export class EveSettings extends LitElement { document.body.classList.toggle('dark', this.darkMode); } + private togglePageTransitions() { + this.pageTransitions = !this.pageTransitions; + localStorage.setItem('pageTransitions', this.pageTransitions.toString()); + location.reload(); + } + private reset() { if (!confirm('Are you sure you want to reset the app?')) return; localStorage.clear(); @@ -133,12 +141,17 @@ export class EveSettings extends LitElement { return html` - + this.toggleDarkMode()} > + this.togglePageTransitions()} + > ${when( diff --git a/src/routes/router.ts b/src/routes/router.ts index 82cd92c..facd729 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -14,6 +14,8 @@ import { spread } from '@open-wc/lit-helpers'; import { LitElement, css } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { keyed } from 'lit/directives/keyed.js'; +import { type Ref, createRef, ref } from 'lit/directives/ref.js'; +import { when } from 'lit/directives/when.js'; import { type StaticValue, html, literal } from 'lit/static-html.js'; export interface RouteParams { @@ -24,7 +26,6 @@ interface Route { pattern: string; params: RouteParams; component: StaticValue; - // component: typeof LitElement | ((params: RouteParams) => typeof LitElement); title?: string; meta?: Record; } @@ -90,9 +91,15 @@ export default class EveRouter extends LitElement { @state() private currentIndex = -1; + @state() + private isTransitioning = false; + @property() public ccnSetup = false; + windowContentRef: Ref = createRef(); + pageTransitions = true; + private beforeEachGuards: ((to: Route, from: Route | null) => boolean)[] = []; private afterEachHooks: ((to: Route, from: Route | null) => void)[] = []; @@ -135,6 +142,33 @@ export default class EveRouter extends LitElement { height: 100%; margin: 0 auto; padding: 1rem; + opacity: 1; + transform-origin: left center; + transform: perspective(1200px) translateX(0); + transition: var(--transition); + backface-visibility: hidden; + filter: blur(0px); + } + + .window-content::after { + content: ''; + position: absolute; + top: 0; + right: 0; + width: 4px; + height: 100%; + opacity: 0; + transition: var(--transition); + } + + .window-content.transitioning { + overflow: hidden; + transform: perspective(1200px) translateX(50vw); + filter: blur(50px); + } + + .window-content.transitioning::after { + opacity: 1; } .hide-overflow { @@ -145,6 +179,7 @@ export default class EveRouter extends LitElement { constructor() { super(); this.initializeRouter(); + this.pageTransitions = localStorage.getItem('pageTransitions') !== 'false'; if (this.ccnSetup) window.relay.start(localStorage.getItem('encryption_key')!); } @@ -171,11 +206,11 @@ export default class EveRouter extends LitElement { window.addEventListener('popstate', this.handlePopState.bind(this)); } - private handleHashChange(): void { + private async handleHashChange(): Promise { const newPath = this.currentPath; if (newPath !== this.history[this.currentIndex]) { + await this.requestUpdateWithTransition(); this.updateHistory(newPath); - this.requestUpdate(); } } @@ -252,7 +287,7 @@ export default class EveRouter extends LitElement { const canProceed = this.beforeEachGuards.every((guard) => guard(to, from)); if (canProceed) { - this.requestUpdate(); + await this.requestUpdateWithTransition(); for (const hook of this.afterEachHooks) { hook(to, from); } @@ -263,6 +298,25 @@ export default class EveRouter extends LitElement { } } + async requestUpdateWithTransition(): Promise { + if (!this.windowContentRef.value) return this.requestUpdate(); + if (!this.pageTransitions) return this.requestUpdate(); + this.isTransitioning = true; + this.requestUpdate(); + + await new Promise((resolve) => { + this.windowContentRef.value!.addEventListener( + 'transitionend', + () => { + this.isTransitioning = false; + resolve(true); + }, + { once: true }, + ); + }); + this.requestUpdate(); + } + goBack(): void { if (this.currentIndex > 0) { this.currentIndex--; @@ -306,10 +360,14 @@ export default class EveRouter extends LitElement { title="Eve" >
-
- ${keyed( - this.currentRoute.params, - html` +
+ ${when( + this.isTransitioning, + () => html``, + () => + keyed( + this.currentRoute.params, + html` <${this.currentRoute.component} ${spread(this.currentRoute.params)} path=${this.currentPath} @@ -318,6 +376,7 @@ export default class EveRouter extends LitElement { @go-forward=${this.goForward} > `, + ), )}
diff --git a/src/style.css b/src/style.css index cee2999..a66b6de 100644 --- a/src/style.css +++ b/src/style.css @@ -6,8 +6,13 @@ --space-sm: clamp(1rem, 1.5vw, 1.5rem); --space-md: clamp(2rem, 3vw, 3rem); - --animation-curve: cubic-bezier(0.68, -0.55, 0.265, 1.55); - --transition: 0.3s var(--animation-curve); + --undershoot-curve: cubic-bezier(0.38, 0, 0.618, 0.88); + --animation-curve: cubic-bezier(0.18, 0, 0.618, 1); + --overshoot-curve: cubic-bezier(0.38, 0, 0.618, 1.12); + --animation-duration: 275ms; + --transition: var(--animation-duration) var(--animation-curve); + --overshoot-transition: calc(var(--animation-duration) * 1.2) var(--overshoot-curve); + --undershoot-transition: calc(var(--animation-duration) * 0.8) var(--undershoot-curve); --color-base-100: oklch(98% 0.016 73.684); --color-base-200: oklch(95% 0.038 75.164);