new: danbooru account login with api is available now.
new: class Client User
adjust: [css] route element padding inline.
new: [css] color variable.
new: interface APIError.
new: [page] /login route.
new: [$nav] menu button and account button.
new: class $Drawer, with user account basic detail, and login/logout button.
new: function numberFormat.
optimize: [$IconButton] $icon default is hiding, method icon will set $icon visible.
new: [$IconButton] method link, set 'click' listener with open link.
This commit is contained in:
defaultkavy 2024-10-07 22:03:10 +08:00
parent e9f5e1808d
commit a992af35c2
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
22 changed files with 415 additions and 76 deletions

1
dist/assets/index-BkbpsU_v.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-La3x4wRW.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -7,8 +7,8 @@
<title>Danbooru Viewer v0.2.5</title>
<script type="module" src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@7.1.0/dist/ionicons/ionicons.js"></script>
<script type="module" crossorigin src="/assets/index-DaDrA_5o.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DeBFHjT4.css">
<script type="module" crossorigin src="/assets/index-La3x4wRW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkbpsU_v.css">
</head>
<body>
</body>

View File

@ -3,6 +3,7 @@
@import '/src/component/PostTile/$PostTile';
@import '/src/component/Searchbar/$Searchbar';
@import '/src/component/IconButton/$IconButton';
@import '/src/component/Drawer/$Drawer';
// routes
@import '/src/route/post/$post_route';
@import '/src/route/login/$login_route';
@ -11,12 +12,15 @@
--background-color: #1e1e2c;
--background-color-lighter: #3b3b66;
--background-color-light: #24243b;
--background-color-dark: #12121f;
--background-color-darker: #07070c;
--primary-color: #d1d1ee;
--primary-color-dark: #9696b3;
--primary-color-darker: #72728d;
--secondary-color: #aeaeec;
--secondary-color-dark: #6d6da1;
--secondary-color-darker: #424268;
--shadow-color: #09090e50;
--border-radius-small: 0.4rem;
--border-radius-medium: 0.8rem;
@ -154,7 +158,7 @@ router {
route {
display: block;
position: relative;
padding-inline: 10px;
padding-inline: 1rem;
padding-top: var(--nav-height);
}
}

View File

@ -2,7 +2,7 @@
"name": "danbooru-viewer",
"module": "index.ts",
"type": "module",
"version": "0.3.4",
"version": "0.3.5",
"devDependencies": {
"@types/bun": "latest",
"vite": "^5.4.8"

View File

@ -0,0 +1,91 @@
import { $Container } from "elexis";
import { Booru } from "../../structure/Booru";
import { numberFormat } from "../../modules";
export class $Drawer extends $Container {
$filter = $('div').class('filter');
$container = $('div').class('drawer-container')
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([
$('div').class('login')
.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)))),
$('div').class('logout').hide(true)
.content([ $('icon-button').icon('log-in-outline').content('Logout').on('dblclick', () => Booru.used.logout()) ])
.self(($div => Booru.events.on('login', () => $div.hide(false)).on('logout', () => $div.hide(true)))),
])
]),
this.$filter.on('click', () => $.back())
])
}
open() {
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'
})
}
close() {
this.$container.animate({
transform: [`translateX(0%)`, `translateX(100%)`]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
}, () => this.hide(true))
this.$filter.animate({
opacity: [1, 0]
}, {
fill: 'both',
duration: 300,
easing: 'ease'
})
}
checkURL(beforeURL: URL | undefined, afterURL: URL) {
if (beforeURL?.hash === '#drawer') this.close();
if (afterURL.hash === '#drawer') this.open();
}
}

View File

@ -0,0 +1,75 @@
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(--background-color);
border-radius: var(--border-radius-large);
z-index: 1;
overflow: hidden;
div.user-info {
background-color: var(--background-color-light);
padding: 2rem;
.username {
margin: 0;
color: var(--secondary-color);
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 {
}
}
}
div.filter {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
background: linear-gradient(90deg,
color-mix(in srgb, var(--background-color-dark) 50%, transparent) 0%,
color-mix(in srgb, var(--background-color-darker) 70%, transparent) 100%
);
}
}

View File

@ -11,7 +11,7 @@ export class $IconButton extends $Button {
private build() {
super.content([
this.$icon,
this.$icon.hide(true),
this.$label
])
}
@ -22,7 +22,12 @@ export class $IconButton extends $Button {
}
icon(name: string) {
this.$icon.name(name);
this.$icon.name(name).hide(false);
return this;
}
link(url: string, replace = false) {
this.on('click', () => replace ? $.replace(url) : $.open(url));
return this;
}
}

