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._
[![Button Website]][Website]
[![Button Setup]](#setup)
[![Button FAQ]][FAQ]
[![Button Develop]][Develop]
<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
: configuration.webserver?.port;
export const siteName = configuration.webserver.ui.home.site_name || "Kemono";
export const favicon = configuration.webserver.ui.favicon || "./static/favicon.ico";
export const homeBackgroundImage =
configuration.webserver.ui.home.home_background_image;
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 footerItems = configuration.webserver.ui.footer_items;
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 headerAd = configuration.webserver.ui.ads?.header;
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"
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"
resolved "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz"
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"
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"
resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz"
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
@ -1325,7 +1325,7 @@ ajv-keywords@^5.0.0:
dependencies:
fast-deep-equal "^3.1.3"
ajv@^6.12.5:
ajv@^6.12.5, ajv@^6.9.1:
version "6.12.6"
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
@ -1335,7 +1335,7 @@ ajv@^6.12.5:
json-schema-traverse "^0.4.1"
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"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz"
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
@ -1497,7 +1497,7 @@ braces@^3.0.2, braces@~3.0.2:
dependencies:
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"
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz"
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"
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
postcss@^8.4.19:
postcss@^8.1.0, postcss@^8.4.19:
version "8.4.21"
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz"
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
@ -3744,7 +3744,7 @@ wbuf@^1.1.0, wbuf@^1.7.3:
dependencies:
minimalistic-assert "^1.0.0"
webpack-cli@^5.1.1:
webpack-cli@^5.1.1, webpack-cli@5.x.x:
version "5.1.1"
resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.1.tgz"
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"
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"
resolved "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz"
integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==

View File

@ -8,6 +8,6 @@
<script src="/static/js/lazy-styles.js"></script>
</head>
<body>
<div id="root" class="transition-preload"></div>
<div id="root"></div>
</body>
</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"
},
"overrides": {
"vite": "$vite"
"vite": "$vite",
"swagger-ui-react": {
"react": "$react",
"react-dom": "$react-dom"
}
},
"imports": {
"#storage/*": "./src/browser/storage/*/index.ts",
@ -37,14 +41,14 @@
"clsx": "^2.1.1",
"diff": "^7.0.0",
"fluid-player": "file:./fluid-player",
"micromodal": "^0.4.10",
"micromodal": "^0.6.1",
"purecss": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-router": "^7.1.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@dr.pogodin/react-helmet": "^3.0.1",
"react-router": "^7.5.0",
"sha256-wasm": "^2.2.2",
"swagger-ui-react": "^5.18.3"
"swagger-ui-react": "^5.20.7"
},
"devDependencies": {
"@babel/core": "^7.26.8",
@ -55,9 +59,9 @@
"@hyperjump/json-schema": "^1.11.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@types/micromodal": "^0.3.5",
"@types/node": "^22.13.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/node": "^22.14.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sha256-wasm": "^2.2.3",
"@types/swagger-ui-react": "^5.18.0",
"@types/webpack-bundle-analyzer": "^4.7.0",

View File

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

View File

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

View File

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

View File

@ -23,16 +23,51 @@ export interface ILocalStorageSchema {
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);
}
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);
} 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);
} catch (error) {
console.error("Failed to remove item from LocalStorage:", error);
}
}
export function isLocalStorageAvailable() {

View File

@ -1,6 +1,7 @@
import { useLocation } from "react-router";
import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars";
import { DangerousContent } from "#components/dangerous-content";
import { useEffect } from "react";
export function HeaderAd() {
const location = useLocation();
@ -11,7 +12,6 @@ export function HeaderAd() {
key={key}
className="ad-container"
html={atob(HEADER_AD)}
allowRerender
/>
);
}
@ -48,10 +48,33 @@ export function SliderAd() {
const location = useLocation();
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 : (
<DangerousContent
key={key}
className="ad-container"
className="ad-container-slider"
html={atob(SLIDER_AD)}
allowRerender
/>

View File

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

View File

@ -11,9 +11,13 @@ interface IProps {
}
export function Timestamp({ time, isRelative, className, children }: IProps) {
if (time === null) {
return;
}
const isClient = useClient();
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 (
<time className={clsx("timestamp", className)} dateTime={time} title={time}>

View File

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

View File

@ -1,8 +1,9 @@
import clsx from "clsx";
import { Fragment, useEffect, useState, type ReactNode } from "react";
import { HelmetProvider } from "react-helmet-async";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { HelmetProvider } from "@dr.pogodin/react-helmet";
import { Link, Outlet, ScrollRestoration, useLocation } from "react-router";
import {
ANNOUNCEMENT_BANNER_GLOBAL,
ARTISTS_OR_CREATORS,
BANNER_GLOBAL,
DISABLE_DMS,
@ -20,7 +21,6 @@ import {
import { fetchHasPendingDMs } from "#api/dms";
import { getLocalStorageItem, setLocalStorageItem } from "#storage/local";
import { ClientProvider } from "#hooks";
import { LoadingIcon } from "#components/loading";
import { isRegisteredAccount } from "#entities/account";
import { NavEntry, NavItem, NavList, type INavItem } from "./sidebar";
import { GlobalFooter } from "./footer";
@ -32,11 +32,13 @@ interface ILayoutProps {
interface IGlobalBodyProps extends ILayoutProps {
isSidebarClosed: boolean;
closeSidebar: (_?: any, setState?: boolean) => void;
noAnim: boolean;
}
interface IGlobalSidebarProps {
isSidebarClosed: boolean;
closeSidebar: (_?: any, setState?: boolean) => void;
noAnim: boolean;
}
interface IHeaderLinkProps {
@ -45,75 +47,97 @@ interface IHeaderLinkProps {
className?: string;
}
const SIDEBAR_MIN_WIDTH = 1020; // match to $sidebar-min-width
/**
* TODO: Matomo integration
*/
export function Layout() {
const [isSidebarClosed, switchSidebar] = useState<boolean>(true);
useEffect(() => {
document.body.firstElementChild!.classList.remove("transition-preload");
const location = useLocation();
const isMobileLayout = useRef(window.innerWidth <= SIDEBAR_MIN_WIDTH);
const [forceNoAnim, setForceNoAnim] = useState(false);
const [isSidebarClosed, switchSidebar] = useState<boolean>(() => {
const sidebarState = getLocalStorageItem("sidebar_state");
killAnimations();
switchSidebar(sidebarState === "true");
// mobile devices should always have the sidebar closed due to inconvenience
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);
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
}, [isSidebarClosed]);
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");
}
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 (
<ClientProvider>
<HelmetProvider>
<GlobalSidebar
isSidebarClosed={isSidebarClosed}
closeSidebar={closeSidebar}
noAnim={forceNoAnim}
/>
<GlobalBody
isSidebarClosed={isSidebarClosed}
closeSidebar={closeSidebar}
noAnim={forceNoAnim}
>
<Outlet />
</GlobalBody>
@ -124,7 +148,7 @@ export function Layout() {
);
}
function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
function GlobalSidebar({ isSidebarClosed, closeSidebar, noAnim }: IGlobalSidebarProps) {
const navListItems: INavItem[][] = [
[
{
@ -197,13 +221,14 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
],
];
const globalSidebarClassName = clsx(
"global-sidebar",
isSidebarClosed ? "retracted" : "expanded"
);
return (
<div className={globalSidebarClassName}>
<div className={clsx(
"global-sidebar",
isSidebarClosed ? "retracted" : "expanded",
{
"disable-transitions": noAnim,
}
)}>
<NavEntry className="clickable-header-entry">
<NavItem
link="/"
@ -231,13 +256,12 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
*/
function AccountEntry() {
const location = useLocation();
const [isLoggedIn, switchLoggedIn] = useState(false);
const [isLoading, switchLoading] = useState<boolean>(true);
const isLoggedIn = isRegisteredAccount();
const [isPendingDMsForReview, switchPendingDMsForReview] = useState(false);
useEffect(() => {
(async () => {
const isRegistered = await isRegisteredAccount();
const isRegistered = isRegisteredAccount();
if (!isRegistered) {
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[] = [
{
header: true,
@ -316,9 +327,7 @@ function AccountEntry() {
},
];
return isLoading ? (
<LoadingIcon />
) : (
return (
<NavEntry
items={isLoggedIn ? loggedInEntries : loggedOutEntries}
className="account"
@ -329,15 +338,11 @@ function AccountEntry() {
function GlobalBody({
isSidebarClosed,
closeSidebar,
noAnim,
children,
}: IGlobalBodyProps) {
const location = useLocation();
const [isLoggedIn, switchLoggedIn] = useState(false);
const [isLoading, switchLoading] = useState<boolean>(true);
const contentWrapperClassName = clsx(
"content-wrapper",
!isSidebarClosed && "shifted"
);
const isLoggedIn = isRegisteredAccount();
const backdropClassName = clsx(
"backdrop",
isSidebarClosed && "backdrop-hidden"
@ -347,21 +352,14 @@ function GlobalBody({
isSidebarClosed && "sidebar-retracted"
);
useEffect(() => {
(async () => {
try {
switchLoading(true);
const isRegistered = await isRegisteredAccount();
switchLoggedIn(isRegistered);
} finally {
switchLoading(false);
}
})();
}, [location]);
return (
<div className={contentWrapperClassName}>
<div className={clsx(
"content-wrapper",
{
"shifted": !isSidebarClosed,
"disable-transitions": noAnim,
}
)}>
<div className={backdropClassName} onClick={closeSidebar} />
<div className={headerClassName}>
@ -372,9 +370,7 @@ function GlobalBody({
<HeaderLink url="/artists" text={ARTISTS_OR_CREATORS} />
<HeaderLink url="/posts" text="Posts" />
<HeaderLink url="/importer" text="Import" className="import" />
{isLoading ? (
<LoadingIcon />
) : isLoggedIn ? (
{isLoggedIn ? (
<>
<HeaderLink
url={String(createAccountFavoriteProfilesPageURL())}
@ -399,7 +395,15 @@ function GlobalBody({
</div>
{BANNER_GLOBAL && (
<div id="ad-banner">
<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">

View File

@ -10,4 +10,3 @@ export type {
IDescriptionTermProps,
IDescriptionDetailsProps,
} 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
): LoaderFunction {
return async (args: LoaderFunctionArgs) => {
const isRegistered = await isRegisteredAccount();
const isRegistered = isRegisteredAccount();
if (!isRegistered) {
throw new Error("You must be registered to access this page.");
@ -26,7 +26,7 @@ export function createAccountPageLoader(
export async function validateAccountPageLoader(
...args: Parameters<LoaderFunction>
): Promise<void> {
const isRegistered = await isRegisteredAccount();
const isRegistered = isRegisteredAccount();
if (!isRegistered) {
throw new Error("You must be registered to access this page.");
@ -36,7 +36,7 @@ export async function validateAccountPageLoader(
export async function validateAccountPageAction(
...args: Parameters<ActionFunction>
): Promise<void> {
const isRegistered = await isRegisteredAccount();
const isRegistered = isRegisteredAccount();
if (!isRegistered) {
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 { IArtistDetails } from "#entities/profiles";
import { IPageProps, PageSkeleton } from "./site";

View File

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

View File

@ -1,6 +1,6 @@
import clsx from "clsx";
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";
export interface IPageProps

View File

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

View File

@ -1,3 +1,5 @@
@use "../css/config/variables/sass" as *;
/*
TODO: Spread the styles around page/component/block files.
*/
@ -279,81 +281,6 @@
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 {
display: inline;
list-style-type: none;
@ -377,46 +304,87 @@ menu li {
.sidebar {
width: auto;
}
#paginator-bottom {
margin-bottom: env(safe-area-inset-bottom);
}
}
/* search forms */
.search-form {
display: table;
padding: 0.5rem;
margin-left: 5px;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 8px;
background-color: #282a2e;
margin: 0px auto 8px auto;
&-hidden {
display: none;
& .wrapper {
display: flex;
&:not(:last-of-type) {
margin-bottom: 8px;
}
}
& > div {
display: table-row;
line-height: 1.5em;
margin-bottom: 2em;
& img {
width: 24px;
height: 24px;
}
& small {
display: block;
line-height: normal;
& input,
& select {
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 {
display: table-cell;
padding-right: 1em;
white-space: nowrap;
text-align: left;
padding-right: 0px;
box-shadow: none;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
padding-left: 8px;
width: 500px;
}
& label {
text-align: right;
font-weight: 700;
& .sort_dir {
background-image: linear-gradient(#282a2e, #111111);
& > .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 {
text-align: center;
display: flex;
justify-content: center;
padding: 12px 0px;
}
.ad-container * {
max-width: 100%;
.ad-container-slider {
display: none;
}

View File

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

View File

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

View File

@ -28,7 +28,7 @@ export async function isFavouriteProfile(service: string, profileID: string) {
export async function findFavouriteProfiles(
profilesData: IProfileData[]
): Promise<IProfileData[]> {
const isRegistered = await isRegisteredAccount();
const isRegistered = isRegisteredAccount();
// return early for non-registered users
// 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 {
IFile,
IFanCard,
IShare,
IShareFile,
IArchiveFile,
} from "./types";

View File

@ -47,12 +47,3 @@ export interface IShareFile {
ext: 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 { PostVideo } from "./video";
import { FlagButton } from "./flag-button";
import { KemonoLink } from "#components/links";
import * as styles from "./body.module.scss";
@ -153,8 +154,8 @@ function PostAttachment({
</a>
{isArchiveFile && (
<>
{/* TODO: a proper URL function */}(
<a href={`/posts/archives/${stem}`}>browse »</a>)
{" "}(
<KemonoLink url={`/posts/archives/${stem}`}>browse »</KemonoLink>)
</>
)}
</li>

View File

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

View File

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

View File

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

View File

@ -54,10 +54,7 @@ export async function getArtists({
const normalizedName = profile.name.trim().toLowerCase();
const normalizedID = profile.id.trim().toLowerCase();
return (
normalizedID.includes(normalizedQuery) ||
normalizedName.includes(normalizedQuery)
);
isQueryMatched = normalizedID.includes(normalizedQuery) || normalizedName.includes(normalizedQuery);
}
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;
/**
* b64-encoded string
*/
export const ANNOUNCEMENT_BANNER_GLOBAL = BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL;
/**
* b64-encoded string
*/
@ -106,6 +111,7 @@ declare global {
const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[];
const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | 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_HOME_BACKGROUND_IMAGE: 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
*/
interface IOptions extends Omit<RequestInit, "headers"> {
method: "GET" | "POST" | "DELETE";
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
body?: any;
headers?: Headers;
}
// TODO: Make this not throw.
/**
* Generic request for Kemono API.
* @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 { Paginator } from "#components/pagination";
import { CardList, DMCard } from "#components/cards";
import { FormRouter } from "#components/forms";
import { ButtonSubmit, FormRouter } from "#components/forms";
import { IApprovedDM } from "#entities/dms";
import { useRef, useState } from "react";
interface IProps {
query?: string;
@ -18,6 +19,7 @@ interface IProps {
export function DMsPage() {
const { query, count, dms, offset } = useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const title = "DMs";
const heading = "DMs";
@ -27,32 +29,18 @@ export function DMsPage() {
<HeaderAd />
<div className="paginator" id="paginator-top">
<SearchForm
query={query}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator
count={count}
offset={offset}
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>
<CardList layout="phone">
<CardList layout="phone" className={isLoading ? "card-list--loading" : ""}>
{count === 0 ? (
<div className="no-results">
<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> {
const searchParams = new URL(request.url).searchParams;

View File

@ -19,8 +19,8 @@ export function DMCAPage() {
</p>
<p>
Notices may be shared to 3rd party's for due diligence and transparency
purposes
Notices may be shared to third parties for due diligence and transparency
purposes.
</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 { PageSkeleton } from "#components/pages";

View File

@ -1,6 +1,35 @@
@use "../../css/config/variables/sass.scss" as *;
.article {
// TODO: remove once layout is rewritten
margin: 0 auto;
h3 {
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 {
ActionFunctionArgs,
LoaderFunctionArgs,
redirect,
useLoaderData,
useNavigate,
} from "react-router";
import { createFilePageURL } from "#lib/urls";
import { apiFetchArchiveFile, apiSetArchiveFilePassword } from "#api/files";
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";
@ -15,14 +17,107 @@ interface IProps {
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() {
const { archive } = useLoaderData() as IProps;
const title = `Archive file "${archive.file.hash}" details`;
const { archive: { file: { hash, ext }, file_list, password: loaderPassword } } = useLoaderData() as IProps;
const title = `Archive file "${hash}" 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 (
<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>
);
}
@ -44,7 +139,7 @@ export async function loader({ params }: LoaderFunctionArgs): Promise<IProps> {
archive,
};
}
/*
export async function action({ params, request }: ActionFunctionArgs) {
try {
const method = request.method;
@ -78,3 +173,4 @@ export async function action({ params, request }: ActionFunctionArgs) {
return error;
}
}
*/

View File

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

View File

@ -45,19 +45,22 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
{ name: "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.";
}
},
},
afdian: {
inputs: [
{ name: "session_key" },
{ name: "session_key", label: "Auth Token", hint: "Can be found in cookies -> auth_token." },
],
validate({ session_key }) {
if (session_key.length > MAX_LENGTH) {
return "Key is too long.";
}
if (!/^[a-f0-9]+_\d+$/.test(session_key)) {
return "Invalid key.";
}
},
},
boosty: {
@ -78,7 +81,7 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
{ name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." },
],
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.";
}
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.";
}
if (!bc_token.match(/^[a-f0-9]{40}$/)) {
return "Invalid BC token (expected 40 hexadecimal characters).";
}
if (!/^[\x00-\x7F]*$/.test(user_agent)) {
return "Invalid User-Agent (contains non-ASCII characters).";
return "Invalid BC token";
}
},
},

View File

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

View File

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

View File

@ -6,9 +6,10 @@ import { PageSkeleton } from "#components/pages";
import { Paginator } from "#components/pagination";
import { FooterAd, HeaderAd, SliderAd } from "#components/advs";
import { CardList, PostCard } from "#components/cards";
import { FormRouter, FormSection } from "#components/forms";
import { ButtonSubmit, FormRouter } from "#components/forms";
import { IPost } from "#entities/posts";
import { findFavouritePosts, findFavouriteProfiles } from "#entities/account";
import { useRef, useState } from "react";
interface IProps {
count: number;
@ -22,12 +23,23 @@ interface IProps {
export function PostsPage() {
const { count, trueCount, offset, query, posts, tags } =
useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const title = "Posts";
const heading = "Posts";
return (
<PageSkeleton name="posts" title={title} heading={heading}>
<SliderAd />
<HeaderAd />
<div className="paginator" id="paginator-top">
<SearchForm
query={query}
tags={tags}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator
count={count}
true_count={trueCount}
@ -36,26 +48,9 @@ export function PostsPage() {
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>
<SliderAd />
<HeaderAd />
<CardList>
<CardList className={isLoading ? "card-list--loading" : ""}>
{count === 0 ? (
<div className="card-list__item--no-results">
<h2 className="subtitle">Nobody here but us chickens!</h2>
@ -73,8 +68,6 @@ export function PostsPage() {
)}
</CardList>
<FooterAd />
<div className="paginator" id="paginator-bottom">
<Paginator
count={count}
@ -85,10 +78,75 @@ export function PostsPage() {
}
/>
</div>
<FooterAd />
</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> {
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 { CardList, PostCard } from "#components/cards";
import { ProfilePageSkeleton } from "#components/pages";
import { FormRouter } from "#components/forms";
import { ButtonSubmit, FormRouter } from "#components/forms";
import {
ProfileHeader,
Tabs,
IArtistDetails,
getArtist,
} from "#entities/profiles";
import { paysites, validatePaysite } from "#entities/paysites";
import { paysites } from "#entities/paysites";
import { IPost } from "#entities/posts";
import { findFavouritePosts } from "#entities/account";
import { useRef, useState } from "react";
interface IProps {
profile: IArtistDetails;
@ -35,6 +36,7 @@ interface IProps {
export function ProfilePage() {
const { profile, postsData, query, tags, dmCount, hasLinks } =
useLoaderData() as IProps;
const [isLoading, setIsLoading] = useState(false);
const { service, id, name } = profile;
const paysite = paysites[service];
const title = `Posts of "${name}" from "${paysite.title}"`;
@ -56,6 +58,10 @@ export function ProfilePage() {
{!(postsData && (postsData?.count !== 0 || query)) ? undefined : (
<>
<SearchForm
query={query}
onLoadingChange={(loading) => setIsLoading(loading)}
/>
<Paginator
offset={postsData.offset}
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>
@ -99,7 +90,7 @@ export function ProfilePage() {
</div>
) : (
<>
<CardList>
<CardList className={isLoading ? "card-list--loading" : ""}>
{postsData.posts.map((post) => (
<PostCard
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({
params,
request,
}: LoaderFunctionArgs): Promise<IProps | Response> {
const searchParams = new URL(request.url).searchParams;
const service = params.service?.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.");
}
}
const service = params.service?.trim() || "";
const profileID = params.creator_id?.trim() || "";
if (service === "discord") {
return redirect(String(createDiscordServerPageURL(profileID)));
}
let offset: number | undefined;
{
const inputValue = searchParams.get("o")?.trim();
if (inputValue) {
offset = parseOffset(inputValue);
}
}
let query: string | undefined = undefined;
{
const inputQuery = searchParams.get("q")?.trim();
if (inputQuery) {
query = inputQuery;
}
}
const offsetParam = searchParams.get("o")?.trim();
const offset = offsetParam ? parseOffset(offsetParam) : undefined;
const query = searchParams.get("q")?.trim();
const tags = searchParams.getAll("tag");
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(
service,
profileID,

View File

@ -1,5 +1,5 @@
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 { fetchAnnouncements } from "#api/posts";
import { fetchArtistProfile } from "#api/profiles";

View File

@ -1,5 +1,5 @@
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 { fetchProfileDMs } from "#api/dms";
import { PageSkeleton } from "#components/pages";

View File

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

View File

@ -1,5 +1,5 @@
import clsx from "clsx";
import { Suspense } from "react";
import { Suspense, useRef, useState } from "react";
import {
useLoaderData,
LoaderFunctionArgs,
@ -59,20 +59,15 @@ export function ArtistsPage() {
<PageSkeleton name="artists" title={title} heading={heading}>
<SliderAd />
<HeaderAd />
<div className="paginator" id="paginator-top">
<SearchForm
query={query}
service={service}
sort_by={sort_by}
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 />}>
<Await errorElement={<></>} resolve={results}>
{(resolvedResult: Awaited<typeof results>) => (
@ -96,8 +91,6 @@ export function ArtistsPage() {
</Suspense>
</div>
<HeaderAd />
<CardList layout="phone">
<Suspense
fallback={
@ -164,6 +157,29 @@ interface ISearchFormProps
extends Pick<IProps, "query" | "service" | "sort_by" | "order"> { }
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 (
<FormRouter
id="search-form"
@ -178,66 +194,44 @@ function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
>
{(state) => (
<>
<FormSection>
<label htmlFor="q">Name</label>
<div className="wrapper">
<input
type="text"
name="q"
id="q"
autoComplete="off"
defaultValue={query}
placeholder="Search..."
onChange={onInputChange}
/>
<small className="subtitle" style={{ marginLeft: "5px" }}>
Leave blank to list all
</small>
</FormSection>
<FormSection>
<label htmlFor="service">Service</label>
<select id="service" name="service" defaultValue={service}>
<option value="">All</option>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
className="search-button"
>
<img src='/static/menu/search.svg' />
</ButtonSubmit>
</div>
<div className="wrapper">
<select className="service" name="service" defaultValue={service} onChange={onSelectChange}>
<option value="">Services</option>
{AVAILABLE_PAYSITE_LIST.map((paysite, index) => (
<option key={index} value={paysite.name}>
{paysite.title}
</option>
))}
</select>
</FormSection>
<FormSection>
<label htmlFor="sort_by">Sort by</label>
<select id="sort_by" name="sort_by" defaultValue={sort_by}>
<select className="sort_by" name="sort_by" defaultValue={sort_by} onChange={onSelectChange}>
<option value="favorited">Popularity</option>
<option value="indexed">Date Indexed</option>
<option value="updated">Date Updated</option>
<option value="name">Alphabetical Order</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>
</FormSection>
<FormSection>
<div></div>
<div style={{ display: "table-cell" }}>
{state === "loading"
? "Loading..."
: state === "submitting"
? "Submitting..."
: "Ready for submit"}
<button onClick={onSortChange} className="sort_dir button" title={sortDirection === "asc" ? "Ascending" : "Descending"} >
<img src="/static/sort.svg" alt="Sort" className={sortDirection} />
</button>
</div>
</FormSection>
<FormSection>
<div style={{ display: "table-cell" }}></div>
<ButtonSubmit
disabled={state === "loading" || state === "submitting"}
>
Search
</ButtonSubmit>
</FormSection>
<input type="text" name="order" className="hidden" ref={sortRef} />
</>
)}
</FormRouter>

View File

@ -8,31 +8,44 @@ import { fetchSearchFileByHash } from "#api/files";
import { PageSkeleton } from "#components/pages";
import { CardList, PostCard } from "#components/cards";
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 { MouseEvent, useState } from "react";
import { LoadingIcon } from "#components/loading";
export function SearchFilesPage() {
const [searchParams, setSearchParams] = useSearchParams();
const initialHash = searchParams.get("hash")?.toLowerCase();
const title = "Search files";
const heading = "Search Files";
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
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);
async function submitClicked(event: MouseEvent) {
event.preventDefault();
function setHashParam(hash: string) {
setSearchParams(params => {
params.set("hash", hash);
return params;
}, { replace: true });
}
async function lookup(hash: string) {
setLoading(true);
try {
if (files?.length) {
let fileHash = await getFileHash(files[0]);
setResult(await fetchSearchFileByHash(fileHash));
setHash(fileHash);
setHashParam(fileHash);
} else {
if (hash.match(/[a-f0-9]{64}/)) {
setResult(await fetchSearchFileByHash(hash));
setHash(hash);
setHashParam(hash);
} else {
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 (
<PageSkeleton name="file-hash-search" title={title} heading={heading}>
<form className={styles.searchForm}>
@ -70,14 +94,7 @@ export function SearchFilesPage() {
<button className="button" onClick={submitClicked} disabled={!(files?.length || hash) || loading}>Submit</button>
</form>
{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>
) : (
{loading ? <LoadingIcon /> : result?.posts?.length ? (
<CardList>
{result.posts.map((post, index) => (
<PostCard
@ -87,9 +104,7 @@ export function SearchFilesPage() {
/>
))}
</CardList>
)}
{result?.discord_posts && result?.discord_posts.length !== 0 && (
) : result?.discord_posts?.length ? (
<>
<h2>Discord</h2>
{result.discord_posts.map((post, index) => (
@ -104,6 +119,13 @@ export function SearchFilesPage() {
</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>
);

View File

@ -73,7 +73,6 @@ import {
import {
ArchiveFilePage,
loader as archiveFilePageLoader,
action as archiveFilePageAction,
} from "#pages/file/archive";
import { loader as postRandomPageLoader } from "#pages/posts/random";
import {
@ -245,7 +244,6 @@ export const routes = [
path: "/file/:file_hash",
element: <ArchiveFilePage />,
loader: archiveFilePageLoader,
action: archiveFilePageAction,
},
{
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 {
siteName,
favicon,
analyticsEnabled,
analyticsCode,
kemonoSite,
@ -40,6 +41,7 @@ import {
gitCommitHash,
isFileServingEnabled,
buildDate,
AnnouncementBannerGlobal,
} from "./configs/vars.mjs";
export const baseConfig = defineConfig(async ({ command, mode }) => {
@ -80,7 +82,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
tag: "link",
attrs: {
rel: "icon",
href: "./static/favicon.ico",
href: favicon,
},
injectTo: "head",
},
@ -151,6 +153,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems),
BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems),
BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal),
BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL: JSON.stringify(AnnouncementBannerGlobal),
BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome),
BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage),
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.",
"type": "string"
},
"favicon": {
"description": "favicon.ico file path.",
"type": "string"
},
"development_mode": {
"type": "boolean"
},
@ -218,6 +222,10 @@
"description": "B64-encoded html fragment.",
"type": "string"
},
"announcement_global": {
"description": "B64-encoded html fragment.",
"type": "string"
},
"welcome": {
"description": "B64-encoded html fragment.",
"type": "string"
@ -448,6 +456,9 @@
"host": {
"type": "string"
},
"password": {
"type": "string"
},
"port": {
"type": "integer"
},
@ -531,7 +542,8 @@
"file",
"archive_files",
"linked_accounts",
"running_imports"
"running_imports",
"importer_configuration"
]
},
"archive-server": {

View File

@ -15,6 +15,7 @@ interface IServerConfig {
interface IUIConfig {
home: IHomeConfig;
favicon?: string;
config: { paysite_list: string[]; artists_or_creators: string };
matomo?: IMatomoConfig;
sidebar?: ISidebarConfig;
@ -51,6 +52,10 @@ interface IBannerConfig {
* b64-encoded string
*/
global?: string;
/**
* b64-encoded string
*/
announcement_global?: 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)
id_filter = (
"(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"
)
query = f"""

View File

@ -1,14 +1,63 @@
import re
from flask import jsonify, make_response
from src.lib.post import get_post_comments
from src.lib.api import create_not_found_error_response
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")
def get_comments(service: str, creator_id: str, post_id: str):
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:
response = create_not_found_error_response("No comments found.")
response.headers["Cache-Control"] = "s-maxage=600"

View File

@ -2,7 +2,7 @@ import re
from flask import jsonify, make_response, request
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.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
@ -26,6 +26,40 @@ def lookup_file(file_hash: str):
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")
def get_shares_data():
base = request.args.to_dict()

View File

@ -8,7 +8,6 @@ from src.lib.api import (
TDAPIError,
)
from .file import file_bp
from .account import account_bp
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)
v2api_bp.register_blueprint(file_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