This commit is contained in:
defaultkavy 2024-05-17 03:30:49 +08:00
commit 4aff683df3
24 changed files with 1171 additions and 0 deletions

3
.gitignore vendored Normal file
View File

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

6
README.md Normal file
View File

@ -0,0 +1,6 @@
# Danbooru Viewer
A modern style viewer for [Danbooru](https://danbooru.donmai.us).
## Usage
- Click this [link](https://danbooru.defaultkavy.com).
- Replace `danbooru.donmai.us` to `danbooru.defaultkavy.com` without changing pathname and url parameters, will directly open the same page on Danbooru Viewer.

1
dist/assets/index-BmY6B-Pf.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/assets/index-Dna-ZQnZ.css vendored Normal file
View File

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

13
dist/index.html vendored Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Node Test</title>
<script type="module" crossorigin src="/assets/index-BmY6B-Pf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dna-ZQnZ.css">
</head>
<body>
</body>
</html>

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Danbooru Viewer v0.1</title>
<link rel="stylesheet" href="/index.scss">
</head>
<body>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

34
index.scss Normal file
View File

@ -0,0 +1,34 @@
@import '/src/component/PostGrid/$PostGrid';
@import '/src/component/PostTile/$PostTile';
@import '/src/route/posts/$post';
body {
background-color: #1e1e2c;
color: #d1d1ee;
margin: 0;
font-family: Microsoft Yahei;
font-size: 14px;
&::-webkit-scrollbar {
background-color: #000000;
width: 8px;
}
&::-webkit-scrollbar-thumb {
background-color: #aeaeec;
border-radius: 2px;
}
}
app {
display: block;
view {
display: block;
page {
min-height: 100%;
padding: 1rem;
display: block;
}
}
}

18
package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "danbooru-viewer",
"module": "index.ts",
"type": "module",
"version": "0.2.0",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"@elexis/layout": "../elexis-ext/layout",
"@elexis/router": "../elexis-ext/router",
"elexis": "../elexis",
"sass": "^1.77.1"
}
}

View File

@ -0,0 +1,9 @@
import { $Layout } from "@elexis/layout";
export class $PostGrid extends $Layout {
constructor() {
super();
this.addStaticClass('post-grid')
this.type('waterfall').column(5).maxHeight(300).gap(10);
}
}

View File

@ -0,0 +1,2 @@
layout.post-grid {
}

View File

@ -0,0 +1,51 @@
import { $Container, $State, $Video } from "elexis";
import type { Post } from "../../structure/Post";
import { time } from "../../structure/Util";
export class $PostTile extends $Container {
post: Post;
$video: $Video | null;
duration$ = $.state(``);
constructor(post: Post) {
super('post');
this.post = post;
this.$video = this.post.isVideo ? $('video').width(this.post.image_width).height(this.post.image_height).disablePictureInPicture(true).loop(true).muted(true).hide(true) : null;
this.attribute('filetype', this.post.file_ext);
this.durationUpdate();
this.build();
}
build() {
let timer: Timer
this.$video?.on('playing', (e, $video) => {
timer = setInterval(() => {
this.durationUpdate();
}, 100)
})
this.$video?.on('pause', () => {
clearInterval(timer);
this.durationUpdate();
})
this.content([
this.post.isVideo ? $('span').class('duration').content(this.duration$) : null,
$('a').href(this.post.pathname).content($a => [
this.$video,
$('img').width(this.post.image_width).height(this.post.image_height).src(this.post.preview_file_url).loading('lazy')
.once('load', (e, $img) => {
if (!this.post.isVideo) $img.src(this.post.large_file_url)
})
])
.on('mouseenter', () => {
if (!this.$video?.isPlaying) this.$video?.src(this.post.file_url).hide(false).play().catch(err => undefined)
})
.on('mouseleave', () => {
this.$video?.pause().currentTime(0).hide(true);
})
])
}
durationUpdate() {
if (!this.$video) return;
const t = time(this.post.media_asset.duration * 1000 - this.$video.currentTime() * 1000)
this.duration$.set(`${t.hh}:${t.mm}:${t.ss}`)
}
}

View File

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

51
src/index.d.ts vendored Normal file
View File

@ -0,0 +1,51 @@
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'
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};

27
src/main.ts Normal file
View File