View File

@ -1,8 +1,8 @@
import { $Container } from "elexis";
import { Tag, TagCategory } from "../../structure/Tag";
import { Booru } from "../../structure/Booru";
import { User } from "../../structure/User";
import { Autocomplete } from "../../structure/Autocomplete";
import { numberFormat } from "../../modules";
export class $Searchbar extends $Container {
$tagInput = new $TagInput(this);
@ -143,7 +143,7 @@ export class $Searchbar extends $Container {
])
]),
data.isTag() ? $('div').class('tag-detail').content([
$('span').class('tag-post-count').content(new Intl.NumberFormat('en', {notation: 'compact'}).format(data.post_count)),
$('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
@ -160,8 +160,8 @@ export class $Searchbar extends $Container {
return this;
}
checkURL(beforeURL: URL, afterURL: URL) {
if (beforeURL.hash === '#search') this.inactivate();
checkURL(beforeURL: URL | undefined, afterURL: URL) {
if (beforeURL?.hash === '#search') this.inactivate();
if (afterURL.hash === '#search') this.activate();
}
}

9
src/index.d.ts vendored
View File

@ -45,7 +45,10 @@ type TextSyntaxComparisons<T> =
{ _lower_space: string }
type UserSyntax = { _id: id } | { _name: username };
type ChainingSyntax = {_id: id} | {has_: boolean};
type PostSyntax = {_id: id} | {_tags_match: string};
type PostSyntax = {_id: id} | {_tags_match: string};
interface APIError {
success: false;
error: string;
message: string;
}

View File

@ -8,6 +8,8 @@ import { $Router, $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';
// declare elexis module
declare module 'elexis' {
export namespace $ {
@ -28,7 +30,8 @@ export const [danbooru, safebooru]: Booru[] = [
]
Booru.set(Booru.manager.get(Booru.storageAPI ?? '') ?? danbooru);
const $searchbar = new $Searchbar().hide(true);
if (location.hash === '#search') $searchbar.activate();
const $drawer = new $Drawer();
// render
$(document.body).content([
// Navigation Bar
@ -60,10 +63,24 @@ $(document.body).content([
// Open Booru
$('ion-icon').class('open').name('open-outline').title('Open in Original Site')
.on('click', () => $.open(location.href.replace(location.origin, Booru.used.origin))),
// 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', () => $.open(location.href + '#drawer'))
])
]),
// Searchbar
$searchbar,
// Drawer
$drawer,
// Base Router
$('router').base('/').map([
// Home Page
@ -71,7 +88,9 @@ $(document.body).content([
// Posts Page
$('route').id('posts').path('/posts?tags').builder(({query}) => new $PostGrid({tags: query.tags})),
// Post Page
post_route
post_route,
// Login Page
$login_route
]).on('beforeSwitch', (e) => {
const DURATION = 300;
const TX = 2;
@ -123,4 +142,9 @@ $(document.body).content([
})
])
$Router.events.on('stateChange', ({beforeURL, afterURL}) => { $searchbar.checkURL(beforeURL, afterURL) })
$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)
}

4
src/modules.ts Normal file
View File

@ -0,0 +1,4 @@
const NUMBER_FORMAT = new Intl.NumberFormat('en', {notation: 'compact'})
export function numberFormat(number: number) {
return NUMBER_FORMAT.format(number)
}

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { Tag, TagCategory } from "../../structure/Tag";
import { ArtistCommentary } from "../../structure/Commentary";
import { Booru } from "../../structure/Booru";
import type { $IonIcon } from "../../component/IonIcon/$IonIcon";
import { numberFormat } from "../../modules";
export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => {
if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND');
@ -87,7 +88,7 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
$('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$)
$('span').class('tag-post-count').content(tag.post_count$.convert(numberFormat))
]))
])
] : null

View File

@ -1,6 +1,7 @@
import { $EventManager, type $EventMap } from "elexis";
import type { Post } from "./Post";
import type { Tag } from "./Tag";
import { ClientUser, type ClientUserData } from "./ClientUser";
export interface BooruOptions {
origin: string;
@ -9,9 +10,10 @@ export interface BooruOptions {
export interface Booru extends BooruOptions {}
export class Booru {
static used: Booru;
static events = new $EventManager<BooruEventMap>();
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>();
constructor(options: BooruOptions) {
@ -24,6 +26,8 @@ export class 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;
}
@ -31,8 +35,33 @@ export class Booru {
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) {
const data = await fetch(`${this.origin}${endpoint}`).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);
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 {
set: []
}

View File

@ -0,0 +1,71 @@
import type { Booru } from "./Booru";
import { User, type UserData } from "./User";
export interface ClientUser extends ClientUserData {}
export class ClientUser extends User {
apiKey: string;
favorite_count$ = $.state(0);
forum_post_count$ = $.state(0);
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);
}
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;
}

View File

@ -113,6 +113,7 @@ export class Post extends $EventManager<{update: []}> {
}
get previewURL() { return this.media_asset.variants?.find(variant => variant.file_ext === 'webp')?.url ?? this.large_file_url }
get url() { return `${this.booru.origin}/posts/${this.id}` }
get isFileSource() { return this.source.startsWith('file://') }
}
export interface PostData extends PostOptions {

View File

@ -1,10 +1,9 @@
import type { Booru } from "./Booru";
const INTL_number = new Intl.NumberFormat('en', {notation: 'compact'})
export interface TagOptions {}
export interface Tag extends TagData {}
export class Tag {
post_count$ = $.state(0, {format: (value) => `${INTL_number.format(value)}`});
post_count$ = $.state(0);
name$ = $.state('');
booru: Booru;
constructor(booru: Booru, data: TagData) {

View File

@ -4,15 +4,20 @@ export class UserOptions {}
export interface User extends UserOptions, UserData {}
export class User {
static manager = new Map<id, User>();
name$ = $.state('loding...');
constructor(data: UserData) {
name$ = $.state('...');
post_upload_count$ = $.state(0);
level$ = $.state(10);
level_string$ = $.state('...');
booru: Booru;
constructor(booru: Booru, data: UserData, update$: boolean = true) {
this.booru = booru;
Object.assign(this, data);
this.update$();
if (update$) this.update$();
}
static async fetch(booru: Booru, id: id) {
const data = await fetch(`${booru.origin}/users/${id}.json`).then(async data => await data.json()) as UserData;
const instance = this.manager.get(data.id)?.update(data) ?? new this(data);
const instance = this.manager.get(data.id)?.update(data) ?? new this(booru, data);
this.manager.set(instance.id, instance);
return instance;
}
@ -33,7 +38,7 @@ export class User {
const req = await fetch(`${booru.origin}/users.json?limit=${limit}${searchQuery}`);
const dataArray: UserData[] = await req.json();
const list = dataArray.map(data => {
const instance = new this(data);
const instance = new this(booru, data);
this.manager.set(instance.id, instance);
return instance;
});
@ -48,9 +53,26 @@ export class User {
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,
@ -61,57 +83,10 @@ export interface UserData {
"note_update_count": number,
"post_upload_count": number,
"is_deleted": boolean,
"level_string": UserLevelString,
"level_string": keyof UserLevel,
"is_banned": boolean,
}
export type UserLevel = 10 | 20 | 30 | 31 | 32 | 40 | 50;
export type UserLevelString = "Member" | "Gold" | "Platinum" | "Admin" | "Contributor" | "Builder" | "Approver";
export interface UserProfileData extends UserData {
"last_logged_in_at": ISOString,
"last_forum_read_at": ISOString,
"comment_threshold": number,
"updated_at": ISOString,
"default_image_size": "large" | "original",
"favorite_tags": null | string,
"blacklisted_tags": string,
"time_zone": string,
"favorite_count": number,
"per_page": number,
"custom_style": string,
"theme": "auto" | "light" | "dark",
"receive_email_notifications": boolean,
"new_post_navigation_layout": boolean,
"enable_private_favorites": boolean,
"show_deleted_children": boolean,
"disable_categorized_saved_searches": boolean,
"disable_tagged_filenames": boolean,
"disable_mobile_gestures": boolean,
"enable_safe_mode": boolean,
"enable_desktop_mode": boolean,
"disable_post_tooltips": boolean,
"requires_verification": boolean,
"is_verified": boolean,
"show_deleted_posts": boolean,
"statement_timeout": number,
"favorite_group_limit": 10 | 100,
"tag_query_limit": 2 | 6,
"max_saved_searches": 250,
"wiki_page_version_count": number,
"artist_version_count": number,
"artist_commentary_version_count": number,
"pool_version_count": number | null,
"forum_post_count": number,
"comment_count": number,
"favorite_group_count": number,
"appeal_count": number,
"flag_count": number,
"positive_feedback_count": number,
"neutral_feedback_count": number,
"negative_feedback_count": number
}
export interface UserSearchParam {
id: NumericSyntax<id>;
level: NumericSyntax<UserLevel>;