This commit is contained in:
SA 2024-11-26 00:11:49 +01:00
parent 755f0ca387
commit b143186d85
585 changed files with 23224 additions and 13112 deletions

View File

@ -2,6 +2,7 @@ test/
storage/
dist/
client/dev/
client/dist/
client/node_modules/
__pycache__
venv

2
.gitignore vendored
View File

@ -15,7 +15,7 @@ dev_*
client/dev
# Dev file server
storage/
/storage/
# Javascript packages
node_modules

View File

@ -1,29 +0,0 @@
# webpack output
**/dev
**/dist
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.code-workspace
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/charts
**/docker-compose*
**/compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
README.md

View File

@ -1,3 +0,0 @@
{
"recommendations": []
}

View File

@ -1,14 +1,9 @@
{
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"files.exclude": {
"node_modules": true
},
// this option does work and is required for emmet in jinja to work
"files.associations": {
"*.html": "jinja-html"
},
"emmet.includeLanguages": {
"jinja-html": "html"
},
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
@ -19,9 +14,6 @@
"javascript.preferences.importModuleSpecifierEnding": "js",
"javascript.preferences.quoteStyle": "double",
"javascript.format.semicolons": "insert",
"[jinja-html]": {
"editor.tabSize": 2
},
"[javascript]": {
"editor.tabSize": 2
},

View File

@ -1,16 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:16.14
ENV NODE_ENV=production
WORKDIR /app
COPY ["package.json", "package-lock.json", "/app/"]
RUN npm install -g npm
RUN npm ci --also=dev
COPY . /app
CMD [ "npm", "run", "build" ]

View File

@ -1,15 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:12.22
ENV NODE_ENV=development
WORKDIR /app
COPY ["package.json", "package-lock.json*", "./"]
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

View File

@ -1,92 +0,0 @@
const path = require("path");
const fse = require("fs-extra");
const HTMLWebpackPlugin = require("html-webpack-plugin");
/**
* @typedef BuildOptions
* @property {string} fileExtension
* @property {string} outputPrefix
* @property {HTMLWebpackPlugin.Options} pluginOptions Webpack plugin options.
*/
/** */
class TemplateFile {
/**
* @param {fse.Dirent} dirent
* @param {string} path Absolute path to the file.
*/
constructor(dirent, path) {
this.dirent = dirent;
this.path = path;
}
}
/**
* Builds an array of HTML webpack plugins from the provided folder.
* @param {string} basePath Absolute path to the template folder.
* @param {BuildOptions} options Build optons.
*/
function buildHTMLWebpackPluginsRecursive(basePath, options) {
/**
* @type {HTMLWebpackPlugin[]}
*/
const plugins = [];
const files = walkFolder(basePath);
files.forEach((file) => {
const isTemplateFile = file.dirent.isFile() && file.path.endsWith(`${options.fileExtension}`);
if (isTemplateFile) {
const outputBase = path.relative(basePath, file.path);
const outputPath = path.join(path.basename(basePath), outputBase);
const webpackPlugin = new HTMLWebpackPlugin({
...options.pluginOptions,
template: file.path,
filename: outputPath,
});
plugins.push(webpackPlugin);
}
});
return plugins;
}
/**
* @param {string} folderPath Absolute path to the folder.
* @param {TemplateFile[]} files
*/
function walkFolder(folderPath, files = [], currentCount = 0) {
const nestedLimit = 1000;
const folderContents = fse.readdirSync(folderPath, { withFileTypes: true });
folderContents.forEach((entry) => {
const file = entry.isFile() && entry;
const folder = entry.isDirectory() && entry;
if (file) {
const filePath = path.join(folderPath, file.name);
files.push(new TemplateFile(file, filePath));
return;
}
if (folder) {
currentCount++;
if (currentCount > nestedLimit) {
throw new Error(`The folder at "${folderPath}" contains more than ${nestedLimit} folders.`);
}
const newFolderPath = path.join(folderPath, folder.name);
return walkFolder(newFolderPath, files, currentCount);
}
});
return files;
}
module.exports = {
buildHTMLWebpackPluginsRecursive,
};

View File

@ -1,5 +0,0 @@
{
"html": {
"snippets": {}
}
}

View File

@ -1,21 +1,167 @@
// @ts-check
const path = require("path");
const fs = require("fs");
require("dotenv").config({
path: path.resolve(__dirname, "..", ".."),
});
/**
* @typedef IConfiguration
* @property {string} site
* @property {string} [sentry_dsn_js]
* @property {boolean} development_mode
* @property {boolean} automatic_migrations
* @property {IServerConfig} webserver
* @property {IArchiveServerConfig} [archive_server]
*/
const kemonoSite = process.env.KEMONO_SITE || "http://localhost:5000";
const nodeEnv = process.env.NODE_ENV || "production";
/**
* @typedef IServerConfig
* @property {IUIConfig} ui
* @property {number} port
* @property {string} [base_url]
*/
/**
* @typedef IUIConfig
* @property {IHomeConfig} home
* @property {{paysite_list: string[], artists_or_creators: string}} config
* @property {IMatomoConfig} [matomo]
* @property {ISidebarConfig} [sidebar]
* @property {unknown[]} sidebar_items
* @property {unknown[]} [footer_items]
* @property {IBannerConfig} [banner]
* @property {IAdsConfig} [ads]
*/
/**
* @typedef IMatomoConfig
* @property {boolean} enabled
* @property {string} plain_code b64-encoded string
* @property {string} tracking_domain
* @property {string} tracking_code
* @property {string} site_id
*/
/**
* @typedef ISidebarConfig
* @property {boolean} [disable_dms]
* @property {boolean} [disable_faq]
* @property {boolean} [disable_filehaus]
*/
/**
* @typedef IBannerConfig
* @property {string} [global] b64-encoded string
* @property {string} [welcome] b64-encoded string
*/
/**
* @typedef IHomeConfig
* @property {string} [site_name]
* @property {string} [mascot_path]
* @property {string} [logo_path]
* @property {string} [welcome_credits] b64-encoded string
* @property {string} [home_background_image]
* @property {{ title: string, date: string, content: string }[]} [announcements]
*/
/**
* @typedef IAdsConfig
* @property {string} [header] b64-encoded string
* @property {string} [middle] b64-encoded string
* @property {string} [footer] b64-encoded string
* @property {string} [slider] b64-encoded string
* @property {string} [video] b64-encoded JSON string
*/
/**
* @typedef IArchiveServerConfig
* @property {boolean} [enabled]
*/
const configuration = getConfiguration();
const apiServerBaseURL = configuration.webserver.base_url;
const sentryDSN = configuration.sentry_dsn_js;
const apiServerPort = !apiServerBaseURL
? undefined
: configuration.webserver.port;
const siteName = configuration.webserver.ui.home.site_name || "Kemono";
const homeBackgroundImage =
configuration.webserver.ui.home.home_background_image;
const homeMascotPath = configuration.webserver.ui.home.mascot_path;
const homeLogoPath = configuration.webserver.ui.home.logo_path;
const homeWelcomeCredits = configuration.webserver.ui.home.welcome_credits;
const homeAnnouncements = configuration.webserver.ui.home.announcements;
// TODO: in development it should point to webpack server
const kemonoSite = configuration.site || "http://localhost:5000";
const paysiteList = configuration.webserver.ui.config.paysite_list;
const artistsOrCreators =
configuration.webserver.ui.config.artists_or_creators ?? "Artists";
const disableDMs = configuration.webserver.ui.sidebar?.disable_dms ?? true;
const disableFAQ = configuration.webserver.ui.sidebar?.disable_faq ?? true;
const disableFilehaus =
configuration.webserver.ui.sidebar?.disable_filehaus ?? true;
const sidebarItems = configuration.webserver.ui.sidebar_items;
const footerItems = configuration.webserver.ui.footer_items;
const bannerGlobal = configuration.webserver.ui.banner?.global;
const bannerWelcome = configuration.webserver.ui.banner?.welcome;
const headerAd = configuration.webserver.ui.ads?.header;
const middleAd = configuration.webserver.ui.ads?.middle;
const footerAd = configuration.webserver.ui.ads?.footer;
const sliderAd = configuration.webserver.ui.ads?.slider;
const videoAd = configuration.webserver.ui.ads?.video;
const isArchiveServerEnabled = configuration.archive_server?.enabled ?? false;
const analyticsEnabled = configuration.webserver.ui.matomo?.enabled ?? false;
const analyticsCode = configuration.webserver.ui.matomo?.plain_code;
const iconsPrepend = process.env.ICONS_PREPEND || "";
const bannersPrepend = process.env.BANNERS_PREPEND || "";
const thumbnailsPrepend = process.env.THUMBNAILS_PREPEND || "";
const creatorsLocation = process.env.CREATORS_LOCATION || "";
/**
* @TODO config validation
* @returns {IConfiguration}
*/
function getConfiguration() {
const configPath = path.resolve(__dirname, "..", "..", "config.json");
// TODO: async reading
const fileContent = fs.readFileSync(configPath, { encoding: "utf8" });
/**
* @type {IConfiguration}
*/
const config = JSON.parse(fileContent);
return config;
}
module.exports = {
kemonoSite,
nodeEnv,
sentryDSN,
siteName,
iconsPrepend,
bannersPrepend,
thumbnailsPrepend,
creatorsLocation,
artistsOrCreators,
disableDMs,
disableFAQ,
disableFilehaus,
sidebarItems,
footerItems,
bannerGlobal,
bannerWelcome,
homeBackgroundImage,
homeMascotPath,
homeLogoPath,
paysiteList,
homeWelcomeCredits,
homeAnnouncements,
headerAd,
middleAd,
footerAd,
sliderAd,
videoAd,
isArchiveServerEnabled,
apiServerBaseURL,
apiServerPort,
analyticsEnabled,
analyticsCode,
};

10
client/extra.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
// required for typescript not to choke on css modules
declare module '*.scss' {
const content: Record<string, string>;
export default content;
}
declare module '*.yaml' {
const data: any
export default data
}

View File

@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"module": "commonJS",
"target": "es2015",
"moduleResolution": "node"
},
"exclude": ["node_modules", "dist", "dev", "src"]
}

