initial asset branch

This commit is contained in:
defaultkavy 2024-10-27 13:09:53 +08:00
parent 396b67a9b3
commit e5ac470a29
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
56 changed files with 0 additions and 4096 deletions

3
.gitignore vendored
View File

@ -1,3 +0,0 @@
node_modules
.vscode
bun.lockb

View File

@ -1,47 +0,0 @@
# Danbooru Viewer
A modern style viewer for [Danbooru](https://danbooru.donmai.us) or other Booru API base site.
## How To Use
- Enter this URL: [https://danbooru.defaultkavy.com](https://danbooru.defaultkavy.com).
- Or, replace `danbooru.donmai.us` to `danbooru.defaultkavy.com` without changing pathname and url query, will directly open the same page on Danbooru Viewer.
- Or, clone this repository and run commands for self-hosting:
```sh
bun i --production
bun run start
```
## Features
- Same path as the original website.
- Support URL query like `/posts?tags=ord:fav+minato_aqua`.
- Search tags with autocomplete.
- Infinite scroll posts with waterfall image layout.
- Mobile friendly.
## Hotkeys
- Global Shortcut
- `Q`: Back.
- `E`: Forward.
- `/`: Open search bar.
- Posts Browser Page
- `W/A/S/D`: Navigation posts in direction.
- `Tab`: Toogle post detail panel.
- `Space/Enter`: Open selected post page.
- Post Page
- `A/D`: Switch to previous/next post page.
- `Spacebar`: Play/Pause video.
## Roadmap to V1.0
- [x] Posts Page
- [x] Posts Search with any tags
- [x] Booru Account Login (Using API keys)
- [x] Favorite Post with Account
- [x] Post Detail Panel in Posts Browser
- [ ] Saved Searches
- [ ] User Page
- [ ] Post Commentary
- [ ] More...
## Tools
- [Elexis](https://git.defaultkavy.com/defaultkavy/elexis): Web Builder.
- [Elysia](https://elysiajs.com/): Server Framework.
- [ionicons](https://ionic.io/ionicons): Open Souces Icons.

BIN
danbooru-viewer-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

25
dist/index.html vendored
View File

@ -1,25 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="/statics/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/statics/danbooru-viewer-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danbooru Viewer</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>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-59HBGP98WR"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-59HBGP98WR');
</script>
<script type="module" crossorigin src="/assets/index-C_mg4_Pk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-g15FC_9F.css">
</head>
<body>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,27 +0,0 @@
{
"lang": "en",
"name": "Danbooru Viewer",
"theme_color": "#1E1E2C",
"background_color": "#1E1E2C",
"short_name": "Danbooru Viewer",
"description": "Danbooru images viewer, modern style user interface.",
"start_url": "/",
"dir": "ltr",
"orientation": "any",
"display": "standalone",
"categories": [
"photo"
],
"icons": [
{
"src": "/statics/danbooru-viewer-icon.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/statics/danbooru-viewer-app-icon@2x.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -1,25 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="manifest" href="/statics/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/statics/danbooru-viewer-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danbooru Viewer</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>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-59HBGP98WR"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-59HBGP98WR');
</script>
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,246 +0,0 @@
// components
@import '/src/component/PostGrid/$PostGrid';
@import '/src/component/PostTile/$PostTile';
@import '/src/component/Searchbar/$Searchbar';
@import '/src/component/IconButton/$IconButton';
@import '/src/component/IonIcon/$IonIcon';
@import '/src/component/Drawer/$Drawer';
@import '/src/component/DetailPanel/$DetailPanel';
// routes
@import '/src/route/post/$post_route';
@import '/src/route/login/$login_route';
:root {
--primary-color: #d1d1ee;
--primary-color-dark: #9696b3;
--primary-color-darker: #72728d;
--secondary-color-9: #aeaeec;
--secondary-color-8: #9a9ad6;
--secondary-color-7: #7c7cb8;
--secondary-color-6: #646497;
--secondary-color-5: #545486;
--secondary-color-4: #424268;
--secondary-color-3: #3b3b66;
--secondary-color-2: #24243b;
--secondary-color-1: #1e1e2c; // background color
--secondary-color-0: #07070c;
--shadow-color: #09090e50;
--border-radius-small: 0.4rem;
--border-radius-medium: 0.8rem;
--border-radius-large: 1.2rem;
--nav-height: 50px;
}
html {
overflow-x: hidden;
font-size: 14px;
scroll-behavior: smooth;
::-webkit-scrollbar {
background-color: var(--secondary-color-1);
width: 8px;
}
::-webkit-scrollbar-thumb {
background-color: #aeaeec;
border-radius: 2px;
}
}
body {
overflow-x: hidden;
background-color: var(--secondary-color-1);
color: var(--primary-color);
margin: 0;
font-family: Microsoft Yahei;
}
nav {
display: flex;
width: 100%;
height: var(--nav-height);
position: fixed;
top: 0;
z-index: 100;
background-color: color-mix(in srgb, var(--secondary-color-1) 70%, transparent);
justify-content: space-between;
align-items: center;
padding-inline: 1rem;
box-sizing: border-box;
backdrop-filter: blur(3px);
a.title {
display: flex;
align-items: center;
gap: 0.4rem;
text-decoration: none;
.booru-name {
color: var(--secondary-color-9);
margin: 0;
}
.app {
display: flex;
align-items: center;
border-radius: var(--border-radius-small);
// border: 1px solid var(--secondary-color-9);
// padding: 0.2rem 0.4rem;
margin: 0;
gap: 0.4rem;
.version {
color: var(--secondary-color-1);
background-color: var(--secondary-color-9);
padding: 0.2em 0.4em;
border-radius: var(--border-radius-small);
font-size: 0.8rem;
font-weight: bold;
}
.app-name {
display: none;
font-size: 1rem;
color: var(--secondary-color-9);
}
}
}
div.searchbar {
padding: 0.4rem 10%;
max-width: 500px;
background-color: color-mix(in srgb, var(--secondary-color-2) 30%, transparent);
border: 1px solid var(--primary-color-darker);
border-radius: var(--border-radius-small);
color: var(--primary-color-dark);
transition: 0.3s all ease;
cursor: pointer;
&:hover {
color: var(--primary-color);
}
}
div.buttons {
display: flex;
align-items: center;
gap: 1rem;
ion-icon {
// background-color: var(--secondary-color-1);
transition: all 0.3s ease;
border-radius: 1rem;
padding: 0.4rem;
&:hover {
background-color: color-mix(in srgb, var(--secondary-color-3) 50%, transparent);
}
}
ion-icon.search {
display: none;
}
a {
display: flex;
justify-content: center;
align-items: center;
}
div.account {
height: 2rem;
width: 2rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 2rem;
font-weight: bolder;
color: var(--secondary-color-9);
background-color: var(--secondary-color-4);
user-select: none;
cursor: pointer;
}
}
@media (max-width: 800px) {
div.searchbar {
display: none;
}
div.buttons ion-icon {
&.search {
display: inline-block;
}
&.detail-panel {
display: none;
}
}
}
}
router {
display: block;
position: relative;
route {
display: block;
position: relative;
padding-inline: 1rem;
padding-top: var(--nav-height);
}
}
route#posts {
header {
margin-bottom: 1rem;
h2 {
margin: 0;
}
div.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
}
.post-grid.detail-panel-enabled {
width: calc(100vw - 300px - 4rem);
@media (max-width: 800px) {
width: 100%;
}
}
@media (max-width: 800px) {
detail-panel {
display: none;
}
}
}
section {
background-color: #2f2f45;
border-radius: var(--border-radius-large);
padding: 20px;
}
button {
background-color: var(--secondary-color-4);
color: var(--secondary-color-9);
padding: 0.8rem 1.2rem;
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover {
background-color: var(--secondary-color-6);
color: var(--primary-color);
}
}
a {
text-decoration: none;
color: var(--secondary-color-9);
}
input {
background-color: var(--secondary-color-2);
border-radius: var(--border-radius-small);
border: 1px solid var(--secondary-color-3);
font-size: 0.9rem;
padding: 0.8rem 1.2rem;
color: var(--primary-color);
outline: none;
&:focus {
border-color: var(--secondary-color-9);
}
}

View File

@ -1,28 +0,0 @@
{
"name": "danbooru-viewer",
"module": "index.ts",
"type": "module",
"version": "0.13.0",
"scripts": {
"dev": "bun x vite",
"build": "bun x vite build",
"start": "bun server.ts"
},
"devDependencies": {
"@types/bun": "latest",
"vite": "^5.4.8",
"sass": "^1.77.1",
"elexis": "../elexis",
"@elexis/layout": "../elexis-ext/layout",
"@elexis/router": "../elexis-ext/router"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@elysiajs/cors": "^1.1.1",
"@elysiajs/html": "^1.1.1",
"cheerio": "^1.0.0",
"elysia": "^1.1.20"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,27 +0,0 @@
{
"lang": "en",
"name": "Danbooru Viewer",
"theme_color": "#1E1E2C",
"background_color": "#1E1E2C",
"short_name": "Danbooru Viewer",
"description": "Danbooru images viewer, modern style user interface.",
"start_url": "/",
"dir": "ltr",
"orientation": "any",
"display": "standalone",
"categories": [
"photo"
],
"icons": [
{
"src": "/statics/danbooru-viewer-icon.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/statics/danbooru-viewer-app-icon@2x.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -1,3 +0,0 @@
#!/bin/sh
bun --hot index.ts

View File

@ -1,2 +0,0 @@
#!/bin/sh
bunx --bun vite build

View File

@ -1,2 +0,0 @@
#!/bin/sh
bunx --bun vite --host 127.0.0.1

102
server.ts
View File

@ -1,102 +0,0 @@
import cors from "@elysiajs/cors";
import Elysia from "elysia";
import * as cheerio from 'cheerio';
import html from "@elysiajs/html";
import type { PostData } from "./src/structure/Post";
const list_format = new Intl.ListFormat('en', {type: 'conjunction', style: 'long'})
const app = new Elysia()
.use(cors())
.use(html())
.get('*', async ({path}) => {
const $ = cheerio.load(Buffer.from(await Bun.file('./dist/index.html').arrayBuffer()));
if (path.match(/posts\/(\d+)/)) {
const post_id = path.match(/posts\/(\d+)/)?.at(1);
const data = await fetch(`https://danbooru.donmai.us/posts/${post_id}.json`).then(res => res.json()) as PostData;
switch (data.file_ext) {
case 'png':
case 'webp':
case 'jpg':
case 'gif': {
$('head')
.append(og("og:image", data.file_url))
.append(og("og:image:secure_url", data.file_url))
.append(og('og:image:type', `image/${data.file_ext}`))
.append(og('og:image:height', data.image_height.toString()))
.append(og('og:image:width', data.image_width.toString()))
.append(og('twitter:image', data.file_url))
break;
}
case 'zip': $('head').append(og("og:video", data.media_asset.variants.find(v => v.file_ext === 'webm')?.url ?? '')); break;
case 'mp4':
case 'webm': {
$('head')
.append(og("og:video", data.file_url))
.append(og("og:video:secure_url", data.file_url))
.append(og("og:video:type", `video/${data.file_ext}`))
.append(og("og:video:height", data.image_height.toString()))
.append(og("og:video:width", data.image_width.toString()))
.append(og("og:image", data.media_asset.variants.find(v => v.file_ext === 'webp')?.url ?? ''))
break;
}
}
const byArtist = `${list_format.format(data.tag_string_artist.split(' '))}`;
const characters = data.tag_string_character.split(' ').map(str => {
const matched = str.match(/([a-z-_]+)(?:\((\w+)\))?/)
console.debug(str)
return matched?.at(1)?.replaceAll('_', ' ')
}).filter(str => str !== undefined);
const copyrights = data.tag_string_copyright.split(' ').map(str => {
const matched = str.match(/([a-z-_]+)(?:\((\w+)\))?/)
return matched?.at(1)?.replaceAll('_', ' ')
}).filter(str => str !== undefined);
const copyright0 = copyrights.at(0);
const title = `${list_format.format(characters)}${copyright0 ? ` (${copyright0}${copyrights.length > 1 ? ` and ${copyrights.length - 1} more` : ''})` : '' }${byArtist ? ` drawn by ${byArtist}` : ''} | Danbooru Viewer`;
const description = `${data.file_ext.toUpperCase()} | ${data.image_width}x${data.image_height} | ${digitalUnit(data.file_size)}`;
$('head')
.append(og('og:title', title))
.append(og('og:description', description))
.append(og('og:site_name', 'Danbooru Viewer'))
.append(og('og:type', 'website'))
.append(og('og:url', `https://danbooru.defaultkavy.com/${path}`))
.append(og('twitter:site', '@defaultkavy_dev'))
.append(og('twitter:title', title))
.append(og('twitter:description', description))
.append(og('twitter:card', 'summary_large_image'))
}
return $.html()
})
.get('/assets/*', (res) => {
return Bun.file(`./dist/${res.path}`)
})
.group('/api', app => { return app
.delete('/favorites/:id', async ({params, query}) => {
const data = await fetch(`${query.origin}/favorites/${params.id}.json?login=${query.login}&api_key=${query.api_key}`, {method: "DELETE"}).then(res => res.ok);
return data
})
})
.get('/statics/*', (res => {
return Bun.file(`./dist/${res.path}`)
}))
.listen(3030);
console.log('Start listening: 3030')
export type Server = typeof app;
function og(property: string, content: string | undefined) {
return `<meta property=${property} content="${content ?? ''}">`
}
export function digitalUnit(bytes: number) {
if (bytes < 1000) return `${bytes}B`
const kb = bytes / 1000;
if (kb < 1000) return `${kb.toFixed(2)}kB`;
const mb = bytes / (1000 ** 2);
if (mb < 1000) return `${mb.toFixed(2)}MB`;
const gb = bytes / (1000 ** 3);
if (gb < 1000) return `${gb.toFixed(2)}GB`;
const tb = bytes / (1000 ** 4);
if (tb < 1000) return `${tb.toFixed(2)}TB`;
const pb = bytes / (1000 ** 5);
if (pb < 1000) return `${pb.toFixed(2)}PB`;
const eb = bytes / (1000 * 6);
return `${eb.toFixed(2)}EB`;
}

View File

@ -1,182 +0,0 @@
import { $Container, $Element, $Node, $Pointer, $PointerDirection, $PointerManager, type $ContainerContentType, type $ContainerEventMap, type $EventMap } from "elexis";
export class $SlideViewer extends $Container<HTMLElement, $SlideViewerEventMap> {
pointers = new $PointerManager(this);
$container = $('div').class('slide-container')
slideMap = new Map<string | number, $Slide>();
slideId: null | string | number = null;
#pointerException?: (pointer: $Pointer, e: PointerEvent) => boolean;
constructor() {
super('slide-viewer');
this.css({position: 'relative'});
this.__build__();
new ResizeObserver(() => {
if (!this.inDOM()) return;
this.__render__();
this.trigger('resize');
}).observe(this.dom);
}
protected __build__() {
this.content([ this.$container ]);
this.$container.css({position: 'relative', height: '100%'})
let containerStartLeft = 0, containerLeft = 0;
this.pointers.on('down', ($pointer, e) => {
if (this.#pointerException) {
if (!this.#pointerException($pointer, e)) return $pointer.delete();
}
containerStartLeft = this.$container.offsetLeft;
})
this.pointers.on('move', ($pointer, e) => {
if ($pointer.direction !== $PointerDirection.Horizontal) return;
e.preventDefault();
containerLeft = containerStartLeft + $pointer.move_x;
if (containerLeft > containerStartLeft && this.slideList.at(0)?.slideId() === this.slideId) return;
if (containerLeft < containerStartLeft && this.slideList.at(-1)?.slideId() === this.slideId) return;
this.$container.css({left: `${containerLeft}px`});
})
this.pointers.on('up', ($pointer) => {
const width = this.domRect().width;
const containerMove = containerStartLeft - this.$container.offsetLeft;
if ($pointer.move_x === 0) return;
if ($pointer.movement_x < -5 || containerMove > width / 2) this.next();
else if ($pointer.movement_x > 5 || containerMove + width < width / 2) this.prev();
else {
containerLeft = containerStartLeft;
this.__slideAnimate__()
}
})
}
addSlides(slides: OrMatrix<$Slide>) {
slides = $.orArrayResolve(slides);
if (!slides.length) return;
for (const $slide of slides) {
if ($slide instanceof Array) this.addSlides($slide);
else {
this.slideMap.set($slide.slideId(), $slide);
this.$container.insert($slide)
}
}
this.__render__();
return this;
}
arrange(list: (string | number)[]) {
const newOrderedMap = new Map<string | number, $Slide>();
list.forEach(id => {
const $slide = this.slideMap.get(id);
if (!$slide) return;
newOrderedMap.set(id, $slide);
})
this.slideMap = newOrderedMap;
this.__render__();
return this;
}
switch(id: string | number | undefined) {
if (id === undefined) return this;
const $targetSlide = this.slideMap.get(id);
if (!$targetSlide) throw 'target undefined';
if ($targetSlide.slideId() === this.slideId) return this;
this.events.fire('beforeSwitch', {prevSlide: this.currentSlide, nextSlide: $targetSlide})
this.slideId = id;
this.__slideAnimate__();
this.events.fire('switch', {nextSlide: $targetSlide})
return this;
}
protected __slideAnimate__() {
const currentIndex = this.currentSlide ? this.slideList.indexOf(this.currentSlide) : undefined;
if (currentIndex === undefined) return;
const ease = Math.abs(this.getPositionLeft(currentIndex) - this.$container.offsetLeft) === this.dom.clientWidth;
this.$container.animate({
left: `-${this.getPositionLeft(currentIndex)}px`,
}, {
duration: 300,
easing: ease ? 'ease' : 'ease-out',
}, (animation) => {
this.$container.css({left: `-${this.getPositionLeft(currentIndex)}px`})
this.__render__(false);
})
}
protected __navigation__(dir: 'next' | 'prev') {
const currentSlide = this.currentSlide;
const slideList = this.slideList;
const currentIndex = currentSlide ? slideList.indexOf(currentSlide) : undefined;
if (currentIndex === undefined) { this.switch(slideList.at(0)?.slideId()); return this }
const targetIndex = $.call(() => {
switch (dir) {
case 'next': return currentIndex === slideList.length ? currentIndex : currentIndex + 1
case 'prev': return currentIndex === 0 ? currentIndex : currentIndex -1
}
})
const $targetSlide = this.slideList.at(targetIndex);
this.switch($targetSlide?.slideId());
return this;
}
next() { return this.__navigation__('next') }
prev() { return this.__navigation__('prev') }
get currentSlide() { return this.slideId ? this.slideMap.get(this.slideId) : undefined; }
get slideIdList() { return Array.from(this.slideMap.keys()); }
get slideList() { return Array.from(this.slideMap.values()); }
protected getPositionLeft(index: number) { return index * this.dom.clientWidth }
protected __render__(positioning = true) {
let i = 0;
this.slideMap.forEach($slide => {
$slide.hide(true, false);
$slide.css({top: '0', left: `${this.getPositionLeft(i)}px`});
i++;
})
if (!this.currentSlide) return;
const currentIndex = this.slideList.indexOf(this.currentSlide);
this.currentSlide.build().hide(false, false);
if (currentIndex !== 0) this.slideList.at(currentIndex - 1)?.build().hide(false, false);
if (currentIndex !== this.slideList.length - 1) this.slideList.at(currentIndex + 1)?.build().hide(false, false);
this.$container.children.render();
if (positioning) this.$container.css({left: `-${this.getPositionLeft(currentIndex)}px`})
}
pointerException(resolver: (pointer: $Pointer, e: PointerEvent) => boolean) {
this.#pointerException = resolver;
return this;
}
}
export interface $SlideViewerEventMap extends $ContainerEventMap {
switch: [{nextSlide: $Slide}];
beforeSwitch: [{prevSlide?: $Slide, nextSlide: $Slide}];
}
export class $Slide extends $Container {
#builder?: () => OrMatrix<$ContainerContentType>;
builded = false;
#slideId?: string | number;
constructor() {
super('slide');
this.css({width: '100%', height: '100%', display: 'block', position: 'absolute'})
}
builder(builder: () => OrMatrix<$ContainerContentType>) {
this.#builder = builder;
return this;
}
build() {
if (!this.builded && this.#builder) {
this.content(this.#builder());
this.builded = true;
}
return this;
}
slideId(): string | number;
slideId(slideId: string | number): this;
slideId(slideId?: string | number) { return $.fluent(this, arguments, () => this.#slideId, () => this.#slideId = slideId) }
}

View File

@ -1,162 +0,0 @@
import { $Container, type $ContainerContentType } from "elexis";
import type { Post } from "../../structure/Post";
import { Booru } from "../../structure/Booru";
import { Tag, TagCategory } from "../../structure/Tag";
import { numberFormat } from "../../structure/Util";
import type { $IonIcon } from "../IonIcon/$IonIcon";
import type { $Route } from "@elexis/router";
export class $DetailPanel extends $Container {
post: Post | null = null;
options: $DetailPanelOptions;
constructor(options?: $DetailPanelOptions) {
super('detail-panel');
this.options = {
preview: options?.preview ?? false,
tagsType: options?.tagsType ?? 'detail'
};
this.build();
}
private build() {
if (this.post) {
this.content([
this.options.preview ? $('div').class('preview').content([
$('img').src(this.post.previewURL)
]) : null,
$('div').class('detail').content([
$('section').class('post-info').content([
new $Property('id').name('Post').content(`#${this.post.id}`),
new $Property('uploader').name('Uploader').content(this.post.uploader$),
new $Property('approver').name('Approver').content(this.post.approver$),
new $Property('date').name('Date').content(this.post.created_date$),
new $Property('size').name('Size').content([this.post.file_size$, this.post.dimension$]),
new $Property('file-type').name('File Type').content(this.post.file_ext$),
$('div').class('inline').content([
new $Property('favorites').name('Favorites').content(this.post.favcount$),
new $Property('score').name('Score').content(this.post.score$)
]),
this.post.file_url ? new $Property('file-url').name('File').content([
$('a').href(this.post.file_url$).content(this.post.file_url$.convert((value) => value ? value.replace('https://', '') : '' )).target('_blank'),
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.file_url!))
]) : null,
new $Property('source-url').name('Source').content([
$('a').href(this.post.source$).content(this.post.source$.convert((value) => value.replace('https://', ''))).target('_blank'),
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.source))
]),
new $Property('booru-url').name(Booru.name$).content([
$('a').href(this.post.booruUrl$).content(this.post.booruUrl$.convert((value) => value.replace('https://', ''))).target('_blank'),
$('ion-icon').name('clipboard').on('click', (e, $ion) => this.copyButtonHandler($ion, this.post!.booruUrl))
]),
new $Property('webm-url').name('Webm').hide(true).self(async ($property) => {
await this.post!.ready;
if (this.post!.isUgoria) $property.content($('a').href(this.post!.webm_url$).content(this.post!.webm_url$.convert((value) => value.replace('https://', ''))).target('_blank')).hide(false);
}),
]),
$('div').class('post-tags').content(async $tags => {
if (this.options.tagsType === 'detail') {
const tags = (await this.post!.fetchTags()).tags;
const [artist_tags, char_tags, gen_tags, meta_tags, copy_tags] = [
tags.filter(tag => tag.category === TagCategory.Artist),
tags.filter(tag => tag.category === TagCategory.Character),
tags.filter(tag => tag.category === TagCategory.General),
tags.filter(tag => tag.category === TagCategory.Meta),
tags.filter(tag => tag.category === TagCategory.Copyright),
]
function $tag_category(category: string, tags: Tag[]) {
return tags.length ? [
$('h3').content(category),
$('section').content([
tags.map(tag => $('div').class('tag').content([
$('a').class('tag-name').content(tag.name).href(`/posts?tags=${tag.name}`),
$('span').class('tag-post-count').content(tag.post_count$.convert(numberFormat))
]))
])
] : null
}
return [
$tag_category('Artist', artist_tags),
$tag_category('Character', char_tags),
$tag_category('Copyright', copy_tags),
$tag_category('Meta', meta_tags),
$tag_category('General', gen_tags),
]
} else {
function $tag_category(category: string, tags: string[]) {
return tags.at(0)?.length ? [
$('h3').content(category),
$('section').class('tag-name-only').content([
tags.map(tag => $('a').class('tag').content(tag).href(`/posts?tags=${tag}`)),
])
] : null
}
return [
$tag_category('Artist', this.post!.tag_string_artist.split(' ')),
$tag_category('Character', this.post!.tag_string_character.split(' ')),
$tag_category('Copyright', this.post!.tag_string_copyright.split(' ')),
$tag_category('Meta', this.post!.tag_string_meta.split(' ')),
$tag_category('General', this.post!.tag_string_general.split(' ')),
]
}
})
])
])
} else {
this.content($('span').class('no-content').content('No Selected'))
}
}
update(post: Post | null) {
this.post = post;
this.build();
return this;
}
private copyButtonHandler($ion: $IonIcon, text: string) {
$ion.name('checkmark');
navigator.clipboard.writeText(text);
setTimeout(() => $ion.name('clipboard'), 3000);
}
position($route: $Route<any>) {
let scrollTop = 0;
addEventListener('scroll', () => { if (this.inDOM()) scrollTop = document.documentElement.scrollTop }, {passive: true})
$route
.on('beforeShift', () => { if (innerWidth > 800) this.css({position: `absolute`, top: `calc(${scrollTop}px + var(--nav-height) + var(--padding))`}) })
.on('afterShift', () => this.css({position: '', top: ''}))
return this;
}
}
export interface $DetailPanelOptions {
preview?: boolean;
tagsType?: 'detail' | 'name_only';
}
class $Property extends $Container {
$name = $('span').class('property-name')
$values = $('div').class('property-values')
constructor(id: string) {
super('div');
this.staticClass('property').attribute('property-id', id);
super.content([
this.$name,
this.$values.hide(true)
])
}
name(content: $ContainerContentType) {
this.$name.content(content);
return this;
}
content(content: OrMatrix<$ContainerContentType>) {
this.$values.hide(false);
const list = $.orArrayResolve(content);
this.$values.content(list.map($item => $('span').staticClass('property-value').content($item)));
return this;
}
}

View File

@ -1,162 +0,0 @@
detail-panel {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
--padding: 1rem;
position: fixed;
top: calc(var(--nav-height) + var(--padding));
right: var(--padding);
width: 300px;
height: calc(100dvh - 2rem - var(--nav-height));
// transition: all 0.3s ease;
background-color: var(--secondary-color-1);
@media (max-width: 800px) {
position: static;
width: 100%;
overflow: visible;
height: 100%;
padding: 1rem;
box-sizing: border-box;
}
span.no-content {
color: var(--secondary-color-3);
font-size: 1.6rem;
font-weight: 900;
}
div.preview {
overflow: hidden;
border-radius: var(--border-radius-large);
height: 300px;
width: 300px;
background-color: var(--secondary-color-0);
img {
height: 100%;
width: 100%;
object-fit: contain;
}
}
div.detail {
display: flex;
flex-direction: column;
gap: 0.4rem;
overflow: scroll;
overflow-x: hidden;
border-radius: var(--border-radius-large);
height: 100%;
width: 100%;
&::-webkit-scrollbar {
background-color: #000000;
width: 0px;
}
&::-webkit-scrollbar-thumb {
background-color: #aeaeec;
}
}
h3 {
padding-left: 1rem;
margin-block: 0.6rem;
}
.post-info {
background-color: #2f2f45;
border-radius: var(--border-radius-large);
padding: 20px;
display: flex;
flex-direction: column;
gap: 0.4rem;
.buttons {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
}
div.property {
display: flex;
gap: 0.6rem;
align-items: center;
width: 100%;
span.property-name {
flex-shrink: 0;
}
div.property-values {
display: flex;
gap: 0.4rem;
width: 100%;
overflow: hidden;
span.property-value {
padding: 0.2rem 0.4rem;
background-color: var(--secondary-color-1);
color: var(--primary-color-dark);
border-radius: var(--border-radius-small);
justify-content: space-between;
flex-shrink: 1;
display: flex;
align-items: center;
overflow: hidden;
&:has(ion-icon) {
flex-shrink: 0;
}
* {
display: block;
overflow: hidden;
text-wrap: nowrap;
text-overflow: ellipsis;
flex-shrink: 1;
}
ion-icon {
font-size: 1rem;
padding: 4px;
box-sizing: border-box;
}
}
}
}
div.inline {
display: flex;
gap: 1rem;
}
div.post-tags {
display: flex;
flex-direction: column;
gap: 0.2rem;
div.tag {
align-items: center;
a.tag-name {
word-break: break-word;
text-decoration: none;
}
span.tag-post-count {
background-color: var(--secondary-color-3);
color: var(--secondary-color-8);
padding: 0px 4px;
border-radius: var(--border-radius-small);
font-size: 12px;
margin-left: 0.4rem;
}
}
section.tag-name-only {
display: flex;
flex-wrap: wrap;
column-gap: 0.5rem;
a.tag {
}
}
}
}

View File

@ -1,113 +0,0 @@
import { $Container } from "elexis";
import { Booru } from "../../structure/Booru";
import { numberFormat } from "../../structure/Util";
import { danbooru, safebooru } from "../../main";
export class $Drawer extends $Container {
$filter = $('div').class('filter');
$container = $('div').class('drawer-container')
pointers = $.pointers($(document.body));
protected opened = false;
constructor() {
super('drawer');
this.hide(true);
this.build();
}
private build() {
this.content([
this.$container.content([
$('div').class('user-info').hide(true).self(($div) => [
Booru.events
.on('login', (user) => {
$div.content([
$('div').content([
$('h3').class('username').content(user.name$),
$('div').class('user-detail').content([
$('span').class('userid').content(`ID: ${user.id}`),
$('span').class('level').content(['Level: ', user.level_string$])
])
]),//.on('click', () => $.replace(user.url)),
$('div').class('user-nav').content([
$('icon-button').title('Uploaded Posts').icon('image').content(user.post_upload_count$.convert(numberFormat)).link(`/posts?tags=user:${user.name}`, true),
$('icon-button').title('Favorites').icon('heart').content(user.favorite_count$.convert(numberFormat)).link(`/posts?tags=ordfav:${user.name}`, true),
$('icon-button').title('Forum Posts').icon('document-text').content(user.forum_post_count$.convert(numberFormat)).hide(true),
])
]).hide(false);
})
.on('logout', () => {
$div.clear().hide(true);
})
]),
$('div').class('nav').content([
$('icon-button').icon('log-in-outline').content('Login').link('/login', true)
.self(($div => Booru.events.on('login', () => $div.hide(true)).on('logout', () => $div.hide(false)))),
$('icon-button').icon('log-in-outline').content('Logout').on('dblclick', () => Booru.used.logout()).hide(true)
.self(($div => Booru.events.on('login', () => $div.hide(false)).on('logout', () => $div.hide(true)))),
$('icon-button').icon('swap-horizontal').class('switch').content('Switch Booru')
.on('click', () => {
if (Booru.used === danbooru) Booru.set(safebooru);
else Booru.set(danbooru);
this.close();
}),
])
]),
this.$filter.on('click', () => $.back())
])
this.pointers.on('move', pointer => {
if ($(':slide-viewer')?.contains(pointer.$target)) return;
pointer.$target.parent
if (pointer.type !== 'pen' && pointer.type !== 'touch') return;
if (pointer.move_y > 4 || pointer.move_y < -4) return;
if (pointer.move_x <= -7) { pointer.delete(); this.open(); }
if (pointer.move_x >= 7) { pointer.delete(); this.close(); }
})
}
open() { if (location.hash !== '#drawer') $.open(location.href + '#drawer'); return this; }
close() { if (location.hash === '#drawer') $.back(); return this; }
private activate() {
this.opened = true;
this.hide(false);
this.$container.animate({
transform: [`translateX(100%)`, `translateX(0%)`]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
})
this.$filter.animate({
opacity: [0, 1]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
})
}
private inactivate() {
this.opened = false
this.$container.animate({
transform: [`translateX(0%)`, `translateX(100%)`]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
}, () => this.hide(!this.opened))
this.$filter.animate({
opacity: [1, 0]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
})
}
checkURL(beforeURL: URL | undefined, afterURL: URL) {
if (beforeURL?.hash === '#drawer') this.inactivate();
if (afterURL.hash === '#drawer') this.activate();
}
}

View File

@ -1,75 +0,0 @@
drawer {
position: fixed;
top: 0;
left: 0;
display: flex;
z-index: 300;
height: 100%;
width: 100%;
box-sizing: border-box;
justify-content: end;
align-items: center;
padding: 1rem;
div.drawer-container {
width: 300px;
max-width: 70%;
height: 100%;
background-color: var(--secondary-color-1);
border-radius: var(--border-radius-large);
z-index: 1;
overflow: hidden;
div.user-info {
background-color: var(--secondary-color-2);
padding: 2rem;
.username {
margin: 0;
color: var(--secondary-color-9);
cursor: pointer;
}
div.user-detail {
display: flex;
gap: 0.5rem;
margin-top: 0.2rem;
span {
font-size: 0.8rem;
color: var(--primary-color-dark);
display: block;
cursor: pointer;
}
}
div.user-nav {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
margin-top: 1rem;
}
}
div.nav {
padding: 2rem;
display: flex;
gap: 1rem;
flex-direction: column;
button.icon {
justify-content: start;
}
}
}
div.filter {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg,
color-mix(in srgb, var(--secondary-color-1) 50%, transparent) 0%,
color-mix(in srgb, var(--secondary-color-0) 70%, transparent) 100%
);
}
}

View File

@ -1,33 +0,0 @@
import { $Button, type $ContainerContentBuilder } from "elexis";
export class $IconButton extends $Button {
$icon = $('ion-icon');
$label = $('span');
constructor() {
super();
this.addStaticClass('icon')
this.build();
}
private build() {
super.content([
this.$icon.hide(true),
this.$label
])
}
content(children: $ContainerContentBuilder<typeof this.$label>): this {
this.$label.content(children);
return this;
}
icon(name: string) {
this.$icon.name(name).hide(false);
return this;
}
link(url: string, replace = false) {
this.on('click', () => replace ? $.replace(url) : $.open(url));
return this;
}
}

View File

@ -1,15 +0,0 @@
button.icon {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
padding: 0.8rem 1.2rem;
ion-icon {
font-size: 1.4rem;
color: inherit;
}
&.vertical {
flex-direction: column;
}
}

View File

@ -1,27 +0,0 @@
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;
}
disable(disable: boolean) {
this.attribute('disable', disable);
return this;
}
link(url: string, replace = false) {
this.on('click', () => replace ? $.replace(url) : $.open(url));
return this;
}
}

View File

@ -1,14 +0,0 @@
ion-icon {
font-size: 24px;
color: var(--primary-color);
cursor: pointer;
&:hover {
color: var(--secondary-color-9);
}
&[disable="true"] {
color: var(--primary-color-darker);
pointer-events: none;
}
}

View File

@ -1,98 +0,0 @@
import { $Layout, type $LayoutEventMap } from "@elexis/layout";
import { Booru } from "../../structure/Booru";
import { Post } from "../../structure/Post";
import { $PostTile } from "../PostTile/$PostTile";
import { $Input } from "elexis/lib/node/$Input";
import { PostManager } from "../../structure/PostManager";
interface $PostGridOptions {
tags?: string
}
export class $PostGrid extends $Layout {
$postMap = new Map<Post, $PostTile>();
tags?: string;
$focus = $.focus();
posts: PostManager;
constructor(options?: $PostGridOptions) {
super();
this.tags = options?.tags;
this.posts = PostManager.get(this.tags);
this.addStaticClass('post-grid');
this.type('waterfall').gap(10);
this.init();
}
protected async init() {
this.posts.events.on('post_fetch', (posts) => { this.renderPosts() })
setInterval(async () => { if (this.inDOM() && document.documentElement.scrollTop === 0) await this.posts.fetchPosts('newer'); }, 10000);
Booru.events.on('set', () => {
this.removeAll();
this.loader();
})
this.on('resize', () => this.resize())
// this.on('afterRender', () => {
// this.$focus.currentLayer?.focus(this.$focus.currentLayer.currentFocus);
// })
this.loader();
this.$focus.layer(100).loop(false).scrollThreshold($.rem(2) + 60);
$.keys($(window))
.if(e => {
if (!this.inDOM()) return;
if ($(e.target) instanceof $Input) return;
return true;
})
// .keydown('Tab', e => {
// e.preventDefault();
// if (e.shiftKey) this.$focus.prev();
// else this.$focus.next();
// })
.keydown(['w', 'W'], e => { e.preventDefault(); this.$focus.up(); })
.keydown(['s', 'S'], e => { e.preventDefault(); this.$focus.down(); })
.keydown(['d', 'D'], e => { e.preventDefault(); this.$focus.right(); })
.keydown(['a', 'A'], e => { e.preventDefault(); this.$focus.left(); })
.keydown([' ', 'Enter'], e => {
e.preventDefault();
const focused = this.$focus.currentLayer?.currentFocus;
if (focused instanceof $PostTile) $.open(focused.url);
})
.keydown(['Escape'], e => { e.preventDefault(); this.$focus.blur(); })
}
protected async loader() {
if (!this.inDOM()) return setTimeout(() => this.loader(), 100);;
while (this.inDOM() && document.documentElement.scrollHeight <= innerHeight * 2) {
const posts = await this.posts.fetchPosts('older');
if (!posts.length) return;
}
if (document.documentElement.scrollTop + innerHeight > document.documentElement.scrollHeight - innerHeight * 2) {
const posts = await this.posts.fetchPosts('older');
if (!posts.length) return;
}
setTimeout(() => this.loader(), 100);
}
protected resize() {
const col = Math.round(this.dom.clientWidth / 300);
this.column(col >= 2 ? col : 2);
}
renderPosts() {
this.$focus.layer(100).elementSet.clear();
const $postList = [...this.posts.orderMap.values()].map(post => {
const $post = this.$postMap.get(post) ?? new $PostTile(this, post).on('$focus', (e, $post) => this.$focus.layer(100).focus($post));
this.$postMap.set(post, $post)
return $post.self(this.$focus.layer(100).add)
});
this.content($postList).render();
return this;
}
removeAll() {
this.$postMap.clear();
this.$focus.layer(100).removeAll();
this.animate({opacity: [1, 0]}, {duration: 300, easing: 'ease'}, () => this.clear().render())
return this;
}
}

View File

@ -1,19 +0,0 @@
layout.post-grid {
margin-block: 1rem;
a {
transition: 0.3s all ease;
}
&:has(post-tile[focus]) {
post-tile:not([focus]) {
a {
opacity: 0.5;
}
}
post-tile:hover {
a {
opacity: 1;
}
}
}
}

View File

@ -1,75 +0,0 @@
import { $Container, $Image, $State, $Video } from "elexis";
import type { Post } from "../../structure/Post";
import { time } from "../../structure/Util";
import { detailPanelEnable$ } from "../../main";
import type { $PostGrid } from "../PostGrid/$PostGrid";
export class $PostTile extends $Container {
post: Post;
$video: $Video | null;
$img: $Image;
duration$ = $.state(``);
$grid: $PostGrid;
constructor($grid: $PostGrid, post: Post) {
super('post-tile');
this.$grid = $grid;
this.post = post;
this.$video = this.post.isVideo ? $('video').width(this.post.image_width).height(this.post.image_height).disablePictureInPicture(true).loop(true).muted(true).hide(true).on('mousedown', (e) => e.preventDefault()) : null;
this.$img = $('img').draggable(false).css({opacity: '0'}).width(this.post.image_width).height(this.post.image_height).src(this.post.previewURL).loading('lazy');
this.attribute('filetype', this.post.file_ext);
this.durationUpdate();
this.build();
}
build() {
this.$video?.on('timeupdate', (e, $video) => {
this.durationUpdate();
})
this.class('loading').content([
// Video Detail
this.post.isVideo
? $('div').class('video-detail').content([
this.post.hasSound ? $('ion-icon').name('volume-medium-outline') : null,
this.post.isUgoria ? $('ion-icon').name('images-outline') : null,
$('span').class('duration').content(this.duration$)
]) : null,
// Gif
this.post.isGif
? $('div').class('gif-detail').content([
$('span').content('GIF')
]) : null,
// Tile
$('a').href(this.url).preventDefault(detailPanelEnable$).content(() => [
this.$video,
this.$img.on('mousedown', (e) => e.preventDefault())
.once('load', (e, $img) => {
$img.animate({opacity: [0, 1]}, {duration: 300}, () => $img.css({opacity: ''}));
this.removeClass('loading');
})
])
])
this.on(['focus', 'mouseenter', 'touchstart'], () => {
if (!this.$video?.isPlaying) {
this.$video?.src(this.post.large_file_url).hide(false).play().catch(err => undefined)
}
if (this.post.isGif) { this.$img.src(this.post.large_file_url) }
}, {passive: true} )
.on(['blur', 'mouseleave', 'touchend', 'touchcancel'], () => {
this.$video?.pause().currentTime(0).hide(true);
if (this.post.isGif) { this.$img.src(this.post.previewURL) }
}, {passive: true} )
.on('click', () => {
if (!detailPanelEnable$.value) return;
if (innerWidth <= 800) return $.open(this.url);
if (this.attribute('focus') === '') $.open(this.url);
else this.trigger('$focus');
})
}
durationUpdate() {
if (!this.$video) return;
const t = time(this.post.media_asset.duration * 1000 - this.$video.currentTime() * 1000)
this.duration$.set(Number(t.hh) > 0 ? `${t.hh}:${t.mm}:${t.ss}` : `${t.mm}:${t.ss}`)
}
get url() { return `${this.post.pathname}${this.$grid.tags ? `?q=${this.$grid.tags}` : ''}` }
}

View File

@ -1,81 +0,0 @@
post-tile {
display: block;
transition: 0.3s all ease;
position: relative;
transition: all 0.3s ease;
border-radius: var(--border-radius-medium);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
user-select: none;
outline: transparent solid 2px;
background-color: var(--secondary-color-1);
&[focus] {
outline: var(--secondary-color-9) solid 2px;
transform: scale(1.02);
}
@media (hover: hover) {
&:hover {
transform: scale(1.02);
z-index: 1;
box-shadow: 0 0 10px color-mix(in srgb, var(--secondary-color-1) 50%, transparent)
}
}
&.loading {
transition: none;
}
&:active {
transform: scale(0.95);
}
div.video-detail, div.gif-detail {
position: absolute;
background-color: var(--secondary-color-3);//color-mix(in srgb, var(--secondary-color-3) 80%, transparent);
color: var(--primary-color);
bottom: 0.3rem;
right: 0.3rem;
padding: 0.2em 0.4em;
height: 1rem;
border-radius: var(--border-radius-small);
font-size: 12px;
display: flex;
align-items: center;
gap: 0.2rem;
z-index: 2;
// text-shadow: 0 0 0.5em var(--secondary-color-1);
ion-icon {
font-size: 1.4rem;
&[name="images-outline"] {
padding: 0.1rem;
font-size: 1rem;
}
}
span.duration {
text-transform: uppercase;
z-index: 2;
}
}
a {
background-color: transparent;
padding: 0;
border-radius: 0;
img {
height: 100%;
width: 100%;
vertical-align: top;
background-color: var(--secondary-color-1);
}
video {
height: 100%;
width: 100%;
object-fit: cover;
position: absolute;
z-index: 1;
}
}
}

View File

@ -1,104 +0,0 @@
import { $Container } from "elexis";
import type { Post } from "../../structure/Post";
import { Booru } from "../../structure/Booru";
import { ClientUser } from "../../structure/ClientUser";
import { $VideoController } from "../VideoController/$VideoController";
import { $Input } from "elexis/lib/node/$Input";
export class $PostViewer extends $Container<HTMLElement, $PostViewerEventMap> {
$video = $('video');
post: Post;
constructor(post: Post) {
super('div');
this.post = post
this.class('viewer');
this.build();
}
async build() {
await this.post.ready;
this.events.on('video_play_pause', () => { if (this.$video.isPlaying) this.$video.pause(); else this.$video.play() })
this.content([
$('div').class('viewer-panel').hide(false).content($viewerPanel => {
this.events.on('viewerPanel_hide', () => $viewerPanel.hide(true))
.on('viewerPanel_show', () => $viewerPanel.hide(false))
.on('viewerPanel_switch', () => { $viewerPanel.hide(!$viewerPanel.hide()) })
return [
$('div').class('panel').content([
this.post.isVideo ? new $VideoController(this.$video, this, this.post) : null,
$('div').class('buttons').content([
$('ion-icon').title('Favorite').name('heart-outline').self($heart => {
ClientUser.events.on('favoriteUpdate', (user) => {
if (user.favorites.has(this.post.id)) $heart.name('heart');
else $heart.name('heart-outline');
})
if (Booru.used.user?.favorites.has(this.post.id)) $heart.name('heart');
$heart.on('click', () => {
if (Booru.used.user?.favorites.has(this.post.id)) this.post.deleteFavorite();
else this.post.createFavorite();
})
}),
$('ion-icon').title('Original Size').name('resize-outline').self($original => {
$original.on('click', () => { this.events.fire('original_size'); $original.disable(true); })
if (!this.post.isLargeFile || this.post.isVideo) $original.disable(true);
})
])
]),
$('div').class('overlay')
]
}),
this.post.isVideo
? this.$video.height(this.post.image_height).width(this.post.image_width).src(this.post.file_ext === 'zip' ? this.post.large_file_url : this.post.file_url)
.controls(false).loop(true).disablePictureInPicture(true)
: $('img').height(this.post.image_height).width(this.post.image_width).self($img => {
$img.once('load', () =>
$img.once('load', () => $img.removeClass('loading')).src(this.post.isLargeFile ? this.post.large_file_url : this.post.file_url)
).src(this.post.preview_file_url)
if (!$img.complete) $img.class('loading')
this.events.on('original_size', () => $img.src(this.post.file_url))
})
])
this.on('pointerleave', (e) => {
if (e.pointerType === 'touch') return;
this.events.fire('viewerPanel_hide');
})
this.on('pointermove', (e) => {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') this.events.fire('viewerPanel_show');
})
let doubleTap: Timer | null = null;
$.pointers(this)
.on('up', pointer => {
if ( this.$(':.viewer-panel .panel')?.contains($(pointer.$target)) ) return;
if (pointer.type === 'mouse') this.events.fire('video_play_pause');
else {
if (doubleTap !== null) {
this.events.fire('video_play_pause');
}
doubleTap = setTimeout(() => {
doubleTap = null;
}, 300);
this.events.fire('viewerPanel_switch');
}
})
$.keys($(window)).self($keys => $keys
.if(e => {
if ($(e.target) instanceof $Input) return;
if (!this.inDOM()) return;
return true;
})
.keydown(' ', e => {
e.preventDefault();
if (this.$video.isPlaying) this.$video.pause();
else this.$video.play();
})
)
}
}
export interface $PostViewerEventMap {
viewerPanel_hide: [],
viewerPanel_show: [],
viewerPanel_switch: [],
original_size: [],
video_play_pause: [],
}

View File

@ -1,338 +0,0 @@
import { $Container } from "elexis";
import { Tag, TagCategory } from "../../structure/Tag";
import { Booru } from "../../structure/Booru";
import { Autocomplete } from "../../structure/Autocomplete";
import { numberFormat } from "../../structure/Util";
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.open();
if (this.inDOM() && e.key === 'Escape') this.close();
})
}
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') this.close();
})
])
}
open() { if (location.hash !== '#search') $.open(location.href + '#search'); return this; }
close() { if (location.hash === '#search') $.back(); return this; }
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);
if (this.$tagInput.$input.value().at(-1) === ':') return this.getSearchSuggestions();
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) {
if (inputIndex - 1 >= 0) 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();
const results = await Autocomplete.fetch(Booru.used, input, 20);
this.$selectionList
.clearSelections()
.addSelections(results.map(data => new $Selection().value(data.value)
.content([
$('div').class('selection-label').content([
data.isTagAntecedent() ? $('span').class('tag-antecedent').self($span => $span.dom.innerHTML = data.antecedent.replaceAll(input, `<b>${input}</b>`)) : null,
$('div').class('label-container').content([
data.isTagAntecedent() ? $('ion-icon').name('arrow-forward-outline') : null,
$('span').class('label').self($span => $span.dom.innerHTML = data.label.replaceAll(input, `<b>${input}</b>`))
])
]),
data.isTag() ? $('div').class('tag-detail').content([
$('span').class('tag-post-count').content(numberFormat(data.post_count)),
$('span').class('tag-category').content(TagCategory[data.category])
]) : null,
data.isUser() ? $('span').class('user-level').content(data.level) : null
])
.on('click', () => {this.$tagInput.addTag(data.value).input()})
))
}
search() {
$.replace(`/posts?tags=${this.$tagInput.query.replace(':', '%3A')}`);
this.$tagInput.clearAll();
this.inactivate();
return this;
}
checkURL(beforeURL: URL | undefined, afterURL: URL) {
if (beforeURL?.hash === '#search') this.inactivate();
if (afterURL.hash === '#search') this.activate();
if (`${beforeURL?.pathname}${beforeURL?.search}` === `${afterURL.pathname}${afterURL.search}`) return;
const tags_string = afterURL.searchParams.get('tags');
this.$tagInput.clearAll();
tags_string?.split(' ').forEach(tag => this.$tagInput.addTag(tag));
}
}
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();
if ($selection.offsetTop < this.scrollTop()) this.scrollTop($selection.offsetTop);
if ($selection.offsetTop + $selection.offsetHeight > this.scrollTop() + this.offsetHeight) this.scrollTop($selection.offsetTop + $selection.offsetHeight - this.offsetHeight);
return this;
}
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();
this.$seachbar.$selectionList.clearSelections();
this.$seachbar.getSearchSuggestions();
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('');
if (this.$input.inDOM()) this.$inputor.replace($tag);
else this.insert($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;
}
focus() {
this.$input.focus();
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

@ -1,184 +0,0 @@
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: color-mix(in srgb, var(--secondary-color-2) 100%, transparent);
border-radius: var(--border-radius-small);
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-4);
&: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-4);
color: var(--secondary-color-9);
border-radius: var(--border-radius-small);
cursor: pointer;
}
}
ion-icon {
font-size: 20px;
color: var(--secondary-color-4);
cursor: pointer;
&:hover {
color: var(--secondary-color-9);
}
}
}
div.selection-list-container {
overflow: hidden;
border-radius: var(--border-radius-small);
background-color: var(--secondary-color-1);
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;
&::-webkit-scrollbar {
width: 4px;
}
selection {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.4rem 1rem;
cursor: pointer;
gap: 1rem;
&:hover {
background-color: color-mix(in srgb, var(--secondary-color-3) 50%, transparent);
}
&.active {
background-color: var(--secondary-color-3);
}
div.selection-label {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
.label-container {
display: flex;
gap: 0.5rem;
align-items: center;
ion-icon {
font-size: 1rem;
}
}
}
div.tag-detail {
display: flex;
align-items: center;
gap: 0.5rem;
.tag-post-count {
font-size: 0.8rem;
}
}
.tag-category, .user-level {
padding: 0.1rem 0.4rem;
border-radius: var(--border-radius-small);
font-size: 0.9rem;
background-color: var(--secondary-color-4);
color: var(--secondary-color-9);
}
}
}
}
div.filter {
background-color: var(--secondary-color-1);
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-9);
border-radius: var(--border-radius-small);
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;
}
}

