fix: event passive.
remove: Post sidebar copy link buttons.
fix: page transition transform value undefined.
enhance: $PostGrid loader will stop when no more post can fetched.
enhance: reset $PostGrid loader when booru switched.
enhance: searchbar autocomplete result same as official site.
new: class Autocomplete.
new: post sidebar add File, Booru, Webm, Source url.
This commit is contained in:
defaultkavy 2024-10-06 23:12:53 +08:00
parent 86c2d5aa9f
commit 315246a37a
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
17 changed files with 224 additions and 72 deletions

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-DaDrA_5o.js vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -7,8 +7,8 @@
<title>Danbooru Viewer v0.2.5</title>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script type="module" crossorigin src="/assets/index-C21fHnwq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B240dcNf.css">
<script type="module" crossorigin src="/assets/index-DaDrA_5o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DeBFHjT4.css">
</head>
<body>
</body>

View File

@ -157,3 +157,8 @@ ion-icon {
color: var(--secondary-color);
}
}
a {
color: var(--secondary-color);
text-decoration: none;
}

View File

@ -2,7 +2,7 @@
"name": "danbooru-viewer",
"module": "index.ts",
"type": "module",
"version": "0.3.3",
"version": "0.3.4",
"devDependencies": {
"@types/bun": "latest",
"vite": "^5.4.8"

View File

@ -10,6 +10,7 @@ export class $PostGrid extends $Layout {
posts = new Set<Post>();
$posts = new Set<$PostTile>();
tags?: string;
finished = false;
constructor(options?: $PostGridOptions) {
super();
this.tags = options?.tags;
@ -20,7 +21,13 @@ export class $PostGrid extends $Layout {
protected async init() {
setInterval(() => { if (this.inDOM() && document.documentElement.scrollTop === 0) this.updateNewest(); }, 10000);
Booru.events.on('set', () => { this.removeAll(); })
Booru.events.on('set', () => {
this.removeAll();
if (this.finished) {
this.finished = false;
this.loader();
}
})
this.on('resize', () => this.resize())
this.loader();
}
@ -28,10 +35,13 @@ export class $PostGrid extends $Layout {
protected async loader() {
if (!this.inDOM()) return setTimeout(() => this.loader(), 100);;
while (this.inDOM() && document.documentElement.scrollHeight <= innerHeight * 2) {
await this.getPosts();
if (!this.posts.size) return;
const posts = await this.getPosts();
if (!posts.length) return this.finished = true;
}
if (document.documentElement.scrollTop + innerHeight > document.documentElement.scrollHeight - innerHeight * 2) {
const posts = await this.getPosts();
if (!posts.length) return this.finished = true;
}
if (document.documentElement.scrollTop + innerHeight > document.documentElement.scrollHeight - innerHeight * 2) await this.getPosts();
setTimeout(() => this.loader(), 100);
}
@ -65,14 +75,14 @@ export class $PostGrid extends $Layout {
const latestPost = this.sortedPosts.at(0);
const posts = await Post.fetchMultiple(Booru.used, {tags: this.tags, id: latestPost ? `>${latestPost.id}` : undefined}, 100);
this.addPost(posts);
return this;
return posts;
}
async getPosts() {
const oldestPost = this.sortedPosts.at(-1);
const posts = await Post.fetchMultiple(Booru.used, {tags: this.tags, id: oldestPost ? `<${oldestPost.id}` : undefined}, 100);
this.addPost(posts);
return this;
return posts;
}
get sortedPosts() { return this.posts.array.sort((a, b) => +b.createdDate - +a.createdDate); }

View File

@ -46,14 +46,14 @@ export class $PostTile extends $Container {
.once('load', (e, $img) => {
$img
.src(this.post.previewURL)
.on(['mouseenter', 'touchstart'], () => { if (this.post.isGif) { $img.src(this.post.large_file_url) } })
.on(['mouseleave', 'touchend', 'touchcancel'], () => { if (this.post.isGif) { $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');
})
])
.on(['mouseenter', 'touchstart'], () => { if (!this.$video?.isPlaying) { this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined) } })
.on(['mouseleave', 'touchend', 'touchcancel'], () => { this.$video?.pause().currentTime(0).hide(true); })
.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})
])
}

View File

@ -1,6 +1,8 @@
import { $Container } from "elexis";
import { Tag, TagCategory } from "../../structure/Tag";
import { Booru } from "../../structure/Booru";
import { User } from "../../structure/User";
import { Autocomplete } from "../../structure/Autocomplete";
export class $Searchbar extends $Container {
$tagInput = new $TagInput(this);
@ -128,21 +130,31 @@ export class $Searchbar extends $Container {
async getSearchSuggestions() {
const input = this.$tagInput.$input.value()
if (!input.length) return this.$selectionList.clearSelections();
const tags = await Tag.fetchMultiple(Booru.used, {fuzzy_name_matches: input, order: 'similarity'});
const results = await Autocomplete.fetch(Booru.used, input, 20);
this.$selectionList
.clearSelections()
.addSelections(tags.map(tag => new $Selection().value(tag.name)
.addSelections(results.map(data => new $Selection().value(data.value)
.content([
$('span').class('tag-name').content(tag.name),
$('span').class('tag-category').content(TagCategory[tag.category])
$('div').class('selection-label').content([
data.isTagAntecedent() ? $('span').class('tag-antecedent').self($span => $span.dom.innerHTML = data.antecedent.replaceAll(input, `<b>${input}</b>`)) : null,
$('div').class('label-container').content([
data.isTagAntecedent() ? $('ion-icon').name('arrow-forward-outline') : null,
$('span').class('label').self($span => $span.dom.innerHTML = data.label.replaceAll(input, `<b>${input}</b>`))
])
]),
data.isTag() ? $('div').class('tag-detail').content([
$('span').class('tag-post-count').content(new Intl.NumberFormat('en', {notation: 'compact'}).format(data.post_count)),
$('span').class('tag-category').content(TagCategory[data.category])
]) : null,
data.isUser() ? $('span').class('user-level').content(data.level) : null
])
.on('click', () => {this.$tagInput.addTag(tag.name).input()})
.on('click', () => {this.$tagInput.addTag(data.label).input()})
))
if (!this.$tagInput.$input.value().length) this.$selectionList.clearSelections();
}
search() {
$.replace(`/posts?tags=${this.$tagInput.query}`);
$.replace(`/posts?tags=${this.$tagInput.query.replace(':', '%3A')}`);
this.$tagInput.clearAll();
this.inactivate();
return this;
@ -177,10 +189,12 @@ class $SelectionList extends $Container {
return this;
}
focusSelection(selection: $Selection) {
focusSelection($selection: $Selection) {
this.blurSelection();
this.focused = selection;
selection.focus();
this.focused = $selection;
$selection.focus();
if ($selection.offsetTop < this.scrollTop()) this.scrollTop($selection.offsetTop);
if ($selection.offsetTop + $selection.offsetHeight > this.scrollTop() + this.offsetHeight) this.scrollTop($selection.offsetTop + $selection.offsetHeight - this.offsetHeight);
return this;
}

View File

@ -81,6 +81,7 @@ searchbar {
align-items: center;
padding: 0.4rem 1rem;
cursor: pointer;
gap: 1rem;
&:hover {
background-color: color-mix(in srgb, var(--background-color-lighter) 50%, transparent);
@ -88,9 +89,30 @@ searchbar {
&.active {
background-color: var(--background-color-lighter);
}
.tag-name {
div.selection-label {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.label-container {
display: flex;
gap: 0.5rem;
align-items: center;
ion-icon {
font-size: 1rem;
}
}
}
.tag-category {
div.tag-detail {
display: flex;
align-items: center;
gap: 0.5rem;
.tag-post-count {
font-size: 0.8rem;
}
}
.tag-category, .user-level {
padding: 0.1rem 0.4rem;
border-radius: 0.4rem;
font-size: 0.9rem;

View File

@ -81,7 +81,7 @@ $(document.body).content([
switch ($Router.navigationDirection) {
case $RouterNavigationDirection.Forward: return [`translateX(${TX}%)`, `translateX(0%)`];
case $RouterNavigationDirection.Back: return [`translateX(-${TX}%)`, `translateX(0%)`];
case $RouterNavigationDirection.Replace: return undefined;
case $RouterNavigationDirection.Replace: return '';
}
})
e.$view.content(e.nextContent);
@ -102,7 +102,7 @@ $(document.body).content([
switch ($Router.navigationDirection) {
case $RouterNavigationDirection.Forward: return [`translateX(0%)`, `translateX(-${TX}%)`];
case $RouterNavigationDirection.Back: return [`translateX(0%)`, `translateX(${TX}%)`];
case $RouterNavigationDirection.Replace: return undefined;
case $RouterNavigationDirection.Replace: return '';
}
})

View File

@ -29,53 +29,60 @@ 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 })
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').value(`#${params.id}`),
new $Property('uploader').name('Uploader').value(post.uploader$),
new $Property('approver').name('Approver').value(post.approver$),
new $Property('date').name('Date').value(post.created_date$),
new $Property('size').name('Size').value([post.file_size$, post.dimension$]),
new $Property('file').name('File Type').value(post.file_ext$),
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').value(post.favorites$),
new $Property('score').name('Score').value(post.score$)
]),
$('div').class('buttons').content([
$('icon-button').class('vertical').icon('link-outline').content(Booru.name$)
.on('click', (e, $button) => {
e.preventDefault();
navigator.clipboard.writeText(`${Booru.used.origin}${location.pathname}`);
$button.content('Copied!');
setTimeout(() => {
$button.content(Booru.name$)
}, 2000);
}),
$('icon-button').class('vertical').icon('link-outline').content(`File`)
.on('click', (e, $button) => {
e.preventDefault();
navigator.clipboard.writeText(post.file_url);
$button.content('Copied!');
setTimeout(() => {
$button.content('File')
}, 2000);
}),
$('icon-button').class('vertical').icon('link-outline').content(`Webm`)
.on('click', (e, $button) => {
e.preventDefault();
navigator.clipboard.writeText(post.previewURL);
$button.content('Copied!');
setTimeout(() => {
$button.content('Webm')
}, 2000);
})
.hide(true).self(async ($button) => { await post.ready; if (post.file_ext === 'zip') $button.hide(false) })
new $Property('favorites').name('Favorites').content(post.favorites$),
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')),
new $Property('source-url').name('Source').content($('a').href(post.source$).content(post.source$.convert((value) => value.replace('https://', ''))).target('_blank')),
new $Property('booru-url').name(Booru.name$).content($('a').href(post.url$).content(post.url$.convert((value) => value.replace('https://', ''))).target('_blank')),
new $Property('booru-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('buttons').content([
// $('icon-button').class('vertical').icon('link-outline').content(Booru.name$)
// .on('click', (e, $button) => {
// e.preventDefault();
// navigator.clipboard.writeText(`${Booru.used.origin}${location.pathname}`);
// $button.content('Copied!');
// setTimeout(() => {
// $button.content(Booru.name$)
// }, 2000);
// }),
// $('icon-button').class('vertical').icon('link-outline').content(`File`)
// .on('click', (e, $button) => {
// e.preventDefault();
// navigator.clipboard.writeText(post.file_url);
// $button.content('Copied!');
// setTimeout(() => {
// $button.content('File')
// }, 2000);
// }),
// $('icon-button').class('vertical').icon('link-outline').content(`Webm`)
// .on('click', (e, $button) => {
// e.preventDefault();
// navigator.clipboard.writeText(post.previewURL);
// $button.content('Copied!');
// setTimeout(() => {
// $button.content('Webm')
// }, 2000);
// })
// .hide(true).self(async ($button) => { await post.ready; if (post.file_ext === 'zip') $button.hide(false) })
// ]),
]),
$('div').class('post-tags').content(async $tags => {
const tags = await post.fetchTags();
@ -116,9 +123,9 @@ class $Property extends $Container {
constructor(id: string) {
super('div');
this.staticClass('property').attribute('property-id', id);
this.content([
super.content([
this.$name,
this.$values
this.$values.hide(true)
])
}
@ -127,7 +134,8 @@ class $Property extends $Container {
return this;
}
value(content: OrMatrix<$ContainerContentType>) {
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;

View File

@ -129,14 +129,22 @@
display: flex;
gap: 0.6rem;
align-items: center;
width: 100%;
span.property-name {
flex-shrink: 0;
}
div.property-values {
display: flex;
gap: 0.4rem;
overflow: hidden;
span.property-value {
background-color: var(--secondary-color-dark);
color: var(--secondary-color);
padding: 2px 4px;
border-radius: 4px;
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
}
}

View File

@ -0,0 +1,75 @@
import type { Booru } from "./Booru";
import type { TagCategory } from "./Tag";
import type { UserLevelString } from "./User";
export class Autocomplete {
static async fetch(booru: Booru, query: string, limit: number = 20) {
const res = await fetch(`${booru.origin}/autocomplete.json?search[query]=${query}&search[type]=tag_query&version=1&limit=${limit}`).then(res => res.json()) as AutocompleteData[];
return res.map(data => new AutocompleteResult(data));
}
}
export interface AutocompleteResult extends AutocompleteBaseData {}
export class AutocompleteResult {
constructor(data: AutocompleteData) {
Object.assign(this, data);
}
isTag(): this is AutocompleteResult & (AutocompleteTagData | AutocompleteTagAliasData | AutocompleteTagAutocorrectData) {
return this.type === 'tag' || this.type === 'tag-autocorrect' || this.type === 'tag-alias' || this.type === 'tag-word';
}
isTagAutocorrect(): this is AutocompleteResult & AutocompleteTagAutocorrectData {
return this.type === 'tag-autocorrect';
}
isTagAntecedent(): this is Autocomplete & AutocompleteTagAutocorrectData {
//@ts-expect-error
return !!this['antecedent' as any]
}
isTagWord(): this is AutocompleteResult & AutocompleteTagWordData {
return this.type === 'tag-word'
}
isUser(): this is AutocompleteResult & AutocompleteUserData {
return this.type === 'user';
}
}
type AutocompleteData = AutocompleteBaseData & (AutocompleteUserData | AutocompleteTagData | AutocompleteTagAutocorrectData | AutocompleteTagAliasData);
interface AutocompleteBaseData {
type: 'user' | 'tag' | 'tag-autocorrect' | 'tag-alias' | 'tag-word';
label: string;
value: string;
}
interface AutocompleteUserData {
type: 'user';
id: number;
level: Lowercase<UserLevelString>;
}
interface AutocompleteTagData {
type: 'tag';
category: TagCategory;
post_count: number;
}
interface AutocompleteTagAutocorrectData {
type: 'tag-autocorrect';
category: TagCategory;
post_count: number;
antecedent: string;
}
interface AutocompleteTagAliasData {
type: 'tag-alias';
category: TagCategory;
post_count: number;
antecedent: string;
}
interface AutocompleteTagWordData{
type: 'tag-word';
category: TagCategory;
post_count: number;
antecedent: string;
}

View File

@ -16,9 +16,13 @@ export class Post extends $EventManager<{update: []}> {
score$ = $.state(0);
file_size$ = $.state(LOADING_STRING);
file_ext$ = $.state(LOADING_STRING);
file_url$ = $.state(LOADING_STRING);
source$ = $.state(LOADING_STRING);
dimension$ = $.state(LOADING_STRING);
url$ = $.state(LOADING_STRING);
createdDate = new Date(this.created_at);
ready?: Promise<this>;
webm_url$ = $.state(LOADING_STRING);
booru: Booru;
constructor(booru: Booru, id: id, data?: PostData) {
@ -56,6 +60,7 @@ export class Post extends $EventManager<{update: []}> {
}
const req = await fetch(`${booru.origin}/posts.json?limit=${limit}&tags=${tagsQuery}&_method=get`);
const dataArray: PostData[] = await req.json();
if (dataArray instanceof Array === false) return [];
const list = dataArray.map(data => {
const instance = booru.posts.get(data.id)?.update(data) ?? new this(booru, data.id, data);
booru.posts.set(instance.id, instance);
@ -75,7 +80,11 @@ export class Post extends $EventManager<{update: []}> {
this.score$.set(this.score);
this.file_size$.set(digitalUnit(this.file_size));
this.file_ext$.set(this.file_ext as any);
this.file_url$.set(this.file_url);
this.source$.set(this.source);
this.dimension$.set(`${this.image_width}x${this.image_height}`);
this.url$.set(`${this.url}`);
if (this.isUgoria) this.webm_url$.set(this.large_file_url);
this.createdDate = new Date(this.created_at);
this.fire('update');
}
@ -102,7 +111,8 @@ 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 url() { return `${this.booru.origin}/posts/${this.id}` }
}
export interface PostData extends PostOptions {

View File

@ -66,7 +66,7 @@ export interface UserData {
}
export type UserLevel = 10 | 20 | 30 | 31 | 32 | 40 | 50;
export type UserLevelString = "Member" | "Gold" | "Platinum" | "Admin";
export type UserLevelString = "Member" | "Gold" | "Platinum" | "Admin" | "Contributor" | "Builder" | "Approver";
export interface UserProfileData extends UserData {
"last_logged_in_at": ISOString,