2968
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,64 @@
{
"name": "kemono-2-client",
"version": "0.2.1",
"version": "1.0.0",
"description": "frontend for kemono 2",
"private": true,
"scripts": {
"dev": "webpack serve --config webpack.dev.js",
"build": "webpack --config webpack.prod.js"
},
"keywords": [],
"author": "BassOfBass",
"license": "ISC",
"scripts": {
"dev": "webpack serve --config webpack.dev.js",
"validate": "node scripts/validate.mjs",
"build": "webpack --config webpack.prod.js"
},
"imports": {
"#storage/*": "./src/browser/storage/*/index.ts",
"#hooks": "./src/browser/hooks/index.ts",
"#components/*": "./src/components/*/index.ts",
"#env/*": "./src/env/*.ts",
"#lib/*": "./src/lib/*/index.ts",
"#pages/*": "./src/pages/*.tsx",
"#entities/*": "./src/entities/*/index.ts",
"#css": "./src/css/*.scss",
"#assets/*": "./src/assets/*",
"#api/*": "./src/api/*/index.ts"
},
"dependencies": {
"@babel/runtime": "^7.22.10",
"@uppy/core": "^3.4.0",
"@uppy/dashboard": "^3.5.1",
"@uppy/form": "^3.0.2",
"@uppy/tus": "^3.1.3",
"clsx": "^2.1.0",
"diff": "^5.1.0",
"fluid-player": "^3.22.0",
"micromodal": "^0.4.10",
"purecss": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^2.0.5",
"react-router-dom": "^6.24.0",
"sha256-wasm": "^2.2.2",
"swagger-ui-react": "^5.17.14",
"whatwg-fetch": "^3.6.17"
},
"devDependencies": {
"@babel/core": "^7.22.10",
"@babel/plugin-transform-runtime": "^7.22.10",
"@babel/preset-env": "^7.22.10",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@hyperjump/json-schema": "^1.9.3",
"@types/micromodal": "^0.3.5",
"@types/node": "^20.1.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/sha256-wasm": "^2.2.3",
"@types/swagger-ui-react": "^4.18.3",
"@types/webpack-bundle-analyzer": "^4.7.0",
"babel-loader": "^8.3.0",
"buffer": "^6.0.3",
"copy-webpack-plugin": "^8.1.1",
"css-loader": "^5.2.7",
"dotenv": "^8.6.0",
"fs-extra": "^10.1.0",
"html-webpack-plugin": "^5.5.3",
"mini-css-extract-plugin": "^1.6.2",
@ -43,10 +70,14 @@
"sass-loader": "^11.1.1",
"stream-browserify": "^3.0.0",
"style-loader": "^2.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.3.3",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-manifest-plugin": "^5.0.0",
"webpack-merge": "^5.9.0"
"webpack-merge": "^5.9.0",
"yaml": "^2.4.5"
}
}

View File

@ -0,0 +1,29 @@
// @ts-check
import path from "node:path";
import fs from "node:fs/promises";
import { cwd } from "node:process";
import { validate } from "@hyperjump/json-schema/openapi-3-1";
import YAML from "yaml";
const schemaPath = path.join(cwd(), "..", "src", "pages", "api", "schema.yaml");
run().catch((error) => {
console.error(error);
process.exitCode = 1;
});
async function run() {
const fileContent = await fs.readFile(schemaPath, { encoding: "utf8" });
const parsedSchema = YAML.parse(fileContent)
const output = await validate(
"https://spec.openapis.org/oas/3.1/schema-base",
parsedSchema,
// the library doesn't support values beyond `"FLAG"` and `"BASIC"`
// but it's the only library in js which can validate OpenAPI 3.1 schemas
"BASIC"
);
if (!output.valid) {
throw new Error("Failed to validate OpenAPI Schema.")
}
}