View File

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

1
src/env.d.ts vendored
View File

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

54
src/index.d.ts vendored
View File

@ -1,54 +0,0 @@
type timestamp = number;
type ISOString = string;
type id = number;
type username = string;
type DateType = `${number}-${number}-${number}`;
type PeriodType = `${number}${PeriodUnit}`
type PeriodUnit = 'seconds' | 's' | 'minutes' | 'mi' | 'hours' | 'h' | 'days' | 'd' | 'weeks' | 'w' | 'months' | 'mo' | 'years' | 'y'
type UserSyntax = username | 'any' | 'none'
type Rating = 'explicit' | 'e' | 'questionable' | 'q' | 'sensitive' | 's' | 'general' | 'g'
type Source = 'http' | `https://${string}` | `*${string}*` | 'none'
type Ratio = `${numebr}:${numebr}` | `${number}/${number}` | number
type FileSize = `${number}${FileSizeUnit}`
type FileSizeUnit = 'b' | 'kb' | 'm'
type FileType = 'jpg' | 'png' | 'gif' | 'swf' | 'webm' | 'mp4' | 'zip' | 'webp'
type seconds = number;
type poolname = string;
type NumericSymbols = '<' | '>' | '<=' | '>='
type NumericBasicSyntax<N> = N | N[] | `${N}` | `${NumericSymbols}${N}` | `${N}..` | `..${N}` | `${N}...${N}`
type NumericSyntax<T> = NumericBasicSyntax<T> | NumericSyntaxComparisons<T>;
type NumericSyntaxComparisons<T> =
{ _not: NumericBasicSyntax<T> } |
{ _eq: NumericBasicSyntax<T> } |
{ _not_eq: NumericBasicSyntax<T> } |
{ _gt: NumericBasicSyntax<T> } |
{ _gteq: NumericBasicSyntax<T> } |
{ _lt: NumericBasicSyntax<T> } |
{ _lteq: NumericBasicSyntax<T>}
type TextSyntax<T> = T | TextSyntaxComparisons<T>;
type TextSyntaxComparisons<T> =
{ _eq: T } |
{ _not_eq: T } |
{ _like: T } |
{ _ilike: T } |
{ _not_like: T } |
{ _not_ilike: T } |
{ _regex: string } |
{ _not_regex: string } |
{ _array: string } |
{ _comma: string } |
{ _space: string } |
{ _lower_array: string } |
{ _lower_comma: string } |
{ _lower_space: string }
type UserSyntax = { _id: id } | { _name: username };
type ChainingSyntax = {_id: id} | {has_: boolean};
type PostSyntax = {_id: id} | {_tags_match: string};
interface APIError {
success: false;
error: string;
message: string;
}