@ -0,0 +1,27 @@
import 'elexis';
import '@elexis/layout';
import '@elexis/router';
import { Booru } from './structure/Booru';
import { Router } from '@elexis/router';
import { home_route } from './route/home/$home';
import { posts_route } from './route/posts/$post';
export const booru = new Booru({
api: 'https://danbooru.donmai.us',
name: 'Testbooru'
})
const router = new Router('/');
$.anchorPreventDefault = true;
$.anchorHandler = ($a) => { $.open($a.href())}
$(document.body).content([
$('app').content([
router.$view
])
])
router.addRoute([
home_route,
posts_route
]).listen();

46
src/route/home/$home.ts Normal file
View File

@ -0,0 +1,46 @@
import { Route } from "@elexis/router";
import { Post } from "../../structure/Post";
import { booru } from "../../main";
import { $PostGrid } from "../../component/PostGrid/$PostGrid";
import { $PostTile } from "../../component/PostTile/$PostTile";
export const home_route = new Route((path) => {
if (path === '/posts' || path === '/') return '/';
}, ({record}) => {
const $page = $('page').id('root');
async function load(tags: string) {
const posts = await Post.fetchMultiple(booru, tags.length ? {tags: tags} : undefined, 100)
const $grid = new $PostGrid().content([
posts.filter(post => post.file_url).map(post => new $PostTile(post))
]).on('resize', () => { resizeCheck() });
resizeCheck();
return $grid
function resizeCheck() {
if (innerWidth < 350) $grid.column(1);
else if (innerWidth < 700) $grid.column(2);
else {
const col = Math.round(innerWidth / 300)
$grid.column(col);
}
}
}
const gridManager = new Map<string, $PostGrid>();
record.on('open', async () => {
const tags = new URL(location.href).searchParams.get('tags') ?? '';
const $cacheGrid = gridManager.get(tags);
if ($cacheGrid) {
$page.content($cacheGrid);
$cacheGrid.render()
return;
} else {
$page.clear();
}
const $grid = await load(tags);
$page.content($grid);
$grid.render();
gridManager.set(tags, $grid);
})
return $page;
})

136
src/route/posts/$post.ts Normal file
View File

@ -0,0 +1,136 @@
import { Route } from "@elexis/router";
import { Post } from "../../structure/Post";
import { booru } from "../../main";
import { $Container, type $ContainerContentType } from "elexis";
import { digitalUnit } from "../../structure/Util";
import { Tag, TagCategory } from "../../structure/Tag";
import { ArtistCommentary } from "../../structure/Commentary";
export const posts_route = new Route('/posts/:id', ({params}) => {
if (!Number(params.id)) return '404';
const ele = {
$viewer: $('div').class('viewer'),
$tags: $('div').class('post-tags'),
$commentary: $('section').class('commentary')
}
const value = {
uploader$: $.state<string|number>('loading...'),
approver$: $.state<string|number>('loading...'),
date$: $.state('loading...'),
size$: $.state('loading...'),
dimension$: $.state(`loading...`),
favorites$: $.state(0),
score$: $.state(0),
ext$: $.state(`loading...`),
}
async function load() {
const post = Post.manager.get(+params.id) ?? await Post.fetch(booru, +params.id);
ele.$viewer.content([
post.isVideo
? $('video').src(post.file_url).controls(true)
: $('img').src(post.large_file_url).once('load', (e, $img) => { $img.src(post.file_url)})
])
value.uploader$.set(post.uploader$);
value.approver$.set(post.approver$);
value.date$.set(post.created_date$);
value.size$.set(digitalUnit(post.file_size));
value.dimension$.set(`${post.image_width}x${post.image_height}`)
value.favorites$.set(post.favorites$)
value.score$.set(post.score$)
value.ext$.set(post.file_ext.toUpperCase())
loadTags();
async function loadTags() {
const tags = await Tag.fetchMultiple(booru, {name: {_space: post.tag_string}});
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),
]
ele.$tags.content([
tag_category('Artist', artist_tags),
tag_category('Character', char_tags),
tag_category('Copyright', copy_tags),
tag_category('Meta', meta_tags),
tag_category('General', gen_tags),
])
function tag_category(category: string, tags: Tag[]) {
return tags.length ? [
$('h3').content(category),
$('section').content([
tags.map(tag => $('div').class('tag').content([
$('a').class('tag-name').content(tag.name).href(`/posts?tags=${tag.name}`),
$('span').class('tag-post-count').content(`${tag.post_count}`)
]))
])
] : null
}
}
loadCommentary();
async function loadCommentary() {
const commentary = (await ArtistCommentary.fetchMultiple(booru, {post: {_id: post.id}})).at(0);
if (!commentary) return ele.$commentary.content('No commentary');
ele.$commentary.content([
commentary.original_title ? $('h3').content(commentary.original_title) : null,
$('pre').content(commentary.original_description)
])
}
}
load();
return $('page').id('post').content([
$('div').class('main').content([
ele.$viewer,
$('h3').content(`Artist's Commentary`),
ele.$commentary.content('loading...')
]),
$('div').class('sidebar').content([
$('section').class('post-info').content([
new $Property('id').name('Post').value(`#${params.id}`),
new $Property('uploader').name('Uploader').value(value.uploader$),
new $Property('approver').name('Approver').value(value.approver$),
new $Property('date').name('Date').value(value.date$),
new $Property('size').name('Size').value([value.size$, value.dimension$]),
new $Property('file').name('File Type').value(value.ext$),
$('div').class('inline').content([
new $Property('favorites').name('Favorites').value(value.favorites$),
new $Property('score').name('Score').value(value.score$)
]),
$('a').content('Copy link').href(`${booru.api}${location.pathname}`).on('click', (e, $a) => {
navigator.clipboard.writeText($a.href());
$a.content('Copied!');
setTimeout(() => {
$a.content('Copy link')
}, 2000);
})
]),
ele.$tags.content('loading...')
])
])
})
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);
this.content([
this.$name,
this.$values
])
}
name(content: $ContainerContentType) {
this.$name.content(content);
return this;
}
value(content: OrMatrix<$ContainerContentType>) {
const list = $.orArrayResolve(content);
this.$values.content(list.map($item => $('span').staticClass('property-value').content($item)));
return this;
}
}

