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._
|
_Frontend designed for Paysite leaking._
|
||||||
|
|
||||||
|
[![Button Website]][Website]
|
||||||
|
|
||||||
|
[![Button Setup]](#setup)
|
||||||
|
[![Button FAQ]][FAQ]
|
||||||
|
|
||||||
|
[![Button Develop]][Develop]
|
||||||
|
|
||||||
<img src="docs/resources/Preview.png" width="700" />
|
<img src="docs/resources/Preview.png" width="700" />
|
||||||
|
|
||||||
[Website]: https://kemono.su/
|
## Setup
|
||||||
|
|
||||||
|
_How to use this project for yourself._
|
||||||
|
|
||||||
|
1. Clone the repository and switch to its folder.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone https://code.kemono.su/Kemono2
|
||||||
|
cd Kemono2
|
||||||
|
```
|
||||||
|
|
||||||
|
[Website]: https://kemono.party/
|
||||||
|
[Develop]: docs/Develop.md
|
||||||
|
[FAQ]: docs/FAQ.md
|
||||||
|
|
||||||
|
<!---------------------------------[ Buttons ]--------------------------------->
|
||||||
|
|
||||||
|
[Button Website]: https://img.shields.io/badge/Website-e6702f?style=for-the-badge&logoColor=white&logo=FirefoxBrowser
|
||||||
|
[Button Develop]: https://img.shields.io/badge/Develop-3955A3?style=for-the-badge&logoColor=white&logo=VisualStudioCode
|
||||||
|
[Button Setup]: https://img.shields.io/badge/Setup-3EAAAF?style=for-the-badge&logoColor=white&logo=GitBook
|
||||||
|
[Button FAQ]: https://img.shields.io/badge/FAQ-569A31?style=for-the-badge&logoColor=white&logo=AskUbuntu
|
||||||
|
|
|
@ -9,6 +9,7 @@ export const apiServerPort = !apiServerBaseURL
|
||||||
? undefined
|
? undefined
|
||||||
: configuration.webserver?.port;
|
: configuration.webserver?.port;
|
||||||
export const siteName = configuration.webserver.ui.home.site_name || "Kemono";
|
export const siteName = configuration.webserver.ui.home.site_name || "Kemono";
|
||||||
|
export const favicon = configuration.webserver.ui.favicon || "./static/favicon.ico";
|
||||||
export const homeBackgroundImage =
|
export const homeBackgroundImage =
|
||||||
configuration.webserver.ui.home.home_background_image;
|
configuration.webserver.ui.home.home_background_image;
|
||||||
export const homeMascotPath = configuration.webserver.ui.home.mascot_path;
|
export const homeMascotPath = configuration.webserver.ui.home.mascot_path;
|
||||||
|
@ -27,6 +28,7 @@ export const disableFilehaus =
|
||||||
export const sidebarItems = configuration.webserver.ui.sidebar_items;
|
export const sidebarItems = configuration.webserver.ui.sidebar_items;
|
||||||
export const footerItems = configuration.webserver.ui.footer_items;
|
export const footerItems = configuration.webserver.ui.footer_items;
|
||||||
export const bannerGlobal = configuration.webserver.ui.banner?.global;
|
export const bannerGlobal = configuration.webserver.ui.banner?.global;
|
||||||
|
export const AnnouncementBannerGlobal = configuration.webserver.ui.banner?.announcement_global;
|
||||||
export const bannerWelcome = configuration.webserver.ui.banner?.welcome;
|
export const bannerWelcome = configuration.webserver.ui.banner?.welcome;
|
||||||
export const headerAd = configuration.webserver.ui.ads?.header;
|
export const headerAd = configuration.webserver.ui.ads?.header;
|
||||||
export const middleAd = configuration.webserver.ui.ads?.middle;
|
export const middleAd = configuration.webserver.ui.ads?.middle;
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.14.tgz"
|
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.20.14.tgz"
|
||||||
integrity sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw==
|
integrity sha512-0YpKHD6ImkWMEINCyDAD0HLLUH/lPCefG8ld9it8DJB2wnApraKuhgYTvTY1z7UFIfBTGy5LwncZ+5HWWGbhFw==
|
||||||
|
|
||||||
"@babel/core@^7.20.12":
|
"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.12.0", "@babel/core@^7.13.0", "@babel/core@^7.20.12", "@babel/core@^7.4.0-0":
|
||||||
version "7.20.12"
|
version "7.20.12"
|
||||||
resolved "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz"
|
resolved "https://registry.npmjs.org/@babel/core/-/core-7.20.12.tgz"
|
||||||
integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==
|
integrity sha512-XsMfHovsUYHFMdrIHkZphTN/2Hzzi78R08NuHfDBehym2VsPDL6Zn/JAD/JQdnRvbSsbQc4mVaU1m6JgtTEElg==
|
||||||
|
@ -1301,7 +1301,7 @@ acorn-import-assertions@^1.7.6:
|
||||||
resolved "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz"
|
resolved "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz"
|
||||||
integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==
|
integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==
|
||||||
|
|
||||||
acorn@^8.5.0, acorn@^8.7.1:
|
acorn@^8, acorn@^8.5.0, acorn@^8.7.1:
|
||||||
version "8.8.2"
|
version "8.8.2"
|
||||||
resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz"
|
resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz"
|
||||||
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
|
||||||
|
@ -1325,7 +1325,7 @@ ajv-keywords@^5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal "^3.1.3"
|
fast-deep-equal "^3.1.3"
|
||||||
|
|
||||||
ajv@^6.12.5:
|
ajv@^6.12.5, ajv@^6.9.1:
|
||||||
version "6.12.6"
|
version "6.12.6"
|
||||||
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
|
resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
|
||||||
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
|
||||||
|
@ -1335,7 +1335,7 @@ ajv@^6.12.5:
|
||||||
json-schema-traverse "^0.4.1"
|
json-schema-traverse "^0.4.1"
|
||||||
uri-js "^4.2.2"
|
uri-js "^4.2.2"
|
||||||
|
|
||||||
ajv@^8.0.0, ajv@^8.8.0:
|
ajv@^8.0.0, ajv@^8.8.0, ajv@^8.8.2:
|
||||||
version "8.12.0"
|
version "8.12.0"
|
||||||
resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz"
|
resolved "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz"
|
||||||
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
|
integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==
|
||||||
|
@ -1497,7 +1497,7 @@ braces@^3.0.2, braces@~3.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
fill-range "^7.0.1"
|
fill-range "^7.0.1"
|
||||||
|
|
||||||
browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4:
|
browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.4, "browserslist@>= 4.21.0":
|
||||||
version "4.21.5"
|
version "4.21.5"
|
||||||
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz"
|
resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz"
|
||||||
integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==
|
integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==
|
||||||
|
@ -3062,7 +3062,7 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
|
||||||
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8.4.19:
|
postcss@^8.1.0, postcss@^8.4.19:
|
||||||
version "8.4.21"
|
version "8.4.21"
|
||||||
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz"
|
resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz"
|
||||||
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
|
integrity sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==
|
||||||
|
@ -3744,7 +3744,7 @@ wbuf@^1.1.0, wbuf@^1.7.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
minimalistic-assert "^1.0.0"
|
minimalistic-assert "^1.0.0"
|
||||||
|
|
||||||
webpack-cli@^5.1.1:
|
webpack-cli@^5.1.1, webpack-cli@5.x.x:
|
||||||
version "5.1.1"
|
version "5.1.1"
|
||||||
resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.1.tgz"
|
resolved "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.1.tgz"
|
||||||
integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw==
|
integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw==
|
||||||
|
@ -3822,7 +3822,7 @@ webpack-sources@^3.2.3:
|
||||||
resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz"
|
resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz"
|
||||||
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
||||||
|
|
||||||
webpack@^5.75.0:
|
"webpack@^4.0.0 || ^5.0.0", "webpack@^4.37.0 || ^5.0.0", webpack@^5.0.0, webpack@^5.1.0, webpack@^5.20.0, webpack@^5.75.0, webpack@>=5, webpack@5.x.x:
|
||||||
version "5.75.0"
|
version "5.75.0"
|
||||||
resolved "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz"
|
resolved "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz"
|
||||||
integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==
|
integrity sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==
|
||||||
|
|
|
@ -8,6 +8,6 @@
|
||||||
<script src="/static/js/lazy-styles.js"></script>
|
<script src="/static/js/lazy-styles.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root" class="transition-preload"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
4235
client/package-lock.json
generated
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"
|
"build": "vite build --config ./vite.prod.mjs"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"vite": "$vite"
|
"vite": "$vite",
|
||||||
|
"swagger-ui-react": {
|
||||||
|
"react": "$react",
|
||||||
|
"react-dom": "$react-dom"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"#storage/*": "./src/browser/storage/*/index.ts",
|
"#storage/*": "./src/browser/storage/*/index.ts",
|
||||||
|
@ -37,14 +41,14 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"fluid-player": "file:./fluid-player",
|
"fluid-player": "file:./fluid-player",
|
||||||
"micromodal": "^0.4.10",
|
"micromodal": "^0.6.1",
|
||||||
"purecss": "^3.0.0",
|
"purecss": "^3.0.0",
|
||||||
"react": "^18.2.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"@dr.pogodin/react-helmet": "^3.0.1",
|
||||||
"react-router": "^7.1.5",
|
"react-router": "^7.5.0",
|
||||||
"sha256-wasm": "^2.2.2",
|
"sha256-wasm": "^2.2.2",
|
||||||
"swagger-ui-react": "^5.18.3"
|
"swagger-ui-react": "^5.20.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.26.8",
|
"@babel/core": "^7.26.8",
|
||||||
|
@ -55,9 +59,9 @@
|
||||||
"@hyperjump/json-schema": "^1.11.0",
|
"@hyperjump/json-schema": "^1.11.0",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@types/micromodal": "^0.3.5",
|
"@types/micromodal": "^0.3.5",
|
||||||
"@types/node": "^22.13.1",
|
"@types/node": "^22.14.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@types/sha256-wasm": "^2.2.3",
|
"@types/sha256-wasm": "^2.2.3",
|
||||||
"@types/swagger-ui-react": "^5.18.0",
|
"@types/swagger-ui-react": "^5.18.0",
|
||||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||||
|
|
|
@ -1,30 +1,35 @@
|
||||||
import { apiV2Fetch } from "#lib/api";
|
import { apiFetch } from "#lib/api";
|
||||||
import { IArchiveFile } from "#entities/files";
|
|
||||||
|
export interface IArchiveFile {
|
||||||
|
password?: string;
|
||||||
|
file: {
|
||||||
|
hash: string;
|
||||||
|
ext: string;
|
||||||
|
};
|
||||||
|
file_list: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetchArchiveFile(fileHash: string) {
|
export async function apiFetchArchiveFile(fileHash: string) {
|
||||||
const pathSpec = `/file/{file_hash}`;
|
|
||||||
const path = `/file/${fileHash}`;
|
const path = `/file/${fileHash}`;
|
||||||
|
|
||||||
const result = await apiV2Fetch<IArchiveFile>(pathSpec, "GET", path);
|
const result = await apiFetch<IArchiveFile>(path, { method: "GET" });
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IBody {
|
type SetPasswordBody = Array<string>;
|
||||||
password: string;
|
type SetPasswordResponse = "ok" | {
|
||||||
}
|
error: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function apiSetArchiveFilePassword(
|
export async function apiSetArchiveFilePassword(
|
||||||
archiveHash: string,
|
archiveHash: string,
|
||||||
password: string
|
password: string
|
||||||
) {
|
): Promise<SetPasswordResponse> {
|
||||||
const pathSpec = `/file/{file_hash}`;
|
|
||||||
const path = `/file/${archiveHash}`;
|
const path = `/file/${archiveHash}`;
|
||||||
const body: IBody = {
|
const body: SetPasswordBody = [password];
|
||||||
password,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiV2Fetch<string>(pathSpec, "PATCH", path, { body });
|
const result = await apiFetch<SetPasswordResponse>(path, { body, method: "PATCH" });
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export { apiFetchArchiveFile, apiSetArchiveFilePassword } from "./archive-file";
|
export { apiFetchArchiveFile, apiSetArchiveFilePassword, type IArchiveFile } from "./archive-file";
|
||||||
export { fetchSearchFileByHash } from "./search-by-hash";
|
export { fetchSearchFileByHash } from "./search-by-hash";
|
||||||
|
|
|
@ -13,8 +13,8 @@ interface IResult {
|
||||||
|
|
||||||
size: number;
|
size: number;
|
||||||
ihash: string;
|
ihash: string;
|
||||||
posts: IPostResult[];
|
|
||||||
|
|
||||||
|
posts: IPostResult[];
|
||||||
discord_posts: IDiscordPostResult[];
|
discord_posts: IDiscordPostResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function ClientProvider({ children }: IProps) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
const clientData: IClientContext = { isRegistered };
|
const clientData: IClientContext = { isRegistered };
|
||||||
changeClient(clientData);
|
changeClient(clientData);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -23,16 +23,51 @@ export interface ILocalStorageSchema {
|
||||||
|
|
||||||
type ILocalStorageName = (typeof storageNames)[number];
|
type ILocalStorageName = (typeof storageNames)[number];
|
||||||
|
|
||||||
export function getLocalStorageItem(name: ILocalStorageName) {
|
let localStorageAvailable: boolean | null = null;
|
||||||
|
|
||||||
|
function checkLocalStorageAvailability(): boolean {
|
||||||
|
if (localStorageAvailable === null) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem("__storage_test__", "__storage_test__");
|
||||||
|
localStorage.removeItem("__storage_test__");
|
||||||
|
localStorageAvailable = true;
|
||||||
|
} catch (error) {
|
||||||
|
localStorageAvailable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localStorageAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalStorageItem(name: ILocalStorageName): string | null {
|
||||||
|
if (!checkLocalStorageAvailability()) {
|
||||||
|
console.warn("LocalStorage is not available.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return localStorage.getItem(name);
|
return localStorage.getItem(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setLocalStorageItem(name: ILocalStorageName, value: string) {
|
export function setLocalStorageItem(name: ILocalStorageName, value: string): void {
|
||||||
localStorage.setItem(name, value);
|
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 {
|
||||||
localStorage.removeItem(name);
|
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() {
|
export function isLocalStorageAvailable() {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars";
|
import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars";
|
||||||
import { DangerousContent } from "#components/dangerous-content";
|
import { DangerousContent } from "#components/dangerous-content";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export function HeaderAd() {
|
export function HeaderAd() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
@ -11,7 +12,6 @@ export function HeaderAd() {
|
||||||
key={key}
|
key={key}
|
||||||
className="ad-container"
|
className="ad-container"
|
||||||
html={atob(HEADER_AD)}
|
html={atob(HEADER_AD)}
|
||||||
allowRerender
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -48,10 +48,33 @@ export function SliderAd() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const key = `${location.pathname}${location.search}`;
|
const key = `${location.pathname}${location.search}`;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const mutation of mutationsList) {
|
||||||
|
if (mutation.type === "childList") {
|
||||||
|
const slideAnimationElements = document.querySelectorAll('[class*="slideAnimation"]');
|
||||||
|
const elementsToRemove = Array.from(slideAnimationElements).slice(1);
|
||||||
|
elementsToRemove.forEach((element) => {
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
document.querySelectorAll('[class*="slideAnimation"]').forEach((element) => {
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return !SLIDER_AD ? undefined : (
|
return !SLIDER_AD ? undefined : (
|
||||||
<DangerousContent
|
<DangerousContent
|
||||||
key={key}
|
key={key}
|
||||||
className="ad-container"
|
className="ad-container-slider"
|
||||||
html={atob(SLIDER_AD)}
|
html={atob(SLIDER_AD)}
|
||||||
allowRerender
|
allowRerender
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -43,6 +43,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--loading {
|
||||||
|
* {
|
||||||
|
opacity: 0.8;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__item {
|
&__item {
|
||||||
&--no-results {
|
&--no-results {
|
||||||
--card-size: $width-phone;
|
--card-size: $width-phone;
|
||||||
|
|
|
@ -11,9 +11,13 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Timestamp({ time, isRelative, className, children }: IProps) {
|
export function Timestamp({ time, isRelative, className, children }: IProps) {
|
||||||
|
if (time === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isClient = useClient();
|
const isClient = useClient();
|
||||||
let dateTime = new Date(time);
|
let dateTime = new Date(time);
|
||||||
let formatted = `${dateTime.getFullYear()}-${dateTime.getMonth().toString().padStart(2, "0")}-${dateTime.getDay().toString().padStart(2, "0")}`;
|
let formatted = `${dateTime.getFullYear()}-${(dateTime.getMonth() + 1).toString().padStart(2, "0")}-${dateTime.getDate().toString().padStart(2, "0")}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<time className={clsx("timestamp", className)} dateTime={time} title={time}>
|
<time className={clsx("timestamp", className)} dateTime={time} title={time}>
|
||||||
|
|
|
@ -147,6 +147,8 @@
|
||||||
|
|
||||||
& > .main {
|
& > .main {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
@media (max-width: $sidebar-min-width) {
|
@media (max-width: $sidebar-min-width) {
|
||||||
padding: 1em 0;
|
padding: 1em 0;
|
||||||
|
@ -239,6 +241,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-preload * {
|
.disable-transitions,
|
||||||
|
.disable-transitions * {
|
||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Fragment, useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "@dr.pogodin/react-helmet";
|
||||||
import { Link, Outlet, ScrollRestoration, useLocation } from "react-router";
|
import { Link, Outlet, ScrollRestoration, useLocation } from "react-router";
|
||||||
import {
|
import {
|
||||||
|
ANNOUNCEMENT_BANNER_GLOBAL,
|
||||||
ARTISTS_OR_CREATORS,
|
ARTISTS_OR_CREATORS,
|
||||||
BANNER_GLOBAL,
|
BANNER_GLOBAL,
|
||||||
DISABLE_DMS,
|
DISABLE_DMS,
|
||||||
|
@ -20,7 +21,6 @@ import {
|
||||||
import { fetchHasPendingDMs } from "#api/dms";
|
import { fetchHasPendingDMs } from "#api/dms";
|
||||||
import { getLocalStorageItem, setLocalStorageItem } from "#storage/local";
|
import { getLocalStorageItem, setLocalStorageItem } from "#storage/local";
|
||||||
import { ClientProvider } from "#hooks";
|
import { ClientProvider } from "#hooks";
|
||||||
import { LoadingIcon } from "#components/loading";
|
|
||||||
import { isRegisteredAccount } from "#entities/account";
|
import { isRegisteredAccount } from "#entities/account";
|
||||||
import { NavEntry, NavItem, NavList, type INavItem } from "./sidebar";
|
import { NavEntry, NavItem, NavList, type INavItem } from "./sidebar";
|
||||||
import { GlobalFooter } from "./footer";
|
import { GlobalFooter } from "./footer";
|
||||||
|
@ -32,11 +32,13 @@ interface ILayoutProps {
|
||||||
interface IGlobalBodyProps extends ILayoutProps {
|
interface IGlobalBodyProps extends ILayoutProps {
|
||||||
isSidebarClosed: boolean;
|
isSidebarClosed: boolean;
|
||||||
closeSidebar: (_?: any, setState?: boolean) => void;
|
closeSidebar: (_?: any, setState?: boolean) => void;
|
||||||
|
noAnim: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGlobalSidebarProps {
|
interface IGlobalSidebarProps {
|
||||||
isSidebarClosed: boolean;
|
isSidebarClosed: boolean;
|
||||||
closeSidebar: (_?: any, setState?: boolean) => void;
|
closeSidebar: (_?: any, setState?: boolean) => void;
|
||||||
|
noAnim: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IHeaderLinkProps {
|
interface IHeaderLinkProps {
|
||||||
|
@ -45,75 +47,97 @@ interface IHeaderLinkProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_MIN_WIDTH = 1020; // match to $sidebar-min-width
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TODO: Matomo integration
|
* TODO: Matomo integration
|
||||||
*/
|
*/
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [isSidebarClosed, switchSidebar] = useState<boolean>(true);
|
const location = useLocation();
|
||||||
|
const isMobileLayout = useRef(window.innerWidth <= SIDEBAR_MIN_WIDTH);
|
||||||
useEffect(() => {
|
const [forceNoAnim, setForceNoAnim] = useState(false);
|
||||||
document.body.firstElementChild!.classList.remove("transition-preload");
|
const [isSidebarClosed, switchSidebar] = useState<boolean>(() => {
|
||||||
|
|
||||||
const sidebarState = getLocalStorageItem("sidebar_state");
|
const sidebarState = getLocalStorageItem("sidebar_state");
|
||||||
|
|
||||||
killAnimations();
|
// mobile devices should always have the sidebar closed due to inconvenience
|
||||||
switchSidebar(sidebarState === "true");
|
if (window.innerWidth <= SIDEBAR_MIN_WIDTH) return true;
|
||||||
|
// keep open unless user closed it
|
||||||
|
if (typeof sidebarState !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return sidebarState === "true";
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if location changed, if so and we're on mobile, close the sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobileLayout.current) {
|
||||||
|
switchSidebar(true);
|
||||||
|
}
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!forceNoAnim) return;
|
||||||
|
// kill transitions after first actual frame got rendered
|
||||||
|
const animFrame = requestAnimationFrame(() => {
|
||||||
|
setForceNoAnim(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animFrame);
|
||||||
|
};
|
||||||
|
}, [forceNoAnim]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onResize() {
|
||||||
|
if (window.innerWidth > SIDEBAR_MIN_WIDTH) {
|
||||||
|
// due to code below, check if the current sidebar state in local storage mismatches
|
||||||
|
const sidebarState = getLocalStorageItem("sidebar_state");
|
||||||
|
if (((typeof sidebarState !== "string") || sidebarState === "false") && isSidebarClosed) {
|
||||||
|
// kill animations, and open sidebar
|
||||||
|
setForceNoAnim(true);
|
||||||
|
switchSidebar(false);
|
||||||
|
}
|
||||||
|
isMobileLayout.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isMobileLayout.current) return;
|
||||||
|
// only trigger once
|
||||||
|
isMobileLayout.current = true;
|
||||||
|
// window was rezied below SIDEBAR_MIN_WIDTH, force no animation else it'll briefly appear (minor)
|
||||||
|
// also close sidebar, mobile devices should always have the sidebar closed
|
||||||
|
setForceNoAnim(true);
|
||||||
|
switchSidebar(true);
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [isSidebarClosed]);
|
||||||
|
|
||||||
function closeSidebar(_?: unknown, setState = true) {
|
function closeSidebar(_?: unknown, setState = true) {
|
||||||
if (setState && window.innerWidth > 1020) {
|
// don't save state for mobile devices, as it should always be closed
|
||||||
|
if (setState && window.innerWidth > SIDEBAR_MIN_WIDTH) {
|
||||||
setLocalStorageItem("sidebar_state", !isSidebarClosed ? "true" : "false");
|
setLocalStorageItem("sidebar_state", !isSidebarClosed ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
switchSidebar((isClosed) => !isClosed);
|
switchSidebar((isClosed) => !isClosed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResize() {
|
|
||||||
const sidebarState = getLocalStorageItem("sidebar_state");
|
|
||||||
|
|
||||||
if (typeof sidebarState !== "string") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTrue = sidebarState === "true";
|
|
||||||
|
|
||||||
if (window.innerWidth <= 1020) {
|
|
||||||
if (isTrue && isSidebarClosed) {
|
|
||||||
killAnimations();
|
|
||||||
closeSidebar(null, false);
|
|
||||||
}
|
|
||||||
} else if (isTrue && !isSidebarClosed) {
|
|
||||||
killAnimations();
|
|
||||||
closeSidebar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function killAnimations() {
|
|
||||||
document.body.firstElementChild!.classList.add("transition-preload");
|
|
||||||
requestAnimationFrame(() =>
|
|
||||||
setInterval(() =>
|
|
||||||
document.body.firstElementChild!.classList.remove("transition-preload")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientProvider>
|
<ClientProvider>
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<GlobalSidebar
|
<GlobalSidebar
|
||||||
isSidebarClosed={isSidebarClosed}
|
isSidebarClosed={isSidebarClosed}
|
||||||
closeSidebar={closeSidebar}
|
closeSidebar={closeSidebar}
|
||||||
|
noAnim={forceNoAnim}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GlobalBody
|
<GlobalBody
|
||||||
isSidebarClosed={isSidebarClosed}
|
isSidebarClosed={isSidebarClosed}
|
||||||
closeSidebar={closeSidebar}
|
closeSidebar={closeSidebar}
|
||||||
|
noAnim={forceNoAnim}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</GlobalBody>
|
</GlobalBody>
|
||||||
|
@ -124,7 +148,7 @@ export function Layout() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
|
function GlobalSidebar({ isSidebarClosed, closeSidebar, noAnim }: IGlobalSidebarProps) {
|
||||||
const navListItems: INavItem[][] = [
|
const navListItems: INavItem[][] = [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
@ -197,13 +221,14 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
const globalSidebarClassName = clsx(
|
|
||||||
"global-sidebar",
|
|
||||||
isSidebarClosed ? "retracted" : "expanded"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={globalSidebarClassName}>
|
<div className={clsx(
|
||||||
|
"global-sidebar",
|
||||||
|
isSidebarClosed ? "retracted" : "expanded",
|
||||||
|
{
|
||||||
|
"disable-transitions": noAnim,
|
||||||
|
}
|
||||||
|
)}>
|
||||||
<NavEntry className="clickable-header-entry">
|
<NavEntry className="clickable-header-entry">
|
||||||
<NavItem
|
<NavItem
|
||||||
link="/"
|
link="/"
|
||||||
|
@ -231,13 +256,12 @@ function GlobalSidebar({ isSidebarClosed, closeSidebar }: IGlobalSidebarProps) {
|
||||||
*/
|
*/
|
||||||
function AccountEntry() {
|
function AccountEntry() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isLoggedIn, switchLoggedIn] = useState(false);
|
const isLoggedIn = isRegisteredAccount();
|
||||||
const [isLoading, switchLoading] = useState<boolean>(true);
|
|
||||||
const [isPendingDMsForReview, switchPendingDMsForReview] = useState(false);
|
const [isPendingDMsForReview, switchPendingDMsForReview] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isRegistered) {
|
if (!isRegistered) {
|
||||||
return;
|
return;
|
||||||
|
@ -248,19 +272,6 @@ function AccountEntry() {
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
switchLoading(true);
|
|
||||||
|
|
||||||
const isRegistered = await isRegisteredAccount();
|
|
||||||
switchLoggedIn(isRegistered);
|
|
||||||
} finally {
|
|
||||||
switchLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
const loggedOutEntries: INavItem[] = [
|
const loggedOutEntries: INavItem[] = [
|
||||||
{
|
{
|
||||||
header: true,
|
header: true,
|
||||||
|
@ -316,9 +327,7 @@ function AccountEntry() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return isLoading ? (
|
return (
|
||||||
<LoadingIcon />
|
|
||||||
) : (
|
|
||||||
<NavEntry
|
<NavEntry
|
||||||
items={isLoggedIn ? loggedInEntries : loggedOutEntries}
|
items={isLoggedIn ? loggedInEntries : loggedOutEntries}
|
||||||
className="account"
|
className="account"
|
||||||
|
@ -329,15 +338,11 @@ function AccountEntry() {
|
||||||
function GlobalBody({
|
function GlobalBody({
|
||||||
isSidebarClosed,
|
isSidebarClosed,
|
||||||
closeSidebar,
|
closeSidebar,
|
||||||
|
noAnim,
|
||||||
children,
|
children,
|
||||||
}: IGlobalBodyProps) {
|
}: IGlobalBodyProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isLoggedIn, switchLoggedIn] = useState(false);
|
const isLoggedIn = isRegisteredAccount();
|
||||||
const [isLoading, switchLoading] = useState<boolean>(true);
|
|
||||||
const contentWrapperClassName = clsx(
|
|
||||||
"content-wrapper",
|
|
||||||
!isSidebarClosed && "shifted"
|
|
||||||
);
|
|
||||||
const backdropClassName = clsx(
|
const backdropClassName = clsx(
|
||||||
"backdrop",
|
"backdrop",
|
||||||
isSidebarClosed && "backdrop-hidden"
|
isSidebarClosed && "backdrop-hidden"
|
||||||
|
@ -347,21 +352,14 @@ function GlobalBody({
|
||||||
isSidebarClosed && "sidebar-retracted"
|
isSidebarClosed && "sidebar-retracted"
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
switchLoading(true);
|
|
||||||
|
|
||||||
const isRegistered = await isRegisteredAccount();
|
|
||||||
switchLoggedIn(isRegistered);
|
|
||||||
} finally {
|
|
||||||
switchLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, [location]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={contentWrapperClassName}>
|
<div className={clsx(
|
||||||
|
"content-wrapper",
|
||||||
|
{
|
||||||
|
"shifted": !isSidebarClosed,
|
||||||
|
"disable-transitions": noAnim,
|
||||||
|
}
|
||||||
|
)}>
|
||||||
<div className={backdropClassName} onClick={closeSidebar} />
|
<div className={backdropClassName} onClick={closeSidebar} />
|
||||||
|
|
||||||
<div className={headerClassName}>
|
<div className={headerClassName}>
|
||||||
|
@ -372,9 +370,7 @@ function GlobalBody({
|
||||||
<HeaderLink url="/artists" text={ARTISTS_OR_CREATORS} />
|
<HeaderLink url="/artists" text={ARTISTS_OR_CREATORS} />
|
||||||
<HeaderLink url="/posts" text="Posts" />
|
<HeaderLink url="/posts" text="Posts" />
|
||||||
<HeaderLink url="/importer" text="Import" className="import" />
|
<HeaderLink url="/importer" text="Import" className="import" />
|
||||||
{isLoading ? (
|
{isLoggedIn ? (
|
||||||
<LoadingIcon />
|
|
||||||
) : isLoggedIn ? (
|
|
||||||
<>
|
<>
|
||||||
<HeaderLink
|
<HeaderLink
|
||||||
url={String(createAccountFavoriteProfilesPageURL())}
|
url={String(createAccountFavoriteProfilesPageURL())}
|
||||||
|
@ -399,7 +395,15 @@ function GlobalBody({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{BANNER_GLOBAL && (
|
{BANNER_GLOBAL && (
|
||||||
<aside dangerouslySetInnerHTML={{ __html: atob(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">
|
<main className="main" id="main">
|
||||||
|
|
|
@ -10,4 +10,3 @@ export type {
|
||||||
IDescriptionTermProps,
|
IDescriptionTermProps,
|
||||||
IDescriptionDetailsProps,
|
IDescriptionDetailsProps,
|
||||||
} from "./description";
|
} 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
|
loader?: LoaderFunction
|
||||||
): LoaderFunction {
|
): LoaderFunction {
|
||||||
return async (args: LoaderFunctionArgs) => {
|
return async (args: LoaderFunctionArgs) => {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isRegistered) {
|
if (!isRegistered) {
|
||||||
throw new Error("You must be registered to access this page.");
|
throw new Error("You must be registered to access this page.");
|
||||||
|
@ -26,7 +26,7 @@ export function createAccountPageLoader(
|
||||||
export async function validateAccountPageLoader(
|
export async function validateAccountPageLoader(
|
||||||
...args: Parameters<LoaderFunction>
|
...args: Parameters<LoaderFunction>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isRegistered) {
|
if (!isRegistered) {
|
||||||
throw new Error("You must be registered to access this page.");
|
throw new Error("You must be registered to access this page.");
|
||||||
|
@ -36,7 +36,7 @@ export async function validateAccountPageLoader(
|
||||||
export async function validateAccountPageAction(
|
export async function validateAccountPageAction(
|
||||||
...args: Parameters<ActionFunction>
|
...args: Parameters<ActionFunction>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isRegistered) {
|
if (!isRegistered) {
|
||||||
throw new Error("You must be registered to access this page.");
|
throw new Error("You must be registered to access this page.");
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import { ICONS_PREPEND, KEMONO_SITE } from "#env/env-vars";
|
import { ICONS_PREPEND, KEMONO_SITE } from "#env/env-vars";
|
||||||
import { IArtistDetails } from "#entities/profiles";
|
import { IArtistDetails } from "#entities/profiles";
|
||||||
import { IPageProps, PageSkeleton } from "./site";
|
import { IPageProps, PageSkeleton } from "./site";
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
.site-section {
|
.site-section {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
padding: 0 0 $size-little;
|
padding: 0 0 $size-little;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
import { ComponentPropsWithoutRef, ReactElement, ReactNode } from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import { SITE_NAME } from "#env/env-vars";
|
import { SITE_NAME } from "#env/env-vars";
|
||||||
|
|
||||||
export interface IPageProps
|
export interface IPageProps
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
@use "./paginator_new";
|
@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 clsx from "clsx";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { createRange } from "#lib/range";
|
|
||||||
import { PAGINATION_LIMIT } from "#lib/pagination";
|
import { PAGINATION_LIMIT } from "#lib/pagination";
|
||||||
import { KemonoLink } from "#components/links";
|
import { KemonoLink } from "#components/links";
|
||||||
|
|
||||||
|
@ -12,7 +11,7 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IPaginatorButtonProps {
|
interface IPaginatorButtonProps {
|
||||||
href?: string;
|
href: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
@ -23,48 +22,52 @@ export function Paginator({
|
||||||
offset = 0,
|
offset = 0,
|
||||||
constructURL,
|
constructURL,
|
||||||
}: IProps) {
|
}: IProps) {
|
||||||
|
// there are less than 50 items, so we don't need to show pagination
|
||||||
|
if (count < PAGINATION_LIMIT) return null;
|
||||||
|
|
||||||
const limit = PAGINATION_LIMIT;
|
const limit = PAGINATION_LIMIT;
|
||||||
const skip = offset;
|
const skip = offset;
|
||||||
|
// is for displaying Showing X - Y of Z, where Y is the current ceiling of range
|
||||||
const currentCeilingOfRange = skip + limit < count ? skip + limit : count;
|
const currentCeilingOfRange = skip + limit < count ? skip + limit : count;
|
||||||
const totalButtons = 5;
|
// number of buttons shown before and after the current page number
|
||||||
const optionalButtons = totalButtons - 2;
|
const buttonsPerSide = 5;
|
||||||
const mandatoryButtons = totalButtons - optionalButtons;
|
// 5 buttons before, 5 buttons after, 1 current page button
|
||||||
|
const totalButtonCount = buttonsPerSide * 2 + 1;
|
||||||
|
// number of buttons that can be hidden for mobile view
|
||||||
|
const optionalButtons = buttonsPerSide - 2;
|
||||||
|
// number of buttons that are always shown no matter what
|
||||||
|
const mandatoryButtons = buttonsPerSide - optionalButtons;
|
||||||
const currentPageNumber = Math.ceil((skip + limit) / limit);
|
const currentPageNumber = Math.ceil((skip + limit) / limit);
|
||||||
const totalPages = Math.ceil(count / limit);
|
const totalPages = Math.ceil(count / limit);
|
||||||
const numberBeforeCurrentPage =
|
|
||||||
totalPages < totalButtons || currentPageNumber < totalButtons
|
// left most button is base page number
|
||||||
? currentPageNumber - 1
|
// we show 5 buttons before and 5 after the current page number!
|
||||||
: totalPages - currentPageNumber < totalButtons
|
// we move the buttons around if they don't fit in left or right side of the current page number
|
||||||
? totalButtons - 1 + (totalButtons - (totalPages - currentPageNumber))
|
// but the total number of buttons is always the same, aka 10 buttons
|
||||||
: totalButtons - 1;
|
|
||||||
const basePageNumber = Math.max(
|
const basePageNumber = Math.max(
|
||||||
currentPageNumber - numberBeforeCurrentPage - 1,
|
totalPages < buttonsPerSide ? 1 // less pages than buttons, start at page 1
|
||||||
|
: currentPageNumber < buttonsPerSide ? 1 // current page number is less than num of buttons to the left, start at page 1
|
||||||
|
: (totalPages - currentPageNumber) < buttonsPerSide // do we have more buttons than pages after the current page number? (nearing the end of the list)
|
||||||
|
? currentPageNumber - buttonsPerSide - (buttonsPerSide - (totalPages - currentPageNumber)) // show 5 buttons, + how many are missing after current page
|
||||||
|
: currentPageNumber - buttonsPerSide, // show 5 buttons before the current page number
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
const showFirstPostsButton = basePageNumber > 1;
|
|
||||||
const showLastPostsButton =
|
// calculate which base page number starts being optional (usually current page number - 2)
|
||||||
currentPageNumber - basePageNumber <
|
|
||||||
totalButtons +
|
|
||||||
(currentPageNumber - basePageNumber < totalButtons
|
|
||||||
? totalButtons - (currentPageNumber + basePageNumber)
|
|
||||||
: 0);
|
|
||||||
const optionalBeforeButtons =
|
const optionalBeforeButtons =
|
||||||
currentPageNumber -
|
currentPageNumber -
|
||||||
mandatoryButtons -
|
mandatoryButtons - // anything before curr page - mandatory buttons is optional
|
||||||
(totalPages - currentPageNumber < mandatoryButtons
|
(totalPages - currentPageNumber < mandatoryButtons // however, we have to check for if we are nearing the end of num of pages, example page 13 of 14
|
||||||
? mandatoryButtons - (totalPages - currentPageNumber)
|
? mandatoryButtons - (totalPages - currentPageNumber) // since we are nearing the end, we will subtract how many mandatory buttons are now missing on the right side
|
||||||
: 0);
|
: 0);
|
||||||
|
|
||||||
|
// calculate which base page number starts being optional (usually current page number + 2, an inverted version of the above)
|
||||||
const optionalAfterButtons =
|
const optionalAfterButtons =
|
||||||
currentPageNumber +
|
currentPageNumber +
|
||||||
mandatoryButtons +
|
mandatoryButtons +
|
||||||
(currentPageNumber - basePageNumber < mandatoryButtons
|
(currentPageNumber - basePageNumber < mandatoryButtons
|
||||||
? mandatoryButtons - (currentPageNumber - basePageNumber)
|
? mandatoryButtons - (currentPageNumber - basePageNumber)
|
||||||
: 0);
|
: 0);
|
||||||
const range = createRange(0, totalButtons * 2 + 1);
|
|
||||||
|
|
||||||
if (!(count > limit)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -73,139 +76,62 @@ export function Paginator({
|
||||||
</small>
|
</small>
|
||||||
|
|
||||||
<menu>
|
<menu>
|
||||||
{!(
|
{
|
||||||
showFirstPostsButton || showLastPostsButton
|
totalPages > totalButtonCount ?
|
||||||
) ? undefined : showFirstPostsButton ? (
|
<PaginatorButton
|
||||||
<PaginatorButton href={constructURL(0)}>{"<<"}</PaginatorButton>
|
className={clsx({ "pagination-button-disabled": currentPageNumber === 1 })}
|
||||||
) : (
|
href={constructURL(0)}
|
||||||
<PaginatorButton
|
>
|
||||||
className={clsx(
|
{"<<"}
|
||||||
"pagination-button-disabled",
|
</PaginatorButton>
|
||||||
currentPageNumber - mandatoryButtons - 1 && "pagination-desktop"
|
: null
|
||||||
)}
|
}
|
||||||
>
|
<PaginatorButton
|
||||||
{"<<"}
|
className={clsx({ "pagination-button-disabled": currentPageNumber === 1 })}
|
||||||
</PaginatorButton>
|
href={constructURL((currentPageNumber - 2) * limit)}
|
||||||
)}
|
>
|
||||||
|
{"<"}
|
||||||
|
</PaginatorButton>
|
||||||
|
{
|
||||||
|
Array.from({ length: buttonsPerSide * 2 + 1 }, (_, index) => {
|
||||||
|
const pageNum = index + basePageNumber;
|
||||||
|
if (pageNum > totalPages) return null; // don't show more than total pages
|
||||||
|
|
||||||
{showFirstPostsButton ? undefined : currentPageNumber -
|
return (
|
||||||
mandatoryButtons -
|
|
||||||
1 ? (
|
|
||||||
<PaginatorButton className="pagination-mobile" href={constructURL(0)}>
|
|
||||||
{"<<"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : totalPages - currentPageNumber > mandatoryButtons &&
|
|
||||||
!showLastPostsButton ? (
|
|
||||||
<PaginatorButton
|
|
||||||
className={clsx("pagination-button-disabled", "pagination-mobile")}
|
|
||||||
>
|
|
||||||
{"<<"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
{currentPageNumber > 1 ? (
|
|
||||||
<PaginatorButton
|
|
||||||
className="prev"
|
|
||||||
href={constructURL((currentPageNumber - 2) * limit)}
|
|
||||||
>
|
|
||||||
{"<"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : (
|
|
||||||
<PaginatorButton className="pagination-button-disabled">
|
|
||||||
{"<"}
|
|
||||||
</PaginatorButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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 = (
|
|
||||||
<PaginatorButton
|
<PaginatorButton
|
||||||
key={index}
|
key={index}
|
||||||
href={
|
href={constructURL((pageNum - 1) * limit)}
|
||||||
page + basePageNumber === currentPageNumber
|
className={clsx({
|
||||||
? undefined
|
"pagination-button-disabled": pageNum === currentPageNumber,
|
||||||
: constructURL(localOffset)
|
"pagination-button-current": pageNum === currentPageNumber,
|
||||||
}
|
"pagination-button-after-current": pageNum === currentPageNumber + 1,
|
||||||
className={clsx(buttonClassName)}
|
"pagination-button-optional": pageNum < optionalBeforeButtons || pageNum > optionalAfterButtons
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
{page + basePageNumber}
|
{pageNum}
|
||||||
</PaginatorButton>
|
</PaginatorButton>
|
||||||
);
|
);
|
||||||
|
})
|
||||||
buttons.push(button);
|
}
|
||||||
}
|
<PaginatorButton
|
||||||
|
className={clsx({
|
||||||
return buttons;
|
"pagination-button-disabled": currentPageNumber === totalPages,
|
||||||
}, [])}
|
"pagination-button-after-current": currentPageNumber === totalPages
|
||||||
|
})}
|
||||||
{currentPageNumber < totalPages ? (
|
href={constructURL(currentPageNumber * limit)}
|
||||||
<PaginatorButton
|
>
|
||||||
className="next"
|
{">"}
|
||||||
href={constructURL(currentPageNumber * limit)}
|
</PaginatorButton>
|
||||||
>
|
{
|
||||||
{">"}
|
totalPages > totalButtonCount ?
|
||||||
</PaginatorButton>
|
<PaginatorButton
|
||||||
) : (
|
className={clsx({ "pagination-button-disabled": currentPageNumber === totalPages })}
|
||||||
<PaginatorButton
|
href={constructURL((totalPages - 1) * limit)}
|
||||||
className={clsx(
|
>
|
||||||
"pagination-button-disabled",
|
{">>"}
|
||||||
totalPages && " pagination-button-after-current"
|
</PaginatorButton>
|
||||||
)}
|
: null
|
||||||
>
|
}
|
||||||
{">"}
|
|
||||||
</PaginatorButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!(
|
|
||||||
showFirstPostsButton || showLastPostsButton
|
|
||||||
) ? undefined : showLastPostsButton ? (
|
|
||||||
<PaginatorButton href={constructURL((totalPages - 1) * limit)}>
|
|
||||||
{">>"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : (
|
|
||||||
<PaginatorButton
|
|
||||||
className={clsx(
|
|
||||||
"pagination-button-disabled",
|
|
||||||
totalPages - currentPageNumber > mandatoryButtons &&
|
|
||||||
"pagination-desktop"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{">>"}
|
|
||||||
</PaginatorButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLastPostsButton ? undefined : totalPages - currentPageNumber >
|
|
||||||
mandatoryButtons ? (
|
|
||||||
<PaginatorButton
|
|
||||||
href={constructURL((totalPages - 1) * limit)}
|
|
||||||
className="pagination-mobile"
|
|
||||||
>
|
|
||||||
{">>"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : currentPageNumber > optionalButtons && !showFirstPostsButton ? (
|
|
||||||
<PaginatorButton
|
|
||||||
className={clsx("pagination-button-disabled", "pagination-mobile")}
|
|
||||||
>
|
|
||||||
{">>"}
|
|
||||||
</PaginatorButton>
|
|
||||||
) : undefined}
|
|
||||||
</menu>
|
</menu>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
@use "../css/config/variables/sass" as *;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
TODO: Spread the styles around page/component/block files.
|
TODO: Spread the styles around page/component/block files.
|
||||||
*/
|
*/
|
||||||
|
@ -279,81 +281,6 @@
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* pagination */
|
|
||||||
.paginator {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paginator menu {
|
|
||||||
padding: 0;
|
|
||||||
margin: 5px auto;
|
|
||||||
display: table;
|
|
||||||
|
|
||||||
& > li,
|
|
||||||
& > a {
|
|
||||||
border: 1px solid var(--colour0-tertirary);
|
|
||||||
display: table-cell;
|
|
||||||
line-height: 33px;
|
|
||||||
color: var(--colour0-secondary);
|
|
||||||
user-select: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
min-width: 35px;
|
|
||||||
transition-property: color background-color;
|
|
||||||
|
|
||||||
@media (min-width: #{600px + 1}) {
|
|
||||||
&.pagination-mobile:not(:last-child) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pagination-mobile:last-child {
|
|
||||||
min-width: unset;
|
|
||||||
border-right: none;
|
|
||||||
border-top: none;
|
|
||||||
border-bottom: none;
|
|
||||||
& > * {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
&.pagination-button-optional {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
&.pagination-desktop {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.pagination-button-disabled {
|
|
||||||
color: var(--colour0-tertirary);
|
|
||||||
background-color: unset;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
&.pagination-button-current {
|
|
||||||
background-color: var(--anchour-internal-colour2-primary);
|
|
||||||
color: var(--anchour-internal-colour1-secondary);
|
|
||||||
border-color: var(--anchour-internal-colour1-primary);
|
|
||||||
}
|
|
||||||
&.pagination-button-after-current {
|
|
||||||
border-left: 1px solid var(--anchour-internal-colour1-primary);
|
|
||||||
}
|
|
||||||
&:not(.pagination-button-disabled):hover,
|
|
||||||
&:not(.pagination-button-disabled):focus,
|
|
||||||
&:not(.pagination-button-disabled):active {
|
|
||||||
background-color: var(--colour0-tertirary);
|
|
||||||
color: var(--colour0-primary);
|
|
||||||
}
|
|
||||||
& > b {
|
|
||||||
padding: 0 9px;
|
|
||||||
}
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
menu li {
|
menu li {
|
||||||
display: inline;
|
display: inline;
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
@ -377,46 +304,87 @@ menu li {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
#paginator-bottom {
|
|
||||||
margin-bottom: env(safe-area-inset-bottom);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* search forms */
|
/* search forms */
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: table;
|
display: flex;
|
||||||
padding: 0.5rem;
|
flex-direction: column;
|
||||||
margin-left: 5px;
|
align-items: stretch;
|
||||||
|
padding: 8px;
|
||||||
background-color: #282a2e;
|
background-color: #282a2e;
|
||||||
margin: 0px auto 8px auto;
|
margin: 0px auto 8px auto;
|
||||||
|
|
||||||
&-hidden {
|
& .wrapper {
|
||||||
display: none;
|
display: flex;
|
||||||
|
|
||||||
|
&:not(:last-of-type) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& > div {
|
& img {
|
||||||
display: table-row;
|
width: 24px;
|
||||||
line-height: 1.5em;
|
height: 24px;
|
||||||
margin-bottom: 2em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& small {
|
& input,
|
||||||
display: block;
|
& select {
|
||||||
line-height: normal;
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& button {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0px;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: unset;
|
||||||
|
box-shadow: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.search-button {
|
||||||
|
background: var(--colour1-secondary);
|
||||||
|
color: var(--colour0-primary);
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-bottom-left-radius: 0px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& label,
|
|
||||||
& input {
|
& input {
|
||||||
display: table-cell;
|
padding-right: 0px;
|
||||||
padding-right: 1em;
|
box-shadow: none;
|
||||||
white-space: nowrap;
|
border-top-right-radius: 0px;
|
||||||
text-align: left;
|
border-bottom-right-radius: 0px;
|
||||||
|
padding-left: 8px;
|
||||||
|
width: 500px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& label {
|
& .sort_dir {
|
||||||
text-align: right;
|
background-image: linear-gradient(#282a2e, #111111);
|
||||||
font-weight: 700;
|
|
||||||
|
& > .asc {
|
||||||
|
transform: scaleY(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& select {
|
||||||
|
color: var(--colour0-primary);
|
||||||
|
|
||||||
|
option {
|
||||||
|
color: var(--colour0-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .sort_by {
|
||||||
|
margin: 0 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -449,9 +417,11 @@ thead th {
|
||||||
}
|
}
|
||||||
|
|
||||||
.ad-container {
|
.ad-container {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ad-container * {
|
.ad-container-slider {
|
||||||
max-width: 100%;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,6 @@ export async function logoutAccount(isLocalOnly?: boolean) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isRegisteredAccount() {
|
export function isRegisteredAccount() {
|
||||||
return getLocalStorageItem("logged_in") === "yes";
|
return getLocalStorageItem("logged_in") === "yes";
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,7 @@ export async function isFavouritePost(
|
||||||
export async function findFavouritePosts(
|
export async function findFavouritePosts(
|
||||||
postsData: IPostData[]
|
postsData: IPostData[]
|
||||||
): Promise<IPostData[]> {
|
): Promise<IPostData[]> {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
// return early for non-registered users
|
// return early for non-registered users
|
||||||
// instead of rewriting all flows depending on it
|
// instead of rewriting all flows depending on it
|
||||||
|
@ -126,8 +126,8 @@ export async function getAllFavouritePosts(
|
||||||
return prev.faved_seq === next.faved_seq
|
return prev.faved_seq === next.faved_seq
|
||||||
? 0
|
? 0
|
||||||
: prev.faved_seq < next.faved_seq
|
: prev.faved_seq < next.faved_seq
|
||||||
? -1
|
? -1
|
||||||
: 1;
|
: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
import { IFavouriteArtist } from "../types";
|
import { IFavouriteArtist } from "../types";
|
||||||
import { isRegisteredAccount } from "./auth";
|
import { isRegisteredAccount } from "./auth";
|
||||||
|
|
||||||
interface IProfileData extends Pick<IFavouriteArtist, "service" | "id"> {}
|
interface IProfileData extends Pick<IFavouriteArtist, "service" | "id"> { }
|
||||||
|
|
||||||
let favouriteProfiles:
|
let favouriteProfiles:
|
||||||
| Awaited<ReturnType<typeof fetchFavouriteProfiles>>
|
| Awaited<ReturnType<typeof fetchFavouriteProfiles>>
|
||||||
|
@ -28,7 +28,7 @@ export async function isFavouriteProfile(service: string, profileID: string) {
|
||||||
export async function findFavouriteProfiles(
|
export async function findFavouriteProfiles(
|
||||||
profilesData: IProfileData[]
|
profilesData: IProfileData[]
|
||||||
): Promise<IProfileData[]> {
|
): Promise<IProfileData[]> {
|
||||||
const isRegistered = await isRegisteredAccount();
|
const isRegistered = isRegisteredAccount();
|
||||||
|
|
||||||
// return early for non-registered users
|
// return early for non-registered users
|
||||||
// instead of rewriting all flows depending on it
|
// instead of rewriting all flows depending on it
|
||||||
|
@ -110,8 +110,8 @@ export async function getAllFavouriteProfiles(
|
||||||
return prev.faved_seq === next.faved_seq
|
return prev.faved_seq === next.faved_seq
|
||||||
? 0
|
? 0
|
||||||
: prev.faved_seq < next.faved_seq
|
: prev.faved_seq < next.faved_seq
|
||||||
? -1
|
? -1
|
||||||
: 1;
|
: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "last_imported": {
|
case "last_imported": {
|
||||||
|
|
|
@ -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 {
|
export type {
|
||||||
IFile,
|
IFile,
|
||||||
IFanCard,
|
IFanCard,
|
||||||
IShare,
|
IShare,
|
||||||
IShareFile,
|
IShareFile,
|
||||||
IArchiveFile,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
|
@ -47,12 +47,3 @@ export interface IShareFile {
|
||||||
ext: string;
|
ext: string;
|
||||||
added: string;
|
added: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IArchiveFile {
|
|
||||||
password?: string;
|
|
||||||
file: {
|
|
||||||
hash: string;
|
|
||||||
ext: string;
|
|
||||||
};
|
|
||||||
file_list: string[];
|
|
||||||
}
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { cleanupBody } from "./clean-body";
|
||||||
import { IPostOverviewProps } from "./types";
|
import { IPostOverviewProps } from "./types";
|
||||||
import { PostVideo } from "./video";
|
import { PostVideo } from "./video";
|
||||||
import { FlagButton } from "./flag-button";
|
import { FlagButton } from "./flag-button";
|
||||||
|
import { KemonoLink } from "#components/links";
|
||||||
|
|
||||||
import * as styles from "./body.module.scss";
|
import * as styles from "./body.module.scss";
|
||||||
|
|
||||||
|
@ -153,8 +154,8 @@ function PostAttachment({
|
||||||
</a>
|
</a>
|
||||||
{isArchiveFile && (
|
{isArchiveFile && (
|
||||||
<>
|
<>
|
||||||
{/* TODO: a proper URL function */}(
|
{" "}(
|
||||||
<a href={`/posts/archives/${stem}`}>browse »</a>)
|
<KemonoLink url={`/posts/archives/${stem}`}>browse »</KemonoLink>)
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -172,12 +172,7 @@ function PostComment({
|
||||||
{service !== "boosty" ? (
|
{service !== "boosty" ? (
|
||||||
content
|
content
|
||||||
) : (
|
) : (
|
||||||
<Preformatted className={styles.content}>
|
<Preformatted className={styles.content} dangerouslySetInnerHTML={{__html: content}}/>
|
||||||
{content.replace(
|
|
||||||
'/size/large" title="',
|
|
||||||
'/size/small" title="'
|
|
||||||
)}
|
|
||||||
</Preformatted>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</Preformatted>
|
</Preformatted>
|
||||||
|
|
|
@ -207,7 +207,7 @@ function FavoriteButton({ service, profileID, postID }: IFavoriteButtonProps) {
|
||||||
try {
|
try {
|
||||||
switchLoading(true);
|
switchLoading(true);
|
||||||
|
|
||||||
const isLoggedIn = await isRegisteredAccount();
|
const isLoggedIn = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -108,7 +108,7 @@ function FavouriteButton({ service, profileID }: IFavouriteButtonProps) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
switchLoading(true);
|
switchLoading(true);
|
||||||
const isLoggedIn = await isRegisteredAccount();
|
const isLoggedIn = isRegisteredAccount();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -54,10 +54,7 @@ export async function getArtists({
|
||||||
const normalizedName = profile.name.trim().toLowerCase();
|
const normalizedName = profile.name.trim().toLowerCase();
|
||||||
const normalizedID = profile.id.trim().toLowerCase();
|
const normalizedID = profile.id.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
isQueryMatched = normalizedID.includes(normalizedQuery) || normalizedName.includes(normalizedQuery);
|
||||||
normalizedID.includes(normalizedQuery) ||
|
|
||||||
normalizedName.includes(normalizedQuery)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return isServiceMatched && isQueryMatched;
|
return isServiceMatched && isQueryMatched;
|
||||||
|
@ -68,8 +65,8 @@ export async function getArtists({
|
||||||
return prev.favorited === next.favorited
|
return prev.favorited === next.favorited
|
||||||
? 0
|
? 0
|
||||||
: prev.favorited > next.favorited
|
: prev.favorited > next.favorited
|
||||||
? 1
|
? 1
|
||||||
: -1;
|
: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "service": {
|
case "service": {
|
||||||
|
@ -89,8 +86,8 @@ export async function getArtists({
|
||||||
return prevIndexed === nextIndexed
|
return prevIndexed === nextIndexed
|
||||||
? 0
|
? 0
|
||||||
: prevIndexed > nextIndexed
|
: prevIndexed > nextIndexed
|
||||||
? 1
|
? 1
|
||||||
: -1;
|
: -1;
|
||||||
}
|
}
|
||||||
case "updated": {
|
case "updated": {
|
||||||
// @ts-expect-error fuck dates
|
// @ts-expect-error fuck dates
|
||||||
|
@ -101,8 +98,8 @@ export async function getArtists({
|
||||||
return prevUpdated === nextUpdated
|
return prevUpdated === nextUpdated
|
||||||
? 0
|
? 0
|
||||||
: prevUpdated > nextUpdated
|
: prevUpdated > nextUpdated
|
||||||
? 1
|
? 1
|
||||||
: -1;
|
: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
|
@ -118,11 +115,11 @@ export async function getArtists({
|
||||||
order === "asc"
|
order === "asc"
|
||||||
? filteredArtists.slice(offset, offset + PAGINATION_LIMIT)
|
? filteredArtists.slice(offset, offset + PAGINATION_LIMIT)
|
||||||
: filteredArtists
|
: filteredArtists
|
||||||
.slice(
|
.slice(
|
||||||
-offset + -PAGINATION_LIMIT,
|
-offset + -PAGINATION_LIMIT,
|
||||||
offset === 0 ? undefined : -offset
|
offset === 0 ? undefined : -offset
|
||||||
)
|
)
|
||||||
.reverse();
|
.reverse();
|
||||||
const profilesData: Parameters<typeof findFavouriteProfiles>[0] =
|
const profilesData: Parameters<typeof findFavouriteProfiles>[0] =
|
||||||
slicedArtists.map(({ id, service }) => {
|
slicedArtists.map(({ id, service }) => {
|
||||||
return { id, service };
|
return { id, service };
|
||||||
|
|
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;
|
export const BANNER_GLOBAL = BUNDLER_ENV_BANNER_GLOBAL;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* b64-encoded string
|
||||||
|
*/
|
||||||
|
export const ANNOUNCEMENT_BANNER_GLOBAL = BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* b64-encoded string
|
* b64-encoded string
|
||||||
*/
|
*/
|
||||||
|
@ -106,6 +111,7 @@ declare global {
|
||||||
const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[];
|
const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[];
|
||||||
const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | undefined;
|
const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | undefined;
|
||||||
const BUNDLER_ENV_BANNER_GLOBAL: string | undefined;
|
const BUNDLER_ENV_BANNER_GLOBAL: string | undefined;
|
||||||
|
const BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL: string | undefined;
|
||||||
const BUNDLER_ENV_BANNER_WELCOME: string | undefined;
|
const BUNDLER_ENV_BANNER_WELCOME: string | undefined;
|
||||||
const BUNDLER_ENV_HOME_BACKGROUND_IMAGE: string | undefined;
|
const BUNDLER_ENV_HOME_BACKGROUND_IMAGE: string | undefined;
|
||||||
const BUNDLER_ENV_HOME_MASCOT_PATH: string | undefined;
|
const BUNDLER_ENV_HOME_MASCOT_PATH: string | undefined;
|
||||||
|
|
|
@ -11,11 +11,12 @@ jsonHeaders.append("Content-Type", "application/json");
|
||||||
* TODO: discriminated union with JSONable body signature
|
* TODO: discriminated union with JSONable body signature
|
||||||
*/
|
*/
|
||||||
interface IOptions extends Omit<RequestInit, "headers"> {
|
interface IOptions extends Omit<RequestInit, "headers"> {
|
||||||
method: "GET" | "POST" | "DELETE";
|
method: "GET" | "HEAD" | "POST" | "PUT" | "DELETE" | "CONNECT" | "OPTIONS" | "TRACE" | "PATCH";
|
||||||
body?: any;
|
body?: any;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Make this not throw.
|
||||||
/**
|
/**
|
||||||
* Generic request for Kemono API.
|
* Generic request for Kemono API.
|
||||||
* @param path
|
* @param path
|
||||||
|
|
|
@ -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 { HeaderAd, SliderAd } from "#components/advs";
|
||||||
import { Paginator } from "#components/pagination";
|
import { Paginator } from "#components/pagination";
|
||||||
import { CardList, DMCard } from "#components/cards";
|
import { CardList, DMCard } from "#components/cards";
|
||||||
import { FormRouter } from "#components/forms";
|
import { ButtonSubmit, FormRouter } from "#components/forms";
|
||||||
import { IApprovedDM } from "#entities/dms";
|
import { IApprovedDM } from "#entities/dms";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
query?: string;
|
query?: string;
|
||||||
|
@ -18,6 +19,7 @@ interface IProps {
|
||||||
|
|
||||||
export function DMsPage() {
|
export function DMsPage() {
|
||||||
const { query, count, dms, offset } = useLoaderData() as IProps;
|
const { query, count, dms, offset } = useLoaderData() as IProps;
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const title = "DMs";
|
const title = "DMs";
|
||||||
const heading = "DMs";
|
const heading = "DMs";
|
||||||
|
|
||||||
|
@ -27,32 +29,18 @@ export function DMsPage() {
|
||||||
<HeaderAd />
|
<HeaderAd />
|
||||||
|
|
||||||
<div className="paginator" id="paginator-top">
|
<div className="paginator" id="paginator-top">
|
||||||
|
<SearchForm
|
||||||
|
query={query}
|
||||||
|
onLoadingChange={(loading) => setIsLoading(loading)}
|
||||||
|
/>
|
||||||
<Paginator
|
<Paginator
|
||||||
count={count}
|
count={count}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
constructURL={(offset) => String(createDMsPageURL(offset, query))}
|
constructURL={(offset) => String(createDMsPageURL(offset, query))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormRouter
|
|
||||||
action="/dms"
|
|
||||||
method="GET"
|
|
||||||
encType="application/x-www-form-urlencoded"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
id="q"
|
|
||||||
className="search-input"
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
autoComplete="off"
|
|
||||||
defaultValue={query}
|
|
||||||
minLength={3}
|
|
||||||
placeholder="search for dms..."
|
|
||||||
/>
|
|
||||||
<input type="submit" style={{ display: "none" }} />
|
|
||||||
</FormRouter>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardList layout="phone">
|
<CardList layout="phone" className={isLoading ? "card-list--loading" : ""}>
|
||||||
{count === 0 ? (
|
{count === 0 ? (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<h2 className="site-section__subheading">
|
<h2 className="site-section__subheading">
|
||||||
|
@ -78,6 +66,69 @@ export function DMsPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ISearchFormProps
|
||||||
|
extends Pick<IProps, "query"> {
|
||||||
|
onLoadingChange: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchForm({ query, onLoadingChange }: ISearchFormProps) {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
onLoadingChange(true);
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (target.form) target.form.requestSubmit();
|
||||||
|
onLoadingChange(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormRouter
|
||||||
|
id="search-form"
|
||||||
|
className="search-form"
|
||||||
|
method="GET"
|
||||||
|
autoComplete="off"
|
||||||
|
noValidate={true}
|
||||||
|
acceptCharset="UTF-8"
|
||||||
|
statusSection={null}
|
||||||
|
submitButton={undefined}
|
||||||
|
style={{ maxWidth: "fit-content" }}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
<div className="wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
id="q"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={query}
|
||||||
|
minLength={3}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
<ButtonSubmit
|
||||||
|
disabled={state === "loading" || state === "submitting"}
|
||||||
|
className="search-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
onLoadingChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src='/static/menu/search.svg' />
|
||||||
|
</ButtonSubmit>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
|
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
|
||||||
const searchParams = new URL(request.url).searchParams;
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,8 @@ export function DMCAPage() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Notices may be shared to 3rd party's for due diligence and transparency
|
Notices may be shared to third parties for due diligence and transparency
|
||||||
purposes
|
purposes.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import SwaggerUI from "swagger-ui-react";
|
import SwaggerUI from "swagger-ui-react";
|
||||||
import { PageSkeleton } from "#components/pages";
|
import { PageSkeleton } from "#components/pages";
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,35 @@
|
||||||
@use "../../css/config/variables/sass.scss" as *;
|
@use "../../css/config/variables/sass.scss" as *;
|
||||||
|
|
||||||
.article {
|
.article {
|
||||||
// TODO: remove once layout is rewritten
|
h3 {
|
||||||
margin: 0 auto;
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--negative-colour1-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
height: 30px;
|
||||||
|
user-select: all;
|
||||||
|
background-color: var(--colour1-secondary);
|
||||||
|
border: 1px solid var(--colour1-tertiary);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
|
ul {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="submit"]:disabled {
|
||||||
|
color: hsl(0deg 0% 40%);
|
||||||
|
background-image: linear-gradient(#4a5059, #4a5059);
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import {
|
import {
|
||||||
ActionFunctionArgs,
|
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
redirect,
|
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
|
useNavigate,
|
||||||
} from "react-router";
|
} from "react-router";
|
||||||
import { createFilePageURL } from "#lib/urls";
|
|
||||||
import { apiFetchArchiveFile, apiSetArchiveFilePassword } from "#api/files";
|
import { apiFetchArchiveFile, apiSetArchiveFilePassword } from "#api/files";
|
||||||
import { PageSkeleton } from "#components/pages";
|
import { PageSkeleton } from "#components/pages";
|
||||||
import { ArchiveFileOverview, IArchiveFile } from "#entities/files";
|
import { IS_FILE_SERVING_ENABLED } from "#env/env-vars";
|
||||||
|
import { createArchiveFileURL } from "#lib/urls";
|
||||||
|
import { KemonoLink } from "#components/links";
|
||||||
|
import { IArchiveFile } from "#api/files";
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
import * as styles from "./archive.module.scss";
|
import * as styles from "./archive.module.scss";
|
||||||
|
|
||||||
|
@ -15,14 +17,107 @@ interface IProps {
|
||||||
archive: IArchiveFile;
|
archive: IArchiveFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IFileItemProps {
|
||||||
|
name: string;
|
||||||
|
archiveHash: string;
|
||||||
|
archiveExtension: string;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileItem({
|
||||||
|
name,
|
||||||
|
archiveHash,
|
||||||
|
archiveExtension,
|
||||||
|
password,
|
||||||
|
}: IFileItemProps) {
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{!IS_FILE_SERVING_ENABLED ? (
|
||||||
|
name
|
||||||
|
) : (
|
||||||
|
<KemonoLink
|
||||||
|
url={String(
|
||||||
|
createArchiveFileURL(archiveHash, archiveExtension, name, password)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</KemonoLink>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ArchiveFilePage() {
|
export function ArchiveFilePage() {
|
||||||
const { archive } = useLoaderData() as IProps;
|
const { archive: { file: { hash, ext }, file_list, password: loaderPassword } } = useLoaderData() as IProps;
|
||||||
const title = `Archive file "${archive.file.hash}" details`;
|
const title = `Archive file "${hash}" details`;
|
||||||
const heading = "Archive File Details";
|
const heading = "Archive File Details";
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [password, setPassword] = useState(loaderPassword);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function submitPassword(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
let form = event.target as HTMLFormElement;
|
||||||
|
let password = (form.elements.namedItem("password-input") as HTMLInputElement).value;
|
||||||
|
if (!password) {
|
||||||
|
setError("Please enter a password.");
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await apiSetArchiveFilePassword(hash, password);
|
||||||
|
setPassword(password);
|
||||||
|
} catch {
|
||||||
|
setError("Invalid password");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSkeleton name="archives" title={title} heading={heading}>
|
<PageSkeleton name="archives" title={title} heading={heading}>
|
||||||
<ArchiveFileOverview className={styles.article} archiveFile={archive} />
|
<small>
|
||||||
|
<KemonoLink url="#" onClick={() => navigate(-1)}>« Go Back</KemonoLink>{" | "}
|
||||||
|
<KemonoLink url={`/search_hash?hash=${hash}`}>Find Posts</KemonoLink>
|
||||||
|
</small>
|
||||||
|
<article className={styles.article}>
|
||||||
|
<header>
|
||||||
|
<h3>Hash: {hash}</h3>
|
||||||
|
{password ? (
|
||||||
|
<h5>Password: <code className={styles.code}>{password}</code></h5>
|
||||||
|
) : (password === "") ? (
|
||||||
|
<>
|
||||||
|
<span>Password required but not found.</span>
|
||||||
|
<form onSubmit={submitPassword}>
|
||||||
|
<input type="text" name="password-input" placeholder="Password" autoComplete="off" />
|
||||||
|
<input type="submit" value={loading ? "Testing..." : "Try Password"} disabled={loading}></input>
|
||||||
|
<span className={styles.error} hidden={!error}>{error}</span>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Password not required.</>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h5>Files</h5>
|
||||||
|
{file_list.length === 0 ? (
|
||||||
|
<>Archive is empty or missing password.</>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{file_list.map((filename, index) => (
|
||||||
|
<FileItem
|
||||||
|
key={`${filename}-${index}`}
|
||||||
|
name={filename}
|
||||||
|
archiveHash={hash}
|
||||||
|
archiveExtension={ext}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
</PageSkeleton>
|
</PageSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -44,7 +139,7 @@ export async function loader({ params }: LoaderFunctionArgs): Promise<IProps> {
|
||||||
archive,
|
archive,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
export async function action({ params, request }: ActionFunctionArgs) {
|
export async function action({ params, request }: ActionFunctionArgs) {
|
||||||
try {
|
try {
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
|
@ -78,3 +173,4 @@ export async function action({ params, request }: ActionFunctionArgs) {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
|
@ -16,13 +16,7 @@ div.content-wrapper {
|
||||||
min-height: 450px;
|
min-height: 450px;
|
||||||
background-color: rgba(0, 0, 0, 0.7);
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
padding: $size-small;
|
padding: $size-small;
|
||||||
border: 5px solid var(--anchour-internal-colour2-primary);
|
height: 100%;
|
||||||
border-radius: 10px;
|
|
||||||
margin: 1em;
|
|
||||||
|
|
||||||
@media (max-width: $width-tablet) {
|
|
||||||
background-color: #3b3e44;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.jumbo-welcome-mascot {
|
.jumbo-welcome-mascot {
|
||||||
|
|
|
@ -45,19 +45,22 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
|
||||||
{ name: "session_key" },
|
{ name: "session_key" },
|
||||||
],
|
],
|
||||||
validate({ session_key }) {
|
validate({ session_key }) {
|
||||||
if (!session_key.match(/^\d+_\w+$/) || session_key.length > MAX_LENGTH) {
|
if (!/^\d+_\w+$/.test(session_key) || session_key.length > MAX_LENGTH) {
|
||||||
return "Invalid key.";
|
return "Invalid key.";
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
afdian: {
|
afdian: {
|
||||||
inputs: [
|
inputs: [
|
||||||
{ name: "session_key" },
|
{ name: "session_key", label: "Auth Token", hint: "Can be found in cookies -> auth_token." },
|
||||||
],
|
],
|
||||||
validate({ session_key }) {
|
validate({ session_key }) {
|
||||||
if (session_key.length > MAX_LENGTH) {
|
if (session_key.length > MAX_LENGTH) {
|
||||||
return "Key is too long.";
|
return "Key is too long.";
|
||||||
}
|
}
|
||||||
|
if (!/^[a-f0-9]+_\d+$/.test(session_key)) {
|
||||||
|
return "Invalid key.";
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
boosty: {
|
boosty: {
|
||||||
|
@ -78,7 +81,7 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
|
||||||
{ name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." },
|
{ name: "channel_ids", label: "Channel IDs", hint: "Separate with commas." },
|
||||||
],
|
],
|
||||||
validate({ session_key, channel_ids }) {
|
validate({ session_key, channel_ids }) {
|
||||||
if (!session_key.match(/^(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})$/)) {
|
if (!/^(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27,})/i.test(session_key)) {
|
||||||
return "Invalid token format.";
|
return "Invalid token format.";
|
||||||
}
|
}
|
||||||
for (const id of channel_ids.split(/\s*,\s*/)) {
|
for (const id of channel_ids.split(/\s*,\s*/)) {
|
||||||
|
@ -151,10 +154,7 @@ const PAYSITES: { [name: string]: PaysiteForm } = {
|
||||||
return "User ID must consist of only digits.";
|
return "User ID must consist of only digits.";
|
||||||
}
|
}
|
||||||
if (!bc_token.match(/^[a-f0-9]{40}$/)) {
|
if (!bc_token.match(/^[a-f0-9]{40}$/)) {
|
||||||
return "Invalid BC token (expected 40 hexadecimal characters).";
|
return "Invalid BC token";
|
||||||
}
|
|
||||||
if (!/^[\x00-\x7F]*$/.test(user_agent)) {
|
|
||||||
return "Invalid User-Agent (contains non-ASCII characters).";
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
|
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import {
|
import {
|
||||||
ICONS_PREPEND,
|
ICONS_PREPEND,
|
||||||
IS_ARCHIVER_ENABLED,
|
IS_ARCHIVER_ENABLED,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
|
import { LoaderFunctionArgs, useLoaderData, Link } from "react-router";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import {
|
import {
|
||||||
ICONS_PREPEND,
|
ICONS_PREPEND,
|
||||||
IS_ARCHIVER_ENABLED,
|
IS_ARCHIVER_ENABLED,
|
||||||
|
|
|
@ -6,9 +6,10 @@ import { PageSkeleton } from "#components/pages";
|
||||||
import { Paginator } from "#components/pagination";
|
import { Paginator } from "#components/pagination";
|
||||||
import { FooterAd, HeaderAd, SliderAd } from "#components/advs";
|
import { FooterAd, HeaderAd, SliderAd } from "#components/advs";
|
||||||
import { CardList, PostCard } from "#components/cards";
|
import { CardList, PostCard } from "#components/cards";
|
||||||
import { FormRouter, FormSection } from "#components/forms";
|
import { ButtonSubmit, FormRouter } from "#components/forms";
|
||||||
import { IPost } from "#entities/posts";
|
import { IPost } from "#entities/posts";
|
||||||
import { findFavouritePosts, findFavouriteProfiles } from "#entities/account";
|
import { findFavouritePosts, findFavouriteProfiles } from "#entities/account";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
count: number;
|
count: number;
|
||||||
|
@ -22,12 +23,23 @@ interface IProps {
|
||||||
export function PostsPage() {
|
export function PostsPage() {
|
||||||
const { count, trueCount, offset, query, posts, tags } =
|
const { count, trueCount, offset, query, posts, tags } =
|
||||||
useLoaderData() as IProps;
|
useLoaderData() as IProps;
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const title = "Posts";
|
const title = "Posts";
|
||||||
const heading = "Posts";
|
const heading = "Posts";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSkeleton name="posts" title={title} heading={heading}>
|
<PageSkeleton name="posts" title={title} heading={heading}>
|
||||||
|
<SliderAd />
|
||||||
|
|
||||||
|
<HeaderAd />
|
||||||
|
|
||||||
<div className="paginator" id="paginator-top">
|
<div className="paginator" id="paginator-top">
|
||||||
|
<SearchForm
|
||||||
|
query={query}
|
||||||
|
tags={tags}
|
||||||
|
onLoadingChange={(loading) => setIsLoading(loading)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Paginator
|
<Paginator
|
||||||
count={count}
|
count={count}
|
||||||
true_count={trueCount}
|
true_count={trueCount}
|
||||||
|
@ -36,26 +48,9 @@ export function PostsPage() {
|
||||||
String(createPostsPageURL(offset, query, tags))
|
String(createPostsPageURL(offset, query, tags))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FormRouter method="GET">
|
|
||||||
<FormSection>
|
|
||||||
<input
|
|
||||||
id="q"
|
|
||||||
className="search-input"
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
aria-autocomplete="none"
|
|
||||||
defaultValue={query}
|
|
||||||
minLength={2}
|
|
||||||
placeholder="search for posts..."
|
|
||||||
/>
|
|
||||||
</FormSection>
|
|
||||||
</FormRouter>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SliderAd />
|
<CardList className={isLoading ? "card-list--loading" : ""}>
|
||||||
<HeaderAd />
|
|
||||||
|
|
||||||
<CardList>
|
|
||||||
{count === 0 ? (
|
{count === 0 ? (
|
||||||
<div className="card-list__item--no-results">
|
<div className="card-list__item--no-results">
|
||||||
<h2 className="subtitle">Nobody here but us chickens!</h2>
|
<h2 className="subtitle">Nobody here but us chickens!</h2>
|
||||||
|
@ -73,8 +68,6 @@ export function PostsPage() {
|
||||||
)}
|
)}
|
||||||
</CardList>
|
</CardList>
|
||||||
|
|
||||||
<FooterAd />
|
|
||||||
|
|
||||||
<div className="paginator" id="paginator-bottom">
|
<div className="paginator" id="paginator-bottom">
|
||||||
<Paginator
|
<Paginator
|
||||||
count={count}
|
count={count}
|
||||||
|
@ -85,10 +78,75 @@ export function PostsPage() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FooterAd />
|
||||||
</PageSkeleton>
|
</PageSkeleton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ISearchFormProps
|
||||||
|
extends Pick<IProps, "query" | "tags"> {
|
||||||
|
onLoadingChange: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: TAGS! add tag search!!
|
||||||
|
function SearchForm({ query, tags, onLoadingChange }: ISearchFormProps) {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
onLoadingChange(true);
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (target.form) target.form.requestSubmit();
|
||||||
|
onLoadingChange(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormRouter
|
||||||
|
id="search-form"
|
||||||
|
className="search-form"
|
||||||
|
method="GET"
|
||||||
|
autoComplete="off"
|
||||||
|
noValidate={true}
|
||||||
|
acceptCharset="UTF-8"
|
||||||
|
statusSection={null}
|
||||||
|
submitButton={undefined}
|
||||||
|
style={{ maxWidth: "fit-content" }}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
<div className="wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
id="q"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={query}
|
||||||
|
minLength={2}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
<ButtonSubmit
|
||||||
|
disabled={state === "loading" || state === "submitting"}
|
||||||
|
className="search-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
onLoadingChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src='/static/menu/search.svg' />
|
||||||
|
</ButtonSubmit>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
|
export async function loader({ request }: LoaderFunctionArgs): Promise<IProps> {
|
||||||
const searchParams = new URL(request.url).searchParams;
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
|
||||||
|
|
|
@ -7,16 +7,17 @@ import { FooterAd, SliderAd } from "#components/advs";
|
||||||
import { Paginator } from "#components/pagination";
|
import { Paginator } from "#components/pagination";
|
||||||
import { CardList, PostCard } from "#components/cards";
|
import { CardList, PostCard } from "#components/cards";
|
||||||
import { ProfilePageSkeleton } from "#components/pages";
|
import { ProfilePageSkeleton } from "#components/pages";
|
||||||
import { FormRouter } from "#components/forms";
|
import { ButtonSubmit, FormRouter } from "#components/forms";
|
||||||
import {
|
import {
|
||||||
ProfileHeader,
|
ProfileHeader,
|
||||||
Tabs,
|
Tabs,
|
||||||
IArtistDetails,
|
IArtistDetails,
|
||||||
getArtist,
|
getArtist,
|
||||||
} from "#entities/profiles";
|
} from "#entities/profiles";
|
||||||
import { paysites, validatePaysite } from "#entities/paysites";
|
import { paysites } from "#entities/paysites";
|
||||||
import { IPost } from "#entities/posts";
|
import { IPost } from "#entities/posts";
|
||||||
import { findFavouritePosts } from "#entities/account";
|
import { findFavouritePosts } from "#entities/account";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
profile: IArtistDetails;
|
profile: IArtistDetails;
|
||||||
|
@ -35,6 +36,7 @@ interface IProps {
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { profile, postsData, query, tags, dmCount, hasLinks } =
|
const { profile, postsData, query, tags, dmCount, hasLinks } =
|
||||||
useLoaderData() as IProps;
|
useLoaderData() as IProps;
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { service, id, name } = profile;
|
const { service, id, name } = profile;
|
||||||
const paysite = paysites[service];
|
const paysite = paysites[service];
|
||||||
const title = `Posts of "${name}" from "${paysite.title}"`;
|
const title = `Posts of "${name}" from "${paysite.title}"`;
|
||||||
|
@ -56,6 +58,10 @@ export function ProfilePage() {
|
||||||
|
|
||||||
{!(postsData && (postsData?.count !== 0 || query)) ? undefined : (
|
{!(postsData && (postsData?.count !== 0 || query)) ? undefined : (
|
||||||
<>
|
<>
|
||||||
|
<SearchForm
|
||||||
|
query={query}
|
||||||
|
onLoadingChange={(loading) => setIsLoading(loading)}
|
||||||
|
/>
|
||||||
<Paginator
|
<Paginator
|
||||||
offset={postsData.offset}
|
offset={postsData.offset}
|
||||||
count={postsData.count}
|
count={postsData.count}
|
||||||
|
@ -71,21 +77,6 @@ export function ProfilePage() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormRouter method="GET">
|
|
||||||
<input
|
|
||||||
id="q"
|
|
||||||
className="search-input"
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
autoComplete="off"
|
|
||||||
defaultValue={query}
|
|
||||||
minLength={2}
|
|
||||||
placeholder={"search for posts..."}
|
|
||||||
/>
|
|
||||||
{/* TODO: rewrite this into a proper form */}
|
|
||||||
<input type="submit" style={{ display: "none" }} />
|
|
||||||
</FormRouter>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -99,7 +90,7 @@ export function ProfilePage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CardList>
|
<CardList className={isLoading ? "card-list--loading" : ""}>
|
||||||
{postsData.posts.map((post) => (
|
{postsData.posts.map((post) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
key={`${post.id}-${post.service}-${post.user}`}
|
key={`${post.id}-${post.service}-${post.user}`}
|
||||||
|
@ -135,52 +126,91 @@ export function ProfilePage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ISearchFormProps
|
||||||
|
extends Pick<IProps, "query"> {
|
||||||
|
onLoadingChange: (loading: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchForm({ query, onLoadingChange }: ISearchFormProps) {
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
onLoadingChange(true);
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (target.form) target.form.requestSubmit();
|
||||||
|
onLoadingChange(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormRouter
|
||||||
|
id="search-form"
|
||||||
|
className="search-form"
|
||||||
|
method="GET"
|
||||||
|
autoComplete="off"
|
||||||
|
noValidate={true}
|
||||||
|
acceptCharset="UTF-8"
|
||||||
|
statusSection={null}
|
||||||
|
submitButton={undefined}
|
||||||
|
style={{ maxWidth: "fit-content" }}
|
||||||
|
>
|
||||||
|
{(state) => (
|
||||||
|
<>
|
||||||
|
<div className="wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="q"
|
||||||
|
id="q"
|
||||||
|
autoComplete="off"
|
||||||
|
defaultValue={query}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
<ButtonSubmit
|
||||||
|
disabled={state === "loading" || state === "submitting"}
|
||||||
|
className="search-button"
|
||||||
|
onClick={() => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
onLoadingChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src='/static/menu/search.svg' />
|
||||||
|
</ButtonSubmit>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
params,
|
params,
|
||||||
request,
|
request,
|
||||||
}: LoaderFunctionArgs): Promise<IProps | Response> {
|
}: LoaderFunctionArgs): Promise<IProps | Response> {
|
||||||
const searchParams = new URL(request.url).searchParams;
|
const searchParams = new URL(request.url).searchParams;
|
||||||
const service = params.service?.trim();
|
const service = params.service?.trim() || "";
|
||||||
{
|
const profileID = params.creator_id?.trim() || "";
|
||||||
if (!service) {
|
|
||||||
throw new Error("Service name is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
validatePaysite(service);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileID = params.creator_id?.trim();
|
|
||||||
{
|
|
||||||
if (!profileID) {
|
|
||||||
throw new Error("Profile ID is required.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (service === "discord") {
|
if (service === "discord") {
|
||||||
return redirect(String(createDiscordServerPageURL(profileID)));
|
return redirect(String(createDiscordServerPageURL(profileID)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let offset: number | undefined;
|
const offsetParam = searchParams.get("o")?.trim();
|
||||||
{
|
const offset = offsetParam ? parseOffset(offsetParam) : undefined;
|
||||||
const inputValue = searchParams.get("o")?.trim();
|
const query = searchParams.get("q")?.trim();
|
||||||
|
|
||||||
if (inputValue) {
|
|
||||||
offset = parseOffset(inputValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let query: string | undefined = undefined;
|
|
||||||
{
|
|
||||||
const inputQuery = searchParams.get("q")?.trim();
|
|
||||||
|
|
||||||
if (inputQuery) {
|
|
||||||
query = inputQuery;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = searchParams.getAll("tag");
|
const tags = searchParams.getAll("tag");
|
||||||
|
|
||||||
const profile = await getArtist(service, profileID);
|
const profile = await getArtist(service, profileID);
|
||||||
|
|
||||||
|
if (profileID == profile.public_id && profile.public_id != profile.id) {
|
||||||
|
return redirect(String(createProfilePageURL({ service, profileID: profile.id })));
|
||||||
|
}
|
||||||
|
|
||||||
const { props, results: posts } = await fetchProfilePosts(
|
const { props, results: posts } = await fetchProfilePosts(
|
||||||
service,
|
service,
|
||||||
profileID,
|
profileID,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
|
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
|
||||||
import { fetchAnnouncements } from "#api/posts";
|
import { fetchAnnouncements } from "#api/posts";
|
||||||
import { fetchArtistProfile } from "#api/profiles";
|
import { fetchArtistProfile } from "#api/profiles";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
|
import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars";
|
||||||
import { fetchProfileDMs } from "#api/dms";
|
import { fetchProfileDMs } from "#api/dms";
|
||||||
import { PageSkeleton } from "#components/pages";
|
import { PageSkeleton } from "#components/pages";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
import { LoaderFunctionArgs, useLoaderData } from "react-router";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "@dr.pogodin/react-helmet";
|
||||||
import {
|
import {
|
||||||
ICONS_PREPEND,
|
ICONS_PREPEND,
|
||||||
KEMONO_SITE,
|
KEMONO_SITE,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Suspense } from "react";
|
import { Suspense, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
|
@ -59,20 +59,15 @@ export function ArtistsPage() {
|
||||||
<PageSkeleton name="artists" title={title} heading={heading}>
|
<PageSkeleton name="artists" title={title} heading={heading}>
|
||||||
<SliderAd />
|
<SliderAd />
|
||||||
|
|
||||||
<SearchForm
|
<HeaderAd />
|
||||||
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">
|
<div className="paginator" id="paginator-top">
|
||||||
|
<SearchForm
|
||||||
|
query={query}
|
||||||
|
service={service}
|
||||||
|
sort_by={sort_by}
|
||||||
|
order={order}
|
||||||
|
/>
|
||||||
<Suspense fallback={<LoadingIcon />}>
|
<Suspense fallback={<LoadingIcon />}>
|
||||||
<Await errorElement={<></>} resolve={results}>
|
<Await errorElement={<></>} resolve={results}>
|
||||||
{(resolvedResult: Awaited<typeof results>) => (
|
{(resolvedResult: Awaited<typeof results>) => (
|
||||||
|
@ -96,8 +91,6 @@ export function ArtistsPage() {
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HeaderAd />
|
|
||||||
|
|
||||||
<CardList layout="phone">
|
<CardList layout="phone">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
|
@ -161,9 +154,32 @@ export function ArtistsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ISearchFormProps
|
interface ISearchFormProps
|
||||||
extends Pick<IProps, "query" | "service" | "sort_by" | "order"> {}
|
extends Pick<IProps, "query" | "service" | "sort_by" | "order"> { }
|
||||||
|
|
||||||
function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
|
function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
|
||||||
|
const sortRef = useRef<HTMLInputElement>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [sortDirection, setSortDirection] = useState<string | undefined>(order);
|
||||||
|
|
||||||
|
const onSortChange = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||||
|
if (sortRef.current) {
|
||||||
|
sortRef.current.value = sortDirection === "asc" ? "desc" : "asc";
|
||||||
|
sortRef.current.form?.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => e.currentTarget.form?.requestSubmit();
|
||||||
|
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
|
const target = e.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (target.form) target.form.requestSubmit();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormRouter
|
<FormRouter
|
||||||
id="search-form"
|
id="search-form"
|
||||||
|
@ -178,66 +194,44 @@ function SearchForm({ query, service, sort_by, order }: ISearchFormProps) {
|
||||||
>
|
>
|
||||||
{(state) => (
|
{(state) => (
|
||||||
<>
|
<>
|
||||||
<FormSection>
|
<div className="wrapper">
|
||||||
<label htmlFor="q">Name</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="q"
|
name="q"
|
||||||
id="q"
|
id="q"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
defaultValue={query}
|
defaultValue={query}
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={onInputChange}
|
||||||
/>
|
/>
|
||||||
<small className="subtitle" style={{ marginLeft: "5px" }}>
|
<ButtonSubmit
|
||||||
Leave blank to list all
|
disabled={state === "loading" || state === "submitting"}
|
||||||
</small>
|
className="search-button"
|
||||||
</FormSection>
|
>
|
||||||
|
<img src='/static/menu/search.svg' />
|
||||||
<FormSection>
|
</ButtonSubmit>
|
||||||
<label htmlFor="service">Service</label>
|
</div>
|
||||||
<select id="service" name="service" defaultValue={service}>
|
<div className="wrapper">
|
||||||
<option value="">All</option>
|
<select className="service" name="service" defaultValue={service} onChange={onSelectChange}>
|
||||||
|
<option value="">Services</option>
|
||||||
{AVAILABLE_PAYSITE_LIST.map((paysite, index) => (
|
{AVAILABLE_PAYSITE_LIST.map((paysite, index) => (
|
||||||
<option key={index} value={paysite.name}>
|
<option key={index} value={paysite.name}>
|
||||||
{paysite.title}
|
{paysite.title}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormSection>
|
<select className="sort_by" name="sort_by" defaultValue={sort_by} onChange={onSelectChange}>
|
||||||
|
|
||||||
<FormSection>
|
|
||||||
<label htmlFor="sort_by">Sort by</label>
|
|
||||||
<select id="sort_by" name="sort_by" defaultValue={sort_by}>
|
|
||||||
<option value="favorited">Popularity</option>
|
<option value="favorited">Popularity</option>
|
||||||
<option value="indexed">Date Indexed</option>
|
<option value="indexed">Date Indexed</option>
|
||||||
<option value="updated">Date Updated</option>
|
<option value="updated">Date Updated</option>
|
||||||
<option value="name">Alphabetical Order</option>
|
<option value="name">Alphabetical Order</option>
|
||||||
<option value="service">Service</option>
|
<option value="service">Service</option>
|
||||||
</select>{" "}
|
|
||||||
<select id="order" name="order" defaultValue={order}>
|
|
||||||
<option value="desc">Descending</option>
|
|
||||||
<option value="asc">Ascending</option>
|
|
||||||
</select>
|
</select>
|
||||||
</FormSection>
|
<button onClick={onSortChange} className="sort_dir button" title={sortDirection === "asc" ? "Ascending" : "Descending"} >
|
||||||
|
<img src="/static/sort.svg" alt="Sort" className={sortDirection} />
|
||||||
<FormSection>
|
</button>
|
||||||
<div></div>
|
</div>
|
||||||
<div style={{ display: "table-cell" }}>
|
<input type="text" name="order" className="hidden" ref={sortRef} />
|
||||||
{state === "loading"
|
|
||||||
? "Loading..."
|
|
||||||
: state === "submitting"
|
|
||||||
? "Submitting..."
|
|
||||||
: "Ready for submit"}
|
|
||||||
</div>
|
|
||||||
</FormSection>
|
|
||||||
|
|
||||||
<FormSection>
|
|
||||||
<div style={{ display: "table-cell" }}></div>
|
|
||||||
<ButtonSubmit
|
|
||||||
disabled={state === "loading" || state === "submitting"}
|
|
||||||
>
|
|
||||||
Search
|
|
||||||
</ButtonSubmit>
|
|
||||||
</FormSection>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FormRouter>
|
</FormRouter>
|
||||||
|
|
|
@ -8,31 +8,44 @@ import { fetchSearchFileByHash } from "#api/files";
|
||||||
import { PageSkeleton } from "#components/pages";
|
import { PageSkeleton } from "#components/pages";
|
||||||
import { CardList, PostCard } from "#components/cards";
|
import { CardList, PostCard } from "#components/cards";
|
||||||
import { KemonoLink } from "#components/links";
|
import { KemonoLink } from "#components/links";
|
||||||
|
import { MouseEvent, useEffect, useState } from "react";
|
||||||
|
import { LoadingIcon } from "#components/loading";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
import * as styles from "./search_hash.module.scss";
|
import * as styles from "./search_hash.module.scss";
|
||||||
import { MouseEvent, useState } from "react";
|
|
||||||
import { LoadingIcon } from "#components/loading";
|
|
||||||
|
|
||||||
export function SearchFilesPage() {
|
export function SearchFilesPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const initialHash = searchParams.get("hash")?.toLowerCase();
|
||||||
const title = "Search files";
|
const title = "Search files";
|
||||||
const heading = "Search Files";
|
const heading = "Search Files";
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [files, setFiles] = useState<FileList | null>(null);
|
const [files, setFiles] = useState<FileList | null>(null);
|
||||||
const [hash, setHash] = useState("");
|
const [hash, setHash] = useState(initialHash ?? "");
|
||||||
const [result, setResult] = useState<Awaited<ReturnType<typeof fetchSearchFileByHash>> | null>(null);
|
const [result, setResult] = useState<Awaited<ReturnType<typeof fetchSearchFileByHash>> | null>(null);
|
||||||
|
|
||||||
async function submitClicked(event: MouseEvent) {
|
function setHashParam(hash: string) {
|
||||||
event.preventDefault();
|
setSearchParams(params => {
|
||||||
|
params.set("hash", hash);
|
||||||
|
return params;
|
||||||
|
}, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lookup(hash: string) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (files?.length) {
|
if (files?.length) {
|
||||||
let fileHash = await getFileHash(files[0]);
|
let fileHash = await getFileHash(files[0]);
|
||||||
setResult(await fetchSearchFileByHash(fileHash));
|
setResult(await fetchSearchFileByHash(fileHash));
|
||||||
|
setHash(fileHash);
|
||||||
|
setHashParam(fileHash);
|
||||||
} else {
|
} else {
|
||||||
if (hash.match(/[a-f0-9]{64}/)) {
|
if (hash.match(/[a-f0-9]{64}/)) {
|
||||||
setResult(await fetchSearchFileByHash(hash));
|
setResult(await fetchSearchFileByHash(hash));
|
||||||
|
setHash(hash);
|
||||||
|
setHashParam(hash);
|
||||||
} else {
|
} else {
|
||||||
setError("Invalid hash!");
|
setError("Invalid hash!");
|
||||||
}
|
}
|
||||||
|
@ -42,6 +55,17 @@ export function SearchFilesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialHash) {
|
||||||
|
lookup(initialHash);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function submitClicked(event: MouseEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
await lookup(hash);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSkeleton name="file-hash-search" title={title} heading={heading}>
|
<PageSkeleton name="file-hash-search" title={title} heading={heading}>
|
||||||
<form className={styles.searchForm}>
|
<form className={styles.searchForm}>
|
||||||
|
@ -70,14 +94,7 @@ export function SearchFilesPage() {
|
||||||
<button className="button" onClick={submitClicked} disabled={!(files?.length || hash) || loading}>Submit</button>
|
<button className="button" onClick={submitClicked} disabled={!(files?.length || hash) || loading}>Submit</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{loading ? <LoadingIcon /> : !result?.posts?.length ? (
|
{loading ? <LoadingIcon /> : result?.posts?.length ? (
|
||||||
<div className="no-results">
|
|
||||||
<h2 className="site-section__subheading">
|
|
||||||
Nobody here but us chickens!
|
|
||||||
</h2>
|
|
||||||
<p className="subtitle">There are no posts for your query.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CardList>
|
<CardList>
|
||||||
{result.posts.map((post, index) => (
|
{result.posts.map((post, index) => (
|
||||||
<PostCard
|
<PostCard
|
||||||
|
@ -87,9 +104,7 @@ export function SearchFilesPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</CardList>
|
</CardList>
|
||||||
)}
|
) : result?.discord_posts?.length ? (
|
||||||
|
|
||||||
{result?.discord_posts && result?.discord_posts.length !== 0 && (
|
|
||||||
<>
|
<>
|
||||||
<h2>Discord</h2>
|
<h2>Discord</h2>
|
||||||
{result.discord_posts.map((post, index) => (
|
{result.discord_posts.map((post, index) => (
|
||||||
|
@ -104,6 +119,13 @@ export function SearchFilesPage() {
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="no-results">
|
||||||
|
<h2 className="site-section__subheading">
|
||||||
|
Nobody here but us chickens!
|
||||||
|
</h2>
|
||||||
|
<p className="subtitle">There are no posts for your query.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageSkeleton>
|
</PageSkeleton>
|
||||||
);
|
);
|
||||||
|
|
|
@ -73,7 +73,6 @@ import {
|
||||||
import {
|
import {
|
||||||
ArchiveFilePage,
|
ArchiveFilePage,
|
||||||
loader as archiveFilePageLoader,
|
loader as archiveFilePageLoader,
|
||||||
action as archiveFilePageAction,
|
|
||||||
} from "#pages/file/archive";
|
} from "#pages/file/archive";
|
||||||
import { loader as postRandomPageLoader } from "#pages/posts/random";
|
import { loader as postRandomPageLoader } from "#pages/posts/random";
|
||||||
import {
|
import {
|
||||||
|
@ -245,7 +244,6 @@ export const routes = [
|
||||||
path: "/file/:file_hash",
|
path: "/file/:file_hash",
|
||||||
element: <ArchiveFilePage />,
|
element: <ArchiveFilePage />,
|
||||||
loader: archiveFilePageLoader,
|
loader: archiveFilePageLoader,
|
||||||
action: archiveFilePageAction,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/posts/random",
|
path: "/posts/random",
|
||||||
|
|
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 { patchCssModules } from 'vite-css-modules'
|
||||||
import {
|
import {
|
||||||
siteName,
|
siteName,
|
||||||
|
favicon,
|
||||||
analyticsEnabled,
|
analyticsEnabled,
|
||||||
analyticsCode,
|
analyticsCode,
|
||||||
kemonoSite,
|
kemonoSite,
|
||||||
|
@ -40,6 +41,7 @@ import {
|
||||||
gitCommitHash,
|
gitCommitHash,
|
||||||
isFileServingEnabled,
|
isFileServingEnabled,
|
||||||
buildDate,
|
buildDate,
|
||||||
|
AnnouncementBannerGlobal,
|
||||||
} from "./configs/vars.mjs";
|
} from "./configs/vars.mjs";
|
||||||
|
|
||||||
export const baseConfig = defineConfig(async ({ command, mode }) => {
|
export const baseConfig = defineConfig(async ({ command, mode }) => {
|
||||||
|
@ -80,7 +82,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
|
||||||
tag: "link",
|
tag: "link",
|
||||||
attrs: {
|
attrs: {
|
||||||
rel: "icon",
|
rel: "icon",
|
||||||
href: "./static/favicon.ico",
|
href: favicon,
|
||||||
},
|
},
|
||||||
injectTo: "head",
|
injectTo: "head",
|
||||||
},
|
},
|
||||||
|
@ -151,6 +153,7 @@ export const baseConfig = defineConfig(async ({ command, mode }) => {
|
||||||
BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems),
|
BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems),
|
||||||
BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems),
|
BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems),
|
||||||
BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal),
|
BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal),
|
||||||
|
BUNDLER_ENV_ANNOUNCEMENT_BANNER_GLOBAL: JSON.stringify(AnnouncementBannerGlobal),
|
||||||
BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome),
|
BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome),
|
||||||
BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage),
|
BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage),
|
||||||
BUNDLER_ENV_HOME_MASCOT_PATH: JSON.stringify(homeMascotPath),
|
BUNDLER_ENV_HOME_MASCOT_PATH: JSON.stringify(homeMascotPath),
|
||||||
|
|
|
@ -11,6 +11,10 @@
|
||||||
"$comment": "`$deprecated` keyword was in introduced in draft `2019-09`, so just using `description` field instead.",
|
"$comment": "`$deprecated` keyword was in introduced in draft `2019-09`, so just using `description` field instead.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"favicon": {
|
||||||
|
"description": "favicon.ico file path.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"development_mode": {
|
"development_mode": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
@ -218,6 +222,10 @@
|
||||||
"description": "B64-encoded html fragment.",
|
"description": "B64-encoded html fragment.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"announcement_global": {
|
||||||
|
"description": "B64-encoded html fragment.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"description": "B64-encoded html fragment.",
|
"description": "B64-encoded html fragment.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
@ -448,6 +456,9 @@
|
||||||
"host": {
|
"host": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"port": {
|
"port": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
@ -531,7 +542,8 @@
|
||||||
"file",
|
"file",
|
||||||
"archive_files",
|
"archive_files",
|
||||||
"linked_accounts",
|
"linked_accounts",
|
||||||
"running_imports"
|
"running_imports",
|
||||||
|
"importer_configuration"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"archive-server": {
|
"archive-server": {
|
||||||
|
|
|
@ -15,6 +15,7 @@ interface IServerConfig {
|
||||||
|
|
||||||
interface IUIConfig {
|
interface IUIConfig {
|
||||||
home: IHomeConfig;
|
home: IHomeConfig;
|
||||||
|
favicon?: string;
|
||||||
config: { paysite_list: string[]; artists_or_creators: string };
|
config: { paysite_list: string[]; artists_or_creators: string };
|
||||||
matomo?: IMatomoConfig;
|
matomo?: IMatomoConfig;
|
||||||
sidebar?: ISidebarConfig;
|
sidebar?: ISidebarConfig;
|
||||||
|
@ -51,6 +52,10 @@ interface IBannerConfig {
|
||||||
* b64-encoded string
|
* b64-encoded string
|
||||||
*/
|
*/
|
||||||
global?: string;
|
global?: string;
|
||||||
|
/**
|
||||||
|
* b64-encoded string
|
||||||
|
*/
|
||||||
|
announcement_global?: string;
|
||||||
/**
|
/**
|
||||||
* b64-encoded string
|
* b64-encoded string
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -85,7 +85,7 @@ def get_artist(service: str, artist_id: str, reload: bool = False) -> TDArtist:
|
||||||
params = dict(artist_id=artist_id, service=service)
|
params = dict(artist_id=artist_id, service=service)
|
||||||
id_filter = (
|
id_filter = (
|
||||||
"(id = %(artist_id)s or public_id = %(artist_id)s)"
|
"(id = %(artist_id)s or public_id = %(artist_id)s)"
|
||||||
if service == ("onlyfans", "fansly", "candfans", "patreon", "fanbox", "boosty")
|
if service in ("onlyfans", "fansly", "candfans", "patreon", "fanbox", "boosty")
|
||||||
else "id = %(artist_id)s"
|
else "id = %(artist_id)s"
|
||||||
)
|
)
|
||||||
query = f"""
|
query = f"""
|
||||||
|
|
|
@ -1,14 +1,63 @@
|
||||||
|
import re
|
||||||
|
|
||||||
from flask import jsonify, make_response
|
from flask import jsonify, make_response
|
||||||
|
|
||||||
from src.lib.post import get_post_comments
|
from src.lib.post import get_post_comments
|
||||||
from src.lib.api import create_not_found_error_response
|
from src.lib.api import create_not_found_error_response
|
||||||
from src.pages.api.v1 import v1api_bp
|
from src.pages.api.v1 import v1api_bp
|
||||||
|
|
||||||
|
boosty_emote_uuid_to_file = {
|
||||||
|
"05df7389-a9e9-4a51-aefc-96c9c374175c": "Heart.webp",
|
||||||
|
"7b3fda0d-5ea9-4e66-a8bd-a19c590c8cef": "ClappingHands.webp",
|
||||||
|
"bb6e8aaf-4ac6-4f71-b8a5-b9307f86071b": "HighVoltage.webp",
|
||||||
|
"3f26b442-06b1-4b94-b9bd-3a1af887057e": "BeamingFace.webp",
|
||||||
|
"2f73c11d-ed75-4638-bb2c-4d6911d95e63": "PartyPopper.webp",
|
||||||
|
"67198e42-128a-4a41-bf7a-94d7c98bb44f": "Star.webp",
|
||||||
|
"5781617f-106b-4c05-bec0-f55d3904307a": "Gemstone.webp",
|
||||||
|
"1ad0dde5-846e-4c54-a20f-2d54a8ab1b85": "Gaspar.webp",
|
||||||
|
"84149636-8701-4d5b-a92d-445ebc49d39c": "Hurt.webp",
|
||||||
|
"3a7d0922-8dc1-4175-90bc-561d3d2bda7d": "MoneyFace.webp",
|
||||||
|
"da0661bb-aae6-4e54-87fa-0c4065ec435b": "Rocket.webp",
|
||||||
|
"9db7bb0d-1148-4686-9d0c-643d2c94837b": "ExplodingHead.webp",
|
||||||
|
"37a6e1ec-f63a-416f-88d4-63e48a68c71b": "ThinkingFace.webp",
|
||||||
|
"bc1334a6-af7e-4618-b37e-2be63bf8a112": "CheckMark.webp",
|
||||||
|
"663cbbdf-639e-4dac-8912-6a580d3ef3e6": "CallMe.webp",
|
||||||
|
"517b1805-13dd-43ed-ac65-bfa0fd0d16b8": "Burn.webp",
|
||||||
|
"4cd3b821-ec9c-4d35-827e-30be025c3ca0": "FaceScreaming.webp",
|
||||||
|
"6d674dd1-789c-408f-9ab4-912e9a8d2539": "LoudlyFace.webp",
|
||||||
|
"f3a31f3c-24b4-4760-a577-10fb0cce8605": "NauseatedFace.webp",
|
||||||
|
"eb55272b-0724-4854-ad44-899ad286a992": "Eggplant.webp",
|
||||||
|
"c00141f1-7f23-4841-b8c1-e3cd85f8f5bb": "Apple.webp",
|
||||||
|
"0b02f581-876f-4b38-823e-00d8c026dc39": "Peach.webp",
|
||||||
|
"deb46686-294c-49ae-b987-6df4d41e2b9d": "Hamburger.webp",
|
||||||
|
"5969bcfa-3dc5-4e1b-95dd-b7a1567220fb": "Pizza.webp",
|
||||||
|
"04541c27-5491-49f6-b70d-aefbdff0884c": "Banana.webp",
|
||||||
|
"90ccef34-14cf-4528-8763-0d993d892dfe": "Moon.webp",
|
||||||
|
"76119773-3a29-4548-b4c8-811f7fdc2936": "Sun.webp",
|
||||||
|
"58b34b35-4d64-45d1-852e-274206dd90b7": "ColdFace.webp",
|
||||||
|
"fd3780a9-05f7-46fe-92ec-c5f6f2ef5aa3": "Devil.webp",
|
||||||
|
"97cb65d2-15b7-42d2-9d44-6165f16f3e6e": "Shield.webp",
|
||||||
|
"fedfd339-daaf-4bff-857f-4d68ae9e5727": "SweatDroplets.webp",
|
||||||
|
"58852139-f95e-4238-9c1c-871fa6d0889a": "Beach.webp",
|
||||||
|
"10f3bc42-fc33-437b-a452-de97c748ca22": "Ball.webp",
|
||||||
|
"5388e3ce-e4d5-4b0c-ba4c-5b58d8d35db9": "Gift.webp",
|
||||||
|
"9c3d8ff6-bf13-4255-b8ff-30b9c9c98162": "MyPressF.webp",
|
||||||
|
"97101bae-9beb-47b0-bfe2-70ac24bce094": "MyIlluminati.webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
boosty_emote_regex = re.compile(r"https:\/\/images\.boosty\.to\/smile\/([a-f0-9\-]+)\/size\/large.*?")
|
||||||
|
|
||||||
@v1api_bp.get("/<service>/user/<creator_id>/post/<post_id>/comments")
|
@v1api_bp.get("/<service>/user/<creator_id>/post/<post_id>/comments")
|
||||||
def get_comments(service: str, creator_id: str, post_id: str):
|
def get_comments(service: str, creator_id: str, post_id: str):
|
||||||
comments = get_post_comments(post_id, service)
|
comments = get_post_comments(post_id, service)
|
||||||
|
|
||||||
|
if service == "boosty":
|
||||||
|
for comment in comments:
|
||||||
|
comment["content"] = boosty_emote_regex.sub(
|
||||||
|
lambda match: f"/thumbnail/boosty_smile/{boosty_emote_uuid_to_file.get(match.group(1), match.group(1))}",
|
||||||
|
comment["content"],
|
||||||
|
)
|
||||||
|
|
||||||
if not comments:
|
if not comments:
|
||||||
response = create_not_found_error_response("No comments found.")
|
response = create_not_found_error_response("No comments found.")
|
||||||
response.headers["Cache-Control"] = "s-maxage=600"
|
response.headers["Cache-Control"] = "s-maxage=600"
|
||||||
|
|
|
@ -2,7 +2,7 @@ import re
|
||||||
from flask import jsonify, make_response, request
|
from flask import jsonify, make_response, request
|
||||||
|
|
||||||
from src.config import Configuration
|
from src.config import Configuration
|
||||||
from src.lib.files import get_file_relationships, try_set_password
|
from src.lib.files import get_archive_files, get_file_relationships, try_set_password
|
||||||
from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int
|
from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int
|
||||||
from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares
|
from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares
|
||||||
from src.lib.api import create_not_found_error_response, create_client_error_response
|
from src.lib.api import create_not_found_error_response, create_client_error_response
|
||||||
|
@ -26,6 +26,40 @@ def lookup_file(file_hash: str):
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@v1api_bp.get("/file/<file_hash>")
|
||||||
|
def get_archive_data(file_hash: str):
|
||||||
|
print("get_archive_data for", file_hash)
|
||||||
|
file_hash = file_hash.lower()
|
||||||
|
if not HASH_REGEX.match(file_hash):
|
||||||
|
return create_client_error_response("Invalid SHA256 hash")
|
||||||
|
|
||||||
|
archive = get_archive_files(file_hash)
|
||||||
|
|
||||||
|
if not archive:
|
||||||
|
return create_client_error_response("File not found", 404)
|
||||||
|
|
||||||
|
response = make_response(jsonify(archive), 200)
|
||||||
|
response.headers["Cache-Control"] = "s-maxage=600"
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@v1api_bp.patch("/file/<file_hash>")
|
||||||
|
def set_archive_password(file_hash: str):
|
||||||
|
if not Configuration().archive_server["enabled"]:
|
||||||
|
return create_not_found_error_response()
|
||||||
|
|
||||||
|
file_hash = file_hash.lower()
|
||||||
|
if not HASH_REGEX.match(file_hash):
|
||||||
|
return create_client_error_response("Invalid SHA256 hash")
|
||||||
|
|
||||||
|
passwords: list[str] = request.get_json()
|
||||||
|
|
||||||
|
if try_set_password(file_hash, passwords):
|
||||||
|
return make_response(jsonify("ok"), 200)
|
||||||
|
return create_client_error_response("Invalid password")
|
||||||
|
|
||||||
|
|
||||||
@v1api_bp.route("/shares")
|
@v1api_bp.route("/shares")
|
||||||
def get_shares_data():
|
def get_shares_data():
|
||||||
base = request.args.to_dict()
|
base = request.args.to_dict()
|
||||||
|
|
|
@ -8,7 +8,6 @@ from src.lib.api import (
|
||||||
TDAPIError,
|
TDAPIError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .file import file_bp
|
|
||||||
from .account import account_bp
|
from .account import account_bp
|
||||||
|
|
||||||
v2api_bp = Blueprint("v2", __name__, url_prefix="/v2")
|
v2api_bp = Blueprint("v2", __name__, url_prefix="/v2")
|
||||||
|
@ -45,5 +44,4 @@ def handle_server_error(error: Exception):
|
||||||
return create_api_v2_error_response(responseError, 500)
|
return create_api_v2_error_response(responseError, 500)
|
||||||
|
|
||||||
|
|
||||||
v2api_bp.register_blueprint(file_bp)
|
|
||||||
v2api_bp.register_blueprint(account_bp)
|
v2api_bp.register_blueprint(account_bp)
|
||||||
|
|
|
@ -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