feat: add/remove favourites group

This commit is contained in:
tdgao
2026-07-30 15:32:24 -07:00
parent 88b694976b
commit 25ca13b74c
7 changed files with 142 additions and 43 deletions
@@ -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';
+32 -2
View File
@@ -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");
}
}