+2
-1
changelog
+2
-1
changelog
···
18
18
- Migrate to tauri v2
19
19
20
20
- Photos shouldn't cause the ui to lag while loading
21
-
- Removed the metadata loading screen in favour of loading the metadata just before an image it rendered
22
21
- Added the context menu back to the photo viewer screen
22
+
- Added filter menu, you can now search for photos taken in specific worlds or with specific people
23
23
- Fixed some weird bugs where the world data cache would be ignored
24
24
- Fixed the ui forgetting the user account in some cases where the token stored it still valid
25
25
- Updated no photos text to be kinder
26
26
- Settings menu can now be closed with ESC
27
27
- Fixed photos being extremely wide under certain conditions
28
28
- Fixed some icons not showing correctly
29
+
- Fixed a bug where it would store multiple versions of cache for a world, and then request the world data again everytime
29
30
30
31
- Photo viewer can now be navigated with keybinds:
31
32
- Up Arrow: Open Tray
+5
src-tauri/src/util/handle_uri_proto.rs
+5
src-tauri/src/util/handle_uri_proto.rs
···
21
21
}
22
22
23
23
// TODO: Only accept files that are in the vrchat photos folder
24
+
// Slightly more complex than originally thought, need to find a way to cache the VRC photos path
25
+
// since i need to be able to load lots of photos very quickly. This shouldn't be a security issue
26
+
// because tauri should only let the frontend of VRCPhotoManager read files throught this. Only
27
+
// becomes a potential issue if the frontend gets modified or there's an issue with tauri.
28
+
24
29
let path = uri.path().split_at(1).1;
25
30
let file = fs::File::open(path);
26
31
+37
src/Components/FilterMenu.tsx
+37
src/Components/FilterMenu.tsx
···
1
+
enum FilterType{
2
+
USER, WORLD
3
+
}
4
+
5
+
class FilterMenuProps{
6
+
setFilterType!: ( type: FilterType ) => void;
7
+
setFilter!: ( filter: string ) => void;
8
+
}
9
+
10
+
let FilterMenu = ( props: FilterMenuProps ) => {
11
+
let selectionButtons: HTMLDivElement[] = [];
12
+
13
+
let select = ( index: number ) => {
14
+
selectionButtons.forEach(e => e.classList.remove('selected-filter'));
15
+
selectionButtons[index].classList.add('selected-filter');
16
+
}
17
+
18
+
return (
19
+
<>
20
+
<div class="filter-type-select">
21
+
<div class="selected-filter" ref={( el ) => selectionButtons.push(el)} onClick={() => {
22
+
select(0);
23
+
props.setFilterType(FilterType.USER);
24
+
}}>User</div>
25
+
<div ref={( el ) => selectionButtons.push(el)} onClick={() => {
26
+
select(1);
27
+
props.setFilterType(FilterType.WORLD);
28
+
}}>World</div>
29
+
</div>
30
+
31
+
<input class="filter-search" type="text" onInput={( el ) => props.setFilter(el.target.value)} placeholder="Enter Search Term..."></input>
32
+
</>
33
+
)
34
+
}
35
+
36
+
export default FilterMenu
37
+
export { FilterType }
+84
-42
src/Components/PhotoList.tsx
+84
-42
src/Components/PhotoList.tsx
···
3
3
import { listen } from '@tauri-apps/api/event';
4
4
5
5
import anime from "animejs";
6
+
import FilterMenu, { FilterType } from "./FilterMenu";
6
7
7
8
const PHOTO_HEIGHT = 200;
8
9
const MAX_IMAGE_LOAD = 10;
···
30
31
NONE
31
32
}
32
33
33
-
// TODO: Photo filtering / Searching (By users, By date, By world)
34
34
let PhotoList = ( props: PhotoListProps ) => {
35
35
let amountLoaded = 0;
36
36
let imagesLoading = 0;
37
+
38
+
let hasFirstLoaded = false;
37
39
38
40
let photoTreeLoadingContainer: HTMLElement;
39
41
···
51
53
let photos: Photo[] = [];
52
54
let currentPhotoIndex: number = -1;
53
55
54
-
let datesList: any = {};
55
-
56
56
let scroll: number = 0;
57
57
let targetScroll: number = 0;
58
58
···
61
61
62
62
let currentPopup = ListPopup.NONE;
63
63
64
+
let filterType: FilterType = FilterType.USER;
65
+
let filter = '';
66
+
67
+
let filteredPhotos: Photo[] = [];
68
+
64
69
let closeWithKey = ( e: KeyboardEvent ) => {
65
70
if(e.key === 'Escape'){
66
71
closeCurrentPopup();
···
128
133
this.dateString = this.path.split('_')[1];
129
134
}
130
135
136
+
loadMeta(){
137
+
invoke('load_photo_meta', { photo: this.path });
138
+
}
139
+
131
140
loadImage(){
132
141
if(this.loading || this.loaded || imagesLoading >= MAX_IMAGE_LOAD)return;
133
142
134
-
invoke('load_photo_meta', { photo: this.path });
143
+
this.loadMeta();
135
144
if(!this.metaLoaded)return;
136
145
137
146
this.loading = true;
···
164
173
165
174
switch(action){
166
175
case 'prev':
167
-
if(!photos[currentPhotoIndex - 1])break;
168
-
props.setCurrentPhotoView(photos[currentPhotoIndex - 1]);
176
+
if(!filteredPhotos[currentPhotoIndex - 1])break;
177
+
props.setCurrentPhotoView(filteredPhotos[currentPhotoIndex - 1]);
169
178
170
179
currentPhotoIndex--;
171
180
break;
172
181
case 'next':
173
-
if(!photos[currentPhotoIndex + 1])break;
174
-
props.setCurrentPhotoView(photos[currentPhotoIndex + 1]);
182
+
if(!filteredPhotos[currentPhotoIndex + 1])break;
183
+
props.setCurrentPhotoView(filteredPhotos[currentPhotoIndex + 1]);
175
184
176
185
currentPhotoIndex++;
177
186
break;
···
207
216
scroll = scroll + (targetScroll - scroll) * 0.2;
208
217
209
218
let lastPhoto;
210
-
for (let i = 0; i < photos.length; i++) {
211
-
let p = photos[i];
219
+
for (let i = 0; i < filteredPhotos.length; i++) {
220
+
let p = filteredPhotos[i];
212
221
213
222
if(currentRowIndex * 210 - scroll > photoContainer.height){
214
223
p.shown = false;
···
328
337
})
329
338
}
330
339
331
-
if(photos.length == 0){
340
+
if(filteredPhotos.length == 0){
332
341
ctx.textAlign = 'center';
333
342
ctx.textBaseline = 'middle';
334
343
ctx.globalAlpha = 1;
335
344
ctx.fillStyle = '#fff';
336
-
ctx.font = '50px Rubik';
345
+
ctx.font = '40px Rubik';
337
346
338
347
ctx.fillText("It's looking empty in here! You have no photos :O", photoContainer.width / 2, photoContainer.height / 2);
339
348
}
···
361
370
362
371
photo.metaLoaded = true;
363
372
photo.onMetaLoaded();
373
+
374
+
if(amountLoaded === photos.length && !hasFirstLoaded){
375
+
filteredPhotos = photos;
376
+
hasFirstLoaded = true;
377
+
378
+
anime({
379
+
targets: photoTreeLoadingContainer,
380
+
height: 0,
381
+
easing: 'easeInOutQuad',
382
+
duration: 500,
383
+
opacity: 0,
384
+
complete: () => {
385
+
photoTreeLoadingContainer.style.display = 'none';
386
+
}
387
+
})
388
+
389
+
anime({
390
+
targets: '.reload-photos',
391
+
opacity: 1,
392
+
duration: 150,
393
+
easing: 'easeInOutQuad'
394
+
})
395
+
396
+
render();
397
+
}
364
398
})
365
399
366
400
listen('photo_create', ( event: any ) => {
367
401
let photo = new Photo(event.payload);
402
+
368
403
photos.splice(0, 0, photo);
404
+
photo.loadMeta();
369
405
370
406
if(!props.isPhotosSyncing() && props.storageInfo().sync){
371
407
props.setIsPhotosSyncing(true);
···
392
428
quitRender = true;
393
429
amountLoaded = 0;
394
430
scroll = 0;
431
+
395
432
photos = [];
433
+
filteredPhotos = [];
396
434
397
435
anime({
398
436
targets: '.reload-photos',
···
418
456
photoPaths.forEach(( path: string ) => {
419
457
let photo = new Photo(path);
420
458
photos.push(photo);
421
-
})
422
459
423
-
anime({
424
-
targets: photoTreeLoadingContainer,
425
-
height: 0,
426
-
easing: 'easeInOutQuad',
427
-
duration: 500,
428
-
opacity: 0,
429
-
complete: () => {
430
-
photoTreeLoadingContainer.style.display = 'none';
431
-
}
432
-
})
433
-
434
-
anime({
435
-
targets: '.reload-photos',
436
-
opacity: 1,
437
-
duration: 150,
438
-
easing: 'easeInOutQuad'
460
+
photo.loadMeta();
439
461
})
440
-
441
-
photoPaths.forEach(( path: string ) => {
442
-
let date = path.split('_')[1];
443
-
444
-
if(!datesList[date])
445
-
datesList[date] = 1;
446
-
});
447
-
448
-
render();
449
462
})
450
463
}
451
464
···
480
493
})
481
494
482
495
photoContainer.addEventListener('click', ( e: MouseEvent ) => {
483
-
let photo = photos.find(x =>
496
+
let photo = filteredPhotos.find(x =>
484
497
e.clientX > x.x &&
485
498
e.clientY > x.y &&
486
499
e.clientX < x.x + x.scaledWidth! &&
···
490
503
491
504
if(photo){
492
505
props.setCurrentPhotoView(photo);
493
-
currentPhotoIndex = photos.indexOf(photo);
506
+
currentPhotoIndex = filteredPhotos.indexOf(photo);
494
507
} else
495
508
currentPhotoIndex = -1;
496
509
})
···
500
513
window.removeEventListener('keyup', closeWithKey);
501
514
})
502
515
503
-
return (
516
+
let reloadFilters = () => {
517
+
filteredPhotos = [];
518
+
519
+
switch(filterType){
520
+
case FilterType.USER:
521
+
photos.map(p => {
522
+
if(p.metadata){
523
+
let meta = JSON.parse(p.metadata);
524
+
let photo = meta.players.find(( y: any ) => y.displayName.toLowerCase().includes(filter) || y.id === filter);
525
+
526
+
if(photo)filteredPhotos.push(p);
527
+
}
528
+
})
529
+
break;
530
+
case FilterType.WORLD:
531
+
photos.map(p => {
532
+
if(p.metadata){
533
+
let meta = JSON.parse(p.metadata);
534
+
let photo = meta.world.name.toLowerCase().includes(filter) || meta.world.id === filter;
535
+
536
+
if(photo)filteredPhotos.push(p);
537
+
}
538
+
})
539
+
break;
540
+
}
541
+
}
542
+
543
+
return (
504
544
<div class="photo-list">
505
545
<div ref={filterContainer!} class="filter-container">
506
-
<div class="filter-title">Filters</div>
546
+
<FilterMenu
547
+
setFilter={( f ) => { filter = f.toLowerCase(); reloadFilters(); }}
548
+
setFilterType={( t ) => { filterType = t; reloadFilters(); }} />
507
549
</div>
508
550
509
551
<div class="photo-tree-loading" ref={( el ) => photoTreeLoadingContainer = el}>Scanning Photo Tree...</div>
+3
-6
src/Components/PhotoViewer.tsx
+3
-6
src/Components/PhotoViewer.tsx
···
253
253
imageViewer.style.opacity = '0';
254
254
255
255
if(photo){
256
-
console.log(photo);
257
-
258
256
(async () => {
259
257
if(!photoPath)
260
258
photoPath = await invoke('get_user_photos_path') + '/';
···
277
275
278
276
let meta = JSON.parse(photo.metadata);
279
277
let worldData = worldCache.find(x => x.worldData.id === meta.world.id);
280
-
278
+
281
279
allowedToOpenTray = true;
282
280
trayButton.style.display = 'flex';
283
281
···
305
303
</div>
306
304
</div> as Node
307
305
);
308
-
309
-
306
+
310
307
if(!worldData){
311
308
console.log('Fetching new world data');
312
309
···
314
311
} else if(worldData.expiresOn < Date.now()){
315
312
console.log('Fetching new world data since cache has expired');
316
313
317
-
worldCache = worldCache.filter(x => x !== worldData)
314
+
worldCache = worldCache.filter(x => x.worldData.id !== meta.world.id)
318
315
invoke('find_world_by_id', { worldId: meta.world.id });
319
316
} else
320
317
loadWorldData(worldData);
+49
-4
src/styles.css
+49
-4
src/styles.css
···
197
197
.filter-container{
198
198
display: none;
199
199
position: fixed;
200
-
top: 50%;
200
+
bottom: 0;
201
201
left: 50%;
202
202
width: 600px;
203
-
height: 250px;
204
-
transform: translate(-50%, -50%);
203
+
height: 83px;
204
+
transform: translate(-50%);
205
205
padding: 10px;
206
-
border-radius: 5px;
206
+
border-radius: 5px 5px 0 0;
207
207
backdrop-filter: blur(5px);
208
208
background: #555a;
209
209
color: #fff;
···
214
214
215
215
.filter-container > .filter-title{
216
216
font-size: 30px;
217
+
}
218
+
219
+
.filter-type-select{
220
+
display: flex;
221
+
justify-content: center;
222
+
align-items: center;
223
+
width: 75%;
224
+
margin: auto;
225
+
}
226
+
227
+
.filter-type-select > div{
228
+
width: 100%;
229
+
border: #fff 4px solid;
230
+
border-left: #fff 2px solid;
231
+
border-right: #fff 2px solid;
232
+
padding: 5px 0;
233
+
cursor: pointer;
234
+
user-select: none;
235
+
}
236
+
237
+
.filter-type-select > div:first-child{
238
+
border-left: #fff 4px solid;
239
+
border-radius: 10px 0 0 10px;
240
+
}
241
+
242
+
.filter-type-select > div:last-child{
243
+
border-right: #fff 4px solid;
244
+
border-radius: 0 10px 10px 0;
245
+
}
246
+
247
+
.filter-type-select > .selected-filter{
248
+
background: #00ccff55;
249
+
}
250
+
251
+
.filter-search{
252
+
margin-top: 10px;
253
+
padding: 5px;
254
+
border: #fff 4px solid;
255
+
border-radius: 10px;
256
+
background: #0008;
257
+
outline: none;
258
+
color: white;
259
+
font-size: 15px;
260
+
font-family: 'Rubik';
261
+
width: calc(75% - 18px);
217
262
}
218
263
219
264
.date-list{