mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
feat: add/remove favourites group
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
MinusIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -18,6 +19,7 @@ import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-gr
|
||||
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
|
||||
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
|
||||
import { getLibraryInstanceSelectionKey, provideLibrary } from '@/components/ui/library/use-library'
|
||||
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
@@ -42,10 +44,11 @@ const hasActiveFilters = computed(() =>
|
||||
)
|
||||
|
||||
const visibleInstanceGroups = computed(() =>
|
||||
instanceGroups.value.filter(
|
||||
(instanceGroup) =>
|
||||
instanceGroup.instances.length > 0 ||
|
||||
(!hasActiveFilters.value && instanceGroup.key !== 'None'),
|
||||
instanceGroups.value.filter((instanceGroup) =>
|
||||
instanceGroup.id === FAVORITES_GROUP_ID
|
||||
? instanceGroup.instances.length > 0
|
||||
: instanceGroup.instances.length > 0 ||
|
||||
(!hasActiveFilters.value && instanceGroup.key !== 'None'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,22 +110,19 @@ function setConfirmDeleteModal(component: unknown) {
|
||||
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedLibraryInstances,
|
||||
(selectedInstances) => {
|
||||
if (selectedInstances.size === 0) {
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
if (selectedInstances.size === 0) {
|
||||
anchorInstance.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
anchorInstance.value &&
|
||||
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
|
||||
) {
|
||||
anchorInstance.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
if (
|
||||
anchorInstance.value &&
|
||||
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
|
||||
) {
|
||||
anchorInstance.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -153,6 +153,11 @@ watch(
|
||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||
<template #play> <PlayIcon /> Play </template>
|
||||
<template #stop> <StopCircleIcon /> Stop </template>
|
||||
<template #add_to_favorites> <StarIcon /> Add to favorites </template>
|
||||
<template #remove_from_favorites>
|
||||
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" /> Remove
|
||||
from favorites
|
||||
</template>
|
||||
<template #add_content> <PlusIcon /> Add content </template>
|
||||
<template #edit> <EyeIcon /> View instance </template>
|
||||
<template #duplicate> <ClipboardCopyIcon /> Duplicate instance</template>
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
InstanceGroup as InstanceGroupType,
|
||||
} from '@/components/ui/library/use-library'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import { FAVORITES_GROUP_ID } from '@/helpers/instance-groups'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -69,7 +70,10 @@ const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
|
||||
const deletingGroup = ref(false)
|
||||
const groupName = ref(props.instanceGroup.key)
|
||||
const isUngrouped = computed(() => props.instanceGroup.id === 'group:none')
|
||||
const isCustomGroup = computed(() => displayState.value.group === 'Group' && !isUngrouped.value)
|
||||
const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID)
|
||||
const isCustomGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const groupContextMenuOpen = ref(false)
|
||||
const isGroupToggleBlocked = computed(
|
||||
() => groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
||||
@@ -95,6 +99,10 @@ const messages = defineMessages({
|
||||
id: 'app.library.group.ungrouped',
|
||||
defaultMessage: 'Ungrouped',
|
||||
},
|
||||
favorites: {
|
||||
id: 'app.library.group.favorites',
|
||||
defaultMessage: 'Favorites',
|
||||
},
|
||||
deleteGroup: {
|
||||
id: 'app.library.group.delete',
|
||||
defaultMessage: 'Delete group',
|
||||
@@ -165,7 +173,7 @@ async function removeGroup() {
|
||||
}
|
||||
|
||||
function requestGroupDeletion() {
|
||||
if (isUngrouped.value) return
|
||||
if (isUngrouped.value || isFavorites.value) return
|
||||
|
||||
if (props.instanceGroup.instances.length > 0) {
|
||||
confirmDeleteGroupModal.value?.show()
|
||||
@@ -181,15 +189,17 @@ function openGroupContextMenu(event: MouseEvent) {
|
||||
groupOptions.value?.showMenu(
|
||||
event,
|
||||
props.instanceGroup,
|
||||
isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
isFavorites.value
|
||||
? [{ name: 'new_instance' }]
|
||||
: isCustomGroup.value
|
||||
? [
|
||||
{ name: 'new_instance' },
|
||||
{ name: 'add_instances' },
|
||||
{ name: 'edit_name' },
|
||||
{ type: 'divider' },
|
||||
{ name: 'delete_group', color: 'danger' },
|
||||
]
|
||||
: [{ name: 'new_instance' }],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -351,7 +361,7 @@ watch(
|
||||
/>
|
||||
</button>
|
||||
<InlineEditableText
|
||||
v-if="!isUngrouped"
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
ref="groupNameInput"
|
||||
v-model="groupName"
|
||||
activation-mode="manual"
|
||||
@@ -364,11 +374,17 @@ watch(
|
||||
:validate="validateGroupName"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
v-else-if="isUngrouped"
|
||||
class="text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.ungrouped) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1.5 text-base font-semibold text-primary select-none group-hover/open-target:text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.favorites) }}
|
||||
</span>
|
||||
<TagItem
|
||||
v-if="instanceGroup.instances.length"
|
||||
class="shrink-0 border-surface-3 bg-surface-2"
|
||||
@@ -378,7 +394,7 @@ watch(
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<GroupActionButtons
|
||||
v-if="!isUngrouped"
|
||||
v-if="!isUngrouped && !isFavorites"
|
||||
:deleting="deletingGroup"
|
||||
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
|
||||
:on-delete-group="requestGroupDeletion"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import { useEventListener, useMagicKeys } from '@vueuse/core'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -47,6 +47,7 @@ const instanceCardElement = ref<InstanceType<typeof InstanceCardView> | null>(nu
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const currentEvent = ref<ProcessEvent | null>(null)
|
||||
const isPrimaryPointerDown = ref(false)
|
||||
const modLoading = computed(
|
||||
() =>
|
||||
loading.value ||
|
||||
@@ -85,6 +86,20 @@ const toggleSelection = (event?: MouseEvent) => {
|
||||
emit('toggle-selection', event?.shiftKey ?? false)
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
isPrimaryPointerDown.value =
|
||||
event.button === 0 &&
|
||||
!(event.target instanceof Element && event.target.closest('.selection-button'))
|
||||
}
|
||||
|
||||
const resetPrimaryPointer = () => {
|
||||
isPrimaryPointerDown.value = false
|
||||
}
|
||||
|
||||
useEventListener(window, 'pointerup', resetPrimaryPointer)
|
||||
useEventListener(window, 'pointercancel', resetPrimaryPointer)
|
||||
useEventListener(window, 'blur', resetPrimaryPointer)
|
||||
|
||||
const activateCard = (event: MouseEvent) => {
|
||||
if (isLibraryInstanceSelectionActive.value || event.shiftKey) {
|
||||
toggleSelection(event)
|
||||
@@ -207,8 +222,7 @@ onUnmounted(() => unlisten())
|
||||
:class="{
|
||||
'!scale-100': isDragging,
|
||||
'opacity-50': isPartOfActiveDrag,
|
||||
'[&:active:not(:has(.selection-button:active))]:scale-[0.95]':
|
||||
!isLibraryInstanceSelectionActive,
|
||||
'scale-[0.95]': isPrimaryPointerDown && !isLibraryInstanceSelectionActive,
|
||||
}"
|
||||
:instance="instance"
|
||||
:selected="selected"
|
||||
@@ -226,6 +240,7 @@ onUnmounted(() => unlisten())
|
||||
@click="activateCard"
|
||||
@keydown="handleCardKeydown"
|
||||
@mouseenter="checkProcess"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<template #leading="{ instanceType }">
|
||||
<div class="relative flex size-10 shrink-0 items-center justify-center">
|
||||
@@ -237,9 +252,7 @@ onUnmounted(() => unlisten())
|
||||
!instance.quarantined && !isLibraryInstanceSelectionActive,
|
||||
}"
|
||||
>
|
||||
<InstanceFileIcon
|
||||
class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current"
|
||||
/>
|
||||
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
|
||||
<span class="h-3.5 text-sm font-extrabold leading-[13px]">
|
||||
{{ instanceType }}
|
||||
</span>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { edit, remove } from '@/helpers/instance'
|
||||
import {
|
||||
create_group as createInstanceGroup,
|
||||
delete_group as deleteInstanceGroup,
|
||||
FAVORITES_GROUP_ID,
|
||||
type InstanceGroupDefinition,
|
||||
list_groups as listInstanceGroups,
|
||||
rename_group as renameInstanceGroup,
|
||||
@@ -152,6 +153,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
() =>
|
||||
new Set(
|
||||
libraryGroups.value
|
||||
.filter((group) => group.id !== FAVORITES_GROUP_ID)
|
||||
.map((group) => group.name)
|
||||
.map((group) => group.trim())
|
||||
.filter((group) => group && group.toLowerCase() !== 'none'),
|
||||
@@ -345,6 +347,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
|
||||
if (displayState.value.group === 'Group') {
|
||||
groups.sort((a, b) => {
|
||||
if (a.id === b.id) return 0
|
||||
if (a.id === FAVORITES_GROUP_ID) return -1
|
||||
if (b.id === FAVORITES_GROUP_ID) return 1
|
||||
if (a.key === 'None') return 1
|
||||
if (b.key === 'None') return -1
|
||||
return a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
|
||||
@@ -894,12 +899,22 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
instanceGroupId: string,
|
||||
) => {
|
||||
currentContextGroupId.value =
|
||||
displayState.value.group === 'Group' && instanceGroupId !== 'group:none'
|
||||
displayState.value.group === 'Group' &&
|
||||
instanceGroupId !== 'group:none' &&
|
||||
instanceGroupId !== FAVORITES_GROUP_ID
|
||||
? instanceGroupId
|
||||
: null
|
||||
|
||||
const baseOptions = [
|
||||
...(item.instance.quarantined ? [] : [{ name: 'add_content' }, { type: 'divider' }]),
|
||||
{
|
||||
name: item.instance.group_ids.includes(FAVORITES_GROUP_ID)
|
||||
? 'remove_from_favorites'
|
||||
: 'add_to_favorites',
|
||||
},
|
||||
{ type: 'divider' },
|
||||
...(!item.instance.quarantined && !item.instance.link
|
||||
? [{ name: 'add_content' }, { type: 'divider' }]
|
||||
: []),
|
||||
{ name: 'edit' },
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
@@ -933,6 +948,18 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
case 'add_content':
|
||||
await item.addContent()
|
||||
break
|
||||
case 'add_to_favorites':
|
||||
await edit(item.instance.id, {
|
||||
group_ids: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
|
||||
}).catch((error) => handleError(toError(error)))
|
||||
break
|
||||
case 'remove_from_favorites':
|
||||
await edit(item.instance.id, {
|
||||
group_ids: item.instance.group_ids.filter(
|
||||
(instanceGroupId) => instanceGroupId !== FAVORITES_GROUP_ID,
|
||||
),
|
||||
}).catch((error) => handleError(toError(error)))
|
||||
break
|
||||
case 'edit':
|
||||
await item.seeInstance()
|
||||
break
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export const FAVORITES_GROUP_ID = 'group:favorites'
|
||||
|
||||
export type InstanceGroupDefinition = {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
INSERT OR IGNORE INTO instance_groups (id, name)
|
||||
VALUES ('group:favorites', 'Favorites');
|
||||
|
||||
UPDATE instance_groups
|
||||
SET name = 'Favorites'
|
||||
WHERE id = 'group:favorites';
|
||||
@@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_GROUP_NAME_LENGTH: usize = 128;
|
||||
pub const FAVORITES_GROUP_ID: &str = "group:favorites";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct InstanceGroup {
|
||||
@@ -65,6 +66,13 @@ pub async fn rename_group(
|
||||
id: String,
|
||||
new_name: String,
|
||||
) -> crate::Result<InstanceGroup> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be renamed".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_name = validate_group_name(&new_name)?;
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
@@ -112,6 +120,13 @@ pub async fn rename_group(
|
||||
}
|
||||
|
||||
pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
if id == FAVORITES_GROUP_ID {
|
||||
return Err(crate::ErrorKind::InputError(
|
||||
"Favorites cannot be deleted".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut tx = state.pool.begin().await?;
|
||||
let instance_ids = sqlx::query_scalar::<_, String>(
|
||||
@@ -153,7 +168,9 @@ pub async fn delete_group(id: String) -> crate::Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_group_name;
|
||||
use super::{
|
||||
FAVORITES_GROUP_ID, MAX_GROUP_NAME_LENGTH, validate_group_name,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_trims_valid_names() {
|
||||
@@ -170,8 +187,21 @@ mod tests {
|
||||
assert!(validate_group_name("NoNe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_allows_favorites() {
|
||||
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_name_validation_rejects_long_names() {
|
||||
assert!(validate_group_name(&"a".repeat(33)).is_err());
|
||||
assert!(
|
||||
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorites_group_id_is_stable() {
|
||||
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user