View File

@ -1,210 +0,0 @@
import 'elexis';
import '@elexis/layout';
import '@elexis/router';
import { Booru } from './structure/Booru';
import { post_route } from './route/post/$post_route';
import { $PostGrid } from './component/PostGrid/$PostGrid';
import { $Route, $Router, $RouterAnchor, $RouterNavigationDirection } from '@elexis/router';
import { $Searchbar } from './component/Searchbar/$Searchbar';
import { $IonIcon } from './component/IonIcon/$IonIcon';
import { $IconButton } from './component/IconButton/$IconButton';
import { $login_route } from './route/login/$login_route';
import { $Drawer } from './component/Drawer/$Drawer';
import { $Input } from 'elexis/lib/node/$Input';
import { $DetailPanel } from './component/DetailPanel/$DetailPanel';
import { $PostTile } from './component/PostTile/$PostTile';
import { LocalSettings } from './structure/LocalSettings';
// declare elexis module
declare module 'elexis' {
export namespace $ {
export interface TagNameElementMap {
'ion-icon': typeof $IonIcon;
'icon-button': typeof $IconButton;
'a': typeof $RouterAnchor;
}
}
}
$.registerTagName('ion-icon', $IonIcon)
$.registerTagName('icon-button', $IconButton)
$.registerTagName('a', $RouterAnchor)
// 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);
const $drawer = new $Drawer();
export const detailPanelEnable$ = $.state(LocalSettings.detailPanelEnabled ?? false).on('update', ({state$}) => LocalSettings.detailPanelEnabled = state$.value)
// render
$(document.body).content([
// Navigation Bar
$('nav').content([
// Title
$('a').class('title').href('/').content([
$('h1').class('booru-name').content(Booru.name$),
$('h2').class('app').content([
$('span').class('app-name').content(`Viewer`),
$('span').class('version').content(`v${__APP_VERSION__}`)
])
]),
// 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', () => $searchbar.open()),
// 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', () => $searchbar.open()),
// Detail Panel Button
$('ion-icon').class('detail-panel').name('reader-outline').title('Toggle Detail Panel').on('click', () => detailPanelEnable$.set(!detailPanelEnable$.value)),
// Open Booru
$('a').content($('ion-icon').class('open').name('open-outline').title('Open in Original Site')).href(location.href.replace(location.origin, Booru.used.origin)).target('_blank'),
// Copy Button
$('ion-icon').class('copy').name('link-outline').title('Copy Page Link').hide(false)
.on('click', (e, $copy) => {
navigator.clipboard.writeText(`${location.origin}${location.pathname}${location.search}`)
$copy.name('checkmark-outline');
setTimeout(() => {
$copy.name('link-outline')
}, 2000);
}),
// Menu Button
$('ion-icon').class('menu').name('menu-outline').title('Menu').hide(false)
.self(($icon) => { Booru.events.on('login', () => $icon.hide(true)).on('logout', () => $icon.hide(false)) })
.on('click', () => $.open(location.href + '#drawer')),
// Account Menu
$('div').class('account').hide(true).title('Menu')
.self(($account) => {
Booru.events
.on('login', user => { $account.content(user.name$.convert(value => value.at(0)?.toUpperCase() ?? '')).hide(false); })
.on('logout', () => $account.hide(true))
})
.on('click', () => $drawer.open())
])
]),
// Searchbar
$searchbar,
// Drawer
$drawer,
// Base Router
$('router').base('/').map([
// Home Page
$('route').id('posts').path(['/', '/posts']).builder(({$route, query}) => {
const { $postGrid, $detail } = $postsPageComponents($route, query);
return [ $postGrid, $detail ]
}),
// Posts Page
$('route').id('posts').path('/posts?tags').builder(({$route, query}) => {
const { $postGrid, $detail } = $postsPageComponents($route, query)
return [
$('header').content([
$('h2').content('Posts'),
$('div').class('tags').self($div => {
query.tags.split('+').forEach(tag => {
$div.insert($('a').class('tag').content(decodeURIComponent(tag)).href(`posts?tags=${tag}`))
})
})
]),
$('div').class('no-post').hide(true).self($div => {
$div.on('startLoad', () => $div.hide(true))
$postGrid.self(() => {
$postGrid.posts.events
.on('noPost', () => $div.hide(false).content('No Posts'))
.on('post_error', message => $div.hide(false).content(message))
})
}),
$postGrid,
$detail
]
}),
// Post Page
post_route,
// Login Page
$login_route
]).on('beforeSwitch', (e) => {
const DURATION = 300;
const TX = 2;
e.preventDefault();
function intro() {
$(document.documentElement).css({scrollBehavior: 'auto'});
const transform = $.call(() => {
switch ($Router.navigationDirection) {
case $RouterNavigationDirection.Forward: return [`translateX(${TX}%)`, `translateX(0%)`];
case $RouterNavigationDirection.Back: return [`translateX(-${TX}%)`, `translateX(0%)`];
case $RouterNavigationDirection.Replace: return '';
}
})
e.$view.content(e.nextContent);
e.rendered();
e.nextContent.element?.class('animated').animate({
opacity: [0, 1],
transform
}, {
duration: DURATION,
easing: 'ease'
}, () => {
e.switched();
$(document.documentElement).css({scrollBehavior: ''});
e.nextContent.element?.removeClass('animated')
})
}
function outro() {
$(document.documentElement).css({scrollBehavior: 'auto'});
const transform = $.call(() => {
switch ($Router.navigationDirection) {
case $RouterNavigationDirection.Forward: return [`translateX(0%)`, `translateX(-${TX}%)`];
case $RouterNavigationDirection.Back: return [`translateX(0%)`, `translateX(${TX}%)`];
case $RouterNavigationDirection.Replace: return '';
}
})
e.previousContent?.element?.class('animated').animate({
opacity: [1, 0],
transform
}, {
duration: DURATION,
easing: 'ease'
}, () => {
e.previousContent?.element?.removeClass('animated');
intro();
})
}
if (e.previousContent) outro();
else intro();
})
])
$Router.events.on('stateChange', ({beforeURL, afterURL}) => componentState(beforeURL, afterURL))
componentState(undefined, new URL(location.href))
function componentState(beforeURL: URL | undefined, afterURL: URL) {
$searchbar.checkURL(beforeURL, afterURL); $drawer.checkURL(beforeURL, afterURL)
}
function $postsPageComponents($route: $Route, query: {tags?: string}) {
const $postGrid = new $PostGrid(query);
const $detail = new $DetailPanel({preview: true, tagsType: 'name_only'}).hide(detailPanelEnable$.convert(bool => !bool)).position($route);
detailPanelCheck();
detailPanelEnable$.on('update', detailPanelCheck);
Booru.events.on('set', () => $detail.update(null));
function detailPanelCheck() { detailPanelEnable$.value ? $postGrid.addClass('detail-panel-enabled') : $postGrid.removeClass('detail-panel-enabled') }
$postGrid.$focus
.on('focus', ({$focused: $target}) => {if ($target.inDOM() && $target instanceof $PostTile) $detail.update($target.post) })
.on('blur', () => $detail.update(null))
return { $postGrid, $detail };
}
$.keys($(window))
.if(e => {
if ($(e.target) instanceof $Input) return;
return true;
})
.keydown(['q', 'Q'], e => { e.preventDefault(); if ($Router.index !== 0) $.back(); })
.keydown(['e', 'E'], e => { e.preventDefault(); if ($Router.forwardIndex !== 0) $.forward(); })
.keydown('Tab', e => { e.preventDefault(); detailPanelEnable$.set(!detailPanelEnable$.value) })

