+1
src-tauri/Cargo.lock
+1
src-tauri/Cargo.lock
+1
src-tauri/Cargo.toml
+1
src-tauri/Cargo.toml
+17
-1
src-tauri/src/main.rs
+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
+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
+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
+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
+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
}