mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 22:41:51 +01:00
Merge pull request #376 from castdrian/multi-search-opensearch
feat: multisearch & opensearch & sitelinks
This commit is contained in:
commit
17ff003651
36
.eslintrc.js
36
.eslintrc.js
@ -8,26 +8,26 @@ const a11yOff = Object.keys(require("eslint-plugin-jsx-a11y").rules).reduce(
|
||||
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
browser: true
|
||||
},
|
||||
extends: [
|
||||
"airbnb",
|
||||
"airbnb/hooks",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts"],
|
||||
ignorePatterns: ["public/*", "dist/*", "/*.js", "/*.ts", "/plugins/*.ts"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
tsconfigRootDir: "./",
|
||||
tsconfigRootDir: "./"
|
||||
},
|
||||
settings: {
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
project: "./tsconfig.json",
|
||||
},
|
||||
},
|
||||
project: "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: ["@typescript-eslint", "import", "prettier"],
|
||||
rules: {
|
||||
@ -55,15 +55,15 @@ module.exports = {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
"react/jsx-filename-extension": [
|
||||
"error",
|
||||
{ extensions: [".js", ".tsx", ".jsx"] },
|
||||
{ extensions: [".js", ".tsx", ".jsx"] }
|
||||
],
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
ts: "never",
|
||||
tsx: "never",
|
||||
},
|
||||
tsx: "never"
|
||||
}
|
||||
],
|
||||
"import/order": [
|
||||
"error",
|
||||
@ -74,14 +74,14 @@ module.exports = {
|
||||
"internal",
|
||||
["sibling", "parent"],
|
||||
"index",
|
||||
"unknown",
|
||||
"unknown"
|
||||
],
|
||||
"newlines-between": "always",
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
caseInsensitive: true,
|
||||
},
|
||||
},
|
||||
caseInsensitive: true
|
||||
}
|
||||
}
|
||||
],
|
||||
"sort-imports": [
|
||||
"error",
|
||||
@ -90,9 +90,9 @@ module.exports = {
|
||||
ignoreDeclarationSort: true,
|
||||
ignoreMemberSort: false,
|
||||
memberSyntaxSortOrder: ["none", "all", "multiple", "single"],
|
||||
allowSeparatedGroups: true,
|
||||
},
|
||||
allowSeparatedGroups: true
|
||||
}
|
||||
],
|
||||
...a11yOff,
|
||||
},
|
||||
...a11yOff
|
||||
}
|
||||
};
|
||||
|
@ -1,3 +1,8 @@
|
||||
VITE_TMDB_READ_API_KEY=...
|
||||
VITE_OPENSEARCH_ENABLED=false
|
||||
|
||||
# make sure the cors proxy url does NOT have a slash at the end
|
||||
VITE_CORS_PROXY_URL=...
|
||||
VITE_TMDB_READ_API_KEY=...
|
||||
|
||||
# make sure the domain does NOT have a slash at the end
|
||||
VITE_APP_DOMAIN=http://localhost:5173
|
||||
|
22
index.html
22
index.html
@ -33,6 +33,28 @@
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
|
||||
<title>movie-web</title>
|
||||
|
||||
{{#if opensearchEnabled }}
|
||||
<!-- OpenSearch -->
|
||||
<link rel="search" type="application/opensearchdescription+xml" title="movie-web" href="/opensearch.xml">
|
||||
|
||||
<!-- Google Sitelinks -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"url": "{{ routeDomain }}",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "{{ routeDomain }}/browse/?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{{/if}}
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
@ -60,7 +60,6 @@
|
||||
"@babel/core": "^7.21.3",
|
||||
"@babel/preset-env": "^7.20.2",
|
||||
"@babel/preset-typescript": "^7.21.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/chromecast-caf-sender": "^1.0.5",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/dompurify": "^2.4.0",
|
||||
@ -88,6 +87,8 @@
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "7.29.4",
|
||||
"eslint-plugin-react-hooks": "4.3.0",
|
||||
"glob": "^10.3.3",
|
||||
"handlebars": "^4.7.7",
|
||||
"jsdom": "^21.1.0",
|
||||
"postcss": "^8.4.20",
|
||||
"prettier": "^2.5.1",
|
||||
@ -99,6 +100,7 @@
|
||||
"vite-plugin-checker": "^0.5.6",
|
||||
"vite-plugin-package-version": "^1.0.2",
|
||||
"vite-plugin-pwa": "^0.14.4",
|
||||
"vite-plugin-static-copy": "^0.16.0",
|
||||
"vitest": "^0.28.5",
|
||||
"workbox-build": "^6.5.4",
|
||||
"workbox-window": "^6.5.4"
|
||||
|
41
plugins/handlebars.ts
Normal file
41
plugins/handlebars.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { globSync } from "glob";
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
import { PluginOption } from "vite";
|
||||
import Handlebars from "handlebars";
|
||||
import path from "path";
|
||||
|
||||
export const handlebars = (options: { vars?: Record<string, any> } = {}): PluginOption[] => {
|
||||
const files = globSync("src/assets/**/**.hbs");
|
||||
|
||||
function render(content: string): string {
|
||||
const template = Handlebars.compile(content);
|
||||
return template(options?.vars ?? {});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'hbs-templating',
|
||||
enforce: "pre",
|
||||
transformIndexHtml: {
|
||||
order: 'pre',
|
||||
handler(html) {
|
||||
return render(html);
|
||||
}
|
||||
},
|
||||
},
|
||||
viteStaticCopy({
|
||||
silent: true,
|
||||
targets: files.map(file => ({
|
||||
src: file,
|
||||
dest: '',
|
||||
rename: path.basename(file).slice(0, -4), // remove .hbs file extension
|
||||
transform: {
|
||||
encoding: 'utf8',
|
||||
handler(content: string) {
|
||||
return render(content);
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
]
|
||||
}
|
6
src/assets/opensearch.xml.hbs
Normal file
6
src/assets/opensearch.xml.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>movie-web</ShortName>
|
||||
<Description>The place for your favorite movies & shows</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<Url type="text/html" template="{{ routeDomain }}/browse/?q={searchTerms}" />
|
||||
</OpenSearchDescription>
|
@ -6,7 +6,6 @@ import {
|
||||
TMDBMediaToMediaType,
|
||||
formatTMDBMeta,
|
||||
getEpisodes,
|
||||
getExternalIds,
|
||||
getMediaDetails,
|
||||
getMediaPoster,
|
||||
getMovieFromExternalId,
|
||||
@ -19,6 +18,7 @@ import {
|
||||
} from "./types/justwatch";
|
||||
import { MWMediaMeta, MWMediaType } from "./types/mw";
|
||||
import {
|
||||
TMDBContentTypes,
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBSeasonMetaResult,
|
||||
@ -74,8 +74,7 @@ export async function getMetaFromId(
|
||||
|
||||
if (!details) return null;
|
||||
|
||||
const externalIds = await getExternalIds(id, mediaTypeToTMDB(type));
|
||||
const imdbId = externalIds.imdb_id ?? undefined;
|
||||
const imdbId = details.external_ids.imdb_id ?? undefined;
|
||||
|
||||
let seasonData: TMDBSeasonMetaResult | undefined;
|
||||
|
||||
@ -165,7 +164,13 @@ export async function getLegacyMetaFromId(
|
||||
}
|
||||
|
||||
export function isLegacyUrl(url: string): boolean {
|
||||
if (url.startsWith("/media/JW")) return true;
|
||||
if (url.startsWith("/media/JW") || url.startsWith("/media/tmdb-show"))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isLegacyMediaType(url: string): boolean {
|
||||
if (url.startsWith("/media/tmdb-show")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -177,7 +182,16 @@ export async function convertLegacyUrl(
|
||||
const urlParts = url.split("/").slice(2);
|
||||
const [, type, id] = urlParts[0].split("-", 3);
|
||||
|
||||
const mediaType = TMDBMediaToMediaType(type);
|
||||
if (isLegacyMediaType(url)) {
|
||||
const details = await getMediaDetails(id, TMDBContentTypes.TV);
|
||||
return `/media/${TMDBIdToUrlId(
|
||||
MWMediaType.SERIES,
|
||||
details.id.toString(),
|
||||
details.name
|
||||
)}`;
|
||||
}
|
||||
|
||||
const mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
|
||||
const meta = await getLegacyMetaFromId(mediaType, id);
|
||||
|
||||
if (!meta) return undefined;
|
||||
|
@ -1,26 +1,21 @@
|
||||
import { SimpleCache } from "@/utils/cache";
|
||||
|
||||
import {
|
||||
formatTMDBMeta,
|
||||
formatTMDBSearchResult,
|
||||
mediaTypeToTMDB,
|
||||
searchMedia,
|
||||
} from "./tmdb";
|
||||
import { formatTMDBMeta, formatTMDBSearchResult, multiSearch } from "./tmdb";
|
||||
import { MWMediaMeta, MWQuery } from "./types/mw";
|
||||
|
||||
const cache = new SimpleCache<MWQuery, MWMediaMeta[]>();
|
||||
cache.setCompare((a, b) => {
|
||||
return a.type === b.type && a.searchQuery.trim() === b.searchQuery.trim();
|
||||
return a.searchQuery.trim() === b.searchQuery.trim();
|
||||
});
|
||||
cache.initialize();
|
||||
|
||||
export async function searchForMedia(query: MWQuery): Promise<MWMediaMeta[]> {
|
||||
if (cache.has(query)) return cache.get(query) as MWMediaMeta[];
|
||||
const { searchQuery, type } = query;
|
||||
const { searchQuery } = query;
|
||||
|
||||
const data = await searchMedia(searchQuery, mediaTypeToTMDB(type));
|
||||
const results = data.results.map((v) => {
|
||||
const formattedResult = formatTMDBSearchResult(v, mediaTypeToTMDB(type));
|
||||
const data = await multiSearch(searchQuery);
|
||||
const results = data.map((v) => {
|
||||
const formattedResult = formatTMDBSearchResult(v, v.media_type);
|
||||
return formatTMDBMeta(formattedResult);
|
||||
});
|
||||
|
||||
|
@ -7,33 +7,26 @@ import {
|
||||
ExternalIdMovieSearchResult,
|
||||
TMDBContentTypes,
|
||||
TMDBEpisodeShort,
|
||||
TMDBExternalIds,
|
||||
TMDBMediaResult,
|
||||
TMDBMovieData,
|
||||
TMDBMovieExternalIds,
|
||||
TMDBMovieResponse,
|
||||
TMDBMovieResult,
|
||||
TMDBMovieSearchResult,
|
||||
TMDBSearchResult,
|
||||
TMDBSeason,
|
||||
TMDBSeasonMetaResult,
|
||||
TMDBShowData,
|
||||
TMDBShowExternalIds,
|
||||
TMDBShowResponse,
|
||||
TMDBShowResult,
|
||||
TMDBShowSearchResult,
|
||||
} from "./types/tmdb";
|
||||
import { mwFetch } from "../helpers/fetch";
|
||||
|
||||
export function mediaTypeToTMDB(type: MWMediaType): TMDBContentTypes {
|
||||
if (type === MWMediaType.MOVIE) return "movie";
|
||||
if (type === MWMediaType.SERIES) return "show";
|
||||
if (type === MWMediaType.MOVIE) return TMDBContentTypes.MOVIE;
|
||||
if (type === MWMediaType.SERIES) return TMDBContentTypes.TV;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
export function TMDBMediaToMediaType(type: string): MWMediaType {
|
||||
if (type === "movie") return MWMediaType.MOVIE;
|
||||
if (type === "show") return MWMediaType.SERIES;
|
||||
export function TMDBMediaToMediaType(type: TMDBContentTypes): MWMediaType {
|
||||
if (type === TMDBContentTypes.MOVIE) return MWMediaType.MOVIE;
|
||||
if (type === TMDBContentTypes.TV) return MWMediaType.SERIES;
|
||||
throw new Error("unsupported type");
|
||||
}
|
||||
|
||||
@ -103,7 +96,7 @@ export function decodeTMDBId(
|
||||
if (prefix !== "tmdb") return null;
|
||||
let mediaType;
|
||||
try {
|
||||
mediaType = TMDBMediaToMediaType(type);
|
||||
mediaType = TMDBMediaToMediaType(type as TMDBContentTypes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@ -131,40 +124,10 @@ async function get<T>(url: string, params?: object): Promise<T> {
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function searchMedia(
|
||||
query: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBMovieResponse | TMDBShowResponse> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieResponse>("search/movie", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowResponse>("search/tv", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
page: 1,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function multiSearch(
|
||||
query: string
|
||||
): Promise<(TMDBMovieSearchResult | TMDBShowSearchResult)[]> {
|
||||
const data = await get<TMDBSearchResult>(`search/multi`, {
|
||||
const data = await get<TMDBSearchResult>("search/multi", {
|
||||
query,
|
||||
include_adult: false,
|
||||
language: "en-US",
|
||||
@ -172,7 +135,9 @@ export async function multiSearch(
|
||||
});
|
||||
// filter out results that aren't movies or shows
|
||||
const results = data.results.filter(
|
||||
(r) => r.media_type === "movie" || r.media_type === "tv"
|
||||
(r) =>
|
||||
r.media_type === TMDBContentTypes.MOVIE ||
|
||||
r.media_type === TMDBContentTypes.TV
|
||||
);
|
||||
return results;
|
||||
}
|
||||
@ -183,20 +148,21 @@ export async function generateQuickSearchMediaUrl(
|
||||
const data = await multiSearch(query);
|
||||
if (data.length === 0) return undefined;
|
||||
const result = data[0];
|
||||
const type = result.media_type === "movie" ? "movie" : "show";
|
||||
const title = result.media_type === "movie" ? result.title : result.name;
|
||||
const title =
|
||||
result.media_type === TMDBContentTypes.MOVIE ? result.title : result.name;
|
||||
|
||||
return `/media/${TMDBIdToUrlId(
|
||||
TMDBMediaToMediaType(type),
|
||||
TMDBMediaToMediaType(result.media_type),
|
||||
result.id.toString(),
|
||||
title
|
||||
)}`;
|
||||
}
|
||||
|
||||
// Conditional type which for inferring the return type based on the content type
|
||||
type MediaDetailReturn<T extends TMDBContentTypes> = T extends "movie"
|
||||
type MediaDetailReturn<T extends TMDBContentTypes> =
|
||||
T extends TMDBContentTypes.MOVIE
|
||||
? TMDBMovieData
|
||||
: T extends "show"
|
||||
: T extends TMDBContentTypes.TV
|
||||
? TMDBShowData
|
||||
: never;
|
||||
|
||||
@ -204,11 +170,11 @@ export function getMediaDetails<
|
||||
T extends TMDBContentTypes,
|
||||
TReturn = MediaDetailReturn<T>
|
||||
>(id: string, type: T): Promise<TReturn> {
|
||||
if (type === "movie") {
|
||||
return get<TReturn>(`/movie/${id}`);
|
||||
if (type === TMDBContentTypes.MOVIE) {
|
||||
return get<TReturn>(`/movie/${id}`, { append_to_response: "external_ids" });
|
||||
}
|
||||
if (type === "show") {
|
||||
return get<TReturn>(`/tv/${id}`);
|
||||
if (type === TMDBContentTypes.TV) {
|
||||
return get<TReturn>(`/tv/${id}`, { append_to_response: "external_ids" });
|
||||
}
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
@ -229,26 +195,6 @@ export async function getEpisodes(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getExternalIds(
|
||||
id: string,
|
||||
type: TMDBContentTypes
|
||||
): Promise<TMDBExternalIds> {
|
||||
let data;
|
||||
|
||||
switch (type) {
|
||||
case "movie":
|
||||
data = await get<TMDBMovieExternalIds>(`/movie/${id}/external_ids`);
|
||||
break;
|
||||
case "show":
|
||||
data = await get<TMDBShowExternalIds>(`/tv/${id}/external_ids`);
|
||||
break;
|
||||
default:
|
||||
throw new Error("Invalid media type");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getMovieFromExternalId(
|
||||
imdbId: string
|
||||
): Promise<string | undefined> {
|
||||
@ -263,12 +209,12 @@ export async function getMovieFromExternalId(
|
||||
}
|
||||
|
||||
export function formatTMDBSearchResult(
|
||||
result: TMDBShowResult | TMDBMovieResult,
|
||||
result: TMDBMovieSearchResult | TMDBShowSearchResult,
|
||||
mediatype: TMDBContentTypes
|
||||
): TMDBMediaResult {
|
||||
const type = TMDBMediaToMediaType(mediatype);
|
||||
if (type === MWMediaType.SERIES) {
|
||||
const show = result as TMDBShowResult;
|
||||
const show = result as TMDBShowSearchResult;
|
||||
return {
|
||||
title: show.name,
|
||||
poster: getMediaPoster(show.poster_path),
|
||||
@ -277,7 +223,8 @@ export function formatTMDBSearchResult(
|
||||
object_type: mediatype,
|
||||
};
|
||||
}
|
||||
const movie = result as TMDBMovieResult;
|
||||
|
||||
const movie = result as TMDBMovieSearchResult;
|
||||
|
||||
return {
|
||||
title: movie.title,
|
||||
|
@ -43,7 +43,6 @@ export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific;
|
||||
|
||||
export interface MWQuery {
|
||||
searchQuery: string;
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface DetailedMeta {
|
||||
|
@ -1,4 +1,7 @@
|
||||
export type TMDBContentTypes = "movie" | "show";
|
||||
export enum TMDBContentTypes {
|
||||
MOVIE = "movie",
|
||||
TV = "tv",
|
||||
}
|
||||
|
||||
export type TMDBSeasonShort = {
|
||||
title: string;
|
||||
@ -121,6 +124,9 @@ export interface TMDBShowData {
|
||||
type: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
external_ids: {
|
||||
imdb_id: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBMovieData {
|
||||
@ -169,6 +175,9 @@ export interface TMDBMovieData {
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
external_ids: {
|
||||
imdb_id: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBEpisodeResult {
|
||||
@ -183,54 +192,6 @@ export interface TMDBEpisodeResult {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TMDBShowResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
origin_country: string[];
|
||||
original_language: string;
|
||||
original_name: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
first_air_date: string;
|
||||
name: string;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBShowResponse {
|
||||
page: number;
|
||||
results: TMDBShowResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TMDBMovieResult {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface TMDBMovieResponse {
|
||||
page: number;
|
||||
results: TMDBMovieResult[];
|
||||
total_pages: number;
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
export interface TMDBEpisode {
|
||||
air_date: string;
|
||||
episode_number: number;
|
||||
@ -259,30 +220,6 @@ export interface TMDBSeason {
|
||||
season_number: number;
|
||||
}
|
||||
|
||||
export interface TMDBShowExternalIds {
|
||||
id: number;
|
||||
imdb_id: null | string;
|
||||
freebase_mid: null | string;
|
||||
freebase_id: null | string;
|
||||
tvdb_id: number;
|
||||
tvrage_id: null | string;
|
||||
wikidata_id: null | string;
|
||||
facebook_id: null | string;
|
||||
instagram_id: null | string;
|
||||
twitter_id: null | string;
|
||||
}
|
||||
|
||||
export interface TMDBMovieExternalIds {
|
||||
id: number;
|
||||
imdb_id: null | string;
|
||||
wikidata_id: null | string;
|
||||
facebook_id: null | string;
|
||||
instagram_id: null | string;
|
||||
twitter_id: null | string;
|
||||
}
|
||||
|
||||
export type TMDBExternalIds = TMDBShowExternalIds | TMDBMovieExternalIds;
|
||||
|
||||
export interface ExternalIdMovieSearchResult {
|
||||
movie_results: {
|
||||
adult: boolean;
|
||||
@ -316,7 +253,7 @@ export interface TMDBMovieSearchResult {
|
||||
original_title: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: "movie";
|
||||
media_type: TMDBContentTypes.MOVIE;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
release_date: string;
|
||||
@ -334,7 +271,7 @@ export interface TMDBShowSearchResult {
|
||||
original_name: string;
|
||||
overview: string;
|
||||
poster_path: string;
|
||||
media_type: "tv";
|
||||
media_type: TMDBContentTypes.TV;
|
||||
genre_ids: number[];
|
||||
popularity: number;
|
||||
first_air_date: string;
|
||||
|
@ -1,14 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MWQuery } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||
|
||||
import { DropdownButton } from "./buttons/DropdownButton";
|
||||
import { Icon, Icons } from "./Icon";
|
||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||
|
||||
export interface SearchBarProps {
|
||||
buttonText?: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: MWQuery, force: boolean) => void;
|
||||
onUnFocus: () => void;
|
||||
@ -16,9 +11,6 @@ export interface SearchBarProps {
|
||||
}
|
||||
|
||||
export function SearchBarInput(props: SearchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
function setSearch(value: string) {
|
||||
props.onChange(
|
||||
{
|
||||
@ -28,15 +20,6 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
false
|
||||
);
|
||||
}
|
||||
function setType(type: string) {
|
||||
props.onChange(
|
||||
{
|
||||
...props.value,
|
||||
type: type as MWMediaType,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||
@ -51,31 +34,6 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-4 pt-0 sm:px-2 sm:py-2">
|
||||
<DropdownButton
|
||||
icon={Icons.SEARCH}
|
||||
open={dropdownOpen}
|
||||
setOpen={(val) => setDropdownOpen(val)}
|
||||
selectedItem={props.value.type}
|
||||
setSelectedItem={(val) => setType(val)}
|
||||
options={[
|
||||
{
|
||||
id: MWMediaType.MOVIE,
|
||||
name: t("searchBar.movie"),
|
||||
icon: Icons.FILM,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.SERIES,
|
||||
name: t("searchBar.series"),
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
{props.buttonText || t("searchBar.search")}
|
||||
</DropdownButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -117,7 +117,7 @@ function MediaCardContent({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { generatePath, useHistory, useRouteMatch } from "react-router-dom";
|
||||
import { generatePath, useHistory, useParams } from "react-router-dom";
|
||||
|
||||
import { MWMediaType, MWQuery } from "@/backend/metadata/types/mw";
|
||||
import { MWQuery } from "@/backend/metadata/types/mw";
|
||||
import { useQueryParams } from "@/hooks/useQueryParams";
|
||||
|
||||
function getInitialValue(params: { type: string; query: string }) {
|
||||
const type =
|
||||
Object.values(MWMediaType).find((v) => params.type === v) ||
|
||||
MWMediaType.MOVIE;
|
||||
const searchQuery = decodeURIComponent(params.query || "");
|
||||
return { type, searchQuery };
|
||||
function getInitialValue(
|
||||
query: Record<string, string>,
|
||||
params: Record<string, string>
|
||||
) {
|
||||
let searchQuery = decodeURIComponent(params.query || "");
|
||||
if (query.q) searchQuery = query.q;
|
||||
return { searchQuery };
|
||||
}
|
||||
|
||||
export function useSearchQuery(): [
|
||||
@ -17,28 +19,34 @@ export function useSearchQuery(): [
|
||||
() => void
|
||||
] {
|
||||
const history = useHistory();
|
||||
const { path, params } = useRouteMatch<{ type: string; query: string }>();
|
||||
const [search, setSearch] = useState<MWQuery>(getInitialValue(params));
|
||||
const query = useQueryParams();
|
||||
const params = useParams<{ query: string }>();
|
||||
const [search, setSearch] = useState<MWQuery>(getInitialValue(query, params));
|
||||
|
||||
const updateParams = (inp: Partial<MWQuery>, force: boolean) => {
|
||||
const copySearch: MWQuery = { ...search };
|
||||
const copySearch = { ...search };
|
||||
Object.assign(copySearch, inp);
|
||||
setSearch(copySearch);
|
||||
if (!force) return;
|
||||
if (copySearch.searchQuery.length === 0) {
|
||||
history.replace("/");
|
||||
return;
|
||||
}
|
||||
history.replace(
|
||||
generatePath(path, {
|
||||
query:
|
||||
copySearch.searchQuery.length === 0 ? undefined : inp.searchQuery,
|
||||
type: copySearch.type,
|
||||
generatePath("/browse/:query", {
|
||||
query: copySearch.searchQuery,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const onUnFocus = () => {
|
||||
if (search.searchQuery.length === 0) {
|
||||
history.replace("/");
|
||||
return;
|
||||
}
|
||||
history.replace(
|
||||
generatePath(path, {
|
||||
query: search.searchQuery.length === 0 ? undefined : search.searchQuery,
|
||||
type: search.type,
|
||||
generatePath("/browse/:query", {
|
||||
query: search.searchQuery,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
|
||||
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
||||
import { Layout } from "@/setup/Layout";
|
||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||
@ -64,9 +63,6 @@ function App() {
|
||||
<Switch>
|
||||
{/* functional routes */}
|
||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/search/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
<Route exact path="/s/:query">
|
||||
<QuickSearch />
|
||||
</Route>
|
||||
@ -82,9 +78,15 @@ function App() {
|
||||
<MediaView />
|
||||
</LegacyUrlView>
|
||||
</Route>
|
||||
<Route exact path="/search/:type/:query?">
|
||||
<Redirect to="/browse/:query" />
|
||||
</Route>
|
||||
<Route exact path="/search/:type">
|
||||
<Redirect to="/browse" />
|
||||
</Route>
|
||||
<Route
|
||||
exact
|
||||
path="/search/:type/:query?"
|
||||
path={["/browse/:query?", "/"]}
|
||||
component={SearchView}
|
||||
/>
|
||||
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Načítání Vašich oblíbených seriálů...",
|
||||
"loading_movie": "Načítání Vašich oblíbených filmů...",
|
||||
"loading": "Načítání...",
|
||||
"allResults": "To je vše co máme!",
|
||||
"noResults": "Nemohli jsme nic najít!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Auf der Suche nach deiner Lieblingsserie...",
|
||||
"loading_movie": "Auf der Suche nach deinen Lieblingsfilmen...",
|
||||
"loading": "Wird geladen...",
|
||||
"allResults": "Das ist alles, was wir haben!",
|
||||
"noResults": "Wir haben nichts gefunden!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Fetching your favourite series...",
|
||||
"loading_movie": "Fetching your favourite movies...",
|
||||
"loading": "Loading...",
|
||||
"allResults": "That's all we have!",
|
||||
"noResults": "We couldn't find anything!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Recherche de votre série préférée...",
|
||||
"loading_movie": "Recherche de vos films préférés...",
|
||||
"loading": "Chargement...",
|
||||
"allResults": "C'est tout ce que nous avons!",
|
||||
"noResults": "Nous n'avons rien trouvé!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Recupero delle tue serie preferite...",
|
||||
"loading_movie": "Recupero dei tuoi film preferiti...",
|
||||
"loading": "Caricamento...",
|
||||
"allResults": "Ecco tutto ciò che abbiamo!",
|
||||
"noResults": "Non abbiamo trovato nulla!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "We zoeken je favoriete series...",
|
||||
"loading_movie": "We zoeken je favoriete films...",
|
||||
"loading": "Aan het zoeken...",
|
||||
"allResults": "Dat is het!",
|
||||
"noResults": "We konden helaas niets vinden.",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Fetchin' yer favorite series...",
|
||||
"loading_movie": "Fetchin' yer favorite movies...",
|
||||
"loadin'": "Loadin'...",
|
||||
"allResults": "That be all we 'ave, me hearty!",
|
||||
"noResults": "We couldn't find anythin' that matches yer search!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Szukamy twoich ulubionych seriali...",
|
||||
"loading_movie": "Szukamy twoich ulubionych filmów...",
|
||||
"loading": "Wczytywanie...",
|
||||
"allResults": "To wszystko co mamy!",
|
||||
"noResults": "Nie mogliśmy niczego znaleźć!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Favori dizileriniz aranıyor...",
|
||||
"loading_movie": "Favori filmleriniz aranıyor...",
|
||||
"loading": "Yükleniyor...",
|
||||
"allResults": "Bu kadarını bulabildik!",
|
||||
"noResults": "Hiçbir şey bulamadık!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "Đang tìm chương trình yêu thích của bạn...",
|
||||
"loading_movie": "Đang tìm bộ phim yêu thích của bạn...",
|
||||
"loading": "Đang tải...",
|
||||
"allResults": "Đó là tất cả chúng tôi có!",
|
||||
"noResults": "Chúng tôi không thể tìm thấy gì!",
|
||||
|
@ -3,8 +3,6 @@
|
||||
"name": "movie-web"
|
||||
},
|
||||
"search": {
|
||||
"loading_series": "正在获取您最喜欢的连续剧……",
|
||||
"loading_movie": "正在获取您最喜欢的影片……",
|
||||
"loading": "载入中……",
|
||||
"allResults": "以上是我们能找到的所有结果!",
|
||||
"noResults": "我们找不到任何结果!",
|
||||
|
@ -48,7 +48,6 @@ async function getMetas(
|
||||
const year = Number(item.year.toString().split("-")[0]);
|
||||
const data = await searchForMedia({
|
||||
searchQuery: `${item.title} ${year}`,
|
||||
type: item.mediaType,
|
||||
});
|
||||
const relevantItem = data.find(
|
||||
(res) =>
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
getMovieFromExternalId,
|
||||
} from "@/backend/metadata/tmdb";
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw";
|
||||
import { TMDBContentTypes } from "@/backend/metadata/types/tmdb";
|
||||
import { BookmarkStoreData } from "@/state/bookmark/types";
|
||||
import { isNotNull } from "@/utils/typeguard";
|
||||
|
||||
@ -59,7 +60,10 @@ export async function migrateV3Videos(
|
||||
clone.item.meta.id = migratedId;
|
||||
if (clone.item.series) {
|
||||
const series = clone.item.series;
|
||||
const details = await getMediaDetails(migratedId, "show");
|
||||
const details = await getMediaDetails(
|
||||
migratedId,
|
||||
TMDBContentTypes.TV
|
||||
);
|
||||
|
||||
const season = details.seasons.find(
|
||||
(v) => v.season_number === series.season
|
||||
|
@ -1,19 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
|
||||
export function SearchLoadingView() {
|
||||
const { t } = useTranslation();
|
||||
const [query] = useSearchQuery();
|
||||
return (
|
||||
<Loading
|
||||
className="mb-24 mt-40 "
|
||||
text={
|
||||
t(`search.loading_${query.type}`) ||
|
||||
t("search.loading") ||
|
||||
"Fetching your favourite shows..."
|
||||
}
|
||||
/>
|
||||
<Loading className="mb-24 mt-40 " text={t("search.loading") || "..."} />
|
||||
);
|
||||
}
|
||||
|
@ -42,5 +42,5 @@ module.exports = {
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwind-scrollbar"), require("@tailwindcss/line-clamp")]
|
||||
plugins: [require("tailwind-scrollbar")]
|
||||
};
|
||||
|
@ -4,9 +4,21 @@ import loadVersion from "vite-plugin-package-version";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import checker from "vite-plugin-checker";
|
||||
import path from "path";
|
||||
import { handlebars } from "./plugins/handlebars";
|
||||
import { loadEnv } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
return {
|
||||
plugins: [
|
||||
handlebars({
|
||||
vars: {
|
||||
opensearchEnabled: env.VITE_OPENSEARCH_ENABLED === "true",
|
||||
routeDomain: env.VITE_APP_DOMAIN + (env.VITE_NORMAL_ROUTER !== 'true' ? "/#" : ""),
|
||||
domain: env.VITE_APP_DOMAIN,
|
||||
env,
|
||||
},
|
||||
}),
|
||||
react({
|
||||
babel: {
|
||||
presets: [
|
||||
@ -91,4 +103,5 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
178
yarn.lock
178
yarn.lock
@ -1141,6 +1141,18 @@
|
||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
|
||||
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
|
||||
|
||||
"@isaacs/cliui@^8.0.2":
|
||||
version "8.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
|
||||
integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==
|
||||
dependencies:
|
||||
string-width "^5.1.2"
|
||||
string-width-cjs "npm:string-width@^4.2.0"
|
||||
strip-ansi "^7.0.1"
|
||||
strip-ansi-cjs "npm:strip-ansi@^6.0.1"
|
||||
wrap-ansi "^8.1.0"
|
||||
wrap-ansi-cjs "npm:wrap-ansi@^7.0.0"
|
||||
|
||||
"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2":
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz#7e02e6eb5df901aaedb08514203b096614024098"
|
||||
@ -1207,6 +1219,11 @@
|
||||
"@nodelib/fs.scandir" "2.1.5"
|
||||
fastq "^1.6.0"
|
||||
|
||||
"@pkgjs/parseargs@^0.11.0":
|
||||
version "0.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
|
||||
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
|
||||
|
||||
"@react-spring/animated@~9.7.2":
|
||||
version "9.7.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.7.2.tgz#0119db8075e91d693ec45c42575541e01b104a70"
|
||||
@ -1391,11 +1408,6 @@
|
||||
magic-string "^0.25.0"
|
||||
string.prototype.matchall "^4.0.6"
|
||||
|
||||
"@tailwindcss/line-clamp@^0.4.2":
|
||||
version "0.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
|
||||
integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
|
||||
|
||||
"@tootallnate/once@2":
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
|
||||
@ -1836,7 +1848,7 @@ ansi-styles@^3.2.1:
|
||||
dependencies:
|
||||
color-convert "^1.9.0"
|
||||
|
||||
ansi-styles@^4.1.0:
|
||||
ansi-styles@^4.0.0, ansi-styles@^4.1.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
|
||||
integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
|
||||
@ -1848,7 +1860,7 @@ ansi-styles@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
|
||||
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
|
||||
|
||||
ansi-styles@^6.0.0:
|
||||
ansi-styles@^6.0.0, ansi-styles@^6.1.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
@ -2238,7 +2250,7 @@ core-js@^3.29.1, core-js@^3.6.5:
|
||||
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.30.1.tgz#fc9c5adcc541d8e9fa3e381179433cbf795628ba"
|
||||
integrity sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==
|
||||
|
||||
cross-spawn@^7.0.2:
|
||||
cross-spawn@^7.0.0, cross-spawn@^7.0.2:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
|
||||
@ -2453,6 +2465,11 @@ electron-to-chromium@^1.4.284:
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.369.tgz#a98d838cdd79be4471cd04e9b4dffe891d037874"
|
||||
integrity sha512-LfxbHXdA/S+qyoTEA4EbhxGjrxx7WK2h6yb5K2v0UCOufUKX+VZaHbl3svlzZfv9sGseym/g3Ne4DpsgRULmqg==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
emoji-regex@^9.2.2:
|
||||
version "9.2.2"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72"
|
||||
@ -2866,6 +2883,17 @@ fast-diff@^1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
|
||||
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
|
||||
|
||||
fast-glob@^3.2.11:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.0.tgz#7c40cb491e1e2ed5664749e87bfb516dbe8727c0"
|
||||
integrity sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==
|
||||
dependencies:
|
||||
"@nodelib/fs.stat" "^2.0.2"
|
||||
"@nodelib/fs.walk" "^1.2.3"
|
||||
glob-parent "^5.1.2"
|
||||
merge2 "^1.3.0"
|
||||
micromatch "^4.0.4"
|
||||
|
||||
fast-glob@^3.2.12, fast-glob@^3.2.7, fast-glob@^3.2.9:
|
||||
version "3.2.12"
|
||||
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80"
|
||||
@ -2958,6 +2986,14 @@ for-each@^0.3.3:
|
||||
dependencies:
|
||||
is-callable "^1.1.3"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.1.1.tgz#1d173e776d75d2772fed08efe4a0de1ea1b12d0d"
|
||||
integrity sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==
|
||||
dependencies:
|
||||
cross-spawn "^7.0.0"
|
||||
signal-exit "^4.0.1"
|
||||
|
||||
form-data@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||
@ -3089,6 +3125,17 @@ glob@7.1.6:
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
glob@^10.3.3:
|
||||
version "10.3.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.3.tgz#8360a4ffdd6ed90df84aa8d52f21f452e86a123b"
|
||||
integrity sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==
|
||||
dependencies:
|
||||
foreground-child "^3.1.0"
|
||||
jackspeak "^2.0.3"
|
||||
minimatch "^9.0.1"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
path-scurry "^1.10.1"
|
||||
|
||||
glob@^7.1.3, glob@^7.1.6, glob@^7.2.0:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
|
||||
@ -3149,6 +3196,18 @@ grapheme-splitter@^1.0.4:
|
||||
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
|
||||
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
|
||||
|
||||
handlebars@^4.7.7:
|
||||
version "4.7.7"
|
||||
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
|
||||
integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
|
||||
dependencies:
|
||||
minimist "^1.2.5"
|
||||
neo-async "^2.6.0"
|
||||
source-map "^0.6.1"
|
||||
wordwrap "^1.0.0"
|
||||
optionalDependencies:
|
||||
uglify-js "^3.1.4"
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"
|
||||
@ -3397,6 +3456,11 @@ is-extglob@^2.1.1:
|
||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||
|
||||
is-fullwidth-code-point@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
|
||||
integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
|
||||
|
||||
is-fullwidth-code-point@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88"
|
||||
@ -3541,6 +3605,15 @@ isexe@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
jackspeak@^2.0.3:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.2.1.tgz#655e8cf025d872c9c03d3eb63e8f0c024fef16a6"
|
||||
integrity sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==
|
||||
dependencies:
|
||||
"@isaacs/cliui" "^8.0.2"
|
||||
optionalDependencies:
|
||||
"@pkgjs/parseargs" "^0.11.0"
|
||||
|
||||
jake@^10.8.5:
|
||||
version "10.8.5"
|
||||
resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
|
||||
@ -3815,6 +3888,11 @@ lru-cache@^6.0.0:
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
"lru-cache@^9.1.1 || ^10.0.0":
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.0.0.tgz#b9e2a6a72a129d81ab317202d93c7691df727e61"
|
||||
integrity sha512-svTf/fzsKHffP42sujkO/Rjs37BCIsQVRCeNYIm9WN8rgT7ffoUnRtZCqU+6BqcSBdv8gwJeTz8knJpgACeQMw==
|
||||
|
||||
magic-string@^0.25.0, magic-string@^0.25.7:
|
||||
version "0.25.9"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
|
||||
@ -3878,11 +3956,23 @@ minimatch@^5.0.1:
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.6:
|
||||
minimatch@^9.0.1:
|
||||
version "9.0.3"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825"
|
||||
integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==
|
||||
dependencies:
|
||||
brace-expansion "^2.0.1"
|
||||
|
||||
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
|
||||
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
|
||||
|
||||
"minipass@^5.0.0 || ^6.0.2 || ^7.0.0":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.0.2.tgz#58a82b7d81c7010da5bd4b2c0c85ac4b4ec5131e"
|
||||
integrity sha512-eL79dXrE1q9dBbDCLg7xfn/vl7MS4F1gvJAgjJrQli/jbQWdUttuVawphqpffoIYfRdq78LHx6GP4bU/EQ2ATA==
|
||||
|
||||
mlly@^1.1.0, mlly@^1.1.1:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.2.0.tgz#f0f6c2fc8d2d12ea6907cd869066689b5031b613"
|
||||
@ -3946,6 +4036,11 @@ natural-compare@^1.4.0:
|
||||
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
|
||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||
|
||||
neo-async@^2.6.0:
|
||||
version "2.6.2"
|
||||
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
|
||||
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
|
||||
|
||||
node-fetch-native@^1.0.2:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.1.0.tgz#a530f5c4cadb49b382dcf81d8f5f19ed0f457fbe"
|
||||
@ -4151,6 +4246,14 @@ path-parse@^1.0.7:
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
|
||||
integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
|
||||
|
||||
path-scurry@^1.10.1:
|
||||
version "1.10.1"
|
||||
resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.10.1.tgz#9ba6bf5aa8500fe9fd67df4f0d9483b2b0bfc698"
|
||||
integrity sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==
|
||||
dependencies:
|
||||
lru-cache "^9.1.1 || ^10.0.0"
|
||||
minipass "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
|
||||
path-to-regexp@^1.7.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
|
||||
@ -4751,6 +4854,11 @@ siginfo@^2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
|
||||
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
|
||||
|
||||
signal-exit@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.0.2.tgz#ff55bb1d9ff2114c13b400688fa544ac63c36967"
|
||||
integrity sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==
|
||||
|
||||
slash@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
|
||||
@ -4850,7 +4958,17 @@ stop-iteration-iterator@^1.0.0:
|
||||
dependencies:
|
||||
internal-slot "^1.0.4"
|
||||
|
||||
string-width@^5.0.0:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0:
|
||||
name string-width-cjs
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||
integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==
|
||||
@ -4909,7 +5027,7 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@ -5241,6 +5359,11 @@ ufo@^1.1.0, ufo@^1.1.1:
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.1.1.tgz#e70265e7152f3aba425bd013d150b2cdf4056d7c"
|
||||
integrity sha512-MvlCc4GHrmZdAllBc0iUDowff36Q9Ndw/UzqmEKyrfSzokTd9ZCy1i+IIk5hrYKkjoYVQyNbrw7/F8XJ2rEwTg==
|
||||
|
||||
uglify-js@^3.1.4:
|
||||
version "3.17.4"
|
||||
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"
|
||||
integrity sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==
|
||||
|
||||
unbox-primitive@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"
|
||||
@ -5388,6 +5511,16 @@ vite-plugin-pwa@^0.14.4:
|
||||
workbox-build "^6.5.4"
|
||||
workbox-window "^6.5.4"
|
||||
|
||||
vite-plugin-static-copy@^0.16.0:
|
||||
version "0.16.0"
|
||||
resolved "https://registry.yarnpkg.com/vite-plugin-static-copy/-/vite-plugin-static-copy-0.16.0.tgz#2f65227037f17fc99c0782fd0b344e962935e69e"
|
||||
integrity sha512-dMVEg5Z2SwYRgQnHZaeokvSKB4p/TOTf65JU4sP3U6ccSBsukqdtDOjpmT+xzTFHAA8WJjcS31RMLjUdWQCBzw==
|
||||
dependencies:
|
||||
chokidar "^3.5.3"
|
||||
fast-glob "^3.2.11"
|
||||
fs-extra "^11.1.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
"vite@^3.0.0 || ^4.0.0", vite@^4.0.1:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-4.3.1.tgz#9badb1377f995632cdcf05f32103414db6fbb95a"
|
||||
@ -5577,6 +5710,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
||||
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
|
||||
|
||||
wordwrap@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
|
||||
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
|
||||
|
||||
workbox-background-sync@6.5.4:
|
||||
version "6.5.4"
|
||||
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9"
|
||||
@ -5735,6 +5873,24 @@ workbox-window@6.5.4, workbox-window@^6.5.4:
|
||||
"@types/trusted-types" "^2.0.2"
|
||||
workbox-core "6.5.4"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==
|
||||
dependencies:
|
||||
ansi-styles "^6.1.0"
|
||||
string-width "^5.0.1"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
|
Loading…
Reference in New Issue
Block a user