A photo manager for VRChat.

photo syncing is "semi-stable"

+2 -2
src-tauri/src/main.rs
··· 100 100 101 101 // On requested sync the photos to the cloud 102 102 #[tauri::command] 103 - fn sync_photos( token: String ){ 103 + fn sync_photos( token: String, window: tauri::Window ){ 104 104 thread::spawn(move || { 105 - photosync::sync_photos(token, get_photo_path()); 105 + photosync::sync_photos(token, get_photo_path(), window); 106 106 }); 107 107 } 108 108
+108 -20
src-tauri/src/photosync.rs
··· 1 - use std::{ path, /*fs*/ }; 2 - // use regex::Regex; 1 + use std::{ fs, path, time::Duration }; 2 + use regex::Regex; 3 + use reqwest::{ self, Error }; 4 + use serde::Serialize; 5 + use serde_json::Value; 6 + use tauri::Manager; 3 7 4 - pub fn sync_photos( _token: String, _path: path::PathBuf ){ 5 - // match fs::metadata(&path){ 6 - // Ok(_) => {} 7 - // Err(_) => { 8 - // fs::create_dir(&path).unwrap(); 9 - // } 10 - // }; 8 + #[derive(Clone, Serialize)] 9 + struct PhotoUploadMeta{ 10 + photos_uploading: usize, 11 + photos_total: usize 12 + } 11 13 12 - // let mut photos: Vec<path::PathBuf> = Vec::new(); 13 - // let mut size: usize = 0; 14 + pub fn sync_photos( token: String, path: path::PathBuf, window: tauri::Window ){ 15 + match fs::metadata(&path){ 16 + Ok(_) => {} 17 + Err(_) => { 18 + fs::create_dir(&path).unwrap(); 19 + } 20 + }; 14 21 15 - // for folder in fs::read_dir(&path).unwrap() { 16 - // let f = folder.unwrap(); 22 + let mut photos: Vec<String> = Vec::new(); 17 23 18 - // if f.metadata().unwrap().is_dir() { 19 - // for photo in fs::read_dir(f.path()).unwrap() { 20 - // let p = photo.unwrap(); 24 + for folder in fs::read_dir(&path).unwrap() { 25 + let f = folder.unwrap(); 21 26 22 - // dbg!(p.file_name()); 23 - // } 24 - // } 25 - // } 27 + if f.metadata().unwrap().is_dir() { 28 + for photo in fs::read_dir(f.path()).unwrap() { 29 + let p = photo.unwrap(); 30 + 31 + let re1 = Regex::new(r"(?m)VRChat_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}.[0-9]{3}_[0-9]{4}x[0-9]{4}.png").unwrap(); 32 + let re2 = Regex::new( 33 + r"(?m)/VRChat_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}.[0-9]{3}_[0-9]{4}x[0-9]{4}_wrld_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}.png/gm").unwrap(); 34 + 35 + if 36 + re1.is_match(p.file_name().to_str().unwrap()) || 37 + re2.is_match(p.file_name().to_str().unwrap()) 38 + { 39 + photos.push(p.file_name().into_string().unwrap()); 40 + } 41 + } 42 + } 43 + } 44 + 45 + let body: Value = reqwest::blocking::get(format!("https://photos.phazed.xyz/api/v1/photos/exists?token={}", &token)).unwrap() 46 + .json().unwrap(); 47 + 48 + let mut photos_to_upload: Vec<String> = Vec::new(); 49 + let uploaded_photos = body["files"].as_array().unwrap(); 50 + 51 + let photos_len = photos.len(); 52 + 53 + for photo in photos{ 54 + let mut found_photo = false; 55 + 56 + for uploaded_photo in uploaded_photos{ 57 + if photo == uploaded_photo.as_str().unwrap(){ 58 + found_photo = true; 59 + break; 60 + } 61 + } 62 + 63 + if !found_photo { 64 + photos_to_upload.push(photo); 65 + } 66 + } 67 + 68 + window.emit_all("photos-upload-meta", PhotoUploadMeta { photos_uploading: photos_to_upload.len(), photos_total: photos_len }).unwrap(); 69 + let mut photos_left = photos_to_upload.len(); 70 + 71 + let client = reqwest::blocking::Client::new(); 72 + 73 + loop { 74 + match photos_to_upload.pop(){ 75 + Some(photo) => { 76 + let folder_name = photo.clone().replace("VRChat_", ""); 77 + let mut folder_name = folder_name.split("-"); 78 + let folder_name = format!("{}-{}", folder_name.nth(0).unwrap(), folder_name.nth(0).unwrap()); 79 + 80 + let full_path = format!("{}\\{}\\{}", path.to_str().unwrap(), folder_name, photo); 81 + let file = fs::File::open(full_path).unwrap(); 82 + 83 + let res: Result<Value, Error> = client.put(format!("https://photos.phazed.xyz/api/v1/photos?token={}", &token)) 84 + .header("Content-Type", "image/png") 85 + .header("filename", photo) 86 + .body(file) 87 + .timeout(Duration::from_secs(120)) 88 + .send().unwrap().json(); 89 + 90 + match res { 91 + Ok(res) => { 92 + if !res["ok"].as_bool().unwrap(){ 93 + println!("Failed to upload: {}", res["error"].as_str().unwrap()); 94 + window.emit_all("sync-failed", res["error"].as_str().unwrap()).unwrap(); 95 + break; 96 + } 97 + } 98 + Err(err) => { 99 + dbg!(err); 100 + } 101 + } 102 + 103 + photos_left -= 1; 104 + window.emit_all("photos-upload-meta", PhotoUploadMeta { photos_uploading: photos_left, photos_total: photos_len }).unwrap(); 105 + } 106 + None => { 107 + break; 108 + } 109 + } 110 + } 111 + 112 + window.emit_all("sync-finished", "h").unwrap(); 113 + println!("Finished Uploading."); 26 114 }
+19 -4
src/Components/App.tsx
··· 28 28 29 29 let [ requestPhotoReload, setRequestPhotoReload ] = createSignal(false); 30 30 31 + let isPhotosSyncing = false; 32 + 31 33 let setConfirmationBox = ( text: string, cb: () => void ) => { 32 34 setConfirmationBoxText(text); 33 35 confirmationBoxCallback = cb; ··· 48 50 setLoggedIn({ loggedIn: true, username: data.data.user.username, avatar: data.data.user.avatar, id: data.data.user._id, serverVersion: data.data.user.serverVersion }); 49 51 setStorageInfo({ storage: data.data.user.storage, used: data.data.user.used, sync: data.data.user.settings.enableSync }); 50 52 51 - invoke('sync_photos', { token: localStorage.getItem('token') }); 53 + if(!isPhotosSyncing){ 54 + isPhotosSyncing = true; 55 + invoke('sync_photos', { token: localStorage.getItem('token') }); 56 + } 52 57 }) 53 58 .catch(e => { 54 59 console.error(e); ··· 129 134 setLoggedIn({ loggedIn: true, username: data.data.user.username, avatar: data.data.user.avatar, id: data.data.user._id, serverVersion: data.data.user.serverVersion }); 130 135 setStorageInfo({ storage: data.data.user.storage, used: data.data.user.used, sync: data.data.user.settings.enableSync }); 131 136 132 - invoke('sync_photos', { token: localStorage.getItem('token') }); 137 + if(!isPhotosSyncing){ 138 + isPhotosSyncing = true; 139 + invoke('sync_photos', { token: localStorage.getItem('token') }); 140 + } 133 141 }) 134 142 .catch(e => { 135 143 setLoadingType('none'); ··· 142 150 console.warn('Authetication Denied'); 143 151 }) 144 152 153 + listen('sync-finished', () => { 154 + isPhotosSyncing = false; 155 + }) 156 + 145 157 onMount(() => { 146 158 anime.set('.settings', 147 159 { ··· 153 165 154 166 return ( 155 167 <div class="container"> 156 - <NavBar setLoadingType={setLoadingType} loggedIn={loggedIn} /> 168 + <NavBar setLoadingType={setLoadingType} loggedIn={loggedIn} setStorageInfo={setStorageInfo} /> 157 169 <PhotoList 170 + isPhotosSyncing={isPhotosSyncing} 158 171 setCurrentPhotoView={setCurrentPhotoView} 159 172 currentPhotoView={currentPhotoView} 160 173 photoNavChoice={photoNavChoice} ··· 177 190 photoSize={photoSize} 178 191 setRequestPhotoReload={setRequestPhotoReload} 179 192 loggedIn={loggedIn} 180 - storageInfo={storageInfo} /> 193 + storageInfo={storageInfo} 194 + setStorageInfo={setStorageInfo} 195 + setConfirmationBox={setConfirmationBox} /> 181 196 182 197 <div class="copy-notif">Image Copied!</div> 183 198
+52 -1
src/Components/NavBar.tsx
··· 1 1 import { invoke } from '@tauri-apps/api/tauri'; 2 + import { listen } from '@tauri-apps/api/event'; 3 + import { fetch, ResponseType } from "@tauri-apps/api/http" 2 4 import anime from 'animejs'; 3 - import { Show, onMount } from 'solid-js'; 5 + import { Show, createSignal, onMount } from 'solid-js'; 4 6 5 7 class NavBarProps{ 6 8 setLoadingType!: ( type: string ) => string; 7 9 loggedIn!: () => { loggedIn: boolean, username: string, avatar: string, id: string, serverVersion: string }; 10 + setStorageInfo!: ( info: { storage: number, used: number, sync: boolean } ) => { storage: number, used: number, sync: boolean }; 8 11 } 9 12 10 13 let NavBar = ( props: NavBarProps ) => { ··· 12 15 let inAnimation = false; 13 16 let dropdown: HTMLElement; 14 17 18 + let [ isSyncing, setIsSyncing ] = createSignal(false); 19 + let [ syncPhotoTotal, setSyncPhotoTotal ] = createSignal(0); 20 + let [ syncPhotoUploading, setSyncPhotoUploading ] = createSignal(0); 21 + let [ syncError, setSyncError ] = createSignal(""); 22 + 15 23 onMount(() => { 16 24 anime.set(dropdown, { opacity: 0, translateX: -10 }); 17 25 dropdown.style.display = 'none'; 18 26 }) 19 27 28 + listen('photos-upload-meta', ( e: any ) => { 29 + setIsSyncing(true); 30 + setSyncPhotoTotal(e.payload.photos_total); 31 + setSyncPhotoUploading(e.payload.photos_total - e.payload.photos_uploading); 32 + 33 + console.log(e.payload) 34 + }) 35 + 20 36 let setDropdownVisibility = ( visible: boolean ) => { 21 37 if(inAnimation)return; 22 38 ··· 53 69 } 54 70 } 55 71 72 + 73 + listen('sync-failed', ( e: any ) => { 74 + setSyncError(e.payload); 75 + }) 76 + 56 77 window.CloseAllPopups.push(() => setDropdownVisibility(false)); 57 78 58 79 return ( ··· 73 94 }) 74 95 }}>Photos</div> 75 96 </div> 97 + <div class="nav-tab" style={{ width: '200px', "text-align": 'center' }}> 98 + <Show when={isSyncing()}> 99 + <Show when={ syncError() == "" } fallback={ "Error: " + syncError() }> 100 + <div style={{ width: '100%', "text-align": 'center', 'font-size': '14px' }}> 101 + Uploading: { syncPhotoUploading() } / { syncPhotoTotal() }<br /> 102 + <div style={{ width: '80%', height: '2px', margin: 'auto', "margin-top": '5px', background: '#111' }}> 103 + <div style={{ height: '2px', width: (syncPhotoUploading() / syncPhotoTotal()) * 100 + '%', background: '#00ccff' }}></div> 104 + </div> 105 + </div> 106 + </Show> 107 + </Show> 108 + </div> 76 109 <div class="account" onClick={() => setDropdownVisibility(!dropdownVisible)}> 77 110 <Show when={props.loggedIn().loggedIn}> 78 111 <div class="user-pfp" style={{ background: `url('https://cdn.phazed.xyz/id/avatars/${props.loggedIn().id}/${props.loggedIn().avatar}.png')` }}></div> ··· 92 125 easing: 'easeInOutQuad', 93 126 duration: 250 94 127 }) 128 + 129 + fetch<any>('https://photos.phazed.xyz/api/v1/account', { 130 + method: 'GET', 131 + headers: { auth: localStorage.getItem('token')! }, 132 + responseType: ResponseType.JSON 133 + }) 134 + .then(data => { 135 + if(!data.data.ok){ 136 + console.error(data); 137 + return; 138 + } 139 + 140 + console.log(data.data); 141 + props.setStorageInfo({ storage: data.data.user.storage, used: data.data.user.used, sync: data.data.user.settings.enableSync }); 142 + }) 143 + .catch(e => { 144 + console.error(e); 145 + }) 95 146 96 147 setDropdownVisibility(false); 97 148 }}>Settings</div>
+6
src/Components/PhotoList.tsx
··· 20 20 requestPhotoReload!: () => boolean; 21 21 setRequestPhotoReload!: ( val: boolean ) => boolean; 22 22 loggedIn!: () => { loggedIn: boolean, username: string, avatar: string, id: string, serverVersion: string }; 23 + isPhotosSyncing!: boolean; 23 24 } 24 25 25 26 let PhotoList = ( props: PhotoListProps ) => { ··· 310 311 listen('photo_create', ( event: any ) => { 311 312 let photo = new Photo(event.payload); 312 313 photos.splice(0, 0, photo); 314 + 315 + if(!props.isPhotosSyncing){ 316 + props.isPhotosSyncing = true; 317 + invoke('sync_photos', { token: localStorage.getItem('token') }); 318 + } 313 319 }) 314 320 315 321 listen('photo_remove', ( event: any ) => {
+9 -6
src/Components/PhotoViewer.tsx
··· 127 127 128 128 if(photo.metadata){ 129 129 let meta = JSON.parse(photo.metadata); 130 - 131 130 let worldData = worldCache.find(x => x.worldData.id === meta.world.id); 132 131 133 132 photoTray.innerHTML = ''; ··· 157 156 158 157 if(!worldData) 159 158 invoke('find_world_by_id', { worldId: meta.world.id }); 160 - else if(worldData.expiresOn < Date.now()) 159 + else if(worldData.expiresOn < Date.now()){ 160 + worldCache = worldCache.filter(x => x !== worldData) 161 161 invoke('find_world_by_id', { worldId: meta.world.id }); 162 - else 162 + } else 163 163 loadWorldData(worldData); 164 164 165 165 trayButton.style.display = 'flex'; ··· 217 217 }) 218 218 219 219 let loadWorldData = ( data: WorldCache ) => { 220 + let meta = props.currentPhotoView().metadata; 221 + if(!meta)return; 222 + 220 223 worldInfoContainer.innerHTML = ''; 221 224 worldInfoContainer.appendChild( 222 225 <div> 223 - <Show when={ data.worldData.found == false && props.currentPhotoView().metadata }> 226 + <Show when={ data.worldData.found == false && meta }> 224 227 <div> 225 - <div class="world-name">{ JSON.parse(props.currentPhotoView().metadata).world.name } <i onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} class="fa-solid fa-arrow-up-right-from-square"></i></div> 228 + <div class="world-name">{ JSON.parse(meta).world.name } <i onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/world/' + data.worldData.id })} style={{ "margin-left": '0px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} class="fa-solid fa-arrow-up-right-from-square"></i></div> 226 229 <div style={{ width: '75%', margin: 'auto' }}>Could not fetch world information... Is the world private?</div> 227 230 </div> 228 231 </Show> ··· 237 240 <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div> 238 241 } 239 242 </For> 240 - </div> 243 + </div><br /> 241 244 </Show> 242 245 </div> as Node 243 246 )
+28
src/Components/SettingsMenu.tsx
··· 3 3 import { relaunch } from '@tauri-apps/api/process'; 4 4 import { invoke } from '@tauri-apps/api/tauri'; 5 5 import anime from "animejs"; 6 + import { fetch, ResponseType } from "@tauri-apps/api/http" 6 7 7 8 class SettingsMenuProps{ 8 9 photoCount!: () => number; ··· 10 11 setRequestPhotoReload!: ( val: boolean ) => boolean; 11 12 loggedIn!: () => { loggedIn: boolean, username: string, avatar: string, id: string, serverVersion: string }; 12 13 storageInfo!: () => { storage: number, used: number, sync: boolean }; 14 + setStorageInfo!: ( info: { storage: number, used: number, sync: boolean } ) => { storage: number, used: number, sync: boolean }; 15 + setConfirmationBox!: ( text: string, cb: () => void ) => void; 13 16 } 14 17 15 18 let SettingsMenu = ( props: SettingsMenuProps ) => { ··· 152 155 }) 153 156 }) 154 157 158 + let refreshAccount = () => { 159 + fetch<any>('https://photos.phazed.xyz/api/v1/account', { 160 + method: 'GET', 161 + headers: { auth: localStorage.getItem('token')! }, 162 + responseType: ResponseType.JSON 163 + }) 164 + .then(data => { 165 + if(!data.data.ok){ 166 + console.error(data); 167 + return; 168 + } 169 + 170 + console.log(data.data); 171 + props.setStorageInfo({ storage: data.data.user.storage, used: data.data.user.used, sync: data.data.user.settings.enableSync }); 172 + }) 173 + .catch(e => { 174 + console.error(e); 175 + }) 176 + } 177 + 155 178 return ( 156 179 <div class="settings"> 157 180 <div class="settings-container" ref={( el ) => settingsContainer = el}> ··· 258 281 <div class="account-profile"> 259 282 <div class="account-pfp" style={{ background: `url('https://cdn.phazed.xyz/id/avatars/${props.loggedIn().id}/${props.loggedIn().avatar}.png')` }}></div> 260 283 <div class="account-desc"> 284 + <div class="reload-photos" onClick={() => refreshAccount()}><i class="fa-solid fa-arrows-rotate"></i></div> 261 285 <h2>{ props.loggedIn().username }</h2> 262 286 263 287 <Show when={props.storageInfo().sync}> ··· 275 299 </div> 276 300 277 301 <div class="account-notice">To enable cloud storage or get more storage please contact "_phaz" on discord</div> 302 + 303 + <div class="account-notice" style={{ display: 'flex' }}> 304 + <div class="button-danger" onClick={() => props.setConfirmationBox("You are about to delete all your photos from the cloud, and disable syncing. This will NOT delete any local files.", () => {})}>Delete All Photos.</div> <div>This deletes all photos stored in the cloud and disables syncing.</div> 305 + </div> 278 306 </Show> 279 307 </div> 280 308 </div>
+1 -1
src/styles.css
··· 40 40 } 41 41 42 42 .navbar .tabs{ 43 - width: calc(100% - 100px); 43 + width: calc(100% - 200px); 44 44 height: 100%; 45 45 display: flex; 46 46 }