new: touch or pen gesture open/close $Drawer.
optimize: move numberFormat function to Util.
new: video controller.
optimize: $PostTile video duration update by timeupdate event.
change: post viewer height depend on dynamic viewport height.
This commit is contained in:
defaultkavy 2024-10-15 13:27:36 +08:00
parent 580ac885de
commit 93b06d7c80
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
15 changed files with 219 additions and 39 deletions

File diff suppressed because one or more lines are too long

1
dist/assets/index-D689878Y.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-adASBXxZ.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -16,8 +16,8 @@
gtag('config', 'G-59HBGP98WR'); gtag('config', 'G-59HBGP98WR');
</script> </script>
<script type="module" crossorigin src="/assets/index-B5ohILm0.js"></script> <script type="module" crossorigin src="/assets/index-adASBXxZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-U9yaKy4a.css"> <link rel="stylesheet" crossorigin href="/assets/index-D689878Y.css">
</head> </head>
<body> <body>
</body> </body>

View File

@ -2,7 +2,7 @@
"name": "danbooru-viewer", "name": "danbooru-viewer",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.7.0", "version": "0.8.0",
"scripts": { "scripts": {
"dev": "bun x vite", "dev": "bun x vite",
"build": "bun x vite build", "build": "bun x vite build",

View File

@ -1,11 +1,13 @@
import { $Container } from "elexis"; import { $Container } from "elexis";
import { Booru } from "../../structure/Booru"; import { Booru } from "../../structure/Booru";
import { numberFormat } from "../../modules"; import { numberFormat } from "../../structure/Util";
import { danbooru, safebooru } from "../../main"; import { danbooru, safebooru } from "../../main";
export class $Drawer extends $Container { export class $Drawer extends $Container {
$filter = $('div').class('filter'); $filter = $('div').class('filter');
$container = $('div').class('drawer-container') $container = $('div').class('drawer-container')
pointers = $.pointers($(document.body));
protected opened = false;
constructor() { constructor() {
super('drawer'); super('drawer');
this.hide(true); this.hide(true);
@ -52,12 +54,22 @@ export class $Drawer extends $Container {
]), ]),
this.$filter.on('click', () => $.back()) this.$filter.on('click', () => $.back())
]) ])
this.pointers.on('move', pointer => {
if ($(':.viewer')?.contains(pointer.$target)) return;
pointer.$target.parent
if (pointer.type !== 'pen' && pointer.type !== 'touch') return;
if (pointer.move_y > 4 || pointer.move_y < -4) return;
if (pointer.move_x <= -7) { pointer.delete(); this.open(); }
if (pointer.move_x >= 7) { pointer.delete(); this.close(); }
})
} }
open() { if (location.hash !== '#drawer') $.open(location.href + '#drawer'); return this; } open() { if (location.hash !== '#drawer') $.open(location.href + '#drawer'); return this; }
close() { if (location.hash === '#drawer') $.back(); return this; } close() { if (location.hash === '#drawer') $.back(); return this; }
private activate() { private activate() {
this.opened = true;
this.hide(false); this.hide(false);
this.$container.animate({ this.$container.animate({
transform: [`translateX(100%)`, `translateX(0%)`] transform: [`translateX(100%)`, `translateX(0%)`]
@ -76,13 +88,14 @@ export class $Drawer extends $Container {
} }
private inactivate() { private inactivate() {
this.opened = false
this.$container.animate({ this.$container.animate({
transform: [`translateX(0%)`, `translateX(100%)`] transform: [`translateX(0%)`, `translateX(100%)`]
}, { }, {
fill: 'both', fill: 'both',
duration: 300, duration: 300,
easing: 'ease' easing: 'ease'
}, () => this.hide(true)) }, () => this.hide(!this.opened))
this.$filter.animate({ this.$filter.animate({
opacity: [1, 0] opacity: [1, 0]
}, { }, {

View File

@ -16,10 +16,8 @@ export class $PostTile extends $Container {
build() { build() {
let timer: Timer let timer: Timer
this.$video?.on('playing', (e, $video) => { this.$video?.on('timeupdate', (e, $video) => {
timer = setInterval(() => { this.durationUpdate();
this.durationUpdate();
}, 500)
}) })
this.$video?.on('pause', () => { this.$video?.on('pause', () => {
clearInterval(timer); clearInterval(timer);

View File

@ -2,7 +2,7 @@ import { $Container } from "elexis";
import { Tag, TagCategory } from "../../structure/Tag"; import { Tag, TagCategory } from "../../structure/Tag";
import { Booru } from "../../structure/Booru"; import { Booru } from "../../structure/Booru";
import { Autocomplete } from "../../structure/Autocomplete"; import { Autocomplete } from "../../structure/Autocomplete";
import { numberFormat } from "../../modules"; import { numberFormat } from "../../structure/Util";
export class $Searchbar extends $Container { export class $Searchbar extends $Container {
$tagInput = new $TagInput(this); $tagInput = new $TagInput(this);

View File

@ -0,0 +1,103 @@
import { $Container, $Node, type $Video } from "elexis";
import { time } from "../../structure/Util";
import type { Post } from "../../structure/Post";
export class $VideoController extends $Container {
$video: $Video;
$viewer: $Container;
duration$ = $.state('00:00');
post: Post;
constructor($video: $Video, $viewer: $Container, post: Post) {
super('video-controller')
this.$video = $video
this.$viewer = $viewer;
this.post = post;
this.build();
}
protected build() {
const events = $.events<{
progressChange: [number]
}>();
this.$video.on('timeupdate', () => this.durationUpdate())
this.content([
$('div').class('video-details').content([
$('div').class('left').content([
$('ion-icon').class('play').title('Play').name('play').self($play => {
this.$video.on('play', () => $play.name('pause'))
.on('pause', () => $play.name('play'))
$play.on('click', () => this.$video.isPlaying ? this.$video.pause() : this.$video.play())
}),
$('div').class('duration').content([
$('span').class('current-time').content(this.duration$),
$('span').content('/'),
$('span').class('total-time').content('00:00').self($time => {
this.$video.on('loadeddata', () => {
const t = time(this.$video.duration * 1000);
$time.content(Number(t.hh) > 0 ? `${t.hh}:${t.mm}:${t.ss}` : `${t.mm}:${t.ss}`);
})
}),
]),
]),
$('div').class('right').content([
$('ion-icon').class('volume').title('Volume').name('volume-high').disable(!this.post.hasSound).self($volume => {
const check = () => {
if (this.$video.muted()) $volume.name('volume-mute');
else $volume.name('volume-high');
}
$volume.on('click', () => {
this.$video.muted(!this.$video.muted())
check();
})
}),
$('ion-icon').class('full-screen').title('Full-Screen').name('scan').self($fullscreen => {
$fullscreen.on('click', () => {
if (document.fullscreenElement) document.exitFullscreen()
else this.$viewer.dom.requestFullscreen()
})
})
])
]),
$('div').class('progressbar-container').content([
$('div').class('progressbar').content([
$('div').class('progress').self($progress => {
this.$video.on('timeupdate', e => {
$progress.css({width: `${(this.$video.currentTime() / this.$video.duration) * 100}%`})
})
events.on('progressChange', percentage => {
$progress.css({width: `${percentage * 100}%`})
})
})
])
]).self($bar => {
const pointers = $.pointers($(document.body));
let isPlaying = false;
pointers.on('down', (pointer, e) => {
if (!$bar.contains(pointer.$target)) return pointer.delete();
e.preventDefault()
if (this.$video.isPlaying) {
isPlaying = true;
this.$video.pause();
}
const percentage = (pointer.x - $bar.domRect().x) / $bar.offsetWidth;
this.$video.currentTime(percentage * this.$video.duration);
})
pointers.on('move', (pointer, e) => {
e.preventDefault()
const percentage = (pointer.x - $bar.domRect().x) / $bar.offsetWidth;
this.$video.currentTime(percentage * this.$video.duration);
events.fire('progressChange', percentage)
})
pointers.on('up', (pointer, e) => {
if (isPlaying) this.$video.play();
isPlaying = false;
})
})
])
}
durationUpdate() {
const t = time(this.$video.currentTime() * 1000)
this.duration$.set(Number(t.hh) > 0 ? `${t.hh}:${t.mm}:${t.ss}` : `${t.mm}:${t.ss}`)
}
}

View File

@ -1,4 +0,0 @@
const NUMBER_FORMAT = new Intl.NumberFormat('en', {notation: 'compact'})
export function numberFormat(number: number) {
return NUMBER_FORMAT.format(number)
}

View File

@ -4,8 +4,9 @@ import { Tag, TagCategory } from "../../structure/Tag";
import { ArtistCommentary } from "../../structure/Commentary"; import { ArtistCommentary } from "../../structure/Commentary";
import { Booru } from "../../structure/Booru"; import { Booru } from "../../structure/Booru";
import type { $IonIcon } from "../../component/IonIcon/$IonIcon"; import type { $IonIcon } from "../../component/IonIcon/$IonIcon";
import { numberFormat } from "../../modules"; import { numberFormat } from "../../structure/Util";
import { ClientUser } from "../../structure/ClientUser"; import { ClientUser } from "../../structure/ClientUser";
import { $VideoController } from "../../component/VideoController/$VideoController";
export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => { export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => {
if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND'); if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND');
@ -17,26 +18,30 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
original_size: [] original_size: []
}>(); }>();
return [ return [
$('div').class('viewer').content(async () => { $('div').class('viewer').content(async ($viewer) => {
const $video = $('video');
await post.ready; await post.ready;
return [ return [
$('div').class('viewer-panel').hide(true).content([ $('div').class('viewer-panel').hide(false).content([
$('div').class('panel').content([ $('div').class('panel').content([
$('ion-icon').title('Favorite').name('heart-outline').self($heart => { post.isVideo ? new $VideoController($video, $viewer, post) : null,
ClientUser.events.on('favoriteUpdate', (user) => { $('div').class('buttons').content([
if (user.favorites.has(post.id)) $heart.name('heart'); $('ion-icon').title('Favorite').name('heart-outline').self($heart => {
else $heart.name('heart-outline'); ClientUser.events.on('favoriteUpdate', (user) => {
if (user.favorites.has(post.id)) $heart.name('heart');
else $heart.name('heart-outline');
})
if (Booru.used.user?.favorites.has(post.id)) $heart.name('heart');
$heart.on('click', () => {
if (Booru.used.user?.favorites.has(post.id)) post.deleteFavorite();
else post.createFavorite();
})
}),
$('ion-icon').title('Original Size').name('resize-outline').self($original => {
$original.on('click', () => { events.fire('original_size'); $original.disable(true); })
if (!post.isLargeFile || post.isVideo) $original.disable(true);
}) })
if (Booru.used.user?.favorites.has(post.id)) $heart.name('heart'); ])
$heart.on('click', () => {
if (Booru.used.user?.favorites.has(post.id)) post.deleteFavorite();
else post.createFavorite();
})
}),
$('ion-icon').title('Original Size').name('resize-outline').self($original => {
$original.on('click', () => { events.fire('original_size'); $original.disable(true); })
if (!post.isLargeFile || post.isVideo) $original.disable(true);
})
]), ]),
$('div').class('overlay') $('div').class('overlay')
]).self($viewerPanel => { ]).self($viewerPanel => {
@ -45,7 +50,7 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
.on('viewerPanel_switch', () => $viewerPanel.hide(!$viewerPanel.hide())) .on('viewerPanel_switch', () => $viewerPanel.hide(!$viewerPanel.hide()))
}), }),
post.isVideo post.isVideo
? $('video').height(post.image_height).width(post.image_width).src(post.file_ext === 'zip' ? post.large_file_url : post.file_url).controls(true).autoplay(true).loop(true).disablePictureInPicture(true) ? $video.height(post.image_height).width(post.image_width).src(post.file_ext === 'zip' ? post.large_file_url : post.file_url).controls(false).autoplay(true).loop(true).disablePictureInPicture(true)
: $('img').src(post.isLargeFile ? post.large_file_url : post.file_url).self($img => { : $('img').src(post.isLargeFile ? post.large_file_url : post.file_url).self($img => {
events.on('original_size', () => $img.src(post.file_url)) events.on('original_size', () => $img.src(post.file_url))
}) })
@ -56,7 +61,7 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
if (e.pointerType === 'mouse' || e.pointerType === 'pen') events.fire('viewerPanel_show'); if (e.pointerType === 'mouse' || e.pointerType === 'pen') events.fire('viewerPanel_show');
}) })
.on('pointerup', (e) => { .on('pointerup', (e) => {
if (e.pointerType === 'touch') events.fire('viewerPanel_hide'); if (e.pointerType === 'touch') events.fire('viewerPanel_switch');
}) })
.on('mouseleave', () => { .on('mouseleave', () => {
events.fire('viewerPanel_hide'); events.fire('viewerPanel_hide');

View File

@ -8,7 +8,7 @@
} }
div.viewer { div.viewer {
height: calc(100vh - 2rem - var(--nav-height)); height: calc(100dvh - 2rem - var(--nav-height));
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
@ -18,10 +18,11 @@
width: calc(100vw - 300px - 4rem); width: calc(100vw - 300px - 4rem);
margin: 1rem; margin: 1rem;
position: relative; position: relative;
transition: all 0.3s ease;
@media (max-width: 800px) { @media (max-width: 800px) {
width: 100%; width: 100%;
height: calc(100vh - var(--nav-height)); height: calc(100dvh - var(--nav-height));
border-radius: 0; border-radius: 0;
margin:0; margin:0;
} }
@ -49,8 +50,63 @@
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
flex-direction: column;
padding: 1rem; padding: 1rem;
gap: 2rem; gap: 1rem;
box-sizing: border-box;
video-controller {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
width: 100%;
div.video-details {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
div {
display: flex;
align-items: center;
gap: 1rem;
}
}
div.progressbar-container {
height: 2rem;
width: 100%;
display: flex;
touch-action: none;
align-items: center;
div.progressbar {
height: 0.4rem;
width: 100%;
background-color: var(--secondary-color-1);
flex-shrink: 1;
cursor: pointer;
div.progress {
height: 100%;
background-color: var(--secondary-color-3);
width: 100px;
}
}
}
.play {
flex-shrink: 0;
}
}
div.buttons {
width: 100%;
display: flex;
justify-content: center;
gap: 2rem;
}
} }
div.overlay { div.overlay {
@ -112,8 +168,9 @@
width: 300px; width: 300px;
overflow: scroll; overflow: scroll;
overflow-x: hidden; overflow-x: hidden;
height: calc(100vh - 2rem - var(--nav-height)); height: calc(100dvh - 2rem - var(--nav-height));
border-radius: var(--border-radius-large); border-radius: var(--border-radius-large);
transition: all 0.3s ease;
@media (max-width: 800px) { @media (max-width: 800px) {
position: static; position: static;

View File

@ -47,4 +47,9 @@ export function digitalUnit(bytes: number) {
if (pb < 1000) return `${pb.toFixed(2)}PB`; if (pb < 1000) return `${pb.toFixed(2)}PB`;
const eb = bytes / (1000 * 6); const eb = bytes / (1000 * 6);
return `${eb.toFixed(2)}EB`; return `${eb.toFixed(2)}EB`;
}
const NUMBER_FORMAT = new Intl.NumberFormat('en', {notation: 'compact'})
export function numberFormat(number: number) {
return NUMBER_FORMAT.format(number)
} }

View File

@ -6,6 +6,9 @@ export default defineConfig({
target: 'http://localhost:3030', target: 'http://localhost:3030',
changeOrigin: true changeOrigin: true
}, },
},
watch: {
usePolling: true
} }
}, },
define: { define: {