View File

@ -1,30 +0,0 @@
import { Booru } from "../../structure/Booru"
import { ClientUser } from "../../structure/ClientUser";
export const $login_route = $('route').id('login').path('/login').builder(() => {
const [username$, apiKey$] = [$.state(''), $.state('')]
return [
$('div').class('login-container').content([
$('h1').content('Login'),
$('div').class('username', 'input-container').content([
$('label').for('username').content('Username'),
$('input').type('text').id('username').value(username$)
]),
$('div').class('api-key', 'input-container').content([
$('label').for('api-key').content('API Key'),
$('input').type('password').id('api-key').value(apiKey$)
]),
$('icon-button').content('Login').on('click', async () => {
await Booru.used.login(username$.value, apiKey$.value);
if (Booru.used.user) {
ClientUser.storageUserData = { apiKey: apiKey$.value, username: username$.value }
// Clear input
username$.set('');
apiKey$.set('');
$.replace('/');
};
}),
$('icon-button').content('Create Account').icon('open-outline').on('click', () => $.open('https://danbooru.donmai.us/users/new', '_blank')),
])
]
})

View File

@ -1,31 +0,0 @@
route#login {
display: flex;
justify-content: center;
align-items: center;
margin-top: 5rem;
.login-container {
padding: 2rem;
border: 1px solid var(--secondary-color-9);
border-radius: var(--border-radius-large);
display: flex;
flex-direction: column;
justify-content: center;
gap: 1rem;
max-width: 400px;
width: 100%;
box-sizing: border-box;
h1 {
margin: 0;
}
.input-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
input {
display: block;
}
}
}
}

