This commit is contained in:
SA 2025-04-11 00:54:15 +02:00
parent e639176911
commit ae0c94d807
70 changed files with 3233 additions and 3020 deletions

View File

@ -4,6 +4,33 @@
_Frontend designed for Paysite leaking._ _Frontend designed for Paysite leaking._
[![Button Website]][Website]
[![Button Setup]](#setup)
[![Button FAQ]][FAQ]
[![Button Develop]][Develop]
<img src="docs/resources/Preview.png" width="700" /> <img src="docs/resources/Preview.png" width="700" />
[Website]: https://kemono.su/ ## Setup
_How to use this project for yourself._
1. Clone the repository and switch to its folder.
```sh
git clone https://code.kemono.su/Kemono2
cd Kemono2
```
[Website]: https://kemono.party/
[Develop]: docs/Develop.md
[FAQ]: docs/FAQ.md
<!---------------------------------[ Buttons ]--------------------------------->
[Button Website]: https://img.shields.io/badge/Website-e6702f?style=for-the-badge&logoColor=white&logo=FirefoxBrowser
[Button Develop]: https://img.shields.io/badge/Develop-3955A3?style=for-the-badge&logoColor=white&logo=VisualStudioCode
[Button Setup]: https://img.shields.io/badge/Setup-3EAAAF?style=for-the-badge&logoColor=white&logo=GitBook
[Button FAQ]: https://img.shields.io/badge/FAQ-569A31?style=for-the-badge&logoColor=white&logo=AskUbuntu

View File

@ -9,6 +9,7 @@ export const apiServerPort = !apiServerBaseURL
? undefined ? undefined
: configuration.webserver?.port; : configuration.webserver?.port;
export const siteName = configuration.webserver.ui.home.site_name || "Kemono"; export const siteName = configuration.webserver.ui.home.site_name || "Kemono";
export const favicon = configuration.webserver.ui.favicon || "./static/favicon.ico";
export const homeBackgroundImage = export const homeBackgroundImage =
configuration.webserver.ui.home.home_background_image; configuration.webserver.ui.home.home_background_image;
export const homeMascotPath = configuration.webserver.ui.home.mascot_path; export const homeMascotPath = configuration.webserver.ui.home.mascot_path;
@ -27,6 +28,7 @@ export const disableFilehaus =
export const sidebarItems = configuration.webserver.ui.sidebar_items; export const sidebarItems = configuration.webserver.ui.sidebar_items;
export const footerItems = configuration.webserver.ui.footer_items; export const footerItems = configuration.webserver.ui.footer_items;
export const bannerGlobal = configuration.webserver.ui.banner?.global; export const bannerGlobal = configuration.webserver.ui.banner?.global;
export const AnnouncementBannerGlobal = configuration.webserver.ui.banner?.announcement_global;
export const bannerWelcome = configuration.webserver.ui.banner?.welcome; export const bannerWelcome = configuration.webserver.ui.banner?.welcome;
export const headerAd = configuration.webserver.ui.ads?.header; export const headerAd = configuration.webserver.ui.ads?.header;
export const middleAd = configuration.webserver.ui.ads?.middle; export const middleAd = configuration.webserver.ui.ads?.middle;

View File

@ -22,7 +22,7 @@
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.14.tgz" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.14.tgz"
integrity sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw== integrity sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw==
"@babel/core@^7.20.12": "@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.20.12", "@babel/core@^7.4.0-0":
version "7.20.12" version "7.20.12"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz" resolved "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz"
integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg== integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==
@ -1301,7 +1301,7 @@ acorn-import-assertions@^1.7.6:
resolved "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz" resolved "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz"
integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==
acorn@^8.5.0, acorn@^8.7.1: acorn@^8, acorn@^8.5.0, acorn@^8.7.1:
version "8.8.2" version "8.8.2"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz" resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
@ -1325,7 +1325,7 @@ ajv-keywords@^5.0.0:
dependencies: dependencies:
fast-deep-equal "^3.1.3" fast-deep-equal "^3.1.3"
ajv@^6.12.5: ajv@^6.12.5, ajv@^6.9.1:
version "6.12.6" version "6.12.6"
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@ -1335,7 +1335,7 @@ ajv@^6.12.5:
json-schema-traverse "^0.4.1" json-schema-traverse "^0.4.1"
uri-js "^4.2.2" uri-js "^4.2.2"
ajv@^8.0.0, ajv@^8.8.0: ajv@^8.0.0, ajv@^8.8.0, ajv@^8.8.2:
version "8.12.0" version "8.12.0"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz" resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz"
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
@ -1497,7 +1497,7 @@ braces@^3.0.2, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4: browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4, "browserslist@>= 4.21.0":
version "4.21.5" version "4.21.5"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz"
integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==
@ -3062,7 +3062,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.19: postcss@^8.1.0, postcss@^8.4.19:
version "8.4.21" version "8.4.21"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz" resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz"
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg== integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
@ -3744,7 +3744,7 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies: dependencies:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
webpack-cli@^5.1.1: webpack-cli@^5.1.1, webpack-cli@5.x.x:
version "5.1.1" version "5.1.1"
resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.1.tgz" resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.1.tgz"
integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw== integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw==
@ -3822,7 +3822,7 @@ webpack-sources@^3.2.3:
resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz" resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
webpack@^5.75.0: "webpack@^4.0.0 || ^5.0.0", "webpack@^4.37.0 || ^5.0.0", webpack@^5.0.0, webpack@^5.1.0, webpack@^5.20.0, webpack@^5.75.0, webpack@>=5, webpack@5.x.x:
version "5.75.0" version "5.75.0"
resolved "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz" resolved "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz"
integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ== integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==

View File

@ -8,6 +8,6 @@
<script src="/static/js/lazy-styles.js"></script> <script src="/static/js/lazy-styles.js"></script>
</head> </head>
<body> <body>
<div id="root" class="transition-preload"></div> <div id="root"></div>
</body> </body>
</html> </html>

4235
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,11 @@
"build": "vite build --config ./vite.prod.mjs" "build": "vite build --config ./vite.prod.mjs"
}, },
"overrides": { "overrides": {
"vite": "$vite" "vite": "$vite",
"swagger-ui-react": {
"react": "$react",
"react-dom": "$react-dom"
}
}, },
"imports": { "imports": {
"#storage/*": "./src/browser/storage/*/index.ts", "#storage/*": "./src/browser/storage/*/index.ts",
@ -37,14 +41,14 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"diff": "^7.0.0", "diff": "^7.0.0",
"fluid-player": "file:./fluid-player", "fluid-player": "file:./fluid-player",
"micromodal": "^0.4.10", "micromodal": "^0.6.1",
"purecss": "^3.0.0", "purecss": "^3.0.0",
"react": "^18.2.0", "react": "^19.0.0",
"react-dom": "^18.2.0", "react-dom": "^19.0.0",
"react-helmet-async": "^2.0.5", "@dr.pogodin/react-helmet": "^3.0.1",
"react-router": "^7.1.5", "react-router": "^7.5.0",
"sha256-wasm": "^2.2.2", "sha256-wasm": "^2.2.2",
"swagger-ui-react": "^5.18.3" "swagger-ui-react": "^5.20.7"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.26.8", "@babel/core": "^7.26.8",
@ -55,9 +59,9 @@
"@hyperjump/json-schema": "^1.11.0", "@hyperjump/json-schema": "^1.11.0",
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/micromodal": "^0.3.5", "@types/micromodal": "^0.3.5",
"@types/node": "^22.13.1", "@types/node": "^22.14.0",
"@types/react": "^18.2.0", "@types/react": "^19.0.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^19.0.0",
"@types/sha256-wasm": "^2.2.3", "@types/sha256-wasm": "^2.2.3",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"@types/webpack-bundle-analyzer": "^4.7.0", "@types/webpack-bundle-analyzer": "^4.7.0",

View File

@ -1,30 +1,35 @@
import { apiV2Fetch } from "#lib/api"; import { apiFetch } from "#lib/api";
import { IArchiveFile } from "#entities/files";
export interface IArchiveFile {
password?: string;
file: {
hash: string;
ext: string;
};
file_list: string[];
}
export async function apiFetchArchiveFile(fileHash: string) { export async function apiFetchArchiveFile(fileHash: string) {
const pathSpec = `/file/{file_hash}`;
const path = `/file/${fileHash}`; const path = `/file/${fileHash}`;
const result = await apiV2Fetch<IArchiveFile>(pathSpec, "GET", path); const result = await apiFetch<IArchiveFile>(path, { method: "GET" });
return result; return result;
} }
interface IBody { type SetPasswordBody = Array<string>;
password: string; type SetPasswordResponse = "ok" | {
} error: string;
};
export async function apiSetArchiveFilePassword( export async function apiSetArchiveFilePassword(
archiveHash: string, archiveHash: string,
password: string password: string
) { ): Promise<SetPasswordResponse> {
const pathSpec = `/file/{file_hash}`;
const path = `/file/${archiveHash}`; const path = `/file/${archiveHash}`;
const body: IBody = { const body: SetPasswordBody = [password];
password,
};
const result = await apiV2Fetch<string>(pathSpec, "PATCH", path, { body }); const result = await apiFetch<SetPasswordResponse>(path, { body, method: "PATCH" });
return result; return result;
} }

View File

@ -1,2 +1,2 @@
export { apiFetchArchiveFile, apiSetArchiveFilePassword } from "./archive-file"; export { apiFetchArchiveFile, apiSetArchiveFilePassword, type IArchiveFile } from "./archive-file";
export { fetchSearchFileByHash } from "./search-by-hash"; export { fetchSearchFileByHash } from "./search-by-hash";

View File

@ -13,8 +13,8 @@ interface IResult {
size: number; size: number;
ihash: string; ihash: string;
posts: IPostResult[];
posts: IPostResult[];
discord_posts: IDiscordPostResult[]; discord_posts: IDiscordPostResult[];
} }

View File

@ -20,7 +20,7 @@ export function ClientProvider({ children }: IProps) {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
const clientData: IClientContext = { isRegistered }; const clientData: IClientContext = { isRegistered };
changeClient(clientData); changeClient(clientData);
})(); })();

View File

