A photo manager for VRChat.

i did something, i forgot what, but it's something

+70
src-tauri/Cargo.lock
··· 753 753 ] 754 754 755 755 [[package]] 756 + name = "fsevent-sys" 757 + version = "4.1.0" 758 + source = "registry+https://github.com/rust-lang/crates.io-index" 759 + checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" 760 + dependencies = [ 761 + "libc", 762 + ] 763 + 764 + [[package]] 756 765 name = "futf" 757 766 version = "0.1.5" 758 767 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1398 1407 ] 1399 1408 1400 1409 [[package]] 1410 + name = "inotify" 1411 + version = "0.9.6" 1412 + source = "registry+https://github.com/rust-lang/crates.io-index" 1413 + checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" 1414 + dependencies = [ 1415 + "bitflags 1.3.2", 1416 + "inotify-sys", 1417 + "libc", 1418 + ] 1419 + 1420 + [[package]] 1421 + name = "inotify-sys" 1422 + version = "0.1.5" 1423 + source = "registry+https://github.com/rust-lang/crates.io-index" 1424 + checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" 1425 + dependencies = [ 1426 + "libc", 1427 + ] 1428 + 1429 + [[package]] 1401 1430 name = "instant" 1402 1431 version = "0.1.12" 1403 1432 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1521 1550 ] 1522 1551 1523 1552 [[package]] 1553 + name = "kqueue" 1554 + version = "1.0.8" 1555 + source = "registry+https://github.com/rust-lang/crates.io-index" 1556 + checksum = "7447f1ca1b7b563588a205fe93dea8df60fd981423a768bc1c0ded35ed147d0c" 1557 + dependencies = [ 1558 + "kqueue-sys", 1559 + "libc", 1560 + ] 1561 + 1562 + [[package]] 1563 + name = "kqueue-sys" 1564 + version = "1.0.4" 1565 + source = "registry+https://github.com/rust-lang/crates.io-index" 1566 + checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" 1567 + dependencies = [ 1568 + "bitflags 1.3.2", 1569 + "libc", 1570 + ] 1571 + 1572 + [[package]] 1524 1573 name = "kuchikiki" 1525 1574 version = "0.8.2" 1526 1575 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1718 1767 checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" 1719 1768 dependencies = [ 1720 1769 "libc", 1770 + "log", 1721 1771 "wasi 0.11.0+wasi-snapshot-preview1", 1722 1772 "windows-sys 0.48.0", 1723 1773 ] ··· 1779 1829 version = "0.1.14" 1780 1830 source = "registry+https://github.com/rust-lang/crates.io-index" 1781 1831 checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" 1832 + 1833 + [[package]] 1834 + name = "notify" 1835 + version = "6.1.1" 1836 + source = "registry+https://github.com/rust-lang/crates.io-index" 1837 + checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" 1838 + dependencies = [ 1839 + "bitflags 2.4.2", 1840 + "crossbeam-channel", 1841 + "filetime", 1842 + "fsevent-sys", 1843 + "inotify", 1844 + "kqueue", 1845 + "libc", 1846 + "log", 1847 + "mio", 1848 + "walkdir", 1849 + "windows-sys 0.48.0", 1850 + ] 1782 1851 1783 1852 [[package]] 1784 1853 name = "nu-ansi-term" ··· 3603 3672 version = "0.0.0" 3604 3673 dependencies = [ 3605 3674 "dirs", 3675 + "notify", 3606 3676 "open 5.0.1", 3607 3677 "regex", 3608 3678 "serde",
+1
src-tauri/Cargo.toml
··· 17 17 open = "5" 18 18 tauri-plugin-deep-link = "0.1.2" 19 19 dirs = "5.0.1" 20 + notify = "6.1.1" 20 21 regex = "1.10.3" 21 22 22 23 [features]
+82 -3
src-tauri/src/main.rs
··· 3 3 mod pngmeta; 4 4 5 5 use tauri::{ CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem, WindowEvent, http::ResponseBuilder }; 6 + use core::time; 6 7 use std::{ fs, io::Read, path, thread }; 7 8 use regex::Regex; 8 9 use pngmeta::PNGImage; 10 + use notify::{ EventKind, RecursiveMode, Watcher }; 9 11 10 12 #[derive(Clone, serde::Serialize)] 11 13 struct PhotoLoadResponse{ ··· 23 25 open::that("https://id.phazed.xyz?oauth=79959294626406").unwrap(); 24 26 } 25 27 28 + // Scans all files under the "Pictures/VRChat" path 29 + // then sends the list of photos to the frontend 26 30 #[tauri::command] 27 31 fn load_photos(window: tauri::Window) { 28 32 thread::spawn(move || { ··· 43 47 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(); 44 48 let re2 = Regex::new( 45 49 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(); 46 - 50 + 47 51 if 48 52 re1.is_match(p.file_name().to_str().unwrap()) || 49 53 re2.is_match(p.file_name().to_str().unwrap()) 50 54 { 51 55 let path = fname.to_path_buf().clone(); 52 56 let path = path.strip_prefix(dirs::home_dir().unwrap().join("Pictures\\VRChat")).unwrap().to_path_buf(); 53 - 57 + 54 58 photos.push(path); 55 59 } 56 60 } ··· 62 66 }); 63 67 } 64 68 69 + // Reads the PNG file and loads the image metadata from it 70 + // then sends the metadata to the frontend, returns width, height, colour depth and so on... more info "pngmeta.rs" 65 71 #[tauri::command] 66 72 fn load_photo_meta( photo: &str, window: tauri::Window ){ 67 73 let photo = photo.to_string(); ··· 78 84 }); 79 85 } 80 86 87 + // Delete a photo when the users confirms the prompt in the ui 88 + #[tauri::command] 89 + fn delete_photo( path: &str ){ 90 + let p = dirs::home_dir().unwrap().join("Pictures\\VRChat").join(path); 91 + fs::remove_file(p).unwrap(); 92 + } 93 + 81 94 fn main() { 82 95 std::env::set_var("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--ignore-gpu-blacklist"); 83 96 84 97 tauri_plugin_deep_link::prepare("uk.phaz.vrcpm"); 85 98 99 + // Setup the tray icon and menu buttons 86 100 let quit = CustomMenuItem::new("quit".to_string(), "Quit"); 87 101 let hide = CustomMenuItem::new("hide".to_string(), "Hide / Show"); 88 102 ··· 93 107 94 108 let tray = SystemTray::new().with_menu(tray_menu); 95 109 110 + // Listen for file updates, store each update in an mpsc channel and send to the frontend 111 + let (sender, receiver) = std::sync::mpsc::channel(); 112 + let mut watcher = notify::recommended_watcher(move | res: Result<notify::Event, notify::Error> | { 113 + match res { 114 + Ok(event) => { 115 + match event.kind{ 116 + EventKind::Remove(_) => { 117 + let path = event.paths.first().unwrap(); 118 + 119 + 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(); 120 + let re2 = 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}_wrld_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}.png/gm").unwrap(); 121 + 122 + if 123 + re1.is_match(path.to_str().unwrap()) || 124 + re2.is_match(path.to_str().unwrap()) 125 + { 126 + sender.send((2, path.clone().strip_prefix(dirs::home_dir().unwrap().join("Pictures\\VRChat")).unwrap().to_path_buf())).unwrap(); 127 + } 128 + }, 129 + EventKind::Create(_) => { 130 + let path = event.paths.first().unwrap(); 131 + 132 + 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(); 133 + let re2 = 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}_wrld_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}.png/gm").unwrap(); 134 + 135 + if 136 + re1.is_match(path.to_str().unwrap()) || 137 + re2.is_match(path.to_str().unwrap()) 138 + { 139 + sender.send((1, path.clone().strip_prefix(dirs::home_dir().unwrap().join("Pictures\\VRChat")).unwrap().to_path_buf())).unwrap(); 140 + } 141 + }, 142 + _ => {} 143 + } 144 + }, 145 + Err(e) => println!("watch error: {:?}", e), 146 + } 147 + }).unwrap(); 148 + 149 + watcher.watch(&dirs::home_dir().unwrap().join("Pictures\\VRChat"), RecursiveMode::Recursive).unwrap(); 150 + 96 151 tauri::Builder::default() 97 152 .system_tray(tray) 98 153 .on_system_tray_event(|app, event| match event { ··· 131 186 _ => {} 132 187 }) 133 188 .register_uri_scheme_protocol("photo", | _app, request | { 189 + // Loads the requested image file, sends data back to the user 134 190 let uri = request.uri(); 135 191 136 192 if request.method() != "GET" { 137 193 return ResponseBuilder::new() 138 194 .status(404) 195 + .header("Access-Control-Allow-Origin", "*") 139 196 .body(Vec::new()); 140 197 } 141 198 ··· 154 211 155 212 ResponseBuilder::new() 156 213 .status(200) 214 + .header("Access-Control-Allow-Origin", "*") 157 215 .body(buffer) 158 216 }, 159 217 Err(_) => { 160 218 ResponseBuilder::new() 161 219 .status(404) 220 + .header("Access-Control-Allow-Origin", "*") 162 221 .body("File Not Found".into()) 163 222 } 164 223 } ··· 166 225 .setup(|app| { 167 226 let handle = app.handle(); 168 227 228 + // Register "deep link" for authentication via vrcpm:// 169 229 tauri_plugin_deep_link::register( 170 230 "vrcpm", 171 231 move | request | { ··· 190 250 } 191 251 ).unwrap(); 192 252 253 + // I hate this approach but i have no clue how else to do this... 254 + // reads the mpsc channel and sends the events to the frontend 255 + let window = app.get_window("main").unwrap(); 256 + thread::spawn(move || { 257 + thread::sleep(time::Duration::from_millis(100)); 258 + 259 + for event in receiver { 260 + match event.0 { 261 + 1 => { 262 + window.emit("photo_create", event.1).unwrap(); 263 + }, 264 + 2 => { 265 + window.emit("photo_remove", event.1).unwrap(); 266 + }, 267 + _ => {} 268 + } 269 + } 270 + }); 271 + 193 272 Ok(()) 194 273 }) 195 - .invoke_handler(tauri::generate_handler![start_user_auth, load_photos, close_splashscreen, load_photo_meta]) 274 + .invoke_handler(tauri::generate_handler![start_user_auth, load_photos, close_splashscreen, load_photo_meta, delete_photo]) 196 275 .run(tauri::generate_context!()) 197 276 .expect("error while running tauri application"); 198 277 }
+39 -2
src/Components/App.tsx
··· 16 16 let [ currentPhotoView, setCurrentPhotoView ] = createSignal<any>(null); 17 17 let [ photoNavChoice, setPhotoNavChoice ] = createSignal<string>(''); 18 18 19 + let [ confirmationBoxText, setConfirmationBoxText ] = createSignal<string>(''); 20 + let confirmationBoxCallback = () => {} 21 + 22 + let setConfirmationBox = ( text: string, cb: () => void ) => { 23 + setConfirmationBoxText(text); 24 + confirmationBoxCallback = cb; 25 + } 26 + 19 27 if(localStorage.getItem('token')){ 20 28 fetch<any>('https://photos.phazed.xyz/api/v1/account', { 21 29 method: 'GET', ··· 41 49 let loadingBlackout: HTMLElement; 42 50 let loadingShown = false; 43 51 52 + let confirmationBox: HTMLElement; 53 + 54 + createEffect(() => { 55 + if(confirmationBoxText() !== ''){ 56 + confirmationBox.style.display = 'block'; 57 + 58 + setTimeout(() => { 59 + confirmationBox.style.opacity = '1'; 60 + }, 1); 61 + } else{ 62 + confirmationBox.style.opacity = '0'; 63 + 64 + setTimeout(() => { 65 + confirmationBox.style.display = 'none'; 66 + }, 250); 67 + } 68 + }) 69 + 44 70 createEffect(() => { 45 71 let type = loadingType(); 46 72 ··· 102 128 return ( 103 129 <div class="container"> 104 130 <NavBar setLoadingType={setLoadingType} loggedIn={loggedIn} /> 105 - <PhotoList setCurrentPhotoView={setCurrentPhotoView} photoNavChoice={photoNavChoice} setPhotoNavChoice={setPhotoNavChoice} /> 106 - <PhotoViewer setPhotoNavChoice={setPhotoNavChoice} currentPhotoView={currentPhotoView} setCurrentPhotoView={setCurrentPhotoView} /> 131 + <PhotoList setCurrentPhotoView={setCurrentPhotoView} currentPhotoView={currentPhotoView} photoNavChoice={photoNavChoice} setPhotoNavChoice={setPhotoNavChoice} setConfirmationBox={setConfirmationBox} /> 132 + <PhotoViewer setPhotoNavChoice={setPhotoNavChoice} currentPhotoView={currentPhotoView} setCurrentPhotoView={setCurrentPhotoView} setConfirmationBox={setConfirmationBox} /> 133 + 134 + <div class="copy-notif">Image Copied!</div> 107 135 108 136 <div class="loading" ref={( el ) => loadingBlackout = el}> 109 137 <Switch> ··· 114 142 <p>Loading App...</p> 115 143 </Match> 116 144 </Switch> 145 + </div> 146 + 147 + <div class="confirmation-box" ref={( el ) => confirmationBox = el}> 148 + <div class="confirmation-box-container"> 149 + { confirmationBoxText() }<br /><br /> 150 + 151 + <div class="button-danger" onClick={() => { confirmationBoxCallback(); setConfirmationBoxText('') }}>Confirm</div> 152 + <div class="button" onClick={() => setConfirmationBoxText('') }>Deny</div> 153 + </div> 117 154 </div> 118 155 </div> 119 156 );
+65 -4
src/Components/PhotoList.tsx
··· 1 1 import { createEffect, onMount } from "solid-js"; 2 2 import { invoke } from '@tauri-apps/api/tauri'; 3 - import { listen } from '@tauri-apps/api/event' 3 + import { listen } from '@tauri-apps/api/event'; 4 4 5 5 import anime from "animejs"; 6 6 ··· 11 11 12 12 class PhotoListProps{ 13 13 setCurrentPhotoView!: ( view: any ) => any; 14 + currentPhotoView!: () => any; 14 15 photoNavChoice!: () => string; 15 16 setPhotoNavChoice!: ( view: any ) => any; 17 + setConfirmationBox!: ( text: string, cb: () => void ) => void; 16 18 } 17 19 18 20 let PhotoList = ( props: PhotoListProps ) => { ··· 32 34 let scroll: number = 0; 33 35 let targetScroll: number = 0; 34 36 37 + let quitRender: boolean = false; 38 + 35 39 class PhotoMetadata{ 36 40 width!: number; 37 41 height!: number; ··· 43 47 path: string; 44 48 loaded: boolean = false; 45 49 loading: boolean = false; 50 + metaLoaded: boolean = false; 46 51 image?: HTMLCanvasElement; 47 52 imageEl?: HTMLImageElement; 48 53 width?: number; ··· 68 73 } 69 74 70 75 loadImage(){ 71 - if(this.loading || this.loaded || imagesLoading >= MAX_IMAGE_LOAD)return; 76 + if(this.loading || this.loaded || !this.metaLoaded || imagesLoading >= MAX_IMAGE_LOAD)return; 72 77 this.loading = true; 73 78 74 79 imagesLoading++; ··· 76 81 this.image = document.createElement('canvas'); 77 82 78 83 this.imageEl = document.createElement('img'); 84 + this.imageEl.crossOrigin = 'anonymous'; 79 85 this.imageEl.src = 'https://photo.localhost/' + this.path; 80 86 81 87 this.imageEl.onload = () => { ··· 114 120 }) 115 121 116 122 let render = () => { 117 - requestAnimationFrame(render); 123 + if(!quitRender) 124 + requestAnimationFrame(render); 125 + else 126 + return quitRender = false; 118 127 119 128 if(!ctx)return; 120 129 ctx.clearRect(0, 0, photoContainer.width, photoContainer.height); ··· 237 246 amountLoaded++; 238 247 239 248 photoMetaDataLoadingBar.style.width = (amountLoaded / photos.length) * 100 + '%'; 249 + photo.metaLoaded = true; 240 250 241 251 if(amountLoaded / photos.length === 1){ 242 252 render(); ··· 251 261 photoMetaDataLoadingContainer.style.display = 'none'; 252 262 } 253 263 }) 264 + 265 + anime({ 266 + targets: '.reload-photos', 267 + opacity: 1, 268 + duration: 150, 269 + easing: 'easeInOutQuad' 270 + }) 254 271 } 255 272 }) 256 273 274 + listen('photo_create', ( event: any ) => { 275 + console.log(event); 276 + 277 + let photo = new Photo(event.payload); 278 + photos.splice(0, 0, photo); 279 + }) 280 + 281 + listen('photo_remove', ( event: any ) => { 282 + photos = photos.filter(x => x.path !== event.payload); 283 + 284 + if(event.payload === props.currentPhotoView().path){ 285 + currentPhotoIndex = -1; 286 + props.setCurrentPhotoView(null); 287 + } 288 + }) 289 + 290 + let reloadPhotos = () => { 291 + photoTreeLoadingContainer.style.opacity = '1'; 292 + photoTreeLoadingContainer.style.height = '100%'; 293 + photoTreeLoadingContainer.style.display = 'flex'; 294 + 295 + photoMetaDataLoadingContainer.style.opacity = '1'; 296 + photoMetaDataLoadingContainer.style.height = '100%'; 297 + photoMetaDataLoadingContainer.style.display = 'flex'; 298 + 299 + photoMetaDataLoadingBar.style.width = '0%'; 300 + quitRender = true; 301 + 302 + amountLoaded = 0; 303 + scroll = 0; 304 + photos = []; 305 + 306 + anime({ 307 + targets: '.reload-photos', 308 + opacity: 0, 309 + duration: 150, 310 + easing: 'easeInOutQuad' 311 + }) 312 + 313 + invoke('load_photos'); 314 + } 315 + 257 316 let loadPhotos = async () => { 258 317 invoke('load_photos') 259 318 ··· 287 346 288 347 if(targetScroll < 0) 289 348 targetScroll = 0; 290 - }) 349 + }); 291 350 292 351 photoContainer.width = window.innerWidth; 293 352 photoContainer.height = window.innerHeight; ··· 323 382 <div class="loading-bar"><div class="loading-bar-inner" ref={( el ) => photoMetaDataLoadingBar = el}></div></div> 324 383 </div> 325 384 </div> 385 + 386 + <div class="reload-photos" onClick={() => props.setConfirmationBox("Are you sure you want to reload all photos? This can cause the application to slow down while it is loading...", reloadPhotos)}><i class="fa-solid fa-arrows-rotate"></i></div> 326 387 327 388 <canvas class="photo-container" ref={( el ) => photoContainer = el}></canvas> 328 389 </div>
+80 -1
src/Components/PhotoViewer.tsx
··· 1 1 import { createEffect, onMount } from "solid-js"; 2 + import { invoke } from '@tauri-apps/api/tauri'; 2 3 import anime from 'animejs'; 3 4 4 5 class PhotoViewerProps{ 5 6 currentPhotoView!: () => any; 6 7 setCurrentPhotoView!: ( view: any ) => any; 7 8 setPhotoNavChoice!: ( view: any ) => any; 9 + setConfirmationBox!: ( text: string, cb: () => void ) => void; 8 10 } 9 11 10 12 let PhotoViewer = ( props: PhotoViewerProps ) => { ··· 44 46 targets: '.navbar', 45 47 top: '-50px' 46 48 }) 47 - 49 + 50 + anime.set('.prev-button', { left: '-50px', top: '50%' }); 51 + anime.set('.next-button', { right: '-50px', top: '50%' }); 52 + 53 + anime({ targets: '.prev-button', left: '0', easing: 'easeInOutQuad', duration: 100 }); 54 + anime({ targets: '.next-button', right: '0', easing: 'easeInOutQuad', duration: 100 }); 55 + 48 56 window.CloseAllPopups.forEach(p => p()); 49 57 } else if(!photo && isOpen){ 50 58 anime({ ··· 63 71 }) 64 72 65 73 window.CloseAllPopups.forEach(p => p()); 74 + 75 + anime({ targets: '.prev-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 76 + anime({ targets: '.next-button', top: '75%', easing: 'easeInOutQuad', duration: 100 }); 66 77 } 67 78 68 79 isOpen = photo != null; ··· 76 87 77 88 <div class="prev-button" onClick={() => props.setPhotoNavChoice('prev')}><i class="fa-solid fa-arrow-left"></i></div> 78 89 <div class="next-button" onClick={() => props.setPhotoNavChoice('next')}><i class="fa-solid fa-arrow-right"></i></div> 90 + 91 + <div class="control-buttons"> 92 + <div class="viewer-button" 93 + onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 94 + onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 95 + onClick={() => { 96 + let canvas = document.createElement('canvas'); 97 + let ctx = canvas.getContext('2d')!; 98 + 99 + canvas.width = props.currentPhotoView().width; 100 + canvas.height = props.currentPhotoView().height; 101 + 102 + ctx.drawImage(props.currentPhotoView().imageEl, 0, 0); 103 + 104 + canvas.toBlob(( blob ) => { 105 + navigator.clipboard.write([ 106 + new ClipboardItem({ 107 + 'image/png': blob! 108 + }) 109 + ]); 110 + 111 + canvas.remove(); 112 + 113 + anime.set('.copy-notif', { translateX: '-50%', translateY: '-100px' }); 114 + anime({ 115 + targets: '.copy-notif', 116 + opacity: 1, 117 + translateY: '0px' 118 + }); 119 + 120 + setTimeout(() => { 121 + anime({ 122 + targets: '.copy-notif', 123 + opacity: 0, 124 + translateY: '-100px' 125 + }); 126 + }, 2000); 127 + }); 128 + }} 129 + > 130 + <i class="fa-solid fa-copy"></i> 131 + </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' })} 147 + > 148 + <i class="fa-solid fa-file"></i> 149 + </div> 150 + <div class="viewer-button" 151 + onMouseOver={( el ) => anime({ targets: el.currentTarget, width: '40px', height: '40px', 'margin-left': '15px', 'margin-right': '15px', 'margin-top': '-10px' })} 152 + onMouseLeave={( el ) => anime({ targets: el.currentTarget, width: '30px', height: '30px', 'margin-left': '20px', 'margin-right': '20px', 'margin-top': '0px' })} 153 + onClick={() => props.setConfirmationBox("Are you sure you want to delete this photo?", () => { invoke("delete_photo", { path: props.currentPhotoView().path }); })} 154 + > 155 + <i class="fa-solid fa-trash"></i> 156 + </div> 157 + </div> 79 158 </div> 80 159 ) 81 160 }
+103
src/styles.css
··· 197 197 user-select: none; 198 198 cursor: pointer; 199 199 z-index: 7; 200 + box-shadow: #0008 0 0 10px; 200 201 } 201 202 202 203 .viewer-close{ ··· 253 254 254 255 .next-button:hover{ 255 256 background: rgba(255, 255, 255, 0.349); 257 + } 258 + 259 + .reload-photos{ 260 + position: fixed; 261 + top: 70px; 262 + right: 20px; 263 + color: white; 264 + user-select: none; 265 + cursor: pointer; 266 + opacity: 0; 267 + } 268 + 269 + .confirmation-box{ 270 + position: fixed; 271 + top: 0; 272 + left: 0; 273 + width: 100%; 274 + height: 100%; 275 + z-index: 15; 276 + background: #0005; 277 + transition: 0.25s; 278 + backdrop-filter: blur(10px); 279 + } 280 + 281 + .confirmation-box-container{ 282 + position: fixed; 283 + top: 50%; 284 + left: 50%; 285 + transform: translate(-50%, -50%); 286 + color: white; 287 + text-align: center; 288 + background: #9995; 289 + padding: 10px; 290 + width: 60%; 291 + border-radius: 10px; 292 + box-shadow: #000 0 0 10px; 293 + font-size: 18px; 294 + backdrop-filter: blur(10px); 295 + } 296 + 297 + .button-danger{ 298 + display: inline-block; 299 + backdrop-filter: blur(10px); 300 + padding: 10px; 301 + background: rgba(255, 0, 0, 0.333); 302 + box-shadow: #0005 inset 0 0 10px; 303 + border-radius: 50px; 304 + margin: 0 10px; 305 + cursor: pointer; 306 + user-select: none; 307 + width: 200px; 308 + transition: 0.25s; 309 + } 310 + 311 + .button{ 312 + display: inline-block; 313 + padding: 10px; 314 + backdrop-filter: blur(10px); 315 + background: #9995; 316 + box-shadow: #0005 inset 0 0 10px; 317 + border-radius: 50px; 318 + margin: 0 10px; 319 + cursor: pointer; 320 + user-select: none; 321 + width: 200px; 322 + transition: 0.25s; 323 + } 324 + 325 + .button:hover{ 326 + box-shadow: #000a inset 0 0 10px; 327 + } 328 + 329 + .button-danger:hover{ 330 + box-shadow: #000a inset 0 0 10px; 331 + } 332 + 333 + .control-buttons{ 334 + position: fixed; 335 + bottom: 10px; 336 + left: 50%; 337 + transform: translateX(-50%); 338 + display: flex; 339 + } 340 + 341 + .control-buttons div{ 342 + margin: 0 20px; 343 + } 344 + 345 + .copy-notif{ 346 + position: fixed; 347 + top: 40px; 348 + left: 50%; 349 + color: white; 350 + transform: translateX(-50%) translateY(-100px); 351 + background: #8885; 352 + padding: 10px 40px; 353 + backdrop-filter: blur(10px); 354 + border-radius: 50px; 355 + box-shadow: #000 0 0 10px; 356 + z-index: 12; 357 + opacity: 0; 358 + pointer-events: none; 256 359 }
+2 -3
vite.config.ts
··· 14 14 port: 1420, 15 15 strictPort: true, 16 16 watch: { 17 - // 3. tell vite to ignore watching `src-tauri` 18 - ignored: ["**/src-tauri/**"], 19 - }, 17 + ignored: [ 'src-tauri/**' ] 18 + } 20 19 }, 21 20 }));