View File

@ -1,117 +0,0 @@
import { Post } from "../../structure/Post";
import { ArtistCommentary } from "../../structure/Commentary";
import { Booru } from "../../structure/Booru";
import { $Input } from "elexis/lib/node/$Input";
import { $DetailPanel } from "../../component/DetailPanel/$DetailPanel";
import { PostManager } from "../../structure/PostManager";
import { $PostViewer } from "../../component/PostViewer/$PostViewer";
import { $Slide, $SlideViewer } from "../../component/$SlideViewer";
import { $Video } from "elexis";
export const post_route = $('route').path('/posts/:id?q').id('post').static(false).builder(({$route, params}) => {
if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND');
const events = $.events<{
post_switch: [Post]
}>();
let post: Post, posts: PostManager;
$.keys($(window)).self($keys => $keys
.if(e => {
if ($(e.target) instanceof $Input) return;
if (!$route.inDOM()) return;
return true;
})
.keydown(['f', 'F'], e => {
if (Booru.used.user?.favorites.has(post.id)) post.deleteFavorite();
else post.createFavorite();
})
.keydown(['a', 'A'], e => navPost('prev') )
.keydown(['d', 'D'], e => { navPost('next') })
)
const $slideViewerMap = new Map<string | undefined, $SlideViewer>();
$route.on('open', async ({params, query}) => {
posts = PostManager.get(query.q);
post = Post.get(Booru.used, +params.id);
posts.events.on('post_fetch', slideViewerHandler);
if (!posts.orderMap.size || !posts.cache.has(post)) {
await post.ready
posts.addPosts(post);
posts.orderMap.set(post.id, post);
posts.fetchPosts('newer');
posts.fetchPosts('older');
} else {
const ordered = [...posts.orderMap.values()];
const index = ordered.indexOf(post);
if (!posts.finished && index === ordered.length - 1) {
posts.fetchPosts('older');
} else if (index === 0) {
posts.fetchPosts('newer');
}
}
slideViewerHandler({manager: posts});
const $slideViewer = $getSlideViewer(posts.tags)
$slideViewer.switch(post.id);
events.fire('post_switch', post);
})
function $getSlideViewer(q: string | undefined) {
const $slideViewer = $slideViewerMap.get(q) ??
new $SlideViewer()
.pointerException((pointer) => {
if ($slideViewer.currentSlide?.$('::.progressbar-container').find($div => $div.contains(pointer.$target))) return false;
if (pointer.type === 'mouse') return false;
return true;
})
.on('switch', ({nextSlide: $target}) => {
$.replace(`/posts/${$target.slideId()}${q ? `?q=${q}` : ''}`);
}).on('beforeSwitch', ({prevSlide, nextSlide}) => {
const $prevVideo = prevSlide?.$<$Video>(':video');
if ($prevVideo?.isPlaying) $prevVideo.pause();
const $nextVideo = nextSlide.$<$Video>(':video');
if ($nextVideo?.isPlaying === false) $nextVideo.play();
})
$slideViewerMap.set(q, $slideViewer);
return $slideViewer;
}
function navPost(dir: 'next' | 'prev') {
const orderList = [...posts.orderMap.values()];
const index = orderList.indexOf(post);
if (dir === 'prev' && index === 0) return;
const targetPost = orderList.at(dir === 'next' ? index + 1 : index - 1);
if (!targetPost) return;
$.replace(`/posts/${targetPost.id}${posts.tags ? `?q=${posts.tags}` : ''}`);
}
function slideViewerHandler(params: {manager: PostManager}) {
const { manager: posts } = params;
const $slideViewer = $getSlideViewer(posts.tags);
const postList = posts.cache.array.filter(post => !$slideViewer.slideMap.has(post.id));
$slideViewer.addSlides(postList.map(post => new $Slide().slideId(post.id).builder(() => new $PostViewer(post))));
if (postList.length) $slideViewer.arrange([...posts.orderMap.values()].map(post => post.id));
}
return [
$('div').class('slide-viewer-container').self($div => {
$route.on('open', () => {
$div.content($getSlideViewer(posts.tags))
})
}),
$('div').class('content').content([
$('h3').content(`Artist's Commentary`),
$('section').class('commentary').self(async ($comentary) => {
events.on('post_switch', async post => {
const commentary = (await ArtistCommentary.fetchMultiple(Booru.used, {post: {_id: post.id}})).at(0);
$comentary.content([
commentary ? [
commentary.original_title ? $('h3').content(commentary.original_title) : null,
$('pre').content(commentary.original_description)
] : 'No commentary'
])
})
})
]),
new $DetailPanel().position($route).self($detail => {
events.on('post_switch', (post) => $detail.update(post))
})
]
})

View File

