@@ -60,3 +60,201 @@ function formatNumber(num, locale) {
6060 return formatted ;
6161 }
6262}
63+
64+ class StartupPopupConfetti {
65+ constructor ( canvas ) {
66+ this . canvas = canvas ;
67+ this . context = canvas . getContext ( '2d' ) ;
68+ this . prefersReducedMotion = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
69+ this . animationFrameId = null ;
70+ this . confettiPieces = [ ] ;
71+ this . viewportWidth = window . innerWidth ;
72+ this . viewportHeight = window . innerHeight ;
73+ this . confettiColors = [ '#ef4444' , '#dc2626' , '#f97316' , '#f59e0b' , '#fde047' , '#3b82f6' , '#2563eb' , '#60a5fa' ] ;
74+ this . confettiCount = Math . min ( Math . max ( Math . floor ( this . viewportWidth / 6 ) , 80 ) , 220 ) ;
75+ this . handleResize = this . resizeCanvas . bind ( this ) ;
76+ this . renderFrame = this . renderFrame . bind ( this ) ;
77+ }
78+
79+ randomBetween ( min , max ) {
80+ return Math . random ( ) * ( max - min ) + min ;
81+ }
82+
83+ createConfettiPiece ( withinViewport ) {
84+ return {
85+ x : this . randomBetween ( 0 , this . viewportWidth ) ,
86+ y : withinViewport ? this . randomBetween ( - this . viewportHeight , this . viewportHeight ) : this . randomBetween ( - this . viewportHeight * 0.3 , - 20 ) ,
87+ size : this . randomBetween ( 6 , 13 ) ,
88+ speedY : this . randomBetween ( 1.2 , 3.6 ) ,
89+ drift : this . randomBetween ( - 1.2 , 1.2 ) ,
90+ rotation : this . randomBetween ( 0 , Math . PI * 2 ) ,
91+ rotationSpeed : this . randomBetween ( - 0.08 , 0.08 ) ,
92+ color : this . confettiColors [ Math . floor ( Math . random ( ) * this . confettiColors . length ) ] ,
93+ } ;
94+ }
95+
96+ resizeCanvas ( ) {
97+ this . viewportWidth = window . innerWidth ;
98+ this . viewportHeight = window . innerHeight ;
99+ const dpr = window . devicePixelRatio || 1 ;
100+ this . canvas . width = Math . floor ( this . viewportWidth * dpr ) ;
101+ this . canvas . height = Math . floor ( this . viewportHeight * dpr ) ;
102+ this . canvas . style . width = `${ this . viewportWidth } px` ;
103+ this . canvas . style . height = `${ this . viewportHeight } px` ;
104+ if ( this . context ) {
105+ this . context . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 ) ;
106+ }
107+ }
108+
109+ renderFrame ( ) {
110+ if ( ! this . context ) {
111+ return ;
112+ }
113+ this . context . clearRect ( 0 , 0 , this . viewportWidth , this . viewportHeight ) ;
114+
115+ for ( let i = 0 ; i < this . confettiPieces . length ; i += 1 ) {
116+ const piece = this . confettiPieces [ i ] ;
117+ piece . y += piece . speedY ;
118+ piece . x += piece . drift + Math . sin ( ( piece . y + piece . rotation ) * 0.01 ) * 0.7 ;
119+ piece . rotation += piece . rotationSpeed ;
120+
121+ if ( piece . y > this . viewportHeight + 24 || piece . x < - 24 || piece . x > this . viewportWidth + 24 ) {
122+ this . confettiPieces [ i ] = this . createConfettiPiece ( false ) ;
123+ }
124+ }
125+
126+ for ( const piece of this . confettiPieces ) {
127+ this . context . save ( ) ;
128+ this . context . translate ( piece . x , piece . y ) ;
129+ this . context . rotate ( piece . rotation ) ;
130+ this . context . fillStyle = piece . color ;
131+ this . context . fillRect ( - piece . size / 2 , - piece . size / 2 , piece . size , piece . size * 0.6 ) ;
132+ this . context . restore ( ) ;
133+ }
134+
135+ this . animationFrameId = window . requestAnimationFrame ( this . renderFrame ) ;
136+ }
137+
138+ start ( ) {
139+ if ( ! this . context ) {
140+ return ;
141+ }
142+ this . resizeCanvas ( ) ;
143+ this . confettiPieces = Array . from ( { length : this . confettiCount } , ( ) => this . createConfettiPiece ( true ) ) ;
144+ if ( ! this . prefersReducedMotion ) {
145+ this . renderFrame ( ) ;
146+ }
147+ window . addEventListener ( 'resize' , this . handleResize ) ;
148+ }
149+
150+ stop ( ) {
151+ if ( this . animationFrameId ) {
152+ window . cancelAnimationFrame ( this . animationFrameId ) ;
153+ this . animationFrameId = null ;
154+ }
155+ window . removeEventListener ( 'resize' , this . handleResize ) ;
156+ }
157+ }
158+
159+ const STARTUP_POPUP_CONFIG = {
160+ enabled : true ,
161+ showOnlyOnce : false ,
162+ seenStorageKey : 'cryptomator-startup-popup-seen' ,
163+ } ;
164+
165+ ( function setupStartupPopup ( ) {
166+ const popup = document . getElementById ( 'startup-popup' ) ;
167+ if ( ! popup || ! STARTUP_POPUP_CONFIG . enabled ) {
168+ return ;
169+ }
170+
171+ if ( STARTUP_POPUP_CONFIG . showOnlyOnce ) {
172+ try {
173+ const hasSeenPopup = window . localStorage . getItem ( STARTUP_POPUP_CONFIG . seenStorageKey ) === 'true' ;
174+ if ( hasSeenPopup ) {
175+ return ;
176+ }
177+ window . localStorage . setItem ( STARTUP_POPUP_CONFIG . seenStorageKey , 'true' ) ;
178+ } catch {
179+ // localStorage can be unavailable in private browsing modes
180+ }
181+ }
182+
183+ popup . classList . remove ( 'hidden' ) ;
184+ popup . removeAttribute ( 'aria-hidden' ) ;
185+ document . body . classList . add ( 'popup-open' ) ;
186+
187+ const closeButton = document . getElementById ( 'startup-popup-close' ) ;
188+ const canvas = document . getElementById ( 'startup-popup-confetti' ) ;
189+ const countdown = document . getElementById ( 'startup-popup-countdown' ) ;
190+
191+ if ( countdown ) {
192+ const targetTime = new Date ( 2026 , 2 , 9 , 0 , 0 , 0 , 0 ) . getTime ( ) ;
193+ let countdownIntervalId = null ;
194+ const daysElement = countdown . querySelector ( '[data-unit="days"]' ) ;
195+ const hoursElement = countdown . querySelector ( '[data-unit="hours"]' ) ;
196+ const minutesElement = countdown . querySelector ( '[data-unit="minutes"]' ) ;
197+ const secondsElement = countdown . querySelector ( '[data-unit="seconds"]' ) ;
198+
199+ function formatTime ( value ) {
200+ return String ( value ) . padStart ( 2 , '0' ) ;
201+ }
202+
203+ function setCountdownValue ( days , hours , minutes , seconds ) {
204+ if ( daysElement && hoursElement && minutesElement && secondsElement ) {
205+ daysElement . textContent = formatTime ( days ) ;
206+ hoursElement . textContent = formatTime ( hours ) ;
207+ minutesElement . textContent = formatTime ( minutes ) ;
208+ secondsElement . textContent = formatTime ( seconds ) ;
209+ return ;
210+ }
211+
212+ countdown . textContent = `${ formatTime ( days ) } :${ formatTime ( hours ) } :${ formatTime ( minutes ) } :${ formatTime ( seconds ) } ` ;
213+ }
214+
215+ function updateCountdown ( ) {
216+ const remaining = targetTime - Date . now ( ) ;
217+ if ( remaining <= 0 ) {
218+ setCountdownValue ( 0 , 0 , 0 , 0 ) ;
219+ if ( countdownIntervalId ) {
220+ window . clearInterval ( countdownIntervalId ) ;
221+ }
222+ return ;
223+ }
224+
225+ const days = Math . floor ( remaining / ( 24 * 60 * 60 * 1000 ) ) ;
226+ const hours = Math . floor ( ( remaining % ( 24 * 60 * 60 * 1000 ) ) / ( 60 * 60 * 1000 ) ) ;
227+ const minutes = Math . floor ( ( remaining % ( 60 * 60 * 1000 ) ) / ( 60 * 1000 ) ) ;
228+ const seconds = Math . floor ( ( remaining % ( 60 * 1000 ) ) / 1000 ) ;
229+ setCountdownValue ( days , hours , minutes , seconds ) ;
230+ }
231+
232+ updateCountdown ( ) ;
233+ countdownIntervalId = window . setInterval ( updateCountdown , 1000 ) ;
234+ popup . addEventListener ( 'popup:close' , ( ) => {
235+ if ( countdownIntervalId ) {
236+ window . clearInterval ( countdownIntervalId ) ;
237+ }
238+ } , { once : true } ) ;
239+ }
240+
241+ if ( canvas ) {
242+ const confetti = new StartupPopupConfetti ( canvas ) ;
243+ confetti . start ( ) ;
244+
245+ popup . addEventListener ( 'popup:close' , ( ) => {
246+ confetti . stop ( ) ;
247+ } , { once : true } ) ;
248+ }
249+
250+ if ( ! closeButton ) {
251+ return ;
252+ }
253+
254+ closeButton . addEventListener ( 'click' , ( ) => {
255+ popup . classList . add ( 'hidden' ) ;
256+ popup . setAttribute ( 'aria-hidden' , 'true' ) ;
257+ popup . dispatchEvent ( new CustomEvent ( 'popup:close' ) ) ;
258+ document . body . classList . remove ( 'popup-open' ) ;
259+ } , { once : true } ) ;
260+ } ) ( ) ;
0 commit comments