A photo manager for VRChat.

support prints, closes #6

Changed files
+77 -38
public
src
+1
public/icon/user-solid.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l388.6 0c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304l-91.4 0z"/></svg>
+4 -4
src/Components/Managers/PhotoListRenderingManager.tsx
··· 93 93 // and then render that text 94 94 95 95 // === DEBUG === 96 - // ctx.strokeStyle = '#f00'; 97 - // ctx.strokeRect(0, currentY - scroll, canvas.width, row.Height); 96 + ctx.strokeStyle = '#f00'; 97 + ctx.strokeRect(0, currentY - scroll, canvas.width, row.Height); 98 98 99 99 ctx.textAlign = 'center'; 100 100 ctx.textBaseline = 'middle'; ··· 108 108 let photo = (el as PhotoListPhoto).Photo; 109 109 110 110 // === DEBUG === 111 - // ctx.strokeStyle = '#f00'; 112 - // ctx.strokeRect((rowXPos - row.Width / 2) + canvas.width / 2, currentY - scroll, photo.scaledWidth!, row.Height); 111 + ctx.strokeStyle = '#f00'; 112 + ctx.strokeRect((rowXPos - row.Width / 2) + canvas.width / 2, currentY - scroll, photo.scaledWidth!, row.Height); 113 113 114 114 if(!photo.loaded) 115 115 // If the photo is not loaded, start a new task and load it in that task
+71 -33
src/Components/PhotoViewer.tsx
··· 23 23 let allowedToOpenTray = false; 24 24 let trayInAnimation = false; 25 25 26 + let authorProfileButton: HTMLDivElement; 27 + 26 28 let switchPhotoWithKey = ( e: KeyboardEvent ) => { 27 29 switch(e.key){ 28 30 case 'Escape': ··· 240 242 if(photo.metadata){ 241 243 photo.onMetaLoaded = () => {} 242 244 243 - let meta = JSON.parse(photo.metadata); 245 + try{ 246 + // Try JSON format ( VRCX ) 247 + let meta = JSON.parse(photo.metadata); 244 248 245 - allowedToOpenTray = true; 246 - trayButton.style.display = 'flex'; 247 - 248 - photoTray.innerHTML = ''; 249 - photoTray.appendChild( 250 - <div class="photo-tray-columns"> 251 - <div class="photo-tray-column" style={{ width: '20%' }}><br /> 252 - <div class="tray-heading">People</div> 253 - 254 - <For each={meta.players}> 255 - {( item ) => 256 - <div> 257 - { item.displayName } 258 - <Show when={item.id}> 259 - <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/user/' + item.id })} style={{ "margin-left": '10px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /> 260 - </Show> 261 - </div> 262 - } 263 - </For><br /> 264 - </div> 265 - <div class="photo-tray-column"><br /> 266 - <div class="tray-heading">World</div> 267 - 268 - <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 269 - </div> 270 - </div> as Node 271 - ); 249 + allowedToOpenTray = true; 250 + trayButton.style.display = 'flex'; 272 251 273 - window.WorldCacheManager.getWorldById(meta.world.id) 274 - .then(worldData => { 275 - if(worldData) 276 - loadWorldData(worldData); 277 - }); 252 + authorProfileButton!.style.display = 'none'; 253 + 254 + photoTray.innerHTML = ''; 255 + photoTray.appendChild( 256 + <div class="photo-tray-columns"> 257 + <div class="photo-tray-column" style={{ width: '20%' }}><br /> 258 + <div class="tray-heading">People</div> 259 + 260 + <For each={meta.players}> 261 + {( item ) => 262 + <div> 263 + { item.displayName } 264 + <Show when={item.id}> 265 + <img width="15" src="/icon/up-right-from-square-solid.svg" onClick={() => invoke('open_url', { url: 'https://vrchat.com/home/user/' + item.id })} style={{ "margin-left": '10px', "font-size": '12px', 'color': '#bbb', cursor: 'pointer' }} /> 266 + </Show> 267 + </div> 268 + } 269 + </For><br /> 270 + </div> 271 + <div class="photo-tray-column"><br /> 272 + <div class="tray-heading">World</div> 273 + 274 + <div ref={( el ) => worldInfoContainer = el}>Loading World Data...</div> 275 + </div> 276 + </div> as Node 277 + ); 278 + 279 + window.WorldCacheManager.getWorldById(meta.world.id) 280 + .then(worldData => { 281 + if(worldData) 282 + loadWorldData(worldData); 283 + }); 284 + } catch(e){ 285 + try{ 286 + // Not json lets try XML (vrc prints) 287 + let parser = new DOMParser(); 288 + let doc = parser.parseFromString(photo.metadata, "text/xml"); 289 + 290 + let id = doc.getElementsByTagName('xmp:Author')[0]!.innerHTML; 291 + 292 + authorProfileButton!.style.display = 'flex'; 293 + authorProfileButton!.onclick = () => 294 + invoke('open_url', { url: 'https://vrchat.com/home/user/' + id }); 295 + } catch(e){ 296 + console.error(e); 297 + console.log('Couldn\'t decode metadata') 298 + 299 + authorProfileButton!.style.display = 'none'; 300 + } 301 + 302 + trayButton.style.display = 'none'; 303 + closeTray(); 304 + } 278 305 } else{ 279 306 trayButton.style.display = 'none'; 280 307 closeTray(); ··· 428 455 <img draggable="false" src="/icon/angle-up-solid.svg"></img> 429 456 </div> 430 457 </div> 458 + 459 + <div class="viewer-button" 460 + ref={authorProfileButton!} 461 + onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 462 + onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 463 + > 464 + <div class="icon" style={{ width: '12px', margin: '0' }}> 465 + <img draggable="false" src="/icon/user-solid.svg"></img> 466 + </div> 467 + </div> 468 + 431 469 <div class="viewer-button" 432 470 onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 433 471 onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })}
+1 -1
vite.config.ts
··· 11 11 12 12 // https://vitejs.dev/config/ 13 13 export default defineConfig(async () => ({ 14 - plugins: [solid(), fullReloadAlways], 14 + plugins: [solid(),], //fullReloadAlways], 15 15 16 16 // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` 17 17 //