@ -1,176 +0,0 @@
#post {
padding: 0;
padding-top: var(--nav-height);
slide-viewer {
display: block;
height: calc(100dvh - 2rem - var(--nav-height));
background-color: #000000;
border-radius: var(--border-radius-large);
overflow: hidden;
width: calc(100vw - 300px - 4rem);
margin: 1rem;
position: relative;
transition: all 0.3s ease;
touch-action: pan-y;
@media (max-width: 800px) {
width: 100%;
height: calc(100dvh - var(--nav-height));
border-radius: 0;
margin:0;
}
}
div.viewer {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: #000000;
border-radius: var(--border-radius-large);
overflow: hidden;
position: relative;
transition: all 0.3s ease;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: all 0.3s ease;
&.loading {
filter: blur(5px);
}
}
video {
max-width: 100%;
max-height: 100%;
-webkit-user-drag: none;
transition: all 0.3s ease;
}
div.viewer-panel {
position: absolute;
bottom: 0;
width: 100%;
z-index: 1;
div.panel {
width: 100%;
display: flex;
justify-content: center;
flex-direction: column;
padding: 1rem;
gap: 1rem;
box-sizing: border-box;
video-controller {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
width: 100%;
div.video-details {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
div {
display: flex;
align-items: center;
gap: 1rem;
}
}
div.progressbar-container {
height: 2rem;
width: 100%;
display: flex;
touch-action: none;
align-items: center;
cursor: pointer;
div.progressbar {
height: 0.4rem;
width: 100%;
background-color: var(--secondary-color-1);
flex-shrink: 1;
div.progress {
height: 100%;
background-color: var(--secondary-color-3);
width: 100px;
}
}
}
.play {
flex-shrink: 0;
}
}
div.buttons {
width: 100%;
display: flex;
justify-content: center;
gap: 2rem;
}
}
div.overlay {
position: absolute;
bottom: 0;
width: 100%;
height: 200%;
z-index: -1;
background: linear-gradient(180deg,
color-mix(in srgb, var(--secondary-color-1) 0%, transparent) 0%,
color-mix(in srgb, var(--secondary-color-0) 70%, transparent) 100%
);
}
}
}
div.content {
width: calc(100vw - 300px - 2rem);
display: flex;
flex-direction: column;
padding: 1rem;
box-sizing: border-box;
@media (max-width: 800px) {
width: 100%;
}
&::-webkit-scrollbar {
background-color: #000000;
width: 4px;
}
&::-webkit-scrollbar-thumb {
background-color: #aeaeec;
border-radius: 2px;
}
& > h3 {
padding-left: 1rem;
margin-block: 1rem;
}
section.commentary {
* {
text-wrap: wrap;
word-break: break-word;
}
}
}
}
// animated resolver
// .animated div.sidebar {
// top: 0 !important;
// }

View File

@ -1,135 +0,0 @@
import type { Booru } from "./Booru";
import type { TagCategory } from "./Tag";
import type { UserLevel } from "./User";
export class Autocomplete {
static async fetch(booru: Booru, query: string, limit: number = 20) {
if (!query.length) return this.searchQuery.map(data => new AutocompleteResult(data))
const res = await booru.fetch<AutocompleteData[]>(`/autocomplete.json?search[query]=${query}&search[type]=tag_query&version=1&limit=${limit}`);
const searchQueryResult = query.length ? this.searchQuery.filter(sq => sq.value.startsWith(query) && sq.value !== query) : this.searchQuery
return [...searchQueryResult, ...res].map(data => new AutocompleteResult(data));
}
static searchQuery: AutocompleteSearchQueryData[] = [
{value: 'user:', label: 'user:'},
{value: 'approver:', label: 'approver:'},
{value: '-approver:', label: '-approver:'},
{value: 'order:', label: 'order:'},
{value: 'ordfav:', label: 'ordfav:'},
{value: 'ordfavgroup:', label: 'ordfavgroup:'},
{value: 'search:', label: 'search:'},
{value: 'favgroup:', label: 'favgroup:'},
{value: '-favgroup:', label: '-favgroup:'},
{value: 'favcount:', label: 'favcount:'},
{value: 'id:', label: 'id:'},
{value: 'tagcount:', label: 'tagcount:'},
{value: 'gentags:', label: 'gentags:'},
{value: 'arttags:', label: 'arttags:'},
{value: 'chartags:', label: 'chartags:'},
{value: 'copytags:', label: 'copytags:'},
{value: 'metatags:', label: 'metatags:'},
{value: 'score:', label: 'score:'},
{value: 'upvote:', label: 'upvote:'},
{value: 'downvote:', label: 'downvote:'},
{value: 'disapproved:', label: 'disapproved:'},
{value: 'md5:', label: 'md5:'},
{value: 'width:', label: 'width:'},
{value: 'height:', label: 'height:'},
{value: 'ratio:', label: 'ratio:'},
{value: 'mpixels:', label: 'mpixels:'},
{value: 'filesize:', label: 'filesize:'},
{value: 'duration:', label: 'duration:'},
{value: 'is:', label: 'is:'},
{value: 'has:', label: 'has:'},
{value: 'pool:', label: 'pool:'},
{value: '-pool:', label: '-pool:'},
{value: 'ordpool:', label: 'ordpool:'},
{value: 'random:', label: 'random:'},
{value: 'limit:', label: 'limit:'},
{value: 'date:', label: 'date:'},
{value: 'commenter:', label: 'commenter:'},
{value: 'note:', label: 'note:'},
{value: 'noter:', label: 'noter:'},
{value: 'noteupdater:', label: 'noteupdater:'},
{value: 'status:', label: 'status:'},
{value: '-status:', label: '-status:'},
{value: 'rating:', label: 'rating:'},
{value: '-rating:', label: '-rating:'},
{value: 'source:', label: 'source:'},
{value: '-source:', label: '-source:'},
{value: 'pixiv:', label: 'pixiv:'},
{value: 'parent:', label: 'parent:'},
{value: 'child:', label: 'child:'},
{value: 'flagger:', label: 'flagger:'},
{value: 'appealer:', label: 'appealer:'},
{value: 'commentary:', label: 'commentary:'},
{value: 'commentaryupdater:', label: 'commentaryupdater:'},
].map(data => ({type: 'query', ...data}))
}
export interface AutocompleteResult extends AutocompleteBaseData {}
export class AutocompleteResult {
constructor(data: AutocompleteData) {
Object.assign(this, data);
}
isTag(): this is AutocompleteResult & (AutocompleteTagData | AutocompleteTagAliasData | AutocompleteTagAutocorrectData) {
return this.type === 'tag' || this.type === 'tag-autocorrect' || this.type === 'tag-alias' || this.type === 'tag-word';
}
isTagAutocorrect(): this is AutocompleteResult & AutocompleteTagAutocorrectData {
return this.type === 'tag-autocorrect';
}
isTagAntecedent(): this is Autocomplete & AutocompleteTagAutocorrectData {
//@ts-expect-error
return !!this['antecedent' as any]
}
isTagWord(): this is AutocompleteResult & AutocompleteTagWordData {
return this.type === 'tag-word'
}
isUser(): this is AutocompleteResult & AutocompleteUserData {
return this.type === 'user';
}
}
type AutocompleteData = AutocompleteBaseData & (AutocompleteUserData | AutocompleteTagData | AutocompleteTagAutocorrectData | AutocompleteTagAliasData | AutocompleteSearchQueryData);
interface AutocompleteBaseData {
type: 'user' | 'tag' | 'tag-autocorrect' | 'tag-alias' | 'tag-word' | 'query';
label: string;
value: string;
}
interface AutocompleteUserData {
type: 'user';
id: number;
level: Lowercase<keyof UserLevel>;
}
interface AutocompleteTagData {
type: 'tag';
category: TagCategory;
post_count: number;
}
interface AutocompleteTagAutocorrectData {
type: 'tag-autocorrect';
category: TagCategory;
post_count: number;
antecedent: string;
}
interface AutocompleteTagAliasData {
type: 'tag-alias';
category: TagCategory;
post_count: number;
antecedent: string;
}
interface AutocompleteTagWordData{
type: 'tag-word';
category: TagCategory;
post_count: number;
antecedent: string;
}
interface AutocompleteSearchQueryData {type: 'query', value: string, label: string}

View File

