squash
This commit is contained in:
parent
e639176911
commit
ae0c94d807
29
README.md
29
README.md
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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==
|
||||
|
|
|
@ -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
4235
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { apiFetchArchiveFile, apiSetArchiveFilePassword } from "./archive-file";
|
||||
export { apiFetchArchiveFile, apiSetArchiveFilePassword, type IArchiveFile } from "./archive-file";
|
||||
export { fetchSearchFileByHash } from "./search-by-hash";
|
||||
|
|
|
@ -13,8 +13,8 @@ interface IResult {
|
|||
|
||||
size: number;
|
||||
ihash: string;
|
||||
posts: IPostResult[];
|
||||
|
||||
posts: IPostResult[];
|
||||
discord_posts: IDiscordPostResult[];
|
||||
}
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ export function ClientProvider({ children }: IProps) {
|
|||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const isRegistered = await isRegisteredAccount();
|
||||
const isRegistered = isRegisteredAccount();
|
||||
const clientData: IClientContext = { isRegistered };
|
||||
changeClient(clientData);
|
||||
})();
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
|
|
|
@ -43,6 +43,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--loading {
|
||||
* {
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
&--no-results {
|
||||
--card-size: $width-phone;
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -10,4 +10,3 @@ export type {
|
|||
IDescriptionTermProps,
|
||||
IDescriptionDetailsProps,
|
||||
} from "./description";
|
||||
export { List, ListUnordered, ListOrdered, ListItem } from "./list";
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
@use "../../css/config/variables/sass" as *;
|
||||
|
||||
.ordered {
|
||||
list-style-type: decimal-leading-zero;
|
||||
}
|
|
@ -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} />;
|
||||
}
|
|
@ -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.");
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
.site-section {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
|
||||
&__header {
|
||||
padding: 0 0 $size-little;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
@use "./paginator_new";
|
||||
@use "./paginator.scss";
|
||||
|
|
61
client/src/components/pagination/paginator.scss
Normal file
61
client/src/components/pagination/paginator.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -46,6 +46,6 @@ export async function logoutAccount(isLocalOnly?: boolean) {
|
|||
return true;
|
||||
}
|
||||
|
||||
export async function isRegisteredAccount() {
|
||||
export function isRegisteredAccount() {
|
||||
return getLocalStorageItem("logged_in") === "yes";
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
export { ArchiveFileOverview } from "./archive-overview";
|
||||
export type {
|
||||
IFile,
|
||||
IFanCard,
|
||||
IShare,
|
||||
IShareFile,
|
||||
IArchiveFile,
|
||||
} from "./types";
|
||||
|
|
|
@ -47,12 +47,3 @@ export interface IShareFile {
|
|||
ext: string;
|
||||
added: string;
|
||||
}
|
||||
|
||||
export interface IArchiveFile {
|
||||
password?: string;
|
||||
file: {
|
||||
hash: string;
|
||||
ext: string;
|
||||
};
|
||||
file_list: string[];
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -207,7 +207,7 @@ function FavoriteButton({ service, profileID, postID }: IFavoriteButtonProps) {
|
|||
try {
|
||||
switchLoading(true);
|
||||
|
||||
const isLoggedIn = await isRegisteredAccount();
|
||||
const isLoggedIn = isRegisteredAccount();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
|
|
|
@ -108,7 +108,7 @@ function FavouriteButton({ service, profileID }: IFavouriteButtonProps) {
|
|||
(async () => {
|
||||
try {
|
||||
switchLoading(true);
|
||||
const isLoggedIn = await isRegisteredAccount();
|
||||
const isLoggedIn = isRegisteredAccount();
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return;
|
||||
|
|
|
@ -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;
|
||||
|
|
6
client/src/env/env-vars.ts
vendored
6
client/src/env/env-vars.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -33,3 +33,8 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.site-section--home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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",
|
||||
|
|
BIN
client/static/favicon-coomer.ico
Normal file
BIN
client/static/favicon-coomer.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
1
client/static/sort.svg
Normal file
1
client/static/sort.svg
Normal 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 |
|
@ -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),
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
|
@ -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
|
Loading…
Reference in New Issue
Block a user