v0.10.0
- new - $DetailPanel on posts gallery page. - new - hotkeys support: - toggle detail panel. - video play pause. - new - LocalSettings
This commit is contained in:
parent
d6be4c0c62
commit
1913e010b6
1
dist/assets/index-BMZsSbMp.js
vendored
1
dist/assets/index-BMZsSbMp.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-BgVVzy-z.css
vendored
Normal file
1
dist/assets/index-BgVVzy-z.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-ByWqsQtl.css
vendored
1
dist/assets/index-ByWqsQtl.css
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-C_KR8nb7.js
vendored
Normal file
1
dist/assets/index-C_KR8nb7.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@ -16,8 +16,8 @@
|
||||
|
||||
gtag('config', 'G-59HBGP98WR');
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BMZsSbMp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ByWqsQtl.css">
|
||||
<script type="module" crossorigin src="/assets/index-C_KR8nb7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BgVVzy-z.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
32
index.scss
32
index.scss
@ -5,6 +5,7 @@
|
||||
@import '/src/component/IconButton/$IconButton';
|
||||
@import '/src/component/IonIcon/$IonIcon';
|
||||
@import '/src/component/Drawer/$Drawer';
|
||||
@import '/src/component/DetailPanel/$DetailPanel';
|
||||
// routes
|
||||
@import '/src/route/post/$post_route';
|
||||
@import '/src/route/login/$login_route';
|
||||
@ -154,8 +155,14 @@ nav {
|
||||
div.searchbar {
|
||||
display: none;
|
||||
}
|
||||
div.buttons ion-icon.search {
|
||||
display: inline-block;
|
||||
div.buttons ion-icon {
|
||||
&.search {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.detail-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -173,7 +180,7 @@ router {
|
||||
}
|
||||
|
||||
route#posts {
|
||||
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
h2 {
|
||||
@ -185,6 +192,25 @@ route#posts {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post-grid.detail-panel-enabled {
|
||||
width: calc(100vw - 300px - 4rem);
|
||||
@media (max-width: 800px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
detail-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
background-color: #2f2f45;
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
|
@ -2,7 +2,7 @@
|
||||
"name": "danbooru-viewer",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"scripts": {
|
||||
"dev": "bun x vite",
|
||||
"build": "bun x vite build",
|
||||
|
162
src/component/DetailPanel/$DetailPanel.ts
Normal file
162
src/component/DetailPanel/$DetailPanel.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { $Container, type $ContainerContentType } from "elexis";
|
||||
import type { Post } from "../../structure/Post";
|
||||
import { Booru } from "../../structure/Booru";
|
||||
import { Tag, TagCategory } from "../../structure/Tag";
|
||||
import { numberFormat } from "../../structure/Util";
|
||||
import type { $IonIcon } from "../IonIcon/$IonIcon";
|
||||
import type { $Route } from "@elexis/router";
|
||||
|
||||
export class $DetailPanel extends $Container {
|
||||
post: Post | null = null;
|
||||
options: $DetailPanelOptions;
|
||||
constructor(options?: $DetailPanelOptions) {
|
||||
super('detail-panel');
|
||||
this.options = {
|
||||
preview: options?.preview ?? false,
|
||||
tagsType: options?.tagsType ?? 'detail'
|
||||
};
|
||||
this.build();
|
||||
|
||||
}
|
||||
|
||||
private build() {
|
||||
if (this.post) {
|
||||
this.content([
|
||||
this.options.preview ? $('div').class('preview').content([
|
||||
$('img').src(this.post.previewURL)
|
||||
]) : null,
|
||||
$('div').class('detail').content([
|
||||
$('section').class('post-info').content([
|
||||
new $Property('id').name('Post').content(`#${this.post.id}`),
|
||||
new $Property('uploader').name('Uploader').content(this.post.uploader$),
|
||||
new $Property('approver').name('Approver').content(this.post.approver$),
|
||||
new $Property('date').name('Date').content(this.post.created_date$),
|
||||
new $Property('size').name('Size').content([this.post.file_size$, this.post.dimension$]),
|
||||
new $Property('file-type').name('File Type').content(this.post.file_ext$),
|
||||
$('div').class('inline').content([
|
||||
new $Property('favorites').name('Favorites').content(this.post.favcount$),
|
||||
new $Property('score').name('Score').content(this.post.score$)
|
||||
]),
|
||||
new $Property('file-url').name('File').content([
|
||||
$('a').href(this.post.file_url$).content(this.post.file_url$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.file_url))
|
||||
]),
|
||||
new $Property('source-url').name('Source').content([
|
||||
$('a').href(this.post.source$).content(this.post.source$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.source))
|
||||
]),
|
||||
new $Property('booru-url').name(Booru.name$).content([
|
||||
$('a').href(this.post.booruUrl$).content(this.post.booruUrl$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.booruUrl))
|
||||
]),
|
||||
new $Property('webm-url').name('Webm').hide(true).self(async ($property) => {
|
||||
await this.post!.ready;
|
||||
if (this.post!.isUgoria) $property.content($('a').href(this.post!.webm_url$).content(this.post!.webm_url$.convert((value) => value.replace('https://', ''))).target('_blank')).hide(false);
|
||||
}),
|
||||
]),
|
||||
$('div').class('post-tags').content(async $tags => {
|
||||
if (this.options.tagsType === 'detail') {
|
||||
const tags = await this.post!.fetchTags();
|
||||
const [artist_tags, char_tags, gen_tags, meta_tags, copy_tags] = [
|
||||
tags.filter(tag => tag.category === TagCategory.Artist),
|
||||
tags.filter(tag => tag.category === TagCategory.Character),
|
||||
tags.filter(tag => tag.category === TagCategory.General),
|
||||
tags.filter(tag => tag.category === TagCategory.Meta),
|
||||
tags.filter(tag => tag.category === TagCategory.Copyright),
|
||||
]
|
||||
|
||||
function $tag_category(category: string, tags: Tag[]) {
|
||||
return tags.length ? [
|
||||
$('h3').content(category),
|
||||
$('section').content([
|
||||
tags.map(tag => $('div').class('tag').content([
|
||||
$('a').class('tag-name').content(tag.name).href(`/posts?tags=${tag.name}`),
|
||||
$('span').class('tag-post-count').content(tag.post_count$.convert(numberFormat))
|
||||
]))
|
||||
])
|
||||
] : null
|
||||
}
|
||||
|
||||
return [
|
||||
$tag_category('Artist', artist_tags),
|
||||
$tag_category('Character', char_tags),
|
||||
$tag_category('Copyright', copy_tags),
|
||||
$tag_category('Meta', meta_tags),
|
||||
$tag_category('General', gen_tags),
|
||||
]
|
||||
} else {
|
||||
function $tag_category(category: string, tags: string[]) {
|
||||
return tags.at(0)?.length ? [
|
||||
$('h3').content(category),
|
||||
$('section').class('tag-name-only').content([
|
||||
tags.map(tag => $('a').class('tag').content(tag).href(`/posts?tags=${tag}`)),
|
||||
])
|
||||
] : null
|
||||
}
|
||||
return [
|
||||
$tag_category('Artist', this.post!.tag_string_artist.split(' ')),
|
||||
$tag_category('Character', this.post!.tag_string_character.split(' ')),
|
||||
$tag_category('Copyright', this.post!.tag_string_copyright.split(' ')),
|
||||
$tag_category('Meta', this.post!.tag_string_meta.split(' ')),
|
||||
$tag_category('General', this.post!.tag_string_general.split(' ')),
|
||||
]
|
||||
}
|
||||
})
|
||||
])
|
||||
])
|
||||
} else {
|
||||
this.content($('span').class('no-content').content('No Selected'))
|
||||
}
|
||||
}
|
||||
|
||||
update(post: Post | null) {
|
||||
this.post = post;
|
||||
this.build();
|
||||
return this;
|
||||
}
|
||||
|
||||
private copyButtonHandler($ion: $IonIcon, text: string) {
|
||||
$ion.name('checkmark');
|
||||
navigator.clipboard.writeText(text);
|
||||
setTimeout(() => $ion.name('clipboard'), 3000);
|
||||
}
|
||||
|
||||
position($route: $Route<any>) {
|
||||
let scrollTop = 0;
|
||||
addEventListener('scroll', () => { if (this.inDOM()) scrollTop = document.documentElement.scrollTop }, {passive: true})
|
||||
$route
|
||||
.on('beforeShift', () => { if (innerWidth > 800) this.css({position: `absolute`, top: `calc(${scrollTop}px + var(--nav-height) + var(--padding))`}) })
|
||||
.on('afterShift', () => this.css({position: '', top: ''}))
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export interface $DetailPanelOptions {
|
||||
preview?: boolean;
|
||||
tagsType?: 'detail' | 'name_only';
|
||||
}
|
||||
|
||||
class $Property extends $Container {
|
||||
$name = $('span').class('property-name')
|
||||
$values = $('div').class('property-values')
|
||||
constructor(id: string) {
|
||||
super('div');
|
||||
this.staticClass('property').attribute('property-id', id);
|
||||
super.content([
|
||||
this.$name,
|
||||
this.$values.hide(true)
|
||||
])
|
||||
}
|
||||
|
||||
name(content: $ContainerContentType) {
|
||||
this.$name.content(content);
|
||||
return this;
|
||||
}
|
||||
|
||||
content(content: OrMatrix<$ContainerContentType>) {
|
||||
this.$values.hide(false);
|
||||
const list = $.orArrayResolve(content);
|
||||
this.$values.content(list.map($item => $('span').staticClass('property-value').content($item)));
|
||||
return this;
|
||||
}
|
||||
}
|
162
src/component/DetailPanel/_$DetailPanel.scss
Normal file
162
src/component/DetailPanel/_$DetailPanel.scss
Normal file
@ -0,0 +1,162 @@
|
||||
detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
--padding: 1rem;
|
||||
position: fixed;
|
||||
top: calc(var(--nav-height) + var(--padding));
|
||||
right: var(--padding);
|
||||
width: 300px;
|
||||
height: calc(100dvh - 2rem - var(--nav-height));
|
||||
// transition: all 0.3s ease;
|
||||
background-color: var(--secondary-color-1);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
position: static;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
span.no-content {
|
||||
color: var(--secondary-color-3);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
div.preview {
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-large);
|
||||
height: 300px;
|
||||
width: 300px;
|
||||
background-color: var(--secondary-color-0);
|
||||
img {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
div.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow: scroll;
|
||||
overflow-x: hidden;
|
||||
border-radius: var(--border-radius-large);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
background-color: #000000;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #aeaeec;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
h3 {
|
||||
padding-left: 1rem;
|
||||
margin-block: 0.6rem;
|
||||
}
|
||||
.post-info {
|
||||
background-color: #2f2f45;
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.property {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
span.property-name {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
div.property-values {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
span.property-value {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: var(--secondary-color-1);
|
||||
color: var(--primary-color-dark);
|
||||
border-radius: var(--border-radius-small);
|
||||
justify-content: space-between;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&:has(ion-icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div.inline {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
div.post-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
div.tag {
|
||||
align-items: center;
|
||||
a.tag-name {
|
||||
word-break: break-word;
|
||||
text-decoration: none;
|
||||
}
|
||||
span.tag-post-count {
|
||||
background-color: var(--secondary-color-3);
|
||||
color: var(--secondary-color-8);
|
||||
padding: 0px 4px;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 12px;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
section.tag-name-only {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 0.5rem;
|
||||
a.tag {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -34,6 +34,9 @@ export class $PostGrid extends $Layout<$PostGridEventMap> {
|
||||
}
|
||||
})
|
||||
this.on('resize', () => this.resize())
|
||||
this.on('afterRender', () => {
|
||||
this.$focus.currentLayer?.focus(this.$focus.currentLayer.currentFocus);
|
||||
})
|
||||
this.events.fire('startLoad');
|
||||
this.loader();
|
||||
this.$focus.layer(100).loop(false).scrollThreshold($.rem(2) + 60);
|
||||
@ -44,11 +47,11 @@ export class $PostGrid extends $Layout<$PostGridEventMap> {
|
||||
if (!this.inDOM()) return;
|
||||
return true;
|
||||
})
|
||||
.keydown('Tab', e => {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this.$focus.prev();
|
||||
else this.$focus.next();
|
||||
})
|
||||
// .keydown('Tab', e => {
|
||||
// e.preventDefault();
|
||||
// if (e.shiftKey) this.$focus.prev();
|
||||
// else this.$focus.next();
|
||||
// })
|
||||
.keydown(['w', 'W'], e => { e.preventDefault(); this.$focus.up(); })
|
||||
.keydown(['s', 'S'], e => { e.preventDefault(); this.$focus.down(); })
|
||||
.keydown(['d', 'D'], e => { e.preventDefault(); this.$focus.right(); })
|
||||
@ -169,7 +172,8 @@ interface $PostGridEventMap extends $LayoutEventMap {
|
||||
startLoad: [];
|
||||
noPost: [];
|
||||
endPost: [];
|
||||
post_error: [message: string]
|
||||
post_error: [message: string];
|
||||
|
||||
}
|
||||
|
||||
interface FavoritesData {
|
||||
|
@ -1,12 +1,19 @@
|
||||
layout.post-grid {
|
||||
margin-top: 0.4rem;
|
||||
margin-block: 1rem;
|
||||
|
||||
a {
|
||||
transition: 0.3s all ease;
|
||||
}
|
||||
&:has(post-tile[focus]) {
|
||||
post-tile:not([focus]) {
|
||||
opacity: 0.5;
|
||||
a {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
post-tile:hover {
|
||||
opacity: 1;
|
||||
a {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,28 +1,26 @@
|
||||
import { $Container, $Image, $State, $Video } from "elexis";
|
||||
import type { Post } from "../../structure/Post";
|
||||
import { time } from "../../structure/Util";
|
||||
import { detailPanelEnable$ } from "../../main";
|
||||
export class $PostTile extends $Container {
|
||||
post: Post;
|
||||
$video: $Video | null;
|
||||
$img: $Image;
|
||||
duration$ = $.state(``);
|
||||
constructor(post: Post) {
|
||||
super('post-tile');
|
||||
this.post = post;
|
||||
this.$video = this.post.isVideo ? $('video').width(this.post.image_width).height(this.post.image_height).disablePictureInPicture(true).loop(true).muted(true).hide(true).on('mousedown', (e) => e.preventDefault()) : null;
|
||||
this.$img = $('img').draggable(false).css({opacity: '0'}).width(this.post.image_width).height(this.post.image_height).src(this.post.previewURL).loading('lazy');
|
||||
this.attribute('filetype', this.post.file_ext);
|
||||
this.durationUpdate();
|
||||
this.build();
|
||||
}
|
||||
|
||||
build() {
|
||||
let timer: Timer
|
||||
this.$video?.on('timeupdate', (e, $video) => {
|
||||
this.durationUpdate();
|
||||
})
|
||||
this.$video?.on('pause', () => {
|
||||
clearInterval(timer);
|
||||
this.durationUpdate();
|
||||
})
|
||||
this.class('loading').content([
|
||||
// Video Detail
|
||||
this.post.isVideo
|
||||
@ -37,22 +35,31 @@ export class $PostTile extends $Container {
|
||||
$('span').content('GIF')
|
||||
]) : null,
|
||||
// Tile
|
||||
$('a').href(this.post.pathname).content(() => [
|
||||
$('a').href(this.post.pathname).preventDefault(detailPanelEnable$).content(() => [
|
||||
this.$video,
|
||||
$('img').draggable(false).css({opacity: '0'}).width(this.post.image_width).height(this.post.image_height).src(this.post.previewURL).loading('lazy')
|
||||
.on('mousedown', (e) => e.preventDefault())
|
||||
.once('load', (e, $img) => {
|
||||
$img
|
||||
.src(this.post.previewURL)
|
||||
.on(['mouseenter', 'touchstart'], () => { if (this.post.isGif) { $img.src(this.post.large_file_url) } }, {passive: true})
|
||||
.on(['mouseleave', 'touchend', 'touchcancel'], () => { if (this.post.isGif) { $img.src(this.post.previewURL) } }, {passive: true})
|
||||
.animate({opacity: [0, 1]}, {duration: 300, fill: 'both'});
|
||||
this.removeClass('loading');
|
||||
this.$img.on('mousedown', (e) => e.preventDefault())
|
||||
.once('load', (e, $img) => {
|
||||
$img.animate({opacity: [0, 1]}, {duration: 300}, () => $img.css({opacity: ''}));
|
||||
this.removeClass('loading');
|
||||
})
|
||||
])
|
||||
.on(['mouseenter', 'touchstart'], () => { if (!this.$video?.isPlaying) { this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined) } }, {passive: true})
|
||||
.on(['mouseleave', 'touchend', 'touchcancel'], () => { this.$video?.pause().currentTime(0).hide(true); }, {passive: true})
|
||||
])
|
||||
this.on(['focus', 'mouseenter', 'touchstart'], () => {
|
||||
if (!this.$video?.isPlaying) {
|
||||
this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined)
|
||||
}
|
||||
if (this.post.isGif) { this.$img.src(this.post.large_file_url) }
|
||||
}, {passive: true} )
|
||||
.on(['blur', 'mouseleave', 'touchend', 'touchcancel'], () => {
|
||||
this.$video?.pause().currentTime(0).hide(true);
|
||||
if (this.post.isGif) { this.$img.src(this.post.previewURL) }
|
||||
}, {passive: true} )
|
||||
.on('click', () => {
|
||||
if (!detailPanelEnable$.value) return;
|
||||
if (innerWidth <= 800) return $.open(this.post.pathname);
|
||||
if ($(document.activeElement) === this) $.open(this.post.pathname);
|
||||
else this.focus();
|
||||
})
|
||||
}
|
||||
|
||||
durationUpdate() {
|
||||
|
@ -8,6 +8,7 @@ post-tile {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
outline: transparent solid 2px;
|
||||
background-color: var(--secondary-color-1);
|
||||
|
||||
&[focus] {
|
||||
outline: var(--secondary-color-9) solid 2px;
|
||||
|
39
src/main.ts
39
src/main.ts
@ -4,25 +4,29 @@ import '@elexis/router';
|
||||
import { Booru } from './structure/Booru';
|
||||
import { post_route } from './route/post/$post_route';
|
||||
import { $PostGrid } from './component/PostGrid/$PostGrid';
|
||||
import { $Router, $RouterNavigationDirection } from '@elexis/router';
|
||||
import { $Route, $Router, $RouterAnchor, $RouterNavigationDirection } from '@elexis/router';
|
||||
import { $Searchbar } from './component/Searchbar/$Searchbar';
|
||||
import { $IonIcon } from './component/IonIcon/$IonIcon';
|
||||
import { $IconButton } from './component/IconButton/$IconButton';
|
||||
import { $login_route } from './route/login/$login_route';
|
||||
import { $Drawer } from './component/Drawer/$Drawer';
|
||||
import { $Input } from 'elexis/lib/node/$Input';
|
||||
import { $DetailPanel } from './component/DetailPanel/$DetailPanel';
|
||||
import { $PostTile } from './component/PostTile/$PostTile';
|
||||
import { LocalSettings } from './structure/LocalSettings';
|
||||
// declare elexis module
|
||||
declare module 'elexis' {
|
||||
export namespace $ {
|
||||
export interface TagNameElementMap {
|
||||
'ion-icon': typeof $IonIcon;
|
||||
'icon-button': typeof $IconButton;
|
||||
'a': typeof $RouterAnchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
$.registerTagName('ion-icon', $IonIcon)
|
||||
$.registerTagName('icon-button', $IconButton)
|
||||
$.anchorHandler = ($a) => { $.open($a.href(), $a.target())}
|
||||
$.registerTagName('a', $RouterAnchor)
|
||||
// settings
|
||||
export const [danbooru, safebooru]: Booru[] = [
|
||||
new Booru({ origin: 'https://danbooru.donmai.us', name: 'Danbooru' }),
|
||||
@ -32,6 +36,7 @@ export const [danbooru, safebooru]: Booru[] = [
|
||||
Booru.set(Booru.manager.get(Booru.storageAPI ?? '') ?? danbooru);
|
||||
const $searchbar = new $Searchbar().hide(true);
|
||||
const $drawer = new $Drawer();
|
||||
export const detailPanelEnable$ = $.state(LocalSettings.detailPanelEnabled ?? false).on('update', ({state$}) => LocalSettings.detailPanelEnabled = state$.value)
|
||||
|
||||
// render
|
||||
$(document.body).content([
|
||||
@ -55,6 +60,8 @@ $(document.body).content([
|
||||
$('ion-icon').class('search').name('search-outline').title('Search')
|
||||
.self($self => $Router.events.on('stateChange', ({beforeURL, afterURL}) => {if (beforeURL.hash === '#search') $self.hide(false); if (afterURL.hash === '#search') $self.hide(true)}))
|
||||
.on('click', () => $searchbar.open()),
|
||||
// Detail Panel Button
|
||||
$('ion-icon').class('detail-panel').name('reader-outline').title('Toggle Detail Panel').on('click', () => detailPanelEnable$.set(!detailPanelEnable$.value)),
|
||||
// Open Booru
|
||||
$('a').content($('ion-icon').class('open').name('open-outline').title('Open in Original Site')).href(location.href.replace(location.origin, Booru.used.origin)).target('_blank'),
|
||||
// Copy Button
|
||||
@ -87,10 +94,13 @@ $(document.body).content([
|
||||
// Base Router
|
||||
$('router').base('/').map([
|
||||
// Home Page
|
||||
$('route').id('posts').path(['/', '/posts']).builder(() => new $PostGrid()),
|
||||
$('route').id('posts').path(['/', '/posts']).builder(({$route, query}) => {
|
||||
const { $postGrid, $detail } = $postsPageComponents($route, query);
|
||||
return [ $postGrid, $detail ]
|
||||
}),
|
||||
// Posts Page
|
||||
$('route').id('posts').path('/posts?tags').builder(({query}) => {
|
||||
const $postGrid = new $PostGrid({tags: query.tags});
|
||||
$('route').id('posts').path('/posts?tags').builder(({$route, query}) => {
|
||||
const { $postGrid, $detail } = $postsPageComponents($route, query)
|
||||
return [
|
||||
$('header').content([
|
||||
$('h2').content('Posts'),
|
||||
@ -106,7 +116,8 @@ $(document.body).content([
|
||||
.on('noPost', () => $div.hide(false).content('No Posts'))
|
||||
.on('post_error', message => $div.hide(false).content(message))
|
||||
}),
|
||||
$postGrid
|
||||
$postGrid,
|
||||
$detail
|
||||
]
|
||||
}),
|
||||
// Post Page
|
||||
@ -173,10 +184,24 @@ componentState(undefined, new URL(location.href))
|
||||
function componentState(beforeURL: URL | undefined, afterURL: URL) {
|
||||
$searchbar.checkURL(beforeURL, afterURL); $drawer.checkURL(beforeURL, afterURL)
|
||||
}
|
||||
|
||||
function $postsPageComponents($route: $Route, query: {tags?: string}) {
|
||||
const $postGrid = new $PostGrid(query);
|
||||
const $detail = new $DetailPanel({preview: true, tagsType: 'name_only'}).hide(detailPanelEnable$.convert(bool => !bool)).position($route);
|
||||
detailPanelCheck();
|
||||
detailPanelEnable$.on('update', detailPanelCheck)
|
||||
function detailPanelCheck() { detailPanelEnable$.value ? $postGrid.addClass('detail-panel-enabled') : $postGrid.removeClass('detail-panel-enabled') }
|
||||
$postGrid.$focus
|
||||
.on('focus', ({$focused: $target}) => {if ($target instanceof $PostTile) $detail.update($target.post) })
|
||||
.on('blur', () => $detail.update(null))
|
||||
return { $postGrid, $detail };
|
||||
}
|
||||
|
||||
$.keys($(window))
|
||||
.if(e => {
|
||||
if ($(e.target) instanceof $Input) return;
|
||||
return true;
|
||||
})
|
||||
.keydown(['q', 'Q'], e => { e.preventDefault(); if ($Router.index !== 0) $.back(); })
|
||||
.keydown(['e', 'E'], e => { e.preventDefault(); if ($Router.forwardIndex !== 0) $.forward(); })
|
||||
.keydown(['e', 'E'], e => { e.preventDefault(); if ($Router.forwardIndex !== 0) $.forward(); })
|
||||
.keydown('Tab', e => { e.preventDefault(); detailPanelEnable$.set(!detailPanelEnable$.value) })
|
@ -1,17 +1,15 @@
|
||||
import { Post } from "../../structure/Post";
|
||||
import { $Container, type $ContainerContentType } from "elexis";
|
||||
import { Tag, TagCategory } from "../../structure/Tag";
|
||||
import { ArtistCommentary } from "../../structure/Commentary";
|
||||
import { Booru } from "../../structure/Booru";
|
||||
import type { $IonIcon } from "../../component/IonIcon/$IonIcon";
|
||||
import { numberFormat } from "../../structure/Util";
|
||||
import { ClientUser } from "../../structure/ClientUser";
|
||||
import { $VideoController } from "../../component/VideoController/$VideoController";
|
||||
import { $Input } from "elexis/lib/node/$Input";
|
||||
import { $DetailPanel } from "../../component/DetailPanel/$DetailPanel";
|
||||
|
||||
export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => {
|
||||
if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND');
|
||||
const post = Post.get(Booru.used, +params.id);
|
||||
const $video = $('video');
|
||||
const events = $.events<{
|
||||
viewerPanel_hide: [],
|
||||
viewerPanel_show: [],
|
||||
@ -29,9 +27,13 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
|
||||
if (Booru.used.user?.favorites.has(post.id)) post.deleteFavorite();
|
||||
else post.createFavorite();
|
||||
})
|
||||
.keydown(' ', e => {
|
||||
e.preventDefault();
|
||||
if ($video.isPlaying) $video.pause();
|
||||
else $video.play();
|
||||
})
|
||||
return [
|
||||
$('div').class('viewer').content(async ($viewer) => {
|
||||
const $video = $('video');
|
||||
events.on('video_play_pause', () => { if ($video.isPlaying) $video.pause(); else $video.play() })
|
||||
await post.ready;
|
||||
$viewer
|
||||
@ -95,103 +97,6 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
|
||||
]
|
||||
})
|
||||
]),
|
||||
$('div').class('sidebar')
|
||||
.self($sidebar => {
|
||||
let scrollTop = 0;
|
||||
addEventListener('scroll', () => { if ($sidebar.inDOM()) scrollTop = document.documentElement.scrollTop }, {passive: true})
|
||||
$route
|
||||
.on('beforeShift', () => { if (innerWidth > 800) $sidebar.css({position: `absolute`, top: `calc(${scrollTop}px + var(--nav-height) + var(--padding))`}) })
|
||||
.on('afterShift', () => $sidebar.css({position: '', top: ''}))
|
||||
})
|
||||
.content([
|
||||
$('section').class('post-info').content([
|
||||
new $Property('id').name('Post').content(`#${params.id}`),
|
||||
new $Property('uploader').name('Uploader').content(post.uploader$),
|
||||
new $Property('approver').name('Approver').content(post.approver$),
|
||||
new $Property('date').name('Date').content(post.created_date$),
|
||||
new $Property('size').name('Size').content([post.file_size$, post.dimension$]),
|
||||
new $Property('file-type').name('File Type').content(post.file_ext$),
|
||||
$('div').class('inline').content([
|
||||
new $Property('favorites').name('Favorites').content(post.favcount$),
|
||||
new $Property('score').name('Score').content(post.score$)
|
||||
]),
|
||||
new $Property('file-url').name('File').content([
|
||||
$('a').href(post.file_url$).content(post.file_url$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => copyButtonHandler($ion, post.file_url))
|
||||
]),
|
||||
new $Property('source-url').name('Source').content([
|
||||
$('a').href(post.source$).content(post.source$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => copyButtonHandler($ion, post.source))
|
||||
]),
|
||||
new $Property('booru-url').name(Booru.name$).content([
|
||||
$('a').href(post.booruUrl$).content(post.booruUrl$.convert((value) => value.replace('https://', ''))).target('_blank'),
|
||||
$('ion-icon').name('clipboard').on('click', (e, $ion) => copyButtonHandler($ion, post.booruUrl))
|
||||
]),
|
||||
new $Property('webm-url').name('Webm').hide(true).self(async ($property) => {
|
||||
await post.ready;
|
||||
if (post.isUgoria) $property.content($('a').href(post.webm_url$).content(post.webm_url$.convert((value) => value.replace('https://', ''))).target('_blank')).hide(false);
|
||||
}),
|
||||
]),
|
||||
$('div').class('post-tags').content(async $tags => {
|
||||
const tags = await post.fetchTags();
|
||||
const [artist_tags, char_tags, gen_tags, meta_tags, copy_tags] = [
|
||||
tags.filter(tag => tag.category === TagCategory.Artist),
|
||||
tags.filter(tag => tag.category === TagCategory.Character),
|
||||
tags.filter(tag => tag.category === TagCategory.General),
|
||||
tags.filter(tag => tag.category === TagCategory.Meta),
|
||||
tags.filter(tag => tag.category === TagCategory.Copyright),
|
||||
]
|
||||
return [
|
||||
$tag_category('Artist', artist_tags),
|
||||
$tag_category('Character', char_tags),
|
||||
$tag_category('Copyright', copy_tags),
|
||||
$tag_category('Meta', meta_tags),
|
||||
$tag_category('General', gen_tags),
|
||||
]
|
||||
|
||||
function $tag_category(category: string, tags: Tag[]) {
|
||||
return tags.length ? [
|
||||
$('h3').content(category),
|
||||
$('section').content([
|
||||
tags.map(tag => $('div').class('tag').content([
|
||||
$('a').class('tag-name').content(tag.name).href(`/posts?tags=${tag.name}`),
|
||||
$('span').class('tag-post-count').content(tag.post_count$.convert(numberFormat))
|
||||
]))
|
||||
])
|
||||
] : null
|
||||
}
|
||||
})
|
||||
])
|
||||
new $DetailPanel().position($route).update(post)
|
||||
]
|
||||
})
|
||||
|
||||
function copyButtonHandler($ion: $IonIcon, text: string) {
|
||||
$ion.name('checkmark');
|
||||
navigator.clipboard.writeText(text);
|
||||
setTimeout(() => $ion.name('clipboard'), 3000);
|
||||
}
|
||||
|
||||
class $Property extends $Container {
|
||||
$name = $('span').class('property-name')
|
||||
$values = $('div').class('property-values')
|
||||
constructor(id: string) {
|
||||
super('div');
|
||||
this.staticClass('property').attribute('property-id', id);
|
||||
super.content([
|
||||
this.$name,
|
||||
this.$values.hide(true)
|
||||
])
|
||||
}
|
||||
|
||||
name(content: $ContainerContentType) {
|
||||
this.$name.content(content);
|
||||
return this;
|
||||
}
|
||||
|
||||
content(content: OrMatrix<$ContainerContentType>) {
|
||||
this.$values.hide(false);
|
||||
const list = $.orArrayResolve(content);
|
||||
this.$values.content(list.map($item => $('span').staticClass('property-value').content($item)));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
})
|
@ -1,11 +1,6 @@
|
||||
#post {
|
||||
padding: 0;
|
||||
padding-top: var(--nav-height);
|
||||
section {
|
||||
background-color: #2f2f45;
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
div.viewer {
|
||||
height: calc(100dvh - 2rem - var(--nav-height));
|
||||
@ -80,13 +75,13 @@
|
||||
display: flex;
|
||||
touch-action: none;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
div.progressbar {
|
||||
height: 0.4rem;
|
||||
width: 100%;
|
||||
background-color: var(--secondary-color-1);
|
||||
flex-shrink: 1;
|
||||
cursor: pointer;
|
||||
|
||||
div.progress {
|
||||
height: 100%;
|
||||
@ -156,129 +151,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.sidebar {
|
||||
--padding: 1rem;
|
||||
position: fixed;
|
||||
top: calc(var(--nav-height) + var(--padding));
|
||||
right: var(--padding);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
width: 300px;
|
||||
overflow: scroll;
|
||||
overflow-x: hidden;
|
||||
height: calc(100dvh - 2rem - var(--nav-height));
|
||||
border-radius: var(--border-radius-large);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
position: static;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
height: 100%;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
background-color: #000000;
|
||||
width: 0px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #aeaeec;
|
||||
}
|
||||
|
||||
h3 {
|
||||
padding-left: 1rem;
|
||||
margin-block: 0.6rem;
|
||||
}
|
||||
.post-info {
|
||||
background-color: #2f2f45;
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.property {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
span.property-name {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
div.property-values {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
span.property-value {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: var(--secondary-color-1);
|
||||
color: var(--primary-color-dark);
|
||||
border-radius: var(--border-radius-small);
|
||||
justify-content: space-between;
|
||||
flex-shrink: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
&:has(ion-icon) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1rem;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div.inline {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
div.post-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
div.tag {
|
||||
align-items: center;
|
||||
a.tag-name {
|
||||
word-break: break-word;
|
||||
text-decoration: none;
|
||||
}
|
||||
span.tag-post-count {
|
||||
background-color: var(--secondary-color-3);
|
||||
color: var(--secondary-color-8);
|
||||
padding: 0px 4px;
|
||||
border-radius: var(--border-radius-small);
|
||||
font-size: 12px;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// animated resolver
|
||||
|
11
src/structure/LocalSettings.ts
Normal file
11
src/structure/LocalSettings.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export class LocalSettings {
|
||||
static get detailPanelEnabled() { return this.localdata?.detailPanelEnabled }
|
||||
static set detailPanelEnabled(boolean: boolean | undefined) { this.localdata = {...this.localdata, detailPanelEnabled: boolean }}
|
||||
|
||||
static get localdata() { const data = localStorage.getItem('local_settings_data'); return data ? JSON.parse(data) as LocalSettingsStoreData : null }
|
||||
static set localdata(data: LocalSettingsStoreData | null) { localStorage.setItem('local_settings_data', JSON.stringify(data)) }
|
||||
}
|
||||
|
||||
export interface LocalSettingsStoreData {
|
||||
detailPanelEnabled?: boolean;
|
||||
}
|
@ -4,7 +4,6 @@ import { Tag } from "./Tag";
|
||||
import { User } from "./User";
|
||||
import { dateFrom, digitalUnit } from "./Util";
|
||||
import { ClientUser } from "./ClientUser";
|
||||
import type { FavoriteData } from "./Favorite";
|
||||
|
||||
const LOADING_STRING = '...'
|
||||
|
||||
@ -132,7 +131,7 @@ export class Post extends $EventManager<{update: []}> {
|
||||
const tag_list = this.tag_string.split(' ');
|
||||
return [...this.booru.tags.values()].filter(tag => tag_list.includes(tag.name))
|
||||
}
|
||||
get previewURL() { return this.media_asset.variants?.find(variant => variant.file_ext === 'webp')?.url ?? this.large_file_url }
|
||||
get previewURL() { return this.media_asset?.variants?.find(variant => variant.file_ext === 'webp')?.url ?? this.large_file_url }
|
||||
get booruUrl() { return `${this.booru.origin}/posts/${this.id}` }
|
||||
get url() { return `https://danbooru.defaultkavy.com/posts/${this.id}` }
|
||||
get isFileSource() { return this.source.startsWith('file://') }
|
||||
|
Loading…
Reference in New Issue
Block a user