@ -1,76 +0,0 @@
import { $EventManager, type $EventMap } from "elexis";
import type { Post } from "./Post";
import type { Tag } from "./Tag";
import { ClientUser, type ClientUserData } from "./ClientUser";
import type { User } from "./User";
import type { Favorite } from "./Favorite";
export interface BooruOptions {
origin: string;
name: string;
}
export interface Booru extends BooruOptions {}
export class Booru {
static used: Booru;
static events = new $EventManager<BooruStaticEventMap>();
static name$ = $.state(this.name);
static manager = new Map<string, Booru>()
user?: ClientUser;
posts = new Map<id, Post>();
tags = new Map<id, Tag>();
users = new Map<id, User>();
favorites = new Map<id, Favorite>();
constructor(options: BooruOptions) {
Object.assign(this, options);
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;
const userdata = ClientUser.storageUserData;
if (userdata) booru.login(userdata.username, userdata.apiKey);
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') }
async fetch<T>(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET') {
const auth = this.user ? `${endpoint.includes('?') ? '&' : '?'}login=${this.user.name}&api_key=${this.user.apiKey}` : '';
const data = await fetch(`${this.origin}${endpoint}${auth}`, {
method: method,
}).then(res => res.json()) as any;
if (data.success === false) throw data.message;
return data as T;
}
async login(username: string, apiKey: string) {
const data = await this.fetch<ClientUserData>(`/profile.json?login=${username}&api_key=${apiKey}`);
this.user = new ClientUser(this, apiKey, data);
this.user.init();
Booru.events.fire('login', this.user);
return this.user;
}
logout() {
this.user = undefined;
ClientUser.storageUserData = null;
Booru.events.fire('logout');
return this
}
}
interface BooruStaticEventMap extends $EventMap {
set: [];
login: [user: ClientUser];
logout: [];
}
interface BooruEventMap extends $EventMap {
}

View File

@ -1,92 +0,0 @@
import { $EventManager } from "elexis";
import type { Booru } from "./Booru";
import { Favorite, type FavoriteData } from "./Favorite";
import { User, type UserData } from "./User";
import type { Post } from "./Post";
export interface ClientUser extends ClientUserData {}
export class ClientUser extends User {
apiKey: string;
favorite_count$ = $.state(0);
forum_post_count$ = $.state(0);
static events = new $EventManager<ClientUserEventMap>()
constructor(booru: Booru, apiKey: string, data: ClientUserData) {
super(booru, data, false);
this.apiKey = apiKey;
this.update$();
}
update$() {
super.update$();
this.forum_post_count$?.set(this.forum_post_count);
this.favorite_count$?.set(this.favorite_count);
}
async init() {
await this.fetchFavorites();
}
async fetchFavorites() {
const oldestId = Array.from(this.favorites.keys()).at(-1);
const list = await Favorite.fetchUserFavorites(this.booru, this, ``, 1000, oldestId ? `b${oldestId}` : 1);
ClientUser.events.fire('favoriteUpdate', this);
if (list.length >= 1000) this.fetchFavorites();
return list;
}
static get storageUserData() { const data = localStorage.getItem('user_data'); return data ? JSON.parse(data) as ClientUserStoreData : null }
static set storageUserData(data: ClientUserStoreData | null) { localStorage.setItem('user_data', JSON.stringify(data)) }
}
export interface ClientUserData extends UserData {
"last_logged_in_at": ISOString,
"last_forum_read_at": ISOString,
"comment_threshold": number,
"updated_at": ISOString,
"default_image_size": "large" | "original",
"favorite_tags": null | string,
"blacklisted_tags": string,
"time_zone": string,
"favorite_count": number,
"per_page": number,
"custom_style": string,
"theme": "auto" | "light" | "dark",
"receive_email_notifications": boolean,
"new_post_navigation_layout": boolean,
"enable_private_favorites": boolean,
"show_deleted_children": boolean,
"disable_categorized_saved_searches": boolean,
"disable_tagged_filenames": boolean,
"disable_mobile_gestures": boolean,
"enable_safe_mode": boolean,
"enable_desktop_mode": boolean,
"disable_post_tooltips": boolean,
"requires_verification": boolean,
"is_verified": boolean,
"show_deleted_posts": boolean,
"statement_timeout": number,
"favorite_group_limit": 10 | 100,
"tag_query_limit": 2 | 6,
"max_saved_searches": 250,
"wiki_page_version_count": number,
"artist_version_count": number,
"artist_commentary_version_count": number,
"pool_version_count": number | null,
"forum_post_count": number,
"comment_count": number,
"favorite_group_count": number,
"appeal_count": number,
"flag_count": number,
"positive_feedback_count": number,
"neutral_feedback_count": number,
"negative_feedback_count": number
}
export interface ClientUserStoreData {
username: string;
apiKey: string;
}
export interface ClientUserEventMap {
favoriteUpdate: [user: ClientUser]
}

View File

@ -1,63 +0,0 @@
import type { Booru } from "./Booru";
export interface ArtistCommentary extends ArtistCommentaryData {}
export class ArtistCommentary {
static manager = new Map<id, ArtistCommentary>();
constructor(data: ArtistCommentaryData) {
Object.assign(this, data);
}
static async fetch(booru: Booru, id: id) {
const data = await booru.fetch<ArtistCommentaryData>(`/artist_commentaries/${id}.json`);
const post = new this(data);
return post;
}
static async fetchMultiple(booru: Booru, search?: Partial<ArtistCommentarySearchParams>, limit = 200) {
let searchQuery = '';
if (search) {
for (const [key, val] of Object.entries(search)) {
if (val instanceof Array) searchQuery += `&search[${key}]=${val}`;
else if (val instanceof Object) {
for (const [ckey, cval] of Object.entries(val)) {
searchQuery += `&search[${key}${ckey}]=${cval}`
}
}
else searchQuery += `&search[${key}]=${val}`
}
}
const dataArray = await booru.fetch<ArtistCommentaryData[]>(`/artist_commentaries.json?limit=${limit}${searchQuery}`);
const list = dataArray.map(data => {
const instance = new this(data);
this.manager.set(instance.id, instance);
return instance;
});
return list;
}
}
export interface ArtistCommentaryData {
"id": id,
"post_id": id,
"original_title": string,
"original_description": string,
"translated_title": string,
"translated_description": string,
"created_at": ISOString,
"updated_at": ISOString
}
export interface ArtistCommentarySearchParams {
id: NumericSyntax<id>;
created_at: NumericSyntax<ISOString>;
updated_at: NumericSyntax<ISOString>;
original_title: TextSyntax<string>;
original_description: TextSyntax<string>;
translated_title: TextSyntax<string>;
translated_description: TextSyntax<string>;
post: PostSyntax;
text_matches: string;
original_present: boolean;
translated_present: boolean;
is_deleted: 'yes' | 'no';
}

View File

@ -1,32 +0,0 @@
import type { Booru } from "./Booru";
import type { ClientUser } from "./ClientUser";
import type { Post } from "./Post";
import type { User } from "./User";
export interface Favorite extends FavoriteData {}
export class Favorite {
booru: Booru;
constructor(booru: Booru, data: FavoriteData) {
Object.assign(this, data);
this.booru = booru;
}
static async fetchUserFavorites(booru: Booru, user: User, query: string, limit: number = 100, page: number | string) {
const dataArray = await booru.fetch<FavoriteData[]>(`/favorites.json?${query}&${`search[user_id]=${user.id}`}&limit=${limit}&page=${page}`);
return dataArray.map(data => {
user.favorites.add(data.post_id);
return data.post_id;
})
}
update(data: FavoriteData) {
Object.assign(this, data)
return this;
}
}
export interface FavoriteData {
id: id;
post_id: id;
user_id: id;
}

View File

@ -1,11 +0,0 @@
export class LocalSettings {
static get detailPanelEnabled() { return this.localdata?.detailPanelEnabled }
static set detailPanelEnabled(boolean: boolean | undefined) { this.localdata = {...this.localdata, detailPanelEnabled: boolean }}
static get localdata() { const data = localStorage.getItem('local_settings_data'); return data ? JSON.parse(data) as LocalSettingsStoreData : null }
static set localdata(data: LocalSettingsStoreData | null) { localStorage.setItem('local_settings_data', JSON.stringify(data)) }
}
export interface LocalSettingsStoreData {
detailPanelEnabled?: boolean;
}

View File

@ -1,316 +0,0 @@
import { $, $EventManager } from "elexis";
import { Booru } from "./Booru";
import { Tag } from "./Tag";
import { User } from "./User";
import { dateFrom, digitalUnit } from "./Util";
import { ClientUser } from "./ClientUser";
const LOADING_STRING = '...'
export interface PostOptions {}
export interface Post extends PostData {}
export class Post extends $EventManager<{update: []}> {
uploader$ = $.state(LOADING_STRING);
approver$ = $.state(LOADING_STRING);
created_date$ = $.state(LOADING_STRING);
favcount$ = $.state(0);
score$ = $.state(0);
file_size$ = $.state(LOADING_STRING);
file_ext$ = $.state(LOADING_STRING);
file_url$ = $.state<string | undefined>(LOADING_STRING);
source$ = $.state(LOADING_STRING);
dimension$ = $.state(LOADING_STRING);
booruUrl$ = $.state(LOADING_STRING);
createdDate = new Date(this.created_at);
ready?: Promise<this>;
webm_url$ = $.state(LOADING_STRING);
booru: Booru;
constructor(booru: Booru, id: id, data?: PostData) {
super();
this.booru = booru;
this.id = id;
booru.posts.set(this.id, this);
if (data) this.update(data);
else this.ready = this.fetch();
}
static get(booru: Booru, id: id) {
return booru.posts.get(id) ?? new Post(booru, id);
}
async fetch() {
const data = await this.booru.fetch<PostData>(`/posts/${this.id}.json`);
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, page?: string | number) {
let tagsQuery = '';
if (tags) {
if (typeof tags === 'string') tagsQuery = tags;
else {
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 dataArray = await booru.fetch<PostData[]>(`/posts.json?limit=${limit}&tags=${tagsQuery}${page ? `&page=${page}` : ''}&_method=get`);
if (dataArray instanceof Array === false) return [];
const tagnameSet = new Set<string>();
const list = dataArray.map(data => {
const instance = booru.posts.get(data.id)?.update(data) ?? new this(booru, data.id, data);
booru.posts.set(instance.id, instance);
instance.tag_string.split(' ').forEach(tag_name => tagnameSet.add(tag_name))
return instance;
});
if (!list.length) return list;
const userIds = [...new Set(dataArray.map(data => [data.approver_id, data.uploader_id].detype(null)).flat())];
// Tag.fetchMultiple(booru, {name: tagnameSet.array.toString().replaceAll(',', ' ')});
User.fetchMultiple(booru, {id: userIds}).then(() => list.forEach(post => post.update$()));
return list;
}
update$() {
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.favcount$.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.file_url$.set(this.file_url);
this.source$.set(this.source);
this.dimension$.set(`${this.image_width}x${this.image_height}`);
this.booruUrl$.set(`${this.booruUrl}`);
if (this.isUgoria) this.webm_url$.set(this.large_file_url);
this.createdDate = new Date(this.created_at);
this.fire('update');
}
update(data: PostData) {
Object.assign(this, data);
this.update$();
return this;
}
async fetchTags() {
await this.ready;
const uncached_tags = this.tag_string.split(' ').filter(tag_name => !Tag.get(this.booru, tag_name));
if (!uncached_tags.length) return this;
await Tag.fetchMultiple(this.booru, {name: {_space: uncached_tags.toString().replaceAll(',', ' ')}});
return this;
}
async createFavorite() {
if (!this.booru.user) return;
const data = await this.booru.fetch<Post>(`/favorites.json?post_id=${this.id}`, 'POST')
this.update(data);
this.booru.user.favorites.add(data.id);
ClientUser.events.fire('favoriteUpdate', this.booru.user);
return data.id;
}
async deleteFavorite() {
if (!this.booru.user) return;
const data = await fetch(`/api/favorites/${this.id}?login=${this.booru.user.name}&api_key=${this.booru.user.apiKey}&origin=${this.booru.origin}`, {method: 'DELETE'}).then(res => res.json()) as boolean;
if (data === false) return;
this.fav_count--;
this.favcount$.set(this.fav_count);
this.booru.user.favorites.delete(this.id);
ClientUser.events.fire('favoriteUpdate', this.booru.user);
return;
}
get pathname() { return `/posts/${this.id}` }
get uploader() { return this.booru.users.get(this.uploader_id); }
get approver() { if (this.approver_id) return this.booru.users.get(this.approver_id); else return null }
get isVideo() { return this.file_ext === 'mp4' || this.file_ext === 'webm' || this.file_ext === 'zip' }
get isGif() { return this.file_ext === 'gif' }
get isUgoria() { return this.file_ext === 'zip' }
get hasSound() { return this.tag_string_meta.includes('sound') }
get tags() {
const tag_list = this.tag_string.split(' ');
return [...this.booru.tags.values()].filter(tag => tag_list.includes(tag.name))
}
get previewURL() { return this.media_asset?.variants?.find(variant => variant.file_ext === 'webp')?.url ?? this.large_file_url }
get booruUrl() { return `${this.booru.origin}/posts/${this.id}` }
get url() { return `https://danbooru.defaultkavy.com/posts/${this.id}` }
get isFileSource() { return this.source.startsWith('file://') }
get isLargeFile() { return this.file_size > 5_000_000 } // || this.image_height > innerHeight || this.image_width > innerWidth
}
export interface PostData extends PostOptions {
"id": id,
"created_at": ISOString,
"uploader_id": id,
"score": number,
"source": string,
"md5": string,
"last_comment_bumped_at": timestamp | null,
"rating": 'g' | 's' | 'q' | 'e' | null,
"image_width": number,
"image_height": number,
"tag_string": string,
"fav_count": number,
"file_ext": FileType,
"last_noted_at": null | timestamp,
"parent_id": null | id,
"has_children": boolean,
"approver_id": null | id,
"tag_count_general": number,
"tag_count_artist": number,
"tag_count_character": number,
"tag_count_copyright": number,
"file_size": number,
"up_score": number,
"down_score": number,
"is_pending": boolean,
"is_flagged": boolean,
"is_deleted": boolean,
"tag_count": number,
"updated_at": ISOString,
"is_banned": boolean,
"pixiv_id": null | id,
"last_commented_at": null | timestamp,
"has_active_children": boolean,
"bit_flags": number,
"tag_count_meta": number,
"has_large": boolean,
"has_visible_children": boolean,
"media_asset": MediaAssetData,
"tag_string_general": string,
"tag_string_character": string,
"tag_string_copyright": string,
"tag_string_artist": string,
"tag_string_meta": string,
"file_url"?: string,
"large_file_url": string,
"preview_file_url": string
}
export interface MediaAssetData {
"id": id,
"created_at": ISOString,
"updated_at": ISOString,
"md5": string,
"file_ext": FileType,
"file_size": number,
"image_width": number,
"image_height": number,
"duration": number,
"status": "active",
"file_key": string,
"is_public": boolean,
"pixel_hash": string,
"variants": MediaAssetVariant[];
}
export interface MediaAssetVariant {
"type": "original" | "720x720" | "360x360" | "180x180",
"url": string,
"width": number,
"height": number,
"file_ext": FileType
}
export interface MetaTags {
/** Search tags */
'tags': string;
/** Search for posts uploaded by the user */
'user': username;
/** Search for posts not uploaded by the user */
'-user': username;
/** Search for posts favorited by the user */
'fav': username;
/** Search for posts not favorited by the user */
'-fav': username;
/** Search for posts favorited by the user ordered in the order they were favorited in, instead of by the date they were uploaded. */
'ordfav': username;
/** Search for posts with at least favorites. */
'favcount': NumericBasicSyntax<number>;
/** Order search results. */
'order': 'favcount' | 'comm' | 'comment' | 'comment_bumped' | 'note' | 'artcomm' | 'id' | 'id_asc' | 'id_desc' | 'custom' | 'score' | 'score_asc' | 'rank' | 'downvotes' | 'upvotes' | 'changes' | 'md5' | 'landscape' | 'protrait' | 'mpixels' | 'mpixels_asc' | 'filesize';
/** Search for posts that were approved by the user. */
'approver': UserSyntax;
/** Search for posts that were not approved by the user. */
'-approver': username;
/** Search for posts that were commented on by the user. */
'commenter': UserSyntax;
/** Search for posts that were commented on by the user. */
'comm': UserSyntax;
/** Search for posts with comments saying string */
'comment': string;
/** Search for posts that have had notes created by the user. */
'noter': UserSyntax;
/** Search for posts that have had notes updated by the user. */
'notepdater': username;
/** Search for posts with notes saying string. */
'note': string;
/** Search for posts by status. */
'status': PostStatus;
'-status': PostStatus;
/** Search for posts that have ever been flagged by user (mod only; normal users may only search for flags created by themselves). */
'flagger': UserSyntax;
'appeals': UserSyntax;
'commentary': boolean | 'translated' | 'untranslated' | string
'commentaryupdater': username;
'favgroup': string;
'-favgroup': string;
'ordfavgroup': string;
/** Search for posts in the saved search named string. */
'search': string;
'id': NumericBasicSyntax<id>;
'date': NumericBasicSyntax<DateType>;
'age': NumericBasicSyntax<PeriodType>;
'rating': Rating | Rating[];
'-rating': Rating | Rating[];
'source': Source;
'pixiv': NumericBasicSyntax<id> | 'any';
'parent': id | `any`;
'-parent': id;
'child': 'none' | 'any';
'tagcount': NumericBasicSyntax<number>;
'gentags': NumericBasicSyntax<number>;
'arttags': NumericBasicSyntax<number>;
'chartags': NumericBasicSyntax<number>;
'copytags': NumericBasicSyntax<number>;
'metatags': NumericBasicSyntax<number>;
'score': NumericBasicSyntax<number>;
'downvotes': NumericBasicSyntax<number>;
'upvotes': NumericBasicSyntax<number>;
'disapproved': 'disinterest' | 'breaks_rules' | 'poor_quality' | username;
'md5': string;
'width': NumericBasicSyntax<number>;
'height': NumericBasicSyntax<number>;
'ratio': NumericBasicSyntax<Ratio>;
'mpixels': NumericBasicSyntax<number>;
'filesize': FileSize;
'filetype': FileType;
'duration': seconds;
'is': 'parent' | 'child' |
'general' | 'sensitive' | 'questionable' | 'explicit' | 'sfw' | 'nsfw' |
'active' | 'deleted' | 'ending' | 'unmoderated' | 'modqueue' | 'banned' | 'appealed' | 'flagged' |
'jpg' | 'png' | 'gif' | 'mp4' | 'webm' | 'swf' | 'zip'
'has': 'children' | 'parent' | 'source' | 'appeals' | 'flags' | 'replacements' | 'comments' | 'commentary' | 'notes' | 'pools';
'pool': poolname | id | 'any' | 'series' | 'collection';
'-pool': poolname | id | 'any' | 'series' | 'collection';
'ordpool': poolname;
'upvote': username;
'downvote': username;
'random': number;
'limit': number;
// 'general': string;
// 'gen': string;
// 'artist': string;
// 'art': string;
// 'character': string;
// 'char': string;
// 'copyright': string;
}
export type PostStatus = 'flagged' | 'deleted' | 'any' | 'all' | 'pending' | 'unmoderated' | 'banned';

View File

@ -1,122 +0,0 @@
import { $EventManager } from "elexis";
import { Post } from "./Post";
import { Booru } from "./Booru";
export class PostManager {
static managers = new Map<string | undefined, PostManager>();
orderMap = new Map<id, Post>();
cache = new Set<Post>();
limit = 100;
tags?: string;
finished = false;
events = new $EventManager<PostManagerEventMap>();
constructor(tags?: string) {
this.tags = tags;
PostManager.managers.set(this.tags, this);
Booru.events.on('set', () => {
this.clear();
if (this.finished) {
this.finished = false;
}
})
}
static get(tags: string | undefined) {
const manager = this.managers.get(tags) ?? new PostManager(tags);
this.managers.set(manager.tags, manager);
return manager;
}
clear() {
this.orderMap.clear();
this.cache.clear();
}
addPosts(posts: OrArray<Post>) {
posts = $.orArrayResolve(posts);
for (const post of posts) {
if (!post.file_url) continue;
if (this.cache.has(post)) continue;
this.cache.add(post);
}
return this;
}
async fetchPosts(direction: 'newer' | 'older'): Promise<Post[]> {
const tags = this.tags ? decodeURIComponent(this.tags).split('+') : undefined;
const generalTags: string[] = [];
const orderTags: string[] = [];
let limit: number = this.limit;
let posts: Post[] = [];
if (tags) for (const tag of tags) {
if (tag.startsWith('ordfav:')) orderTags.push(tag);
else if (tag.startsWith('order:')) orderTags.push(tag);
else if (tag.startsWith('limit:')) limit = Number(tag.split(':')[1]);
else generalTags.push(tag);
}
if (orderTags.length) {
if (orderTags.length > 1) {
this.events.fire('post_error', `Error: These query can't be used together [${orderTags}].`)
return [];
}
const orderTag = orderTags[0];
if (orderTag.startsWith('ordfav:')) {
const username = orderTag.split(':')[1];
const match_tags = generalTags.length ? `&search[post_tags_match]=${generalTags.toString().replaceAll(',', '+')}` : '';
const beforeAfter = this.orderKeyList.length ? direction === 'newer' ? `&search[id]=>${this.orderKeyList.at(0)}` : `&search[id]=<${this.orderKeyList.at(-1)}` : undefined;
const favoritesDataList = await Booru.used.fetch<FavoritesData[]>(`/favorites.json?search[user_name]=${username}${beforeAfter ?? ''}${match_tags}&limit=${limit}`);
posts = await Post.fetchMultiple(Booru.used, {tags: `id:${favoritesDataList.map(data => data.post_id).toString()}`});
const newPostOrderMap = new Map();
for (const fav of favoritesDataList) {
const post = posts.find(post => post.id === fav.post_id);
if (!post) continue;
if (!post.file_url) continue;
newPostOrderMap.set(fav.id, post);
}
this.orderMap = new Map(direction === 'newer' ? [...newPostOrderMap, ...this.orderMap] : [...this.orderMap, ...newPostOrderMap]);
this.events.fire('post_fetch', {manager: this, postList: posts});
return posts;
}
if (orderTag.startsWith('order:')) {
const page = this.orderKeyList.length ? direction === 'newer' ? 1 : (this.orderMap.size / limit) + 1 : undefined;
posts = await Post.fetchMultiple(Booru.used, {tags: this.tags}, limit, page);
const newPostOrderMap = new Map(posts.filter(post => post.file_url).map(post => [post.id, post]));
newPostOrderMap.forEach((post, id) => { if (this.orderMap.has(id)) newPostOrderMap.delete(id) });
this.orderMap = new Map(direction === 'newer' ? [...newPostOrderMap, ...this.orderMap] : [...this.orderMap, ...newPostOrderMap])
this.events.fire('post_fetch', {manager: this, postList: posts});
}
} else {
const beforeAfter = this.orderKeyList.length ? direction === 'newer' ? `a${this.orderKeyList.at(0)}` : `b${this.orderKeyList.at(-1)}` : undefined;
posts = await Post.fetchMultiple(Booru.used, {tags: this.tags}, limit, beforeAfter);
const newPostOrderMap = new Map(posts.filter(post => post.file_url).map(post => [post.id, post]));
this.orderMap = new Map(direction === 'newer' ? [...newPostOrderMap, ...this.orderMap] : [...this.orderMap, ...newPostOrderMap])
}
if (!posts.length) {
this.finished = true;
if (!this.cache.size) this.events.fire('noPost');
else this.events.fire('endPost')
}
this.events.fire('post_fetch', {manager: this, postList: posts});
this.addPosts(posts);
return posts
}
get orderKeyList() { return [...this.orderMap.keys()]}
}
interface PostManagerEventMap {
startLoad: [];
noPost: [];
endPost: [];
post_error: [message: string];
post_fetch: [{manager: PostManager, postList: Post[]}];
}
interface FavoritesData {
id: id;
post_id: id;
user_id: id;
}

View File

@ -1,93 +0,0 @@
import { Booru } from "./Booru";
export interface TagOptions {}
export interface Tag extends TagData {}
export class Tag {
post_count$ = $.state(0);
name$ = $.state('');
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 booru.fetch<TagData>(`/tags/${id}.json`);
const instance = booru.tags.get(data.id)?.update(data) ?? new this(booru, data);
booru.tags.set(instance.id, instance);
return instance;
}
static async fetchMultiple(booru: Booru, search?: Partial<TagSearchParams>, limit = 1000) {
let searchQuery = '';
if (search) {
for (const [key, val] of Object.entries(search)) {
if (val instanceof Array) searchQuery += `&search[${key}]=${val}`;
else if (val instanceof Object) {
for (const [ckey, cval] of Object.entries(val)) {
searchQuery += `&search[${key}${ckey}]=${cval}`
}
}
else searchQuery += `&search[${key}]=${val}`
}
}
const dataArray = await booru.fetch<TagData[]>(`/tags.json?limit=${limit}${searchQuery}`);
const list = dataArray.map(data => {
const instance = booru.tags.get(data.id)?.update(data) ?? new this(booru, data);
booru.tags.set(instance.id, instance);
return instance;
});
return list;
}
static get(booru: Booru, name: string) {
return [...booru.tags.values()].find(tag => tag.name === name);
}
update(data: TagData) {
Object.assign(this, data);
this.$update();
return this;
}
$update() {
this.post_count$.set(this.post_count);
this.name$.set(this.name);
}
}
export interface TagData {
"id": id,
"name": string,
"post_count": number,
"category": number,
"created_at": ISOString,
"updated_at": ISOString,
"is_deprecated": boolean,
"words": string[];
}
export interface TagSearchParams {
id: NumericSyntax<id>;
category: NumericSyntax<TagCategory>;
post_count: NumericSyntax<number>;
created_at: NumericSyntax<ISOString>;
updated_at: NumericSyntax<ISOString>;
name: TextSyntax<string>;
is_deprecated: boolean;
fuzzy_name_matches: string;
name_matches: string;
name_normalize: string;
name_or_alias_matches: string;
hide_empty: boolean;
order: 'name' | 'date' | 'count' | 'similarity'
}
export enum TagCategory {
General = 0,
Artist = 1,
Copyright = 3,
Character = 4,
Meta = 5
}

View File

@ -1,112 +0,0 @@
import type { Booru } from "./Booru";
export class UserOptions {}
export interface User extends UserOptions, UserData {}
export class User {
name$ = $.state('...');
post_upload_count$ = $.state(0);
level$ = $.state(10);
level_string$ = $.state('...');
booru: Booru;
favorites = new Set<id>();
constructor(booru: Booru, data: UserData, update$: boolean = true) {
this.booru = booru;
Object.assign(this, data);
if (update$) this.update$();
}
static async fetch(booru: Booru, id: username): Promise<User>;
static async fetch(booru: Booru, id: id): Promise<User>;
static async fetch(booru: Booru, id: id | username) {
let data: UserData;
if (typeof id === 'string') {
const res = (await booru.fetch<UserData[]>(`/users.json?search[name]=${id}`)).at(0);
if (!res) throw 'User Not Found';
return data = res;
} else data = await booru.fetch<UserData>(`/users/${id}.json`);
const instance = booru.users.get(data.id)?.update(data) ?? new this(booru, data);
booru.users.set(instance.id, instance);
return instance;
}
static async fetchMultiple(booru: Booru, search?: Partial<UserSearchParam>, limit = 200) {
let searchQuery = '';
if (search) {
for (const [key, val] of Object.entries(search)) {
if (val instanceof Array) searchQuery += `&search[${key}]=${val}`;
else if (val instanceof Object) {
for (const [ckey, cval] of Object.entries(val)) {
searchQuery += `&search[${key}${ckey}]=${cval}`
}
}
else searchQuery += `&search[${key}]=${val}`
}
}
const dataArray = await booru.fetch<UserData[]>(`/users.json?limit=${limit}${searchQuery}`);
const list = dataArray.map(data => {
const instance = new this(booru, data);
booru.users.set(instance.id, instance);
return instance;
});
return list;
}
update(data: UserData) {
Object.assign(this, data);
this.update$();
return this;
}
update$() {
this.name$.set(this.name);
this.post_upload_count$.set(this.post_upload_count);
this.level$.set(this.level);
this.level_string$.set(this.level_string);
}
get booruURL() { return `${this.booru.origin}/users/${this.id}`}
get url() { return `/users/${this.id}`}
}
export enum UserLevel {
Restricted = 10,
Member = 20,
Gold = 30,
Platinum = 31,
Builder = 32,
Contributor = 35,
Approver = 37,
Moderater = 40,
Admin = 50
}
export interface UserData {
"id": id,
"name": username,
"level": UserLevel,
"inviter_id": id,
"created_at": ISOString,
"post_update_count": number,
"note_update_count": number,
"post_upload_count": number,
"is_deleted": boolean,
"level_string": keyof UserLevel,
"is_banned": boolean,
}
export interface UserSearchParam {
id: NumericSyntax<id>;
level: NumericSyntax<UserLevel>;
post_upload_count: NumericSyntax<number>;
post_update_count: NumericSyntax<number>;
note_update_count: NumericSyntax<number>;
favorite_count: NumericSyntax<number>;
created_at: NumericSyntax<ISOString>;
updated_at: NumericSyntax<ISOString>;
name: TextSyntax<username>;
inviter: UserSyntax;
name_matches: string;
min_level: UserLevel;
max_level: UserLevel;
current_user_first: boolean;
order: 'name' | 'post_upload_count' | 'post_update_count' | 'note_update_count';
}

View File

@ -1,55 +0,0 @@
const SECOND_MS = 1000;
const MINUTE_MS = SECOND_MS * 60;
const HOUR_MS = MINUTE_MS * 60;
const DAY_MS = HOUR_MS * 24;
const WEEK_MS = DAY_MS * 7;
const INTL_RELATIVE_TIME = new Intl.RelativeTimeFormat('en', {style: 'long'})
export function time(timestamp: number) {
timestamp = Math.floor(timestamp)
const seconds = timestamp / SECOND_MS;
const minutes = timestamp / MINUTE_MS;
const hours = timestamp / HOUR_MS;
const days = timestamp / DAY_MS;
const mil = timestamp % 1000;
const s = Math.floor(timestamp % 60_000 / 1000);
const min = Math.floor(timestamp % 3600_000 / 60_000);
const h = Math.floor(timestamp % (3600_000 * 24) / 3600_000)
const ss = s.toString().padStart(2, '0');
const mm = min.toString().padStart(2, '0');
const hh = h.toString().padStart(2, '0');
return {seconds, minutes, hours, days, mil, s, min, h, ss, mm, hh}
}
export function dateFrom(timestamp: number, from = Date.now()) {
const diff = timestamp - from;
const diff_abs = Math.abs(diff);
if (diff_abs < MINUTE_MS) return INTL_RELATIVE_TIME.format(Math.round(diff / SECOND_MS), 'second');
if (diff_abs < HOUR_MS) return INTL_RELATIVE_TIME.format(Math.round(diff / MINUTE_MS), 'minute');
if (diff_abs < DAY_MS) return INTL_RELATIVE_TIME.format(Math.round(diff / HOUR_MS), 'hour');
if (diff_abs < WEEK_MS) return INTL_RELATIVE_TIME.format(Math.round(diff / DAY_MS), 'day');
const date = new Date(timestamp);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${(date.getDate().toString().padStart(2, '0'))}`;
}
export function digitalUnit(bytes: number) {
if (bytes < 1000) return `${bytes}B`
const kb = bytes / 1000;
if (kb < 1000) return `${kb.toFixed(2)}kB`;
const mb = bytes / (1000 ** 2);
if (mb < 1000) return `${mb.toFixed(2)}MB`;
const gb = bytes / (1000 ** 3);
if (gb < 1000) return `${gb.toFixed(2)}GB`;
const tb = bytes / (1000 ** 4);
if (tb < 1000) return `${tb.toFixed(2)}TB`;
const pb = bytes / (1000 ** 5);
if (pb < 1000) return `${pb.toFixed(2)}PB`;
const eb = bytes / (1000 * 6);
return `${eb.toFixed(2)}EB`;
}
const NUMBER_FORMAT = new Intl.NumberFormat('en', {notation: 'compact'})
export function numberFormat(number: number) {
return NUMBER_FORMAT.format(number)
}

View File

@ -1,28 +0,0 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist"]
}

View File

@ -1,17 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3030',
changeOrigin: true
},
},
watch: {
usePolling: true
}
},
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
}
})