init
This commit is contained in:
commit
4aff683df3
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.vscode
|
||||
bun.lockb
|
6
README.md
Normal file
6
README.md
Normal 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
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
1
dist/assets/index-Dna-ZQnZ.css
vendored
Normal 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
13
dist/index.html
vendored
Normal 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
13
index.html
Normal 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
34
index.scss
Normal 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
18
package.json
Normal 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"
|
||||
}
|
||||
}
|
9
src/component/PostGrid/$PostGrid.ts
Normal file
9
src/component/PostGrid/$PostGrid.ts
Normal 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);
|
||||
}
|
||||
}
|
2
src/component/PostGrid/_$PostGrid.scss
Normal file
2
src/component/PostGrid/_$PostGrid.scss
Normal file
@ -0,0 +1,2 @@
|
||||
layout.post-grid {
|
||||
}
|
51
src/component/PostTile/$PostTile.ts
Normal file
51
src/component/PostTile/$PostTile.ts
Normal 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}`)
|
||||
}
|
||||
}
|
33
src/component/PostTile/_$PostTile.scss
Normal file
33
src/component/PostTile/_$PostTile.scss
Normal 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
51
src/index.d.ts
vendored
Normal 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
27
src/main.ts
Normal 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
46
src/route/home/$home.ts
Normal 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
136
src/route/posts/$post.ts
Normal 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
141
src/route/posts/_$post.scss
Normal 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
11
src/structure/Booru.ts
Normal 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)
|
||||
}
|
||||
}
|
64
src/structure/Commentary.ts
Normal file
64
src/structure/Commentary.ts
Normal 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
241
src/structure/Post.ts
Normal 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
74
src/structure/Tag.ts
Normal 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
118
src/structure/User.ts
Normal 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
50
src/structure/Util.ts
Normal 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
28
tsconfig.json
Normal 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"]
|
||||
}
|
Loading…
Reference in New Issue
Block a user