View File

@ -1,2 +0,0 @@
export { kemonoAPI } from "./kemono/_index";
export { paysitesAPI } from "./paysites/_index";

View File

@ -0,0 +1,19 @@
import { IAccount } from "#entities/account";
import { apiFetch } from "../fetch";
interface IResult {
props: {
currentPage: "account";
title: string;
account: IAccount;
notifications_count: number;
};
}
export async function fetchAccount() {
const path = "/account";
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,40 @@
import { IAccontRole, IAccount } from "#entities/account";
import { IPagination } from "#lib/pagination";
import { apiFetch } from "../../fetch";
interface IResult {
pagination: IPagination;
accounts: IAccount[];
role_list: IAccontRole[];
currentPage: "admin";
}
export async function fetchAccounts(
page?: number,
name?: string,
role?: string,
limit?: number
) {
const path = `/account/administrator/accounts`;
const params = new URLSearchParams();
if (page) {
params.set("page", String(page));
}
if (name) {
params.set("name", name);
}
if (role) {
params.set("role", role);
}
if (limit) {
params.set("limit", String(limit));
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,26 @@
import { apiFetch } from "../../fetch";
interface IBody {
moderator?: number[];
consumer?: number[];
}
export async function fetchChangeRolesOfAccounts(
moderators?: string[],
consumers?: string[]
) {
const path = `/account/administrator/accounts`;
const body: IBody = {};
if (moderators && moderators.length !== 0) {
body.moderator = moderators.map((id) => Number(id));
}
if (consumers && consumers.length !== 0) {
body.consumer = consumers.map((id) => Number(id));
}
await apiFetch(path, { method: "POST", body });
return true;
}

View File

@ -0,0 +1,2 @@
export { fetchAccounts } from "./accounts";
export { fetchChangeRolesOfAccounts } from "./change-roles";

View File

@ -0,0 +1,19 @@
import { IAutoImportKey } from "#entities/account";
import { apiFetch } from "../../fetch";
interface IResult {
props: {
currentPage: "account";
title: "Your service keys";
service_keys: IAutoImportKey[];
};
import_ids: { key_id: string; import_id: string }[];
}
export async function fetchAccountAutoImportKeys() {
const path = `/account/keys`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,2 @@
export { fetchAccountAutoImportKeys } from "./get";
export { fetchRevokeAutoImportKeys } from "./revoke";

View File

@ -0,0 +1,15 @@
import { apiFetch } from "../../fetch";
interface IBody {
revoke: number[];
}
export async function fetchRevokeAutoImportKeys(keyIDs: number[]) {
const path = `/account/keys`;
const body: IBody = {
revoke: keyIDs,
};
await apiFetch(path, { method: "POST", body });
return true;
}

View File

@ -0,0 +1,24 @@
import { apiFetch } from "../fetch";
interface IBody {
"current-password": string;
"new-password": string;
"new-password-confirmation": string;
}
export async function fetchAccountChangePassword(
currentPassword: string,
newPassword: string,
newPasswordConfirmation: string
) {
const path = `/account/change_password`;
const body: IBody = {
"current-password": currentPassword,
"new-password": newPassword,
"new-password-confirmation": newPasswordConfirmation,
};
const result = await apiFetch<true>(path, { method: "POST", body });
return result;
}

View File

@ -0,0 +1,22 @@
import { IUnapprovedDM } from "#entities/dms";
import { apiFetch } from "../../fetch";
interface IResult {
currentPage: "import";
account_id: number;
status: "ignored" | "pending";
dms: IUnapprovedDM[];
}
export async function fetchDMsForReview(status?: "ignored" | "pending") {
const path = `/account/review_dms`;
const params = new URLSearchParams();
if (status) {
params.set("status", status);
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,2 @@
export { fetchDMsForReview } from "./get";
export { fetchApproveDMs } from "./review"

View File

@ -0,0 +1,21 @@
import { apiFetch } from "../../fetch";
interface IBody {
approved_hashes: string[];
delete_ignored?: boolean;
}
export async function fetchApproveDMs(
hashes: string[],
isIgnoredDeleted?: boolean
) {
const path = `/account/review_dms`;
const body: IBody = {
approved_hashes: hashes,
delete_ignored: isIgnoredDeleted,
};
const result = await apiFetch<true>(path, { method: "POST", body });
return result;
}

View File

@ -0,0 +1,25 @@
import { apiFetch } from "../../fetch";
export async function apiFavoritePost(
service: string,
profileID: string,
postID: string
) {
const path = `/favorites/post/${service}/${profileID}/${postID}`;
await apiFetch(path, { method: "POST" });
return true;
}
export async function apiUnfavoritePost(
service: string,
profileID: string,
postID: string
) {
const path = `/favorites/post/${service}/${profileID}/${postID}`;
await apiFetch(path, { method: "DELETE" });
return true;
}

View File

@ -0,0 +1,17 @@
import { apiFetch } from "../../fetch";
export async function apiFavoriteProfile(service: string, profileID: string) {
const path = `/favorites/creator/${service}/${profileID}`;
await apiFetch(path, { method: "POST" });
return true;
}
export async function apiUnfavoriteProfile(service: string, profileID: string) {
const path = `/favorites/creator/${service}/${profileID}`;
await apiFetch(path, { method: "DELETE" });
return true;
}

View File

@ -0,0 +1,15 @@
import { IFavouriteArtist } from "#entities/account";
import { apiFetch } from "../../fetch";
export async function fetchFavouriteProfiles() {
const path = `/account/favorites`;
const params = new URLSearchParams([["type", "artist"]]);
const data = await apiFetch<IFavouriteArtist[]>(
path,
{ method: "GET" },
params
);
return data;
}

View File

@ -0,0 +1,15 @@
import { IFavouritePost } from "#entities/account";
import { apiFetch } from "../../fetch";
export async function fetchFavouritePosts() {
const path = `/account/favorites`;
const params = new URLSearchParams([["type", "post"]]);
const data = await apiFetch<IFavouritePost[]>(
path,
{ method: "GET" },
params
);
return data;
}

View File

@ -0,0 +1,4 @@
export { fetchFavouriteProfiles } from "./get-favourite-artists";
export { fetchFavouritePosts } from "./get-favourite-posts";
export { apiFavoritePost, apiUnfavoritePost } from "./favorite-post";
export { apiFavoriteProfile, apiUnfavoriteProfile } from "./favorite-profile";

View File

@ -0,0 +1,4 @@
export { fetchAccount } from "./account";
export { fetchAccountNotifications } from "./notifications";
export { fetchAddProfileLink } from "./profiles";
export { fetchAccountChangePassword } from "./change-password";

View File

@ -0,0 +1,5 @@
export {
fetchProfileLinkRequests,
fetchApproveLinkRequest,
fetchRejectLinkRequest,
} from "./profile-link-requests";

View File

@ -0,0 +1,30 @@
import { IProfileLinkRequest } from "#entities/account";
import { apiFetch } from "../../fetch";
export async function fetchProfileLinkRequests() {
const path = `/account/moderator/tasks/creator_links`;
const linkRequests = await apiFetch<IProfileLinkRequest[]>(path, {
method: "GET",
});
return linkRequests;
}
export async function fetchApproveLinkRequest(requestID: string) {
const path = `/account/moderator/creator_link_requests/${requestID}/approve`;
const resp = await apiFetch<{ response: "approved" }>(path, {
method: "POST",
});
return resp;
}
export async function fetchRejectLinkRequest(requestID: string) {
const path = `/account/moderator/creator_link_requests/${requestID}/reject`;
const resp = await apiFetch<{ response: "rejected" }>(path, {
method: "POST",
});
return resp;
}

View File

@ -0,0 +1,17 @@
import { INotification } from "#entities/account";
import { apiFetch } from "../fetch";
interface IResult {
props: {
currentPage: "account";
notifications: INotification[];
};
}
export async function fetchAccountNotifications() {
const path = "/account/notifications";
const result = await apiFetch<IResult>(path, { method: "GET" });
return result.props;
}

View File

@ -0,0 +1,42 @@
import { IArtist } from "#entities/profiles";
import { apiFetch } from "../fetch";
interface IResult {
message: string
props: {
id: string
service: string
artist: IArtist
share_count: number
has_links: "✔️" | "0"
display_data: {
service: string
href: string
}
}
}
interface IBody {
service: string;
artist_id: string;
reason?: string;
}
export async function fetchAddProfileLink(
service: string,
profileID: string,
linkService: string,
linkProfileID: string,
reason?: string
) {
const path = `/${service}/user/${profileID}/links/new`;
const body: IBody = {
service: linkService,
artist_id: linkProfileID,
reason,
};
const result = await apiFetch<IResult>(path, { method: "POST", body });
return result;
}

View File

@ -0,0 +1,3 @@
export { fetchRegisterAccount } from "./register";
export { fetchLoginAccount } from "./login";
export { fetchLogoutAccount } from "./logout";

View File

@ -0,0 +1,29 @@
import { IAccount } from "#entities/account";
import { apiFetch } from "../fetch";
import { ensureAPIError } from "#lib/api";
import { fetchAccount } from "../account/account";
export async function fetchLoginAccount(username: string, password: string) {
const path = `/authentication/login`;
const body = {
username,
password,
};
try {
const result = await apiFetch<IAccount>(path, { method: "POST", body });
return result;
} catch (error) {
ensureAPIError(error);
// account is already logged in
if (error.response.status !== 409) {
throw error;
}
const result = await fetchAccount();
return result.props.account;
}
}

View File

@ -0,0 +1,9 @@
import { apiFetch } from "../fetch";
export async function fetchLogoutAccount() {
const path = `/authentication/logout`;
const result = await apiFetch<true>(path, { method: "POST"});
return result;
}

View File

@ -0,0 +1,20 @@
import { apiFetch } from "../fetch";
export async function fetchRegisterAccount(
userName: string,
password: string,
confirmPassword: string,
favorites?: string
) {
const path = `/authentication/register`;
const body = {
username: userName,
password,
confirm_password: confirmPassword,
favorites_json: favorites,
};
const result = await apiFetch<true>(path, { method: "POST", body });
return result;
}

29
client/src/api/dms/all.ts Normal file
View File

@ -0,0 +1,29 @@
import { IApprovedDM } from "#entities/dms";
import { apiFetch } from "../fetch";
interface IResult {
props: {
currentPage: "artists";
count: number;
limit: number;
dms: IApprovedDM[];
};
base: {};
}
export async function fetchDMs(offset?: number, query?: string) {
const path = "/dms";
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
if (query) {
params.set("q", query);
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,8 @@
import { apiFetch } from "../fetch";
export async function fetchHasPendingDMs() {
const path = `/has_pending_dms`;
const result = await apiFetch<boolean>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,3 @@
export { fetchDMs } from "./all";
export { fetchProfileDMs } from "./profile";
export { fetchHasPendingDMs } from "./has-pending";

View File

@ -0,0 +1,27 @@
import { IArtist } from "#entities/profiles";
import { IApprovedDM } from "#entities/dms";
import { apiFetch } from "../fetch";
interface IResult {
props: {
id: string;
service: string;
artist: IArtist;
display_data: {
service: string;
href: string;
};
share_count: number;
dm_count: number;
dms: IApprovedDM[];
has_links: "✔️" | "0";
};
}
export async function fetchProfileDMs(service: string, profileID: string) {
const path = `/${service}/user/${profileID}/dms`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

144
client/src/api/fetch.ts Normal file
View File

@ -0,0 +1,144 @@
import { logoutAccount } from "#entities/account";
import { HTTP_STATUS } from "#lib/http";
import { APIError } from "#lib/api";
const urlBase = `/api/v1`;
const jsonHeaders = new Headers();
jsonHeaders.append("Content-Type", "application/json");
/**
* TODO: discriminated union with JSONable body signature
*/
interface IOptions extends Omit<RequestInit, "headers"> {
method: "GET" | "POST" | "DELETE";
body?: any;
}
/**
* Generic request for Kemono API.
* @param path
* A path to the endpoint, realtive to the base API path.
*/
export async function apiFetch<ReturnShape>(
path: string,
options: IOptions,
searchParams?: URLSearchParams
): Promise<ReturnShape> {
// `URL` constructor requires a full origin
// to be present in either of arguments
// but the argument for `fetch()` accepts relative paths just fine
// so we are doing some gymnastics in order not to depend
// on browser context (does not exist on server)
// or an env variable (not needed if the origin is the same).
const url = new URL(`${urlBase}${path}`, "https://example.com");
url.search = !searchParams ? "" : String(searchParams);
url.searchParams.sort();
const apiPath = `${url.pathname}${
// `URL.search` param includes `?` even with no params
// so we include it conditionally
searchParams?.size !== 0 ? url.search : ""
}`;
let finalOptions: RequestInit;
{
if (!options.body) {
finalOptions = {
...options,
credentials: "same-origin",
};
} else {
const jsonBody = JSON.stringify(options.body);
finalOptions = {
...options,
headers: jsonHeaders,
body: jsonBody,
credentials: "same-origin",
};
}
}
const request = new Request(apiPath, finalOptions);
const response = await fetch(request);
if (!response.ok) {
// server logged the account out
if (response.status === 401) {
await logoutAccount(true);
throw new APIError(
`Failed to fetch from API due to lack of credentials. Reason: ${response.status} - ${response.statusText}.`,
{ request, response }
);
}
if (response.status === 400 || response.status === 422) {
let body: string | undefined;
// doing it this way because response doesn't allow
// parsing body several times
// and cloning response is a bit too much
const text = (await response.text()).trim();
try {
const json = JSON.parse(text);
body = JSON.stringify(json);
} catch (error) {
body = text;
}
throw new APIError(
`Failed to fetch from API due to client inputs. Reason: ${
response.status
} - ${response.statusText}.${!body ? "" : ` ${body}`}`,
{ request, response }
);
}
if (response.status === 404) {
let body: string | undefined;
// doing it this way because response doesn't allow
// parsing body several times
// and cloning response is a bit too much
const text = (await response.text()).trim();
try {
const json = JSON.parse(text);
body = JSON.stringify(json);
} catch (error) {
body = text;
}
throw new APIError(
`Failed to fetch from API because path "${
response.url
}" doesn't exist. Reason: ${response.status} - ${response.statusText}.${
!body ? "" : ` ${body}`
}`,
{ request, response }
);
}
if (response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
throw new APIError("API is in maintenance or not available.", {
request,
response,
});
}
if (response.status >= 500) {
throw new APIError("Failed to fetch from API due to server error.", {
request,
response,
});
}
throw new APIError("Failed to fetch from API for unknown reasons.", {
request,
response,
});
}
const resultBody: ReturnShape = await response.json();
return resultBody;
}

View File

@ -0,0 +1,30 @@
import { IArchiveFile } from "#entities/files";
import { apiFetch } from "../fetch";
interface IResult {
archive: IArchiveFile | null
file_serving_enabled: boolean
}
export async function fetchArchiveFile(fileHash: string) {
const path = `/posts/archives/${fileHash}`
const result = await apiFetch<IResult>( path, { method: "GET" })
return result
}
export async function fetchSetArchiveFilePassword(
archiveHash: string,
password: string
) {
const path = `/set_password`;
const params = new URLSearchParams([
["file_hash", archiveHash],
["password", password],
]);
const result = await apiFetch(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,2 @@
export { fetchArchiveFile, fetchSetArchiveFilePassword } from "./archive-file";
export { fetchSearchFileByHash } from "./search-by-hash";

View File

@ -0,0 +1,56 @@
import { apiFetch } from "../fetch";
interface IResult {
id: number;
hash: string;
mtime: string;
ctime: string;
mime: string;
ext: string;
added: string;
size: number;
ihash: string;
posts: IPostResult[];
discord_posts: IDiscordPostResult[];
}
interface IPostResult {
file_id: number;
id: string;
user: string;
service: string;
title: string;
substring: string;
published: string;
file: {
name: string;
path: string;
};
attachments: { name: string; path: string }[];
}
interface IDiscordPostResult {
file_id: number;
id: string;
server: string;
channel: string;
substring: string;
published: string;
embeds: unknown[];
mentions: unknown[];
attachments: { name: string; path: string }[];
}
export async function fetchSearchFileByHash(fileHash: string) {
const path = `/search_hash/${fileHash}`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,26 @@
import { apiFetch } from "../fetch";
interface IBody {
session_key: string;
service: string;
auto_import?: string;
save_session_key?: string;
save_dms?: boolean;
channel_ids?: string;
"x-bc"?: string;
auth_id?: string;
user_agent?: string;
}
interface IResult {
import_id: string;
}
export async function fetchCreateImport(input: IBody) {
const path = `/importer/submit`;
const body: IBody = input;
const result = await apiFetch<IResult>(path, { method: "POST", body });
return result;
}

View File

@ -0,0 +1,9 @@
import { apiFetch } from "../fetch";
export async function fetchImportLogs(importID: string) {
const path = `/importer/logs/${importID}`;
const result = await apiFetch<string[]>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,2 @@
export { fetchImportLogs } from "./get-import";
export { fetchCreateImport } from "./create-import";

View File

@ -1,14 +0,0 @@
import { favorites } from "./favorites";
import { posts } from "./posts";
import { api } from "./api";
import { dms } from "./dms";
/**
* @type {KemonoAPI}
*/
export const kemonoAPI = {
favorites,
posts,
api,
dms,
};

View File

@ -1,100 +0,0 @@
import { KemonoError } from "@wp/utils";
import { kemonoFetch } from "./kemono-fetch";
import { CREATORS_LOCATION } from "@wp/env/env-vars";
export const api = {
bans,
bannedArtist,
creators,
logs,
};
async function bans() {
try {
const response = await kemonoFetch("/api/v1/creators/bans", { method: "GET" });
if (!response || !response.ok) {
alert(new KemonoError(6));
return null;
}
/**
* @type {KemonoAPI.API.BanItem[]}
*/
const banItems = await response.json();
return banItems;
} catch (error) {
console.error(error);
}
}
/**
* @param {string} id
* @param {string} service
*/
async function bannedArtist(id, service) {
const params = new URLSearchParams([["service", service]]).toString();
try {
const response = await kemonoFetch(`/api/v1/lookup/cache/${id}?${params}`);
if (!response || !response.ok) {
alert(new KemonoError(7));
return null;
}
/**
* @type {KemonoAPI.API.BannedArtist}
*/
const artist = await response.json();
return artist;
} catch (error) {
console.error(error);
}
}
async function creators() {
try {
const response = await kemonoFetch(CREATORS_LOCATION || "/api/v1/creators", {
method: "GET",
});
if (!response || !response.ok) {
alert(new KemonoError(8));
return null;
}
/**
* @type {KemonoAPI.User[]}
*/
const artists = await response.json();
return artists;
} catch (error) {
console.error(error);
}
}
async function logs(importID) {
try {
const response = await kemonoFetch(`/api/v1/importer/logs/${importID}`, {
method: "GET",
});
if (!response || !response.ok) {
alert(new KemonoError(9));
return null;
}
/**
* @type {KemonoAPI.API.LogItem[]}
*/
const logs = await response.json();
return logs;
} catch (error) {
console.error(error);
}
}

View File

@ -1,22 +0,0 @@
import { KemonoError } from "@wp/utils";
import { kemonoFetch } from "./kemono-fetch";
/**
* @type {KemonoAPI.DMs}
*/
export const dms = {
retrieveHasPendingDMs,
};
async function retrieveHasPendingDMs() {
try {
const response = await kemonoFetch(`/api/v1/has_pending_dms`);
if (!response || !response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(error);
}
}

View File

@ -1,142 +0,0 @@
import { KemonoError } from "@wp/utils";
import { kemonoFetch } from "./kemono-fetch";
/**
* @type {KemonoAPI.Favorites}
*/
export const favorites = {
retrieveFavoriteArtists,
favoriteArtist,
unfavoriteArtist,
retrieveFavoritePosts,
favoritePost,
unfavoritePost,
};
async function retrieveFavoriteArtists() {
const params = new URLSearchParams([["type", "artist"]]).toString();
try {
const response = await kemonoFetch(`/api/v1/account/favorites?${params}`);
if (!response || !response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
/**
* @type {string}
*/
const favs = await response.text();
return favs;
} catch (error) {
console.error(error);
}
}
/**
* @param {string} service
* @param {string} userID
*/
async function favoriteArtist(service, userID) {
try {
const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "POST" });
if (!response || !response.ok) {
alert(new KemonoError(3));
return false;
}
return true;
} catch (error) {
console.error(error);
}
}
/**
* @param {string} service
* @param {string} userID
*/
async function unfavoriteArtist(service, userID) {
try {
const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "DELETE" });
if (!response || !response.ok) {
alert(new KemonoError(4));
return false;
}
return true;
} catch (error) {
console.error(error);
}
}
async function retrieveFavoritePosts() {
const params = new URLSearchParams([["type", "post"]]).toString();
try {
const response = await kemonoFetch(`/api/v1/account/favorites?${params}`);
if (!response || !response.ok) {
throw new Error(`Error ${response.status}: ${response.statusText}`);
}
/**
* @type {KemonoAPI.Post[]}
*/
const favs = await response.json();
/**
* @type {KemonoAPI.Favorites.Post[]}
*/
const transformedFavs = favs.map((post) => {
return {
id: post.id,
service: post.service,
user: post.user,
};
});
return JSON.stringify(transformedFavs);
} catch (error) {
console.error(error);
}
}
/**
* @param {string} service
* @param {string} user
* @param {string} post_id
*/
async function favoritePost(service, user, post_id) {
try {
const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "POST" });
if (!response || !response.ok) {
alert(new KemonoError(1));
return false;
}
return true;
} catch (error) {
console.error(error);
}
}
/**
* @param {string} service
* @param {string} user
* @param {string} post_id
*/
async function unfavoritePost(service, user, post_id) {
try {
const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "DELETE" });
if (!response || !response.ok) {
alert(new KemonoError(2));
return false;
}
return true;
} catch (error) {
console.error(error);
}
}

View File

@ -1,46 +0,0 @@
import { isLoggedIn } from "@wp/js/account";
/**
* Generic request for Kemono API.
* @param {RequestInfo} endpoint
* @param {RequestInit} options
* @returns {Promise<Response>}
*/
export async function kemonoFetch(endpoint, options) {
try {
const response = await fetch(endpoint, options);
// doing this because the server returns `401` before redirecting
// in case of favs
if (response.status === 401) {
// server logged the account out
if (isLoggedIn) {
localStorage.removeItem("logged_in");
localStorage.removeItem("role");
localStorage.removeItem("favs");
localStorage.removeItem("post_favs");
location.href = "/account/logout";
return;
}
const loginURL = new URL("/account/login", location.origin).toString();
location = addURLParam(loginURL, "location", location.pathname);
return;
}
return response;
} catch (error) {
console.error(`Kemono request error: ${error}`);
}
}
/**
* @param {string} url
* @param {string} paramName
* @param {string} paramValue
* @returns {string}
*/
function addURLParam(url, paramName, paramValue) {
var newURL = new URL(url);
newURL.searchParams.set(paramName, paramValue);
return newURL.toString();
}

View File

@ -1,26 +0,0 @@
import { kemonoFetch } from "./kemono-fetch";
import { KemonoError } from "@wp/utils";
export const posts = {
attemptFlag,
};
/**
* @param {string} service
* @param {string} user
* @param {string} post_id
*/
async function attemptFlag(service, user, post_id) {
try {
const response = await kemonoFetch(`/api/v1/${service}/user/${user}/post/${post_id}/flag`, { method: "POST" });
if (!response || !response.ok) {
alert(new KemonoError(5));
return false;
}
return true;
} catch (error) {
console.error(error);
}
}

View File

@ -1 +0,0 @@
export const paysitesAPI = {};

View File

@ -0,0 +1,9 @@
import { IAnnouncement } from "#entities/posts";
import { apiFetch } from "../fetch";
export async function fetchAnnouncements(service: string, profileID: string) {
const path = `/${service}/user/${profileID}/announcements`;
const result = await apiFetch<IAnnouncement[]>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,25 @@
import { ensureAPIError } from "#lib/api";
import { HTTP_STATUS } from "#lib/http";
import { apiFetch } from "../fetch";
export async function flagPost(
service: string,
profileID: string,
postID: string
) {
const path = `/${service}/user/${profileID}/post/${postID}/flag`;
try {
await apiFetch(path, { method: "POST" });
return true;
} catch (error) {
ensureAPIError(error);
if (error.response.status !== HTTP_STATUS.CONFLICT) {
throw error;
}
return true;
}
}

View File

@ -0,0 +1,7 @@
export { fetchPosts } from "./posts";
export { fetchPost, fetchPostComments, fetchPostData } from "./post";
export { fetchPostRevision } from "./revision";
export { fetchPopularPosts } from "./popular";
export { fetchAnnouncements } from "./announcements";
export { fetchRandomPost } from "./random";
export { flagPost } from "./flag";

View File

@ -0,0 +1,53 @@
import { IPopularPostsPeriod, IPostWithFavorites } from "#entities/posts";
import { apiFetch } from "../fetch";
interface IResult {
info: {
date: string;
min_date: string;
max_date: string;
navigation_dates: Record<IPopularPostsPeriod, [string, string, string]>;
range_desc: string;
scale: IPopularPostsPeriod;
};
props: {
currentPage: "popular_posts";
today: string;
earliest_date_for_popular: string;
limit: number;
count: number;
};
results: IPostWithFavorites[];
base: {};
result_previews: (
| { type: "thumbnail"; server: string; name: string; path: string }
| { type: "embed"; url: string; subject: string; description: string }
)[];
result_attachments: { server: string; name: string; path: string }[];
result_is_image: boolean;
}
export async function fetchPopularPosts(
date?: string,
scale?: IPopularPostsPeriod,
offset?: number
) {
const path = `/posts/popular`;
const params = new URLSearchParams();
if (date) {
params.set("date", date);
}
if (scale) {
params.set("period", scale);
}
if (offset) {
params.set("o", String(offset));
}
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,57 @@
import {
IComment,
IPost,
IPostAttachment,
IPostPreview,
IPostRevision,
IPostVideo,
} from "#entities/posts";
import { apiFetch } from "../fetch";
interface IResult {
post: IPost;
attachments: IPostAttachment[];
previews: IPostPreview[];
videos: IPostVideo[];
props: {
service: string;
flagged?: 0;
revisions: [number, IPost][];
};
}
export async function fetchPost(
service: string,
profileID: string,
postID: string
) {
const path = `/${service}/user/${profileID}/post/${postID}`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}
export async function fetchPostComments(
service: string,
profileID: string,
postID: string
) {
const path = `/${service}/user/${profileID}/post/${postID}/comments`;
const result = await apiFetch<IComment[]>(path, { method: "GET" });
return result;
}
interface IPostData {
service: string;
artist_id: string;
post_id: string;
}
export async function fetchPostData(service: string, postID: string) {
const path = `/${service}/post/${postID}`;
const result = await apiFetch<IPostData>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,35 @@
import { IPost } from "#entities/posts";
import { apiFetch } from "../fetch";
interface IResult {
count: number;
true_count: number;
posts: IPost[];
}
export async function fetchPosts(
offset?: number,
query?: string,
tags?: string[]
) {
const path = "/posts";
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
if (query) {
params.set("q", query);
}
if (tags) {
for (const tag of tags) {
params.set("tag", tag);
}
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,15 @@
import { apiFetch } from "../fetch";
interface IResult {
service: string;
artist_id: string;
post_id: string;
}
export async function fetchRandomPost() {
const path = `/posts/random`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,39 @@
import { IArtistDetails } from "#entities/profiles";
import {
IComment,
IPost,
IPostAttachment,
IPostPreview,
IPostRevision,
IPostVideo,
} from "#entities/posts";
import { apiFetch } from "../fetch";
interface IResult {
props: {
currentPage: "revisions";
service: string;
artist: IArtistDetails;
flagged?: 0;
revisions: [number, IPostRevision][];
};
post: IPost;
comments: IComment[];
result_previews: IPostPreview[];
result_attachments: IPostAttachment[];
videos: IPostVideo[];
archives_enabled: boolean;
}
export async function fetchPostRevision(
service: string,
profileID: string,
postID: string,
revisionID: string
) {
const path = `/${service}/user/${profileID}/post/${postID}/revision/${revisionID}`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,29 @@
import { IDiscordChannelMessage } from "#entities/posts";
import { apiFetch } from "../../fetch";
export async function fetchDiscordServer(serverID: string) {
const path = `/discord/channel/lookup/${serverID}`;
const result = await apiFetch<{ id: string; name: string }[]>(path, {
method: "GET",
});
return result;
}
export async function fetchDiscordChannel(channelID: string, offset?: number) {
const path = `/discord/channel/${channelID}`;
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
const result = await apiFetch<IDiscordChannelMessage[]>(
path,
{ method: "GET" },
params
);
return result;
}

View File

@ -0,0 +1,9 @@
import { IFanCard } from "#entities/files";
import { apiFetch } from "../fetch";
export async function fetchFanboxProfileFancards(profileID: string) {
const path = `/fanbox/user/${profileID}/fancards`;
const cards = await apiFetch<IFanCard[]>(path, { method: "GET" });
return cards;
}

View File

@ -0,0 +1,6 @@
export { fetchProfiles } from "./profiles";
export { fetchRandomArtist } from "./random";
export { fetchArtistProfile } from "./profile";
export { fetchFanboxProfileFancards } from "./fancards";
export { fetchProfileLinks } from "./links";
export { fetchProfilePosts } from "./posts";

View File

@ -0,0 +1,21 @@
import { apiFetch } from "../fetch";
interface IResult
extends Array<{
id: string;
public_id: string | null;
service: string;
name: string;
indexed: string;
updated: string;
}> {}
export async function fetchProfileLinks(service: string, profileID: string) {
const path = `/${service}/user/${profileID}/links`;
const links = await apiFetch<IResult>(path, { method: "GET" });
return links;
}

View File

@ -0,0 +1,58 @@
import { IArtist } from "#entities/profiles";
import { IPost } from "#entities/posts";
import { apiFetch } from "../fetch";
interface IResult {
props: {
currentPage: "posts";
id: string;
service: string;
name: string;
count: number;
limit: number;
artist: IArtist;
display_data: {
service: string;
href: string;
};
dm_count: number;
share_count: number;
has_links: "0" | "✔️";
};
base: Record<string, unknown>
results: IPost[]
result_previews: Record<string, unknown>[]
result_atachments: Record<string, unknown>[]
result_is_image: boolean[]
disable_service_icons: true
}
export async function fetchProfilePosts(
service: string,
profileID: string,
offset?: number,
query?: string,
tags?: string[]
) {
const path = `/${service}/user/${profileID}/posts-legacy`;
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
if (query) {
params.set("q", query);
}
if (tags && tags.length) {
for (const tag of tags) {
params.append("tag", tag);
}
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,14 @@
import { IArtistDetails } from "#entities/profiles";
import { apiFetch } from "../fetch";
export async function fetchArtistProfile(
service: string,
artistID: string
): Promise<IArtistDetails> {
const path = `/${service}/user/${artistID}/profile`;
const result = await apiFetch<IArtistDetails>(path, {
method: "GET",
});
return result;
}

View File

@ -0,0 +1,12 @@
import { IArtistWithFavs } from "#entities/profiles";
import { IS_DEVELOPMENT } from "#env/derived-vars";
import { apiFetch } from "../fetch";
export async function fetchProfiles(): Promise<IArtistWithFavs[]> {
const path = IS_DEVELOPMENT ? "/creators" : "/creators.txt";
const result = await apiFetch<IArtistWithFavs[]>(path, {
method: "GET",
});
return result;
}

View File

@ -0,0 +1,14 @@
import { apiFetch } from "../fetch";
interface IArtistData {
service: string;
artist_id: string;
}
export async function fetchRandomArtist(): Promise<IArtistData> {
const result = await apiFetch<IArtistData>("/artists/random", {
method: "GET",
});
return result;
}

View File

@ -0,0 +1,3 @@
export { fetchShares } from "./shares";
export { fetchShare } from "./share";
export { fetchProfileShares } from "./profile";

View File

@ -0,0 +1,37 @@
import { IArtist } from "#entities/profiles";
import { IShare } from "#entities/files";
import { apiFetch } from "../fetch";
interface IResult {
results: IShare[];
base: Record<string, unknown>;
props: {
display_data: {
service: string;
href: string;
};
service: string;
artist: IArtist;
id: string;
dm_count: number;
share_count: number;
has_links: "✔️" | "0";
};
}
export async function fetchProfileShares(
service: string,
profileID: string,
offset?: number
) {
const path = `/${service}/user/${profileID}/shares`;
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,15 @@
import { IShare, IShareFile } from "#entities/files";
import { apiFetch } from "../fetch";
interface IResult {
share: IShare;
share_files: IShareFile[];
base: unknown;
}
export async function fetchShare(shareID: string) {
const path = `/share/${shareID}`;
const result = await apiFetch<IResult>(path, { method: "GET" });
return result;
}

View File

@ -0,0 +1,24 @@
import { IShare } from "#entities/files";
import { apiFetch } from "../fetch";
interface IResult {
base: Record<string, unknown>;
props: {
currentPage: "shares";
count: number;
shares: IShare[];
};
}
export async function fetchShares(offset?: number) {
const path = `/shares`;
const params = new URLSearchParams();
if (offset) {
params.set("o", String(offset));
}
const result = await apiFetch<IResult>(path, { method: "GET" }, params);
return result;
}

View File

@ -0,0 +1,14 @@
import { ITag } from "#entities/tags"
import { apiFetch } from "../fetch"
interface IResult {
props: { currentPage: "tags" }
tags: ITag[]
}
export async function fetchTags() {
const path = "/posts/tags"
const result = await apiFetch<IResult>(path, { method: "GET" })
return result
}

View File

@ -0,0 +1,2 @@
export { fetchTags } from "./all";
export { fetchProfileTags } from "./profile";

View File

@ -0,0 +1,29 @@
import { IArtist } from "#entities/profiles";
import { ITag } from "#entities/tags";
import { apiFetch } from "../fetch";
interface IResult {
props: {
display_data: {
service: string;
href: string;
};
artist: IArtist;
service: string;
id: string;
share_count: number;
dm_count: number;
has_links: "✔️" | "0";
};
tags: ITag[];
service: string;
artist: IArtist;
}
export async function fetchProfileTags(service: string, profileID: string) {
const path = `/${service}/user/${profileID}/tags`;
const tags = await apiFetch<IResult>(path, { method: "GET" });
return tags;
}

View File

@ -0,0 +1,3 @@
export { ClientProvider, useClient } from "./use-client";
export { useRoutePathPattern } from "./use-route-path-pattern";
export { useInterval } from "./use-interval";

View File

@ -0,0 +1,38 @@
import {
ReactNode,
createContext,
useContext,
useEffect,
useState,
} from "react";
interface IClientContext {
isClient: boolean;
}
const defaultContext: IClientContext = { isClient: false };
const ClientContext = createContext<IClientContext>(defaultContext);
interface IProps {
children?: ReactNode;
}
export function ClientProvider({ children }: IProps) {
const [isClient, switchIsClient] = useState(false);
useEffect(() => {
switchIsClient(true);
}, []);
return (
<ClientContext.Provider value={{ isClient }}>
{children}
</ClientContext.Provider>
);
}
export function useClient(): boolean {
const { isClient } = useContext(ClientContext);
return isClient;
}

View File

@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
/**
* Stolen from
* https://overreacted.io/making-setinterval-declarative-with-react-hooks/
*/
export function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef<typeof callback>();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
function tick() {
savedCallback.current!();
}
}, [delay]);
}

View File

@ -0,0 +1,11 @@
import { useLocation } from "react-router-dom";
/**
* TODO: path pattern without circular reference
* on the route config object.
*/
export function useRoutePathPattern(): string {
const location = useLocation();
return location.pathname;
}

View File

@ -0,0 +1,46 @@
const storageNames = [
"favorites",
"logged_in",
"role",
"favs",
"post_favs",
"has_pending_review_dms",
"last_checked_has_pending_review_dms",
"sidebar_state",
] as const;
export interface ILocalStorageSchema {
favs: {
service: string;
id: string;
}[];
post_favs: {
id: string;
service: string;
user: string;
}[];
}
type ILocalStorageName = (typeof storageNames)[number];
export function getLocalStorageItem(name: ILocalStorageName) {
return localStorage.getItem(name);
}
export function setLocalStorageItem(name: ILocalStorageName, value: string) {
localStorage.setItem(name, value);
}
export function deleteLocalStorageItem(name: ILocalStorageName) {
localStorage.removeItem(name);
}
export function isLocalStorageAvailable() {
try {
localStorage.setItem("__storage_test__", "__storage_test__");
localStorage.removeItem("__storage_test__");
return true;
} catch (error) {
return false;
}
}

View File

@ -0,0 +1,11 @@
@use "layout";
@use "pages";
@use "images";
@use "links";
@use "dates";
@use "cards";
@use "loading";
@use "buttons";
@use "tooltip";
@use "pagination";
@use "importer_states";

View File

@ -0,0 +1,59 @@
import { useLocation } from "react-router-dom";
import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars";
import { DangerousContent } from "#components/dangerous-content";
export function HeaderAd() {
const location = useLocation();
const key = `${location.pathname}${location.search}`;
return !HEADER_AD ? undefined : (
<DangerousContent
key={key}
className="ad-container"
html={atob(HEADER_AD)}
allowRerender
/>
);
}
export function MiddleAd() {
const location = useLocation();
const key = `${location.pathname}${location.search}`;
return !MIDDLE_AD ? undefined : (
<DangerousContent
key={key}
className="ad-container"
html={atob(MIDDLE_AD)}
allowRerender
/>
);
}
export function FooterAd() {
const location = useLocation();
const key = `${location.pathname}${location.search}`;
return !FOOTER_AD ? undefined : (
<DangerousContent
key={key}
className="ad-container"
html={atob(FOOTER_AD)}
allowRerender
/>
);
}
export function SliderAd() {
const location = useLocation();
const key = `${location.pathname}${location.search}`;
return !SLIDER_AD ? undefined : (
<DangerousContent
key={key}
className="ad-container"
html={atob(SLIDER_AD)}
allowRerender
/>
);
}

View File

@ -0,0 +1 @@
export { MiddleAd, HeaderAd, FooterAd, SliderAd } from "./ads";

View File

@ -0,0 +1 @@
@use "./buttons";

View File

@ -0,0 +1,18 @@
import { ReactNode } from "react";
import { IBlockProps, createBlockComponent } from "#components/meta";
interface IProps extends IBlockProps<"button"> {
className?: string;
isFocusable?: boolean;
children?: ReactNode;
}
export const Button = createBlockComponent("button", Component);
export function Component({ isFocusable = true, children, ...props }: IProps) {
return (
<button {...props} tabIndex={!isFocusable ? undefined : -1}>
{children}
</button>
);
}

View File

@ -0,0 +1 @@
export { Button } from "./buttons";

View File

@ -1,6 +1,7 @@
@use "card_list";
@use "base";
@use "account";
@use "post";
@use "user";
@use "profile";
@use "dm";
@use "no_results";

View File

@ -1,4 +1,4 @@
@use "../../../css/sass-mixins" as mixins;
@use "../../css/sass-mixins" as mixins;
.account-card {
@include mixins.article-card();

View File

@ -0,0 +1,28 @@
import { Timestamp } from "#components/dates";
import { IAccount } from "#entities/account";
interface IProps {
account: IAccount;
}
export function AccountCard({ account }: IProps) {
const { id, username, role, created_at } = account;
return (
<article className="account-card" data-id={id}>
<header className="account-card__header">
<h2 className="account-card__name">{username}</h2>
</header>
<section className="account-card__body">
<p className="account-card__role">
Role: <span>{role}</span>
</p>
</section>
<footer className="account-card__footer">
<Timestamp time={created_at} />
</footer>
</article>
);
}

View File

@ -1,4 +1,4 @@
@use "../../../css/config/variables" as *;
@use "../../css/config/variables" as *;
.card {
display: grid;

View File

@ -0,0 +1,41 @@
import clsx from "clsx";
import { ReactNode } from "react";
interface ICardProps {
className?: string;
children?: ReactNode;
}
interface ICardHeaderProps {
className?: string;
children?: ReactNode;
}
interface ICardBodyProps {
className?: string;
children?: ReactNode;
}
interface ICardFooterProps {
className?: string;
children?: ReactNode;
}
export function Card({ className, children }: ICardProps) {
return <article className={clsx("card", className)}>{children}</article>;
}
export function CardHeader({ className, children }: ICardHeaderProps) {
return (
<header className={clsx("card__header", className)}>{children}</header>
);
}
export function CardBody({ className, children }: ICardBodyProps) {
return (
<section className={clsx("card__body", className)}>{children}</section>
);
}
export function CardFooter({ className, children }: ICardFooterProps) {
return (
<footer className={clsx("card__footer", className)}>{children}</footer>
);
}

Some files were not shown because too many files have changed in this diff Show More