new: post favorite.
new: User.favorites Map.
new: ClientUser.init(), fetch client user fav data on start up.
This commit is contained in:
defaultkavy 2024-10-11 23:05:28 +08:00
parent a58e5d4b95
commit 5605aa94d2
Signed by: defaultkavy
GPG Key ID: DFBB22C4E69D7826
16 changed files with 188 additions and 12 deletions

File diff suppressed because one or more lines are too long

1
dist/assets/index-B0cSv4EX.js 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

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

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -16,8 +16,8 @@
gtag('config', 'G-59HBGP98WR'); gtag('config', 'G-59HBGP98WR');
</script> </script>
<script type="module" crossorigin src="/assets/index-8i15BK3r.js"></script> <script type="module" crossorigin src="/assets/index-B0cSv4EX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3nfTvuP.css"> <link rel="stylesheet" crossorigin href="/assets/index-GbkvssuE.css">
</head> </head>
<body> <body>
</body> </body>

View File

@ -2,7 +2,7 @@
"name": "danbooru-viewer", "name": "danbooru-viewer",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.5.1", "version": "0.6.0",
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
"vite": "^5.4.8" "vite": "^5.4.8"
@ -13,7 +13,11 @@
"dependencies": { "dependencies": {
"@elexis/layout": "../elexis-ext/layout", "@elexis/layout": "../elexis-ext/layout",
"@elexis/router": "../elexis-ext/router", "@elexis/router": "../elexis-ext/router",
"@elysiajs/cors": "^1.1.1",
"@elysiajs/eden": "^1.1.3",
"cheerio": "^1.0.0",
"elexis": "../elexis", "elexis": "../elexis",
"elysia": "^1.1.20",
"sass": "^1.77.1" "sass": "^1.77.1"
} }
} }

20
server.ts Normal file
View File

@ -0,0 +1,20 @@
import cors from "@elysiajs/cors";
import Elysia from "elysia";
const app = new Elysia()
.use(cors())
.get('*', async ({path}) => {
return Bun.file('./dist/index.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);
console.debug(data)
return data
})
})
.listen(3030);
console.log('Start listening: 3030')
export type Server = typeof app;

View File

@ -14,4 +14,9 @@ export class $IonIcon extends $Container {
this.attribute('size', size); this.attribute('size', size);
return this; return this;
} }
link(url: string, replace = false) {
this.on('click', () => replace ? $.replace(url) : $.open(url));
return this;
}
} }

View File