@ -23,16 +23,51 @@ export interface ILocalStorageSchema {
type ILocalStorageName = (typeof storageNames)[number]; type ILocalStorageName = (typeof storageNames)[number];
export function getLocalStorageItem(name: ILocalStorageName) { let localStorageAvailable: boolean | null = null;
function checkLocalStorageAvailability(): boolean {
if (localStorageAvailable === null) {
try {
localStorage.setItem("__storage_test__", "__storage_test__");
localStorage.removeItem("__storage_test__");
localStorageAvailable = true;
} catch (error) {
localStorageAvailable = false;
}
}
return localStorageAvailable;
}
export function getLocalStorageItem(name: ILocalStorageName): string | null {
if (!checkLocalStorageAvailability()) {
console.warn("LocalStorage is not available.");
return null;
}
return localStorage.getItem(name); return localStorage.getItem(name);
} }
export function setLocalStorageItem(name: ILocalStorageName, value: string) { export function setLocalStorageItem(name: ILocalStorageName, value: string): void {
if (!checkLocalStorageAvailability()) {
console.warn("LocalStorage is not available.");
return;
}
try {
localStorage.setItem(name, value); localStorage.setItem(name, value);
} catch (error) {
console.error("Failed to set item in LocalStorage:", error);
}
} }
export function deleteLocalStorageItem(name: ILocalStorageName) { export function deleteLocalStorageItem(name: ILocalStorageName): void {
if (!checkLocalStorageAvailability()) {
console.warn("LocalStorage is not available.");
return;
}
try {
localStorage.removeItem(name); localStorage.removeItem(name);
} catch (error) {
console.error("Failed to remove item from LocalStorage:", error);
}
} }
export function isLocalStorageAvailable() { export function isLocalStorageAvailable() {

View File

@ -1,6 +1,7 @@
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars"; import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars";
import { DangerousContent } from "#components/dangerous-content"; import { DangerousContent } from "#components/dangerous-content";
import { useEffect } from "react";
export function HeaderAd() { export function HeaderAd() {
const location = useLocation(); const location = useLocation();
@ -11,7 +12,6 @@ export function HeaderAd() {
key={key} key={key}
className="ad-container" className="ad-container"
html={atob(HEADER_AD)} html={atob(HEADER_AD)}
allowRerender
/> />
); );
} }
@ -48,10 +48,33 @@ export function SliderAd() {
const location = useLocation(); const location = useLocation();
const key = `${location.pathname}${location.search}`; const key = `${location.pathname}${location.search}`;
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList") {
const slideAnimationElements = document.querySelectorAll('[class*="slideAnimation"]');
const elementsToRemove = Array.from(slideAnimationElements).slice(1);
elementsToRemove.forEach((element) => {
element.remove();
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
useEffect(() => {
return () => {
observer.disconnect();
document.querySelectorAll('[class*="slideAnimation"]').forEach((element) => {
element.remove();
});
};
}, []);
return !SLIDER_AD ? undefined : ( return !SLIDER_AD ? undefined : (
<DangerousContent <DangerousContent
key={key} key={key}
className="ad-container" className="ad-container-slider"
html={atob(SLIDER_AD)} html={atob(SLIDER_AD)}
allowRerender allowRerender
/> />

View File

@ -43,6 +43,13 @@
} }
} }
&--loading {
* {
opacity: 0.8;
pointer-events: none;
}
}
&__item { &__item {
&--no-results { &--no-results {
--card-size: $width-phone; --card-size: $width-phone;

View File

@ -11,9 +11,13 @@ interface IProps {
} }
export function Timestamp({ time, isRelative, className, children }: IProps) { export function Timestamp({ time, isRelative, className, children }: IProps) {
if (time === null) {
return;
}
const isClient = useClient(); const isClient = useClient();
let dateTime = new Date(time); let dateTime = new Date(time);
let formatted = `${dateTime.getFullYear()}-${dateTime.getMonth().toString().padStart(2, "0")}-${dateTime.getDay().toString().padStart(2, "0")}`; let formatted = `${dateTime.getFullYear()}-${(dateTime.getMonth() + 1).toString().padStart(2, "0")}-${dateTime.getDate().toString().padStart(2, "0")}`;
return ( return (
<time className={clsx("timestamp", className)} dateTime={time} title={time}> <time className={clsx("timestamp", className)} dateTime={time} title={time}>

View File

@ -147,6 +147,8 @@
& > .main { & > .main {
padding: 1em; padding: 1em;
display: flex;
flex: 1;
@media (max-width: $sidebar-min-width) { @media (max-width: $sidebar-min-width) {
padding: 1em 0; padding: 1em 0;
@ -239,6 +241,7 @@
} }
} }
.transition-preload * { .disable-transitions,
.disable-transitions * {
transition: none !important; transition: none !important;
} }

View File

@ -1,8 +1,9 @@
import clsx from "clsx"; import clsx from "clsx";
import { Fragment, useEffect, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { HelmetProvider } from "react-helmet-async"; import { HelmetProvider } from "@dr.pogodin/react-helmet";
import { Link, Outlet, ScrollRestoration, useLocation } from "react-router"; import { Link, Outlet, ScrollRestoration, useLocation } from "react-router";
import { import {
ANNOUNCEMENT_BANNER_GLOBAL,
ARTISTS_OR_CREATORS, ARTISTS_OR_CREATORS,
BANNER_GLOBAL, BANNER_GLOBAL,
DISABLE_DMS, DISABLE_DMS,
@ -20,7 +21,6 @@ import {
import { fetchHasPendingDMs } from "#api/dms"; import { fetchHasPendingDMs } from "#api/dms";
import { getLocalStorageItem, setLocalStorageItem } from "#storage/local"; import { getLocalStorageItem, setLocalStorageItem } from "#storage/local";
import { ClientProvider } from "#hooks"; import { ClientProvider } from "#hooks";
import { LoadingIcon } from "#components/loading";
import { isRegisteredAccount } from "#entities/account"; import { isRegisteredAccount } from "#entities/account";
import { NavEntry, NavItem, NavList, type INavItem } from "./sidebar"; import { NavEntry, NavItem, NavList, type INavItem } from "./sidebar";
import { GlobalFooter } from "./footer"; import { GlobalFooter } from "./footer";
@ -32,11 +32,13 @@ interface ILayoutProps {
interface IGlobalBodyProps extends ILayoutProps { interface IGlobalBodyProps extends ILayoutProps {
isSidebarClosed: boolean; isSidebarClosed: boolean;
closeSidebar: (_?: any, setState?: boolean) => void; closeSidebar: (_?: any, setState?: boolean) => void;
noAnim: boolean;
} }
interface IGlobalSidebarProps { interface IGlobalSidebarProps {
isSidebarClosed: boolean; isSidebarClosed: boolean;
closeSidebar: (_?: any, setState?: boolean) => void; closeSidebar: (_?: any, setState?: boolean) => void;
noAnim: boolean;
} }
interface IHeaderLinkProps { interface IHeaderLinkProps {
@ -45,75 +47,97 @@ interface IHeaderLinkProps {
className?: string; className?: string;
} }
const SIDEBAR_MIN_WIDTH = 1020; // match to $sidebar-min-width
/** /**
* TODO: Matomo integration * TODO: Matomo integration
*/ */
export function Layout() { export function Layout() {
const [isSidebarClosed, switchSidebar] = useState<boolean>(true); const location = useLocation();
const isMobileLayout = useRef(window.innerWidth <= SIDEBAR_MIN_WIDTH);
useEffect(() => { const [forceNoAnim, setForceNoAnim] = useState(false);
document.body.firstElementChild!.classList.remove("transition-preload"); const [isSidebarClosed, switchSidebar] = useState<boolean>(() => {
const sidebarState = getLocalStorageItem("sidebar_state"); const sidebarState = getLocalStorageItem("sidebar_state");
killAnimations(); // mobile devices should always have the sidebar closed due to inconvenience
switchSidebar(sidebarState === "true"); if (window.innerWidth <= SIDEBAR_MIN_WIDTH) return true;
// keep open unless user closed it
if (typeof sidebarState !== "string") {
return false;
}
return sidebarState === "true";
});
// check if location changed, if so and we're on mobile, close the sidebar
useEffect(() => {
if (isMobileLayout.current) {
switchSidebar(true);
}
}, [location.pathname]);
useEffect(() => {
if (!forceNoAnim) return;
// kill transitions after first actual frame got rendered
const animFrame = requestAnimationFrame(() => {
setForceNoAnim(false);
});
return () => {
cancelAnimationFrame(animFrame);
};
}, [forceNoAnim]);
useEffect(() => {
function onResize() {
if (window.innerWidth > SIDEBAR_MIN_WIDTH) {
// due to code below, check if the current sidebar state in local storage mismatches
const sidebarState = getLocalStorageItem("sidebar_state");
if (((typeof sidebarState !== "string") || sidebarState === "false") && isSidebarClosed) {
// kill animations, and open sidebar
setForceNoAnim(true);
switchSidebar(false);
}
isMobileLayout.current = false;
return;
}
if (isMobileLayout.current) return;
// only trigger once
isMobileLayout.current = true;
// window was rezied below SIDEBAR_MIN_WIDTH, force no animation else it'll briefly appear (minor)
// also close sidebar, mobile devices should always have the sidebar closed
setForceNoAnim(true);
switchSidebar(true);
}
window.addEventListener("resize", onResize); window.addEventListener("resize", onResize);
return () => { return () => {
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
}; };
}, []); }, [isSidebarClosed]);
function closeSidebar(_?: unknown, setState = true) { function closeSidebar(_?: unknown, setState = true) {
if (setState && window.innerWidth > 1020) { // don't save state for mobile devices, as it should always be closed
if (setState && window.innerWidth > SIDEBAR_MIN_WIDTH) {
setLocalStorageItem("sidebar_state", !isSidebarClosed ? "true" : "false"); setLocalStorageItem("sidebar_state", !isSidebarClosed ? "true" : "false");
} }
switchSidebar((isClosed) => !isClosed); switchSidebar((isClosed) => !isClosed);
} }
function onResize() {
const sidebarState = getLocalStorageItem("sidebar_state");
if (typeof sidebarState !== "string") {
return;
}
const isTrue = sidebarState === "true";
if (window.innerWidth <= 1020) {
if (isTrue && isSidebarClosed) {
killAnimations();
closeSidebar(null, false);
}
} else if (isTrue && !isSidebarClosed) {
killAnimations();
closeSidebar();
}
}
function killAnimations() {
document.body.firstElementChild!.classList.add("transition-preload");
requestAnimationFrame(() =>
setInterval(() =>
document.body.firstElementChild!.classList.remove("transition-preload")
)
);
}
return ( return (
<ClientProvider> <ClientProvider>
<HelmetProvider> <HelmetProvider>
<GlobalSidebar <GlobalSidebar
isSidebarClosed={isSidebarClosed} isSidebarClosed={isSidebarClosed}
closeSidebar={closeSidebar} closeSidebar={closeSidebar}
noAnim={forceNoAnim}
/> />
<GlobalBody <GlobalBody
isSidebarClosed={isSidebarClosed} isSidebarClosed={isSidebarClosed}
closeSidebar={closeSidebar} closeSidebar={closeSidebar}
noAnim={forceNoAnim}
> >
<Outlet /> <Outlet />
</GlobalBody> </GlobalBody>
@ -124,7 +148,7 @@ export function Layout() {
); );
} }
function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) { function GlobalSidebar({ isSidebarClosed, closeSidebar, noAnim }: IGlobalSidebarProps) {
const navListItems: INavItem[][] = [ const navListItems: INavItem[][] = [
[ [
{ {
@ -197,13 +221,14 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
], ],
]; ];
const globalSidebarClassName = clsx(
"global-sidebar",
isSidebarClosed ? "retracted" : "expanded"
);
return ( return (
<div className={globalSidebarClassName}> <div className={clsx(
"global-sidebar",
isSidebarClosed ? "retracted" : "expanded",
{
"disable-transitions": noAnim,
}
)}>
<NavEntry className="clickable-header-entry"> <NavEntry className="clickable-header-entry">
<NavItem <NavItem
link="/" link="/"
@ -231,13 +256,12 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
*/ */
function AccountEntry() { function AccountEntry() {
const location = useLocation(); const location = useLocation();
const [isLoggedIn, switchLoggedIn] = useState(false); const isLoggedIn = isRegisteredAccount();
const [isLoading, switchLoading] = useState<boolean>(true);
const [isPendingDMsForReview, switchPendingDMsForReview] = useState(false); const [isPendingDMsForReview, switchPendingDMsForReview] = useState(false);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
if (!isRegistered) { if (!isRegistered) {
return; return;
@ -248,19 +272,6 @@ function AccountEntry() {
})(); })();
}, []); }, []);
useEffect(() => {
(async () => {
try {
switchLoading(true);
const isRegistered = await isRegisteredAccount();
switchLoggedIn(isRegistered);
} finally {
switchLoading(false);
}
})();
}, [location]);
const loggedOutEntries: INavItem[] = [ const loggedOutEntries: INavItem[] = [
{ {
header: true, header: true,
@ -316,9 +327,7 @@ function AccountEntry() {
}, },
]; ];
return isLoading ? ( return (
<LoadingIcon />
) : (
<NavEntry <NavEntry
items={isLoggedIn ? loggedInEntries : loggedOutEntries} items={isLoggedIn ? loggedInEntries : loggedOutEntries}
className="account" className="account"
@ -329,15 +338,11 @@ function AccountEntry() {
function GlobalBody({ function GlobalBody({
isSidebarClosed, isSidebarClosed,
closeSidebar, closeSidebar,
noAnim,
children, children,
}: IGlobalBodyProps) { }: IGlobalBodyProps) {
const location = useLocation(); const location = useLocation();
const [isLoggedIn, switchLoggedIn] = useState(false); const isLoggedIn = isRegisteredAccount();
const [isLoading, switchLoading] = useState<boolean>(true);
const contentWrapperClassName = clsx(
"content-wrapper",
!isSidebarClosed && "shifted"
);
const backdropClassName = clsx( const backdropClassName = clsx(
"backdrop", "backdrop",
isSidebarClosed && "backdrop-hidden" isSidebarClosed && "backdrop-hidden"
@ -347,21 +352,14 @@ function GlobalBody({
isSidebarClosed && "sidebar-retracted" isSidebarClosed && "sidebar-retracted"
); );
useEffect(() => {
(async () => {
try {
switchLoading(true);
const isRegistered = await isRegisteredAccount();
switchLoggedIn(isRegistered);
} finally {
switchLoading(false);
}
})();
}, [location]);
return ( return (
<div className={contentWrapperClassName}> <div className={clsx(
"content-wrapper",
{
"shifted": !isSidebarClosed,
"disable-transitions": noAnim,
}
)}>
<div className={backdropClassName} onClick={closeSidebar} /> <div className={backdropClassName} onClick={closeSidebar} />
<div className={headerClassName}> <div className={headerClassName}>
@ -372,9 +370,7 @@ function GlobalBody({
<HeaderLink url="/artists" text={ARTISTS_OR_CREATORS} /> <HeaderLink url="/artists" text={ARTISTS_OR_CREATORS} />
<HeaderLink url="/posts" text="Posts" /> <HeaderLink url="/posts" text="Posts" />
<HeaderLink url="/importer" text="Import" className="import" /> <HeaderLink url="/importer" text="Import" className="import" />
{isLoading ? ( {isLoggedIn ? (
<LoadingIcon />
) : isLoggedIn ? (
<> <>
<HeaderLink <HeaderLink
url={String(createAccountFavoriteProfilesPageURL())} url={String(createAccountFavoriteProfilesPageURL())}
@ -399,7 +395,15 @@ function GlobalBody({
</div> </div>
{BANNER_GLOBAL && ( {BANNER_GLOBAL && (
<div id="ad-banner">
<aside dangerouslySetInnerHTML={{ __html: atob(BANNER_GLOBAL) }} /> <aside dangerouslySetInnerHTML={{ __html: atob(BANNER_GLOBAL) }} />
</div>
)}
{ANNOUNCEMENT_BANNER_GLOBAL && (
<div id="announcement-banner">
<aside dangerouslySetInnerHTML={{ __html: atob(ANNOUNCEMENT_BANNER_GLOBAL) }} />
</div>
)} )}
<main className="main" id="main"> <main className="main" id="main">

View File

@ -10,4 +10,3 @@ export type {
IDescriptionTermProps, IDescriptionTermProps,
IDescriptionDetailsProps, IDescriptionDetailsProps,
} from "./description"; } from "./description";
export { List, ListUnordered, ListOrdered, ListItem } from "./list";

View File

@ -1,5 +0,0 @@
@use "../../css/config/variables/sass" as *;
.ordered {
list-style-type: decimal-leading-zero;
}

View File

@ -1,63 +0,0 @@
import { forwardRef, LegacyRef } from "react";
import { createBlockComponent, IBlockProps } from "#components/meta";
import * as styles from "./list.module.scss"
export type IListProps =
| ({ isOrdered: true } & IListOrderedProps)
| IListUnorderedProps;
interface IListUnorderedProps extends IBlockProps<"ul"> {}
interface IListOrderedProps extends IBlockProps<"ol"> {}
interface IListItemProps extends IBlockProps<"li"> {}
export const List = forwardRef<HTMLUListElement | HTMLOListElement, IListProps>(
createBlockComponent(undefined, ListComponent)
);
export const ListUnordered = forwardRef<HTMLUListElement, IListUnorderedProps>(
createBlockComponent(undefined, ListUnorderedComponent)
);
export const ListOrdered = forwardRef<HTMLOListElement, IListOrderedProps>(
createBlockComponent(styles.ordered, ListOrderedComponent)
);
export const ListItem = forwardRef<HTMLLIElement, IListItemProps>(
createBlockComponent(undefined, ListItemComponent)
);
function ListComponent(
props: IListProps,
ref: LegacyRef<HTMLUListElement | HTMLOListElement>
) {
if ("isOrdered" in props) {
const { isOrdered, ...restProps } = props;
return (
<ListOrdered ref={ref as LegacyRef<HTMLOListElement>} {...restProps} />
);
}
return <ListUnordered ref={ref} {...props} />;
}
function ListUnorderedComponent(
{ ...props }: IListUnorderedProps,
ref: LegacyRef<HTMLUListElement>
) {
return <ul ref={ref} {...props} />;
}
function ListOrderedComponent(
{ ...props }: IListOrderedProps,
ref: LegacyRef<HTMLOListElement>
) {
return <ol ref={ref} {...props} />;
}
function ListItemComponent(
{ ...props }: IListItemProps,
ref: LegacyRef<HTMLLIElement>
) {
return <li ref={ref} {...props} />;
}

View File

@ -9,7 +9,7 @@ export function createAccountPageLoader(
loader?: LoaderFunction loader?: LoaderFunction
): LoaderFunction { ): LoaderFunction {
return async (args: LoaderFunctionArgs) => { return async (args: LoaderFunctionArgs) => {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
if (!isRegistered) { if (!isRegistered) {
throw new Error("You must be registered to access this page."); throw new Error("You must be registered to access this page.");
@ -26,7 +26,7 @@ export function createAccountPageLoader(
export async function validateAccountPageLoader( export async function validateAccountPageLoader(
...args: Parameters<LoaderFunction> ...args: Parameters<LoaderFunction>
): Promise<void> { ): Promise<void> {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
if (!isRegistered) { if (!isRegistered) {
throw new Error("You must be registered to access this page."); throw new Error("You must be registered to access this page.");
@ -36,7 +36,7 @@ export async function validateAccountPageLoader(
export async function validateAccountPageAction( export async function validateAccountPageAction(
...args: Parameters<ActionFunction> ...args: Parameters<ActionFunction>
): Promise<void> { ): Promise<void> {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
if (!isRegistered) { if (!isRegistered) {
throw new Error("You must be registered to access this page."); throw new Error("You must be registered to access this page.");

View File

@ -1,4 +1,4 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { ICONS_PREPEND, KEMONO_SITE } from "#env/env-vars"; import { ICONS_PREPEND, KEMONO_SITE } from "#env/env-vars";
import { IArtistDetails } from "#entities/profiles"; import { IArtistDetails } from "#entities/profiles";
import { IPageProps, PageSkeleton } from "./site"; import { IPageProps, PageSkeleton } from "./site";

View File

@ -2,6 +2,7 @@
.site-section { .site-section {
margin: 0 auto; margin: 0 auto;
width: 100%;
&__header { &__header {
padding: 0 0 $size-little; padding: 0 0 $size-little;

View File

@ -1,6 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react"; import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { SITE_NAME } from "#env/env-vars"; import { SITE_NAME } from "#env/env-vars";
export interface IPageProps export interface IPageProps

View File

@ -1 +1,2 @@
@use "./paginator_new"; @use "./paginator_new";
@use "./paginator.scss";

View File

@ -0,0 +1,61 @@
.paginator {
text-align: center;
}
#paginator-bottom {
margin-bottom: env(safe-area-inset-bottom);
display: flex;
flex-direction: column-reverse;
}
.paginator menu {
padding: 0;
margin: 5px auto;
display: table;
& > li,
& > a {
border: 1px solid var(--colour0-tertirary);
display: table-cell;
line-height: 33px;
color: var(--colour0-secondary);
user-select: none;
cursor: pointer;
padding: 0;
min-width: 35px;
@media (max-width: 600px) {
&.pagination-button-optional {
display: none;
}
}
&.pagination-button-disabled {
color: var(--colour0-tertirary);
background-color: unset;
cursor: default;
pointer-events: none;
}
&.pagination-button-current {
background-color: var(--anchour-internal-colour2-primary);
color: var(--anchour-internal-colour1-secondary);
border-color: var(--anchour-internal-colour1-primary);
}
&.pagination-button-after-current {
border-left: 1px solid var(--anchour-internal-colour1-primary);
}
&:not(.pagination-button-disabled):hover,
&:not(.pagination-button-disabled):focus,
&:not(.pagination-button-disabled):active {
background-color: var(--colour0-tertirary);
color: var(--colour0-primary);
}
& > b {
padding: 0 9px;
}
&:not(:last-child) {
border-right: none;
}
}
}

View File

@ -1,6 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { createRange } from "#lib/range";
import { PAGINATION_LIMIT } from "#lib/pagination"; import { PAGINATION_LIMIT } from "#lib/pagination";
import { KemonoLink } from "#components/links"; import { KemonoLink } from "#components/links";
@ -12,7 +11,7 @@ interface IProps {
} }
interface IPaginatorButtonProps { interface IPaginatorButtonProps {
href?: string; href: string;
className?: string; className?: string;
children: ReactNode; children: ReactNode;
} }
@ -23,48 +22,52 @@ export function Paginator({
offset = 0, offset = 0,
constructURL, constructURL,
}: IProps) { }: IProps) {
// there are less than 50 items, so we don't need to show pagination
if (count < PAGINATION_LIMIT) return null;
const limit = PAGINATION_LIMIT; const limit = PAGINATION_LIMIT;
const skip = offset; const skip = offset;
// is for displaying Showing X - Y of Z, where Y is the current ceiling of range
const currentCeilingOfRange = skip + limit < count ? skip + limit : count; const currentCeilingOfRange = skip + limit < count ? skip + limit : count;
const totalButtons = 5; // number of buttons shown before and after the current page number
const optionalButtons = totalButtons - 2; const buttonsPerSide = 5;
const mandatoryButtons = totalButtons - optionalButtons; // 5 buttons before, 5 buttons after, 1 current page button
const totalButtonCount = buttonsPerSide * 2 + 1;
// number of buttons that can be hidden for mobile view
const optionalButtons = buttonsPerSide - 2;
// number of buttons that are always shown no matter what
const mandatoryButtons = buttonsPerSide - optionalButtons;
const currentPageNumber = Math.ceil((skip + limit) / limit); const currentPageNumber = Math.ceil((skip + limit) / limit);
const totalPages = Math.ceil(count / limit); const totalPages = Math.ceil(count / limit);
const numberBeforeCurrentPage =
totalPages < totalButtons || currentPageNumber < totalButtons // left most button is base page number
? currentPageNumber - 1 // we show 5 buttons before and 5 after the current page number!
: totalPages - currentPageNumber < totalButtons // we move the buttons around if they don't fit in left or right side of the current page number
? totalButtons - 1 + (totalButtons - (totalPages - currentPageNumber)) // but the total number of buttons is always the same, aka 10 buttons
: totalButtons - 1;
const basePageNumber = Math.max( const basePageNumber = Math.max(
currentPageNumber - numberBeforeCurrentPage - 1, totalPages < buttonsPerSide ? 1 // less pages than buttons, start at page 1
: currentPageNumber < buttonsPerSide ? 1 // current page number is less than num of buttons to the left, start at page 1
: (totalPages - currentPageNumber) < buttonsPerSide // do we have more buttons than pages after the current page number? (nearing the end of the list)
? currentPageNumber - buttonsPerSide - (buttonsPerSide - (totalPages - currentPageNumber)) // show 5 buttons, + how many are missing after current page
: currentPageNumber - buttonsPerSide, // show 5 buttons before the current page number
1 1
); );
const showFirstPostsButton = basePageNumber > 1;
const showLastPostsButton = // calculate which base page number starts being optional (usually current page number - 2)
currentPageNumber - basePageNumber <
totalButtons +
(currentPageNumber - basePageNumber < totalButtons
? totalButtons - (currentPageNumber + basePageNumber)
: 0);
const optionalBeforeButtons = const optionalBeforeButtons =
currentPageNumber - currentPageNumber -
mandatoryButtons - mandatoryButtons - // anything before curr page - mandatory buttons is optional
(totalPages - currentPageNumber < mandatoryButtons (totalPages - currentPageNumber < mandatoryButtons // however, we have to check for if we are nearing the end of num of pages, example page 13 of 14
? mandatoryButtons - (totalPages - currentPageNumber) ? mandatoryButtons - (totalPages - currentPageNumber) // since we are nearing the end, we will subtract how many mandatory buttons are now missing on the right side
: 0); : 0);
// calculate which base page number starts being optional (usually current page number + 2, an inverted version of the above)
const optionalAfterButtons = const optionalAfterButtons =
currentPageNumber + currentPageNumber +
mandatoryButtons + mandatoryButtons +
(currentPageNumber - basePageNumber < mandatoryButtons (currentPageNumber - basePageNumber < mandatoryButtons
? mandatoryButtons - (currentPageNumber - basePageNumber) ? mandatoryButtons - (currentPageNumber - basePageNumber)
: 0); : 0);
const range = createRange(0, totalButtons * 2 + 1);
if (!(count > limit)) {
return undefined;
}
return ( return (
<> <>
@ -73,139 +76,62 @@ export function Paginator({
</small> </small>
<menu> <menu>
{!( {
showFirstPostsButton || showLastPostsButton totalPages > totalButtonCount ?
) ? undefined : showFirstPostsButton ? (
<PaginatorButton href={constructURL(0)}>{"<<"}</PaginatorButton>
) : (
<PaginatorButton <PaginatorButton
className={clsx( className={clsx({ "pagination-button-disabled": currentPageNumber === 1 })}
"pagination-button-disabled", href={constructURL(0)}
currentPageNumber - mandatoryButtons - 1 && "pagination-desktop"
)}
> >
{"<<"} {"<<"}
</PaginatorButton> </PaginatorButton>
)} : null
}
{showFirstPostsButton ? undefined : currentPageNumber -
mandatoryButtons -
1 ? (
<PaginatorButton className="pagination-mobile" href={constructURL(0)}>
{"<<"}
</PaginatorButton>
) : totalPages - currentPageNumber > mandatoryButtons &&
!showLastPostsButton ? (
<PaginatorButton <PaginatorButton
className={clsx("pagination-button-disabled", "pagination-mobile")} className={clsx({ "pagination-button-disabled": currentPageNumber === 1 })}
>
{"<<"}
</PaginatorButton>
) : undefined}
{currentPageNumber > 1 ? (
<PaginatorButton
className="prev"
href={constructURL((currentPageNumber - 2) * limit)} href={constructURL((currentPageNumber - 2) * limit)}
> >
{"<"} {"<"}
</PaginatorButton> </PaginatorButton>
) : ( {
<PaginatorButton className="pagination-button-disabled"> Array.from({ length: buttonsPerSide * 2 + 1 }, (_, index) => {
{"<"} const pageNum = index + basePageNumber;
</PaginatorButton> if (pageNum > totalPages) return null; // don't show more than total pages
)}
{range.reduce<JSX.Element[]>((buttons, page, index) => { return (
if (page + basePageNumber && page + basePageNumber <= totalPages) {
const localOffset =
page + basePageNumber !== 1
? (page + basePageNumber - 1) * limit
: 0;
const buttonClassName =
page + basePageNumber < optionalBeforeButtons ||
(page + basePageNumber > optionalAfterButtons &&
page + basePageNumber != currentPageNumber)
? "pagination-button-optional"
: page + basePageNumber == currentPageNumber
? clsx(
"pagination-button-disabled",
"pagination-button-current"
)
: page + basePageNumber == currentPageNumber + 1
? "pagination-button-after-current"
: undefined;
const button = (
<PaginatorButton <PaginatorButton
key={index} key={index}
href={ href={constructURL((pageNum - 1) * limit)}
page + basePageNumber === currentPageNumber className={clsx({
? undefined "pagination-button-disabled": pageNum === currentPageNumber,
: constructURL(localOffset) "pagination-button-current": pageNum === currentPageNumber,
} "pagination-button-after-current": pageNum === currentPageNumber + 1,
className={clsx(buttonClassName)} "pagination-button-optional": pageNum < optionalBeforeButtons || pageNum > optionalAfterButtons
})}
> >
{page + basePageNumber} {pageNum}
</PaginatorButton> </PaginatorButton>
); );
})
buttons.push(button);
} }
return buttons;
}, [])}
{currentPageNumber < totalPages ? (
<PaginatorButton <PaginatorButton
className="next" className={clsx({
"pagination-button-disabled": currentPageNumber === totalPages,
"pagination-button-after-current": currentPageNumber === totalPages
})}
href={constructURL(currentPageNumber * limit)} href={constructURL(currentPageNumber * limit)}
> >
{">"} {">"}
</PaginatorButton> </PaginatorButton>
) : ( {
<PaginatorButton totalPages > totalButtonCount ?
className={clsx(
"pagination-button-disabled",
totalPages && " pagination-button-after-current"
)}
>
{">"}
</PaginatorButton>
)}
{!(
showFirstPostsButton || showLastPostsButton
) ? undefined : showLastPostsButton ? (
<PaginatorButton href={constructURL((totalPages - 1) * limit)}>
{">>"}
</PaginatorButton>
) : (
<PaginatorButton
className={clsx(
"pagination-button-disabled",
totalPages - currentPageNumber > mandatoryButtons &&
"pagination-desktop"
)}
>
{">>"}
</PaginatorButton>
)}
{showLastPostsButton ? undefined : totalPages - currentPageNumber >
mandatoryButtons ? (
<PaginatorButton <PaginatorButton
className={clsx({ "pagination-button-disabled": currentPageNumber === totalPages })}
href={constructURL((totalPages - 1) * limit)} href={constructURL((totalPages - 1) * limit)}
className="pagination-mobile"
> >
{">>"} {">>"}
</PaginatorButton> </PaginatorButton>
) : currentPageNumber > optionalButtons && !showFirstPostsButton ? ( : null
<PaginatorButton }
className={clsx("pagination-button-disabled", "pagination-mobile")}
>
{">>"}
</PaginatorButton>
) : undefined}
</menu> </menu>
</> </>
); );

View File

@ -1,3 +1,5 @@
@use "../css/config/variables/sass" as *;
/* /*
TODO: Spread the styles around page/component/block files. TODO: Spread the styles around page/component/block files.
*/ */
@ -279,81 +281,6 @@
max-width: 100%; max-width: 100%;
} }
/* pagination */
.paginator {
text-align: center;
}
.paginator menu {
padding: 0;
margin: 5px auto;
display: table;
& > li,
& > a {
border: 1px solid var(--colour0-tertirary);
display: table-cell;
line-height: 33px;
color: var(--colour0-secondary);
user-select: none;
cursor: pointer;
padding: 0;
min-width: 35px;
transition-property: color background-color;
@media (min-width: #{600px + 1}) {
&.pagination-mobile:not(:last-child) {
display: none;
}
&.pagination-mobile:last-child {
min-width: unset;
border-right: none;
border-top: none;
border-bottom: none;
& > * {
display: none;
}
}
}
@media (max-width: 600px) {
&.pagination-button-optional {
display: none;
}
&.pagination-desktop {
display: none;
}
}
&.pagination-button-disabled {
color: var(--colour0-tertirary);
background-color: unset;
cursor: default;
}
&.pagination-button-current {
background-color: var(--anchour-internal-colour2-primary);
color: var(--anchour-internal-colour1-secondary);
border-color: var(--anchour-internal-colour1-primary);
}
&.pagination-button-after-current {
border-left: 1px solid var(--anchour-internal-colour1-primary);
}
&:not(.pagination-button-disabled):hover,
&:not(.pagination-button-disabled):focus,
&:not(.pagination-button-disabled):active {
background-color: var(--colour0-tertirary);
color: var(--colour0-primary);
}
& > b {
padding: 0 9px;
}
&:not(:last-child) {
border-right: none;
}
}
}
menu li { menu li {
display: inline; display: inline;
list-style-type: none; list-style-type: none;
@ -377,46 +304,87 @@ menu li {
.sidebar { .sidebar {
width: auto; width: auto;
} }
#paginator-bottom {
margin-bottom: env(safe-area-inset-bottom);
}
} }
/* search forms */ /* search forms */
.search-form { .search-form {
display: table; display: flex;
padding: 0.5rem; flex-direction: column;
margin-left: 5px; align-items: stretch;
padding: 8px;
background-color: #282a2e; background-color: #282a2e;
margin: 0px auto 8px auto; margin: 0px auto 8px auto;
&-hidden { & .wrapper {
display: none; display: flex;
&:not(:last-of-type) {
margin-bottom: 8px;
}
} }
& > div { & img {
display: table-row; width: 24px;
line-height: 1.5em; height: 24px;
margin-bottom: 2em;
} }
& small { & input,
display: block; & select {
line-height: normal; width: 100%;
height: 32px;
&:focus-visible {
outline: none;
}
}
& button {
width: 32px;
height: 32px;
padding: 0px;
min-width: 32px;
min-height: unset;
box-shadow: none;
display: flex;
justify-content: center;
align-items: center;
&.search-button {
background: var(--colour1-secondary);
color: var(--colour0-primary);
border-top-left-radius: 0px;
border-bottom-left-radius: 0px;
}
} }
& label,
& input { & input {
display: table-cell; padding-right: 0px;
padding-right: 1em; box-shadow: none;
white-space: nowrap; border-top-right-radius: 0px;
text-align: left; border-bottom-right-radius: 0px;
padding-left: 8px;
width: 500px;
} }
& label { & .sort_dir {
text-align: right; background-image: linear-gradient(#282a2e, #111111);
font-weight: 700;
& > .asc {
transform: scaleY(-1);
}
}
& select {
color: var(--colour0-primary);
option {
color: var(--colour0-primary);
}
}
& .sort_by {
margin: 0 8px;
} }
} }
@ -449,9 +417,11 @@ thead th {
} }
.ad-container { .ad-container {
text-align: center; display: flex;
justify-content: center;
padding: 12px 0px;
} }
.ad-container * { .ad-container-slider {
max-width: 100%; display: none;
} }

View File

@ -46,6 +46,6 @@ export async function logoutAccount(isLocalOnly?: boolean) {
return true; return true;
} }
export async function isRegisteredAccount() { export function isRegisteredAccount() {
return getLocalStorageItem("logged_in") === "yes"; return getLocalStorageItem("logged_in") === "yes";
} }

View File

@ -38,7 +38,7 @@ export async function isFavouritePost(
export async function findFavouritePosts( export async function findFavouritePosts(
postsData: IPostData[] postsData: IPostData[]
): Promise<IPostData[]> { ): Promise<IPostData[]> {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
// return early for non-registered users // return early for non-registered users
// instead of rewriting all flows depending on it // instead of rewriting all flows depending on it

View File

@ -13,7 +13,7 @@ import {
import { IFavouriteArtist } from "../types"; import { IFavouriteArtist } from "../types";
import { isRegisteredAccount } from "./auth"; import { isRegisteredAccount } from "./auth";
interface IProfileData extends Pick<IFavouriteArtist, "service" | "id"> {} interface IProfileData extends Pick<IFavouriteArtist, "service" | "id"> { }
let favouriteProfiles: let favouriteProfiles:
| Awaited<ReturnType<typeof fetchFavouriteProfiles>> | Awaited<ReturnType<typeof fetchFavouriteProfiles>>
@ -28,7 +28,7 @@ export async function isFavouriteProfile(service: string, profileID: string) {
export async function findFavouriteProfiles( export async function findFavouriteProfiles(
profilesData: IProfileData[] profilesData: IProfileData[]
): Promise<IProfileData[]> { ): Promise<IProfileData[]> {
const isRegistered = await isRegisteredAccount(); const isRegistered = isRegisteredAccount();
// return early for non-registered users // return early for non-registered users
// instead of rewriting all flows depending on it // instead of rewriting all flows depending on it

View File

@ -1,117 +0,0 @@
import { IS_FILE_SERVING_ENABLED } from "#env/env-vars";
import { createArchiveFileURL } from "#lib/urls";
import { createBlockComponent } from "#components/meta";
import { Details } from "#components/details";
import { FormRouter } from "#components/forms";
import { FormSectionText } from "#components/forms/sections";
import { KemonoLink } from "#components/links";
import {
DescriptionList,
DescriptionSection,
List,
ListItem,
} from "#components/lists";
import {
Overview,
OverviewHeader,
OverviewBody,
IOverviewProps,
} from "#components/overviews";
import { IArchiveFile } from "./types";
interface IProps extends IOverviewProps {
archiveFile: IArchiveFile;
}
export const ArchiveFileOverview = createBlockComponent(undefined, Component);
function Component({ id, archiveFile, ...props }: IProps) {
const { file, file_list, password } = archiveFile;
const { hash } = file;
const formID = `${id}-update`;
return (
<Overview id={id} {...props}>
<OverviewHeader>
{/* TODO: replace with a heading component */}
<h2>{file.hash}</h2>
{password ? (
<DescriptionList>
<DescriptionSection
dKey="Password"
dValue={password}
isValuePreformatted
/>
</DescriptionList> ? (
// empty string means it needs password because reasons
password === ""
) : (
<div>
<p>Archive needs a password, but none was provided.</p>
<Details summary="Provide password">
<FormRouter
action={`/file/${hash}`}
method="PATCH"
submitButton={() => "Send"}
>
<FormSectionText
id={`${formID}-password`}
name="password"
label="Password"
/>
</FormRouter>
</Details>
</div>
)
) : undefined}
</OverviewHeader>
<OverviewBody>
{file_list.length === 0 ? (
<>Archive is empty or missing password.</>
) : (
<List isOrdered>
{file_list.map((filename, index) => (
<FileItem
key={`${filename}-${index}`}
name={filename}
archiveHash={file.hash}
archiveExtension={file.ext}
/>
))}
</List>
)}
</OverviewBody>
</Overview>
);
}
interface IFileItemProps {
name: string;
archiveHash: string;
archiveExtension: string;
password?: string;
}
function FileItem({
name,
archiveHash,
archiveExtension,
password,
}: IFileItemProps) {
return (
<ListItem>
{!IS_FILE_SERVING_ENABLED ? (
name
) : (
<KemonoLink
url={String(
createArchiveFileURL(archiveHash, archiveExtension, name, password)
)}
>
{name}
</KemonoLink>
)}
</ListItem>
);
}

View File

@ -1,8 +1,6 @@
export { ArchiveFileOverview } from "./archive-overview";
export type { export type {
IFile, IFile,
IFanCard, IFanCard,
IShare, IShare,
IShareFile, IShareFile,
IArchiveFile,
} from "./types"; } from "./types";

View File

@ -47,12 +47,3 @@ export interface IShareFile {
ext: string; ext: string;
added: string; added: string;
} }
export interface IArchiveFile {
password?: string;
file: {
hash: string;
ext: string;
};
file_list: string[];
}

View File

@ -13,6 +13,7 @@ import { cleanupBody } from "./clean-body";
import { IPostOverviewProps } from "./types"; import { IPostOverviewProps } from "./types";
import { PostVideo } from "./video"; import { PostVideo } from "./video";
import { FlagButton } from "./flag-button"; import { FlagButton } from "./flag-button";
import { KemonoLink } from "#components/links";
import * as styles from "./body.module.scss"; import * as styles from "./body.module.scss";
@ -153,8 +154,8 @@ function PostAttachment({
</a> </a>
{isArchiveFile && ( {isArchiveFile && (
<> <>
{/* TODO: a proper URL function */}( {" "}(
<a href={`/posts/archives/${stem}`}>browse »</a>) <KemonoLink url={`/posts/archives/${stem}`}>browse »</KemonoLink>)
</> </>
)} )}
</li> </li>

View File

@ -172,12 +172,7 @@ function PostComment({
{service !== "boosty" ? ( {service !== "boosty" ? (
content content
) : ( ) : (
<Preformatted className={styles.content}> <Preformatted className={styles.content} dangerouslySetInnerHTML={{__html: content}}/>
{content.replace(
'/size/large" title="',
'/size/small" title="'
)}
</Preformatted>
)} )}
</p> </p>
</Preformatted> </Preformatted>

View File

@ -207,7 +207,7 @@ function FavoriteButton({ service, profileID, postID }: IFavoriteButtonProps) {
try { try {
switchLoading(true); switchLoading(true);
const isLoggedIn = await isRegisteredAccount(); const isLoggedIn = isRegisteredAccount();
if (!isLoggedIn) { if (!isLoggedIn) {
return; return;

View File

@ -108,7 +108,7 @@ function FavouriteButton({ service, profileID }: IFavouriteButtonProps) {
(async () => { (async () => {
try { try {
switchLoading(true); switchLoading(true);
const isLoggedIn = await isRegisteredAccount(); const isLoggedIn = isRegisteredAccount();
if (!isLoggedIn) { if (!isLoggedIn) {
return; return;

View File

@ -54,10 +54,7 @@ export async function getArtists({
const normalizedName = profile.name.trim().toLowerCase(); const normalizedName = profile.name.trim().toLowerCase();
const normalizedID = profile.id.trim().toLowerCase(); const normalizedID = profile.id.trim().toLowerCase();
return ( isQueryMatched = normalizedID.includes(normalizedQuery) || normalizedName.includes(normalizedQuery);
normalizedID.includes(normalizedQuery) ||
normalizedName.includes(normalizedQuery)
);
} }
return isServiceMatched && isQueryMatched; return isServiceMatched && isQueryMatched;

View File

@ -35,6 +35,11 @@ export const FOOTER_ITEMS = BUNDLER_ENV_FOOTER_ITEMS;
*/ */
export const BANNER_GLOBAL = BUNDLER_ENV_BANNER_GLOBAL; export const BANNER_GLOBAL = BUNDLER_ENV_BANNER_GLOBAL;
/**
* b64-encoded string
*/
export const ANNOUNCEMENT_BANNER_GLOBAL = BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL;
/** /**
* b64-encoded string * b64-encoded string
*/ */
@ -106,6 +111,7 @@ declare global {
const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[]; const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[];
const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | undefined; const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | undefined;
const BUNDLER_ENV_BANNER_GLOBAL: string | undefined; const BUNDLER_ENV_BANNER_GLOBAL: string | undefined;
const BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL: string | undefined;
const BUNDLER_ENV_BANNER_WELCOME: string | undefined; const BUNDLER_ENV_BANNER_WELCOME: string | undefined;
const BUNDLER_ENV_HOME_BACKGROUND_IMAGE: string | undefined; const BUNDLER_ENV_HOME_BACKGROUND_IMAGE: string | undefined;
const BUNDLER_ENV_HOME_MASCOT_PATH: string | undefined; const BUNDLER_ENV_HOME_MASCOT_PATH: string | undefined;

View File

@ -11,11 +11,12 @@ jsonHeaders.append("Content-Type", "application/json");
* TODO: discriminated union with JSONable body signature * TODO: discriminated union with JSONable body signature
*/ */
interface IOptions extends Omit<RequestInit, "headers"> { interface IOptions extends Omit<RequestInit, "headers"> {
method: "GET" | "POST" | "DELETE"; method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
body?: any; body?: any;
headers?: Headers; headers?: Headers;
} }
// TODO: Make this not throw.
/** /**
* Generic request for Kemono API. * Generic request for Kemono API.
* @param path * @param path

View File

@ -1,22 +0,0 @@
/**
* Python [`range()`](https://docs.python.org/3/library/functions.html#func-range) but in javascript.
*/
export function createRange(start: number, stop: number, step = 1) {
if (step === 0) {
throw new RangeError("Step must not be equal to zero.");
}
const length = stop - start;
let currentValue = start;
// running `Array.fill()` because I don't remember off top of my head
// if `Array.map()` iterates over sparse values or not
const range = new Array(length).fill(null).map<number>(() => {
const oldCurrentValue = currentValue;
currentValue = currentValue + step;
return oldCurrentValue;
});
return range;
}

View File

@ -33,3 +33,8 @@
} }
} }
} }
.site-section--home {
display: flex;
flex-direction: column;
}

View File

@ -6,8 +6,9 @@ import { PageSkeleton } from "#components/pages";
import { HeaderAd, SliderAd } from "#components/advs"; import { HeaderAd, SliderAd } from "#components/advs";
import { Paginator } from "#components/pagination"; import { Paginator } from "#components/pagination";
import { CardList, DMCard } from "#components/cards"; import { CardList, DMCard } from "#components/cards";
import { FormRouter } from "#components/forms"; import { ButtonSubmit, FormRouter } from "#components/forms";
import { IApprovedDM } from "#entities/dms"; import { IApprovedDM } from "#entities/dms";
import { useRef, useState } from "react";
interface IProps { interface IProps {
query?: string; query?: string;
@ -18,6 +19,7 @@ interface IProps {
export function DMsPage() { export function DMsPage() {
const { query, count, dms, offset } = useLoaderData() as IProps; const { query, count, dms, offset } = useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const title = "DMs"; const title = "DMs";
const heading = "DMs"; const heading = "DMs";
@ -27,32 +29,18 @@ export function DMsPage() {
<HeaderAd /> <HeaderAd />
<div className="paginator" id="paginator-top"> <div className="paginator" id="paginator-top">
<SearchForm
query={query}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator <Paginator
count={count} count={count}
offset={offset} offset={offset}
constructURL={(offset) => String(createDMsPageURL(offset, query))} constructURL={(offset) => String(createDMsPageURL(offset, query))}
/> />
<FormRouter
action="/dms"
method="GET"
encType="application/x-www-form-urlencoded"
>
<input
id="q"
className="search-input"
type="text"
name="q"
autoComplete="off"
defaultValue={query}
minLength={3}
placeholder="search for dms..."
/>
<input type="submit" style={{ display: "none" }} />
</FormRouter>
</div> </div>
<CardList layout="phone"> <CardList layout="phone" className={isLoading ? "card-list--loading" : ""}>
{count === 0 ? ( {count === 0 ? (
<div className="no-results"> <div className="no-results">
<h2 className="site-section__subheading"> <h2 className="site-section__subheading">
@ -78,6 +66,69 @@ export function DMsPage() {
); );
} }
interface ISearchFormProps
extends Pick<IProps, "query"> {
onLoadingChange: (loading: boolean) => void;
}
function SearchForm({ query, onLoadingChange }: ISearchFormProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const target = e.currentTarget as HTMLInputElement;
onLoadingChange(true);
timeoutRef.current = setTimeout(() => {
if (target.form) target.form.requestSubmit();
onLoadingChange(false);
}, 1000);
};
return (
<FormRouter
id="search-form"
className="search-form"
method="GET"
autoComplete="off"
noValidate={true}
acceptCharset="UTF-8"
statusSection={null}
submitButton={undefined}
style={{ maxWidth: "fit-content" }}
>
{(state) => (
<>
<div className="wrapper">
<input
type="text"
name="q"
id="q"
autoComplete="off"
defaultValue={query}
minLength={3}
placeholder="Search..."
onChange={onInputChange}
/>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
className="search-button"
onClick={() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
onLoadingChange(false);
}}
>
<img src='/static/menu/search.svg' />
</ButtonSubmit>
</div>
</>
)}
</FormRouter>
);
}
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> { export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;

View File

@ -19,8 +19,8 @@ export function DMCAPage() {
</p> </p>
<p> <p>
Notices may be shared to 3rd party's for due diligence and transparency Notices may be shared to third parties for due diligence and transparency
purposes purposes.
</p> </p>
<p> <p>

View File

@ -1,4 +1,4 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import SwaggerUI from "swagger-ui-react"; import SwaggerUI from "swagger-ui-react";
import { PageSkeleton } from "#components/pages"; import { PageSkeleton } from "#components/pages";

View File

@ -1,6 +1,35 @@
@use "../../css/config/variables/sass.scss" as *; @use "../../css/config/variables/sass.scss" as *;
.article { .article {
// TODO: remove once layout is rewritten h3 {
margin: 0 auto; margin-bottom: 0.25em;
}
.error {
color: var(--negative-colour1-primary);
}
.code {
height: 30px;
user-select: all;
background-color: var(--colour1-secondary);
border: 1px solid var(--colour1-tertiary);
border-radius: 5px;
padding: 2px;
top: 1px;
}
section {
margin-top: 1em;
ul {
margin-top: 4px;
}
}
input[type="submit"]:disabled {
color: hsl(0deg 0% 40%);
background-image: linear-gradient(#4a5059, #4a5059);
cursor: progress;
}
} }

View File

@ -1,13 +1,15 @@
import { import {
ActionFunctionArgs,
LoaderFunctionArgs, LoaderFunctionArgs,
redirect,
useLoaderData, useLoaderData,
useNavigate,
} from "react-router"; } from "react-router";
import { createFilePageURL } from "#lib/urls";
import { apiFetchArchiveFile, apiSetArchiveFilePassword } from "#api/files"; import { apiFetchArchiveFile, apiSetArchiveFilePassword } from "#api/files";
import { PageSkeleton } from "#components/pages"; import { PageSkeleton } from "#components/pages";
import { ArchiveFileOverview, IArchiveFile } from "#entities/files"; import { IS_FILE_SERVING_ENABLED } from "#env/env-vars";
import { createArchiveFileURL } from "#lib/urls";
import { KemonoLink } from "#components/links";
import { IArchiveFile } from "#api/files";
import { FormEvent, useState } from "react";
import * as styles from "./archive.module.scss"; import * as styles from "./archive.module.scss";
@ -15,14 +17,107 @@ interface IProps {
archive: IArchiveFile; archive: IArchiveFile;
} }
interface IFileItemProps {
name: string;
archiveHash: string;
archiveExtension: string;
password?: string;
}
function FileItem({
name,
archiveHash,
archiveExtension,
password,
}: IFileItemProps) {
return (
<li>
{!IS_FILE_SERVING_ENABLED ? (
name
) : (
<KemonoLink
url={String(
createArchiveFileURL(archiveHash, archiveExtension, name, password)
)}
>
{name}
</KemonoLink>
)}
</li>
);
}
export function ArchiveFilePage() { export function ArchiveFilePage() {
const { archive } = useLoaderData() as IProps; const { archive: { file: { hash, ext }, file_list, password: loaderPassword } } = useLoaderData() as IProps;
const title = `Archive file "${archive.file.hash}" details`; const title = `Archive file "${hash}" details`;
const heading = "Archive File Details"; const heading = "Archive File Details";
const [error, setError] = useState("");
const [password, setPassword] = useState(loaderPassword);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function submitPassword(event: FormEvent) {
event.preventDefault();
let form = event.target as HTMLFormElement;
let password = (form.elements.namedItem("password-input") as HTMLInputElement).value;
if (!password) {
setError("Please enter a password.");
} else {
setLoading(true);
try {
await apiSetArchiveFilePassword(hash, password);
setPassword(password);
} catch {
setError("Invalid password");
} finally {
setLoading(false);
}
}
}
return ( return (
<PageSkeleton name="archives" title={title} heading={heading}> <PageSkeleton name="archives" title={title} heading={heading}>
<ArchiveFileOverview className={styles.article} archiveFile={archive} /> <small>
<KemonoLink url="#" onClick={() => navigate(-1)}>« Go Back</KemonoLink>{" | "}
<KemonoLink url={`/search_hash?hash=${hash}`}>Find Posts</KemonoLink>
</small>
<article className={styles.article}>
<header>
<h3>Hash: {hash}</h3>
{password ? (
<h5>Password: <code className={styles.code}>{password}</code></h5>
) : (password === "") ? (
<>
<span>Password required but not found.</span>
<form onSubmit={submitPassword}>
<input type="text" name="password-input" placeholder="Password" autoComplete="off" />
<input type="submit" value={loading ? "Testing..." : "Try Password"} disabled={loading}></input>
<span className={styles.error} hidden={!error}>{error}</span>
</form>
</>
) : (
<>Password not required.</>
)}
</header>
<section>
<h5>Files</h5>
{file_list.length === 0 ? (
<>Archive is empty or missing password.</>
) : (
<ul>
{file_list.map((filename, index) => (
<FileItem
key={`${filename}-${index}`}
name={filename}
archiveHash={hash}
archiveExtension={ext}
/>
))}
</ul>
)}
</section>
</article>
</PageSkeleton> </PageSkeleton>
); );
} }
@ -44,7 +139,7 @@ export async function loader({ params }: LoaderFunctionArgs): Promise<IProps> {
archive, archive,
}; };
} }
/*
export async function action({ params, request }: ActionFunctionArgs) { export async function action({ params, request }: ActionFunctionArgs) {
try { try {
const method = request.method; const method = request.method;
@ -78,3 +173,4 @@ export async function action({ params, request }: ActionFunctionArgs) {
return error; return error;
} }
} }
*/

View File

@ -16,13 +16,7 @@ div.content-wrapper {
min-height: 450px; min-height: 450px;
background-color: rgba(0, 0, 0, 0.7); background-color: rgba(0, 0, 0, 0.7);
padding: $size-small; padding: $size-small;
border: 5px solid var(--anchour-internal-colour2-primary); height: 100%;
border-radius: 10px;
margin: 1em;
@media (max-width: $width-tablet) {
background-color: #3b3e44;
}
} }
.jumbo-welcome-mascot { .jumbo-welcome-mascot {

View File

@ -45,19 +45,22 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
{ name: "session_key" }, { name: "session_key" },
], ],
validate({ session_key }) { validate({ session_key }) {
if (!session_key.match(/^\d+_\w+$/) || session_key.length > MAX_LENGTH) { if (!/^\d+_\w+$/.test(session_key) || session_key.length > MAX_LENGTH) {
return "Invalid key."; return "Invalid key.";
} }
}, },
}, },
afdian: { afdian: {
inputs: [ inputs: [
{ name: "session_key" }, { name: "session_key", label: "Auth Token", hint: "Can be found in cookies -> auth_token." },
], ],
validate({ session_key }) { validate({ session_key }) {
if (session_key.length > MAX_LENGTH) { if (session_key.length > MAX_LENGTH) {
return "Key is too long."; return "Key is too long.";
} }
if (!/^[a-f0-9]+_\d+$/.test(session_key)) {
return "Invalid key.";
}
}, },
}, },
boosty: { boosty: {
@ -78,7 +81,7 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
{ name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." }, { name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." },
], ],
validate({ session_key, channel_ids }) { validate({ session_key, channel_ids }) {
if (!session_key.match(/^(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})$/)) { if (!/^(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27,})/i.test(session_key)) {
return "Invalid token format."; return "Invalid token format.";
} }
for (const id of channel_ids.split(/\s*,\s*/)) { for (const id of channel_ids.split(/\s*,\s*/)) {
@ -151,10 +154,7 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
return "User ID must consist of only digits."; return "User ID must consist of only digits.";
} }
if (!bc_token.match(/^[a-f0-9]{40}$/)) { if (!bc_token.match(/^[a-f0-9]{40}$/)) {
return "Invalid BC token (expected 40 hexadecimal characters)."; return "Invalid BC token";
}
if (!/^[\x00-\x7F]*$/.test(user_agent)) {
return "Invalid User-Agent (contains non-ASCII characters).";
} }
}, },
}, },

View File

@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router"; import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { import {
ICONS_PREPEND, ICONS_PREPEND,
IS_ARCHIVER_ENABLED, IS_ARCHIVER_ENABLED,

View File

@ -1,6 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router"; import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { import {
ICONS_PREPEND, ICONS_PREPEND,
IS_ARCHIVER_ENABLED, IS_ARCHIVER_ENABLED,

View File

@ -6,9 +6,10 @@ import { PageSkeleton } from "#components/pages";
import { Paginator } from "#components/pagination"; import { Paginator } from "#components/pagination";
import { FooterAd, HeaderAd, SliderAd } from "#components/advs"; import { FooterAd, HeaderAd, SliderAd } from "#components/advs";
import { CardList, PostCard } from "#components/cards"; import { CardList, PostCard } from "#components/cards";
import { FormRouter, FormSection } from "#components/forms"; import { ButtonSubmit, FormRouter } from "#components/forms";
import { IPost } from "#entities/posts"; import { IPost } from "#entities/posts";
import { findFavouritePosts, findFavouriteProfiles } from "#entities/account"; import { findFavouritePosts, findFavouriteProfiles } from "#entities/account";
import { useRef, useState } from "react";
interface IProps { interface IProps {
count: number; count: number;
@ -22,12 +23,23 @@ interface IProps {
export function PostsPage() { export function PostsPage() {
const { count, trueCount, offset, query, posts, tags } = const { count, trueCount, offset, query, posts, tags } =
useLoaderData() as IProps; useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const title = "Posts"; const title = "Posts";
const heading = "Posts"; const heading = "Posts";
return ( return (
<PageSkeleton name="posts" title={title} heading={heading}> <PageSkeleton name="posts" title={title} heading={heading}>
<SliderAd />
<HeaderAd />
<div className="paginator" id="paginator-top"> <div className="paginator" id="paginator-top">
<SearchForm
query={query}
tags={tags}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator <Paginator
count={count} count={count}
true_count={trueCount} true_count={trueCount}
@ -36,26 +48,9 @@ export function PostsPage() {
String(createPostsPageURL(offset, query, tags)) String(createPostsPageURL(offset, query, tags))
} }
/> />
<FormRouter method="GET">
<FormSection>
<input
id="q"
className="search-input"
type="text"
name="q"
aria-autocomplete="none"
defaultValue={query}
minLength={2}
placeholder="search for posts..."
/>
</FormSection>
</FormRouter>
</div> </div>
<SliderAd /> <CardList className={isLoading ? "card-list--loading" : ""}>
<HeaderAd />
<CardList>
{count === 0 ? ( {count === 0 ? (
<div className="card-list__item--no-results"> <div className="card-list__item--no-results">
<h2 className="subtitle">Nobody here but us chickens!</h2> <h2 className="subtitle">Nobody here but us chickens!</h2>
@ -73,8 +68,6 @@ export function PostsPage() {
)} )}
</CardList> </CardList>
<FooterAd />
<div className="paginator" id="paginator-bottom"> <div className="paginator" id="paginator-bottom">
<Paginator <Paginator
count={count} count={count}
@ -85,10 +78,75 @@ export function PostsPage() {
} }
/> />
</div> </div>
<FooterAd />
</PageSkeleton> </PageSkeleton>
); );
} }
interface ISearchFormProps
extends Pick<IProps, "query" | "tags"> {
onLoadingChange: (loading: boolean) => void;
}
// TODO: TAGS! add tag search!!
function SearchForm({ query, tags, onLoadingChange }: ISearchFormProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const target = e.currentTarget as HTMLInputElement;
onLoadingChange(true);
timeoutRef.current = setTimeout(() => {
if (target.form) target.form.requestSubmit();
onLoadingChange(false);
}, 1000);
};
return (
<FormRouter
id="search-form"
className="search-form"
method="GET"
autoComplete="off"
noValidate={true}
acceptCharset="UTF-8"
statusSection={null}
submitButton={undefined}
style={{ maxWidth: "fit-content" }}
>
{(state) => (
<>
<div className="wrapper">
<input
type="text"
name="q"
id="q"
autoComplete="off"
defaultValue={query}
minLength={2}
placeholder="Search..."
onChange={onInputChange}
/>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
className="search-button"
onClick={() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
onLoadingChange(false);
}}
>
<img src='/static/menu/search.svg' />
</ButtonSubmit>
</div>
</>
)}
</FormRouter>
);
}
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> { export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;

View File

@ -7,16 +7,17 @@ import { FooterAd, SliderAd } from "#components/advs";
import { Paginator } from "#components/pagination"; import { Paginator } from "#components/pagination";
import { CardList, PostCard } from "#components/cards"; import { CardList, PostCard } from "#components/cards";
import { ProfilePageSkeleton } from "#components/pages"; import { ProfilePageSkeleton } from "#components/pages";
import { FormRouter } from "#components/forms"; import { ButtonSubmit, FormRouter } from "#components/forms";
import { import {
ProfileHeader, ProfileHeader,
Tabs, Tabs,
IArtistDetails, IArtistDetails,
getArtist, getArtist,
} from "#entities/profiles"; } from "#entities/profiles";
import { paysites, validatePaysite } from "#entities/paysites"; import { paysites } from "#entities/paysites";
import { IPost } from "#entities/posts"; import { IPost } from "#entities/posts";
import { findFavouritePosts } from "#entities/account"; import { findFavouritePosts } from "#entities/account";
import { useRef, useState } from "react";
interface IProps { interface IProps {
profile: IArtistDetails; profile: IArtistDetails;
@ -35,6 +36,7 @@ interface IProps {
export function ProfilePage() { export function ProfilePage() {
const { profile, postsData, query, tags, dmCount, hasLinks } = const { profile, postsData, query, tags, dmCount, hasLinks } =
useLoaderData() as IProps; useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const { service, id, name } = profile; const { service, id, name } = profile;
const paysite = paysites[service]; const paysite = paysites[service];
const title = `Posts of "${name}" from "${paysite.title}"`; const title = `Posts of "${name}" from "${paysite.title}"`;
@ -56,6 +58,10 @@ export function ProfilePage() {
{!(postsData && (postsData?.count !== 0 || query)) ? undefined : ( {!(postsData && (postsData?.count !== 0 || query)) ? undefined : (
<> <>
<SearchForm
query={query}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator <Paginator
offset={postsData.offset} offset={postsData.offset}
count={postsData.count} count={postsData.count}
@ -71,21 +77,6 @@ export function ProfilePage() {
) )
} }
/> />
<FormRouter method="GET">
<input
id="q"
className="search-input"
type="text"
name="q"
autoComplete="off"
defaultValue={query}
minLength={2}
placeholder={"search for posts..."}
/>
{/* TODO: rewrite this into a proper form */}
<input type="submit" style={{ display: "none" }} />
</FormRouter>
</> </>
)} )}
</div> </div>
@ -99,7 +90,7 @@ export function ProfilePage() {
</div> </div>
) : ( ) : (
<> <>
<CardList> <CardList className={isLoading ? "card-list--loading" : ""}>
{postsData.posts.map((post) => ( {postsData.posts.map((post) => (
<PostCard <PostCard
key={`${post.id}-${post.service}-${post.user}`} key={`${post.id}-${post.service}-${post.user}`}
@ -135,52 +126,91 @@ export function ProfilePage() {
); );
} }
interface ISearchFormProps
extends Pick<IProps, "query"> {
onLoadingChange: (loading: boolean) => void;
}
function SearchForm({ query, onLoadingChange }: ISearchFormProps) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const target = e.currentTarget as HTMLInputElement;
onLoadingChange(true);
timeoutRef.current = setTimeout(() => {
if (target.form) target.form.requestSubmit();
onLoadingChange(false);
}, 1000);
};
return (
<FormRouter
id="search-form"
className="search-form"
method="GET"
autoComplete="off"
noValidate={true}
acceptCharset="UTF-8"
statusSection={null}
submitButton={undefined}
style={{ maxWidth: "fit-content" }}
>
{(state) => (
<>
<div className="wrapper">
<input
type="text"
name="q"
id="q"
autoComplete="off"
defaultValue={query}
placeholder="Search..."
onChange={onInputChange}
/>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
className="search-button"
onClick={() => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
onLoadingChange(false);
}}
>
<img src='/static/menu/search.svg' />
</ButtonSubmit>
</div>
</>
)}
</FormRouter>
);
}
export async function loader({ export async function loader({
params, params,
request, request,
}: LoaderFunctionArgs): Promise<IProps | Response> { }: LoaderFunctionArgs): Promise<IProps | Response> {
const searchParams = new URL(request.url).searchParams; const searchParams = new URL(request.url).searchParams;
const service = params.service?.trim(); const service = params.service?.trim() || "";
{ const profileID = params.creator_id?.trim() || "";
if (!service) {
throw new Error("Service name is required.");
}
validatePaysite(service);
}
const profileID = params.creator_id?.trim();
{
if (!profileID) {
throw new Error("Profile ID is required.");
}
}
if (service === "discord") { if (service === "discord") {
return redirect(String(createDiscordServerPageURL(profileID))); return redirect(String(createDiscordServerPageURL(profileID)));
} }
let offset: number | undefined; const offsetParam = searchParams.get("o")?.trim();
{ const offset = offsetParam ? parseOffset(offsetParam) : undefined;
const inputValue = searchParams.get("o")?.trim(); const query = searchParams.get("q")?.trim();
if (inputValue) {
offset = parseOffset(inputValue);
}
}
let query: string | undefined = undefined;
{
const inputQuery = searchParams.get("q")?.trim();
if (inputQuery) {
query = inputQuery;
}
}
const tags = searchParams.getAll("tag"); const tags = searchParams.getAll("tag");
const profile = await getArtist(service, profileID); const profile = await getArtist(service, profileID);
if (profileID == profile.public_id && profile.public_id != profile.id) {
return redirect(String(createProfilePageURL({ service, profileID: profile.id })));
}
const { props, results: posts } = await fetchProfilePosts( const { props, results: posts } = await fetchProfilePosts(
service, service,
profileID, profileID,

View File

@ -1,5 +1,5 @@
import { LoaderFunctionArgs, useLoaderData } from "react-router"; import { LoaderFunctionArgs, useLoaderData } from "react-router";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars"; import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
import { fetchAnnouncements } from "#api/posts"; import { fetchAnnouncements } from "#api/posts";
import { fetchArtistProfile } from "#api/profiles"; import { fetchArtistProfile } from "#api/profiles";

View File

@ -1,5 +1,5 @@
import { LoaderFunctionArgs, useLoaderData } from "react-router"; import { LoaderFunctionArgs, useLoaderData } from "react-router";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars"; import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
import { fetchProfileDMs } from "#api/dms"; import { fetchProfileDMs } from "#api/dms";
import { PageSkeleton } from "#components/pages"; import { PageSkeleton } from "#components/pages";

View File

@ -1,5 +1,5 @@
import { LoaderFunctionArgs, useLoaderData } from "react-router"; import { LoaderFunctionArgs, useLoaderData } from "react-router";
import { Helmet } from "react-helmet-async"; import { Helmet } from "@dr.pogodin/react-helmet";
import { import {
ICONS_PREPEND, ICONS_PREPEND,
KEMONO_SITE, KEMONO_SITE,

View File

@ -1,5 +1,5 @@
import clsx from "clsx"; import clsx from "clsx";
import { Suspense } from "react"; import { Suspense, useRef, useState } from "react";
import { import {
useLoaderData, useLoaderData,
LoaderFunctionArgs, LoaderFunctionArgs,
@ -59,20 +59,15 @@ export function ArtistsPage() {
<PageSkeleton name="artists" title={title} heading={heading}> <PageSkeleton name="artists" title={title} heading={heading}>
<SliderAd /> <SliderAd />
<HeaderAd />
<div className="paginator" id="paginator-top">
<SearchForm <SearchForm
query={query} query={query}
service={service} service={service}
sort_by={sort_by} sort_by={sort_by}
order={order} order={order}
/> />
<div style={{ textAlign: "center" }}>
<h2 id="display-status" className="subtitle">
Displaying cached popular artists
</h2>
</div>
<div className="paginator" id="paginator-top">
<Suspense fallback={<LoadingIcon />}> <Suspense fallback={<LoadingIcon />}>
<Await errorElement={<></>} resolve={results}> <Await errorElement={<></>} resolve={results}>
{(resolvedResult: Awaited<typeof results>) => ( {(resolvedResult: Awaited<typeof results>) => (
@ -96,8 +91,6 @@ export function ArtistsPage() {
</Suspense> </Suspense>
</div> </div>
<HeaderAd />
<CardList layout="phone"> <CardList layout="phone">
<Suspense <Suspense
fallback={ fallback={
@ -161,9 +154,32 @@ export function ArtistsPage() {
} }
interface ISearchFormProps interface ISearchFormProps
extends Pick<IProps, "query" | "service" | "sort_by" | "order"> {} extends Pick<IProps, "query" | "service" | "sort_by" | "order"> { }
function SearchForm({ query, service, sort_by, order }: ISearchFormProps) { function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
const sortRef = useRef<HTMLInputElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [sortDirection, setSortDirection] = useState<string | undefined>(order);
const onSortChange = (e: React.MouseEvent) => {
e.preventDefault();
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
if (sortRef.current) {
sortRef.current.value = sortDirection === "asc" ? "desc" : "asc";
sortRef.current.form?.requestSubmit();
}
}
const onSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => e.currentTarget.form?.requestSubmit();
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
const target = e.currentTarget as HTMLInputElement;
timeoutRef.current = setTimeout(() => {
if (target.form) target.form.requestSubmit();
}, 500);
};
return ( return (
<FormRouter <FormRouter
id="search-form" id="search-form"
@ -178,66 +194,44 @@ function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
> >
{(state) => ( {(state) => (
<> <>
<FormSection> <div className="wrapper">
<label htmlFor="q">Name</label>
<input <input
type="text" type="text"
name="q" name="q"
id="q" id="q"
autoComplete="off" autoComplete="off"
defaultValue={query} defaultValue={query}
placeholder="Search..."
onChange={onInputChange}
/> />
<small className="subtitle" style={{ marginLeft: "5px" }}> <ButtonSubmit
Leave blank to list all disabled={state === "loading" || state === "submitting"}
</small> className="search-button"
</FormSection> >
<img src='/static/menu/search.svg' />
<FormSection> </ButtonSubmit>
<label htmlFor="service">Service</label> </div>
<select id="service" name="service" defaultValue={service}> <div className="wrapper">
<option value="">All</option> <select className="service" name="service" defaultValue={service} onChange={onSelectChange}>
<option value="">Services</option>
{AVAILABLE_PAYSITE_LIST.map((paysite, index) => ( {AVAILABLE_PAYSITE_LIST.map((paysite, index) => (
<option key={index} value={paysite.name}> <option key={index} value={paysite.name}>
{paysite.title} {paysite.title}
</option> </option>
))} ))}
</select> </select>
</FormSection> <select className="sort_by" name="sort_by" defaultValue={sort_by} onChange={onSelectChange}>
<FormSection>
<label htmlFor="sort_by">Sort by</label>
<select id="sort_by" name="sort_by" defaultValue={sort_by}>
<option value="favorited">Popularity</option> <option value="favorited">Popularity</option>
<option value="indexed">Date Indexed</option> <option value="indexed">Date Indexed</option>
<option value="updated">Date Updated</option> <option value="updated">Date Updated</option>
<option value="name">Alphabetical Order</option> <option value="name">Alphabetical Order</option>
<option value="service">Service</option> <option value="service">Service</option>
</select>{" "}
<select id="order" name="order" defaultValue={order}>
<option value="desc">Descending</option>
<option value="asc">Ascending</option>
</select> </select>
</FormSection> <button onClick={onSortChange} className="sort_dir button" title={sortDirection === "asc" ? "Ascending" : "Descending"} >
<img src="/static/sort.svg" alt="Sort" className={sortDirection} />
<FormSection> </button>
<div></div>
<div style={{ display: "table-cell" }}>
{state === "loading"
? "Loading..."
: state === "submitting"
? "Submitting..."
: "Ready for submit"}
</div> </div>
</FormSection> <input type="text" name="order" className="hidden" ref={sortRef} />
<FormSection>
<div style={{ display: "table-cell" }}></div>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
>
Search
</ButtonSubmit>
</FormSection>
</> </>
)} )}
</FormRouter> </FormRouter>

View File

@ -8,31 +8,44 @@ import { fetchSearchFileByHash } from "#api/files";
import { PageSkeleton } from "#components/pages"; import { PageSkeleton } from "#components/pages";
import { CardList, PostCard } from "#components/cards"; import { CardList, PostCard } from "#components/cards";
import { KemonoLink } from "#components/links"; import { KemonoLink } from "#components/links";
import { MouseEvent, useEffect, useState } from "react";
import { LoadingIcon } from "#components/loading";
import { useSearchParams } from "react-router";
import * as styles from "./search_hash.module.scss"; import * as styles from "./search_hash.module.scss";
import { MouseEvent, useState } from "react";
import { LoadingIcon } from "#components/loading";
export function SearchFilesPage() { export function SearchFilesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialHash = searchParams.get("hash")?.toLowerCase();
const title = "Search files"; const title = "Search files";
const heading = "Search Files"; const heading = "Search Files";
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [files, setFiles] = useState<FileList | null>(null); const [files, setFiles] = useState<FileList | null>(null);
const [hash, setHash] = useState(""); const [hash, setHash] = useState(initialHash ?? "");
const [result, setResult] = useState<Awaited<ReturnType<typeof fetchSearchFileByHash>> | null>(null); const [result, setResult] = useState<Awaited<ReturnType<typeof fetchSearchFileByHash>> | null>(null);
async function submitClicked(event: MouseEvent) { function setHashParam(hash: string) {
event.preventDefault(); setSearchParams(params => {
params.set("hash", hash);
return params;
}, { replace: true });
}
async function lookup(hash: string) {
setLoading(true); setLoading(true);
try { try {
if (files?.length) { if (files?.length) {
let fileHash = await getFileHash(files[0]); let fileHash = await getFileHash(files[0]);
setResult(await fetchSearchFileByHash(fileHash)); setResult(await fetchSearchFileByHash(fileHash));
setHash(fileHash);
setHashParam(fileHash);
} else { } else {
if (hash.match(/[a-f0-9]{64}/)) { if (hash.match(/[a-f0-9]{64}/)) {
setResult(await fetchSearchFileByHash(hash)); setResult(await fetchSearchFileByHash(hash));
setHash(hash);
setHashParam(hash);
} else { } else {
setError("Invalid hash!"); setError("Invalid hash!");
} }
@ -42,6 +55,17 @@ export function SearchFilesPage() {
} }
} }
useEffect(() => {
if (initialHash) {
lookup(initialHash);
}
}, []);
async function submitClicked(event: MouseEvent) {
event.preventDefault();
await lookup(hash);
}
return ( return (
<PageSkeleton name="file-hash-search" title={title} heading={heading}> <PageSkeleton name="file-hash-search" title={title} heading={heading}>
<form className={styles.searchForm}> <form className={styles.searchForm}>
@ -70,14 +94,7 @@ export function SearchFilesPage() {
<button className="button" onClick={submitClicked} disabled={!(files?.length || hash) || loading}>Submit</button> <button className="button" onClick={submitClicked} disabled={!(files?.length || hash) || loading}>Submit</button>
</form> </form>
{loading ? <LoadingIcon /> : !result?.posts?.length ? ( {loading ? <LoadingIcon /> : result?.posts?.length ? (
<div className="no-results">
<h2 className="site-section__subheading">
Nobody here but us chickens!
</h2>
<p className="subtitle">There are no posts for your query.</p>
</div>
) : (
<CardList> <CardList>
{result.posts.map((post, index) => ( {result.posts.map((post, index) => (
<PostCard <PostCard
@ -87,9 +104,7 @@ export function SearchFilesPage() {
/> />
))} ))}
</CardList> </CardList>
)} ) : result?.discord_posts?.length ? (
{result?.discord_posts && result?.discord_posts.length !== 0 && (
<> <>
<h2>Discord</h2> <h2>Discord</h2>
{result.discord_posts.map((post, index) => ( {result.discord_posts.map((post, index) => (
@ -104,6 +119,13 @@ export function SearchFilesPage() {
</p> </p>
))} ))}
</> </>
) : (
<div className="no-results">
<h2 className="site-section__subheading">
Nobody here but us chickens!
</h2>
<p className="subtitle">There are no posts for your query.</p>
</div>
)} )}
</PageSkeleton> </PageSkeleton>
); );

View File

@ -73,7 +73,6 @@ import {
import { import {
ArchiveFilePage, ArchiveFilePage,
loader as archiveFilePageLoader, loader as archiveFilePageLoader,
action as archiveFilePageAction,
} from "#pages/file/archive"; } from "#pages/file/archive";
import { loader as postRandomPageLoader } from "#pages/posts/random"; import { loader as postRandomPageLoader } from "#pages/posts/random";
import { import {
@ -245,7 +244,6 @@ export const routes = [
path: "/file/:file_hash", path: "/file/:file_hash",
element: <ArchiveFilePage />, element: <ArchiveFilePage />,
loader: archiveFilePageLoader, loader: archiveFilePageLoader,
action: archiveFilePageAction,
}, },
{ {
path: "/posts/random", path: "/posts/random",

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

1
client/static/sort.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M120-240v-80h240v80H120Zm0-200v-80h480v80H120Zm0-200v-80h720v80H120Z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

View File

@ -8,6 +8,7 @@ import { createHtmlPlugin } from "vite-plugin-html";
import { patchCssModules } from 'vite-css-modules' import { patchCssModules } from 'vite-css-modules'
import { import {
siteName, siteName,
favicon,
analyticsEnabled, analyticsEnabled,
analyticsCode, analyticsCode,
kemonoSite, kemonoSite,
@ -40,6 +41,7 @@ import {
gitCommitHash, gitCommitHash,
isFileServingEnabled, isFileServingEnabled,
buildDate, buildDate,
AnnouncementBannerGlobal,
} from "./configs/vars.mjs"; } from "./configs/vars.mjs";
export const baseConfig = defineConfig(async ({ command, mode }) => { export const baseConfig = defineConfig(async ({ command, mode }) => {
@ -80,7 +82,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
tag: "link", tag: "link",
attrs: { attrs: {
rel: "icon", rel: "icon",
href: "./static/favicon.ico", href: favicon,
}, },
injectTo: "head", injectTo: "head",
}, },
@ -151,6 +153,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems), BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems),
BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems), BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems),
BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal), BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal),
BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL: JSON.stringify(AnnouncementBannerGlobal),
BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome), BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome),
BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage), BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage),
BUNDLER_ENV_HOME_MASCOT_PATH: JSON.stringify(homeMascotPath), BUNDLER_ENV_HOME_MASCOT_PATH: JSON.stringify(homeMascotPath),

View File

@ -11,6 +11,10 @@
"$comment": "`$deprecated` keyword was in introduced in draft `2019-09`, so just using `description` field instead.", "$comment": "`$deprecated` keyword was in introduced in draft `2019-09`, so just using `description` field instead.",
"type": "string" "type": "string"
}, },
"favicon": {
"description": "favicon.ico file path.",
"type": "string"
},
"development_mode": { "development_mode": {
"type": "boolean" "type": "boolean"
}, },
@ -218,6 +222,10 @@
"description": "B64-encoded html fragment.", "description": "B64-encoded html fragment.",
"type": "string" "type": "string"
}, },
"announcement_global": {
"description": "B64-encoded html fragment.",
"type": "string"
},
"welcome": { "welcome": {
"description": "B64-encoded html fragment.", "description": "B64-encoded html fragment.",
"type": "string" "type": "string"
@ -448,6 +456,9 @@
"host": { "host": {
"type": "string" "type": "string"
}, },
"password": {
"type": "string"
},
"port": { "port": {
"type": "integer" "type": "integer"
}, },
@ -531,7 +542,8 @@
"file", "file",
"archive_files", "archive_files",
"linked_accounts", "linked_accounts",
"running_imports" "running_imports",
"importer_configuration"
] ]
}, },
"archive-server": { "archive-server": {

View File

@ -15,6 +15,7 @@ interface IServerConfig {
interface IUIConfig { interface IUIConfig {
home: IHomeConfig; home: IHomeConfig;
favicon?: string;
config: { paysite_list: string[]; artists_or_creators: string }; config: { paysite_list: string[]; artists_or_creators: string };
matomo?: IMatomoConfig; matomo?: IMatomoConfig;
sidebar?: ISidebarConfig; sidebar?: ISidebarConfig;
@ -51,6 +52,10 @@ interface IBannerConfig {
* b64-encoded string * b64-encoded string
*/ */
global?: string; global?: string;
/**
* b64-encoded string
*/
announcement_global?: string;
/** /**
* b64-encoded string * b64-encoded string
*/ */

View File

@ -85,7 +85,7 @@ def get_artist(service: str, artist_id: str, reload: bool = False) -> TDArtist:
params = dict(artist_id=artist_id, service=service) params = dict(artist_id=artist_id, service=service)
id_filter = ( id_filter = (
"(id = %(artist_id)s or public_id = %(artist_id)s)" "(id = %(artist_id)s or public_id = %(artist_id)s)"
if service == ("onlyfans", "fansly", "candfans", "patreon", "fanbox", "boosty") if service in ("onlyfans", "fansly", "candfans", "patreon", "fanbox", "boosty")
else "id = %(artist_id)s" else "id = %(artist_id)s"
) )
query = f""" query = f"""

View File

@ -1,14 +1,63 @@
import re
from flask import jsonify, make_response from flask import jsonify, make_response
from src.lib.post import get_post_comments from src.lib.post import get_post_comments
from src.lib.api import create_not_found_error_response from src.lib.api import create_not_found_error_response
from src.pages.api.v1 import v1api_bp from src.pages.api.v1 import v1api_bp
boosty_emote_uuid_to_file = {
"05df7389-a9e9-4a51-aefc-96c9c374175c": "Heart.webp",
"7b3fda0d-5ea9-4e66-a8bd-a19c590c8cef": "ClappingHands.webp",
"bb6e8aaf-4ac6-4f71-b8a5-b9307f86071b": "HighVoltage.webp",
"3f26b442-06b1-4b94-b9bd-3a1af887057e": "BeamingFace.webp",
"2f73c11d-ed75-4638-bb2c-4d6911d95e63": "PartyPopper.webp",
"67198e42-128a-4a41-bf7a-94d7c98bb44f": "Star.webp",
"5781617f-106b-4c05-bec0-f55d3904307a": "Gemstone.webp",
"1ad0dde5-846e-4c54-a20f-2d54a8ab1b85": "Gaspar.webp",
"84149636-8701-4d5b-a92d-445ebc49d39c": "Hurt.webp",
"3a7d0922-8dc1-4175-90bc-561d3d2bda7d": "MoneyFace.webp",
"da0661bb-aae6-4e54-87fa-0c4065ec435b": "Rocket.webp",
"9db7bb0d-1148-4686-9d0c-643d2c94837b": "ExplodingHead.webp",
"37a6e1ec-f63a-416f-88d4-63e48a68c71b": "ThinkingFace.webp",
"bc1334a6-af7e-4618-b37e-2be63bf8a112": "CheckMark.webp",
"663cbbdf-639e-4dac-8912-6a580d3ef3e6": "CallMe.webp",
"517b1805-13dd-43ed-ac65-bfa0fd0d16b8": "Burn.webp",
"4cd3b821-ec9c-4d35-827e-30be025c3ca0": "FaceScreaming.webp",
"6d674dd1-789c-408f-9ab4-912e9a8d2539": "LoudlyFace.webp",
"f3a31f3c-24b4-4760-a577-10fb0cce8605": "NauseatedFace.webp",
"eb55272b-0724-4854-ad44-899ad286a992": "Eggplant.webp",
"c00141f1-7f23-4841-b8c1-e3cd85f8f5bb": "Apple.webp",
"0b02f581-876f-4b38-823e-00d8c026dc39": "Peach.webp",
"deb46686-294c-49ae-b987-6df4d41e2b9d": "Hamburger.webp",
"5969bcfa-3dc5-4e1b-95dd-b7a1567220fb": "Pizza.webp",
"04541c27-5491-49f6-b70d-aefbdff0884c": "Banana.webp",
"90ccef34-14cf-4528-8763-0d993d892dfe": "Moon.webp",
"76119773-3a29-4548-b4c8-811f7fdc2936": "Sun.webp",
"58b34b35-4d64-45d1-852e-274206dd90b7": "ColdFace.webp",
"fd3780a9-05f7-46fe-92ec-c5f6f2ef5aa3": "Devil.webp",
"97cb65d2-15b7-42d2-9d44-6165f16f3e6e": "Shield.webp",
"fedfd339-daaf-4bff-857f-4d68ae9e5727": "SweatDroplets.webp",
"58852139-f95e-4238-9c1c-871fa6d0889a": "Beach.webp",
"10f3bc42-fc33-437b-a452-de97c748ca22": "Ball.webp",
"5388e3ce-e4d5-4b0c-ba4c-5b58d8d35db9": "Gift.webp",
"9c3d8ff6-bf13-4255-b8ff-30b9c9c98162": "MyPressF.webp",
"97101bae-9beb-47b0-bfe2-70ac24bce094": "MyIlluminati.webp"
}
boosty_emote_regex = re.compile(r"https:\/\/images\.boosty\.to\/smile\/([a-f0-9\-]+)\/size\/large.*?")
@v1api_bp.get("/<service>/user/<creator_id>/post/<post_id>/comments") @v1api_bp.get("/<service>/user/<creator_id>/post/<post_id>/comments")
def get_comments(service: str, creator_id: str, post_id: str): def get_comments(service: str, creator_id: str, post_id: str):
comments = get_post_comments(post_id, service) comments = get_post_comments(post_id, service)
if service == "boosty":
for comment in comments:
comment["content"] = boosty_emote_regex.sub(
lambda match: f"/thumbnail/boosty_smile/{boosty_emote_uuid_to_file.get(match.group(1), match.group(1))}",
comment["content"],
)
if not comments: if not comments:
response = create_not_found_error_response("No comments found.") response = create_not_found_error_response("No comments found.")
response.headers["Cache-Control"] = "s-maxage=600" response.headers["Cache-Control"] = "s-maxage=600"

View File

@ -2,7 +2,7 @@ import re
from flask import jsonify, make_response, request from flask import jsonify, make_response, request
from src.config import Configuration from src.config import Configuration
from src.lib.files import get_file_relationships, try_set_password from src.lib.files import get_archive_files, get_file_relationships, try_set_password
from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int
from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares
from src.lib.api import create_not_found_error_response, create_client_error_response from src.lib.api import create_not_found_error_response, create_client_error_response
@ -26,6 +26,40 @@ def lookup_file(file_hash: str):
return response return response
@v1api_bp.get("/file/<file_hash>")
def get_archive_data(file_hash: str):
print("get_archive_data for", file_hash)
file_hash = file_hash.lower()
if not HASH_REGEX.match(file_hash):
return create_client_error_response("Invalid SHA256 hash")
archive = get_archive_files(file_hash)
if not archive:
return create_client_error_response("File not found", 404)
response = make_response(jsonify(archive), 200)
response.headers["Cache-Control"] = "s-maxage=600"
return response
@v1api_bp.patch("/file/<file_hash>")
def set_archive_password(file_hash: str):
if not Configuration().archive_server["enabled"]:
return create_not_found_error_response()
file_hash = file_hash.lower()
if not HASH_REGEX.match(file_hash):
return create_client_error_response("Invalid SHA256 hash")
passwords: list[str] = request.get_json()
if try_set_password(file_hash, passwords):
return make_response(jsonify("ok"), 200)
return create_client_error_response("Invalid password")
@v1api_bp.route("/shares") @v1api_bp.route("/shares")
def get_shares_data(): def get_shares_data():
base = request.args.to_dict() base = request.args.to_dict()

View File

@ -8,7 +8,6 @@ from src.lib.api import (
TDAPIError, TDAPIError,
) )
from .file import file_bp
from .account import account_bp from .account import account_bp
v2api_bp = Blueprint("v2", __name__, url_prefix="/v2") v2api_bp = Blueprint("v2", __name__, url_prefix="/v2")
@ -45,5 +44,4 @@ def handle_server_error(error: Exception):
return create_api_v2_error_response(responseError, 500) return create_api_v2_error_response(responseError, 500)
v2api_bp.register_blueprint(file_bp)
v2api_bp.register_blueprint(account_bp) v2api_bp.register_blueprint(account_bp)

View File

@ -1,12 +0,0 @@
from flask import Blueprint
from src.config import Configuration
from .overview import get_file_overview, set_file_password
file_bp = Blueprint("file", __name__, url_prefix="/file")
file_bp.get("/<file_hash>")(get_file_overview)
if Configuration().archive_server["enabled"]:
file_bp.patch("/<file_hash>")(set_file_password)

View File

@ -1,65 +0,0 @@
from typing import TypedDict
from flask import request
from src.lib.api import (
create_api_v2_response,
create_api_v2_not_found_error_response,
get_api_v2_request_data,
create_api_v2_invalid_body_error_response,
create_api_v2_client_error_response,
TDAPIError,
)
from src.lib.files import get_archive_files, try_set_password
def get_file_overview(file_hash: str):
archive = get_archive_files(file_hash)
if not archive:
return create_api_v2_not_found_error_response(600)
response = create_api_v2_response(archive)
response.headers["Cache-Control"] = "s-maxage=600"
return response
class TDFileUpdate(TypedDict):
password: str
def set_file_password(file_hash: str):
data: TDFileUpdate = get_api_v2_request_data(request)
if not isinstance(data, dict):
return create_api_v2_invalid_body_error_response()
password = data.get("password")
if not password:
error = TDAPIError(type="api_invalid_body_data", message="Password is required.")
return create_api_v2_client_error_response(error)
archive = get_archive_files(file_hash)
if not archive:
return create_api_v2_not_found_error_response()
if archive.password:
error = TDAPIError(type="api_invalid_body_data", message="Password is already set.")
return create_api_v2_client_error_response(error, 409)
is_set = try_set_password(file_hash, [password])
if not is_set:
error = TDAPIError(type="api_invalid_body_data", message="Invalid password.")
return create_api_v2_client_error_response(error)
response = create_api_v2_response(file_hash)
return response