Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
e5ac470a29 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,3 +0,0 @@
|
|||||||
node_modules
|
|
||||||
.vscode
|
|
||||||
bun.lockb
|
|
49
README.md
49
README.md
@ -1,49 +0,0 @@
|
|||||||
<picture style="display: flex; justify-content: center;">
|
|
||||||
<img style="max-width: 500px" src="https://git.defaultkavy.com/defaultkavy/danbooru-viewer/raw/branch/asset/danbooru-viewer-logo.png" alt="Danbooru Viewer Logo">
|
|
||||||
</picture>
|
|
||||||
<p style="text-align: center">A modern style viewer for <a href="https://danbooru.donmai.us">Dannbooru</a> or other Booru API base site.</p>
|
|
||||||
|
|
||||||
## 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
BIN
danbooru-viewer-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 18 KiB |
1
dist/assets/index-C_mg4_Pk.js
vendored
1
dist/assets/index-C_mg4_Pk.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-g15FC_9F.css
vendored
1
dist/assets/index-g15FC_9F.css
vendored
File diff suppressed because one or more lines are too long
25
dist/index.html
vendored
25
dist/index.html
vendored
@ -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>
|
|
BIN
dist/statics/danbooru-viewer-app-icon@2x.png
vendored
BIN
dist/statics/danbooru-viewer-app-icon@2x.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 3.3 KiB |
BIN
dist/statics/danbooru-viewer-icon.png
vendored
BIN
dist/statics/danbooru-viewer-icon.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 1.1 KiB |
27
dist/statics/manifest.json
vendored
27
dist/statics/manifest.json
vendored
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
25
index.html
25
index.html
@ -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>
|
|
246
index.scss
246
index.scss
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
28
package.json
28
package.json
@ -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 |
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
bun --hot index.ts
|
|
@ -1,2 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
bunx --bun vite build
|
|
@ -1,2 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
bunx --bun vite --host 127.0.0.1
|
|
102
server.ts
102
server.ts
@ -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`;
|
|
||||||
}
|
|
@ -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) }
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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}` : ''}` }
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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: [],
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
1
src/env.d.ts
vendored
@ -1 +0,0 @@
|
|||||||
declare const __APP_VERSION__: string;
|
|
54
src/index.d.ts
vendored
54
src/index.d.ts
vendored
@ -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;
|
|
||||||
}
|
|
210
src/main.ts
210
src/main.ts
@ -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) })
|
|
@ -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')),
|
|
||||||
])
|
|
||||||
]
|
|
||||||
})
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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))
|
|
||||||
})
|
|
||||||
]
|
|
||||||
})
|
|
@ -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;
|
|
||||||
// }
|
|
@ -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}
|
|
@ -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 {
|
|
||||||
}
|
|
@ -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]
|
|
||||||
}
|
|
@ -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';
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
@ -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';
|
|
@ -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;
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
@ -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';
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
@ -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"]
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
Loading…
Reference in New Issue
Block a user