141
src/route/posts/_$post.scss Normal file
View File

@ -0,0 +1,141 @@
#post {
section {
background-color: #2f2f45;
border-radius: 20px;
padding: 20px;
}
div.main {
// overflow: scroll;
// overflow-x: hidden;
// height: calc(100vh - 2rem);
width: calc(100vw - 300px - 4rem);
padding-right: 10px;
display: flex;
flex-direction: column;
@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;
}
div.viewer {
height: calc(100vh - 2rem);
display: flex;
justify-content: center;
align-items: center;
background-color: #000000;
border-radius: 20px;
overflow: hidden;
img {
max-width: 100%;
max-height: 100%;
}
video {
max-width: 100%;
max-height: 100%;
}
}
}
div.sidebar {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
width: 300px;
overflow: scroll;
overflow-x: hidden;
height: calc(100vh - 2rem);
border-radius: 20px;
@media (max-width: 800px) {
position: relative;
width: 100%;
overflow: visible;
height: 100%;
padding-inline: 1rem;
padding-bottom: 1rem;
}
&::-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: 20px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
div.property {
display: flex;
gap: 0.6rem;
align-items: center;
div.property-values {
display: flex;
gap: 0.4rem;
span.property-value {
background-color: #525278;
color: #aeaeec;
padding: 2px 4px;
border-radius: 4px;
}
}
}
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;
color: #d1d1ee;
text-decoration: none;
}
span.tag-post-count {
background-color: #525278;
color: #aeaeec;
padding: 0px 4px;
border-radius: 4px;
font-size: 12px;
margin-left: 0.4rem;
}
}
}
}
}

11
src/structure/Booru.ts Normal file
View File

@ -0,0 +1,11 @@
export interface BooruOptions {
api: string;
name: string;
}
export interface Booru extends BooruOptions {}
export class Booru {
constructor(options: BooruOptions) {
Object.assign(this, options);
if (this.api.endsWith('/')) this.api = this.api.slice(0, -1)
}
}

View File

@ -0,0 +1,64 @@
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 req = await fetch(`${booru.api}/artist_commentaries/${id}.json`);
const post = new this(await req.json());
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 req = await fetch(`${booru.api}/artist_commentaries.json?limit=${limit}${searchQuery}`);
const dataArray: ArtistCommentaryData[] = await req.json();
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';
}

241
src/structure/Post.ts Normal file
View File

