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;