Fix sockets issues (#3015)

* Fix sockets issues

* Fix app comp
This commit is contained in:
Geometrically 2024-12-12 13:25:25 -08:00 committed by GitHub
parent 10ef25eabb
commit c970e9c015
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 572 additions and 456 deletions

18
.idea/code.iml generated Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/apps/daedalus_client/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/packages/daedalus/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app-playground/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/app/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -33,6 +33,7 @@ pub enum ServerToClientMessage {
UserOffline { id: UserId }, UserOffline { id: UserId },
FriendStatuses { statuses: Vec<UserStatus> }, FriendStatuses { statuses: Vec<UserStatus> },
FriendRequest { from: UserId }, FriendRequest { from: UserId },
FriendRequestRejected { from: UserId },
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -40,7 +41,7 @@ struct LauncherHeartbeatInit {
code: String, code: String,
} }
#[get("launcher_heartbeat")] #[get("launcher_socket")]
pub async fn ws_init( pub async fn ws_init(
req: HttpRequest, req: HttpRequest,
pool: Data<PgPool>, pool: Data<PgPool>,
@ -122,16 +123,12 @@ pub async fn ws_init(
user.id, user.id,
ServerToClientMessage::StatusUpdate { status }, ServerToClientMessage::StatusUpdate { status },
&pool, &pool,
&redis,
&db, &db,
Some(friends), Some(friends),
) )
.await?; .await?;
let mut stream = msg_stream let mut stream = msg_stream.aggregate_continuations();
.aggregate_continuations()
// aggregate continuation frames up to 1MiB
.max_continuation_size(2_usize.pow(20));
actix_web::rt::spawn(async move { actix_web::rt::spawn(async move {
// receive messages from websocket // receive messages from websocket
@ -168,7 +165,6 @@ pub async fn ws_init(
status: status.clone(), status: status.clone(),
}, },
&pool, &pool,
&redis,
&db, &db,
None, None,
) )
@ -180,12 +176,23 @@ pub async fn ws_init(
} }
Ok(AggregatedMessage::Close(_)) => { Ok(AggregatedMessage::Close(_)) => {
let _ = close_socket(user.id, &pool, &redis, &db).await; let _ = close_socket(user.id, &pool, &db).await;
}
Ok(AggregatedMessage::Ping(msg)) => {
if let Some(mut socket) =
db.auth_sockets.get_mut(&user.id.into())
{
let (_, socket) = socket.value_mut();
let _ = socket.pong(&msg).await;
}
} }
_ => {} _ => {}
} }
} }
let _ = close_socket(user.id, &pool, &db).await;
}); });
Ok(res) Ok(res)
@ -195,7 +202,6 @@ pub async fn broadcast_friends(
user_id: UserId, user_id: UserId,
message: ServerToClientMessage, message: ServerToClientMessage,
pool: &PgPool, pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets, sockets: &ActiveSockets,
friends: Option<Vec<FriendItem>>, friends: Option<Vec<FriendItem>>,
) -> Result<(), crate::database::models::DatabaseError> { ) -> Result<(), crate::database::models::DatabaseError> {
@ -218,17 +224,7 @@ pub async fn broadcast_friends(
{ {
let (_, socket) = socket.value_mut(); let (_, socket) = socket.value_mut();
// TODO: bulk close sockets for better perf let _ = socket.text(serde_json::to_string(&message)?).await;
if socket.text(serde_json::to_string(&message)?).await.is_err()
{
Box::pin(close_socket(
friend_id.into(),
pool,
redis,
sockets,
))
.await?;
}
} }
} }
} }
@ -239,22 +235,20 @@ pub async fn broadcast_friends(
pub async fn close_socket( pub async fn close_socket(
id: UserId, id: UserId,
pool: &PgPool, pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets, sockets: &ActiveSockets,
) -> Result<(), crate::database::models::DatabaseError> { ) -> Result<(), crate::database::models::DatabaseError> {
if let Some((_, (_, socket))) = sockets.auth_sockets.remove(&id) { if let Some((_, (_, socket))) = sockets.auth_sockets.remove(&id) {
let _ = socket.close(None).await; let _ = socket.close(None).await;
}
broadcast_friends( broadcast_friends(
id, id,
ServerToClientMessage::UserOffline { id }, ServerToClientMessage::UserOffline { id },
pool, pool,
redis,
sockets, sockets,
None, None,
) )
.await?; .await?;
}
Ok(()) Ok(())
} }

View File

@ -74,8 +74,6 @@ pub async fn add_friend(
async fn send_friend_status( async fn send_friend_status(
user_id: UserId, user_id: UserId,
friend_id: UserId, friend_id: UserId,
pool: &PgPool,
redis: &RedisPool,
sockets: &ActiveSockets, sockets: &ActiveSockets,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) { if let Some(pair) = sockets.auth_sockets.get(&user_id.into()) {
@ -85,45 +83,21 @@ pub async fn add_friend(
{ {
let (_, socket) = socket.value_mut(); let (_, socket) = socket.value_mut();
if socket let _ = socket
.text(serde_json::to_string( .text(serde_json::to_string(
&ServerToClientMessage::StatusUpdate { &ServerToClientMessage::StatusUpdate {
status: friend_status.clone(), status: friend_status.clone(),
}, },
)?) )?)
.await .await;
.is_err()
{
close_socket(
friend_id.into(),
pool,
redis,
sockets,
)
.await?;
}
} }
} }
Ok(()) Ok(())
} }
send_friend_status( send_friend_status(friend.user_id, friend.friend_id, &db).await?;
friend.user_id, send_friend_status(friend.friend_id, friend.user_id, &db).await?;
friend.friend_id,
&pool,
&redis,
&db,
)
.await?;
send_friend_status(
friend.friend_id,
friend.user_id,
&pool,
&redis,
&db,
)
.await?;
} else { } else {
if friend.id == user.id.into() { if friend.id == user.id.into() {
return Err(ApiError::InvalidInput( return Err(ApiError::InvalidInput(
@ -157,7 +131,7 @@ pub async fn add_friend(
.await .await
.is_err() .is_err()
{ {
close_socket(user.id, &pool, &redis, &db).await?; close_socket(user.id, &pool, &db).await?;
} }
} }
} }
@ -177,6 +151,7 @@ pub async fn remove_friend(
pool: web::Data<PgPool>, pool: web::Data<PgPool>,
redis: web::Data<RedisPool>, redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>, session_queue: web::Data<AuthQueue>,
db: web::Data<ActiveSockets>,
) -> Result<HttpResponse, ApiError> { ) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers( let user = get_user_from_headers(
&req, &req,
@ -202,6 +177,18 @@ pub async fn remove_friend(
) )
.await?; .await?;
if let Some(mut socket) = db.auth_sockets.get_mut(&friend.id.into()) {
let (_, socket) = socket.value_mut();
let _ = socket
.text(serde_json::to_string(
&ServerToClientMessage::FriendRequestRejected {
from: user.id,
},
)?)
.await;
}
transaction.commit().await?; transaction.commit().await?;
Ok(HttpResponse::NoContent().body("")) Ok(HttpResponse::NoContent().body(""))

View File

@ -16,10 +16,10 @@ use reqwest::header::HeaderValue;
use reqwest::Method; use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::RwLock;
type WriteSocket = type WriteSocket =
Arc<Mutex<Option<SplitSink<WebSocketStream<ConnectStream>, Message>>>>; Arc<RwLock<Option<SplitSink<WebSocketStream<ConnectStream>, Message>>>>;
pub struct FriendsSocket { pub struct FriendsSocket {
write: WriteSocket, write: WriteSocket,
@ -67,7 +67,7 @@ impl Default for FriendsSocket {
impl FriendsSocket { impl FriendsSocket {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
write: Arc::new(Mutex::new(None)), write: Arc::new(RwLock::new(None)),
user_statuses: Arc::new(DashMap::new()), user_statuses: Arc::new(DashMap::new()),
} }
} }
@ -83,7 +83,7 @@ impl FriendsSocket {
if let Some(credentials) = credentials { if let Some(credentials) = credentials {
let mut request = format!( let mut request = format!(
"{MODRINTH_SOCKET_URL}_internal/launcher_heartbeat?code={}", "{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
credentials.session credentials.session
) )
.into_client_request()?; .into_client_request()?;
@ -105,7 +105,7 @@ impl FriendsSocket {
let (write, read) = socket.split(); let (write, read) = socket.split();
{ {
let mut write_lock = self.write.lock().await; let mut write_lock = self.write.write().await;
*write_lock = Some(write); *write_lock = Some(write);
} }
@ -181,10 +181,8 @@ impl FriendsSocket {
} }
} }
let mut w = write_handle.lock().await; let mut w = write_handle.write().await;
*w = None; *w = None;
Self::reconnect_task();
}); });
} }
Err(e) => { Err(e) => {
@ -192,8 +190,6 @@ impl FriendsSocket {
"Error connecting to friends socket: {e:?}" "Error connecting to friends socket: {e:?}"
); );
Self::reconnect_task();
return Err(crate::Error::from(e)); return Err(crate::Error::from(e));
} }
} }
@ -202,40 +198,42 @@ impl FriendsSocket {
Ok(()) Ok(())
} }
fn reconnect_task() { pub async fn reconnect_task() -> crate::Result<()> {
tokio::task::spawn(async move {
let res = async {
let state = crate::State::get().await?; let state = crate::State::get().await?;
{ tokio::task::spawn(async move {
if state.friends_socket.write.lock().await.is_some() { let mut last_connection = Utc::now();
return Ok(());
}
}
state loop {
let connected = {
let read = state.friends_socket.write.read().await;
read.is_some()
};
if !connected
&& Utc::now().signed_duration_since(last_connection)
> chrono::Duration::seconds(30)
{
last_connection = Utc::now();
let _ = state
.friends_socket .friends_socket
.connect( .connect(
&state.pool, &state.pool,
&state.api_semaphore, &state.api_semaphore,
&state.process_manager, &state.process_manager,
) )
.await?; .await;
}
Ok::<(), crate::Error>(()) tokio::time::sleep(std::time::Duration::from_secs(1)).await;
};
if let Err(e) = res.await {
tracing::info!("Error reconnecting to friends socket: {e:?}");
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
FriendsSocket::reconnect_task();
} }
}); });
Ok(())
} }
pub async fn disconnect(&self) -> crate::Result<()> { pub async fn disconnect(&self) -> crate::Result<()> {
let mut write_lock = self.write.lock().await; let mut write_lock = self.write.write().await;
if let Some(ref mut write_half) = *write_lock { if let Some(ref mut write_half) = *write_lock {
write_half.close().await?; write_half.close().await?;
*write_lock = None; *write_lock = None;
@ -247,7 +245,7 @@ impl FriendsSocket {
&self, &self,
profile_name: Option<String>, profile_name: Option<String>,
) -> crate::Result<()> { ) -> crate::Result<()> {
let mut write_lock = self.write.lock().await; let mut write_lock = self.write.write().await;
if let Some(ref mut write_half) = *write_lock { if let Some(ref mut write_half) = *write_lock {
write_half write_half
.send(Message::Text(serde_json::to_string( .send(Message::Text(serde_json::to_string(

View File

@ -87,6 +87,16 @@ impl State {
if let Err(e) = res { if let Err(e) = res {
tracing::error!("Error running discord RPC: {e}"); tracing::error!("Error running discord RPC: {e}");
} }
let _ = state
.friends_socket
.connect(
&state.pool,
&state.api_semaphore,
&state.process_manager,
)
.await;
let _ = FriendsSocket::reconnect_task().await;
}); });
Ok(()) Ok(())
@ -138,9 +148,6 @@ impl State {
let process_manager = ProcessManager::new(); let process_manager = ProcessManager::new();
let friends_socket = FriendsSocket::new(); let friends_socket = FriendsSocket::new();
friends_socket
.connect(&pool, &fetch_semaphore, &process_manager)
.await?;
Ok(Arc::new(Self { Ok(Arc::new(Self {
directories, directories,

View File

@ -41,6 +41,9 @@ const props = withDefaults(
{ {
type: 'standard', type: 'standard',
openByDefault: false, openByDefault: false,
buttonClass: null,
contentClass: null,
titleWrapperClass: null,
}, },
) )

View File

@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
to: any to: any
}>() }>()

View File

@ -70,7 +70,7 @@
</ButtonStyled> </ButtonStyled>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CheckIcon, DropdownIcon, SearchIcon, XIcon } from '@modrinth/assets' import { CheckIcon, DropdownIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, PopoutMenu, Button } from '../index' import { ButtonStyled, PopoutMenu, Button } from '../index'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import ScrollablePanel from './ScrollablePanel.vue' import ScrollablePanel from './ScrollablePanel.vue'
@ -94,6 +94,7 @@ const props = withDefaults(
direction: 'auto', direction: 'auto',
displayName: (option: Option) => option as string, displayName: (option: Option) => option as string,
search: false, search: false,
dropdownId: null,
}, },
) )

View File

@ -368,7 +368,6 @@ onMounted(() => {
if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) { if (clipboardData.files && clipboardData.files.length > 0 && props.onImageUpload) {
// If the user is pasting a file, upload it if there's an included handler and insert the link. // If the user is pasting a file, upload it if there's an included handler and insert the link.
uploadImagesFromList(clipboardData.files) uploadImagesFromList(clipboardData.files)
.then(function (url) { .then(function (url) {
const selection = markdownCommands.yankSelection(view) const selection = markdownCommands.yankSelection(view)
const altText = selection || 'Replace this with a description' const altText = selection || 'Replace this with a description'
@ -654,7 +653,7 @@ function cleanUrl(input: string): string {
// Attempt to validate and parse the URL // Attempt to validate and parse the URL
try { try {
url = new URL(input) url = new URL(input)
} catch (e) { } catch {
throw new Error('Invalid URL. Make sure the URL is well-formed.') throw new Error('Invalid URL. Make sure the URL is well-formed.')
} }

View File

@ -20,7 +20,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
const props = defineProps({ defineProps({
sidebar: { sidebar: {
type: Boolean, type: Boolean,
default: false, default: false,

View File

@ -89,7 +89,7 @@ interface Item extends BaseOption {
type Option = Divider | Item type Option = Divider | Item
const props = withDefaults( withDefaults(
defineProps<{ defineProps<{
options: Option[] options: Option[]
disabled?: boolean disabled?: boolean

View File

@ -60,7 +60,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets' import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import Button from './Button.vue'
import ButtonStyled from './ButtonStyled.vue' import ButtonStyled from './ButtonStyled.vue'
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets' import { RadioButtonIcon, RadioButtonChecked } from '@modrinth/assets'
import { ref } from 'vue'
withDefaults( withDefaults(
defineProps<{ defineProps<{

View File

@ -76,11 +76,19 @@ function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
} }
&.bottom-fade { &.bottom-fade {
mask-image: linear-gradient(rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%); mask-image: linear-gradient(
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
} }
&.top-fade.bottom-fade { &.top-fade.bottom-fade {
mask-image: linear-gradient(transparent, rgb(0 0 0 / 100%) var(--_fade-height), rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)), transparent 100%); mask-image: linear-gradient(
transparent,
rgb(0 0 0 / 100%) var(--_fade-height),
rgb(0 0 0 / 100%) calc(100% - var(--_fade-height)),
transparent 100%
);
} }
} }
.scrollable-pane { .scrollable-pane {

View File

@ -1,7 +1,5 @@
<template> <template>
<span <span class="inline-flex items-center gap-1 font-semibold text-secondary">
class="inline-flex items-center gap-1 font-semibold text-secondary"
>
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" /> <component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
{{ formattedName }} {{ formattedName }}
</span> </span>

View File

@ -942,7 +942,6 @@ async function submitPayment() {
defineExpose({ defineExpose({
show: () => { show: () => {
stripe = Stripe(props.publishableKey) stripe = Stripe(props.publishableKey)
selectedPlan.value = 'yearly' selectedPlan.value = 'yearly'

View File

@ -3,21 +3,20 @@ import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue' import Avatar from '../base/Avatar.vue'
import Checkbox from '../base/Checkbox.vue' import Checkbox from '../base/Checkbox.vue'
import type { RouteLocationRaw } from 'vue-router' import type { RouteLocationRaw } from 'vue-router'
import { SlashIcon } from '@modrinth/assets'
import { ref } from 'vue'
export interface ContentCreator { export interface ContentCreator {
name: string name: string
type: 'user' | 'organization' type: 'user' | 'organization'
id: string id: string
link?: string | RouteLocationRaw link?: string | RouteLocationRaw
// eslint-disable-next-line @typescript-eslint/no-explicit-any
linkProps?: any linkProps?: any
} }
export interface ContentProject { export interface ContentProject {
id: string id: string
link?: string | RouteLocationRaw link?: string | RouteLocationRaw
// eslint-disable-next-line @typescript-eslint/no-explicit-any
linkProps?: any linkProps?: any
} }
@ -46,7 +45,7 @@ withDefaults(
}, },
) )
const model = defineModel() const model = defineModel<boolean>()
</script> </script>
<template> <template>
<div <div

View File

@ -5,7 +5,6 @@ import Checkbox from '../base/Checkbox.vue'
import ContentListItem from './ContentListItem.vue' import ContentListItem from './ContentListItem.vue'
import type { ContentItem } from './ContentListItem.vue' import type { ContentItem } from './ContentListItem.vue'
import { DropdownIcon } from '@modrinth/assets' import { DropdownIcon } from '@modrinth/assets'
// @ts-ignore
import { RecycleScroller } from 'vue-virtual-scroller' import { RecycleScroller } from 'vue-virtual-scroller'
const props = withDefaults( const props = withDefaults(

View File

@ -69,6 +69,7 @@ const props = withDefaults(
closeOnClickOutside: true, closeOnClickOutside: true,
closeOnEsc: true, closeOnEsc: true,
warnOnClose: false, warnOnClose: false,
header: null,
onHide: () => {}, onHide: () => {},
onShow: () => {}, onShow: () => {},
}, },

View File

@ -7,31 +7,29 @@ import { computed } from 'vue'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
project: { project: {
body: string, body: string
color: number, color: number
} }
}>(), }>(),
{ {},
},
) )
function clamp (value: number) { function clamp(value: number) {
return Math.max(0, Math.min(255, value)); return Math.max(0, Math.min(255, value))
} }
function toHex (value: number) { function toHex(value: number) {
return clamp(value).toString(16).padStart(2, '0'); return clamp(value).toString(16).padStart(2, '0')
} }
function decimalToHexColor(decimal: number) { function decimalToHexColor(decimal: number) {
const r = (decimal >> 16) & 255; const r = (decimal >> 16) & 255
const g = (decimal >> 8) & 255; const g = (decimal >> 8) & 255
const b = decimal & 255; const b = decimal & 255
return `#${toHex(r)}${toHex(g)}${toHex(b)}`; return `#${toHex(r)}${toHex(g)}${toHex(b)}`
} }
const color = computed(() => { const color = computed(() => {
return decimalToHexColor(props.project.color) return decimalToHexColor(props.project.color)
}) })

View File

@ -7,10 +7,7 @@
{{ project.title }} {{ project.title }}
</template> </template>
<template #title-suffix> <template #title-suffix>
<ProjectStatusBadge <ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
v-if="member || project.status !== 'approved'"
:status="project.status"
/>
</template> </template>
<template #summary> <template #summary>
{{ project.description }} {{ project.description }}
@ -39,10 +36,7 @@
<div class="hidden items-center gap-2 md:flex"> <div class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" /> <TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<TagItem <TagItem v-for="(category, index) in project.categories" :key="index">
v-for="(category, index) in project.categories"
:key="index"
>
{{ formatCategory(category) }} {{ formatCategory(category) }}
</TagItem> </TagItem>
</div> </div>
@ -55,7 +49,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets' import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
import Badge from '../base/SimpleBadge.vue'
import Avatar from '../base/Avatar.vue' import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue' import ContentPageHeader from '../base/ContentPageHeader.vue'
import { formatCategory, formatNumber, type Project } from '@modrinth/utils' import { formatCategory, formatNumber, type Project } from '@modrinth/utils'

View File

@ -2,14 +2,12 @@
<div class="markdown-body" v-html="renderHighlightedString(description ?? '')" /> <div class="markdown-body" v-html="renderHighlightedString(description ?? '')" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { renderHighlightedString } from '@modrinth/utils' import { renderHighlightedString } from '@modrinth/utils'
withDefaults( withDefaults(
defineProps<{ defineProps<{
description: string, description: string
}>(), }>(),
{ {},
},
) )
</script> </script>

View File

@ -87,11 +87,16 @@
<div class="flex items-center"> <div class="flex items-center">
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<TagItem <TagItem
v-for="gameVersion in formatVersionsForDisplay(version.game_versions, gameVersions)" v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
)"
:key="`version-tag-${gameVersion}`" :key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`" v-tooltip="`Toggle filter for ${gameVersion}`"
class="z-[1]" class="z-[1]"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)" :action="
() => versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
> >
{{ gameVersion }} {{ gameVersion }}
</TagItem> </TagItem>
@ -141,10 +146,7 @@
<div class="flex items-start justify-end gap-1 sm:items-center z-[1]"> <div class="flex items-start justify-end gap-1 sm:items-center z-[1]">
<slot name="actions" :version="version"></slot> <slot name="actions" :version="version"></slot>
</div> </div>
<div <div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full">
v-if="showFiles"
class="tag-list pointer-events-none relative z-[1] col-span-full"
>
<div <div
v-for="(file, fileIdx) in version.files" v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`" :key="`platform-tag-${fileIdx}`"
@ -169,17 +171,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
formatBytes, formatBytes,
formatCategory, formatNumber, formatCategory,
formatNumber,
formatVersionsForDisplay, formatVersionsForDisplay,
type GameVersionTag, type PlatformTag, type Version type GameVersionTag,
type PlatformTag,
type Version,
} from '@modrinth/utils' } from '@modrinth/utils'
import { commonMessages } from '../../utils/common-messages' import { commonMessages } from '../../utils/common-messages'
import { import { CalendarIcon, DownloadIcon, StarIcon } from '@modrinth/assets'
CalendarIcon,
DownloadIcon,
StarIcon,
} from '@modrinth/assets'
import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index' import { Pagination, VersionChannelIndicator, VersionFilterControl } from '../index'
import { useVIntl } from '@vintl/vintl' import { useVIntl } from '@vintl/vintl'
import { type Ref, ref, computed } from 'vue' import { type Ref, ref, computed } from 'vue'
@ -196,18 +197,18 @@ type VersionWithDisplayUrlEnding = Version & {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
baseId?: string, baseId?: string
project: { project: {
project_type: string project_type: string
slug?: string slug?: string
id: string id: string
}, }
versions: VersionWithDisplayUrlEnding[], versions: VersionWithDisplayUrlEnding[]
showFiles?: boolean, showFiles?: boolean
currentMember?: boolean, currentMember?: boolean
loaders: PlatformTag[], loaders: PlatformTag[]
gameVersions: GameVersionTag[], gameVersions: GameVersionTag[]
versionLink?: (version: Version) => string, versionLink?: (version: Version) => string
}>(), }>(),
{ {
baseId: undefined, baseId: undefined,
@ -217,23 +218,26 @@ const props = withDefaults(
}, },
) )
const currentPage: Ref<number> = ref(1); const currentPage: Ref<number> = ref(1)
const pageSize: Ref<number> = ref(20); const pageSize: Ref<number> = ref(20)
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null) const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
const selectedGameVersions: Ref<string[]> = computed(() => versionFilters.value?.selectedGameVersions ?? []); const selectedGameVersions: Ref<string[]> = computed(
const selectedPlatforms: Ref<string[]> = computed(() => versionFilters.value?.selectedPlatforms ?? []); () => versionFilters.value?.selectedGameVersions ?? [],
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? []); )
const selectedPlatforms: Ref<string[]> = computed(
() => versionFilters.value?.selectedPlatforms ?? [],
)
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? [])
const filteredVersions = computed(() => { const filteredVersions = computed(() => {
return props.versions.filter( return props.versions.filter(
(version) => (version) =>
hasAnySelected(version.game_versions, selectedGameVersions.value) && hasAnySelected(version.game_versions, selectedGameVersions.value) &&
hasAnySelected(version.loaders, selectedPlatforms.value) && hasAnySelected(version.loaders, selectedPlatforms.value) &&
isAnySelected(version.version_type, selectedChannels.value) isAnySelected(version.version_type, selectedChannels.value),
); )
}); })
function hasAnySelected(values: string[], selected: string[]) { function hasAnySelected(values: string[], selected: string[]) {
return selected.length === 0 || selected.some((value) => values.includes(value)) return selected.length === 0 || selected.some((value) => values.includes(value))
@ -244,33 +248,37 @@ function isAnySelected(value: string, selected: string[]) {
} }
const currentVersions = computed(() => const currentVersions = computed(() =>
filteredVersions.value.slice((currentPage.value - 1) * pageSize.value, currentPage.value * pageSize.value)); filteredVersions.value.slice(
(currentPage.value - 1) * pageSize.value,
currentPage.value * pageSize.value,
),
)
const route = useRoute(); const route = useRoute()
const router = useRouter(); const router = useRouter()
if (route.query.page) { if (route.query.page) {
currentPage.value = Number(route.query.page) || 1; currentPage.value = Number(route.query.page) || 1
} }
function switchPage(page: number) { function switchPage(page: number) {
currentPage.value = page; currentPage.value = page
router.replace({ router.replace({
query: { query: {
...route.query, ...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined, page: currentPage.value !== 1 ? currentPage.value : undefined,
}, },
}); })
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) { function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) {
if (newQueries.page) { if (newQueries.page) {
currentPage.value = Number(newQueries.page); currentPage.value = Number(newQueries.page)
} else if (newQueries.page === undefined) { } else if (newQueries.page === undefined) {
currentPage.value = 1; currentPage.value = 1
} }
router.replace({ router.replace({
@ -278,9 +286,8 @@ function updateQuery(newQueries: Record<string, string | string[] | undefined |
...route.query, ...route.query,
...newQueries, ...newQueries,
}, },
}); })
} }
</script> </script>
<style scoped> <style scoped>
.versions-grid-row { .versions-grid-row {

View File

@ -60,7 +60,10 @@
<TagItem <TagItem
v-if=" v-if="
project.project_type !== 'datapack' && project.project_type !== 'datapack' &&
project.client_side !== 'unsupported' && project.server_side !== 'unsupported' && project.client_side !== 'unknown' && project.server_side !== 'unknown' project.client_side !== 'unsupported' &&
project.server_side !== 'unsupported' &&
project.client_side !== 'unknown' &&
project.server_side !== 'unknown'
" "
> >
<MonitorSmartphoneIcon aria-hidden="true" /> <MonitorSmartphoneIcon aria-hidden="true" />
@ -88,6 +91,7 @@ defineProps<{
loaders: string[] loaders: string[]
client_side: EnvironmentValue client_side: EnvironmentValue
server_side: EnvironmentValue server_side: EnvironmentValue
// eslint-disable-next-line @typescript-eslint/no-explicit-any
versions: any[] versions: any[]
} }
tags: { tags: {

View File

@ -66,7 +66,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { BookTextIcon, CalendarIcon, ScaleIcon, VersionIcon, ExternalIcon } from '@modrinth/assets' import { BookTextIcon, CalendarIcon, ScaleIcon, VersionIcon, ExternalIcon } from '@modrinth/assets'
import { useVIntl, defineMessages } from '@vintl/vintl' import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed, ref } from 'vue' import { computed } from 'vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()

View File

@ -11,7 +11,8 @@ import {
CalendarIcon, CalendarIcon,
GlobeIcon, GlobeIcon,
LinkIcon, LinkIcon,
UnknownIcon, XIcon UnknownIcon,
XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { useVIntl, defineMessage, type MessageDescriptor } from '@vintl/vintl' import { useVIntl, defineMessage, type MessageDescriptor } from '@vintl/vintl'
import type { Component } from 'vue' import type { Component } from 'vue'
@ -30,7 +31,7 @@ const metadata = computed(() => ({
formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status), formattedName: formatMessage(statusMetadata[props.status]?.message ?? props.status),
})) }))
const statusMetadata: Record<ProjectStatus, { icon?: Component, message: MessageDescriptor }> = { const statusMetadata: Record<ProjectStatus, { icon?: Component; message: MessageDescriptor }> = {
approved: { approved: {
icon: GlobeIcon, icon: GlobeIcon,
message: defineMessage({ message: defineMessage({

View File

@ -69,6 +69,7 @@ const props = defineProps<{
}>() }>()
const filters = computed(() => { const filters = computed(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const filters: FilterType<any>[] = [ const filters: FilterType<any>[] = [
{ {
id: 'platform', id: 'platform',

View File

@ -4,8 +4,7 @@
:class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`" :class="`flex border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-2 [@media(hover:hover)]:py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98] ${included ? 'bg-brand-highlight text-contrast hover:brightness-125' : excluded ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg focus-visible:bg-button-bg [&>svg.check-icon]:hover:text-brand [&>svg.check-icon]:focus-visible:text-brand'}`"
@click="() => emit('toggle', option)" @click="() => emit('toggle', option)"
> >
<slot> <slot> </slot>
</slot>
<BanIcon <BanIcon
v-if="excluded" v-if="excluded"
:class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`" :class="`filter-action-icon ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${excluded ? '' : '[@media(hover:hover)]:opacity-0'}`"
@ -17,8 +16,11 @@
aria-hidden="true" aria-hidden="true"
/> />
</button> </button>
<div v-if="supportsNegativeFilter && !excluded" class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents" :class="{ 'opacity-0': included }"> <div
</div> v-if="supportsNegativeFilter && !excluded"
class="w-px h-[1.75rem] bg-button-bg [@media(hover:hover)]:contents"
:class="{ 'opacity-0': included }"
></div>
<button <button
v-if="supportsNegativeFilter && !excluded" v-if="supportsNegativeFilter && !excluded"
v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'" v-tooltip="excluded ? 'Remove exclusion' : 'Exclude'"
@ -34,14 +36,17 @@
import { BanIcon, CheckIcon } from '@modrinth/assets' import { BanIcon, CheckIcon } from '@modrinth/assets'
import type { FilterOption } from '../../utils/search' import type { FilterOption } from '../../utils/search'
withDefaults(defineProps<{ withDefaults(
defineProps<{
option: FilterOption option: FilterOption
included: boolean included: boolean
excluded: boolean excluded: boolean
supportsNegativeFilter?: boolean supportsNegativeFilter?: boolean
}>(), { }>(),
{
supportsNegativeFilter: false, supportsNegativeFilter: false,
}) },
)
const emit = defineEmits<{ const emit = defineEmits<{
toggle: [option: FilterOption] toggle: [option: FilterOption]

View File

@ -41,7 +41,7 @@
<template v-if="locked" #default> <template v-if="locked" #default>
<div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2"> <div class="flex flex-col gap-2 p-3 border-dashed border-2 rounded-2xl border-divider mx-2">
<p class="m-0 font-bold items-center"> <p class="m-0 font-bold items-center">
<slot :name="`locked-${filterType.id}`" > <slot :name="`locked-${filterType.id}`">
{{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }} {{ formatMessage(messages.lockedTitle, { type: filterType.formatted_name }) }}
</slot> </slot>
</p> </p>

View File

@ -52,7 +52,7 @@
v-for="channel in selectedChannels" v-for="channel in selectedChannels"
:key="`remove-filter-${channel}`" :key="`remove-filter-${channel}`"
:style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`" :style="`--_color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'});--_bg-color: var(--color-${channel === 'alpha' ? 'red' : channel === 'beta' ? 'orange' : 'green'}-highlight)`"
:action="() =>toggleFilter('channel', channel)" :action="() => toggleFilter('channel', channel)"
> >
<XIcon /> <XIcon />
{{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }} {{ channel.slice(0, 1).toUpperCase() + channel.slice(1) }}
@ -60,7 +60,7 @@
<TagItem <TagItem
v-for="version in selectedGameVersions" v-for="version in selectedGameVersions"
:key="`remove-filter-${version}`" :key="`remove-filter-${version}`"
:action="() =>toggleFilter('gameVersion', version)" :action="() => toggleFilter('gameVersion', version)"
> >
<XIcon /> <XIcon />
{{ version }} {{ version }}
@ -79,119 +79,119 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { FilterIcon, XCircleIcon, XIcon } from "@modrinth/assets"; import { FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { ManySelect, Checkbox } from "../index"; import { ManySelect, Checkbox } from '../index'
import { type Version , formatCategory, type GameVersionTag } from '@modrinth/utils'; import { type Version, formatCategory, type GameVersionTag } from '@modrinth/utils'
import { ref, computed } from "vue"; import { ref, computed } from 'vue'
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router'
import TagItem from '../base/TagItem.vue' import TagItem from '../base/TagItem.vue'
const props = defineProps<{ const props = defineProps<{
versions: Version[] versions: Version[]
gameVersions: GameVersionTag[] gameVersions: GameVersionTag[]
baseId?: string baseId?: string
}>(); }>()
const emit = defineEmits(["update:query"]); const emit = defineEmits(['update:query'])
const allChannels = ref(["release", "beta", "alpha"]); const allChannels = ref(['release', 'beta', 'alpha'])
const route = useRoute(); const route = useRoute()
const showSnapshots = ref(false); const showSnapshots = ref(false)
type FilterType = "channel" | "gameVersion" | "platform"; type FilterType = 'channel' | 'gameVersion' | 'platform'
type Filter = string; type Filter = string
const filterOptions = computed(() => { const filterOptions = computed(() => {
const filters: Record<FilterType, Filter[]> = { const filters: Record<FilterType, Filter[]> = {
channel: [], channel: [],
gameVersion: [], gameVersion: [],
platform: [], platform: [],
}; }
const platformSet = new Set(); const platformSet = new Set()
const gameVersionSet = new Set(); const gameVersionSet = new Set()
const channelSet = new Set(); const channelSet = new Set()
for (const version of props.versions) { for (const version of props.versions) {
for (const loader of version.loaders) { for (const loader of version.loaders) {
platformSet.add(loader); platformSet.add(loader)
} }
for (const gameVersion of version.game_versions) { for (const gameVersion of version.game_versions) {
gameVersionSet.add(gameVersion); gameVersionSet.add(gameVersion)
} }
channelSet.add(version.version_type); channelSet.add(version.version_type)
} }
if (channelSet.size > 0) { if (channelSet.size > 0) {
filters.channel = Array.from(channelSet) as Filter[]; filters.channel = Array.from(channelSet) as Filter[]
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b)); filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b))
} }
if (gameVersionSet.size > 0) { if (gameVersionSet.size > 0) {
const gameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version)); const gameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version))
filters.gameVersion = gameVersions filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release")) .filter((x) => (showSnapshots.value ? true : x.version_type === 'release'))
.map((x) => x.version); .map((x) => x.version)
} }
if (platformSet.size > 0) { if (platformSet.size > 0) {
filters.platform = Array.from(platformSet) as Filter[]; filters.platform = Array.from(platformSet) as Filter[]
} }
return filters; return filters
}); })
const selectedChannels = ref<string[]>([]); const selectedChannels = ref<string[]>([])
const selectedGameVersions = ref<string[]>([]); const selectedGameVersions = ref<string[]>([])
const selectedPlatforms = ref<string[]>([]); const selectedPlatforms = ref<string[]>([])
selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : []; selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : []
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []; selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []; selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []
async function toggleFilters(type: FilterType, filters: Filter[]) { async function toggleFilters(type: FilterType, filters: Filter[]) {
for (const filter of filters) { for (const filter of filters) {
await toggleFilter(type, filter, true); await toggleFilter(type, filter, true)
} }
updateFilters(); updateFilters()
} }
async function toggleFilter(type: FilterType, filter: Filter, bulk = false) { async function toggleFilter(type: FilterType, filter: Filter, bulk = false) {
if (type === "channel") { if (type === 'channel') {
selectedChannels.value = selectedChannels.value.includes(filter) selectedChannels.value = selectedChannels.value.includes(filter)
? selectedChannels.value.filter((x) => x !== filter) ? selectedChannels.value.filter((x) => x !== filter)
: [...selectedChannels.value, filter]; : [...selectedChannels.value, filter]
} else if (type === "gameVersion") { } else if (type === 'gameVersion') {
selectedGameVersions.value = selectedGameVersions.value.includes(filter) selectedGameVersions.value = selectedGameVersions.value.includes(filter)
? selectedGameVersions.value.filter((x) => x !== filter) ? selectedGameVersions.value.filter((x) => x !== filter)
: [...selectedGameVersions.value, filter]; : [...selectedGameVersions.value, filter]
} else if (type === "platform") { } else if (type === 'platform') {
selectedPlatforms.value = selectedPlatforms.value.includes(filter) selectedPlatforms.value = selectedPlatforms.value.includes(filter)
? selectedPlatforms.value.filter((x) => x !== filter) ? selectedPlatforms.value.filter((x) => x !== filter)
: [...selectedPlatforms.value, filter]; : [...selectedPlatforms.value, filter]
} }
if (!bulk) { if (!bulk) {
updateFilters(); updateFilters()
} }
} }
async function clearFilters() { async function clearFilters() {
selectedChannels.value = []; selectedChannels.value = []
selectedGameVersions.value = []; selectedGameVersions.value = []
selectedPlatforms.value = []; selectedPlatforms.value = []
updateFilters(); updateFilters()
} }
function updateFilters() { function updateFilters() {
emit("update:query", { emit('update:query', {
c: selectedChannels.value, c: selectedChannels.value,
g: selectedGameVersions.value, g: selectedGameVersions.value,
l: selectedPlatforms.value, l: selectedPlatforms.value,
page: undefined, page: undefined,
}); })
} }
defineExpose({ defineExpose({
@ -200,13 +200,13 @@ defineExpose({
selectedChannels, selectedChannels,
selectedGameVersions, selectedGameVersions,
selectedPlatforms, selectedPlatforms,
}); })
function getArrayOrString(x: string | string[]): string[] { function getArrayOrString(x: string | string[]): string[] {
if (typeof x === "string") { if (typeof x === 'string') {
return [x]; return [x]
} else { } else {
return x; return x
} }
} }
</script> </script>

View File

@ -30,18 +30,18 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, VersionChannelIndicator } from "../index"; import { ButtonStyled, VersionChannelIndicator } from '../index'
import { DownloadIcon, ExternalIcon } from "@modrinth/assets"; import { DownloadIcon, ExternalIcon } from '@modrinth/assets'
import { computed } from "vue"; import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
version: Version; version: Version
}>(); }>()
const downloadUrl = computed(() => { const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0]; const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0]
return primary.url; return primary.url
}); })
const emit = defineEmits(["onDownload", "onNavigate"]); const emit = defineEmits(['onDownload', 'onNavigate'])
</script> </script>

View File

@ -1,4 +1,4 @@
import { type Ref , type Component, computed, readonly, ref } from 'vue'; import { type Ref, type Component, computed, readonly, ref } from 'vue'
import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router' import { type LocationQueryRaw, type LocationQueryValue, useRoute } from 'vue-router'
import { defineMessage, useVIntl } from '@vintl/vintl' import { defineMessage, useVIntl } from '@vintl/vintl'
import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils' import { formatCategory, formatCategoryHeader, sortByNameOrNumber } from '@modrinth/utils'
@ -8,40 +8,44 @@ type BaseOption = {
id: string id: string
formatted_name?: string formatted_name?: string
toggle_group?: string toggle_group?: string
icon?: string | Component, icon?: string | Component
query_value?: string, query_value?: string
} }
export type FilterOption = BaseOption & ( export type FilterOption = BaseOption &
{ method: 'or' | 'and', value: string, } | (
{ method: 'environment', environment: 'client' | 'server', } | { method: 'or' | 'and'; value: string }
) | { method: 'environment'; environment: 'client' | 'server' }
)
export type FilterType = { export type FilterType = {
id: string, id: string
formatted_name: string, formatted_name: string
options: FilterOption[], options: FilterOption[]
supported_project_types: ProjectType[], supported_project_types: ProjectType[]
query_param: string, query_param: string
supports_negative_filter: boolean supports_negative_filter: boolean
toggle_groups?: { toggle_groups?: {
id: string, id: string
formatted_name: string, formatted_name: string
query_param?: string, query_param?: string
}[], }[]
searchable: boolean, searchable: boolean
allows_custom_options?: 'and' | 'or', allows_custom_options?: 'and' | 'or'
} & ({ } & (
| {
display: 'all' | 'scrollable' | 'none' display: 'all' | 'scrollable' | 'none'
} | { }
display: 'expandable', | {
display: 'expandable'
default_values: string[] default_values: string[]
}) }
)
export type FilterValue = { export type FilterValue = {
type: string, type: string
option: string, option: string
negative?: boolean, negative?: boolean
} }
export interface GameVersion { export interface GameVersion {
@ -53,7 +57,14 @@ export interface GameVersion {
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin' export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'datapack' | 'plugin'
const ALL_PROJECT_TYPES: ProjectType[] = [ 'mod', 'modpack', 'resourcepack', 'shader', 'datapack', 'plugin' ] const ALL_PROJECT_TYPES: ProjectType[] = [
'mod',
'modpack',
'resourcepack',
'shader',
'datapack',
'plugin',
]
export interface Platform { export interface Platform {
name: string name: string
@ -81,7 +92,11 @@ export interface SortType {
name: string name: string
} }
export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, providedFilters: Ref<FilterValue[]>) { export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
providedFilters: Ref<FilterValue[]>,
) {
const query = ref('') const query = ref('')
const maxResults = ref(20) const maxResults = ref(20)
@ -102,7 +117,7 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
const toggledGroups = ref<string[]>([]) const toggledGroups = ref<string[]>([])
const overriddenProvidedFilterTypes = ref<string[]>([]) const overriddenProvidedFilterTypes = ref<string[]>([])
const { formatMessage } = useVIntl(); const { formatMessage } = useVIntl()
const filters = computed(() => { const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {} const categoryFilters: Record<string, FilterType> = {}
@ -112,12 +127,15 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
categoryFilters[filterTypeId] = { categoryFilters[filterTypeId] = {
id: filterTypeId, id: filterTypeId,
formatted_name: formatCategoryHeader(category.header), formatted_name: formatCategoryHeader(category.header),
supported_project_types: category.project_type === 'mod' ? ['mod', 'plugin', 'datapack'] : [category.project_type], supported_project_types:
category.project_type === 'mod'
? ['mod', 'plugin', 'datapack']
: [category.project_type],
display: 'all', display: 'all',
query_param: category.header === 'resolutions' ? 'g' : 'f', query_param: category.header === 'resolutions' ? 'g' : 'f',
supports_negative_filter: true, supports_negative_filter: true,
searchable: false, searchable: false,
options: [] options: [],
} }
} }
categoryFilters[filterTypeId].options.push({ categoryFilters[filterTypeId].options.push({
@ -125,7 +143,7 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
formatted_name: formatCategory(category.name), formatted_name: formatCategory(category.name),
icon: category.icon, icon: category.icon,
value: `categories:${category.name}`, value: `categories:${category.name}`,
method: category.header === 'resolutions' ? 'or' : 'and' method: category.header === 'resolutions' ? 'or' : 'and',
}) })
} }
@ -133,8 +151,10 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
...Object.values(categoryFilters), ...Object.values(categoryFilters),
{ {
id: 'environment', id: 'environment',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment', defaultMessage: 'Environment' })), formatted_name: formatMessage(
supported_project_types: [ 'mod', 'modpack' ], defineMessage({ id: 'search.filter_type.environment', defaultMessage: 'Environment' }),
),
supported_project_types: ['mod', 'modpack'],
display: 'all', display: 'all',
query_param: 'e', query_param: 'e',
supports_negative_filter: false, supports_negative_filter: false,
@ -142,23 +162,35 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
options: [ options: [
{ {
id: 'client', id: 'client',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.client', defaultMessage: 'Client' })), formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.environment.client',
defaultMessage: 'Client',
}),
),
icon: ClientIcon, icon: ClientIcon,
method: 'environment', method: 'environment',
environment: 'client', environment: 'client',
}, },
{ {
id: 'server', id: 'server',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.environment.server', defaultMessage: 'Server' })), formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.environment.server',
defaultMessage: 'Server',
}),
),
icon: ServerIcon, icon: ServerIcon,
method: 'environment', method: 'environment',
environment: 'server', environment: 'server',
} },
] ],
}, },
{ {
id: 'game_version', id: 'game_version',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version', defaultMessage: 'Game version' })), formatted_name: formatMessage(
defineMessage({ id: 'search.filter_type.game_version', defaultMessage: 'Game version' }),
),
supported_project_types: ALL_PROJECT_TYPES, supported_project_types: ALL_PROJECT_TYPES,
display: 'scrollable', display: 'scrollable',
query_param: 'v', query_param: 'v',
@ -166,30 +198,43 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
toggle_groups: [ toggle_groups: [
{ {
id: 'all_versions', id: 'all_versions',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.game_version.all_versions', defaultMessage: 'Show all versions' })), formatted_name: formatMessage(
query_param: 'h' defineMessage({
} id: 'search.filter_type.game_version.all_versions',
defaultMessage: 'Show all versions',
}),
),
query_param: 'h',
},
], ],
searchable: true, searchable: true,
options: tags.value.gameVersions.map(gameVersion => options: tags.value.gameVersions.map((gameVersion) => ({
({
id: gameVersion.version, id: gameVersion.version,
toggle_group: gameVersion.version_type !== 'release' ? 'all_versions' : undefined, toggle_group: gameVersion.version_type !== 'release' ? 'all_versions' : undefined,
value: `versions:${gameVersion.version}`, value: `versions:${gameVersion.version}`,
query_value: gameVersion.version, query_value: gameVersion.version,
method: 'or' method: 'or',
})), })),
}, },
{ {
id: 'mod_loader', id: 'mod_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.mod_loader', defaultMessage: 'Loader' })), formatted_name: formatMessage(
supported_project_types: [ 'mod' ], defineMessage({ id: 'search.filter_type.mod_loader', defaultMessage: 'Loader' }),
),
supported_project_types: ['mod'],
display: 'expandable', display: 'expandable',
query_param: 'g', query_param: 'g',
supports_negative_filter: true, supports_negative_filter: true,
default_values: [ 'fabric', 'forge', 'neoforge', 'quilt' ], default_values: ['fabric', 'forge', 'neoforge', 'quilt'],
searchable: false, searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('mod') && !loader.supported_project_types.includes('plugin') && !loader.supported_project_types.includes('datapack')).map(loader => { options: tags.value.loaders
.filter(
(loader) =>
loader.supported_project_types.includes('mod') &&
!loader.supported_project_types.includes('plugin') &&
!loader.supported_project_types.includes('datapack'),
)
.map((loader) => {
return { return {
id: loader.name, id: loader.name,
formatted_name: formatCategory(loader.name), formatted_name: formatCategory(loader.name),
@ -201,13 +246,17 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
}, },
{ {
id: 'modpack_loader', id: 'modpack_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.modpack_loader', defaultMessage: 'Loader' })), formatted_name: formatMessage(
supported_project_types: [ 'modpack' ], defineMessage({ id: 'search.filter_type.modpack_loader', defaultMessage: 'Loader' }),
),
supported_project_types: ['modpack'],
display: 'all', display: 'all',
query_param: 'g', query_param: 'g',
supports_negative_filter: true, supports_negative_filter: true,
searchable: false, searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('modpack')).map(loader => { options: tags.value.loaders
.filter((loader) => loader.supported_project_types.includes('modpack'))
.map((loader) => {
return { return {
id: loader.name, id: loader.name,
formatted_name: formatCategory(loader.name), formatted_name: formatCategory(loader.name),
@ -219,13 +268,21 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
}, },
{ {
id: 'plugin_loader', id: 'plugin_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_loader', defaultMessage: 'Loader' })), formatted_name: formatMessage(
supported_project_types: [ 'plugin' ], defineMessage({ id: 'search.filter_type.plugin_loader', defaultMessage: 'Loader' }),
),
supported_project_types: ['plugin'],
display: 'all', display: 'all',
query_param: 'g', query_param: 'g',
supports_negative_filter: true, supports_negative_filter: true,
searchable: false, searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('plugin') && !['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => { options: tags.value.loaders
.filter(
(loader) =>
loader.supported_project_types.includes('plugin') &&
!['bungeecord', 'waterfall', 'velocity'].includes(loader.name),
)
.map((loader) => {
return { return {
id: loader.name, id: loader.name,
formatted_name: formatCategory(loader.name), formatted_name: formatCategory(loader.name),
@ -237,13 +294,17 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
}, },
{ {
id: 'plugin_platform', id: 'plugin_platform',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.plugin_platform', defaultMessage: 'Platform' })), formatted_name: formatMessage(
supported_project_types: [ 'plugin' ], defineMessage({ id: 'search.filter_type.plugin_platform', defaultMessage: 'Platform' }),
),
supported_project_types: ['plugin'],
display: 'all', display: 'all',
query_param: 'g', query_param: 'g',
supports_negative_filter: true, supports_negative_filter: true,
searchable: false, searchable: false,
options: tags.value.loaders.filter((loader) => ['bungeecord', 'waterfall', 'velocity'].includes(loader.name)).map(loader => { options: tags.value.loaders
.filter((loader) => ['bungeecord', 'waterfall', 'velocity'].includes(loader.name))
.map((loader) => {
return { return {
id: loader.name, id: loader.name,
formatted_name: formatCategory(loader.name), formatted_name: formatCategory(loader.name),
@ -255,13 +316,17 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
}, },
{ {
id: 'shader_loader', id: 'shader_loader',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.shader_loader', defaultMessage: 'Loader' })), formatted_name: formatMessage(
supported_project_types: [ 'shader' ], defineMessage({ id: 'search.filter_type.shader_loader', defaultMessage: 'Loader' }),
),
supported_project_types: ['shader'],
display: 'all', display: 'all',
query_param: 'g', query_param: 'g',
supports_negative_filter: true, supports_negative_filter: true,
searchable: false, searchable: false,
options: tags.value.loaders.filter((loader) => loader.supported_project_types.includes('shader')).map(loader => { options: tags.value.loaders
.filter((loader) => loader.supported_project_types.includes('shader'))
.map((loader) => {
return { return {
id: loader.name, id: loader.name,
formatted_name: formatCategory(loader.name), formatted_name: formatCategory(loader.name),
@ -273,8 +338,10 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
}, },
{ {
id: 'license', id: 'license',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license', defaultMessage: 'License' })), formatted_name: formatMessage(
supported_project_types: [ 'mod', 'modpack', 'resourcepack', 'shader', 'plugin', 'datapack' ], defineMessage({ id: 'search.filter_type.license', defaultMessage: 'License' }),
),
supported_project_types: ['mod', 'modpack', 'resourcepack', 'shader', 'plugin', 'datapack'],
query_param: 'l', query_param: 'l',
supports_negative_filter: true, supports_negative_filter: true,
display: 'all', display: 'all',
@ -282,42 +349,58 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
options: [ options: [
{ {
id: 'open_source', id: 'open_source',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.license.open_source', defaultMessage: 'Open source' })), formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.license.open_source',
defaultMessage: 'Open source',
}),
),
method: 'and', method: 'and',
value: 'open_source:true', value: 'open_source:true',
}, },
] ],
}, },
{ {
id: 'project_id', id: 'project_id',
formatted_name: formatMessage(defineMessage({ id: 'search.filter_type.project_id', defaultMessage: 'Project ID' })), formatted_name: formatMessage(
defineMessage({ id: 'search.filter_type.project_id', defaultMessage: 'Project ID' }),
),
supported_project_types: ALL_PROJECT_TYPES, supported_project_types: ALL_PROJECT_TYPES,
query_param: 'pid', query_param: 'pid',
supports_negative_filter: true, supports_negative_filter: true,
display: 'none', display: 'none',
searchable: false, searchable: false,
options: [], options: [],
allows_custom_options: 'and' allows_custom_options: 'and',
} },
] ]
return filterTypes.filter(filterType => filterType.supported_project_types.some(projectType => projectTypes.value.includes(projectType))) return filterTypes.filter((filterType) =>
filterType.supported_project_types.some((projectType) =>
projectTypes.value.includes(projectType),
),
)
}) })
const facets = computed(() => { const facets = computed(() => {
const validProvidedFilters = providedFilters.value.filter(providedFilter => !overriddenProvidedFilterTypes.value.includes(providedFilter.type)) const validProvidedFilters = providedFilters.value.filter(
const filteredFilters = currentFilters.value.filter((userFilter) => !validProvidedFilters.some(providedFilter => providedFilter.type === userFilter.type)) (providedFilter) => !overriddenProvidedFilterTypes.value.includes(providedFilter.type),
)
const filteredFilters = currentFilters.value.filter(
(userFilter) =>
!validProvidedFilters.some((providedFilter) => providedFilter.type === userFilter.type),
)
const filterValues = [...filteredFilters, ...validProvidedFilters] const filterValues = [...filteredFilters, ...validProvidedFilters]
const andFacets: string[][] = []; const andFacets: string[][] = []
const orFacets: Record<string, string[]> = {}; const orFacets: Record<string, string[]> = {}
for (const filterValue of filterValues) { for (const filterValue of filterValues) {
const type = filters.value.find(type => type.id === filterValue.type) const type = filters.value.find((type) => type.id === filterValue.type)
if (!type) { if (!type) {
console.error(`Filter type ${filterValue.type} not found`) console.error(`Filter type ${filterValue.type} not found`)
continue continue
} }
let option = type?.options.find(option => option.id === filterValue.option) let option = type?.options.find((option) => option.id === filterValue.option)
if (!option && type.allows_custom_options) { if (!option && type.allows_custom_options) {
option = { option = {
id: filterValue.option, id: filterValue.option,
@ -333,15 +416,15 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
if (option.method === 'or' || option.method === 'and') { if (option.method === 'or' || option.method === 'and') {
if (filterValue.negative) { if (filterValue.negative) {
andFacets.push([option.value.replace(':', '!=')]); andFacets.push([option.value.replace(':', '!=')])
} else { } else {
if (option.method === 'or') { if (option.method === 'or') {
if (!orFacets[type.id]) { if (!orFacets[type.id]) {
orFacets[type.id] = [] orFacets[type.id] = []
} }
orFacets[type.id].push(option.value); orFacets[type.id].push(option.value)
} else if (option.method === 'and') { } else if (option.method === 'and') {
andFacets.push([option.value]); andFacets.push([option.value])
} }
} }
} }
@ -353,10 +436,12 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
Add environment facets, separate from the rest because it oddly depends on the combination Add environment facets, separate from the rest because it oddly depends on the combination
of filters selected to determine which facets to add. of filters selected to determine which facets to add.
*/ */
const client = currentFilters.value const client = currentFilters.value.some(
.some((filter) => filter.type === 'environment' && filter.option === 'client') (filter) => filter.type === 'environment' && filter.option === 'client',
const server = currentFilters.value )
.some((filter) => filter.type === 'environment' && filter.option === 'server') const server = currentFilters.value.some(
(filter) => filter.type === 'environment' && filter.option === 'server',
)
andFacets.push(...createEnvironmentFacets(client, server)) andFacets.push(...createEnvironmentFacets(client, server))
const projectType = projectTypes.value.map((projectType) => `project_type:${projectType}`) const projectType = projectTypes.value.map((projectType) => `project_type:${projectType}`)
@ -371,95 +456,105 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
const params = [`limit=${maxResults.value}`, `index=${currentSortType.value.name}`] const params = [`limit=${maxResults.value}`, `index=${currentSortType.value.name}`]
if (query.value.length > 0) { if (query.value.length > 0) {
params.push(`query=${encodeURIComponent(query.value)}`); params.push(`query=${encodeURIComponent(query.value)}`)
} }
params.push(`facets=${encodeURIComponent(JSON.stringify(facets.value))}`); params.push(`facets=${encodeURIComponent(JSON.stringify(facets.value))}`)
const offset = (currentPage.value - 1) * maxResults.value; const offset = (currentPage.value - 1) * maxResults.value
if (currentPage.value !== 1) { if (currentPage.value !== 1) {
params.push(`offset=${offset}`); params.push(`offset=${offset}`)
} }
return `?${params.join('&')}`; return `?${params.join('&')}`
}) })
readQueryParams(); readQueryParams()
function readQueryParams() { function readQueryParams() {
const readParams = new Set<string>(); const readParams = new Set<string>()
// Load legacy params // Load legacy params
loadQueryParam(['l'], (openSource) => { loadQueryParam(['l'], (openSource) => {
if (openSource === 'true' && !currentFilters.value.some(filter => filter.type === 'license' && filter.option === 'open_source')) { if (
openSource === 'true' &&
!currentFilters.value.some(
(filter) => filter.type === 'license' && filter.option === 'open_source',
)
) {
currentFilters.value.push({ currentFilters.value.push({
type: 'license', type: 'license',
option: 'open_source', option: 'open_source',
negative: false, negative: false,
}); })
readParams.add('l'); readParams.add('l')
} }
}); })
loadQueryParam(['nf'], (filter) => { loadQueryParam(['nf'], (filter) => {
const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter) const set = typeof filter === 'string' ? new Set([filter]) : new Set(filter)
typesLoop: for (const type of filters.value) { typesLoop: for (const type of filters.value) {
for (const option of type.options) { for (const option of type.options) {
const value = getOptionValue(option, false); const value = getOptionValue(option, false)
if (set.has(value) && !currentFilters.value.some(filter => filter.type === type.id && filter.option === option.id)) { if (
set.has(value) &&
!currentFilters.value.some(
(filter) => filter.type === type.id && filter.option === option.id,
)
) {
currentFilters.value.push({ currentFilters.value.push({
type: type.id, type: type.id,
option: option.id, option: option.id,
negative: true, negative: true,
}) })
readParams.add(type.query_param); readParams.add(type.query_param)
set.delete(value) set.delete(value)
if (set.size === 0) { if (set.size === 0) {
break typesLoop; break typesLoop
} }
} }
} }
} }
}) })
loadQueryParam(['s'], (sort) => { loadQueryParam(['s'], (sort) => {
currentSortType.value = sortTypes.find(sortType => sortType.name === sort) ?? sortTypes[0] currentSortType.value = sortTypes.find((sortType) => sortType.name === sort) ?? sortTypes[0]
readParams.add('s'); readParams.add('s')
}) })
loadQueryParam(['m'], (count) => { loadQueryParam(['m'], (count) => {
maxResults.value = Number(count) maxResults.value = Number(count)
readParams.add('m'); readParams.add('m')
}) })
loadQueryParam(['o'], (offset) => { loadQueryParam(['o'], (offset) => {
currentPage.value = Math.ceil(Number(offset) / maxResults.value) + 1 currentPage.value = Math.ceil(Number(offset) / maxResults.value) + 1
readParams.add('o'); readParams.add('o')
}) })
loadQueryParam(['page'], (page) => { loadQueryParam(['page'], (page) => {
currentPage.value = Number(page) currentPage.value = Number(page)
readParams.add('page'); readParams.add('page')
}) })
for (const key of Object.keys(route.query).filter(key => !readParams.has(key))) { for (const key of Object.keys(route.query).filter((key) => !readParams.has(key))) {
const type = filters.value.find(type => type.query_param === key) const type = filters.value.find((type) => type.query_param === key)
if (type) { if (type) {
const values = getParamValuesAsArray(route.query[key]) const values = getParamValuesAsArray(route.query[key])
for (const value of values) { for (const value of values) {
const negative = !value.includes(':') && value.includes('!=') const negative = !value.includes(':') && value.includes('!=')
const option = type.options.find(option => (getOptionValue(option, negative)) === value) const option = type.options.find((option) => getOptionValue(option, negative) === value)
if (!option && type.allows_custom_options) { if (!option && type.allows_custom_options) {
currentFilters.value.push({ currentFilters.value.push({
type: type.id, type: type.id,
option: value.replace('!=', ':'), option: value.replace('!=', ':'),
negative: negative negative: negative,
}) })
} else if (option) { } else if (option) {
currentFilters.value.push({ currentFilters.value.push({
type: type.id, type: type.id,
option: option.id, option: option.id,
negative: negative negative: negative,
}) })
} else { } else {
console.error(`Unknown filter option: ${value}`) console.error(`Unknown filter option: ${value}`)
@ -472,17 +567,17 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
} }
function createPageParams(): LocationQueryRaw { function createPageParams(): LocationQueryRaw {
const items: Record<string, string[]> = {}; const items: Record<string, string[]> = {}
if (query.value) { if (query.value) {
items.q = [query.value]; items.q = [query.value]
} }
currentFilters.value.forEach(filterValue => { currentFilters.value.forEach((filterValue) => {
const type = filters.value.find(type => type.id === filterValue.type) const type = filters.value.find((type) => type.id === filterValue.type)
const option = type?.options.find((option) => option.id === filterValue.option) const option = type?.options.find((option) => option.id === filterValue.option)
if (type && option) { if (type && option) {
const value = getOptionValue(option, filterValue.negative); const value = getOptionValue(option, filterValue.negative)
if (items[type.query_param]) { if (items[type.query_param]) {
items[type.query_param].push(value) items[type.query_param].push(value)
} else { } else {
@ -491,36 +586,37 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
} }
}) })
toggledGroups.value.forEach(groupId => { toggledGroups.value.forEach((groupId) => {
const group = filters.value const group = filters.value
.flatMap(filter => filter.toggle_groups) .flatMap((filter) => filter.toggle_groups)
.find(group => group && group.id === groupId) .find((group) => group && group.id === groupId)
if (group && 'query_param' in group && group.query_param) { if (group && 'query_param' in group && group.query_param) {
items[group.query_param] = [String(true)] items[group.query_param] = [String(true)]
} }
}) })
if (currentSortType.value.name !== "relevance") { if (currentSortType.value.name !== 'relevance') {
items.s = [currentSortType.value.name]; items.s = [currentSortType.value.name]
} }
if (maxResults.value !== 20) { if (maxResults.value !== 20) {
items.m = [String(maxResults.value)]; items.m = [String(maxResults.value)]
} }
if (currentPage.value > 1) { if (currentPage.value > 1) {
items.page = [String(currentPage.value)]; items.page = [String(currentPage.value)]
} }
return items
return items;
} }
function createPageParamsString(pageParams: Record<string, string | string[] | boolean | number>) { function createPageParamsString(
let url = ``; pageParams: Record<string, string | string[] | boolean | number>,
) {
let url = ``
Object.entries(pageParams).forEach(([key, value]) => { Object.entries(pageParams).forEach(([key, value]) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
value.forEach(value => { value.forEach((value) => {
url = addQueryParam(url, key, value) url = addQueryParam(url, key, value)
}) })
} else { } else {
@ -528,14 +624,17 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
} }
}) })
return url; return url
} }
function loadQueryParam(params: string[], provider: ((param: LocationQueryValue | LocationQueryValue[]) => void)) { function loadQueryParam(
params: string[],
provider: (param: LocationQueryValue | LocationQueryValue[]) => void,
) {
for (const param of params) { for (const param of params) {
if (param in route.query) { if (param in route.query) {
provider(route.query[param]); provider(route.query[param])
return; return
} }
} }
} }
@ -565,21 +664,21 @@ export function useSearch(projectTypes: Ref<ProjectType[]>, tags: Ref<Tags>, pro
} }
export function createEnvironmentFacets(client: boolean, server: boolean): string[][] { export function createEnvironmentFacets(client: boolean, server: boolean): string[][] {
const facets: string[][] = []; const facets: string[][] = []
if (client && server) { if (client && server) {
facets.push(["client_side:required"], ["server_side:required"]) facets.push(['client_side:required'], ['server_side:required'])
} else if (client) { } else if (client) {
facets.push( facets.push(
["client_side:optional", "client_side:required"], ['client_side:optional', 'client_side:required'],
["server_side:optional", "server_side:unsupported"] ['server_side:optional', 'server_side:unsupported'],
); )
} else if (server) { } else if (server) {
facets.push( facets.push(
["client_side:optional", "client_side:unsupported"], ['client_side:optional', 'client_side:unsupported'],
["server_side:optional", "server_side:required"] ['server_side:optional', 'server_side:required'],
); )
} }
return facets; return facets
} }
function getOptionValue(option: FilterOption, negative?: boolean): string { function getOptionValue(option: FilterOption, negative?: boolean): string {
@ -603,6 +702,6 @@ function getParamValuesAsArray(x: LocationQueryValue | LocationQueryValue[]): st
} else if (typeof x === 'string') { } else if (typeof x === 'string') {
return [x] return [x]
} else { } else {
return x.filter(x => x !== null) return x.filter((x) => x !== null)
} }
} }