@ -0,0 +1,241 @@
import { $ } from "elexis";
import type { Booru } from "./Booru";
import { Tag } from "./Tag";
import { User } from "./User";
import { dateFrom } from "./Util";
export interface PostOptions {}
export interface Post extends PostData {}
export class Post {
static manager = new Map<id, Post>();
uploader$ = $.state(this.uploader?.name ?? this.uploader_id);
approver$ = $.state(this.approver?.name ?? this.approver_id ?? 'None');
created_date$ = $.state(``);
favorites$ = $.state(this.fav_count);
score$ = $.state(this.score);
constructor(data: PostData) {
Object.assign(this, data);
this.update$();
}
static async fetch(booru: Booru, id: id) {
const req = await fetch(`${booru.api}/posts/${id}.json`);
const post = new this(await req.json());
User.fetchMultiple(booru, {id: [post.uploader_id, post.approver_id].detype(null)}).then(() => post.update$());
return post;
}
static async fetchMultiple(booru: Booru, tags?: Partial<MetaTags> | string, limit = 20) {
let tagsQuery = '';
if (tags) {
if (typeof tags === 'string') tagsQuery = tags;
else {
tagsQuery += '&tags='
for (const [key, val] of Object.entries(tags)) {
if (key === 'tags') { tagsQuery += `${val}`; continue; }
if (tagsQuery.at(-1) !== '=') tagsQuery += ' '; // add space between tags
tagsQuery += `${key}:${val}`
}
}
}
const req = await fetch(`${booru.api}/posts.json?limit=${limit}${tagsQuery}&_method=get`);
const dataArray: PostData[] = await req.json();
const list = dataArray.map(data => {
const instance = new Post(data);
this.manager.set(instance.id, instance);
return instance;
});
const userIds = [...new Set(dataArray.map(data => [data.approver_id, data.uploader_id].detype(null)).flat())];
User.fetchMultiple(booru, {id: userIds}).then(() => list.forEach(post => post.update$()));
// const tagNames = new Set(dataArray.map(data => data.tag_string.split(' ')).flat());
// Tag.fetchMultiple(booru, {name: {_comma: [...tagNames.values()].toString()}})
return list;
}
update$() {
this.uploader$.set(this.uploader?.name ?? this.uploader_id);
this.approver$.set(this.approver?.name ?? this.approver_id ?? 'None');
this.created_date$.set(dateFrom(+new Date(this.created_at)));
this.favorites$.set(this.fav_count);
this.score$.set(this.score);
}
get pathname() { return `/posts/${this.id}` }
get uploader() { return User.manager.get(this.uploader_id); }
get approver() { if (this.approver_id) return User.manager.get(this.approver_id); else return null }
get isVideo() { return this.file_ext === 'mp4' || this.file_ext === 'webm' }
get tags() {
const tag_list = this.tag_string.split(' ');
return [...Tag.manager.values()].filter(tag => tag_list.includes(tag.name))
}
}
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';

74
src/structure/Tag.ts Normal file
View File

@ -0,0 +1,74 @@
import type { Booru } from "./Booru";
export interface TagOptions {}
export interface Tag extends TagData {}
export class Tag {
static manager = new Map<id, Tag>();
constructor(data: TagData) {
Object.assign(this, data);
}
static async fetch(booru: Booru, id: id) {
const req = await fetch(`${booru.api}/tags/${id}.json`);
const post = new this(await req.json());
return post;
}
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 req = await fetch(`${booru.api}/tags.json?limit=${limit}${searchQuery}`);
const dataArray: TagData[] = await req.json();
const list = dataArray.map(data => {
const instance = new this(data);
this.manager.set(instance.id, instance);
return instance;
});
return list;
}
}
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,
Artist,
Copyright,
Character,
Meta
}

118
src/structure/User.ts Normal file
View File

@ -0,0 +1,118 @@
import type { Booru } from "./Booru";
export class UserOptions {}
export interface User extends UserOptions, UserData {}
export class User {
static manager = new Map<id, User>();
constructor(data: UserData) {
Object.assign(this, data);
}
static async fetch(booru: Booru, id: id) {
const req = await fetch(`${booru.api}/posts/${id}.json`);
const post = new this(await req.json());
return post;
}
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 req = await fetch(`${booru.api}/users.json?limit=${limit}${searchQuery}`);
const dataArray: UserData[] = await req.json();
const list = dataArray.map(data => {
const instance = new this(data);
this.manager.set(instance.id, instance);
return instance;
});
return list;
}
}
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": UserLevelString,
"is_banned": boolean,
}
export type UserLevel = 10 | 20 | 30 | 31 | 32 | 40 | 50;
export type UserLevelString = "Member" | "Gold" | "Platinum" | "Admin";
export interface UserProfileData 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 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';
}

50
src/structure/Util.ts Normal file
View File

@ -0,0 +1,50 @@
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`;
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"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"]
}