A photo manager for VRChat.

PHOTO TRAY!!!!

Changed files
+400 -22
src
src-tauri
+1
src-tauri/Cargo.lock
··· 3675 3675 "notify", 3676 3676 "open 5.0.1", 3677 3677 "regex", 3678 + "reqwest", 3678 3679 "serde", 3679 3680 "serde_json", 3680 3681 "tauri",
+1
src-tauri/Cargo.toml
··· 19 19 dirs = "5.0.1" 20 20 notify = "6.1.1" 21 21 regex = "1.10.3" 22 + reqwest = { version = "0.11", features = ["blocking"] } 22 23 23 24 [features] 24 25 # this feature is used for production builds or when `devPath` points to the filesystem
+17 -1
src-tauri/src/main.rs
··· 1 1 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 2 2 3 3 mod pngmeta; 4 + mod worldscraper; 4 5 5 6 use tauri::{ CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowEvent, http::ResponseBuilder }; 6 7 use core::time; 7 8 use std::{ fs, io::Read, path, thread }; 8 9 use regex::Regex; 9 10 use pngmeta::PNGImage; 11 + use worldscraper::World; 10 12 use notify::{ EventKind, RecursiveMode, Watcher }; 11 13 12 14 #[derive(Clone, serde::Serialize)] ··· 23 25 #[tauri::command] 24 26 fn start_user_auth() { 25 27 open::that("https://id.phazed.xyz?oauth=79959294626406").unwrap(); 28 + } 29 + 30 + #[tauri::command] 31 + fn open_url( url: &str ) { 32 + open::that(url).unwrap(); 33 + } 34 + 35 + // Load vrchat world data 36 + #[tauri::command] 37 + fn find_world_by_id( world_id: String, window: tauri::Window ){ 38 + thread::spawn(move || { 39 + let world = World::new(world_id); 40 + window.emit("world_data", world).unwrap(); 41 + }); 26 42 } 27 43 28 44 // Scans all files under the "Pictures/VRChat" path ··· 271 287 272 288 Ok(()) 273 289 }) 274 - .invoke_handler(tauri::generate_handler![start_user_auth, load_photos, close_splashscreen, load_photo_meta, delete_photo]) 290 + .invoke_handler(tauri::generate_handler![start_user_auth, load_photos, close_splashscreen, load_photo_meta, delete_photo, open_url, find_world_by_id]) 275 291 .run(tauri::generate_context!()) 276 292 .expect("error while running tauri application"); 277 293 }
+107
src-tauri/src/worldscraper.rs
··· 1 + use serde::ser::{ Serialize, SerializeStruct, Serializer }; 2 + use serde_json::json; 3 + 4 + #[derive(Clone)] 5 + pub struct World{ 6 + id: String, 7 + name: String, 8 + author: String, 9 + author_id: String, 10 + desc: String, 11 + img: String, 12 + max_users: u64, 13 + visits: u64, 14 + favourites: u64, 15 + tags: String, 16 + from: String, 17 + from_site: String 18 + } 19 + 20 + impl World{ 21 + pub fn new( world_id: String ) -> World { 22 + println!("Fetching world data for {}", &world_id); 23 + 24 + let mut world = World { 25 + id: "".into(), 26 + name: "".into(), 27 + author: "".into(), 28 + author_id: "".into(), 29 + desc: "".into(), 30 + img: "".into(), 31 + max_users: 0, 32 + visits: 0, 33 + favourites: 0, 34 + tags: "".into(), 35 + from: "https://vrclist.com/worlds/".into(), 36 + from_site: "vrclist.com".into() 37 + }; 38 + 39 + let client = reqwest::blocking::Client::new(); 40 + 41 + let world_id_str = world_id.to_owned(); 42 + let fixed_id_req = client.post("https://api.vrclist.com/worlds/id-convert") 43 + .header("Content-Type", "application/json") 44 + .header("User-Agent", "VRChat-Photo-Manager-Rust/0.0.1") 45 + .body(json!({ "world_id": world_id_str }).to_string()) 46 + .send() 47 + .unwrap() 48 + .text() 49 + .unwrap(); 50 + 51 + let fixed_id: serde_json::Value = serde_json::from_str(&fixed_id_req).unwrap(); 52 + world.from = format!("https://vrclist.com/worlds/{}", fixed_id["id"].to_string()); 53 + 54 + let world_data: serde_json::Value = client.post("https://api.vrclist.com/worlds/single") 55 + .header("Content-Type", "application/json") 56 + .header("User-Agent", "VRChat-Photo-Manager-Rust/0.0.1") 57 + .body(json!({ "id": fixed_id["id"].to_string() }).to_string()) 58 + .send() 59 + .unwrap() 60 + .json() 61 + .unwrap(); 62 + 63 + world.id = world_id.clone(); 64 + world.name = world_data["name"].to_string(); 65 + world.author = world_data["authorName"].to_string(); 66 + world.author_id = world_data["authorId"].to_string(); 67 + world.desc = world_data["description"].to_string(); 68 + world.img = world_data["imageUrl"].to_string(); 69 + world.tags = world_data["tags"].to_string(); 70 + 71 + match world_data["vrchat_visits"].as_u64() { 72 + Some(visits) => { world.visits = visits }, 73 + None => {} 74 + } 75 + 76 + match world_data["capacity"].as_u64() { 77 + Some(cap) => { world.max_users = cap; }, 78 + None => {} 79 + } 80 + 81 + println!("Fetched world data for {}", &world_id); 82 + world 83 + } 84 + } 85 + 86 + impl Serialize for World{ 87 + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> 88 + where 89 + S: Serializer, 90 + { 91 + let mut s = serializer.serialize_struct("World", 7)?; 92 + s.serialize_field("id", &self.id)?; 93 + s.serialize_field("name", &self.name)?; 94 + s.serialize_field("author", &self.author)?; 95 + s.serialize_field("authorId", &self.author_id)?; 96 + s.serialize_field("desc", &self.desc)?; 97 + s.serialize_field("img", &self.img)?; 98 + s.serialize_field("maxUsers", &self.max_users)?; 99 + s.serialize_field("visits", &self.visits)?; 100 + s.serialize_field("favourites", &self.favourites)?; 101 + s.serialize_field("tags", &self.tags)?; 102 + s.serialize_field("from", &self.from)?; 103 + s.serialize_field("fromSite", &self.from_site)?; 104 + 105 + s.end() 106 + } 107 + }
+2 -2
src/Components/PhotoList.tsx
··· 5 5 import anime from "animejs"; 6 6 7 7 const PHOTO_HEIGHT = 200; 8 - const MAX_IMAGE_LOAD = 1; 8 + const MAX_IMAGE_LOAD = 3; 9 9 10 - let months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]; 10 + let months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ]; 11 11 12 12 class PhotoListProps{ 13 13 setCurrentPhotoView!: ( view: any ) => any;
+190 -18
src/Components/PhotoViewer.tsx
··· 1 - import { createEffect, onMount } from "solid-js"; 1 + import { For, Show, createEffect, onMount } from "solid-js"; 2 2 import { invoke } from '@tauri-apps/api/tauri'; 3 + import { listen } from '@tauri-apps/api/event'; 3 4 import anime from 'animejs'; 4 5 5 6 class PhotoViewerProps{ ··· 9 10 setConfirmationBox!: ( text: string, cb: () => void ) => void; 10 11 } 11 12 13 + class WorldCache{ 14 + expiresOn!: number; 15 + worldData!: { 16 + id: string, 17 + name: string, 18 + author: string, 19 + authorId: string, 20 + desc: string, 21 + img: string, 22 + maxUsers: number, 23 + visits: number, 24 + favourites: number, 25 + tags: any, 26 + from: string, 27 + fromSite: string 28 + } 29 + } 30 + 31 + let worldCache: WorldCache[] = JSON.parse(localStorage.getItem('worldCache') || "[]"); 32 + 12 33 let PhotoViewer = ( props: PhotoViewerProps ) => { 13 34 let viewer: HTMLElement; 14 35 let imageViewer: HTMLElement; 15 36 let isOpen = false; 37 + let trayOpen = false; 38 + 39 + let trayButton: HTMLElement; 40 + 41 + let photoTray: HTMLElement; 42 + let photoControls: HTMLElement; 43 + let photoTrayCloseBtn: HTMLElement; 44 + 45 + let worldInfoContainer: HTMLElement; 46 + 47 + let openTray = () => { 48 + if(trayOpen)return; 49 + trayOpen = true; 50 + 51 + anime({ targets: photoTray, bottom: '0px', duration: 500 }); 52 + 53 + anime({ 54 + targets: photoControls, 55 + bottom: '160px', 56 + scale: '0.75', 57 + opacity: 0, 58 + duration: 500, 59 + complete: () => { 60 + photoControls.style.display = 'none'; 61 + } 62 + }); 63 + 64 + photoTrayCloseBtn.style.display = 'flex'; 65 + anime({ 66 + targets: photoTrayCloseBtn, 67 + bottom: '160px', 68 + opacity: 1, 69 + scale: 1, 70 + duration: 500 71 + }) 72 + } 73 + 74 + let closeTray = () => { 75 + if(!trayOpen)return; 76 + 77 + anime({ targets: photoTray, bottom: '-150px', duration: 500 }); 78 + 79 + anime({ 80 + targets: photoTrayCloseBtn, 81 + bottom: '10px', 82 + scale: '0.75', 83 + opacity: 0, 84 + duration: 500, 85 + complete: () => { 86 + photoTrayCloseBtn.style.display = 'none'; 87 + trayOpen = false; 88 + } 89 + }); 90 + 91 + photoControls.style.display = 'flex'; 92 + anime({ 93 + targets: photoControls, 94 + bottom: '10px', 95 + opacity: 1, 96 + scale: 1, 97 + duration: 500, 98 + }) 99 + } 16 100 17 101 onMount(() => { 102 + anime.set(photoControls, { translateX: '-50%' }); 103 + anime.set(photoTrayCloseBtn, { translateX: '-50%', opacity: 0, scale: '0.75', bottom: '10px' }); 104 + 18 105 createEffect(() => { 19 106 let photo = props.currentPhotoView(); 20 107 ··· 30 117 duration: 150, 31 118 easing: 'easeInOutQuad' 32 119 }) 120 + 121 + if(photo.metadata){ 122 + let meta = JSON.parse(photo.metadata); 123 + 124 + let worldData = worldCache.find(x => x.worldData.id === meta.world.id); 125 + 126 + photoTray.innerHTML = ''; 127 + photoTray.appendChild( 128 + <div class="photo-tray-columns"> 129 + <div class="photo-tray-column" style={{ width: '20%' }}><br /> 130 + <div class="tray-heading">People</div> 131 + 132 + <For each={meta.players}> 133 + {( item ) => 134 + <div> 135 + { item.displayName } 136 + <Show when={item.id}> 137 + <i onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/user/' + item.id })} style={{ "margin-left": '10px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} class="fa-solid fa-arrow-up-right-from-square"></i> 138 + </Show> 139 + </div> 140 + } 141 + </For><br /> 142 + </div> 143 + <div class="photo-tray-column"><br /> 144 + <div class="tray-heading">World</div> 145 + 146 + <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 147 + </div> 148 + </div> as Node 149 + ); 150 + 151 + if(!worldData) 152 + invoke('find_world_by_id', { worldId: meta.world.id }); 153 + else if(worldData.expiresOn < Date.now()) 154 + invoke('find_world_by_id', { worldId: meta.world.id }); 155 + else 156 + loadWorldData(worldData); 157 + 158 + trayButton.style.display = 'flex'; 159 + } else{ 160 + trayButton.style.display = 'none'; 161 + } 33 162 } 34 163 35 164 if(photo && !isOpen){ ··· 80 209 }) 81 210 }) 82 211 212 + let loadWorldData = ( data: WorldCache ) => { 213 + let tags: string[] = JSON.parse(data.worldData.tags.split('\\\\').join("").split('\\').join("").slice(1, -1)); 214 + 215 + worldInfoContainer.innerHTML = ''; 216 + worldInfoContainer.appendChild( 217 + <div> 218 + <div class="world-name">{ data.worldData.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> 219 + <div style={{ width: '75%', margin: 'auto' }}>{ data.worldData.desc }</div> 220 + 221 + <br /> 222 + <div class="world-tags"> 223 + <For each={tags}> 224 + {( tag ) => 225 + <div>{ tag.replace("author_tag_", "").replace("system_", "") }</div> 226 + } 227 + </For> 228 + </div> 229 + </div> as Node 230 + ) 231 + } 232 + 233 + listen('world_data', ( event: any ) => { 234 + let worldData = { 235 + expiresOn: Date.now() + 1.2096E+09, 236 + worldData: { 237 + id: event.payload.id, 238 + name: event.payload.name.split('\\').join('').slice(1, -1), 239 + author: event.payload.author.split('\\').join('').slice(1, -1), 240 + authorId: event.payload.authorId.split('\\').join('').slice(1, -1), 241 + desc: event.payload.desc.split('\\').join('').slice(1, -1), 242 + img: event.payload.img.split('\\').join('').slice(1, -1), 243 + maxUsers: event.payload.maxUsers, 244 + visits: event.payload.visits, 245 + favourites: event.payload.favourites, 246 + tags: event.payload.tags, 247 + from: event.payload.from, 248 + fromSite: event.payload.fromSite 249 + } 250 + } 251 + 252 + loadWorldData(worldData); 253 + 254 + worldCache.push(worldData); 255 + localStorage.setItem("worldCache", JSON.stringify(worldCache)); 256 + }) 257 + 83 258 return ( 84 259 <div class="photo-viewer" ref={( el ) => viewer = el}> 85 260 <div class="viewer-close viewer-button" onClick={() => props.setCurrentPhotoView(null)}><i class="fa-solid fa-x"></i></div> ··· 88 263 <div class="prev-button" onClick={() => props.setPhotoNavChoice('prev')}><i class="fa-solid fa-arrow-left"></i></div> 89 264 <div class="next-button" onClick={() => props.setPhotoNavChoice('next')}><i class="fa-solid fa-arrow-right"></i></div> 90 265 91 - <div class="control-buttons"> 266 + <div class="photo-tray" ref={( el ) => photoTray = el}></div> 267 + 268 + <div class="photo-tray-close" 269 + onClick={() => closeTray()} 270 + ref={( el ) => photoTrayCloseBtn = el} 271 + ><i class="fa-solid fa-angle-down"></i></div> 272 + 273 + <div class="control-buttons" ref={( el ) => photoControls = el}> 92 274 <div class="viewer-button" 93 275 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 94 276 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} ··· 129 311 > 130 312 <i class="fa-solid fa-copy"></i> 131 313 </div> 132 - <div class="viewer-button" 133 - onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 134 - onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 135 - > 136 - <i class="fa-solid fa-info"></i> 137 - </div> 138 - <div class="viewer-button" 139 - onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 140 - onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 141 - > 142 - <i class="fa-solid fa-users"></i> 143 - </div> 144 - <div class="viewer-button" 145 - onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 146 - onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 314 + <div class="viewer-button" style={{ width: '50px' }} 315 + onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '70px', height: '30px', 'margin-left': '10px', 'margin-right': '10px' })} 316 + onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '50px', height: '30px', 'margin-left': '20px', 'margin-right': '20px' })} 317 + ref={( el ) => trayButton = el} 318 + onClick={() => openTray()} 147 319 > 148 - <i class="fa-solid fa-file"></i> 320 + <i class="fa-solid fa-angle-up"></i> 149 321 </div> 150 322 <div class="viewer-button" 151 323 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })}
+82 -1
src/styles.css
··· 190 190 display: flex; 191 191 justify-content: center; 192 192 align-items: center; 193 - border-radius: 50%; 193 + border-radius: 50px; 194 194 font-size: 12px; 195 195 background: #8885; 196 196 backdrop-filter: blur(10px); ··· 356 356 z-index: 12; 357 357 opacity: 0; 358 358 pointer-events: none; 359 + } 360 + 361 + .photo-tray{ 362 + position: fixed; 363 + bottom: -150px; 364 + left: 0; 365 + width: 100%; 366 + height: 150px; 367 + background: #7778; 368 + backdrop-filter: blur(10px); 369 + box-shadow: #0008 0 0 10px; 370 + padding-bottom: 150px; 371 + margin-bottom: -150px; 372 + } 373 + 374 + .photo-tray-close{ 375 + position: fixed; 376 + bottom: 160px; 377 + left: 50%; 378 + transform: translate(-50%); 379 + color: white; 380 + background: #8885; 381 + backdrop-filter: blur(10px); 382 + box-shadow: #0008 0 0 10px; 383 + display: flex; 384 + justify-content: center; 385 + align-items: center; 386 + height: 30px; 387 + width: 50px; 388 + border-radius: 50px; 389 + cursor: pointer; 390 + font-size: 12px; 391 + user-select: none; 392 + transition: 0.25s width; 393 + } 394 + 395 + .photo-tray-close:hover{ 396 + width: 70px; 397 + } 398 + 399 + .photo-tray-columns{ 400 + width: 100%; 401 + height: 100%; 402 + display: flex; 403 + color: white; 404 + text-align: center; 405 + } 406 + 407 + .photo-tray-column{ 408 + height: 100%; 409 + width: 100%; 410 + scrollbar-width: thin; 411 + overflow-y: auto; 412 + overflow-x: hidden; 413 + mask-image: linear-gradient(to bottom, #0000 0%, #000 10%, #000 90%, #0000 100%); 414 + } 415 + 416 + .tray-heading{ 417 + font-weight: bold; 418 + font-size: 20px; 419 + } 420 + 421 + .world-tags{ 422 + display: flex; 423 + width: 100%; 424 + justify-content: center; 425 + align-items: center; 426 + } 427 + 428 + .world-tags div{ 429 + padding: 0 10px; 430 + color: #bbb; 431 + transition: 0.25s; 432 + } 433 + 434 + .world-tags div:hover{ 435 + color: #ddd; 436 + } 437 + 438 + .world-name{ 439 + font-size: 17px; 359 440 }