A photo manager for VRChat.
1import { onCleanup, onMount, Show } from "solid-js"; 2import { bytesToFormatted } from "../utils"; 3import { invoke } from '@tauri-apps/api/core'; 4import anime from "animejs"; 5import { ViewState } from "./Managers/ViewManager"; 6 7let SettingsMenu = () => { 8 let sliderBar: HTMLElement; 9 let settingsContainer: HTMLElement; 10 let currentButton = 0; 11 let lastClickedButton = -1; 12 let finalPathConfirm: HTMLElement; 13 let finalPathInput: HTMLElement; 14 let finalPathData: string; 15 let finalPathPreviousData: string; 16 17 let closeWithKey = ( e: KeyboardEvent ) => { 18 if(e.key === 'Escape'){ 19 window.ViewManager.ChangeState(ViewState.PHOTO_LIST); 20 anime({ 21 targets: '.settings', 22 opacity: 0, 23 translateX: '500px', 24 easing: 'easeInOutQuad', 25 duration: 250, 26 complete: () => { 27 anime.set('.settings', { display: 'none' }); 28 } 29 }) 30 } 31 } 32 33 onMount(async () => { 34 if(await invoke('get_config_value_string', { key: 'transparent' }) === "true"){ 35 invoke('set_config_value_string', { key: 'transparent', value: 'true' }); 36 37 anime({ targets: document.body, background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 }); 38 anime({ targets: '.settings', background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 }); 39 } else{ 40 invoke('set_config_value_string', { key: 'transparent', value: 'false' }); 41 42 anime({ targets: document.body, background: 'rgba(0, 0, 0, 1)', easing: 'linear', duration: 100 }); 43 anime({ targets: '.settings', background: 'rgba(0, 0, 0, 0)', easing: 'linear', duration: 100 }); 44 } 45 46 let sliderMouseDown = false; 47 let mouseStartX = 0; 48 49 let width = window.innerWidth; 50 let buttons = [ 370, 680 ]; 51 52 let sliderPos = width / 2 - buttons[currentButton]; 53 let sliderScale = width / (buttons[1] - buttons[0]); 54 55 let render = () => { 56 requestAnimationFrame(render); 57 58 if(!sliderMouseDown){ 59 sliderPos = sliderPos + (width / 2 - buttons[currentButton] - sliderPos) * 0.25; 60 anime.set(sliderBar, { translateX: sliderPos }); 61 62 settingsContainer.style.left = (sliderPos - (width / 2 - buttons[0])) * sliderScale + 'px'; 63 } 64 } 65 66 render(); 67 anime.set(sliderBar, { translateX: sliderPos }); 68 69 sliderBar.addEventListener('touchstart', ( e: TouchEvent ) => { 70 sliderMouseDown = true; 71 mouseStartX = e.touches[0].clientX; 72 }) 73 74 window.addEventListener('touchmove', ( e: TouchEvent ) => { 75 if(sliderMouseDown){ 76 anime.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.touches[0].clientX) }); 77 settingsContainer.style.left = (sliderPos - (mouseStartX - e.touches[0].clientX) - (width / 2 - buttons[0])) * sliderScale + 'px'; 78 } 79 }) 80 81 window.addEventListener('keyup', closeWithKey); 82 83 window.addEventListener('touchend', ( e: TouchEvent ) => { 84 if(sliderMouseDown){ 85 sliderPos = sliderPos - (mouseStartX - e.touches[0].clientX); 86 87 anime.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.touches[0].clientX) }); 88 sliderMouseDown = false; 89 90 if(Math.abs(mouseStartX - e.touches[0].clientX) > 50){ 91 let shortestDistance = 0; 92 let selectedButton = -1; 93 94 buttons.forEach(( pos, indx ) => { 95 let dis = Math.abs(sliderPos - (width / 2 - pos)); 96 97 if(selectedButton === -1){ 98 shortestDistance = dis; 99 selectedButton = indx; 100 } else if(shortestDistance > dis){ 101 shortestDistance = dis; 102 selectedButton = indx; 103 } 104 }) 105 106 currentButton = selectedButton; 107 } else if(lastClickedButton != -1){ 108 currentButton = lastClickedButton; 109 lastClickedButton = -1 110 } 111 } 112 }) 113 114 sliderBar.addEventListener('mousedown', ( e: MouseEvent ) => { 115 sliderMouseDown = true; 116 mouseStartX = e.clientX; 117 }); 118 119 window.addEventListener('mousemove', ( e: MouseEvent ) => { 120 if(sliderMouseDown){ 121 anime.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.clientX) }); 122 settingsContainer.style.left = sliderPos - (mouseStartX - e.clientX) + 'px'; 123 settingsContainer.style.left = (sliderPos - (mouseStartX - e.clientX) - (width / 2 - buttons[0])) * sliderScale + 'px'; 124 } 125 }) 126 127 window.addEventListener('mouseup', ( e: MouseEvent ) => { 128 if(sliderMouseDown){ 129 sliderPos = sliderPos - (mouseStartX - e.clientX); 130 131 anime.set(sliderBar, { translateX: sliderPos - (mouseStartX - e.clientX) }); 132 sliderMouseDown = false; 133 134 if(Math.abs(mouseStartX - e.clientX) > 50){ 135 let shortestDistance = 0; 136 let selectedButton = -1; 137 138 buttons.forEach(( pos, indx ) => { 139 let dis = Math.abs(sliderPos - (width / 2 - pos)); 140 141 if(selectedButton === -1){ 142 shortestDistance = dis; 143 selectedButton = indx; 144 } else if(shortestDistance > dis){ 145 shortestDistance = dis; 146 selectedButton = indx; 147 } 148 }) 149 150 currentButton = selectedButton; 151 } else if(lastClickedButton != -1){ 152 currentButton = lastClickedButton; 153 lastClickedButton = -1 154 } 155 } 156 }) 157 158 window.addEventListener('resize', () => { 159 width = window.innerWidth; 160 sliderPos = width / 2 - buttons[currentButton]; 161 sliderScale = width / (buttons[1] - buttons[0]); 162 163 anime.set(sliderBar, { translateX: sliderPos }); 164 }) 165 166 sliderBar.addEventListener('wheel', ( e: WheelEvent ) => { 167 if(e.deltaY > 0){ 168 if(buttons[currentButton + 1]) 169 currentButton++; 170 } else{ 171 if(buttons[currentButton - 1]) 172 currentButton--; 173 } 174 }) 175 }) 176 177 onCleanup(() => { 178 window.removeEventListener('keyup', closeWithKey); 179 }) 180 181 return ( 182 <div class="settings"> 183 <div class="settings-container" ref={( el ) => settingsContainer = el}> 184 <div class="settings-block"> 185 <h1>Storage Settings</h1> 186 <p>{ window.PhotoManager.PhotoCount() } Photos ({ bytesToFormatted(window.PhotoManager.PhotoSize(), 0) })</p> 187 188 <div class="selector"> 189 <input type="checkbox" id="start-in-bg-check" ref={async ( el ) => { 190 el.checked = await invoke('get_config_value_string', { key: 'start-in-bg' }) === "true" ? true : false; 191 }} onChange={( el ) => { 192 if(el.target.checked){ 193 invoke('set_config_value_string', { key: 'start-in-bg', value: 'true' }); 194 } else{ 195 invoke('set_config_value_string', { key: 'start-in-bg', value: 'false' }); 196 } 197 }} /> 198 Start in background 199 200 <label for="start-in-bg-check"> 201 <div class="selection-box"> 202 <div class="icon" style={{ width: '10px', margin: '0', display: 'inline-flex' }}> 203 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img> 204 </div> 205 </div> 206 </label> 207 </div> 208 209 <div class="selector"> 210 <input type="checkbox" id="start-with-win-check" ref={async ( el ) => { 211 el.checked = await invoke('get_config_value_string', { key: 'start-with-win' }) === "true" ? true : false; 212 }} onChange={( el ) => { 213 if(el.target.checked){ 214 invoke('set_config_value_string', { key: 'start-with-win', value: 'true' }); 215 invoke("start_with_win", { start: true }); 216 } else{ 217 invoke('set_config_value_string', { key: 'start-with-win', value: 'false' }); 218 invoke("start_with_win", { start: false }); 219 } 220 }} /> 221 Start with windows 222 223 <label for="start-with-win-check"> 224 <div class="selection-box"> 225 <div class="icon" style={{ width: '10px', margin: '0', display: 'inline-flex' }}> 226 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img> 227 </div> 228 </div> 229 </label> 230 </div> 231 232 <div class="selector"> 233 <input type="checkbox" id="transparent-check" ref={async ( el ) => { 234 el.checked = await invoke('get_config_value_string', { key: 'transparent' }) === "true" ? true : false; 235 }} onChange={( el ) => { 236 if(el.target.checked){ 237 invoke('set_config_value_string', { key: 'transparent', value: 'true' }); 238 239 anime({ targets: document.body, background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 }); 240 anime({ targets: '.settings', background: 'rgba(0, 0, 0, 0.5)', easing: 'linear', duration: 100 }); 241 } else{ 242 invoke('set_config_value_string', { key: 'transparent', value: 'false' }); 243 244 anime({ targets: document.body, background: 'rgba(0, 0, 0, 1)', easing: 'linear', duration: 100 }); 245 anime({ targets: '.settings', background: 'rgba(0, 0, 0, 0)', easing: 'linear', duration: 100 }); 246 } 247 }} /> 248 Window Transparency 249 250 <label for="transparent-check"> 251 <div class="selection-box"> 252 <div class="icon" style={{ width: '10px', margin: '0', display: 'inline-flex' }}> 253 <img draggable="false" width="10" height="10" src="/icon/check-solid.svg"></img> 254 </div> 255 </div> 256 </label> 257 </div> 258 259 <br /> 260 <p> 261 VRChat Photo Path: 262 <span class="path" ref={( el ) => 263 invoke('get_user_photos_path').then(( path: any ) => { 264 el.innerHTML = ''; 265 el.appendChild(<span style={{ outline: 'none' }} ref={( el ) => finalPathInput = el} onInput={( el ) => { 266 finalPathConfirm.style.display = 'inline-block'; 267 finalPathData = el.target.innerHTML; 268 }} contenteditable>{path}</span> as Node); 269 270 finalPathPreviousData = path; 271 }) 272 }> 273 Loading... 274 </span> 275 <span style={{ display: 'none' }} ref={( el ) => finalPathConfirm = el}> 276 <span class="path" style={{ color: 'green' }} onClick={async () => { 277 finalPathPreviousData = finalPathData; 278 finalPathConfirm.style.display = 'none'; 279 280 await invoke('change_final_path', { newPath: finalPathData }); 281 await invoke('relaunch'); 282 283 anime({ 284 targets: '.settings', 285 opacity: 0, 286 translateX: '500px', 287 easing: 'easeInOutQuad', 288 duration: 250, 289 complete: () => { 290 anime.set('.settings', { display: 'none' }); 291 } 292 }) 293 294 window.location.reload(); 295 }}> 296 Save 297 </span> 298 299 <span class="path" style={{ color: 'red' }} onClick={() => { 300 finalPathData = finalPathPreviousData; 301 finalPathInput.innerHTML = finalPathPreviousData; 302 finalPathConfirm.style.display = 'none'; 303 }}> 304 Cancel 305 </span> 306 </span><br /><br /> 307 308 VRCPM Version: <span ref={( el ) => invoke('get_version').then((ver: any) => el.innerHTML = ver)}>Loading...</span> 309 </p> 310 311 <br /> 312 <p>To change the directory VRChat outputs photos to, you can change the "picture_output_folder" key in the <span style={{ color: '#00ccff', cursor: 'pointer' }} onClick={() => invoke('open_url', { url: 'https://docs.vrchat.com/docs/configuration-file#camera-and-screenshot-settings' })}>config.json file</span><br />Alternitavely, you can use VRCX to edit the config file.</p> 313 314 <br /> 315 <p>VRChat Photo Manager supports photos with extra metadata provided by VRCX.</p> 316 </div> 317 <div class="settings-block"> 318 <h1>Account Settings</h1> 319 320 <Show when={window.AccountManager.hasAccount()} fallback={ 321 <div> 322 You aren't logged in. To enable cloud sync and sharing features you need to login to your PhazeID.<br /><br /> 323 <div class="button" onClick={() => { 324 window.AccountManager.login(); 325 }}>Login</div> 326 </div> 327 }> 328 <div class="account-profile"> 329 <div class="account-pfp" style={{ background: `url('https://cdn.phazed.xyz/id/avatars/${window.AccountManager.Profile()?.id}/${window.AccountManager.Profile()?.avatar}.png')` }}></div> 330 <div class="account-desc"> 331 <div class="reload-photos" onClick={() => window.AccountManager.Refresh()} style={{ opacity: 1 }}> 332 <div class="icon" style={{ width: '17px' }}> 333 <img draggable="false" width="17" height="17" src="/icon/arrows-rotate-solid.svg"></img> 334 </div> 335 </div> 336 <h2>{ window.AccountManager.Profile()?.username }</h2> 337 338 <Show when={window.AccountManager.Storage()?.isSyncing}> 339 <div class="storage-bar"> 340 <div class="storage-bar-inner" style={{ width: ((window.AccountManager.Storage()!.used / window.AccountManager.Storage()!.total) * 100) + '%' }}></div> 341 </div> 342 343 <div> 344 { bytesToFormatted(window.AccountManager.Storage()!.used, 0) } / { bytesToFormatted(window.AccountManager.Storage()!.total, 0) }<br /><br /> 345 346 <span style={{ 'font-size': '10px' }}>Server Version: { window.AccountManager.Profile()?.serverVersion }</span> 347 </div> 348 </Show> 349 </div> 350 </div> 351 352 <div class="account-notice">To enable cloud storage or get more storage please contact "_phaz" on discord</div> 353 354 <div class="account-notice" style={{ display: 'flex' }}> 355 <Show when={false} fallback={ "We are deleting your photos, please leave this window open while we delete them." }> 356 <div class="button-danger" onClick={() => window.ConfirmationBoxManager.SetConfirmationBox("You are about to delete all your photos from the cloud, and disable syncing. This will NOT delete any local files.", async () => { 357 // TODO: Rework all of this 358 359 // props.setStorageInfo({ used: 0, storage: 0, sync: false }); 360 // setDeletingPhotos(true); 361 362 // fetch('https://photos-cdn.phazed.xyz/api/v1/allphotos', { 363 // method: 'DELETE', 364 // headers: { auth: (await invoke('get_config_value_string', { key: 'token' }))! } 365 // }) 366 // .then(data => data.json()) 367 // .then(data => { 368 // console.log(data); 369 // setDeletingPhotos(false); 370 // }) 371 })}>Delete All Photos.</div> <div>This deletes all photos stored in the cloud and disables syncing.</div> 372 </Show> 373 </div> 374 </Show> 375 </div> 376 </div> 377 378 <div class="slide-bar-tri"></div> 379 <div class="slide-bar"> 380 <div class="inner-slide-bar" ref={( el ) => sliderBar = el}> 381 <div class="slider-dot"></div> 382 <div class="slider-dot"></div> 383 <div class="slider-dot"></div> 384 <div class="slider-dot"></div> 385 <div class="slider-dot"></div> 386 <div class="slider-text" onMouseDown={() => lastClickedButton = 0}>Program Settings</div> 387 <div class="slider-dot"></div> 388 <div class="slider-dot"></div> 389 <div class="slider-text" onMouseDown={() => lastClickedButton = 1}>Account Settings</div> 390 <div class="slider-dot"></div> 391 <div class="slider-dot"></div> 392 <div class="slider-dot"></div> 393 <div class="slider-dot"></div> 394 <div class="slider-dot"></div> 395 </div> 396 </div> 397 </div> 398 ) 399} 400 401export default SettingsMenu;