This commit is contained in:
defaultkavy 2024-10-06 02:21:31 +08:00
parent 33a588a3f5
commit b5eb4811d6
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
26 changed files with 973 additions and 176 deletions

1
dist/assets/index-359h29Hw.js vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
post{display:block}post[filetype=mp4] span.duration,post[filetype=webm] span.duration,post[filetype=zip] span.duration{background-color:#fff;color:#000}post span.duration{position:absolute;background-color:#00000050;bottom:.3rem;right:.3rem;padding:2px 4px;border-radius:4px;font-size:12px;text-transform:uppercase;z-index:2}post img{border-radius:10px;height:100%;width:100%}post video{border-radius:10px;height:100%;width:100%;object-fit:cover;position:absolute}#post{padding:0}#post section{background-color:#2f2f45;border-radius:20px;padding:20px}#post div.viewer{height:calc(100vh - 2rem);display:flex;justify-content:center;align-items:center;background-color:#000;border-radius:20px;overflow:hidden;width:calc(100vw - 300px - 4rem);margin:1rem}@media (max-width: 800px){#post div.viewer{width:100%;height:100vh;border-radius:0;margin:0}}#post div.viewer img{max-width:100%;max-height:100%}#post div.viewer video{max-width:100%;max-height:100%}#post div.content{width:calc(100vw - 300px - 4rem);display:flex;flex-direction:column;padding:1rem;box-sizing:border-box}@media (max-width: 800px){#post div.content{width:100%}}#post div.content::-webkit-scrollbar{background-color:#000;width:4px}#post div.content::-webkit-scrollbar-thumb{background-color:#aeaeec;border-radius:2px}#post div.content>h3{padding-left:1rem;margin-block:1rem}#post div.content section.commentary *{text-wrap:wrap;word-break:break-word}#post div.sidebar{position:fixed;top:1rem;right:1rem;display:flex;flex-direction:column;gap:.4rem;width:300px;overflow:scroll;overflow-x:hidden;height:calc(100vh - 2rem);border-radius:20px}@media (max-width: 800px){#post div.sidebar{position:static;width:100%;overflow:visible;height:100%;padding:1rem;box-sizing:border-box}}#post div.sidebar::-webkit-scrollbar{background-color:#000;width:0px}#post div.sidebar::-webkit-scrollbar-thumb{background-color:#aeaeec}#post div.sidebar h3{padding-left:1rem;margin-block:.6rem}#post div.sidebar .post-info{background-color:#2f2f45;border-radius:20px;padding:20px;display:flex;flex-direction:column;gap:.4rem}#post div.sidebar div.property{display:flex;gap:.6rem;align-items:center}#post div.sidebar div.property div.property-values{display:flex;gap:.4rem}#post div.sidebar div.property div.property-values span.property-value{background-color:#525278;color:#aeaeec;padding:2px 4px;border-radius:4px}#post div.sidebar div.inline{display:flex;gap:1rem}#post div.sidebar div.post-tags{display:flex;flex-direction:column;gap:.2rem}#post div.sidebar div.post-tags div.tag{align-items:center}#post div.sidebar div.post-tags div.tag a.tag-name{word-break:break-word;color:#d1d1ee;text-decoration:none}#post div.sidebar div.post-tags div.tag span.tag-post-count{background-color:#525278;color:#aeaeec;padding:0 4px;border-radius:4px;font-size:12px;margin-left:.4rem}page#root layout *{transition:all .3s ease}page#root .loader{text-align:center;padding-block:2rem}html{overflow-x:hidden}body{background-color:#1e1e2c;color:#d1d1ee;margin:0;font-family:Microsoft Yahei;font-size:14px}body::-webkit-scrollbar{background-color:#000;width:8px}body::-webkit-scrollbar-thumb{background-color:#aeaeec;border-radius:2px}app{display:block}app view{display:block}app view page{min-height:100%;padding:1rem;display:block}

File diff suppressed because one or more lines are too long

8
dist/index.html vendored
View File

@ -4,9 +4,11 @@
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danbooru Viewer v0.1</title>
<script type="module" crossorigin src="/assets/index-DMNcFlub.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bnhfn_iK.css">
<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-359h29Hw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BXPCnb25.css">
</head>
<body>
</body>

View File

@ -6,6 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danbooru Viewer v0.2.5</title>
<link rel="stylesheet" href="/index.scss">
<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>
</head>
<body>
<script type="module" src="/src/main.ts"></script>

View File

@ -1,38 +1,147 @@
@import '/src/component/PostGrid/$PostGrid';
@import '/src/component/PostTile/$PostTile';
@import '/src/route/posts/$post';
@import '/src/component/Searchbar/$Searchbar';
@import '/src/route/post/$post';
@import '/src/route/gallery/$gallery';
:root {
--background-color: #1e1e2c;
--background-color-lighter: #3b3b66;
--background-color-light: #24243b;
--primary-color: #d1d1ee;
--primary-color-dark: #9696b3;
--primary-color-darker: #72728d;
--secondary-color: #aeaeec;
--secondary-color-dark: #424268;
--nav-height: 50px;
}
html {
overflow-x: hidden;
}
body {
background-color: #1e1e2c;
color: #d1d1ee;
margin: 0;
font-family: Microsoft Yahei;
font-size: 14px;
&::-webkit-scrollbar {
background-color: #000000;
::-webkit-scrollbar {
background-color: var(--background-color);
width: 8px;
}
&::-webkit-scrollbar-thumb {
::-webkit-scrollbar-thumb {
background-color: #aeaeec;
border-radius: 2px;
}
}
body {
background-color: var(--background-color);
color: var(--primary-color);
margin: 0;
font-family: Microsoft Yahei;
}
app {
display: block;
view {
display: block;
nav {
display: flex;
width: 100%;
height: var(--nav-height);
position: fixed;
top: 0;
z-index: 100;
background-color: var(--background-color);
justify-content: space-between;
align-items: center;
padding-inline: 1rem;
box-sizing: border-box;
page {
min-height: 100%;
padding: 1rem;
display: block;
div.title {
display: flex;
align-items: center;
gap: 0.4rem;
a.booru-name {
color: var(--secondary-color);
text-decoration: none;
h1 {
margin: 0;
}
}
a.version {
color: var(--background-color);
background-color: var(--secondary-color);
padding: 0.2em 0.4em;
border-radius: 0.4rem;
font-size: 0.8rem;
text-decoration: none;
}
}
button {
padding: 2px 4px;
border-radius: 0.4rem;
border: none;
}
div.searchbar {
padding: 0.4rem 10%;
max-width: 500px;
background-color: var(--background-color);
border: 1px solid var(--primary-color-darker);
border-radius: 0.4rem;
color: var(--primary-color-dark);
cursor: pointer;
&:hover {
background-color: var(--background-color-light);
color: var(--primary-color);
}
}
div.buttons {
display: flex;
align-items: center;
gap: 1rem;
ion-icon {
background-color: var(--background-color);
transition: all 0.3s ease;
border-radius: 1rem;
padding: 0.4rem;
&:hover {
background-color: var(--background-color-lighter);
}
}
ion-icon.search {
display: none;
}
}
@media (max-width: 800px) {
div.searchbar {
display: none;
}
div.buttons ion-icon.search {
display: inline-block;
}
}
}
router {
display: block;
position: relative;
padding-top: var(--nav-height);
route {
display: block;
position: relative;
padding-inline: 10px;
}
}
button {
background-color: #aeaeec;
padding: 2px 4px;
border-radius: 0.4rem;
border: none;
cursor: pointer;
}
ion-icon {
font-size: 24px;
color: var(--primary-color);
cursor: pointer;
&:hover {
color: var(--secondary-color);
}
}

View File

@ -2,9 +2,10 @@
"name": "danbooru-viewer",
"module": "index.ts",
"type": "module",
"version": "0.2.5",
"version": "0.3.0",
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"vite": "^5.4.8"
},
"peerDependencies": {
"typescript": "^5.0.0"

View File

@ -0,0 +1,17 @@
import { $Container } from "elexis";
export class $IonIcon extends $Container {
constructor() {
super('ion-icon');
}
name(name: string) {
this.attribute('name', name);
return this;
}
size(size: 'small' | 'large') {
this.attribute('size', size);
return this;
}
}

View File

@ -1,9 +1,80 @@
import { $Layout } from "@elexis/layout";
import { Booru } from "../../structure/Booru";
import { Post } from "../../structure/Post";
import { $PostTile } from "../PostTile/$PostTile";
interface $PostGridOptions {
tags?: string
}
export class $PostGrid extends $Layout {
constructor() {
posts = new Set<Post>();
$posts = new Set<$PostTile>();
tags?: string;
constructor(options?: $PostGridOptions) {
super();
this.addStaticClass('post-grid')
this.type('waterfall').column(5).maxHeight(300).gap(10);
this.tags = options?.tags;
this.addStaticClass('post-grid');
this.type('waterfall').gap(10);
this.init();
}
protected async init() {
setInterval(() => { if (this.inDOM() && document.documentElement.scrollTop === 0) this.updateNewest(); }, 10000);
Booru.events.on('set', () => { this.removeAll(); })
this.on('resize', () => this.resize())
this.loader();
}
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;
}
if (document.documentElement.scrollTop + innerHeight > document.documentElement.scrollHeight - innerHeight * 2) await this.getPosts();
setTimeout(() => this.loader(), 100);
}
protected resize() {
const col = Math.round(this.dom.clientWidth / 300);
this.column(col >= 2 ? col : 2);
}
addPost(posts: OrArray<Post>) {
posts = $.orArrayResolve(posts);
for (const post of posts) {
if (!post.file_url) continue;
if (this.posts.has(post)) continue;
const $post = new $PostTile(post);
this.$posts.add($post);
this.posts.add(post);
}
const $posts = [...this.$posts.values()].sort((a, b) => +b.post.createdDate - +a.post.createdDate);
this.content($posts).render();
return this;
}
removeAll() {
this.posts.clear();
this.$posts.clear();
this.animate({opacity: [1, 0]}, {duration: 300, easing: 'ease'}, () => this.clear().render())
return this;
}
async updateNewest() {
const latestPost = this.sortedPosts.at(0);
const posts = await Post.fetchMultiple(Booru.used, {tags: this.tags, id: latestPost ? `>${latestPost.id}` : undefined});
this.addPost(posts);
return this;
}
async getPosts() {
const oldestPost = this.sortedPosts.at(-1);
const posts = await Post.fetchMultiple(Booru.used, {tags: this.tags, id: oldestPost ? `<${oldestPost.id}` : undefined});
this.addPost(posts);
return this;
}
get sortedPosts() { return this.posts.array.sort((a, b) => +b.createdDate - +a.createdDate); }
}

View File

@ -6,7 +6,7 @@ export class $PostTile extends $Container {
$video: $Video | null;
duration$ = $.state(``);
constructor(post: Post) {
super('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) : null;
this.attribute('filetype', this.post.file_ext);
@ -19,7 +19,7 @@ export class $PostTile extends $Container {
this.$video?.on('playing', (e, $video) => {
timer = setInterval(() => {
this.durationUpdate();
}, 100)
}, 500)
})
this.$video?.on('pause', () => {
clearInterval(timer);
@ -27,15 +27,18 @@ export class $PostTile extends $Container {
})
this.content([
this.post.isVideo ? $('span').class('duration').content(this.duration$) : null,
$('a').href(this.post.pathname).content($a => [
$('a').href(this.post.pathname).content(() => [
this.$video,
$('img').width(this.post.image_width).height(this.post.image_height).src(this.post.preview_file_url).loading('lazy')
$('img').css({opacity: '0'}).width(this.post.image_width).height(this.post.image_height).src(this.post.preview_file_url).loading('lazy')
.once('load', (e, $img) => {
if (!this.post.isVideo) $img.src(this.post.large_file_url)
if (!this.post.isVideo) $img.src(this.post.large_file_url);
$img.animate({opacity: [0, 1]}, {duration: 300, fill: 'both'});
})
])
.on('mouseenter', () => {
if (!this.$video?.isPlaying) this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined)
if (!this.$video?.isPlaying) {
this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined)
}
})
.on('mouseleave', () => {
this.$video?.pause().currentTime(0).hide(true);

View File

@ -1,33 +1,38 @@
post {
post-tile {
display: block;
transition: 0.3s all ease;
position: relative;
&[filetype="mp4"], &[filetype="webm"], &[filetype="zip"] {
span.duration {
background-color: #ffffff;
color: #000000;
&[filetype="mp4"], &[filetype="webm"], &[filetype="zip"] {
span.duration {
background-color: #ffffff;
color: #000000;
}
}
span.duration {
position: absolute;
background-color: #00000050;
bottom: 0.3rem;
right: 0.3rem;
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
text-transform: uppercase;
z-index: 2;
}
img {
border-radius: 10px;
height: 100%;
width: 100%;
vertical-align: top;
}
video {
border-radius: 10px;
height: 100%;
width: 100%;
object-fit: cover;
position: absolute;
z-index: 1;
}
}
span.duration {
position: absolute;
background-color: #00000050;
bottom: 0.3rem;
right: 0.3rem;
padding: 2px 4px;
border-radius: 4px;
font-size: 12px;
text-transform: uppercase;
z-index: 2;
}
img {
border-radius: 10px;
height: 100%;
width: 100%;
}
video {
border-radius: 10px;
height: 100%;
width: 100%;
object-fit: cover;
position: absolute;
}
}

View File

@ -0,0 +1,312 @@
import { $Container } from "elexis";
import { Tag, TagCategory } from "../../structure/Tag";
import { Booru } from "../../structure/Booru";
export class $Searchbar extends $Container {
$tagInput = new $TagInput(this);
$selectionList = new $SelectionList();
typingTimer: Timer | null = null;
$filter = $('div').class('filter');
constructor() {
super('searchbar');
this.build();
window.addEventListener('keyup', (e) => {
if (!this.inDOM() && e.key === '/') this.activate();
if (this.inDOM() && e.key === 'Escape') this.inactivate();
})
}
private build() {
this
.content([
$('div').class('input-container')
.content([
this.$tagInput
.on('input', () => this.inputHandler())
.on('keydown', (e) => this.keyHandler(e)),
$('ion-icon').name('close-circle-outline').title('Clear Input')
.on('click', () => this.$tagInput.clearAll())
])
.on('click', (e) => {
if (e.target === this.$tagInput.dom) this.$tagInput.addTag().input();
}),
$('div').class('selection-list-container').content([
this.$selectionList
]),
this.$filter.on('click', () => {
if (location.hash === '#search') $.back();
})
])
}
activate() {
this.hide(false);
this.$filter
.animate({
opacity: [0, 0.5]
}, { duration: 300, easing: 'ease'})
this.$tagInput.input();
return this;
}
inactivate() {
this.animate({
opacity: [0.5, 0]
}, { duration: 300, easing: 'ease'}, () => this.hide(true))
return this;
}
private keyHandler(e: KeyboardEvent) {
const addTag = () => {e.preventDefault(); this.$tagInput.addTag().input()}
const addSelectedTag = ($selection: $Selection) => {
const inputIndex = this.$tagInput.children.indexOf(this.$tagInput.$inputor);
const nextTag = this.$tagInput.children.array.at(inputIndex + 1) as $Tag;
this.$tagInput.addTag($selection.value());
if (nextTag) this.$tagInput.editTag(nextTag);
else this.$tagInput.input();
}
switch (e.key) {
case 'ArrowUp': {
e.preventDefault();
this.$selectionList.focusPrevSelection();
this.$tagInput.value(this.$selectionList.focused?.value());
break;
}
case 'ArrowDown': {
e.preventDefault();
this.$selectionList.focusNextSelection();
this.$tagInput.value(this.$selectionList.focused?.value());
break;
}
case ' ': addTag(); break;
case 'Enter': {
e.preventDefault();
if (this.$selectionList.focused) addSelectedTag(this.$selectionList.focused);
else {
this.$tagInput.addTag();
this.search();
}
break;
}
case 'Tab': {
e.preventDefault();
const inputIndex = this.$tagInput.children.indexOf(this.$tagInput.$inputor)
if (e.shiftKey) {
this.$tagInput.editTag(this.$tagInput.children.array.at(inputIndex - 1) as $Tag)
break;
}
if (this.$selectionList.focused) addSelectedTag(this.$selectionList.focused);
else {
const nextTag = this.$tagInput.children.array.at(inputIndex + 1) as $Tag;
if (nextTag) this.$tagInput.editTag(nextTag);
else this.$tagInput.addTag().input();
}
break;
}
case 'Backspace': {
const inputIndex = this.$tagInput.children.indexOf(this.$tagInput.$inputor)
if (inputIndex !== 0 && !this.$tagInput.$input.value().length) {
e.preventDefault();
this.$tagInput.editTag(this.$tagInput.children.array.at(inputIndex - 1) as $Tag)
}
break;
}
}
}
private inputHandler() {
if (this.typingTimer) {
clearTimeout(this.typingTimer);
this.typingTimer = null;
}
this.typingTimer = setTimeout(async() => {
this.typingTimer = null;
this.getSearchSuggestions();
}, 200);
}
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'});
this.$selectionList
.clearSelections()
.addSelections(tags.map(tag => new $Selection().value(tag.name)
.content([
$('span').class('tag-name').content(tag.name),
$('span').class('tag-category').content(TagCategory[tag.category])
])
.on('click', () => {this.$tagInput.addTag(tag.name).input()})
))
if (!this.$tagInput.$input.value().length) this.$selectionList.clearSelections();
}
search() {
$.replace(`/posts?tags=${this.$tagInput.query}`);
this.$tagInput.clearAll();
this.inactivate();
return this;
}
checkURL(beforeURL: URL, afterURL: URL) {
if (beforeURL.hash === '#search') this.inactivate();
if (afterURL.hash === '#search') this.activate();
}
}
class $SelectionList extends $Container {
focused: $Selection | null = null;
selections = new Set<$Selection>();
constructor() {
super('selection-list');
}
addSelections(selections: OrArray<$Selection>) {
selections = $.orArrayResolve(selections);
for (const $selection of selections) {
this.selections.add($selection);
}
this.insert(selections);
return this;
}
clearSelections() {
this.focused = null;
this.selections.clear();
this.clear();
return this;
}
focusSelection(selection: $Selection) {
this.blurSelection();
this.focused = selection;
selection.focus();
return this;
}
blurSelection() {
this.focused?.blur();
this.focused = null;
return this;
}
focusNextSelection() {
const selections = this.selections.array;
const first = selections.at(0);
if (this.focused) {
const next = selections.at(selections.indexOf(this.focused) + 1);
if (next) this.focusSelection(next);
else if (first) this.focusSelection(first);
} else if (first) this.focusSelection(first);
}
focusPrevSelection() {
const selections = this.selections.array;
if (this.focused) {
const next = selections.at(selections.indexOf(this.focused) - 1);
if (next) this.focusSelection(next);
} else {
const next = selections.at(0);
if (next) this.focusSelection(next);
}
}
}
class $Selection extends $Container {
private property = {
value: ''
}
constructor() {
super('selection');
}
value(): string;
value(value: string): this;
value(value?: string) { return $.fluent(this, arguments, () => this.property.value, () => $.set(this.property, 'value', value))}
focus() {
this.addClass('active');
return this;
}
blur() {
this.removeClass('active');
return this;
}
}
class $TagInput extends $Container {
$input = $('input').type('text');
$sizer = $('span').class('sizer');
$inputor = $('div').class('input-wrapper').content([
this.$sizer,
this.$input
.on('input', () => {
this.$sizer.content(this.$input.value());
})
])
tags = new Set<$Tag>();
$seachbar: $Searchbar
constructor($seachbar: $Searchbar) {
super('tag-input');
this.$seachbar = $seachbar;
}
input() {
this.insert(this.$inputor);
this.$input.focus();
if (this.$input.value()) this.$seachbar.getSearchSuggestions();
else this.$seachbar.$selectionList.clearSelections();
return this;
}
addTag(tagName?: string) {
tagName = tagName ?? this.$input.value();
if (!tagName.length) return this;
const $tag = new $Tag(tagName);
$tag.on('click', () => this.editTag($tag))
this.tags.add($tag);
this.value('');
this.$inputor.replace($tag);
return this;
}
editTag($tag: $Tag) {
this.addTag();
this.tags.delete($tag);
$tag.replace(this.$inputor);
this.value($tag.name);
this.$input.focus();
this.$seachbar.getSearchSuggestions();
return this;
}
clearAll() {
this.value('');
this.tags.clear();
this.clear();
return this;
}
value(value?: string) {
if (value === undefined) return this;
this.$input.value(value);
this.$sizer.content(value);
return this;
}
get query() { return this.tags.array.map(tag => tag.name).toString().replace(',', '+') }
}
class $Tag extends $Container {
name: string;
constructor(name: string) {
super('tag');
this.name = name;
this.build();
}
private build() {
this.content(this.name)
}
}

View File

@ -0,0 +1,158 @@
searchbar {
display: flex;
// justify-content: center;
align-items: center;
flex-direction: column;
width: 100%;
z-index: 200;
position: fixed;
height: 100%;
div.input-container {
margin-top: 0.4rem;
background-color: var(--background-color-light);
border-radius: 0.4rem;
font-size: 1rem;
width: 500px;
padding: 0.4rem 0.4rem;
max-width: calc(100% - 2rem);
box-sizing: border-box;
z-index: 201;
display: flex;
align-items: center;
border: 1px solid var(--secondary-color-dark);
&:focus-within {
outline: none;
// border-color: var(--primary-color-dark);
}
tag-input {
display: flex;
gap: 0.4rem;
width: 100%;
overflow: hidden;
padding-inline: 0.4rem;
box-sizing: border-box;
cursor: text;
tag {
display: inline-block;
padding: 0.2rem 0.4rem;
background-color: var(--secondary-color-dark);
color: var(--secondary-color);
border-radius: 0.4rem;
cursor: pointer;
}
}
ion-icon {
font-size: 20px;
color: var(--secondary-color-dark);
cursor: pointer;
&:hover {
color: var(--secondary-color);
}
}
}
div.selection-list-container {
overflow: hidden;
border-radius: 0.4rem;
background-color: var(--background-color);
z-index: 201;
max-width: calc(100% - 2rem);
width: 500px;
selection-list {
display: block;
max-height: 40vh;
overflow-y: scroll;
overflow-x: hidden;
position: relative;
selection {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
cursor: pointer;
&:hover {
background-color: color-mix(in srgb, var(--background-color-lighter) 50%, transparent);
}
&.active {
background-color: var(--background-color-lighter);
}
.tag-name {
}
.tag-category {
padding: 0.1rem 0.4rem;
border-radius: 0.4rem;
font-size: 0.9rem;
background-color: var(--secondary-color-dark);
color: var(--secondary-color);
}
}
}
}
div.filter {
background-color: var(--background-color);
opacity: 0.5;
position: fixed;
top: 0;
height: 100%;
width: 100%;
z-index: 199;
}
}
.input-wrapper {
color: var(--primary-color);
border: 1px solid var(--secondary-color);
border-radius: 0.4rem;
position: relative;
box-sizing: border-box;
line-height: 1em;
font-size: 14px;
padding: 4px 8px;
display: inline-block;
max-width: 100%;
text-overflow: ellipsis;
// overflow: hidden;
span.sizer {
font-family: inherit;
white-space: pre;
height: 1em;
display: inline-block;
font-size: inherit;
line-height: inherit;
box-sizing: border-box;
position: relative;
opacity: 0;
min-width: 2px;
user-select: none;
vertical-align: top;
}
input {
color: inherit;
height: 100%;
text-overflow: ellipsis;
font-family: inherit;
background: none;
color: inherit;
top: 0;
left: 0;
font-size: inherit;
line-height: inherit;
padding: inherit;
position: absolute;
box-sizing: border-box;
width: 100%;
border: none;
outline: none;
}
}

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare const __APP_VERSION__: string;

View File

@ -1,27 +1,101 @@
import 'elexis';
import '@elexis/layout';
import '@elexis/router';
import { Booru } from './structure/Booru';
import { Router } from '@elexis/router';
import { home_route } from './route/gallery/$gallery';
import { posts_route } from './route/posts/$post';
export const booru = new Booru({
api: 'https://danbooru.donmai.us',
name: 'Testbooru'
})
const router = new Router('/');
$.anchorPreventDefault = true;
$.anchorHandler = ($a) => { $.open($a.href())}
import { Booru, type BooruOptions } from './structure/Booru';
import { post_route } from './route/post/$post';
import { $PostGrid } from './component/PostGrid/$PostGrid';
import { $Router, $RouterNavigationDirection } from '@elexis/router';
import { $Searchbar } from './component/Searchbar/$Searchbar';
import { $IonIcon } from './component/IonIcon/$IonIcon';
// declare elexis module
declare module 'elexis' {
export namespace $ {
export interface TagNameElementMap {
'ion-icon': typeof $IonIcon
}
}
}
$.registerTagName('ion-icon', $IonIcon)
$.anchorHandler = ($a) => { $.open($a.href(), $a.target())}
// settings
export const [danbooru, safebooru]: Booru[] = [
new Booru({ origin: 'https://danbooru.donmai.us', name: 'Danbooru' }),
new Booru({ origin: 'https://safebooru.donmai.us', name: 'Safebooru' }),
new Booru({ origin: 'https://testbooru.donmai.us', name: 'Testbooru' }),
]
Booru.set(Booru.manager.get(Booru.storageAPI ?? '') ?? danbooru);
const $searchbar = new $Searchbar().hide(true);
if (location.hash === '#search') $searchbar.activate();
// render
$(document.body).content([
$('app').content([
router.$view
])
// Navigation Bar
$('nav').content([
// Title
$('div').class('title').content([
$('a').class('booru-name').content([$('h1').content(Booru.name$)]).href('/'),
$('a').class('version').target('_blank').content(`v${__APP_VERSION__}`).href(`https://git.defaultkavy.com/defaultkavy/danbooru-viewer`)
]),
// Searchbar
$('div').class('searchbar').content(['Search in ', Booru.name$])
.self($self => $Router.events.on('stateChange', ({beforeURL, afterURL}) => {if (beforeURL.hash === '#search') $self.hide(false); if (afterURL.hash === '#search') $self.hide(true)}))
.on('click', () => $.open(location.href + '#search')),
// Buttons
$('div').class('buttons').content([
// Search Icon
$('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', () => $.open(location.href + '#search')),
// Switch Button
$('ion-icon').class('switch').name('swap-horizontal').title('Switch Booru')
.on('click', () => {
if (Booru.used === danbooru) Booru.set(safebooru);
else Booru.set(danbooru);
})
])
]),
// Searchbar
$searchbar,
// Base Router
$('router').base('/').map([
// Home Page
$('route').id('posts').path('/').builder(() => new $PostGrid()),
// Posts Page
$('route').id('posts').path('/posts?tags').builder(({query}) => new $PostGrid({tags: query.tags})),
// Post Page
post_route
]).on('beforeSwitch', (e) => {
const DURATION = 300;
e.preventDefault();
function intro() {
e.$view.content(e.nextContent);
e.rendered();
e.nextContent.element?.class('animated').animate({
opacity: [0, 1],
transform: $Router.navigationDirection === $RouterNavigationDirection.Forward ? [`translateX(40%)`, `translateX(0%)`] : [`translateX(-40%)`, `translateX(0%)`]
}, {
duration: DURATION,
easing: 'ease'
}, () => {
e.switched();
e.nextContent.element?.removeClass('animated')
})
}
function outro() {
e.previousContent?.element?.class('animated').animate({
opacity: [1, 0],
transform: $Router.navigationDirection === $RouterNavigationDirection.Forward ? [`translateX(0%)`, `translateX(-40%)`] : [`translateX(0%)`, `translateX(40%)`]
}, {
duration: DURATION,
easing: 'ease'
}, () => {
e.previousContent?.element?.removeClass('animated');
intro();
})
}
if (e.previousContent) outro();
else intro();
})
])
router.addRoute([
home_route,
posts_route
]).listen();
$Router.events.on('stateChange', ({beforeURL, afterURL}) => { $searchbar.checkURL(beforeURL, afterURL) })

View File

@ -1,12 +1,9 @@
import { Route, Router } from "@elexis/router";
import { Post } from "../../structure/Post";
import { booru } from "../../main";
import { $PostGrid } from "../../component/PostGrid/$PostGrid";
import { $PostTile } from "../../component/PostTile/$PostTile";
const MAX_POST_LENGTH = 100;
export const home_route = new Route((path) => {
if (path === '/posts' || path === '/') return '/';
}, ({record}) => {
export const home_route = $('route').path(['/', '/posts']).builder((record) => {
const $page = $('page').id('root');
async function load(tags: string) {
const posts = await Post.fetchMultiple(booru, tags.length ? {tags: tags} : undefined, MAX_POST_LENGTH)
@ -61,7 +58,7 @@ export const home_route = new Route((path) => {
}
const gridManager = new Map<string, $PostGrid>();
record.on('open', async () => {
record.$route.events.on('opened', async () => {
const tags = new URL(location.href).searchParams.get('tags') ?? '';
const $cacheGrid = gridManager.get(tags);
if ($cacheGrid) {
@ -78,7 +75,6 @@ export const home_route = new Route((path) => {
]);
$layout.render();
gridManager.set(tags, $layout);
Router.recoveryScrollPosition();
})
return $page;
})

View File

@ -1,48 +1,30 @@
import { Route } from "@elexis/router";
import { Post } from "../../structure/Post";
import { booru } from "../../main";
import { $Container, type $ContainerContentType } from "elexis";
import { digitalUnit } from "../../structure/Util";
import { Tag, TagCategory } from "../../structure/Tag";
import { ArtistCommentary } from "../../structure/Commentary";
import { Booru } from "../../structure/Booru";
export const posts_route = new Route('/posts/:id', ({params}) => {
export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => {
if (!Number(params.id)) return '404';
const post = new Post(Booru.used, +params.id);
const ele = {
$viewer: $('div').class('viewer'),
$tags: $('div').class('post-tags'),
$commentary: $('section').class('commentary')
}
const value = {
uploader$: $.state('loading...'),
approver$: $.state('loading...'),
date$: $.state('loading...'),
size$: $.state('loading...'),
dimension$: $.state(`loading...`),
favorites$: $.state(0),
score$: $.state(0),
ext$: $.state(`loading...`),
}
load();
async function load() {
const post = Post.manager.get(+params.id) ?? await Post.fetch(booru, +params.id);
await post.fetch();
ele.$viewer.content([
post.isVideo
? $('video').src(post.file_ext === 'zip' ? post.large_file_url : post.file_url).controls(true)
: $('img').src(post.large_file_url).once('load', (e, $img) => { $img.src(post.file_url)})
: $('img').src(post.large_file_url)//.once('load', (e, $img) => { $img.src(post.file_url)})
])
value.uploader$.set(post.uploader$);
value.approver$.set(post.approver$);
value.date$.set(post.created_date$);
value.size$.set(digitalUnit(post.file_size));
value.dimension$.set(`${post.image_width}x${post.image_height}`)
value.favorites$.set(post.favorites$)
value.score$.set(post.score$)
value.ext$.set(post.file_ext.toUpperCase())
loadTags();
loadCommentary();
async function loadTags() {
const tags = await Tag.fetchMultiple(booru, {name: {_space: post.tag_string}});
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),
@ -71,7 +53,7 @@ export const posts_route = new Route('/posts/:id', ({params}) => {
}
}
async function loadCommentary() {
const commentary = (await ArtistCommentary.fetchMultiple(booru, {post: {_id: post.id}})).at(0);
const commentary = (await ArtistCommentary.fetchMultiple(Booru.used, {post: {_id: post.id}})).at(0);
if (!commentary) return ele.$commentary.content('No commentary');
ele.$commentary.content([
commentary.original_title ? $('h3').content(commentary.original_title) : null,
@ -79,9 +61,7 @@ export const posts_route = new Route('/posts/:id', ({params}) => {
])
}
}
load();
return $('page').id('post').content([
return [
ele.$viewer,
$('div').class('content').content([
$('h3').content(`Artist's Commentary`),
@ -90,27 +70,34 @@ export const posts_route = new Route('/posts/:id', ({params}) => {
$('div').class('sidebar').content([
$('section').class('post-info').content([
new $Property('id').name('Post').value(`#${params.id}`),
new $Property('uploader').name('Uploader').value(value.uploader$),
new $Property('approver').name('Approver').value(value.approver$),
new $Property('date').name('Date').value(value.date$),
new $Property('size').name('Size').value([value.size$, value.dimension$]),
new $Property('file').name('File Type').value(value.ext$),
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$),
$('div').class('inline').content([
new $Property('favorites').name('Favorites').value(value.favorites$),
new $Property('score').name('Score').value(value.score$)
new $Property('favorites').name('Favorites').value(post.favorites$),
new $Property('score').name('Score').value(post.score$)
]),
$('a').content('Copy link').href(`${booru.api}${location.pathname}`)
.on('click', (e, $a) => {
navigator.clipboard.writeText($a.href());
$a.content('Copied!');
$('button').content('Copy link')
.on('click', (e, $button) => {
e.preventDefault();
navigator.clipboard.writeText(`${Booru.used.origin}${location.pathname}`);
$button.content('Copied!');
setTimeout(() => {
$a.content('Copy link')
$button.content('Copy link')
}, 2000);
})
]),
ele.$tags.content('loading...')
])
])
]).self($sidebar => {
let scrollTop = 0;
addEventListener('scroll', () => { if ($sidebar.inDOM()) scrollTop = document.documentElement.scrollTop })
$route
.on('beforeShift', () => { if (innerWidth > 800) $sidebar.css({position: `absolute`, top: `${scrollTop}px`}) })
.on('afterShift', () => $sidebar.css({position: '', top: ''}))
})
]
})
class $Property extends $Container {

View File

@ -7,7 +7,7 @@
}
div.viewer {
height: calc(100vh - 2rem);
height: calc(100vh - 2rem - var(--nav-height));
display: flex;
justify-content: center;
align-items: center;
@ -19,7 +19,7 @@
@media (max-width: 800px) {
width: 100%;
height: calc(100vh);
height: calc(100vh - var(--nav-height));
border-radius: 0;
margin:0;
}
@ -27,10 +27,14 @@
img {
max-width: 100%;
max-height: 100%;
// transition: all 0.3s ease;
}
video {
max-width: 100%;
max-height: 100%;
-webkit-user-drag: none;
transition: all 0.3s ease;
}
}
@ -70,7 +74,7 @@
div.sidebar {
position: fixed;
top: 1rem;
top: calc(var(--nav-height) + 1rem);
right: 1rem;
display: flex;
flex-direction: column;
@ -78,7 +82,7 @@
width: 300px;
overflow: scroll;
overflow-x: hidden;
height: calc(100vh - 2rem);
height: calc(100vh - 2rem - var(--nav-height));
border-radius: 20px;
@media (max-width: 800px) {
@ -120,8 +124,8 @@
display: flex;
gap: 0.4rem;
span.property-value {
background-color: #525278;
color: #aeaeec;
background-color: var(--secondary-color-dark);
color: var(--secondary-color);
padding: 2px 4px;
border-radius: 4px;
}
@ -143,8 +147,8 @@
text-decoration: none;
}
span.tag-post-count {
background-color: #525278;
color: #aeaeec;
background-color: var(--secondary-color-dark);
color: var(--secondary-color);
padding: 0px 4px;
border-radius: 4px;
font-size: 12px;
@ -153,4 +157,9 @@
}
}
}
}
}
// animated resolver
// .animated div.sidebar {
// top: 0 !important;
// }

View File

@ -1,11 +1,38 @@
import { $EventManager, type $EventMap } from "elexis";
import type { Post } from "./Post";
import type { Tag } from "./Tag";
export interface BooruOptions {
api: string;
origin: string;
name: string;
}
export interface Booru extends BooruOptions {}
export class Booru {
static used: Booru;
static events = new $EventManager<BooruEventMap>();
static name$ = $.state(this.name);
static manager = new Map<string, Booru>()
posts = new Map<id, Post>();
tags = new Map<id, Tag>();
constructor(options: BooruOptions) {
Object.assign(this, options);
if (this.api.endsWith('/')) this.api = this.api.slice(0, -1)
if (this.origin.endsWith('/')) this.origin = this.origin.slice(0, -1);
Booru.manager.set(this.name, this);
}
static set(booru: Booru) {
this.used = booru;
this.name$.set(booru.name);
this.storageAPI = booru.name;
this.events.fire('set');
return this;
}
static get storageAPI() { return localStorage.getItem('booru_api'); }
static set storageAPI(name: string | null) { if (name) localStorage.setItem('booru_api', name); else localStorage.removeItem('booru_api') }
}
interface BooruEventMap extends $EventMap {
set: []
}

View File

@ -8,7 +8,7 @@ export class ArtistCommentary {
}
static async fetch(booru: Booru, id: id) {
const req = await fetch(`${booru.api}/artist_commentaries/${id}.json`);
const req = await fetch(`${booru.origin}/artist_commentaries/${id}.json`);
const post = new this(await req.json());
return post;
}
@ -26,7 +26,7 @@ export class ArtistCommentary {
else searchQuery += `&search[${key}]=${val}`
}
}
const req = await fetch(`${booru.api}/artist_commentaries.json?limit=${limit}${searchQuery}`);
const req = await fetch(`${booru.origin}/artist_commentaries.json?limit=${limit}${searchQuery}`);
const dataArray: ArtistCommentaryData[] = await req.json();
const list = dataArray.map(data => {
const instance = new this(data);

View File

@ -1,28 +1,34 @@
import { $ } from "elexis";
import type { Booru } from "./Booru";
import { $, $EventManager } from "elexis";
import { Booru } from "./Booru";
import { Tag } from "./Tag";
import { User } from "./User";
import { dateFrom } from "./Util";
import { dateFrom, digitalUnit } from "./Util";
export interface PostOptions {}
export interface Post extends PostData {}
export class Post {
static manager = new Map<id, Post>();
export class Post extends $EventManager<{update: []}> {
uploader$ = $.state(this.uploader?.name$ ?? this.uploader_id?.toString());
approver$ = $.state(this.approver?.name$ ?? this.approver_id?.toString() ?? 'None');
created_date$ = $.state(``);
favorites$ = $.state(this.fav_count);
score$ = $.state(this.score);
constructor(data: PostData) {
Object.assign(this, data);
this.update$();
file_size$ = $.state('');
file_ext$ = $.state(this.file_ext);
dimension$ = $.state('');
createdDate = new Date(this.created_at);
booru: Booru;
constructor(booru: Booru, id: id) {
super();
this.booru = booru;
this.id = id;
booru.posts.set(this.id, this);
}
static async fetch(booru: Booru, id: id) {
const data = await fetch(`${booru.api}/posts/${id}.json`).then(async data => await data.json()) as PostData;
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
this.manager.set(instance.id, instance);
User.fetchMultiple(booru, {id: [instance.uploader_id, instance.approver_id].detype(null)}).then(() => instance.update$());
return instance;
async fetch() {
const data = await fetch(`${this.booru.origin}/posts/${this.id}.json`).then(async data => await data.json()) as PostData;
this.update(data);
User.fetchMultiple(this.booru, {id: [this.uploader_id, this.approver_id].detype(null)}).then(() => this.update$());
return this;
}
static async fetchMultiple(booru: Booru, tags?: Partial<MetaTags> | string, limit = 20) {
@ -30,19 +36,20 @@ export class Post {
if (tags) {
if (typeof tags === 'string') tagsQuery = tags;
else {
tagsQuery += '&tags='
for (const [key, val] of Object.entries(tags)) {
if (val === undefined) continue;
if (key === 'tags') { tagsQuery += `${val}`; continue; }
if (tagsQuery.at(-1) !== '=') tagsQuery += ' '; // add space between tags
tagsQuery += `${key}:${val}`
}
}
}
const req = await fetch(`${booru.api}/posts.json?limit=${limit}${tagsQuery}&_method=get`);
const req = await fetch(`${booru.origin}/posts.json?limit=${limit}&tags=${tagsQuery}&_method=get`);
const dataArray: PostData[] = await req.json();
const list = dataArray.map(data => {
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
this.manager.set(instance.id, instance);
const instance = booru.posts.get(data.id)?.update(data) ?? new this(booru, data.id);
instance.update(data);
booru.posts.set(instance.id, instance);
return instance;
});
if (!list.length) return list;
@ -52,11 +59,16 @@ export class Post {
}
update$() {
this.uploader$.set(this.uploader?.name$ ?? this.uploader_id.toString());
this.uploader$.set(this.uploader?.name$ ?? this.uploader_id?.toString());
this.approver$.set(this.approver?.name$ ?? this.approver_id?.toString() ?? 'None');
this.created_date$.set(dateFrom(+new Date(this.created_at)));
this.favorites$.set(this.fav_count);
this.score$.set(this.score);
this.file_size$.set(digitalUnit(this.file_size));
this.file_ext$.set(this.file_ext as any);
this.dimension$.set(`${this.image_width}x${this.image_height}`);
this.createdDate = new Date(this.created_at);
this.fire('update');
}
update(data: PostData) {
@ -65,13 +77,17 @@ export class Post {
return this;
}
async fetchTags() {
return await Tag.fetchMultiple(this.booru, {name: {_space: this.tag_string}});
}
get pathname() { return `/posts/${this.id}` }
get uploader() { return User.manager.get(this.uploader_id); }
get approver() { if (this.approver_id) return User.manager.get(this.approver_id); else return null }
get isVideo() { return this.file_ext === 'mp4' || this.file_ext === 'webm' || this.file_ext === 'zip' }
get tags() {
const tag_list = this.tag_string.split(' ');
return [...Tag.manager.values()].filter(tag => tag_list.includes(tag.name))
return [...this.booru.tags.values()].filter(tag => tag_list.includes(tag.name))
}
}

View File

@ -4,18 +4,19 @@ const INTL_number = new Intl.NumberFormat('en', {notation: 'compact'})
export interface TagOptions {}
export interface Tag extends TagData {}
export class Tag {
static manager = new Map<id, Tag>();
post_count$ = $.state(0, {format: (value) => `${INTL_number.format(value)}`});
name$ = $.state('');
constructor(data: TagData) {
booru: Booru;
constructor(booru: Booru, data: TagData) {
this.booru = booru;
Object.assign(this, data);
this.$update();
}
static async fetch(booru: Booru, id: id) {
const data = await fetch(`${booru.api}/tags/${id}.json`).then(async data => await data.json()) as TagData;
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
this.manager.set(instance.id, instance);
const data = await fetch(`${booru.origin}/tags/${id}.json`).then(async data => await data.json()) as TagData;
const instance = booru.tags.get(data.id)?.update(data) ?? new this(booru, data);
booru.tags.set(instance.id, instance);
return instance;
}
@ -32,11 +33,11 @@ export class Tag {
else searchQuery += `&search[${key}]=${val}`
}
}
const req = await fetch(`${booru.api}/tags.json?limit=${limit}${searchQuery}`);
const req = await fetch(`${booru.origin}/tags.json?limit=${limit}${searchQuery}`);
const dataArray: TagData[] = await req.json();
const list = dataArray.map(data => {
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
this.manager.set(instance.id, instance);
const instance = booru.tags.get(data.id)?.update(data) ?? new this(booru, data);
booru.tags.set(instance.id, instance);
return instance;
});
return list;

0
src/structure/Test.ts Normal file
View File

View File

@ -11,7 +11,7 @@ export class User {
}
static async fetch(booru: Booru, id: id) {
const data = await fetch(`${booru.api}/users/${id}.json`).then(async data => await data.json()) as UserData;
const data = await fetch(`${booru.origin}/users/${id}.json`).then(async data => await data.json()) as UserData;
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
this.manager.set(instance.id, instance);
return instance;
@ -30,7 +30,7 @@ export class User {
else searchQuery += `&search[${key}]=${val}`
}
}
const req = await fetch(`${booru.api}/users.json?limit=${limit}${searchQuery}`);
const req = await fetch(`${booru.origin}/users.json?limit=${limit}${searchQuery}`);
const dataArray: UserData[] = await req.json();
const list = dataArray.map(data => {
const instance = new this(data);

6
vite.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
}
})