Compare commits

...
7 Commits
Author SHA1 Message Date
tdgao 188ab15f1a feat: welcome screen styles match figma 2026-07-30 16:22:02 -07:00
tdgao 6e06623e0f feat: add group by instance type and change group sorting 2026-07-30 15:51:55 -07:00
tdgao 8f8e45a29d feat: improve the "New group" jank 2026-07-30 15:46:12 -07:00
tdgao c063ca1ed5 remove manual sort 2026-07-30 15:37:09 -07:00
tdgao 25ca13b74c feat: add/remove favourites group 2026-07-30 15:32:24 -07:00
tdgao 88b694976b feat: add group action buttons 2026-07-30 14:49:40 -07:00
tdgao 98a17ae307 feat: better context menu options 2026-07-30 14:33:28 -07:00
17 changed files with 842 additions and 110 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 B

@@ -0,0 +1,39 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20.3429" height="7.68571" viewBox="0 0 20.3429 7.68571" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 50789">
<g id="Ellipse 1140" filter="url(#filter0_dii_0_10)">
<circle cx="3.84286" cy="3.4619" r="2.5" fill="url(#paint0_linear_0_10)" fill-opacity="0.9" shape-rendering="crispEdges"/>
<circle cx="3.84286" cy="3.4619" r="2.6" stroke="black" stroke-opacity="0.1" stroke-width="0.2" shape-rendering="crispEdges"/>
</g>
<circle id="Ellipse 1141" cx="10.8429" cy="3.4619" r="2.5" fill="black" fill-opacity="0.37"/>
<circle id="Ellipse 1142" cx="17.8429" cy="3.4619" r="2.5" fill="black" fill-opacity="0.37"/>
</g>
<defs>
<filter id="filter0_dii_0_10" x="4.47035e-08" y="1.49012e-08" width="7.68571" height="7.68571" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.380952"/>
<feGaussianBlur stdDeviation="0.571429"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.32 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_10"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_10" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.4"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.3 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_0_10"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.4"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0"/>
<feBlend mode="normal" in2="effect2_innerShadow_0_10" result="effect3_innerShadow_0_10"/>
</filter>
<linearGradient id="paint0_linear_0_10" x1="3.84286" y1="0.961905" x2="3.84286" y2="5.9619" gradientUnits="userSpaceOnUse">
<stop stop-color="#1BD9A9"/>
<stop offset="0.45" stop-color="#1BD96A"/>
<stop offset="1" stop-color="#1BD951"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,32 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="74.0007" height="74" viewBox="0 0 74.0007 74" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group 49552">
<g id="Vector">
<path fill-rule="evenodd" clip-rule="evenodd" d="M72.7343 46.6308C74.0593 41.6962 74.3484 36.5409 73.5835 31.4891C72.8185 26.4374 71.016 21.5985 68.2894 17.2769C65.5627 12.9553 61.9709 9.2445 57.7399 6.37785C53.5089 3.5112 48.7302 1.55077 43.7044 0.619851C38.6786 -0.311064 33.5144 -0.192312 28.5368 0.968637C23.5591 2.12959 18.8757 4.30761 14.781 7.36571C10.6863 10.4238 7.26903 14.2958 4.74408 18.7382C2.21913 23.1805 0.641152 28.0971 0.109401 33.1787H6.37602C7.23467 26.3912 10.338 20.0852 15.193 15.2629C20.048 10.4405 26.376 7.37843 33.1716 6.56315C39.9671 5.74787 46.8403 7.22619 52.699 10.7632C58.5577 14.3002 63.0657 19.693 65.5068 26.0846L59.4177 27.7133C58.0562 24.4413 55.9951 21.5067 53.379 19.1153C50.7629 16.7238 47.655 14.9334 44.2734 13.8697L43.1506 20.2016C46.469 21.4193 49.3529 23.5909 51.4394 26.4432C53.5258 29.2955 54.7216 32.701 54.8763 36.2311C55.0309 39.7612 54.1375 43.2581 52.3084 46.2818C50.4793 49.3055 47.7964 51.7208 44.5972 53.2237L46.263 59.4356C51.1999 57.392 55.325 53.7784 57.9996 49.1543C60.6741 44.5302 61.7489 39.1537 61.0575 33.8573L67.1257 32.2391C67.7664 36.2855 67.5781 40.4199 66.5721 44.3914L72.7343 46.6308Z" fill="url(#paint0_linear_0_14)"/>
<path d="M28.5938 1.21191C33.5378 0.058849 38.6674 -0.0593858 43.6592 0.865234C48.651 1.78989 53.3972 3.73771 57.5996 6.58496C61.8021 9.43226 65.3699 13.1178 68.0782 17.4102C70.7863 21.7025 72.5762 26.5088 73.336 31.5264C74.0821 36.4539 73.8157 41.4799 72.5596 46.3008L66.8672 44.2324C67.8334 40.2956 68.0063 36.2054 67.3721 32.2002L67.3291 31.9258L67.0615 31.9971L60.9932 33.6162L60.7813 33.6719L60.8096 33.8896C61.4939 39.1315 60.4302 44.4528 57.7832 49.0293C55.1838 53.5234 51.1993 57.0519 46.4297 59.0928L44.8916 53.3564C48.0518 51.827 50.7041 49.4171 52.5225 46.4111C54.377 43.3453 55.2828 39.7991 55.126 36.2197C54.9691 32.6407 53.757 29.1878 51.6416 26.2959C49.5672 23.46 46.714 21.2894 43.4317 20.042L44.4688 14.1973C47.7105 15.2594 50.6909 16.9971 53.21 19.2998C55.7992 21.6667 57.8391 24.5713 59.1865 27.8096L59.2705 28.0117L59.4824 27.9551L65.5713 26.3262L65.8389 26.2549L65.7403 25.9951C63.2793 19.5515 58.7345 14.1146 52.8282 10.5488C46.9218 6.9831 39.9924 5.49255 33.1416 6.31445C26.2908 7.13639 19.9111 10.2243 15.0166 15.0859C10.1745 19.8957 7.06234 26.1694 6.15823 32.9287H0.390651C0.946388 27.9796 2.49885 23.1931 4.96096 18.8613C7.46884 14.449 10.8637 10.6038 14.9307 7.56641C18.9977 4.52894 23.6497 2.36503 28.5938 1.21191Z" stroke="url(#paint1_linear_0_14)" stroke-opacity="0.4" stroke-width="0.5"/>
</g>
<g id="Vector_2">
<path d="M46.5453 72.7337C41.2782 74.1472 35.7639 74.3806 30.3962 73.4174C25.0284 72.4542 19.9396 70.318 15.4933 67.1615C11.047 64.005 7.35277 59.9061 4.67454 55.1574C1.99632 50.4086 0.400136 45.1274 0 39.6907H6.26662C6.42964 41.4382 6.7406 43.1688 7.19617 44.8638C7.67146 46.6415 8.3059 48.3729 9.09182 50.0369L14.6691 46.6908C13.0374 42.9123 12.3917 38.7817 12.7928 34.6857C13.1938 30.5898 14.6283 26.6626 16.9619 23.272C19.2956 19.8815 22.452 17.1385 26.1355 15.3C29.8191 13.4615 33.9092 12.5877 38.0227 12.7604L36.8895 19.0923C34.1456 19.1048 31.4411 19.7458 28.9837 20.966C26.5263 22.1863 24.3815 23.9533 22.7138 26.1314C21.0462 28.3095 19.9001 30.8407 19.3638 33.5306C18.8274 36.2205 18.915 38.9974 19.6197 41.6482C19.7816 42.2381 19.954 42.8227 20.1785 43.3447L27.312 39.0695L25.1761 33.3901L31.9128 26.4735L40.4301 24.6412L42.8793 27.6845L38.9523 31.657L35.5317 32.7323L33.0825 35.2484L34.2836 38.5788C34.2836 38.5788 36.7067 41.1575 36.7119 41.1575L40.1429 40.2492L42.5817 37.5713L47.9083 35.88L49.475 39.4506L44.0021 46.1792L34.7902 49.0868L30.6542 44.4879L23.4528 48.8154C25.3066 50.9244 27.6287 52.5701 30.2332 53.6207C32.8376 54.6713 35.652 55.0976 38.4509 54.8654L40.1168 61.093C35.96 61.6355 31.7334 61.0954 27.8468 59.5251C23.9601 57.9548 20.5452 55.4075 17.933 52.1301L12.3766 55.4605C15.55 59.6249 19.736 62.909 24.5369 65.0007C29.3377 67.0923 34.5939 67.922 39.806 67.411C45.018 66.8999 50.0128 65.065 54.3155 62.0808C58.6183 59.0965 62.0861 55.0619 64.3895 50.3605L70.5517 52.5999C68.2702 57.509 64.9374 61.8569 60.789 65.3361C56.6406 68.8153 51.778 71.3407 46.5453 72.7337Z" fill="url(#paint2_linear_0_14)"/>
<path d="M37.7246 13.0026L36.6797 18.8464C33.9693 18.8898 31.3013 19.5356 28.8721 20.7419C26.3807 21.979 24.2064 23.771 22.5156 25.9792C20.8248 28.1876 19.662 30.7547 19.1182 33.4821C18.5744 36.2093 18.6634 39.025 19.3779 41.7126L19.3789 41.7145C19.541 42.3051 19.7173 42.9048 19.9492 43.444L20.0615 43.7057L20.3066 43.5593L27.4404 39.2839L27.6191 39.1764L27.5459 38.9811L25.4658 33.4499L32.0391 26.7009L40.332 24.9177L42.5439 27.6667L38.8174 31.4372L35.457 32.4938L35.3965 32.5124L35.3525 32.5583L32.9033 35.0739L32.7939 35.1862L32.8477 35.3337L34.0488 38.6637L34.0664 38.7126L34.1016 38.7497V38.7507L34.1035 38.7516C34.1047 38.7529 34.1061 38.7551 34.1084 38.7575C34.113 38.7624 34.1201 38.7696 34.1289 38.779C34.1467 38.7979 34.173 38.8258 34.2061 38.861C34.2723 38.9315 34.367 39.0323 34.4805 39.153C34.7077 39.3948 35.0103 39.7174 35.3135 40.0397C35.6167 40.3621 35.9204 40.6846 36.1484 40.9264C36.2623 41.0473 36.3579 41.1478 36.4248 41.2184C36.4582 41.2537 36.4845 41.2822 36.5029 41.3014C36.512 41.3109 36.5199 41.3183 36.5254 41.3239C36.5279 41.3265 36.5305 41.33 36.5332 41.3327C36.5343 41.3338 36.5369 41.3366 36.54 41.3395C36.5412 41.3406 36.5462 41.3446 36.5518 41.3493C36.5552 41.3521 36.5642 41.3596 36.5703 41.3639C36.5845 41.3729 36.6539 41.4011 36.7119 41.4079H36.7441L36.7764 41.3991L40.207 40.4909L40.2783 40.4723L40.3281 40.4177L42.7217 37.7878L47.7695 36.1852L49.1846 39.4108L43.8545 45.9636L34.8672 48.7995L30.8398 44.321L30.7021 44.1677L30.5254 44.2741L23.3242 48.6012L23.0674 48.7555L23.2646 48.9802C25.1442 51.1184 27.499 52.787 30.1396 53.8522C32.7153 54.8912 35.4932 55.3261 38.2617 55.1286L39.7998 60.8776C35.7775 61.354 31.6985 60.812 27.9404 59.2936C24.0938 57.7395 20.7143 55.2179 18.1289 51.9743L17.9922 51.8034L17.8047 51.9157L12.248 55.2458L12.0088 55.3893L12.1777 55.612C15.3769 59.8102 19.5967 63.1215 24.4365 65.2302C29.2764 67.3388 34.5757 68.175 39.8301 67.6598C45.0845 67.1446 50.1202 65.2944 54.458 62.2858C58.734 59.3201 62.1908 55.3249 64.5127 50.6716L70.2061 52.7399C67.9437 57.5147 64.6786 61.747 60.6279 65.1442C56.5078 68.5997 51.6786 71.1083 46.4814 72.4919H46.4805C41.249 73.8958 35.7719 74.1283 30.4404 73.1716C25.1089 72.2148 20.054 70.0929 15.6377 66.9577C11.2216 63.8226 7.55266 59.7513 4.89258 55.0348C2.27662 50.3965 0.702069 45.2462 0.272461 39.9411H6.04102C6.18839 41.4053 6.43742 42.8572 6.78906 44.2858L6.95508 44.9284C7.43423 46.7205 8.07394 48.4658 8.86621 50.1432L8.9834 50.3932L9.2207 50.2516L14.7979 46.905L14.9854 46.7927L14.8984 46.5915C13.2835 42.8519 12.6451 38.7634 13.042 34.7096C13.4389 30.656 14.8584 26.7693 17.168 23.4137C19.4776 20.0582 22.6015 17.3437 26.2471 15.5241C29.8069 13.7473 33.7504 12.8833 37.7246 13.0026Z" stroke="url(#paint3_linear_0_14)" stroke-opacity="0.4" stroke-width="0.5"/>
</g>
</g>
<defs>
<linearGradient id="paint0_linear_0_14" x1="27.7452" y1="-15.7583" x2="40.9431" y2="62.6745" gradientUnits="userSpaceOnUse">
<stop stop-color="#1BD9AA"/>
<stop offset="0.450385" stop-color="#1BD96A"/>
<stop offset="1" stop-color="#1BD951"/>
</linearGradient>
<linearGradient id="paint1_linear_0_14" x1="37.055" y1="3.26749e-09" x2="37.055" y2="59.4356" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
</linearGradient>
<linearGradient id="paint2_linear_0_14" x1="26.3868" y1="-3.50338" x2="41.0049" y2="76.972" gradientUnits="userSpaceOnUse">
<stop stop-color="#1BD9AA"/>
<stop offset="0.450385" stop-color="#1BD96A"/>
<stop offset="1" stop-color="#1BD951"/>
</linearGradient>
<linearGradient id="paint3_linear_0_14" x1="35.2758" y1="12.7389" x2="35.2758" y2="74" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { DownloadIcon, PlusIcon } from '@modrinth/assets'
import { ImportIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { inject, onMounted, onUnmounted, ref } from 'vue'
import appIcon from '../../../../app/icons/128x128.png'
import frog from '../../assets/welcome/frog.png'
import iconLights from '../../assets/welcome/icon-lights.svg?url'
import iconLogo from '../../assets/welcome/icon-logo.svg?url'
import iconTexture from '../../assets/welcome/icon-texture.png'
const showCreationModal = inject<() => void>('showCreationModal')
const showImportModal = inject<() => void>('showImportModal')
@@ -52,21 +55,39 @@ onUnmounted(() => {
</script>
<template>
<div class="grid min-h-full grid-rows-[minmax(30.8125rem,1fr)_auto] px-6 pb-6 pt-16">
<div class="relative flex items-center justify-center">
<div class="grid min-h-full grid-rows-[30.8125rem_1fr_auto] px-6 pb-6 pt-16">
<div class="relative flex items-end justify-center">
<div
class="pointer-events-none absolute left-1/2 top-0 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl bg-[radial-gradient(circle,var(--color-surface-5)_0.5px,transparent_0.75px)] opacity-60 [background-size:0.5625rem_0.5625rem] [mask-image:radial-gradient(ellipse_at_center,black_10%,transparent_68%)] [@media(max-height:700px)]:h-[23rem]"
class="dot-pattern pointer-events-none absolute left-1/2 top-0 h-[29.875rem] w-[min(25.9375rem,80vw)] -translate-x-1/2 rounded-2xl [@media(max-height:700px)]:h-[23rem]"
aria-hidden="true"
/>
<div class="relative flex flex-col items-center gap-6">
<div
class="flex h-[6.25rem] w-[6.25rem] items-center justify-center overflow-hidden rounded-3xl bg-[linear-gradient(180deg,rgb(37_114_79_/_50%)_0%,rgb(15_64_36_/_50%)_100%),linear-gradient(-14deg,rgb(0_0_0_/_37%)_8%,transparent_86%),black] shadow-[0_0_0_1.5px_#07180d,0_1.5rem_3rem_rgb(0_0_0_/_3%),0_0.625rem_1.125rem_rgb(0_0_0_/_3%),0_0.3125rem_0.5rem_rgb(0_0_0_/_4%),0_0.125rem_0.25rem_rgb(0_0_0_/_4%),0_0.625rem_13.75rem_rgb(27_217_106_/_25%)]"
class="welcome-icon relative h-[6.25rem] w-[6.25rem] overflow-hidden rounded-3xl border-[1.5px] border-solid bg-surface-1"
style="border-color: color-mix(in srgb, var(--color-text-primary) 15%, transparent)"
>
<img :src="appIcon" alt="" class="h-[6rem] w-[6rem]" />
<div
class="absolute inset-0 bg-[linear-gradient(180deg,rgb(37_114_79)_0%,rgb(15_64_36)_100%)] opacity-50"
/>
<div
class="absolute inset-0 bg-[linear-gradient(-14deg,rgb(0_0_0_/_37%)_8%,transparent_86%)]"
/>
<img
:src="iconTexture"
alt=""
class="absolute left-1/2 top-1/2 h-[12.70625rem] w-[19.96875rem] max-w-none -translate-x-1/2 -translate-y-1/2 object-cover opacity-40 mix-blend-luminosity"
/>
<img :src="iconLogo" alt="" class="absolute inset-[13%] h-[74%] w-[74%]" />
<img
:src="iconLights"
alt=""
class="absolute left-[0.4775rem] top-[0.4775rem] h-[0.4804rem] w-[1.2714rem]"
/>
</div>
<div class="flex flex-col items-center gap-2">
<h1 class="m-0 flex items-center gap-2 text-2xl font-semibold leading-8 text-contrast">
Welcome to Modrinth
<img :src="frog" alt="" class="h-8 w-8 [image-rendering:pixelated]" />
</h1>
<p class="m-0 text-center text-base leading-6 text-primary">
Minecraft was never meant to fit in one jar.
@@ -90,14 +111,44 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center gap-4 text-sm leading-5 text-secondary">
<div class="flex flex-col items-center justify-end gap-4 text-sm leading-5 text-secondary">
<span class="whitespace-nowrap">Escaping another launcher?</span>
<ButtonStyled>
<button class="!shadow-none" :disabled="offline" @click="showImportModal?.()">
<DownloadIcon />
<button
class="!h-10 !gap-2 !rounded-[0.875rem] !px-4 !py-2.5 !font-medium"
:disabled="offline"
@click="showImportModal?.()"
>
<ImportIcon />
Import from launcher
</button>
</ButtonStyled>
</div>
</div>
</template>
<style scoped>
.dot-pattern {
background-image: radial-gradient(
circle,
color-mix(in srgb, var(--color-text-primary) 25%, transparent) 0.5px,
transparent 0.75px
);
background-size: 0.5625rem 0.5625rem;
opacity: 0.8;
-webkit-mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
mask-image: radial-gradient(ellipse at center, black 10%, transparent 68%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
}
.welcome-icon {
box-shadow:
0 0 0 1.5px #07180d,
0 1.5rem 3rem rgb(0 0 0 / 3%),
0 0.625rem 1.125rem rgb(0 0 0 / 3%),
0 0.3125rem 0.5rem rgb(0 0 0 / 4%),
0 0.125rem 0.25rem rgb(0 0 0 / 4%),
0 0.625rem 13.75rem color-mix(in srgb, var(--color-brand) 32%, transparent);
}
</style>
@@ -21,10 +21,10 @@
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled type="transparent">
<ButtonStyled v-if="displayState.group === 'Group'" type="transparent">
<button type="button" :disabled="busy" @click="createGroupFromSelection">
<PlusIcon />
<span class="bar-label">{{ formatMessage(messages.createGroup) }}</span>
<SquarePlusIcon />
<span class="bar-label">{{ formatMessage(messages.newGroup) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-if="selectedGroupedInstances.length > 0" type="transparent">
@@ -55,7 +55,7 @@
</template>
<script setup lang="ts">
import { MinusIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import { MinusIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
@@ -80,6 +80,7 @@ const {
setSelectedLibraryInstances,
creatingGroup,
createDefaultGroup,
displayState,
} = useLibrary()
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
@@ -111,9 +112,9 @@ const messages = defineMessages({
id: 'app.library.selection.deleting',
defaultMessage: 'Deleting selected instances',
},
createGroup: {
id: 'app.library.selection.create-group',
defaultMessage: 'Create group',
newGroup: {
id: 'app.library.selection.new-group',
defaultMessage: 'New group',
},
removeFromGroup: {
id: 'app.library.selection.remove-from-group',
@@ -124,7 +125,9 @@ const messages = defineMessages({
async function createGroupFromSelection() {
if (busy.value) return
await createDefaultGroup(selectedInstanceIds.value)
const instanceIds = selectedInstanceIds.value
clearLibraryInstanceSelection()
await createDefaultGroup(instanceIds)
}
async function removeSelectedInstancesFromGroups() {
@@ -0,0 +1,153 @@
<template>
<NewModal
ref="modal"
no-padding
scrollable
actions-divider
max-width="560px"
width="560px"
:on-hide="closeGroupInstancesModal"
>
<template #title>
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName: groupInstancesModalGroup?.name ?? '',
})
}}
</span>
</template>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
v-model="groupInstancesSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full"
/>
</div>
<div
v-if="groupInstances.length === 0"
class="flex items-center justify-center py-12 text-secondary"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in groupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<Avatar
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
:tint-by="instance.id"
:alt="instance.name"
size="2rem"
rounded="md"
/>
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
</div>
<ButtonStyled :type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'standard'">
<button type="button" @click="toggleGroupInstance(instance.id)">
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</button>
</ButtonStyled>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="savingGroupInstances" @click="save">
<SpinnerIcon v-if="savingGroupInstances" class="animate-spin" />
<CheckIcon v-else />
{{ formatMessage(commonMessages.saveChangesButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { ref, watch } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.library.group.instances-modal.title',
defaultMessage: 'Add instances to "{groupName}"',
},
searchPlaceholder: {
id: 'app.library.group.instances-modal.search-placeholder',
defaultMessage: 'Search instance',
},
noInstancesFound: {
id: 'app.library.group.instances-modal.no-instances-found',
defaultMessage: 'No instances found',
},
add: {
id: 'app.library.group.instances-modal.add',
defaultMessage: 'Add',
},
added: {
id: 'app.library.group.instances-modal.added',
defaultMessage: 'Added',
},
})
const {
isGroupInstancesModalOpen,
groupInstancesModalGroup,
groupInstancesSearch,
groupInstances,
selectedGroupInstanceIds,
savingGroupInstances,
closeGroupInstancesModal,
toggleGroupInstance,
saveGroupInstances,
} = useLibrary()
const modal = ref<InstanceType<typeof NewModal>>()
watch(isGroupInstancesModalOpen, (open) => {
if (open) {
modal.value?.show()
}
})
async function save() {
if (await saveGroupInstances()) {
modal.value?.hide()
}
}
</script>
@@ -6,17 +6,20 @@ import {
MinusIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
} from '@modrinth/assets'
import { computed, ref, toRef, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
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'
@@ -27,6 +30,8 @@ const props = defineProps<{
const {
instanceGroups,
filters,
instanceOptions,
confirmDeleteModal,
deleteInstance,
handleInstanceOption,
selectedLibraryInstances,
@@ -39,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'),
),
)
@@ -96,43 +102,70 @@ function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boo
anchorInstance.value = { groupId, instanceId }
}
watch(
() => selectedLibraryInstances.value.size,
(selectedInstanceCount) => {
if (selectedInstanceCount === 0) {
anchorInstance.value = null
}
},
)
function setInstanceOptions(component: unknown) {
instanceOptions.value = component as InstanceType<typeof ContextMenu> | null
}
function setConfirmDeleteModal(component: unknown) {
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
}
watch(selectedLibraryInstances, (selectedInstances) => {
if (selectedInstances.size === 0) {
anchorInstance.value = null
return
}
if (
anchorInstance.value &&
!selectedInstances.has(getLibraryInstanceSelectionKey(anchorInstance.value))
) {
anchorInstance.value = null
}
})
</script>
<template>
<InstanceGroupDnd :instances="instances">
<section class="flex flex-col gap-3 pb-16">
<section data-library-page-background class="flex flex-col gap-3 pb-16">
<h2 class="m-0 text-2xl font-semibold text-contrast">Library</h2>
<LibraryToolbar />
<div class="flex flex-col">
<InstanceGroup
v-for="instanceGroup in visibleInstanceGroups"
:key="instanceGroup.id"
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
<TransitionGroup
data-library-page-background
tag="div"
class="flex flex-col"
move-class="transition-transform duration-200 ease-out"
enter-active-class="transition-[opacity,transform] duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<div v-for="instanceGroup in visibleInstanceGroups" :key="instanceGroup.id" class="min-w-0">
<InstanceGroup
:hide-header="instanceGroup.key === 'None' && visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance.instanceId : null
"
@toggle-selection="
(instanceId: string, shiftKey: boolean) =>
handleToggleInstance(instanceGroup.id, instanceId, shiftKey)
"
/>
</div>
</TransitionGroup>
</section>
</InstanceGroupDnd>
<LibrarySelectionActionBar />
<ConfirmDeleteInstanceModal ref="confirmDeleteModal" @delete="deleteInstance" />
<ContextMenu ref="instanceOptions" @option-clicked="handleInstanceOption">
<GroupInstancesModal />
<ConfirmDeleteInstanceModal :ref="setConfirmDeleteModal" @delete="deleteInstance" />
<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>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
defineProps<{
deleting?: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add instances to group',
},
editGroupName: {
id: 'app.library.group.edit-name',
defaultMessage: 'Edit group name',
},
deleteGroup: {
id: 'app.library.group.delete',
defaultMessage: 'Delete group',
},
})
</script>
<template>
<div
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<ButtonStyled circular type="transparent" size="standard">
<button
class="!size-8 !min-w-8"
v-tooltip="formatMessage(messages.editGroupName)"
type="button"
:aria-label="formatMessage(messages.editGroupName)"
@click.stop="onEditGroupName"
>
<EditIcon class="!min-w-4 !min-h-4 !size-4" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent" size="standard">
<button
class="!size-8 !min-w-8"
v-tooltip="formatMessage(messages.addToGroup)"
type="button"
:aria-label="formatMessage(messages.addToGroup)"
@click.stop="onAddToGroup"
>
<SquarePlusIcon class="!min-w-4 !min-h-4 !size-4" />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent" size="standard">
<button
class="!size-8 !min-w-8"
v-tooltip="formatMessage(messages.deleteGroup)"
type="button"
:aria-label="formatMessage(messages.deleteGroup)"
:disabled="deleting"
@click.stop="onDeleteGroup"
>
<TrashIcon class="!min-w-4 !min-h-4 !size-4" />
</button>
</ButtonStyled>
</div>
</template>
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { DropdownIcon, EditIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
DropdownIcon,
EditIcon,
PlusIcon,
SquarePlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Accordion,
ButtonStyled,
@@ -12,15 +19,17 @@ import {
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, inject, nextTick, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
InstanceCard as InstanceCardExposed,
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<{
@@ -44,11 +53,13 @@ const {
groupIdPendingNameEdit,
completePendingGroupNameEdit,
handleInstanceContextMenu,
openGroupInstancesModal,
displayState,
activeInstanceGroupDrag,
instanceGroupDragTarget,
getInstanceGroupDropState,
} = useLibrary()
const showCreationModal = inject<() => void>('showCreationModal')
const instanceComponents = new Map<string, InstanceCardExposed>()
const groupDropTarget = ref<HTMLElement>()
@@ -59,6 +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 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),
@@ -84,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',
@@ -112,6 +131,18 @@ const messages = defineMessages({
id: 'app.library.group.delete-description',
defaultMessage: 'Instances in this group will be ungrouped.',
},
newInstance: {
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
addToGroup: {
id: 'app.library.group.context-menu.add-to-group',
defaultMessage: 'Add to group',
},
emptyGroup: {
id: 'app.library.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
})
function openInstanceContextMenu(event: MouseEvent, instanceId: string, instanceGroupId: string) {
@@ -142,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()
@@ -152,17 +183,37 @@ function requestGroupDeletion() {
}
function openGroupContextMenu(event: MouseEvent) {
if (isUngrouped.value) return
if (displayState.value.group !== 'Group') return
groupContextMenuOpen.value = true
groupOptions.value?.showMenu(event, props.instanceGroup, [
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
])
groupOptions.value?.showMenu(
event,
props.instanceGroup,
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' }],
)
}
function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
return
}
if (option === 'add_instances') {
openGroupInstancesModal(props.instanceGroup.id)
return
}
if (option === 'edit_name') {
void groupNameInput.value?.startEditing()
return
@@ -245,6 +296,7 @@ watch(
if (groupId !== pendingGroupId) return
await nextTick()
groupDropTarget.value?.scrollIntoView({ block: 'nearest' })
await groupNameInput.value?.startEditing()
completePendingGroupNameEdit(groupId)
},
@@ -310,10 +362,10 @@ watch(
/>
</button>
<InlineEditableText
v-if="!isUngrouped"
v-if="!isUngrouped && !isFavorites"
ref="groupNameInput"
v-model="groupName"
activation-mode="icon"
activation-mode="manual"
class="text-base font-semibold !h-10 text-primary select-none group-hover/open-target:text-contrast"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
@@ -323,11 +375,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"
@@ -336,18 +394,13 @@ watch(
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<ButtonStyled v-if="!isUngrouped" circular type="transparent">
<button
v-tooltip="formatMessage(messages.deleteGroup)"
class="opacity-0 !transition-all duration-150 group-hover/instance-container:opacity-100 -m-1.5"
type="button"
:aria-label="formatMessage(messages.deleteGroup)"
:disabled="deletingGroup"
@click.stop="requestGroupDeletion"
>
<TrashIcon class="!size-4 !min-h-4 !min-w-4" />
</button>
</ButtonStyled>
<GroupActionButtons
v-if="!isUngrouped && !isFavorites"
:deleting="deletingGroup"
:on-add-to-group="() => openGroupInstancesModal(instanceGroup.id)"
:on-delete-group="requestGroupDeletion"
:on-edit-group-name="() => groupNameInput?.startEditing()"
/>
</div>
<Accordion
ref="groupAccordion"
@@ -377,7 +430,7 @@ watch(
v-if="instanceGroup.instances.length === 0"
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-secondary opacity-80"
>
This group is empty. Drag and drop to add instances.
{{ formatMessage(messages.emptyGroup) }}
</p>
</section>
</Accordion>
@@ -388,6 +441,10 @@ watch(
@menu-closed="groupContextMenuOpen = false"
@option-clicked="handleGroupOption"
>
<template #new_instance> <PlusIcon /> {{ formatMessage(messages.newInstance) }} </template>
<template #add_instances>
<SquarePlusIcon /> {{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name> <EditIcon /> {{ formatMessage(messages.editGroupName) }} </template>
<template #delete_group> <TrashIcon /> {{ formatMessage(messages.deleteGroup) }} </template>
</ContextMenu>
@@ -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,
@@ -27,7 +28,6 @@ import {
import type { GameInstance } from '@/helpers/types'
export const librarySortOptions = [
'Manual',
'Name',
'Last played',
'Hours played',
@@ -37,11 +37,24 @@ export const librarySortOptions = [
export const libraryGroupOptions = [
{ value: 'Group', label: 'Custom group' },
{ value: 'Instance type', label: 'Instance type' },
{ value: 'Loader', label: 'Loader' },
{ value: 'Game version', label: 'Game version' },
{ value: 'None', label: 'No grouping' },
] as const
const libraryInstanceTypeLabels = {
custom: 'Custom',
modpack: 'Modpack',
server: 'Server',
} as const
const libraryLoaderPriority: Record<string, number> = {
fabric: 0,
forge: 1,
neoforge: 2,
}
export type LibrarySort = (typeof librarySortOptions)[number]
export type LibraryGroupBy = (typeof libraryGroupOptions)[number]['value']
export type LibraryFilters = Record<'instanceType' | 'gameVersion' | 'loader', string[]>
@@ -94,17 +107,27 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const { formatMessage } = useVIntl()
const search = ref('')
const filters = ref<LibraryFilters>({
instanceType: [],
gameVersion: [],
loader: [],
})
const filters = useStorage<LibraryFilters>(
'Instances-grid-filters',
{
instanceType: [],
gameVersion: [],
loader: [],
},
localStorage,
{ mergeDefaults: true },
)
const serverProjectIds = ref(new Set<string>())
const libraryGroups = ref<InstanceGroupDefinition[]>([])
const isNewGroupModalOpen = ref(false)
const newGroupName = ref('')
const newGroupSearch = ref('')
const selectedNewGroupInstanceIds = ref(new Set<string>())
const isGroupInstancesModalOpen = ref(false)
const groupInstancesModalGroupId = ref<string | null>(null)
const groupInstancesSearch = ref('')
const selectedGroupInstanceIds = ref(new Set<string>())
const savingGroupInstances = ref(false)
const selectedLibraryInstances = ref(new Map<string, LibraryInstanceSelection>())
const isLibraryInstanceSelectionActive = computed(() => selectedLibraryInstances.value.size > 0)
const creatingGroup = ref(false)
@@ -141,12 +164,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
displayState.value.sortBy = 'Name'
}
if (!libraryGroupOptions.some((option) => option.value === displayState.value.group)) {
displayState.value.group = 'Group'
}
const linkedInstances = computed(() => instances.value.filter((instance) => instance.link))
const collapsedSectionKeys = computed(() => new Set(displayState.value.collapsedGroups))
const groupNames = computed(
() =>
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'),
@@ -169,6 +197,18 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
return a.name.localeCompare(b.name)
})
})
const groupInstancesModalGroup = computed(
() =>
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
)
const groupInstances = computed(() => {
const query = groupInstancesSearch.value.trim().toLowerCase()
return instances.value
.filter((instance) => !query || instance.name.toLowerCase().includes(query))
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
})
const canCreateGroup = computed(
() => normalizedNewGroupName.value.length > 0 && !creatingGroup.value,
)
@@ -241,8 +281,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
)
switch (displayState.value.sortBy) {
case 'Manual':
break
case 'Name':
visibleInstances.sort((a, b) => a.name.localeCompare(b.name))
break
@@ -275,6 +313,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
for (const instance of visibleInstances) {
switch (displayState.value.group) {
case 'Instance type':
{
const instanceType = getInstanceType(instance)
addToGroup(
`Instance type:${instanceType}`,
libraryInstanceTypeLabels[instanceType],
instance,
)
}
break
case 'Loader':
{
const name = formatLoader(formatMessage, instance.loader)
@@ -322,12 +370,27 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
groups.sort((a, b) => a.key.localeCompare(b.key) || a.id.localeCompare(b.id))
}
if (displayState.value.group === 'Loader') {
groups.sort((a, b) => {
const aPriority = libraryLoaderPriority[a.key.toLowerCase()] ?? Number.MAX_SAFE_INTEGER
const bPriority = libraryLoaderPriority[b.key.toLowerCase()] ?? Number.MAX_SAFE_INTEGER
return aPriority - bPriority || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
})
}
if (displayState.value.group === 'Instance type') {
groups.sort((a, b) => a.key.localeCompare(b.key) || a.id.localeCompare(b.id))
}
if (displayState.value.group === 'Game version') {
groups.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }))
groups.sort((a, b) => b.key.localeCompare(a.key, undefined, { numeric: true }))
}
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)
@@ -586,6 +649,120 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
isNewGroupModalOpen.value = false
}
const openGroupInstancesModal = (groupId: string) => {
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
if (!group) return
groupInstancesModalGroupId.value = groupId
groupInstancesSearch.value = ''
selectedGroupInstanceIds.value = new Set(
instances.value
.filter((instance) => instance.group_ids.includes(groupId))
.map((instance) => instance.id),
)
isGroupInstancesModalOpen.value = true
}
const closeGroupInstancesModal = () => {
isGroupInstancesModalOpen.value = false
groupInstancesModalGroupId.value = null
}
const toggleGroupInstance = (instanceId: string) => {
const selectedIds = new Set(selectedGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
}
selectedGroupInstanceIds.value = selectedIds
}
const saveGroupInstances = async () => {
const groupId = groupInstancesModalGroupId.value
if (!groupId || savingGroupInstances.value) return false
const changedInstances = instances.value.filter(
(instance) =>
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
)
const operations = changedInstances.map((instance) => {
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
const nextGroupIds = shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
return {
instance,
shouldIncludeGroup,
nextGroupIds,
}
})
savingGroupInstances.value = true
try {
await Promise.all(
operations.map(({ instance, nextGroupIds }) =>
edit(instance.id, { group_ids: nextGroupIds }),
),
)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const { instance, shouldIncludeGroup, nextGroupIds } of operations) {
const instanceSelections = [...nextSelectedInstances.values()].filter(
(selection) => selection.instanceId === instance.id,
)
if (shouldIncludeGroup && instanceSelections.length > 0) {
for (const selection of instanceSelections) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(selection))
}
const destinationSelection = {
instanceId: instance.id,
groupId,
}
nextSelectedInstances.set(
getLibraryInstanceSelectionKey(destinationSelection),
destinationSelection,
)
continue
}
const validSelectionGroupIds = new Set(
nextGroupIds.length > 0 ? nextGroupIds : ['group:none'],
)
const invalidSelections = instanceSelections.filter(
(selection) => !validSelectionGroupIds.has(selection.groupId),
)
for (const selection of invalidSelections) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(selection))
}
const hasValidSelection = [...nextSelectedInstances.values()].some(
(selection) => selection.instanceId === instance.id,
)
if (invalidSelections.length > 0 && !hasValidSelection) {
const replacementSelection = {
instanceId: instance.id,
groupId: nextGroupIds[0] ?? 'group:none',
}
nextSelectedInstances.set(
getLibraryInstanceSelectionKey(replacementSelection),
replacementSelection,
)
}
}
selectedLibraryInstances.value = nextSelectedInstances
return true
} catch (error) {
handleError(toError(error))
return false
} finally {
savingGroupInstances.value = false
}
}
const toggleNewGroupInstance = (instanceId: string) => {
const selectedIds = new Set(selectedNewGroupInstanceIds.value)
@@ -671,11 +848,6 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
try {
const group = await createInstanceGroup(getDefaultNewGroupName())
libraryGroups.value = [
...libraryGroups.value.filter((existingGroup) => existingGroup.id !== group.id),
group,
]
const selectedInstanceIds = new Set(instanceIds)
const instancesToAdd = instances.value.filter((instance) =>
selectedInstanceIds.has(instance.id),
@@ -688,14 +860,10 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
),
)
if (instancesToAdd.length > 0) {
setSelectedLibraryInstances(
instancesToAdd.map((instance) => ({
instanceId: instance.id,
groupId: group.id,
})),
)
}
libraryGroups.value = [
...libraryGroups.value.filter((existingGroup) => existingGroup.id !== group.id),
group,
]
displayState.value.group = 'Group'
groupIdPendingNameEdit.value = group.id
@@ -763,12 +931,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' },
@@ -802,6 +980,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
@@ -844,6 +1034,11 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
newGroupName,
newGroupSearch,
selectedNewGroupInstanceIds,
isGroupInstancesModalOpen,
groupInstancesModalGroup,
groupInstancesSearch,
selectedGroupInstanceIds,
savingGroupInstances,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeInstanceGroupDrag,
@@ -855,6 +1050,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
creatingGroup,
groupIdPendingNameEdit,
newGroupInstances,
groupInstances,
canCreateGroup,
instanceOptions,
confirmDeleteModal,
@@ -868,7 +1064,11 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
moveDraggedInstancesToGroup,
openNewGroupModal,
closeNewGroupModal,
openGroupInstancesModal,
closeGroupInstancesModal,
toggleNewGroupInstance,
toggleGroupInstance,
saveGroupInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
@@ -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
+44 -3
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { PlusIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { computed, inject, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import LibrarySection from '@/components/ui/library/index.vue'
import WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -14,9 +16,19 @@ import { injectOnboardingChecklist } from '@/providers/onboarding-checklist'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { hasCreatedInstance, isReady } = injectOnboardingChecklist()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
const showCreationModal = inject<() => void>('showCreationModal')
const pageOptions = ref<InstanceType<typeof ContextMenu>>()
const messages = defineMessages({
newInstance: {
id: 'app.library.context-menu.create-instance',
defaultMessage: 'New instance',
},
})
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
@@ -46,12 +58,41 @@ const unlistenInstance = await instance_listener(fetchInstances)
onUnmounted(() => {
unlistenInstance()
})
function openPageContextMenu(event: MouseEvent) {
if (
!(event.target instanceof HTMLElement) ||
!event.target.hasAttribute('data-library-page-background')
) {
return
}
event.preventDefault()
event.stopPropagation()
pageOptions.value?.showMenu(event, {}, [{ name: 'new_instance' }])
}
function handlePageOption({ option }: { option: string }) {
if (option === 'new_instance') {
showCreationModal?.()
}
}
</script>
<template>
<WelcomeScreen v-if="isReady && !hasCreatedInstance" />
<div v-else-if="isReady" class="flex flex-col gap-6 p-6">
<div
v-else-if="isReady"
data-library-page-background
class="flex flex-col gap-6 p-6"
@contextmenu="openPageContextMenu"
>
<RecentWorldsList v-if="recentInstances?.length > 0" :recent-instances="recentInstances" />
<LibrarySection :instances="instances" />
<ContextMenu ref="pageOptions" @option-clicked="handlePageOption">
<template #new_instance>
<PlusIcon /> {{ formatMessage(messages.newInstance) }}
</template>
</ContextMenu>
</div>
</template>
@@ -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");
}
}
@@ -11,7 +11,7 @@ const props = withDefaults(
maxWidth?: string
maxLength?: number
editLabel?: string
activationMode?: 'text' | 'icon'
activationMode?: 'text' | 'icon' | 'manual'
inputClass?: string
buttonClass?: string
iconTextClass?: string
@@ -231,7 +231,7 @@ defineExpose({
</span>
</div>
<button
v-else
v-else-if="activationMode === 'text'"
type="button"
class="flex w-full max-w-full items-center gap-2 truncate border-0 bg-transparent p-0 text-left text-inherit transition-colors hover:text-brand focus-visible:text-contrast [font:inherit]"
:class="buttonClass"
@@ -241,5 +241,8 @@ defineExpose({
>
<span class="min-w-0 truncate">{{ displayValue }}</span>
</button>
<span v-else class="min-w-0 truncate" :title="model || displayValue">
{{ displayValue }}
</span>
</div>
</template>