@ -5,17 +5,47 @@ import { ArtistCommentary } from "../../structure/Commentary";
import { Booru } from "../../structure/Booru"; import { Booru } from "../../structure/Booru";
import type { $IonIcon } from "../../component/IonIcon/$IonIcon"; import type { $IonIcon } from "../../component/IonIcon/$IonIcon";
import { numberFormat } from "../../modules"; import { numberFormat } from "../../modules";
import { ClientUser } from "../../structure/ClientUser";
export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => { export const post_route = $('route').path('/posts/:id').id('post').builder(({$route, params}) => {
if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND'); if (!Number(params.id)) return $('h1').content('404: POST NOT FOUND');
const post = Post.get(Booru.used, +params.id); const post = Post.get(Booru.used, +params.id);
const $viewerPanel =
$('div').class('viewer-panel').content([
$('div').class('panel').content([
$('ion-icon').name('heart-outline').self($heart => {
ClientUser.events.on('favoriteUpdate', (user) => {
if (user.favorites.has(post.id)) $heart.name('heart');
else $heart.name('heart-outline');
})
if (Booru.used.user?.favorites.has(post.id)) $heart.name('heart');
}).on('click', () => {
if (Booru.used.user?.favorites.has(post.id)) post.deleteFavorite();
else post.createFavorite();
})
]),
$('div').class('overlay')
]).hide(true);
return [ return [
$('div').class('viewer').content(async () => { $('div').class('viewer').content(async () => {
await post.ready; await post.ready;
return post.isVideo return [
$viewerPanel,
post.isVideo
? $('video').height(post.image_height).width(post.image_width).src(post.file_ext === 'zip' ? post.large_file_url : post.file_url).controls(true).autoplay(true).loop(true).disablePictureInPicture(true) ? $('video').height(post.image_height).width(post.image_width).src(post.file_ext === 'zip' ? post.large_file_url : post.file_url).controls(true).autoplay(true).loop(true).disablePictureInPicture(true)
: $('img').src(post.large_file_url)//.once('load', (e, $img) => { $img.src(post.file_url)}) : $('img').src(post.large_file_url)//.once('load', (e, $img) => { $img.src(post.file_url)})
}), ]
})
.on('pointermove', (e) => {
if (e.pointerType === 'mouse' || e.pointerType === 'pen') $viewerPanel.hide(false);
})
.on('pointerup', (e) => {
console.debug(e.movementX)
if (e.pointerType === 'touch') $viewerPanel.hide(!$viewerPanel.hide());
})
.on('mouseleave', () => {
$viewerPanel.hide(true);
}),
$('div').class('content').content([ $('div').class('content').content([
$('h3').content(`Artist's Commentary`), $('h3').content(`Artist's Commentary`),
$('section').class('commentary').content(async ($comentary) => { $('section').class('commentary').content(async ($comentary) => {
@ -45,7 +75,7 @@ export const post_route = $('route').path('/posts/:id').id('post').builder(({$ro
new $Property('size').name('Size').content([post.file_size$, post.dimension$]), new $Property('size').name('Size').content([post.file_size$, post.dimension$]),
new $Property('file-type').name('File Type').content(post.file_ext$), new $Property('file-type').name('File Type').content(post.file_ext$),
$('div').class('inline').content([ $('div').class('inline').content([
new $Property('favorites').name('Favorites').content(post.favorites$), new $Property('favorites').name('Favorites').content(post.favcount$),
new $Property('score').name('Score').content(post.score$) new $Property('score').name('Score').content(post.score$)
]), ]),
new $Property('file-url').name('File').content([ new $Property('file-url').name('File').content([

View File

@ -17,6 +17,7 @@
overflow: hidden; overflow: hidden;
width: calc(100vw - 300px - 4rem); width: calc(100vw - 300px - 4rem);
margin: 1rem; margin: 1rem;
position: relative;
@media (max-width: 800px) { @media (max-width: 800px) {
width: 100%; width: 100%;
@ -37,6 +38,32 @@
-webkit-user-drag: none; -webkit-user-drag: none;
transition: all 0.3s ease; 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;
padding: 1rem;
}
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 { div.content {

View File

@ -3,6 +3,7 @@ import type { Post } from "./Post";
import type { Tag } from "./Tag"; import type { Tag } from "./Tag";
import { ClientUser, type ClientUserData } from "./ClientUser"; import { ClientUser, type ClientUserData } from "./ClientUser";
import type { User } from "./User"; import type { User } from "./User";
import type { Favorite } from "./Favorite";
export interface BooruOptions { export interface BooruOptions {
origin: string; origin: string;
@ -18,6 +19,7 @@ export class Booru {
posts = new Map<id, Post>(); posts = new Map<id, Post>();
tags = new Map<id, Tag>(); tags = new Map<id, Tag>();
users = new Map<id, User>(); users = new Map<id, User>();
favorites = new Map<id, Favorite>();
constructor(options: BooruOptions) { constructor(options: BooruOptions) {
Object.assign(this, options); Object.assign(this, options);
if (this.origin.endsWith('/')) this.origin = this.origin.slice(0, -1); if (this.origin.endsWith('/')) this.origin = this.origin.slice(0, -1);
@ -37,16 +39,20 @@ export class Booru {
static get storageAPI() { return localStorage.getItem('booru_api'); } 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') } static set storageAPI(name: string | null) { if (name) localStorage.setItem('booru_api', name); else localStorage.removeItem('booru_api') }
async fetch<T>(endpoint: string) { 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 auth = this.user ? `${endpoint.includes('?') ? '&' : '?'}login=${this.user.name}&api_key=${this.user.apiKey}` : '';
const data = await fetch(`${this.origin}${endpoint}${auth}`).then(res => res.json()) as any; const data = await fetch(`${this.origin}${endpoint}${auth}`, {
method: method,
}).then(res => res.json()) as any;
if (data.success === false) throw data.message; if (data.success === false) throw data.message;
return data as T; return data as T;
} }
async login(username: string, apiKey: string) { async login(username: string, apiKey: string) {
const data = await this.fetch<ClientUserData>(`/profile.json?login=${username}&api_key=${apiKey}`); const data = await this.fetch<ClientUserData>(`/profile.json?login=${username}&api_key=${apiKey}`);
this.user = new ClientUser(this, apiKey, data); this.user = new ClientUser(this, apiKey, data);
this.user.init();
Booru.events.fire('login', this.user); Booru.events.fire('login', this.user);
return this.user; return this.user;
} }

View File

@ -1,11 +1,15 @@
import { $EventManager } from "elexis";
import type { Booru } from "./Booru"; import type { Booru } from "./Booru";
import { Favorite, type FavoriteData } from "./Favorite";
import { User, type UserData } from "./User"; import { User, type UserData } from "./User";
import type { Post } from "./Post";
export interface ClientUser extends ClientUserData {} export interface ClientUser extends ClientUserData {}
export class ClientUser extends User { export class ClientUser extends User {
apiKey: string; apiKey: string;
favorite_count$ = $.state(0); favorite_count$ = $.state(0);
forum_post_count$ = $.state(0); forum_post_count$ = $.state(0);
static events = new $EventManager<ClientUserEventMap>()
constructor(booru: Booru, apiKey: string, data: ClientUserData) { constructor(booru: Booru, apiKey: string, data: ClientUserData) {
super(booru, data, false); super(booru, data, false);
this.apiKey = apiKey; this.apiKey = apiKey;
@ -18,6 +22,19 @@ export class ClientUser extends User {
this.favorite_count$?.set(this.favorite_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 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)) } static set storageUserData(data: ClientUserStoreData | null) { localStorage.setItem('user_data', JSON.stringify(data)) }
} }
@ -68,4 +85,8 @@ export interface ClientUserData extends UserData {
export interface ClientUserStoreData { export interface ClientUserStoreData {
username: string; username: string;
apiKey: string; apiKey: string;
}
export interface ClientUserEventMap {
favoriteUpdate: [user: ClientUser]
} }

32
src/structure/Favorite.ts Normal file
View File

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

View File

@ -3,6 +3,8 @@ import { Booru } from "./Booru";
import { Tag } from "./Tag"; import { Tag } from "./Tag";
import { User } from "./User"; import { User } from "./User";
import { dateFrom, digitalUnit } from "./Util"; import { dateFrom, digitalUnit } from "./Util";
import { ClientUser } from "./ClientUser";
import type { FavoriteData } from "./Favorite";
const LOADING_STRING = '...' const LOADING_STRING = '...'
@ -12,7 +14,7 @@ export class Post extends $EventManager<{update: []}> {
uploader$ = $.state(LOADING_STRING); uploader$ = $.state(LOADING_STRING);
approver$ = $.state(LOADING_STRING); approver$ = $.state(LOADING_STRING);
created_date$ = $.state(LOADING_STRING); created_date$ = $.state(LOADING_STRING);
favorites$ = $.state(0); favcount$ = $.state(0);
score$ = $.state(0); score$ = $.state(0);
file_size$ = $.state(LOADING_STRING); file_size$ = $.state(LOADING_STRING);
file_ext$ = $.state(LOADING_STRING); file_ext$ = $.state(LOADING_STRING);
@ -75,7 +77,7 @@ export class Post extends $EventManager<{update: []}> {
this.uploader$.set(this.uploader?.name$ ?? this.uploader_id?.toString()); this.uploader$.set(this.uploader?.name$ ?? this.uploader_id?.toString());
this.approver$.set(this.approver?.name$ ?? this.approver_id?.toString() ?? 'None'); this.approver$.set(this.approver?.name$ ?? this.approver_id?.toString() ?? 'None');
this.created_date$.set(dateFrom(+new Date(this.created_at))); this.created_date$.set(dateFrom(+new Date(this.created_at)));
this.favorites$.set(this.fav_count); this.favcount$.set(this.fav_count);
this.score$.set(this.score); this.score$.set(this.score);
this.file_size$.set(digitalUnit(this.file_size)); this.file_size$.set(digitalUnit(this.file_size));
this.file_ext$.set(this.file_ext as any); this.file_ext$.set(this.file_ext as any);
@ -99,6 +101,26 @@ export class Post extends $EventManager<{update: []}> {
return await Tag.fetchMultiple(this.booru, {name: {_space: this.tag_string}}); return await Tag.fetchMultiple(this.booru, {name: {_space: this.tag_string}});
} }
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 pathname() { return `/posts/${this.id}` }
get uploader() { return this.booru.users.get(this.uploader_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 approver() { if (this.approver_id) return this.booru.users.get(this.approver_id); else return null }

View File

@ -8,6 +8,7 @@ export class User {
level$ = $.state(10); level$ = $.state(10);
level_string$ = $.state('...'); level_string$ = $.state('...');
booru: Booru; booru: Booru;
favorites = new Set<id>();
constructor(booru: Booru, data: UserData, update$: boolean = true) { constructor(booru: Booru, data: UserData, update$: boolean = true) {
this.booru = booru; this.booru = booru;
Object.assign(this, data); Object.assign(this, data);

View File

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