v0.3.0
This commit is contained in:
parent
33a588a3f5
commit
b5eb4811d6
1
dist/assets/index-359h29Hw.js
vendored
Normal file
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
1
dist/assets/index-BXPCnb25.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-Bnhfn_iK.css
vendored
1
dist/assets/index-Bnhfn_iK.css
vendored
@ -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}
|
1
dist/assets/index-DMNcFlub.js
vendored
1
dist/assets/index-DMNcFlub.js
vendored
File diff suppressed because one or more lines are too long
8
dist/index.html
vendored
8
dist/index.html
vendored
@ -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>
|
||||
|
@ -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>
|
||||
|
145
index.scss
145
index.scss
@ -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);
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
17
src/component/IonIcon/$IonIcon.ts
Normal file
17
src/component/IonIcon/$IonIcon.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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 {
|
||||
posts = new Set<Post>();
|
||||
$posts = new Set<$PostTile>();
|
||||
tags?: string;
|
||||
constructor(options?: $PostGridOptions) {
|
||||
super();
|
||||
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); }
|
||||
|
||||
export class $PostGrid extends $Layout {
|
||||
constructor() {
|
||||
super();
|
||||
this.addStaticClass('post-grid')
|
||||
this.type('waterfall').column(5).maxHeight(300).gap(10);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
312
src/component/Searchbar/$Searchbar.ts
Normal file
312
src/component/Searchbar/$Searchbar.ts
Normal 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)
|
||||
}
|
||||
}
|
158
src/component/Searchbar/_$Searchbar.scss
Normal file
158
src/component/Searchbar/_$Searchbar.scss
Normal 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
1
src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare const __APP_VERSION__: string;
|
116
src/main.ts
116
src/main.ts
@ -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) })
|
@ -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;
|
||||
})
|
@ -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 {
|
@ -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;
|
||||
@ -154,3 +158,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// animated resolver
|
||||
// .animated div.sidebar {
|
||||
// top: 0 !important;
|
||||
// }
|
@ -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: []
|
||||
}
|
@ -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);
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
0
src/structure/Test.ts
Normal 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
6
vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue
Block a user