Project, Search, User redesign (#1281)

* New project page

* fix silly icon tailwind classes

* Start new versions page, add new ButtonStyled component

* Pagination and finish mocking up versions page functionality

* green download button

* hover animation

* New Modal, Avatar refactor, subpages in NavTabs

* lint

* Download modal

* New user page + fix lint

* fix ui lint

* Download animation fix

* Versions filter + finish project page

* Improve consistency of buttons on home page

* Fix ButtonStyled breaking

* Fix margin on version summary

* finish search, new modals, user + project page mobile

* fix gallery image pages

* New project header

* Fix gallery tab showing improperly

* Use auto direction + position for all popouts

* Preliminary user page

* test to see if this fixes login stuff

* remove extra slash

* Add version actions, move download button on versions page

* Listed -> public

* Shorten download modal selector height

* Fix user menu open direction

* Change breakpoint for header collapse

* Only underline title

* Tighten padding on stats a little

* New nav

* Make mobile breakpoint more consistent

* fix header breakpoint regression

* Add sign in button

* Fix edit icon color

* Fix margin at top of screen

* Fix user bios and ad width

* Fix user nav showing when there's only one type of project

* Fix plural projects on user page & extract i18n

* Remove ads on mobile for now

* Fix overflow menu showing hidden items

* NavTabs on mobile

* Fix navbar z index

* Search filter overhaul + negative filters

* fix no-max-height

* port version filters, fix following/collections, lint

* hide promos

* ui lint

* Disable modal background animation to reduce reported motion sickness

* Hide install with modrinth app button on mobile

---------

Signed-off-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
Geometrically 2024-08-20 23:03:16 -07:00 committed by GitHub
parent a19ce0458a
commit 2d416d491c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 5361 additions and 4488 deletions

6
.gitignore vendored
View File

@ -38,9 +38,6 @@ testem.log
.DS_Store
Thumbs.db
.nx/cache
.nx/workspace-data
# Nuxt dev/build outputs
.output
.data
@ -54,6 +51,7 @@ apps/frontend/src/generated
.turbo
target
generated
.env
# app testing dir
app-playground-data/*
app-playground-data/*

15
.vscode/settings.json vendored
View File

@ -1,4 +1,13 @@
{
"prettier.endOfLine": "lf",
"editor.formatOnSave": true
}
"prettier.endOfLine": "lf",
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}

3
apps/frontend/.env Normal file
View File

@ -0,0 +1,3 @@
BASE_URL=https://api.modrinth.com/v2/
BROWSER_BASE_URL=https://api.modrinth.com/v2/
SITE_URL=https://new-project-page.code-efh.pages.dev

View File

@ -434,6 +434,7 @@ export default defineNuxtConfig({
},
},
compatibilityDate: "2024-07-03",
telemetry: false,
});
function getApiUrl() {

View File

@ -0,0 +1,5 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.116 20.1934C6.71473 18.3852 6.77635 16.5049 7.29513 14.7269C7.8139 12.9488 8.77304 11.3306 10.0836 10.0223C11.3942 8.71392 13.0138 7.75779 14.7922 7.24253C16.5706 6.72727 18.4503 6.66957 20.257 7.07478C21.2514 5.51897 22.6212 4.23862 24.2403 3.35174C25.8595 2.46486 27.6757 2 29.5216 2C31.3675 2 33.1838 2.46486 34.8029 3.35174C36.422 4.23862 37.7919 5.51897 38.7863 7.07478C40.5957 6.66781 42.4786 6.72525 44.2598 7.24176C46.0411 7.75826 47.6628 8.71706 48.9742 10.029C50.2856 11.3409 51.244 12.9632 51.7603 14.7452C52.2766 16.5272 52.334 18.4108 51.9272 20.2209C53.4824 21.2157 54.7623 22.5861 55.6488 24.2059C56.5353 25.8256 57 27.6426 57 29.4892C57 31.3359 56.5353 33.1528 55.6488 34.7726C54.7623 36.3923 53.4824 37.7627 51.9272 38.7575C52.3323 40.5649 52.2746 42.4453 51.7595 44.2244C51.2445 46.0036 50.2887 47.6238 48.9809 48.9349C47.6731 50.246 46.0555 51.2055 44.2781 51.7245C42.5008 52.2434 40.6213 52.3051 38.8138 51.9037C37.8207 53.4654 36.4497 54.7513 34.8278 55.6421C33.206 56.533 31.3856 57 29.5354 57C27.6851 57 25.8648 56.533 24.2429 55.6421C22.621 54.7513 21.2501 53.4654 20.257 51.9037C18.4503 52.3089 16.5706 52.2512 14.7922 51.7359C13.0138 51.2207 11.3942 50.2645 10.0836 48.9562C8.77304 47.6478 7.8139 46.0296 7.29513 44.2516C6.77635 42.4735 6.71473 40.5932 7.116 38.785C5.54886 37.7929 4.25802 36.4203 3.36354 34.795C2.46906 33.1697 2 31.3446 2 29.4892C2 27.6339 2.46906 25.8087 3.36354 24.1834C4.25802 22.5581 5.54886 21.1856 7.116 20.1934Z" fill="#1C3360" stroke="#4F9CFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 24.5V25.5C22 26.2956 22.3161 27.0587 22.8787 27.6213C23.4413 28.1839 24.2044 28.5 25 28.5H35C35.7956 28.5 36.5587 28.1839 37.1213 27.6213C37.6839 27.0587 38 26.2956 38 25.5V24.5M34 20.5L30 24.5M30 24.5L26 20.5M30 24.5V12.5" stroke="#4F9CFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.822 37.2273V44.5H18.0677V38.8679H18.0251L16.3987 39.8622V38.3423L18.192 37.2273H19.822ZM24.4376 44.6776C23.8031 44.6776 23.2562 44.5272 22.797 44.2266C22.3377 43.9235 21.9838 43.4891 21.7352 42.9233C21.4866 42.3551 21.3635 41.6721 21.3659 40.8743C21.3682 40.0765 21.4925 39.3994 21.7387 38.843C21.9873 38.2843 22.3401 37.8594 22.797 37.5682C23.2562 37.2746 23.8031 37.1278 24.4376 37.1278C25.0721 37.1278 25.6189 37.2746 26.0782 37.5682C26.5399 37.8594 26.895 38.2843 27.1436 38.843C27.3921 39.4018 27.5152 40.0788 27.5129 40.8743C27.5129 41.6745 27.3886 42.3587 27.14 42.9268C26.8914 43.495 26.5375 43.9295 26.0782 44.2301C25.6213 44.5284 25.0744 44.6776 24.4376 44.6776ZM24.4376 43.2393C24.8164 43.2393 25.123 43.0464 25.3573 42.6605C25.5917 42.2723 25.7077 41.6768 25.7053 40.8743C25.7053 40.3487 25.6521 39.9155 25.5455 39.5746C25.439 39.2313 25.291 38.9756 25.1017 38.8075C24.9123 38.6394 24.6909 38.5554 24.4376 38.5554C24.0612 38.5554 23.757 38.746 23.5249 39.1271C23.2929 39.5059 23.1758 40.0883 23.1734 40.8743C23.171 41.407 23.2219 41.8485 23.3261 42.1989C23.4326 42.5492 23.5818 42.8108 23.7735 42.9837C23.9653 43.1541 24.1866 43.2393 24.4376 43.2393ZM28.5338 37.2273H30.7107L32.5573 41.7301H32.6425L34.4891 37.2273H36.6659V44.5H34.9543V40.0327H34.8939L33.1468 44.4538H32.053L30.3058 40.0078H30.2455V44.5H28.5338V37.2273ZM39.9285 43.4489V38.3068H41.3596V43.4489H39.9285ZM38.0713 41.5952V40.1605H43.2133V41.5952H38.0713Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,8 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="57" height="57" rx="11" fill="#601C2D"/>
<rect x="1" y="1" width="57" height="57" rx="11" stroke="#FF496E" stroke-width="2"/>
<path d="M13.7907 17.5347V10.2619H18.8617V11.6895H15.5485V13.181H18.6025V14.6121H15.5485V16.1071H18.8617V17.5347H13.7907ZM21.5357 17.5347H19.6465L22.1003 10.2619H24.4405L26.8944 17.5347H25.0051L23.2971 12.0943H23.2402L21.5357 17.5347ZM21.2836 14.6725H25.2324V16.0077H21.2836V14.6725ZM27.6774 17.5347V10.2619H30.6816C31.2261 10.2619 31.6961 10.3602 32.0914 10.5567C32.4892 10.7508 32.7958 11.0302 33.0112 11.3948C33.2266 11.757 33.3343 12.1867 33.3343 12.6838C33.3343 13.1881 33.2243 13.6166 33.0041 13.9693C32.7839 14.3197 32.4714 14.5872 32.0666 14.7719C31.6618 14.9542 31.1824 15.0453 30.6284 15.0453H28.7285V13.6604H30.3017C30.5668 13.6604 30.7882 13.6261 30.9657 13.5574C31.1457 13.4864 31.2818 13.3798 31.3741 13.2378C31.4664 13.0934 31.5126 12.9087 31.5126 12.6838C31.5126 12.4589 31.4664 12.2731 31.3741 12.1263C31.2818 11.9771 31.1457 11.8659 30.9657 11.7925C30.7858 11.7167 30.5645 11.6788 30.3017 11.6788H29.4352V17.5347H27.6774ZM31.7718 14.2108L33.5829 17.5347H31.6653L29.8897 14.2108H31.7718ZM34.2985 17.5347V10.2619H36.0563V16.1071H39.0819V17.5347H34.2985ZM38.5066 10.2619H40.4703L42.008 13.3053H42.0719L43.6096 10.2619H45.5733L42.9135 15.1057V17.5347H41.1664V15.1057L38.5066 10.2619Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.1891 33.8311C38.5895 32.357 38.6166 30.7615 38.1905 29.1803C36.9027 24.3989 31.962 21.5612 27.1556 22.8422C23.4457 23.8312 20.8996 26.9848 20.5234 30.571H22.0458C22.4079 27.6593 24.498 25.1168 27.5219 24.3107C31.2592 23.3143 35.0942 25.3249 36.4316 28.8518L34.95 29.2465C34.2683 27.6058 32.8947 26.4028 31.268 25.8904L30.9958 27.4258C32.2801 27.8975 33.3219 28.9611 33.703 30.3763C34.2585 32.4386 33.2241 34.5518 31.3474 35.4284L31.7528 36.9339C34.2194 35.92 35.7013 33.3667 35.3508 30.7345L36.8267 30.3412C36.9849 31.3467 36.9301 32.3462 36.691 33.2886L38.1891 33.8311Z" fill="#FF496E"/>
<path d="M31.8204 40.1581C27.0137 41.4391 22.0733 38.6014 20.7851 33.82C20.635 33.2621 20.5411 32.7025 20.5 32.1467H22.0213C22.0596 32.5638 22.1338 32.9834 22.2466 33.4015C22.3648 33.8396 22.5198 34.2581 22.7075 34.6546L24.0643 33.8438C23.95 33.5812 23.8537 33.3073 23.777 33.0226C22.9319 29.8847 24.8036 26.6593 27.958 25.8185C28.5557 25.6591 29.1569 25.5971 29.744 25.622L29.4719 27.1577C29.1041 27.1594 28.7308 27.2074 28.3588 27.3065C26.0306 27.9274 24.649 30.3078 25.2726 32.6241C25.3113 32.7666 25.3563 32.9056 25.4077 33.0408L27.1427 32.0038L26.6199 30.623L28.2582 28.9465L30.329 28.5021L30.9285 29.2372L29.9739 30.2003L29.1414 30.4612L28.5465 31.0702L28.838 31.8781C28.838 31.8781 29.4283 32.5029 29.4293 32.5032L30.2633 32.2823L30.8567 31.633L32.1526 31.2243L32.5387 32.0893L31.2016 33.7223L28.961 34.429L27.9562 33.3147L26.2047 34.3614C27.1026 35.3799 28.4527 35.9454 29.8523 35.8274L30.2576 37.333C28.1689 37.6082 26.1287 36.7471 24.8613 35.1648L23.5109 35.9717C25.2902 38.3002 28.3697 39.4538 31.3851 38.6501C33.5558 38.0715 35.2451 36.5979 36.1599 34.7355L37.659 35.2783C36.573 37.5995 34.5005 39.4437 31.8204 40.1581Z" fill="#FF496E"/>
<path d="M13.8386 50.4653H12.5161L14.2338 45.3744H15.8719L17.5896 50.4653H16.2672L15.0715 46.6571H15.0317L13.8386 50.4653ZM13.6621 48.4618H16.4263V49.3964H13.6621V48.4618ZM20.0195 50.4653H18.1377V45.3744H20.017C20.5357 45.3744 20.9823 45.4763 21.3568 45.6802C21.733 45.8824 22.023 46.174 22.2269 46.5552C22.4307 46.9347 22.5326 47.3887 22.5326 47.9174C22.5326 48.4477 22.4307 48.9034 22.2269 49.2846C22.0247 49.6657 21.7355 49.9582 21.3593 50.1621C20.9831 50.3642 20.5365 50.4653 20.0195 50.4653ZM19.3682 49.4163H19.9723C20.2573 49.4163 20.4984 49.3683 20.6956 49.2722C20.8945 49.1744 21.0445 49.0161 21.1456 48.7974C21.2483 48.577 21.2997 48.2836 21.2997 47.9174C21.2997 47.5512 21.2483 47.2595 21.1456 47.0424C21.0428 46.8236 20.8912 46.6662 20.6907 46.5701C20.4918 46.4723 20.2465 46.4234 19.9549 46.4234H19.3682V49.4163ZM28.0561 47.9199C28.0561 48.48 27.9483 48.9548 27.7329 49.3442C27.5175 49.7337 27.2258 50.0295 26.8579 50.2317C26.4917 50.4338 26.0807 50.5349 25.625 50.5349C25.1676 50.5349 24.7558 50.433 24.3895 50.2292C24.0233 50.0253 23.7324 49.7295 23.517 49.3418C23.3032 48.9523 23.1963 48.4784 23.1963 47.9199C23.1963 47.3597 23.3032 46.885 23.517 46.4955C23.7324 46.1061 24.0233 45.8103 24.3895 45.6081C24.7558 45.4059 25.1676 45.3048 25.625 45.3048C26.0807 45.3048 26.4917 45.4059 26.8579 45.6081C27.2258 45.8103 27.5175 46.1061 27.7329 46.4955C27.9483 46.885 28.0561 47.3597 28.0561 47.9199ZM26.7983 47.9199C26.7983 47.5884 26.751 47.3084 26.6566 47.0797C26.5638 46.851 26.4295 46.6778 26.2539 46.5601C26.0799 46.4425 25.8702 46.3837 25.625 46.3837C25.3813 46.3837 25.1717 46.4425 24.996 46.5601C24.8204 46.6778 24.6853 46.851 24.5909 47.0797C24.4981 47.3084 24.4517 47.5884 24.4517 47.9199C24.4517 48.2513 24.4981 48.5314 24.5909 48.7601C24.6853 48.9888 24.8204 49.1619 24.996 49.2796C25.1717 49.3973 25.3813 49.4561 25.625 49.4561C25.8702 49.4561 26.0799 49.3973 26.2539 49.2796C26.4295 49.1619 26.5638 48.9888 26.6566 48.7601C26.751 48.5314 26.7983 48.2513 26.7983 47.9199ZM28.7745 50.4653V45.3744H30.8774C31.2586 45.3744 31.5876 45.449 31.8643 45.5981C32.1427 45.7456 32.3573 45.952 32.5081 46.2171C32.6589 46.4806 32.7343 46.7872 32.7343 47.1369C32.7343 47.4882 32.6573 47.7956 32.5032 48.0591C32.3507 48.3209 32.1328 48.5239 31.8494 48.6681C31.566 48.8123 31.2296 48.8844 30.8402 48.8844H29.5426V47.9149H30.6115C30.7971 47.9149 30.952 47.8826 31.0763 47.818C31.2023 47.7533 31.2975 47.663 31.3622 47.547C31.4268 47.4293 31.4591 47.2926 31.4591 47.1369C31.4591 46.9794 31.4268 46.8435 31.3622 46.7292C31.2975 46.6132 31.2023 46.5237 31.0763 46.4607C30.9504 46.3977 30.7954 46.3663 30.6115 46.3663H30.0049V50.4653H28.7745ZM33.1743 46.3737V45.3744H37.4772V46.3737H35.9335V50.4653H34.7205V46.3737H33.1743ZM38.0782 50.4653V45.3744H41.6279V46.3737H39.3086V47.4177H41.4464V48.4195H39.3086V49.466H41.6279V50.4653H38.0782ZM42.3985 50.4653V45.3744H44.5015C44.8826 45.3744 45.2116 45.4432 45.4883 45.5807C45.7667 45.7166 45.9813 45.9122 46.1321 46.1674C46.283 46.4209 46.3584 46.7217 46.3584 47.0697C46.3584 47.4227 46.2813 47.7227 46.1272 47.9696C45.9731 48.2149 45.7543 48.4021 45.4709 48.5314C45.1875 48.659 44.852 48.7228 44.4642 48.7228H43.1343V47.7533H44.2355C44.4211 47.7533 44.576 47.7293 44.7003 47.6812C44.8263 47.6315 44.9216 47.557 44.9862 47.4575C45.0508 47.3564 45.0831 47.2272 45.0831 47.0697C45.0831 46.9123 45.0508 46.7822 44.9862 46.6795C44.9216 46.5751 44.8263 46.4972 44.7003 46.4458C44.5744 46.3928 44.4194 46.3663 44.2355 46.3663H43.629V50.4653H42.3985ZM45.2646 48.1386L46.5324 50.4653H45.19L43.9471 48.1386H45.2646Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,5 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M52 32.346C52 46.5936 42.1562 53.7175 30.4563 57.8493C29.8436 58.0596 29.1781 58.0495 28.5719 57.8208C16.8438 53.7175 7 46.5936 7 32.346V12.3994C7 11.6436 7.29632 10.9188 7.82376 10.3845C8.35121 9.85007 9.06658 9.54985 9.8125 9.54985C15.4375 9.54985 22.4687 6.13042 27.3625 1.79915C27.9583 1.28338 28.7163 1 29.5 1C30.2837 1 31.0417 1.28338 31.6375 1.79915C36.5594 6.15892 43.5625 9.54985 49.1875 9.54985C49.9334 9.54985 50.6488 9.85007 51.1762 10.3845C51.7037 10.9188 52 11.6436 52 12.3994V32.346Z" fill="#362924" stroke="#FFA347" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.5 16L23.5 17M23.5 17L20.5 26C21.3657 26.649 22.4185 26.9999 23.5005 26.9999C24.5825 26.9999 25.6353 26.649 26.501 26M23.5 17L26.5 26M23.5 17L29.5 15M29.5 15L35.5 17M29.5 15V13M29.5 15V31M35.5 17L38.5 16M35.5 17L32.5 26C33.3657 26.649 34.4185 26.9999 35.5005 26.9999C36.5825 26.9999 37.6353 26.649 38.501 26L35.5 17ZM29.5 31H26.5M29.5 31H32.5" stroke="#FFA347" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17.8385 38.7273H20.0154L21.8619 43.2301H21.9472L23.7938 38.7273H25.9706V46H24.259V41.5327H24.1986L22.4514 45.9538H21.3577L19.6105 41.5078H19.5502V46H17.8385V38.7273ZM33.9314 42.3636C33.9314 43.1638 33.7775 43.8421 33.4697 44.3984C33.162 44.9548 32.7453 45.3774 32.2197 45.6662C31.6965 45.955 31.1094 46.0994 30.4584 46.0994C29.805 46.0994 29.2166 45.9538 28.6934 45.6626C28.1702 45.3714 27.7548 44.9489 27.447 44.3949C27.1416 43.8385 26.9889 43.1615 26.9889 42.3636C26.9889 41.5634 27.1416 40.8852 27.447 40.3288C27.7548 39.7725 28.1702 39.3499 28.6934 39.0611C29.2166 38.7723 29.805 38.6278 30.4584 38.6278C31.1094 38.6278 31.6965 38.7723 32.2197 39.0611C32.7453 39.3499 33.162 39.7725 33.4697 40.3288C33.7775 40.8852 33.9314 41.5634 33.9314 42.3636ZM32.1345 42.3636C32.1345 41.8902 32.067 41.4901 31.9321 41.1634C31.7995 40.8366 31.6077 40.5893 31.3568 40.4212C31.1082 40.2531 30.8087 40.169 30.4584 40.169C30.1104 40.169 29.8109 40.2531 29.5599 40.4212C29.309 40.5893 29.116 40.8366 28.9811 41.1634C28.8485 41.4901 28.7822 41.8902 28.7822 42.3636C28.7822 42.8371 28.8485 43.2372 28.9811 43.5639C29.116 43.8906 29.309 44.138 29.5599 44.3061C29.8109 44.4742 30.1104 44.5582 30.4584 44.5582C30.8087 44.5582 31.1082 44.4742 31.3568 44.3061C31.6077 44.138 31.7995 43.8906 31.9321 43.5639C32.067 43.2372 32.1345 42.8371 32.1345 42.3636ZM37.6459 46H34.9577V38.7273H37.6423C38.3833 38.7273 39.0213 38.8729 39.5564 39.1641C40.0938 39.4529 40.5081 39.8696 40.7993 40.4141C41.0905 40.9562 41.2361 41.6049 41.2361 42.3601C41.2361 43.1177 41.0905 43.7687 40.7993 44.3132C40.5104 44.8577 40.0973 45.2756 39.5599 45.5668C39.0225 45.8556 38.3845 46 37.6459 46ZM36.7155 44.5014H37.5784C37.9856 44.5014 38.33 44.4328 38.6118 44.2955C38.8959 44.1558 39.1101 43.9297 39.2545 43.6172C39.4013 43.3023 39.4747 42.8833 39.4747 42.3601C39.4747 41.8369 39.4013 41.4202 39.2545 41.1101C39.1077 40.7976 38.8911 40.5727 38.6047 40.4354C38.3206 40.2957 37.9702 40.2259 37.5535 40.2259H36.7155V44.5014Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,12 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3318_3530)">
<rect x="19.5" y="1" width="20" height="57" rx="5" fill="#451C60" stroke="#C78AFF" stroke-width="2"/>
<rect x="1" y="39" width="20" height="57" rx="5" transform="rotate(-90 1 39)" fill="#451C60" stroke="#C78AFF" stroke-width="2"/>
<path d="M17.0133 33V25.7273H20.0176C20.5621 25.7273 21.032 25.8338 21.4274 26.0469C21.8251 26.2576 22.1317 26.5523 22.3471 26.9311C22.5626 27.3075 22.6703 27.7455 22.6703 28.245C22.6703 28.7469 22.5602 29.1861 22.34 29.5625C22.1222 29.9366 21.8109 30.2266 21.4061 30.4325C21.0012 30.6385 20.5207 30.7415 19.9643 30.7415H18.1106V29.3565H19.6376C19.9028 29.3565 20.1241 29.3104 20.3017 29.218C20.4816 29.1257 20.6177 28.9967 20.71 28.831C20.8024 28.6629 20.8485 28.4676 20.8485 28.245C20.8485 28.0201 20.8024 27.826 20.71 27.6626C20.6177 27.4969 20.4816 27.3691 20.3017 27.2791C20.1217 27.1892 19.9004 27.1442 19.6376 27.1442H18.7711V33H17.0133ZM23.5368 33V25.7273H25.2946V31.5724H28.3201V33H23.5368ZM33.63 25.7273H35.3842V30.4183C35.3842 30.9605 35.2552 31.4328 34.9972 31.8352C34.7391 32.2353 34.3793 32.5455 33.9176 32.7656C33.456 32.9834 32.9197 33.0923 32.3089 33.0923C31.6911 33.0923 31.1513 32.9834 30.6896 32.7656C30.228 32.5455 29.8693 32.2353 29.6136 31.8352C29.358 31.4328 29.2301 30.9605 29.2301 30.4183V25.7273H30.9879V30.2656C30.9879 30.5166 31.0424 30.7403 31.1513 30.9368C31.2625 31.1333 31.4176 31.2872 31.6165 31.3984C31.8153 31.5097 32.0462 31.5653 32.3089 31.5653C32.5717 31.5653 32.8014 31.5097 32.9979 31.3984C33.1967 31.2872 33.3518 31.1333 33.4631 30.9368C33.5743 30.7403 33.63 30.5166 33.63 30.2656V25.7273ZM40.4171 27.9077C40.3934 27.6473 40.2881 27.4448 40.101 27.3004C39.9164 27.1536 39.6524 27.0803 39.3091 27.0803C39.0819 27.0803 38.8925 27.1098 38.7409 27.169C38.5894 27.2282 38.4758 27.3099 38.4 27.4141C38.3243 27.5159 38.2852 27.633 38.2828 27.7656C38.2781 27.8745 38.2994 27.9704 38.3468 28.0533C38.3965 28.1361 38.4675 28.2095 38.5598 28.2734C38.6545 28.335 38.7682 28.3894 38.9007 28.4368C39.0333 28.4841 39.1825 28.5256 39.3482 28.5611L39.9732 28.7031C40.333 28.7812 40.6503 28.8854 40.9249 29.0156C41.2019 29.1458 41.4339 29.3009 41.6209 29.4808C41.8103 29.6607 41.9535 29.8679 42.0506 30.1023C42.1477 30.3366 42.1974 30.5994 42.1998 30.8906C42.1974 31.3499 42.0814 31.7441 41.8517 32.0732C41.6221 32.4022 41.2918 32.6544 40.861 32.8295C40.4325 33.0047 39.9152 33.0923 39.3091 33.0923C38.7007 33.0923 38.1704 33.0012 37.7182 32.8189C37.266 32.6366 36.9145 32.3596 36.6635 31.9879C36.4126 31.6162 36.2836 31.1463 36.2765 30.5781H37.9597C37.9739 30.8125 38.0366 31.0078 38.1479 31.1641C38.2592 31.3203 38.4119 31.4387 38.606 31.5192C38.8025 31.5997 39.0298 31.6399 39.2878 31.6399C39.5246 31.6399 39.7258 31.608 39.8915 31.544C40.0596 31.4801 40.1886 31.3913 40.2786 31.2777C40.3685 31.1641 40.4147 31.0339 40.4171 30.8871C40.4147 30.7498 40.3721 30.6326 40.2892 30.5355C40.2064 30.4361 40.0785 30.3509 39.9057 30.2798C39.7353 30.2064 39.5175 30.139 39.2523 30.0774L38.4924 29.8999C37.8626 29.7554 37.3667 29.5223 37.0044 29.2003C36.6422 28.8759 36.4623 28.438 36.4647 27.8864C36.4623 27.4366 36.583 27.0424 36.8269 26.7038C37.0707 26.3653 37.4081 26.1013 37.839 25.9119C38.2698 25.7225 38.7611 25.6278 39.3127 25.6278C39.8761 25.6278 40.365 25.7237 40.7793 25.9155C41.196 26.1049 41.5191 26.3712 41.7488 26.7145C41.9784 27.0578 42.0956 27.4555 42.1003 27.9077H40.4171Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3318_3530">
<rect width="59" height="59" rx="9" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -0,0 +1,7 @@
<svg width="59" height="59" viewBox="0 0 59 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="57" height="57" rx="28.5" fill="#24362D"/>
<rect x="1" y="1" width="57" height="57" rx="28.5" stroke="#1BD96A" stroke-width="2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.1302 25.62C41.6662 23.647 41.7024 21.5114 41.1321 19.395C39.4083 12.9952 32.7953 9.197 26.3621 10.9117C21.3966 12.2354 17.9886 16.4565 17.4851 21.2564H19.5227C20.0074 17.3593 22.805 13.9562 26.8524 12.8771C31.8546 11.5435 36.9877 14.2346 38.7778 18.9553L36.7948 19.4836C35.8823 17.2876 34.0438 15.6774 31.8664 14.9916L31.5022 17.0466C33.2212 17.678 34.6155 19.1016 35.1256 20.9958C35.8691 23.7562 34.4847 26.5847 31.9728 27.7579L32.5153 29.7731C35.8169 28.416 37.8004 24.9984 37.3312 21.4753L39.3067 20.9489C39.5185 22.2947 39.4451 23.6325 39.1251 24.8939L41.1302 25.62Z" fill="#1BD96A"/>
<path d="M32.6057 34.0883C26.172 35.803 19.5595 32.0048 17.8353 25.605C17.6343 24.8583 17.5087 24.1092 17.4536 23.3653H19.4898C19.5411 23.9236 19.6404 24.4851 19.7914 25.0448C19.9495 25.6312 20.1571 26.1914 20.4084 26.722L22.2243 25.6368C22.0714 25.2853 21.9424 24.9187 21.8399 24.5376C20.7086 20.3377 23.2139 16.0205 27.436 14.8951C28.2359 14.6818 29.0406 14.5988 29.8265 14.6321L29.4623 16.6876C28.97 16.6899 28.4703 16.7541 27.9724 16.8868C24.8563 17.7179 23.0069 20.9039 23.8417 24.0042C23.8935 24.195 23.9537 24.3811 24.0224 24.562L26.3447 23.1741L25.6449 21.3258L27.8378 19.0819L30.6095 18.4871L31.4119 19.471L30.1343 20.76L29.0199 21.1092L28.2237 21.9244L28.6138 23.0058C28.6138 23.0058 29.4039 23.842 29.4053 23.8425L30.5215 23.5467L31.3159 22.6777L33.0504 22.1306L33.5671 23.2884L31.7775 25.4742L28.7785 26.4201L27.4336 24.9286L25.0892 26.3297C26.2911 27.6928 28.0981 28.4498 29.9714 28.2918L30.514 30.307C27.7183 30.6754 24.9876 29.5227 23.2911 27.405L21.4836 28.4849C23.8652 31.6016 27.987 33.1457 32.0232 32.0699C34.9285 31.2955 37.1896 29.3231 38.4141 26.8303L40.4206 27.5568C38.967 30.6637 36.1929 33.1321 32.6057 34.0883Z" fill="#1BD96A"/>
<path d="M17.9815 41.4077C17.9579 41.1473 17.8525 40.9448 17.6655 40.8004C17.4808 40.6536 17.2169 40.5803 16.8736 40.5803C16.6463 40.5803 16.4569 40.6098 16.3054 40.669C16.1539 40.7282 16.0402 40.8099 15.9645 40.9141C15.8887 41.0159 15.8497 41.133 15.8473 41.2656C15.8426 41.3745 15.8639 41.4704 15.9112 41.5533C15.9609 41.6361 16.032 41.7095 16.1243 41.7734C16.219 41.835 16.3326 41.8894 16.4652 41.9368C16.5978 41.9841 16.7469 42.0256 16.9126 42.0611L17.5376 42.2031C17.8975 42.2812 18.2147 42.3854 18.4893 42.5156C18.7663 42.6458 18.9983 42.8009 19.1854 42.9808C19.3748 43.1607 19.518 43.3679 19.6151 43.6023C19.7121 43.8366 19.7618 44.0994 19.7642 44.3906C19.7618 44.8499 19.6458 45.2441 19.4162 45.5732C19.1866 45.9022 18.8563 46.1544 18.4254 46.3295C17.9969 46.5047 17.4796 46.5923 16.8736 46.5923C16.2652 46.5923 15.7348 46.5012 15.2827 46.3189C14.8305 46.1366 14.4789 45.8596 14.228 45.4879C13.977 45.1162 13.848 44.6463 13.8409 44.0781H15.5241C15.5384 44.3125 15.6011 44.5078 15.7124 44.6641C15.8236 44.8203 15.9763 44.9387 16.1705 45.0192C16.367 45.0997 16.5942 45.1399 16.8523 45.1399C17.089 45.1399 17.2902 45.108 17.456 45.044C17.6241 44.9801 17.7531 44.8913 17.843 44.7777C17.933 44.6641 17.9792 44.5339 17.9815 44.3871C17.9792 44.2498 17.9366 44.1326 17.8537 44.0355C17.7708 43.9361 17.643 43.8509 17.4702 43.7798C17.2997 43.7064 17.0819 43.639 16.8168 43.5774L16.0568 43.3999C15.4271 43.2554 14.9311 43.0223 14.5689 42.7003C14.2067 42.3759 14.0268 41.938 14.0291 41.3864C14.0268 40.9366 14.1475 40.5424 14.3913 40.2038C14.6352 39.8653 14.9725 39.6013 15.4034 39.4119C15.8343 39.2225 16.3255 39.1278 16.8771 39.1278C17.4406 39.1278 17.9295 39.2237 18.3438 39.4155C18.7604 39.6049 19.0836 39.8712 19.3132 40.2145C19.5429 40.5578 19.66 40.9555 19.6648 41.4077H17.9815ZM20.4141 40.6548V39.2273H26.5611V40.6548H24.3558V46.5H22.6229V40.6548H20.4141ZM28.0845 46.5H26.1953L28.6491 39.2273H30.9893L33.4432 46.5H31.554L29.8459 41.0597H29.7891L28.0845 46.5ZM27.8324 43.6378H31.7812V44.973H27.8324V43.6378ZM34.2262 46.5V39.2273H39.1907V40.6548H35.984V42.1463H38.8746V43.5774H35.984V46.5H34.2262ZM40.0758 46.5V39.2273H45.0403V40.6548H41.8336V42.1463H44.7243V43.5774H41.8336V46.5H40.0758Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>

Before

Width:  |  Height:  |  Size: 342 B

After

Width:  |  Height:  |  Size: 326 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 244 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 271 B

View File

@ -367,35 +367,6 @@
Other
*/
// Here lies 𝖄𝖊 𝕸𝖆𝖗𝖌𝖎𝖓 𝕸𝖆𝖌𝖎𝖈
// which allows to have just one wrapper div
.iconified-input {
align-items: center;
display: inline-flex;
position: relative;
input {
padding-left: 2.25rem;
width: 100%;
}
&:focus-within svg {
color: var(--color-button-text-active);
opacity: 1;
}
svg {
position: absolute;
left: 0.75rem;
height: 1.25rem;
width: 1.25rem;
z-index: 1;
color: var(--color-button-text);
opacity: 0.6;
}
}
.text-link {
color: var(--color-link);
@ -691,122 +662,6 @@ tr.button-transparent {
}
}
.multiselect {
&.raised-multiselect {
.multiselect__tags,
.multiselect__content-wrapper,
.multiselect__spinner {
background-color: var(--color-raised-bg);
box-shadow: none;
}
}
color: var(--color-text) !important;
outline: none !important;
input {
background: transparent;
box-shadow: none;
}
input::placeholder {
color: var(--color-text);
}
.multiselect__tags {
border-radius: var(--size-rounded-sm);
background: var(--color-dropdown-bg);
box-shadow: var(--shadow-inset-sm);
border: none;
cursor: pointer;
padding-left: 7px;
padding-top: 10px;
outline: 2px solid transparent;
transition: background-color 0.1s ease-in-out;
&:active {
background: var(--color-button-bg-hover);
.multiselect__spinner {
background: var(--color-button-bg-hover);
}
}
.multiselect__single {
background: transparent;
}
.multiselect__tag {
border-radius: var(--size-rounded-sm);
color: var(--color-text-dark);
background: transparent;
border: 2px solid var(--color-brand);
}
.multiselect__tag-icon {
background: transparent;
&:after {
color: var(--color-text-dark);
}
}
.multiselect__placeholder {
color: var(--color-button-text);
margin-left: 8px;
margin-bottom: 8px;
opacity: 0.6;
font-size: 16px;
line-height: 16px;
}
}
.multiselect__content-wrapper {
background: var(--color-dropdown-bg);
border: none;
overflow-x: hidden;
border-bottom-left-radius: var(--size-rounded-sm);
border-bottom-right-radius: var(--size-rounded-sm);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
outline: 2px solid transparent;
.multiselect__element {
.multiselect__option {
outline: 1px solid transparent;
}
.multiselect__option--highlight {
background: var(--color-button-bg-active);
color: var(--color-text-dark);
}
.multiselect__option--selected {
background: var(--color-brand);
font-weight: bold;
color: var(--color-brand-inverted);
}
}
}
.multiselect__spinner {
background: var(--color-dropdown-bg);
&:active {
background: var(--color-button-bg-hover);
}
}
&.multiselect--disabled {
background: none;
.multiselect__current,
.multiselect__select {
background: none;
}
}
}
.switch {
-webkit-appearance: none;
-moz-appearance: none;
@ -916,9 +771,9 @@ tr.button-transparent {
}
.card-divider {
background-color: var(--color-divider);
background-color: var(--color-button-bg);
border: none;
color: var(--color-divider);
color: var(--color-button-bg);
height: 1px;
margin: var(--spacing-card-bg) 0;
}
@ -1298,7 +1153,7 @@ svg.inline-svg {
.tag-list__item {
background-color: var(--color-button-bg);
padding: var(--gap-2) var(--gap-6);
padding: var(--gap-4) var(--gap-8);
border-radius: var(--radius-max);
font-weight: var(--weight-bold);
font-size: var(--text-14);
@ -1415,6 +1270,10 @@ svg.inline-svg {
width: var(--icon-16);
height: var(--icon-16);
}
> svg:first-child {
flex-shrink: 0;
}
}
.links-list {
@ -1442,23 +1301,4 @@ svg.inline-svg {
font-size: var(--text-14);
}
}
.icon {
--_size: 1rem;
width: var(--_size, var(--icon-16)) !important;
height: var(--_size, var(--icon-16)) !important;
border: 1px solid var(--color-button-border);
&[data-size="32"] {
--_size: var(--icon-32);
}
&[data-shape="circle"] {
border-radius: var(--radius-max) !important;
}
&[data-shape="square"] {
border-radius: calc(2.25 * (var(--_size) / 16)) !important;
}
}
}

View File

@ -51,7 +51,6 @@ html {
--color-text-inverted: initial !important;
--color-bg-inverted: initial !important;
--color-brand-green: initial !important;
--color-brand: var(--color-green) !important;
--color-brand-inverted: initial !important;
@ -141,8 +140,7 @@ html {
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-brand-green: #00af5c;
--color-brand: var(--color-brand-green);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(0, 175, 92, 0.25);
--color-brand-shadow: rgba(0, 175, 92, 0.7);
--color-brand-inverted: #ffffff;
@ -181,7 +179,11 @@ html {
--color-link-hover: #1a76e7;
--color-link-active: #146fd7;
--color-red-bg: rgba(204, 35, 69, 0.1);
--color-red-bg: rgba(203, 34, 69, 0.1);
--color-orange-bg: rgba(224, 131, 37, 0.1);
--color-green-bg: rgba(0, 175, 92, 0.1);
--color-blue-bg: rgba(31, 104, 192, 0.1);
--color-purple-bg: rgba(142, 50, 243, 0.1);
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
@ -273,10 +275,13 @@ html {
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-red-bg: rgba(255, 74, 110, 0.2);
--color-red-bg: rgba(255, 73, 110, 0.2);
--color-orange-bg: rgba(255, 163, 71, 0.2);
--color-green-bg: rgba(27, 217, 106, 0.2);
--color-blue-bg: rgba(79, 156, 255, 0.2);
--color-purple-bg: rgba(199, 138, 255, 0.2);
--color-brand-green: #1bd96a;
--color-brand: var(--color-brand-green);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);
--color-brand-inverted: #000;
@ -420,6 +425,13 @@ html {
--color-blue: rgb(9, 159, 239);
--color-purple: rgb(139, 129, 230);
--color-gray: #718096;
--color-red-highlight: rgba(232, 32, 13, 0.25);
--color-orange-highlight: rgba(232, 141, 13, 0.25);
--color-green-highlight: rgba(60, 219, 54, 0.25);
--color-blue-highlight: rgba(9, 159, 239, 0.25);
--color-purple-highlight: rgba(139, 129, 230, 0.25);
--color-gray-highlight: rgba(113, 128, 150, 0.25);
}
body {

View File

@ -64,10 +64,6 @@
}
}
.site-header {
margin: 0 0.75rem;
}
@media (min-width: 1024px) {
.full-page {
margin: 0 auto;
@ -84,14 +80,14 @@
"sidebar content" auto
"info content" auto
"dummy content" 1fr
/ 20rem 1fr;
/ 18.75rem 1fr;
&.alt-layout {
grid-template:
"content sidebar" auto
"content info" auto
"content dummy" 1fr
/ 1fr 20rem;
/ 1fr 18.75rem;
}
&.no-sidebar {
@ -110,11 +106,59 @@
}
.normal-page__sidebar {
min-width: 20rem;
width: 20rem;
min-width: 18.75rem;
width: 18.75rem;
}
.normal-page__content {
max-width: calc(60rem - 0.75rem);
max-width: calc(80rem - 18.75rem - 0.75rem);
//overflow-x: hidden;
}
}
.new-page {
display: grid;
margin: 0 auto;
max-width: 80rem;
column-gap: 0.75rem;
padding: 0 0.75rem;
grid-template:
"header"
"content"
"sidebar"
/ 100%;
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
"header header" auto
"content sidebar" auto
"content dummy" 1fr
/ 1fr 18.75rem;
&.alt-layout {
grid-template:
"header header" auto
"sidebar content" auto
"dummy content" 1fr
/ 18.75rem 1fr;
}
}
.normal-page__sidebar {
min-width: 18.75rem;
width: 18.75rem;
}
}
.normal-page__sidebar {
grid-area: sidebar;
}
.normal-page__content {
grid-area: content;
max-width: calc(80rem - 18.75rem - 0.75rem);
//overflow-x: hidden;
}
}

View File

@ -1,7 +1,3 @@
.hidden {
display: none !important;
}
body {
overflow-y: scroll;
overflow-x: hidden;

View File

@ -0,0 +1,80 @@
<template>
<div>
<ButtonStyled v-if="!!slots.title" :type="type">
<button class="!w-full" @click="() => (isOpen ? close() : open())">
<slot name="title" /><DropdownIcon
class="ml-auto size-5 transition-transform duration-300"
:class="{ 'rotate-180': isOpen }"
/>
</button>
</ButtonStyled>
<div class="accordion-content" :class="{ open: isOpen }">
<div>
<div :class="{ 'mt-2': !!slots.title }" v-bind="$attrs" :inert="!isOpen">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DropdownIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = withDefaults(
defineProps<{
openByDefault?: boolean;
type?: "standard" | "outlined" | "transparent";
}>(),
{
type: "standard",
openByDefault: false,
},
);
const isOpen = ref(props.openByDefault);
const emit = defineEmits(["onOpen", "onClose"]);
const slots = useSlots();
function open() {
isOpen.value = true;
emit("onOpen");
}
function close() {
isOpen.value = false;
emit("onClose");
}
defineExpose({
open,
close,
isOpen,
});
defineOptions({
inheritAttrs: false,
});
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<div class="ad-parent mb-3 flex w-full justify-center rounded-2xl bg-bg-raised">
<div class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 p-6">
<p class="m-0 text-2xl font-bold text-contrast">90% of ad revenue goes to creators</p>
<nuxt-link to="/plus" class="mt-auto items-center gap-1 text-purple hover:underline">
<span>
Support creators and Modrinth ad-free with
<span class="font-bold">Modrinth+</span>
</span>
<ChevronRightIcon class="relative top-[3px] h-5 w-5" />
</nuxt-link>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon } from "@modrinth/assets";
</script>
<style lang="scss" scoped>
@media (max-width: 1024px) {
.ad-parent {
display: none;
}
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<div class="accordion-wrapper">
<div class="accordion-content">
<div>
<div class="content-container" v-bind="$attrs">
<slot />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content:has(* .content-container:empty) {
grid-template-rows: 0fr;
}
.accordion-content > div {
overflow: hidden;
}
.accordion-wrapper:has(* .content-container:empty) {
display: contents;
}
</style>

View File

@ -1,44 +1,19 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
<OmorphiaAvatar
:src="src"
:alt="alt"
:size="size"
:circle="circle"
:no-shadow="noShadow"
:loading="loading"
@load="updatePixelated"
:raised="raised"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 104 104"
aria-hidden="true"
>
<path fill="none" d="M0 0h103.4v103.4H0z" />
<path
fill="none"
stroke="#9a9a9a"
stroke-width="5"
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
/>
</svg>
</template>
<script setup>
const pixelated = ref(false);
const img = ref(null);
import { Avatar as OmorphiaAvatar } from "@modrinth/ui";
defineProps({
const props = defineProps({
src: {
type: String,
default: null,
@ -49,10 +24,7 @@ defineProps({
},
size: {
type: String,
default: "sm",
validator(value) {
return ["xxs", "xs", "sm", "md", "lg"].includes(value);
},
default: "2rem",
},
circle: {
type: Boolean,
@ -71,69 +43,4 @@ defineProps({
default: false,
},
});
function updatePixelated() {
if (img.value && img.value.naturalWidth && img.value.naturalWidth <= 96) {
pixelated.value = true;
} else {
pixelated.value = false;
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--size-rounded-icon);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
min-height: var(--size);
min-width: var(--size);
max-height: var(--size);
max-width: var(--size);
background-color: var(--color-button-bg);
object-fit: contain;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-md {
--size: 6rem;
border-radius: var(--size-rounded-lg);
}
&.size-lg {
--size: 9rem;
border-radius: var(--size-rounded-lg);
}
&.circle {
border-radius: 50%;
}
&.no-shadow {
box-shadow: none;
}
&.pixelated {
image-rendering: pixelated;
}
&.raised {
background-color: var(--color-raised-bg);
}
}
</style>

View File

@ -1,5 +1,9 @@
<template>
<span :class="'badge flex items-center gap-1 ' + color + ' type--' + type">
<span
:class="
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
"
>
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<!-- User roles -->
@ -9,10 +13,11 @@
<template v-else-if="type === 'plus'"><PlusIcon /> Modrinth Plus</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</template>
<template v-else-if="type === 'approved'"><GlobeIcon /> Public</template>
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</template>
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'unlisted' || type === 'withheld'"
><LinkIcon /> Unlisted</template
>
<template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
@ -36,12 +41,12 @@
</template>
<script setup>
import { GlobeIcon, LinkIcon } from "@modrinth/assets";
import ModrinthIcon from "~/assets/images/logo.svg?component";
import PlusIcon from "~/assets/images/utils/plus.svg?component";
import ModeratorIcon from "~/assets/images/sidebar/admin.svg?component";
import CreatorIcon from "~/assets/images/utils/box.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import EyeOffIcon from "~/assets/images/utils/eye-off.svg?component";
import DraftIcon from "~/assets/images/utils/file-text.svg?component";
import CrossIcon from "~/assets/images/utils/x.svg?component";
import ArchiveIcon from "~/assets/images/utils/archive.svg?component";
@ -65,12 +70,6 @@ defineProps({
<style lang="scss" scoped>
.badge {
font-weight: bold;
width: fit-content;
--badge-color: var(--color-gray);
color: var(--badge-color);
white-space: nowrap;
.circle {
width: 0.5rem;
height: 0.5rem;
@ -110,7 +109,6 @@ defineProps({
}
&.type--creator,
&.type--approved,
&.blue {
--badge-color: var(--color-blue);
}
@ -122,8 +120,9 @@ defineProps({
}
&.type--private,
&.type--approved,
&.gray {
--badge-color: var(--color-gray);
--badge-color: var(--color-secondary);
}
}
</style>

View File

@ -36,23 +36,28 @@ export default {
props: {
label: {
type: String,
required: false,
default: "",
},
disabled: {
type: Boolean,
required: false,
default: false,
},
description: {
type: String,
required: false,
default: null,
},
modelValue: Boolean,
clickEvent: {
type: Function,
required: false,
default: () => {},
},
collapsingToggleStyle: {
type: Boolean,
required: false,
default: false,
},
},

View File

@ -1,47 +1,59 @@
<template>
<Modal ref="modal" header="Create a collection">
<div class="universal-modal modal-creation universal-labels">
<div class="markdown-body">
<p>
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
</p>
<NewModal ref="modal" header="Creating a collection">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description">This appears on your collection's page.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="create">
<CheckIcon />
Continue
</Button>
<p class="m-0 max-w-[30rem]">
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : "no" }}
{{ projectIds.length !== 1 ? "projects" : "project" }}.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="create">
<PlusIcon />
Create collection
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from "@modrinth/assets";
import { Modal, Button } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
import { NewModal, ButtonStyled } from "@modrinth/ui";
const router = useNativeRouter();
@ -86,10 +98,10 @@ async function create() {
}
stopLoading();
}
function show() {
function show(event) {
name.value = "";
description.value = "";
modal.value.show();
modal.value.show(event);
}
defineExpose({

View File

@ -1,241 +1,215 @@
<template>
<Modal ref="modal" header="Create a project">
<div class="modal-creation universal-labels">
<div class="markdown-body">
<p>New projects are created as drafts and can be found under your profile page.</p>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="updatedName()"
/>
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<NewModal ref="modal" header="Creating a project">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="slug"
v-model="slug"
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="manualSlug = true"
@input="updatedName()"
/>
</div>
<label for="visibility">
<span class="label__title">Visibility<span class="required">*</span></span>
<span class="label__description">
The visibility of your project after it has been approved.
</span>
</label>
<multiselect
id="visibility"
v-model="visibility"
:options="visibilities"
track-by="actual"
label="display"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose visibility.."
open-direction="bottom"
/>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description"
>This appears in search and on the sidebar of your project's page.</span
>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="push-right input-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="createProject">
<CheckIcon />
Continue
</button>
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Visibility
<span class="text-brand-red">*</span>
</span>
<span> The visibility of your project after it has been approved. </span>
</label>
<DropdownSelect
id="visibility"
v-model="visibility"
:options="visibilities"
:display-name="(x) => x.display"
name="Visibility"
/>
</div>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span> A sentence or two that describes your project. </span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createProject">
<PlusIcon />
Create project
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="cancel">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script>
import { Multiselect } from "vue-multiselect";
import CrossIcon from "~/assets/images/utils/x.svg?component";
import CheckIcon from "~/assets/images/utils/right-arrow.svg?component";
import Modal from "~/components/ui/Modal.vue";
<script setup>
import { NewModal, ButtonStyled, DropdownSelect } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
export default {
components: {
CrossIcon,
CheckIcon,
Modal,
Multiselect,
const router = useRouter();
const app = useNuxtApp();
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
},
props: {
organizationId: {
type: String,
required: false,
default: null,
},
});
const modal = ref();
const name = ref("");
const slug = ref("");
const description = ref("");
const manualSlug = ref(false);
const visibilities = ref([
{
actual: "approved",
display: "Public",
},
setup() {
const tags = useTags();
return { tags };
{
actual: "unlisted",
display: "Unlisted",
},
data() {
return {
name: "",
slug: "",
description: "",
manualSlug: false,
visibilities: [
{
actual: "approved",
display: "Public",
},
{
actual: "private",
display: "Private",
},
{
actual: "unlisted",
display: "Unlisted",
},
],
visibility: {
actual: "approved",
display: "Public",
},
};
{
actual: "private",
display: "Private",
},
methods: {
cancel() {
this.$refs.modal.hide();
},
async createProject() {
startLoading();
]);
const visibility = ref({
actual: "approved",
display: "Public",
});
const formData = new FormData();
const auth = await useAuth();
const projectData = {
title: this.name.trim(),
project_type: "mod",
slug: this.slug,
description: this.description.trim(),
body: "",
requested_status: this.visibility.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
is_draft: true,
};
if (this.organizationId) {
projectData.organization_id = this.organizationId;
}
formData.append("data", JSON.stringify(projectData));
try {
await useBaseFetch("project", {
method: "POST",
body: formData,
headers: {
"Content-Disposition": formData,
},
});
this.$refs.modal.hide();
await this.$router.push({
name: "type-id",
params: {
type: "project",
id: this.slug,
},
});
} catch (err) {
this.$notify({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
},
show() {
this.projectType = this.tags.projectTypes[0].display;
this.name = "";
this.slug = "";
this.description = "";
this.manualSlug = false;
this.$refs.modal.show();
},
updatedName() {
if (!this.manualSlug) {
this.slug = this.name
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
},
},
const cancel = () => {
modal.value.hide();
};
</script>
<style scoped lang="scss">
.modal-creation {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
async function createProject() {
startLoading();
.markdown-body {
margin-bottom: 0.5rem;
const formData = new FormData();
const auth = await useAuth();
const projectData = {
title: name.value.trim(),
project_type: "mod",
slug: slug.value,
description: description.value.trim(),
body: "",
requested_status: visibility.value.actual,
initial_versions: [],
team_members: [
{
user_id: auth.value.user.id,
name: auth.value.user.username,
role: "Owner",
},
],
categories: [],
client_side: "required",
server_side: "required",
license_id: "LicenseRef-Unknown",
is_draft: true,
};
if (props.organizationId) {
projectData.organization_id = props.organizationId;
}
input {
width: 20rem;
max-width: 100%;
}
formData.append("data", JSON.stringify(projectData));
.text-input-wrapper {
width: 100%;
}
try {
await useBaseFetch("project", {
method: "POST",
body: formData,
headers: {
"Content-Disposition": formData,
},
});
textarea {
min-height: 5rem;
modal.value.hide();
await router.push({
name: "type-id",
params: {
type: "project",
id: slug.value,
},
});
} catch (err) {
app.$notify({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
}
.input-group {
margin-top: var(--spacing-card-md);
function show(event) {
name.value = "";
slug.value = "";
description.value = "";
manualSlug.value = false;
modal.value.show(event);
}
defineExpose({
show,
});
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(" ", "-")
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, "")
.replaceAll(/--+/gm, "-");
}
}
</style>
</script>

View File

@ -306,8 +306,6 @@
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
position="top"
direction="left"
:options="[
{
id: 'withhold',

View File

@ -4,7 +4,7 @@
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
@ -51,8 +51,6 @@ const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
function pickLink() {
console.log("link is picking");
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === "" ? undefined : x.href) === route.path[props.query],
@ -68,10 +66,12 @@ function pickLink() {
}
}
const linkElements = ref();
const rowLinkElements = ref();
function startAnimation() {
const el = linkElements.value[activeIndex.value].$el;
const el = rowLinkElements.value[activeIndex.value].$el;
if (!el || !el.offsetParent) return;
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop + el.offsetHeight;

View File

@ -1,63 +1,84 @@
<template>
<nav class="relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold">
<nav
class="experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{ 'text-brand': activeIndex === index }"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-brand': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span>{{ link.label }}</span>
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<div
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-brand p-1 transition-all"
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'}`"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 0.25,
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
<script setup lang="ts">
const route = useNativeRoute();
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
});
interface Tab {
label: string;
href: string;
shown?: boolean;
icon?: string;
subpages?: string[];
}
const sliderPositionX = ref(0);
const sliderPositionY = ref(0);
const selectedElementWidth = ref(0);
const props = defineProps<{
links: Tab[];
query?: string;
}>();
const sliderLeft = ref(4);
const sliderTop = ref(4);
const sliderRight = ref(4);
const sliderBottom = ref(4);
const activeIndex = ref(-1);
const oldIndex = ref(-1);
const subpageSelected = ref(false);
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
);
const positionToMoveX = computed(() => `${sliderPositionX.value}px`);
const positionToMoveY = computed(() => `${sliderPositionY.value}px`);
const sliderWidth = computed(() => `${selectedElementWidth.value}px`);
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
const sliderTopPx = computed(() => `${sliderTop.value}px`);
const sliderRightPx = computed(() => `${sliderRight.value}px`);
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
function pickLink() {
let index = -1;
subpageSelected.value = false;
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
if (decodeURIComponent(route.path).includes(filteredLinks.value[i].href)) {
const link = filteredLinks.value[i];
if (decodeURIComponent(route.path) === link.href) {
index = i;
break;
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i;
subpageSelected.value = true;
break;
}
}
activeIndex.value = index;
@ -66,19 +87,57 @@ function pickLink() {
startAnimation();
} else {
oldIndex.value = -1;
sliderPositionX.value = 0;
selectedElementWidth.value = 0;
sliderLeft.value = 0;
sliderRight.value = 0;
}
}
const linkElements = ref();
const tabLinkElements = ref();
function startAnimation() {
const el = linkElements.value[activeIndex.value].$el;
const el = tabLinkElements.value[activeIndex.value].$el;
sliderPositionX.value = el.offsetLeft;
sliderPositionY.value = el.offsetTop;
selectedElementWidth.value = el.offsetWidth;
if (!el || !el.offsetParent) return;
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
};
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left;
sliderRight.value = newValues.right;
sliderTop.value = newValues.top;
sliderBottom.value = newValues.bottom;
} else {
const delay = 200;
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left;
setTimeout(() => {
sliderRight.value = newValues.right;
}, delay);
} else {
sliderRight.value = newValues.right;
setTimeout(() => {
sliderLeft.value = newValues.left;
}, delay);
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top;
setTimeout(() => {
sliderBottom.value = newValues.bottom;
}, delay);
} else {
sliderBottom.value = newValues.bottom;
setTimeout(() => {
sliderTop.value = newValues.top;
}, delay);
}
}
}
onMounted(() => {
@ -92,3 +151,11 @@ onUnmounted(() => {
watch(route, () => pickLink());
</script>
<style scoped>
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@ -1,61 +1,78 @@
<template>
<Modal ref="modal" header="Create an organization">
<div class="universal-modal modal-creation universal-labels">
<div class="markdown-body">
<p>
Organizations can be found under your profile page. You will be set as its owner, but you
can invite other members and transfer ownership at any time.
</p>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="updateSlug()"
/>
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<NewModal ref="modal" header="Creating an organization">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
Name
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="slug"
v-model="slug"
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter organization name...`"
autocomplete="off"
@input="manualSlug = true"
@input="updateSlug()"
/>
</div>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description">This will appear on your organization's page.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
URL
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/organization/</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
</div>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="createProject">
<CheckIcon />
Continue
</Button>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span>A sentence or two that describes your organization.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
</div>
<p class="m-0 max-w-[30rem]">
You will be the owner of this organization, but you can invite other members and transfer
ownership at any time.
</p>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="createOrganization">
<PlusIcon />
Create organization
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from "@modrinth/assets";
import { Modal, Button } from "@modrinth/ui";
import { XIcon, PlusIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter();
@ -66,7 +83,7 @@ const manualSlug = ref(false);
const modal = ref();
async function createProject() {
async function createOrganization() {
startLoading();
try {
const value = {
@ -95,10 +112,10 @@ async function createProject() {
}
stopLoading();
}
function show() {
function show(event) {
name.value = "";
description.value = "";
modal.value.show();
modal.value.show(event);
}
function updateSlug() {

View File

@ -118,6 +118,7 @@ export default {
<style scoped lang="scss">
a {
position: relative;
color: var(--color-button-text);
box-shadow: var(--shadow-raised), var(--shadow-inset);

View File

@ -19,7 +19,7 @@
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="author-actions universal-card"
class="author-actions universal-card mb-4"
>
<div class="header__row">
<div class="header__title">
@ -93,7 +93,7 @@
</NuxtLink>
<button
v-else-if="nag.action"
class="iconified-button moderation-button"
class="btn btn-orange"
:disabled="nag.action.disabled()"
@click="nag.action.onClick"
>

View File

@ -1,92 +1,85 @@
<template>
<div
v-if="
loaderFilters.length > 1 || gameVersionFilters.length > 1 || versionTypeFilters.length > 1
"
class="card search-controls"
>
<Multiselect
v-if="loaderFilters.length > 1"
v-model="selectedLoaders"
:options="loaderFilters"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="true"
:clear-search-on-select="false"
:show-labels="false"
:allow-empty="true"
placeholder="Filter loader..."
@update:model-value="updateQuery"
/>
<Multiselect
v-if="gameVersionFilters.length > 1"
v-model="selectedGameVersions"
:options="
includeSnapshots
? gameVersionFilters.map((x) => x.version)
: gameVersionFilters.filter((it) => it.version_type === 'release').map((x) => x.version)
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:show-labels="false"
:hide-selected="true"
:selectable="() => selectedGameVersions.length <= 6"
placeholder="Filter versions..."
@update:model-value="updateQuery"
/>
<Multiselect
v-if="versionTypeFilters.length > 1"
v-model="selectedVersionTypes"
:options="versionTypeFilters"
:custom-label="(x) => $capitalizeString(x)"
:multiple="true"
:searchable="false"
:show-no-results="false"
:close-on-select="true"
:clear-search-on-select="false"
:show-labels="false"
:allow-empty="true"
placeholder="Filter channels..."
@update:model-value="updateQuery"
/>
<Checkbox
v-if="
gameVersionFilters.length > 1 &&
gameVersionFilters.some((v) => v.version_type !== 'release')
"
v-model="includeSnapshots"
label="Show all versions"
description="Show all versions"
:border="false"
@update:model-value="updateQuery"
/>
<button
title="Clear filters"
:disabled="selectedLoaders.length === 0 && selectedGameVersions.length === 0"
class="iconified-button"
@click="
() => {
selectedLoaders = [];
selectedGameVersions = [];
selectedVersionTypes = [];
updateQuery();
}
"
<div class="card flex-card experimental-styles-within">
<span class="text-lg font-bold text-contrast">Filter</span>
<div class="flex items-center gap-2">
<div class="iconified-input w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
placeholder="Search filters..."
autocomplete="off"
/>
</div>
<button
v-if="Object.keys(selectedFilters).length !== 0"
class="btn icon-only"
@click="clearFilters"
>
<FilterXIcon />
</button>
</div>
<div
v-for="(value, key, index) in filters"
:key="key"
:class="`border-0 border-b border-solid border-button-bg py-2 last:border-b-0`"
>
<ClearIcon />
Clear filters
</button>
<button
class="flex !w-full bg-transparent px-0 py-2 font-extrabold text-contrast transition-all active:scale-[0.98]"
@click="
() => {
filterAccordions[index].isOpen
? filterAccordions[index].close()
: filterAccordions[index].open();
}
"
>
<template v-if="key === 'gameVersion'"> Game versions </template>
<template v-else>
{{ $capitalizeString(key) }}
</template>
<DropdownIcon
class="ml-auto h-5 w-5 transition-transform"
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }"
/>
</button>
<Accordion ref="filterAccordions" :open-by-default="true">
<ScrollablePanel
:class="{ 'h-[18rem]': value.length >= 8 && key === 'gameVersion' }"
:no-max-height="key !== 'gameVersion'"
>
<div class="mr-1 flex flex-col gap-1">
<div v-for="filter in value" :key="filter" class="group flex gap-1">
<button
:class="`flex !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all active:scale-[0.98] ${selectedFilters[key]?.includes(filter) ? 'bg-brand-highlight text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg'}`"
@click="toggleFilter(key, filter)"
>
<span v-if="filter === 'release'" class="h-2 w-2 rounded-full bg-brand" />
<span v-else-if="filter === 'beta'" class="h-2 w-2 rounded-full bg-orange" />
<span v-else-if="filter === 'alpha'" class="h-2 w-2 rounded-full bg-red" />
<span class="truncate text-sm">{{ $formatCategory(filter) }}</span>
</button>
</div>
</div>
</ScrollablePanel>
<Checkbox
v-if="key === 'gameVersion'"
v-model="showSnapshots"
class="mx-2"
:label="`Show all versions`"
/>
</Accordion>
</div>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import Checkbox from "~/components/ui/Checkbox.vue";
import ClearIcon from "~/assets/images/utils/clear.svg?component";
import { DropdownIcon, FilterXIcon, SearchIcon } from "@modrinth/assets";
import { ScrollablePanel, Checkbox } from "@modrinth/ui";
import Accordion from "~/components/ui/Accordion.vue";
const props = defineProps({
versions: {
@ -98,64 +91,137 @@ const props = defineProps({
});
const emit = defineEmits(["switch-page"]);
const router = useNativeRouter();
const route = useNativeRoute();
const router = useNativeRouter();
const tags = useTags();
const tempLoaders = new Set();
let tempVersions = new Set();
const tempReleaseChannels = new Set();
const filterAccordions = ref([]);
for (const version of props.versions) {
for (const loader of version.loaders) {
tempLoaders.add(loader);
const queryFilter = ref("");
const showSnapshots = ref(false);
const filters = computed(() => {
const filters = {};
const tempLoaders = new Set();
const tempVersions = new Set();
const tempReleaseChannels = new Set();
for (const version of props.versions) {
for (const loader of version.loaders) {
tempLoaders.add(loader);
}
for (const gameVersion of version.game_versions) {
tempVersions.add(gameVersion);
}
tempReleaseChannels.add(version.version_type);
}
for (const gameVersion of version.game_versions) {
tempVersions.add(gameVersion);
if (tempReleaseChannels.size > 0) {
filters.type = Array.from(tempReleaseChannels);
}
tempReleaseChannels.add(version.version_type);
if (tempVersions.size > 0) {
const gameVersions = tags.value.gameVersions.filter((x) => tempVersions.has(x.version));
filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => x.version);
}
if (tempLoaders.size > 0) {
filters.platform = Array.from(tempLoaders);
}
const filteredObj = {};
for (const [key, value] of Object.entries(filters)) {
const filters = queryFilter.value
? value.filter((x) => x.toLowerCase().includes(queryFilter.value.toLowerCase()))
: value;
if (filters.length > 0) {
filteredObj[key] = filters;
}
}
return filteredObj;
});
const selectedFilters = ref({});
if (route.query.type) {
selectedFilters.value.type = getArrayOrString(route.query.type);
}
if (route.query.gameVersion) {
selectedFilters.value.gameVersion = getArrayOrString(route.query.gameVersion);
}
if (route.query.platform) {
selectedFilters.value.platform = getArrayOrString(route.query.platform);
}
tempVersions = Array.from(tempVersions);
async function toggleFilters(type, filters) {
for (const filter of filters) {
await toggleFilter(type, filter);
}
const loaderFilters = shallowRef(Array.from(tempLoaders));
const gameVersionFilters = shallowRef(
tags.value.gameVersions.filter((gameVer) => tempVersions.includes(gameVer.version)),
);
const versionTypeFilters = shallowRef(Array.from(tempReleaseChannels));
const includeSnapshots = ref(route.query.s === "true");
const selectedGameVersions = shallowRef(getArrayOrString(route.query.g) ?? []);
const selectedLoaders = shallowRef(getArrayOrString(route.query.l) ?? []);
const selectedVersionTypes = shallowRef(getArrayOrString(route.query.c) ?? []);
async function updateQuery() {
await router.replace({
query: {
...route.query,
l: selectedLoaders.value.length === 0 ? undefined : selectedLoaders.value,
g: selectedGameVersions.value.length === 0 ? undefined : selectedGameVersions.value,
c: selectedVersionTypes.value.length === 0 ? undefined : selectedVersionTypes.value,
s: includeSnapshots.value ? true : undefined,
type: selectedFilters.value.type,
gameVersion: selectedFilters.value.gameVersion,
platform: selectedFilters.value.platform,
},
});
emit("switch-page", 1);
}
</script>
<style lang="scss" scoped>
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
async function toggleFilter(type, filter, skipRouter) {
if (!selectedFilters.value[type]) {
selectedFilters.value[type] = [];
}
.checkbox-outer {
min-width: fit-content;
const index = selectedFilters.value[type].indexOf(filter);
if (index !== -1) {
selectedFilters.value[type].splice(index, 1);
} else {
selectedFilters.value[type].push(filter);
}
if (selectedFilters.value[type].length === 0) {
delete selectedFilters.value[type];
}
if (!skipRouter) {
await router.replace({
query: {
...route.query,
type: selectedFilters.value.type,
gameVersion: selectedFilters.value.gameVersion,
platform: selectedFilters.value.platform,
},
});
emit("switch-page", 1);
}
}
</style>
async function clearFilters() {
selectedFilters.value = {};
await router.replace({
query: {
...route.query,
type: undefined,
gameVersion: undefined,
platform: undefined,
},
});
emit("switch-page", 1);
}
defineExpose({
toggleFilter,
toggleFilters,
});
</script>

View File

@ -0,0 +1,45 @@
<template>
<div
class="grid grid-cols-[min-content_auto_min-content_min-content] items-center gap-2 rounded-2xl border-[1px] border-button-bg bg-bg p-2"
>
<VersionChannelIndicator :channel="version.version_type" />
<div class="flex min-w-0 flex-col gap-1">
<h1 class="my-0 truncate text-nowrap text-base font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<p class="m-0 truncate text-nowrap text-xs font-semibold text-secondary">
{{ version.name }}
</p>
</div>
<ButtonStyled color="brand">
<a :href="downloadUrl" class="min-w-0" @click="emit('onDownload')">
<DownloadIcon /> Download
</a>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link
:to="`/project/${props.version.project_id}/version/${props.version.id}`"
class="min-w-0"
@click="emit('onNavigate')"
>
<ExternalIcon />
</nuxt-link>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
import { DownloadIcon, ExternalIcon } from "@modrinth/assets";
const props = defineProps<{
version: Version;
}>();
const downloadUrl = computed(() => {
const primary: VersionFile = props.version.files.find((x) => x.primary) || props.version.files[0];
return primary.url;
});
const emit = defineEmits(["onDownload", "onNavigate"]);
</script>

View File

@ -19,7 +19,7 @@ export default defineComponent({
color: {
type: [String, Boolean],
default:
"repeating-linear-gradient(to right, var(--color-brand-green) 0%, var(--landing-green-label) 100%)",
"repeating-linear-gradient(to right, var(--color-green) 0%, var(--landing-green-label) 100%)",
},
},
setup(props, { slots }) {

View File

@ -154,8 +154,6 @@
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
position="top"
direction="left"
:options="
replyBody
? [

View File

@ -292,7 +292,7 @@ role-moderator {
}
.role-admin {
color: var(--color-brand-green);
color: var(--color-green);
}
.reporter-icon {

View File

@ -114,29 +114,26 @@ export const userCollectProject = async (collection, projectId) => {
export const userFollowProject = async (project) => {
const user = (await useUser()).value;
user.follows = user.follows.concat(project);
project.followers++;
if (user.follows.find((x) => x.id === project.id)) {
user.follows = user.follows.filter((x) => x.id !== project.id);
project.followers--;
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: "POST",
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: "DELETE",
});
});
});
};
} else {
user.follows = user.follows.concat(project);
project.followers++;
export const userUnfollowProject = async (project) => {
const user = (await useUser()).value;
user.follows = user.follows.filter((x) => x.id !== project.id);
project.followers--;
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: "DELETE",
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: "POST",
});
});
});
}
};
export const resendVerifyEmail = async () => {
const app = useNuxtApp();

View File

@ -82,10 +82,14 @@ export const REJECTED_PROJECT_STATUSES = ["rejected", "withheld"];
export const UNDER_REVIEW_PROJECT_STATUSES = ["processing"];
export const DRAFT_PROJECT_STATUSES = ["draft"];
export function getVersionsToDisplay(project, overrideTags) {
export function getVersionsToDisplay(project) {
return formatVersionsForDisplay(project.game_versions.slice());
}
export function formatVersionsForDisplay(gameVersions, overrideTags) {
const tags = overrideTags ?? useTags().value;
const projectVersions = project.game_versions.slice();
const inputVersions = gameVersions.slice();
const allVersions = tags.gameVersions.slice();
const allSnapshots = allVersions.filter((version) => version.version_type === "snapshot");
@ -99,17 +103,17 @@ export function getVersionsToDisplay(project, overrideTags) {
map[gameVersion.version] = index;
return map;
}, {});
projectVersions.sort((a, b) => indices[a] - indices[b]);
inputVersions.sort((a, b) => indices[a] - indices[b]);
}
const releaseVersions = projectVersions.filter((projVer) =>
const releaseVersions = inputVersions.filter((projVer) =>
allReleases.some((gameVer) => gameVer.version === projVer),
);
const latestReleaseVersionDate = Date.parse(
allReleases.find((version) => version.version === releaseVersions[0])?.date,
);
const latestSnapshot = projectVersions.find((projVer) =>
const latestSnapshot = inputVersions.find((projVer) =>
allSnapshots.some(
(gameVer) =>
gameVer.version === projVer &&
@ -140,7 +144,7 @@ export function getVersionsToDisplay(project, overrideTags) {
});
const legacyVersionsAsRanges = groupConsecutiveIndices(
projectVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)),
inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)),
allLegacy,
);
@ -149,7 +153,7 @@ export function getVersionsToDisplay(project, overrideTags) {
// show all snapshots if there's no release versions
if (releaseVersionsAsRanges.length === 0) {
const snapshotVersionsAsRanges = groupConsecutiveIndices(
projectVersions.filter((projVer) =>
inputVersions.filter((projVer) =>
allSnapshots.some((gameVer) => gameVer.version === projVer),
),
allSnapshots,
@ -159,7 +163,7 @@ export function getVersionsToDisplay(project, overrideTags) {
output = [...releaseVersionsAsRanges, ...output];
}
if (latestSnapshot) {
if (latestSnapshot && !output.includes(latestSnapshot)) {
output = [latestSnapshot, ...output];
}
return output;

File diff suppressed because it is too large Load Diff

View File

@ -173,12 +173,18 @@
"button.continue": {
"message": "Continue"
},
"button.copy-id": {
"message": "Copy ID"
},
"button.create-a-project": {
"message": "Create a project"
},
"button.edit": {
"message": "Edit"
},
"button.report": {
"message": "Report"
},
"button.save": {
"message": "Save"
},
@ -363,7 +369,7 @@
"message": "For security purposes, please enter your email on Modrinth."
},
"layout.banner.staging.description": {
"message": "The staging environment is running on a copy of the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
"message": "The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance."
},
"layout.banner.staging.title": {
"message": "Youre viewing Modrinths staging environment."
@ -440,14 +446,17 @@
"profile.button.manage-projects": {
"message": "Manage projects"
},
"profile.button.report": {
"message": "Report"
},
"profile.error.not-found": {
"message": "User not found"
},
"profile.joined-at": {
"message": "Joined {ago}"
"message": "Joined <date>{ago}</date>"
},
"profile.label.badges": {
"message": "Badges"
},
"profile.label.details": {
"message": "Details"
},
"profile.label.no-collections": {
"message": "This user has no collections!"
@ -471,10 +480,13 @@
"message": "{bio} - Download {username}'s projects on Modrinth"
},
"profile.stats.downloads": {
"message": "{count, plural, one {<stat>{count}</stat> download} other {<stat>{count}</stat> downloads}}"
"message": "{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}"
},
"profile.stats.projects": {
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
},
"profile.stats.projects-followers": {
"message": "{count, plural, one {<stat>{count}</stat> follower} other {<stat>{count}</stat> followers}} of projects"
"message": "{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}"
},
"profile.user-id": {
"message": "User ID: {id}"
@ -530,6 +542,99 @@
"project-type.shader.singular": {
"message": "Shader"
},
"project.about.compatibility.environments": {
"message": "Environments"
},
"project.about.compatibility.game.minecraftJava": {
"message": "Minecraft: Java Edition"
},
"project.about.compatibility.platforms": {
"message": "Platforms"
},
"project.about.compatibility.title": {
"message": "Compatibility"
},
"project.about.creators.owner": {
"message": "Project owner"
},
"project.about.creators.title": {
"message": "Creators"
},
"project.about.details.created": {
"message": "Created {date}"
},
"project.about.details.licensed": {
"message": "Licensed {license}"
},
"project.about.details.published": {
"message": "Published {date}"
},
"project.about.details.submitted": {
"message": "Submitted {date}"
},
"project.about.details.title": {
"message": "Details"
},
"project.about.details.updated": {
"message": "Updated {date}"
},
"project.about.links.discord": {
"message": "Join Discord server"
},
"project.about.links.donate.bmac": {
"message": "Buy Me a Coffee"
},
"project.about.links.donate.generic": {
"message": "Donate"
},
"project.about.links.donate.github": {
"message": "Sponsor on GitHub"
},
"project.about.links.donate.kofi": {
"message": "Donate on Ko-fi"
},
"project.about.links.donate.patreon": {
"message": "Donate on Patreon"
},
"project.about.links.donate.paypal": {
"message": "Donate on PayPal"
},
"project.about.links.issues": {
"message": "Report issues"
},
"project.about.links.source": {
"message": "View source"
},
"project.about.links.title": {
"message": "Links"
},
"project.about.links.wiki": {
"message": "Visit wiki"
},
"project.about.title": {
"message": "About"
},
"project.gallery.title": {
"message": "Gallery"
},
"project.moderation.title": {
"message": "Moderation"
},
"project.stats.downloads-label": {
"message": "download{count, plural, one {} other {s}}"
},
"project.stats.followers-label": {
"message": "follower{count, plural, one {} other {s}}"
},
"project.version.all-versions": {
"message": "All versions"
},
"project.version.back-to-versions": {
"message": "Back to versions"
},
"project.versions.title": {
"message": "Versions"
},
"revenue.transfers.total": {
"message": "You have withdrawn {amount} in total."
},
@ -923,6 +1028,9 @@
"settings.display.project-list.layouts.collection": {
"message": "Collection"
},
"settings.display.sidebar.Left-aligned-search-sidebar.title": {
"message": "Left-aligned search sidebar"
},
"settings.display.sidebar.advanced-rendering.description": {
"message": "Enables advanced rendering such as blur effects that may cause performance issues without hardware-accelerated rendering."
},
@ -941,17 +1049,14 @@
"settings.display.sidebar.hide-app-promos.title": {
"message": "Hide Modrinth App promotions"
},
"settings.display.sidebar.right-aligned-project-sidebar.description": {
"message": "Aligns the project details sidebar to the right of the page's content."
"settings.display.sidebar.left-aligned-project-sidebar.description": {
"message": "Aligns the project details sidebar to the left of the page's content."
},
"settings.display.sidebar.right-aligned-project-sidebar.title": {
"message": "Right-aligned project sidebar"
"settings.display.sidebar.left-aligned-project-sidebar.title": {
"message": "Left-aligned project sidebar"
},
"settings.display.sidebar.right-aligned-search-sidebar.description": {
"message": "Aligns the search filters sidebar to the right of the search results."
},
"settings.display.sidebar.right-aligned-search-sidebar.title": {
"message": "Right-aligned search sidebar"
"settings.display.sidebar.left-aligned-search-sidebar.description": {
"message": "Aligns the search filters sidebar to the left of the search results."
},
"settings.display.theme.dark": {
"message": "Dark"

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,5 @@
<template>
<div class="content">
<VersionFilterControl :versions="props.versions" />
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div class="card changelog-wrapper">
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
@ -60,17 +52,23 @@
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
class="mb-2 flex justify-end"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder />
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
</div>
</template>
<script setup>
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import { Pagination } from "@modrinth/ui";
import { DownloadIcon } from "@modrinth/assets";
import { renderHighlightedString } from "~/helpers/highlight.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import Pagination from "~/components/ui/Pagination.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({
project: {
@ -106,11 +104,11 @@ useSeoMeta({
const router = useNativeRouter();
const route = useNativeRoute();
const currentPage = ref(Number(route.query.p ?? 1));
const currentPage = ref(Number(route.query.page ?? 1));
const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? [];
return props.versions.filter(
(projectVersion) =>
@ -131,7 +129,7 @@ function switchPage(page) {
router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
@ -250,4 +248,8 @@ function switchPage(page) {
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@ -794,4 +794,8 @@ export default defineNuxtComponent({
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@ -1,23 +1,526 @@
<template>
<div
v-if="project.body"
class="markdown-body card"
v-html="renderHighlightedString(project.body || '')"
/>
<NewModal ref="modalLicense" :header="project.license.name ? project.license.name : 'License'">
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" no-shadow />
<span class="text-lg font-extrabold text-contrast">
{{ project.license.name ? project.license.name : "License" }}
</span>
</template>
<div
class="markdown-body"
v-html="
renderString(licenseText).isEmpty ? 'Loading license text...' : renderString(licenseText)
"
/>
</NewModal>
<section class="normal-page__content">
<div
v-if="project.body"
class="markdown-body card"
v-html="renderHighlightedString(project.body || '')"
/>
</section>
<div class="normal-page__sidebar">
<AdPlaceholder />
<div v-if="versions.length > 0" class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(compatibilityMessages.title) }}</h2>
<section>
<h3>{{ formatMessage(compatibilityMessages.minecraftJava) }}</h3>
<div class="tag-list">
<div
v-for="version in getVersionsToDisplay(project)"
:key="`version-tag-${version}`"
class="tag-list__item"
>
{{ version }}
</div>
</div>
</section>
<section>
<h3>{{ formatMessage(compatibilityMessages.platforms) }}</h3>
<div class="tag-list">
<div
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:class="`tag-list__item`"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</div>
</div>
</section>
<section>
<h3>{{ formatMessage(compatibilityMessages.environments) }}</h3>
<div class="status-list">
<div class="status-list__item status-list__item--color-green">
<CheckIcon />
Singleplayer
</div>
<div
v-if="project.client_side !== 'unsupported' && project.server_side !== 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Client and server
</div>
<div
v-if="project.client_side === 'required' && project.server_side === 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Client
</div>
<div
v-if="project.server_side === 'required' && project.client_side === 'unsupported'"
class="status-list__item status-list__item--color-green"
>
<CheckIcon />
Server
</div>
<div
v-if="
project.client_side === 'optional' ||
(project.client_side === 'required' && project.server_side === 'optional')
"
class="status-list__item status-list__item--color-orange"
>
<CheckIcon />
Client <span class="text-sm">(Limited functionality)</span>
</div>
<div
v-if="
project.server_side === 'optional' ||
(project.server_side === 'required' && project.client_side === 'optional')
"
class="status-list__item status-list__item--color-orange"
>
<CheckIcon />
Server <span class="text-sm">(Limited functionality)</span>
</div>
<div
v-if="project.client_side === 'unsupported'"
class="status-list__item status-list__item--color-red"
>
<XIcon />
Client
</div>
<div
v-if="project.server_side === 'unsupported'"
class="status-list__item status-list__item--color-red"
>
<XIcon />
Server
</div>
</div>
</section>
</div>
<div
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0
"
class="card flex-card experimental-styles-within"
>
<h2>{{ formatMessage(linksMessages.title) }}</h2>
<div class="links-list">
<a
v-if="project.issues_url"
:href="project.issues_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<IssuesIcon aria-hidden="true" />
{{ formatMessage(linksMessages.issues) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.source_url"
:href="project.source_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<CodeIcon aria-hidden="true" />
{{ formatMessage(linksMessages.source) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<WikiIcon aria-hidden="true" />
{{ formatMessage(linksMessages.wiki) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
:target="$external()"
rel="noopener nofollow ugc"
>
<DiscordIcon class="shrink" aria-hidden="true" />
{{ formatMessage(linksMessages.discord) }}
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
<hr
v-if="
(project.issues_url || project.source_url || project.wiki_url || project.discord_url) &&
project.donation_urls.length > 0
"
/>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
:target="$external()"
rel="noopener nofollow ugc"
>
<BuyMeACoffeeIcon v-if="donation.id === 'bmac'" aria-hidden="true" />
<PatreonIcon v-else-if="donation.id === 'patreon'" aria-hidden="true" />
<KoFiIcon v-else-if="donation.id === 'ko-fi'" aria-hidden="true" />
<PayPalIcon v-else-if="donation.id === 'paypal'" aria-hidden="true" />
<OpenCollectiveIcon v-else-if="donation.id === 'open-collective'" aria-hidden="true" />
<HeartIcon v-else-if="donation.id === 'github'" />
<CurrencyIcon v-else />
<span v-if="donation.id === 'bmac'">{{ formatMessage(linksMessages.donateBmac) }}</span>
<span v-else-if="donation.id === 'patreon'">{{
formatMessage(linksMessages.donatePatreon)
}}</span>
<span v-else-if="donation.id === 'paypal'">{{
formatMessage(linksMessages.donatePayPal)
}}</span>
<span v-else-if="donation.id === 'ko-fi'">{{
formatMessage(linksMessages.donateKoFi)
}}</span>
<span v-else-if="donation.id === 'github'">{{
formatMessage(linksMessages.donateGithub)
}}</span>
<span v-else>{{ formatMessage(linksMessages.donateGeneric) }}</span>
<ExternalIcon aria-hidden="true" class="external-icon" />
</a>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(creatorsMessages.title) }}</h2>
<div class="details-list">
<template v-if="organization">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/organization/${organization.slug}`"
>
<Avatar :src="organization.icon_url" :alt="organization.name" size="32px" />
<div class="rows">
<span>
{{ organization.name }}
</span>
<span class="details-list__item__text--style-secondary">Organization</span>
</div>
</nuxt-link>
<hr v-if="members.length > 0" />
</template>
<nuxt-link
v-for="member in members"
:key="`member-${member.id}`"
class="details-list__item details-list__item--type-large"
:to="'/user/' + member.user.username"
>
<Avatar :src="member.avatar_url" :alt="member.name" size="32px" circle />
<div class="rows">
<span class="flex items-center gap-1">
{{ member.name }}
<CrownIcon
v-if="member.is_owner"
v-tooltip="formatMessage(creatorsMessages.owner)"
class="text-brand-orange"
/>
</span>
<span class="details-list__item__text--style-secondary">{{ member.role }}</span>
</div>
</nuxt-link>
</div>
</div>
<div class="card flex-card experimental-styles-within">
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
Licensed
<a
v-if="project.license.url"
class="text-link hover:underline"
:href="project.license.url"
:target="$external()"
rel="noopener nofollow ugc"
>
{{ licenseIdDisplay }}
<ExternalIcon aria-hidden="true" class="external-icon ml-1 mt-[-1px] inline" />
</a>
<span
v-else-if="
project.license.id === 'LicenseRef-All-Rights-Reserved' ||
!project.license.id.includes('LicenseRef')
"
class="text-link hover:underline"
@click="(event) => getLicenseData(event)"
>
{{ licenseIdDisplay }}
</span>
<span v-else>{{ licenseIdDisplay }}</span>
</div>
</div>
<div
v-if="project.approved"
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
</div>
</div>
<div
v-else
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<CalendarIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
</div>
</div>
<div
v-if="project.status === 'processing' && project.queued"
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<ScaleIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
</div>
</div>
<div
v-if="versions.length > 0 && project.updated"
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
class="details-list__item"
>
<VersionIcon aria-hidden="true" />
<div>
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { renderHighlightedString } from "~/helpers/highlight.js";
<script setup>
import {
CheckIcon,
XIcon,
CalendarIcon,
IssuesIcon,
WikiIcon,
OpenCollectiveIcon,
DiscordIcon,
ScaleIcon,
KoFiIcon,
BookTextIcon,
PayPalIcon,
CrownIcon,
BuyMeACoffeeIcon,
CurrencyIcon,
PatreonIcon,
HeartIcon,
VersionIcon,
ExternalIcon,
CodeIcon,
} from "@modrinth/assets";
export default defineNuxtComponent({
props: {
project: {
type: Object,
default() {
return {};
},
import { NewModal, Avatar } from "@modrinth/ui";
import { formatCategory, renderString } from "@modrinth/utils";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { getVersionsToDisplay } from "~/helpers/projects.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const props = defineProps({
project: {
type: Object,
default() {
return {};
},
},
versions: {
type: Array,
default() {
return {};
},
},
members: {
type: Array,
default() {
return {};
},
},
organization: {
type: Object,
default() {
return {};
},
},
methods: { renderHighlightedString },
});
const tags = useTags();
const { formatMessage } = useVIntl();
const formatRelativeTime = useRelativeTime();
const compatibilityMessages = defineMessages({
title: {
id: "project.about.compatibility.title",
defaultMessage: "Compatibility",
},
minecraftJava: {
id: "project.about.compatibility.game.minecraftJava",
defaultMessage: "Minecraft: Java Edition",
},
platforms: {
id: "project.about.compatibility.platforms",
defaultMessage: "Platforms",
},
environments: {
id: "project.about.compatibility.environments",
defaultMessage: "Environments",
},
});
const linksMessages = defineMessages({
title: {
id: "project.about.links.title",
defaultMessage: "Links",
},
issues: {
id: "project.about.links.issues",
defaultMessage: "Report issues",
},
source: {
id: "project.about.links.source",
defaultMessage: "View source",
},
wiki: {
id: "project.about.links.wiki",
defaultMessage: "Visit wiki",
},
discord: {
id: "project.about.links.discord",
defaultMessage: "Join Discord server",
},
donateGeneric: {
id: "project.about.links.donate.generic",
defaultMessage: "Donate",
},
donateGitHub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
donateBmac: {
id: "project.about.links.donate.bmac",
defaultMessage: "Buy Me a Coffee",
},
donatePatreon: {
id: "project.about.links.donate.patreon",
defaultMessage: "Donate on Patreon",
},
donatePayPal: {
id: "project.about.links.donate.paypal",
defaultMessage: "Donate on PayPal",
},
donateKoFi: {
id: "project.about.links.donate.kofi",
defaultMessage: "Donate on Ko-fi",
},
donateGithub: {
id: "project.about.links.donate.github",
defaultMessage: "Sponsor on GitHub",
},
});
const creatorsMessages = defineMessages({
title: {
id: "project.about.creators.title",
defaultMessage: "Creators",
},
owner: {
id: "project.about.creators.owner",
defaultMessage: "Project owner",
},
});
const detailsMessages = defineMessages({
title: {
id: "project.about.details.title",
defaultMessage: "Details",
},
licensed: {
id: "project.about.details.licensed",
defaultMessage: "Licensed {license}",
},
created: {
id: "project.about.details.created",
defaultMessage: "Created {date}",
},
submitted: {
id: "project.about.details.submitted",
defaultMessage: "Submitted {date}",
},
published: {
id: "project.about.details.published",
defaultMessage: "Published {date}",
},
updated: {
id: "project.about.details.updated",
defaultMessage: "Updated {date}",
},
});
const modalLicense = ref(null);
const licenseText = ref("");
const createdDate = computed(() =>
props.project.published ? formatRelativeTime(props.project.published) : "unknown",
);
const submittedDate = computed(() =>
props.project.queued ? formatRelativeTime(props.project.queued) : "unknown",
);
const publishedDate = computed(() =>
props.project.approved ? formatRelativeTime(props.project.approved) : "unknown",
);
const updatedDate = computed(() =>
props.project.updated ? formatRelativeTime(props.project.updated) : "unknown",
);
const licenseIdDisplay = computed(() => {
const id = props.project.license.id;
if (id === "LicenseRef-All-Rights-Reserved") {
return "ARR";
} else if (id.includes("LicenseRef")) {
return id.replaceAll("LicenseRef-", "").replaceAll("-", " ");
} else {
return id;
}
});
async function getLicenseData(event) {
modalLicense.value.show(event);
try {
const text = await useBaseFetch(`tag/license/${props.project.license.id}`);
licenseText.value = text.body || "License text could not be retrieved.";
} catch {
licenseText.value = "License text could not be retrieved.";
}
}
</script>

View File

@ -201,7 +201,7 @@ async function setStatus(status) {
svg {
&.good {
color: var(--color-brand-green);
color: var(--color-green);
}
&.bad {

View File

@ -151,7 +151,7 @@
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Listed and archived projects are visible in search. Unlisted projects are published, but
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
@ -196,7 +196,7 @@
class="small-multiselect"
placeholder="Select one"
:options="tags.approvedStatuses"
:custom-label="(value) => $formatProjectStatus(value)"
:custom-label="(value) => formatProjectStatus(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
@ -243,6 +243,7 @@
<script setup>
import { Multiselect } from "vue-multiselect";
import { formatProjectStatus } from "@modrinth/utils";
import Avatar from "~/components/ui/Avatar.vue";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import FileInput from "~/components/ui/FileInput.vue";
@ -423,7 +424,7 @@ const deleteIcon = async () => {
svg {
&.good {
color: var(--color-brand-green);
color: var(--color-green);
}
&.bad {

View File

@ -0,0 +1,164 @@
<template>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? "Primary file" : "Supplementary resource"
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from "@modrinth/assets";
import { ButtonStyled, VersionChannelIndicator } from "@modrinth/ui";
import { formatBytes, renderHighlightedString } from "@modrinth/utils";
const router = useRouter();
const props = defineProps<{
project: Project;
versions: Version[];
featuredVersions: Version[];
members: User[];
currentMember: User;
dependencies: Dependency[];
resetProject: Function;
}>();
const version = computed(() => {
let version: Version | undefined;
if (route.params.version === "latest") {
let versionList = props.versions;
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader));
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
);
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: "Version not found",
});
}
return version;
});
// const data = useNuxtApp();
const route = useNativeRoute();
// const auth = await useAuth();
// const tags = useTags();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {
if (router.options.history.state.back.includes("/versions")) {
return router.options.history.state.back;
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`;
});
const hasBackLink = computed(
() =>
router.options.history.state.back && router.options.history.state.back.endsWith("/versions"),
);
const { formatMessage } = useVIntl();
const messages = defineMessages({
backToVersions: {
id: "project.version.back-to-versions",
defaultMessage: "Back to versions",
},
allVersions: {
id: "project.version.all-versions",
defaultMessage: "All versions",
},
});
</script>
<style lang="scss"></style>

View File

@ -1,6 +1,6 @@
<template>
<div v-if="version" class="version-page">
<ModalConfirm
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this version?"
@ -37,14 +37,18 @@
open-direction="top"
/>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_package_mod.hide()">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="createDataPackVersion">
<RightArrowIcon />
Begin packaging data pack
</button>
<ButtonStyled>
<button @click="$refs.modal_package_mod.hide()">
<CrossIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="createDataPackVersion">
<RightArrowIcon />
Begin packaging data pack
</button>
</ButtonStyled>
</div>
</div>
</Modal>
@ -94,96 +98,102 @@
</ul>
</div>
<div v-if="isCreating" class="input-group">
<button
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="createVersion"
>
<PlusIcon aria-hidden="true" />
Create
</button>
<nuxt-link
v-if="auth.user"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
class="iconified-button"
>
<CrossIcon aria-hidden="true" />
Cancel
</nuxt-link>
<ButtonStyled color="brand">
<button :disabled="shouldPreventActions" @click="createVersion">
<PlusIcon aria-hidden="true" />
Create
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="auth.user"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
>
<CrossIcon aria-hidden="true" />
Cancel
</nuxt-link>
</ButtonStyled>
</div>
<div v-else-if="isEditing" class="input-group">
<button
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="saveEditedVersion"
>
<SaveIcon aria-hidden="true" />
Save
</button>
<button class="iconified-button" @click="version.featured = !version.featured">
<StarIcon aria-hidden="true" />
<template v-if="!version.featured"> Feature version</template>
<template v-else> Unfeature version</template>
</button>
<nuxt-link
v-if="currentMember"
class="action iconified-button"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
<CrossIcon aria-hidden="true" />
Discard changes
</nuxt-link>
<ButtonStyled color="brand">
<button :disabled="shouldPreventActions" @click="saveEditedVersion">
<SaveIcon aria-hidden="true" />
Save
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="version.featured = !version.featured">
<StarIcon aria-hidden="true" />
<template v-if="!version.featured"> Feature version</template>
<template v-else> Unfeature version</template>
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
<CrossIcon aria-hidden="true" />
Discard changes
</nuxt-link>
</ButtonStyled>
</div>
<div v-else class="input-group">
<a
v-if="primaryFile"
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
class="iconified-button brand-button"
:aria-label="`Download ${primaryFile.filename}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
<nuxt-link v-if="!auth.user" class="iconified-button" to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
<button v-else class="iconified-button" @click="() => reportVersion(version.id)">
<ReportIcon aria-hidden="true" />
Report
</button>
<nuxt-link
v-if="currentMember"
class="action iconified-button"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
<button
v-if="
currentMember &&
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="iconified-button"
@click="$refs.modal_package_mod.show()"
>
<BoxIcon aria-hidden="true" />
Package as mod
</button>
<button
v-if="currentMember"
class="iconified-button danger-button"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete
</button>
<ButtonStyled v-if="primaryFile" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</ButtonStyled>
<ButtonStyled v-if="!auth.user">
<nuxt-link to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="() => reportVersion(version.id)">
<ReportIcon aria-hidden="true" />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
</ButtonStyled>
<ButtonStyled>
<button
v-if="
currentMember &&
version.loaders.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
@click="$refs.modal_package_mod.show()"
>
<BoxIcon aria-hidden="true" />
Package as mod
</button>
</ButtonStyled>
<ButtonStyled>
<button v-if="currentMember" @click="$refs.modal_confirm.show()">
<TrashIcon aria-hidden="true" />
Delete
</button>
</ButtonStyled>
</div>
</div>
<div class="version-page__changelog universal-card">
@ -242,14 +252,12 @@
{{ dependency.dependency_type }}
</span>
</div>
<button
v-if="isEditing && project.project_type !== 'modpack'"
class="iconified-button"
@click="version.dependencies.splice(index, 1)"
>
<TrashIcon />
Remove
</button>
<ButtonStyled v-if="isEditing && project.project_type !== 'modpack'">
<button @click="version.dependencies.splice(index, 1)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</div>
<div
v-for="(dependency, index) in deps.filter((x) => x.file_name)"
@ -297,13 +305,12 @@
/>
</div>
<div class="input-group">
<button
class="iconified-button brand-button"
@click="addDependency(dependencyAddMode, newDependencyId, newDependencyType)"
>
<PlusIcon />
Add dependency
</button>
<ButtonStyled color="brand">
<button @click="addDependency(dependencyAddMode, newDependencyId, newDependencyType)">
<PlusIcon />
Add dependency
</button>
</ButtonStyled>
</div>
</div>
</div>
@ -371,31 +378,32 @@
:show-labels="false"
:allow-empty="false"
/>
<button
v-if="isEditing"
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
class="iconified-button raised-button"
@click="
() => {
deleteFiles.push(file.hashes.sha1);
version.files.splice(index, 1);
oldFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
<a
v-else
:href="file.url"
class="iconified-button raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
>
<DownloadIcon />
Download
</a>
<ButtonStyled v-if="isEditing">
<button
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
@click="
() => {
deleteFiles.push(file.hashes.sha1);
version.files.splice(index, 1);
oldFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
</ButtonStyled>
<ButtonStyled v-else>
<a
:href="file.url"
class="raised-button"
:title="`Download ${file.filename}`"
tabindex="0"
>
<DownloadIcon />
Download
</a>
</ButtonStyled>
</div>
<template v-if="isEditing">
<div v-for="(file, index) in newFiles" :key="index" class="file">
@ -417,18 +425,20 @@
:show-labels="false"
:allow-empty="false"
/>
<button
class="iconified-button raised-button"
@click="
() => {
newFiles.splice(index, 1);
newFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
<ButtonStyled>
<button
class="raised-button"
@click="
() => {
newFiles.splice(index, 1);
newFileTypes.splice(index, 1);
}
"
>
<TrashIcon />
Remove
</button>
</ButtonStyled>
</div>
<div class="additional-files">
<h4>Upload additional files</h4>
@ -455,73 +465,100 @@
</div>
</template>
</div>
<div class="version-page__metadata">
<div class="universal-card full-width-inputs">
<h3>Metadata</h3>
<div>
<h4>Release channel</h4>
<Multiselect
v-if="isEditing"
v-model="version.version_type"
class="input"
placeholder="Select one"
:options="['release', 'beta', 'alpha']"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
</div>
<div class="normal-page__sidebar version-page__metadata">
<AdPlaceholder />
<div class="universal-card full-width-inputs">
<h3>Metadata</h3>
<div>
<h4>Release channel</h4>
<Multiselect
v-if="isEditing"
v-model="version.version_type"
class="input"
placeholder="Select one"
:options="['release', 'beta', 'alpha']"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<template v-else>
<Badge
v-if="version.version_type === 'release'"
class="value"
type="release"
color="green"
/>
<Badge
v-else-if="version.version_type === 'beta'"
class="value"
type="beta"
color="orange"
/>
<Badge
v-else-if="version.version_type === 'alpha'"
class="value"
type="alpha"
color="red"
/>
</template>
</div>
<div>
<h4>Version number</h4>
<div v-if="isEditing" class="iconified-input">
<label class="hidden" for="version-number">Version number</label>
<HashIcon aria-hidden="true" />
<input
id="version-number"
v-model="version.version_number"
type="text"
autocomplete="off"
maxlength="54"
/>
<template v-else>
<Badge
v-if="version.version_type === 'release'"
class="value"
type="release"
color="green"
/>
<Badge
v-else-if="version.version_type === 'beta'"
class="value"
type="beta"
color="orange"
/>
<Badge
v-else-if="version.version_type === 'alpha'"
class="value"
type="alpha"
color="red"
/>
</template>
</div>
<div>
<h4>Version number</h4>
<div v-if="isEditing" class="iconified-input">
<label class="hidden" for="version-number">Version number</label>
<HashIcon aria-hidden="true" />
<input
id="version-number"
v-model="version.version_number"
type="text"
autocomplete="off"
maxlength="54"
/>
</div>
<span v-else>{{ version.version_number }}</span>
</div>
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Multiselect
v-if="isEditing"
v-model="version.loaders"
<span v-else>{{ version.version_number }}</span>
</div>
<div v-if="project.project_type !== 'resourcepack'">
<h4>Loaders</h4>
<Multiselect
v-if="isEditing"
v-model="version.loaders"
:options="
tags.loaders
.filter((x) =>
x.supported_project_types.includes(project.actualProjectType.toLowerCase()),
)
.map((it) => it.name)
"
:custom-label="(value) => $formatCategory(value)"
:loading="tags.loaders.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
/>
<Categories v-else :categories="version.loaders" :type="project.actualProjectType" />
</div>
<div>
<h4>Game versions</h4>
<template v-if="isEditing">
<multiselect
v-model="version.game_versions"
:options="
tags.loaders
.filter((x) =>
x.supported_project_types.includes(project.actualProjectType.toLowerCase()),
)
.map((it) => it.name)
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:custom-label="(value) => $formatCategory(value)"
:loading="tags.loaders.length === 0"
:loading="tags.gameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
@ -530,89 +567,63 @@
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose loaders..."
placeholder="Choose versions..."
/>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-top: 0.5rem"
:border="false"
/>
</template>
<span v-else>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div v-if="!isEditing">
<h4>Downloads</h4>
<span>{{ version.downloads }}</span>
</div>
<div v-if="!isEditing">
<h4>Publication date</h4>
<span>
{{ $dayjs(version.date_published).format("MMMM D, YYYY [at] h:mm A") }}
</span>
</div>
<div v-if="!isEditing && version.author">
<h4>Publisher</h4>
<div
class="team-member columns button-transparent"
@click="$router.push('/user/' + version.author.user.username)"
>
<Avatar
:src="version.author.avatar_url"
:alt="version.author.user.username"
size="sm"
circle
/>
<Categories v-else :categories="version.loaders" :type="project.actualProjectType" />
</div>
<div>
<h4>Game versions</h4>
<template v-if="isEditing">
<multiselect
v-model="version.game_versions"
:options="
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
"
:loading="tags.gameVersions.length === 0"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-on-select="false"
:show-labels="false"
:limit="6"
:hide-selected="true"
placeholder="Choose versions..."
/>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-top: 0.5rem"
:border="false"
/>
</template>
<span v-else>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div v-if="!isEditing">
<h4>Downloads</h4>
<span>{{ version.downloads }}</span>
</div>
<div v-if="!isEditing">
<h4>Publication date</h4>
<span>
{{ $dayjs(version.date_published).format("MMMM D, YYYY [at] h:mm A") }}
</span>
</div>
<div v-if="!isEditing && version.author">
<h4>Publisher</h4>
<div
class="team-member columns button-transparent"
@click="$router.push('/user/' + version.author.user.username)"
>
<Avatar
:src="version.author.avatar_url"
:alt="version.author.user.username"
size="sm"
circle
/>
<div class="member-info">
<nuxt-link :to="'/user/' + version.author.user.username" class="name">
<p>
{{ version.author.name }}
</p>
</nuxt-link>
<p v-if="version.author.role" class="role">
{{ version.author.role }}
<div class="member-info">
<nuxt-link :to="'/user/' + version.author.user.username" class="name">
<p>
{{ version.author.name }}
</p>
<p v-else-if="version.author_id === 'GVFjtWTf'" class="role">Archivist</p>
</div>
</nuxt-link>
<p v-if="version.author.role" class="role">
{{ version.author.role }}
</p>
<p v-else-if="version.author_id === 'GVFjtWTf'" class="role">Archivist</p>
</div>
</div>
<div v-if="!isEditing">
<h4>Version ID</h4>
<CopyCode :text="version.id" />
</div>
</div>
<div v-if="!isEditing">
<h4>Version ID</h4>
<CopyCode :text="version.id" />
</div>
</div>
</div>
</template>
<script>
import { MarkdownEditor } from "@modrinth/ui";
import { ButtonStyled, ConfirmModal, MarkdownEditor } from "@modrinth/ui";
import { Multiselect } from "vue-multiselect";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import { inferVersionInfo } from "~/helpers/infer.js";
@ -626,7 +637,6 @@ import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue";
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
import Chips from "~/components/ui/Chips.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import FileInput from "~/components/ui/FileInput.vue";
@ -649,6 +659,7 @@ import RightArrowIcon from "~/assets/images/utils/right-arrow.svg?component";
import Modal from "~/components/ui/Modal.vue";
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
export default defineNuxtComponent({
components: {
MarkdownEditor,
@ -675,10 +686,12 @@ export default defineNuxtComponent({
Badge,
Breadcrumbs,
CopyCode,
ModalConfirm,
Multiselect,
BoxIcon,
RightArrowIcon,
ConfirmModal,
ButtonStyled,
AdPlaceholder,
},
props: {
project: {
@ -942,10 +955,7 @@ export default defineNuxtComponent({
},
getPreviousLink() {
if (this.$router.options.history.state.back) {
if (
this.$router.options.history.state.back.includes("/changelog") ||
this.$router.options.history.state.back.includes("/versions")
) {
if (this.$router.options.history.state.back.includes("/versions")) {
return this.$router.options.history.state.back;
}
}
@ -955,9 +965,9 @@ export default defineNuxtComponent({
},
getPreviousLabel() {
return this.$router.options.history.state.back &&
this.$router.options.history.state.back.endsWith("/changelog")
? "Changelog"
: "Versions";
this.$router.options.history.state.back.endsWith("/versions")
? "Back to versions"
: "All versions";
},
acceptFileFromProjectType,
renderHighlightedString,
@ -1315,8 +1325,8 @@ export default defineNuxtComponent({
"title" auto
"changelog" auto
"dependencies" auto
"metadata" auto
"files" auto
"dummy" 1fr
/ 1fr;
column-gap: var(--spacing-card-md);
@ -1480,8 +1490,9 @@ export default defineNuxtComponent({
min-width: 235px;
}
.iconified-button {
.raised-button {
margin-left: auto;
background-color: var(--color-raised-bg);
}
&:not(:nth-child(2)) {
@ -1506,44 +1517,30 @@ export default defineNuxtComponent({
}
}
}
.version-page__metadata {
grid-area: metadata;
h4 {
margin: 1rem 0 0.25rem 0;
}
.team-member {
align-items: center;
padding: 0.25rem 0.5rem;
.member-info {
overflow: hidden;
margin: auto 0 auto 0.75rem;
.name {
font-weight: bold;
}
p {
font-size: var(--font-size-sm);
margin: 0.2rem 0;
}
}
}
}
}
@media (min-width: 1200px) {
.version-page {
grid-template:
"title title" auto
"changelog metadata" auto
"dependencies metadata" auto
"files metadata" auto
"dummy metadata" 1fr
/ 1fr 20rem;
.version-page__metadata {
h4 {
margin: 1rem 0 0.25rem 0;
}
.team-member {
align-items: center;
padding: 0.25rem 0.5rem;
.member-info {
overflow: hidden;
margin: auto 0 auto 0.75rem;
.name {
font-weight: bold;
}
p {
font-size: var(--font-size-sm);
margin: 0.2rem 0;
}
}
}
}

View File

@ -1,112 +1,296 @@
<template>
<div class="content">
<div v-if="currentMember" class="card header-buttons">
<section class="normal-page__content experimental-styles-within overflow-visible">
<div
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="iconified-button brand-button"
:disabled="!isPermission(currentMember?.permissions, 1 << 0)"
class="btn btn-primary"
@change="handleFiles"
>
<UploadIcon />
</FileInput>
<span class="indicator">
<span class="flex items-center gap-2">
<InfoIcon /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<VersionFilterControl :versions="props.versions" @switch-page="switchPage" />
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div v-if="filteredVersions.length > 0" id="all-versions" class="universal-card all-versions">
<div class="header">
<div />
<div>Version</div>
<div>Supports</div>
<div>Stats</div>
<div
v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content] supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content]"
>
<div class="versions-grid-row">
<div class="w-9 max-sm:hidden"></div>
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Game version
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Platforms
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Published
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Downloads
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
Compatibility
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
<div class="w-9 max-sm:hidden"></div>
</div>
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="version-button button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
)
"
<template
v-for="(version, index) in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="index"
>
<a
v-tooltip="
version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
"
:href="version.primaryFile.url"
class="download-button square-button brand-button"
:class="version.version_type"
:aria-label="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
class="version__title"
>
{{ version.name }}
</nuxt-link>
<div class="version__metadata">
<VersionBadge v-if="version.version_type === 'release'" type="release" color="green" />
<VersionBadge v-else-if="version.version_type === 'beta'" type="beta" color="orange" />
<VersionBadge v-else-if="version.version_type === 'alpha'" type="alpha" color="red" />
<span class="divider" />
<span class="version_number">{{ version.version_number }}</span>
<div
:class="`versions-grid-row h-px w-full bg-button-bg ${index === 0 ? `max-sm:!hidden` : ``}`"
></div>
<div class="versions-grid-row group relative">
<nuxt-link
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
></nuxt-link>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row items-center gap-2 sm:contents">
<div class="self-center">
<div class="pointer-events-none relative z-[1]">
<VersionChannelIndicator :channel="version.version_type" />
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col justify-center group-hover:underline"
>
<div class="font-bold text-contrast">{{ version.version_number }}</div>
<div class="text-xs font-medium">{{ version.name }}</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
<div class="flex items-center">
<div class="tag-list">
<div
v-for="gameVersion in formatVersionsForDisplay(version.game_versions)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="tag-list__item z-[1] cursor-pointer hover:underline"
@click="versionFilters.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</div>
</div>
</div>
<div class="flex items-center">
<div class="tag-list">
<div
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
:class="`tag-list__item z-[1] cursor-pointer hover:underline`"
:style="`--_color: var(--color-platform-${platform})`"
@click="versionFilters.toggleFilter('platform', platform)"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
{{ formatCategory(platform) }}
</div>
</div>
</div>
</div>
<div
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
>
<div
class="pointer-events-none z-[1] flex items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(version.date_published) }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
>
<DownloadIcon class="xl:hidden" />
{{ formatCompactNumber(version.downloads) }}
</div>
</div>
</div>
</div>
<div class="flex items-start justify-end gap-1 sm:items-center">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="z-[1] group-hover:!bg-brand group-hover:!text-brand-inverted"
@click="emits('onDownload')"
>
<DownloadIcon />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emits('onDownload');
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
action: () => reportVersion(version.id),
},
{ divider: true, shown: currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {},
shown: currentMember && false,
},
]"
>
<MoreVerticalIcon />
<template #download>
<DownloadIcon />
Download
</template>
<template #new-tab>
<ExternalIcon />
Open in new tab
</template>
<template #copy-link>
<LinkIcon />
Copy link
</template>
<template #share>
<ShareIcon />
Share
</template>
<template #report>
<ReportIcon />
Report
</template>
<template #edit>
<EditIcon />
Edit
</template>
<template #delete>
<TrashIcon />
Delete
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full">
<div
v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`"
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
>
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
{{ file.filename }} - {{ formatBytes(file.size) }}
</div>
</div>
</div>
<div class="version__supports">
<span>
{{ version.loaders.map((x) => $formatCategory(x)).join(", ") }}
</span>
<span>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div class="version__stats">
<span>
<strong>{{ $formatNumber(version.downloads) }}</strong>
download<span v-if="version.downloads !== 1">s</span>
</span>
<span>
Published on
<strong>{{ $dayjs(version.date_published).format("MMM D, YYYY") }}</strong>
</span>
</div>
</div>
</template>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
<div class="my-3 flex justify-end">
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
:link-function="(page) => `?page=${currentPage}`"
@switch-page="switchPage"
/>
</div>
</section>
<div class="normal-page__sidebar">
<AdPlaceholder />
<VersionFilterControl
ref="versionFilters"
:versions="props.versions"
@switch-page="switchPage"
/>
</div>
</template>
<script setup>
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import UploadIcon from "~/assets/images/utils/upload.svg?component";
import InfoIcon from "~/assets/images/utils/info.svg?component";
import VersionBadge from "~/components/ui/Badge.vue";
import FileInput from "~/components/ui/FileInput.vue";
import DropArea from "~/components/ui/DropArea.vue";
import Pagination from "~/components/ui/Pagination.vue";
import {
ButtonStyled,
OverflowMenu,
Pagination,
VersionChannelIndicator,
FileInput,
} from "@modrinth/ui";
import {
StarIcon,
CalendarIcon,
DownloadIcon,
MoreVerticalIcon,
TrashIcon,
ExternalIcon,
LinkIcon,
ShareIcon,
EditIcon,
ReportIcon,
UploadIcon,
InfoIcon,
} from "@modrinth/assets";
import { formatBytes, formatCategory } from "@modrinth/utils";
import { formatVersionsForDisplay } from "~/helpers/projects.js";
import VersionFilterControl from "~/components/ui/VersionFilterControl.vue";
import DropArea from "~/components/ui/DropArea.vue";
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const formatCompactNumber = useCompactNumber();
const props = defineProps({
project: {
@ -121,12 +305,6 @@ const props = defineProps({
return [];
},
},
members: {
type: Array,
default() {
return [];
},
},
currentMember: {
type: Object,
default() {
@ -135,30 +313,38 @@ const props = defineProps({
},
});
const data = useNuxtApp();
const tags = useTags();
const formatRelativeTime = useRelativeTime();
const title = `${props.project.title} - Versions`;
const description = `Download and browse ${props.versions.length} ${
props.project.title
} versions. ${data.$formatNumber(props.project.downloads)} total downloads. Last updated ${data
.$dayjs(props.project.updated)
.format("MMM D, YYYY")}.`;
const emits = defineEmits(["onDownload"]);
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
});
const router = useNativeRouter();
const route = useNativeRoute();
const router = useNativeRouter();
const currentPage = ref(Number(route.query.p ?? 1));
const currentPage = ref(route.query.page ?? 1);
const showFiles = ref(false);
function switchPage(page) {
currentPage.value = page;
router.replace({
query: {
...route.query,
page: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0];
}
const versionFilters = ref(null);
const filteredVersions = computed(() => {
const selectedGameVersions = getArrayOrString(route.query.g) ?? [];
const selectedLoaders = getArrayOrString(route.query.l) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.c) ?? [];
const selectedGameVersions = getArrayOrString(route.query.gameVersion) ?? [];
const selectedLoaders = getArrayOrString(route.query.platform) ?? [];
const selectedVersionTypes = getArrayOrString(route.query.type) ?? [];
return props.versions.filter(
(projectVersion) =>
@ -173,17 +359,6 @@ const filteredVersions = computed(() => {
);
});
function switchPage(page) {
currentPage.value = page;
router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
},
});
}
async function handleFiles(files) {
await router.push({
name: "type-id-version-version",
@ -197,144 +372,13 @@ async function handleFiles(files) {
},
});
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text);
}
</script>
<style lang="scss" scoped>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.all-versions {
display: flex;
flex-direction: column;
.header {
display: grid;
grid-template: "download title supports stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
color: var(--color-text-dark);
font-size: var(--font-size-md);
font-weight: bold;
justify-content: left;
margin-inline: var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
column-gap: var(--spacing-card-sm);
div:first-child {
grid-area: download;
}
div:nth-child(2) {
grid-area: title;
}
div:nth-child(3) {
grid-area: supports;
}
div:nth-child(4) {
grid-area: stats;
}
}
.version-button {
display: grid;
grid-template:
"download title supports stats"
"download metadata supports stats"
"download dummy supports stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
column-gap: var(--spacing-card-sm);
justify-content: left;
padding: var(--spacing-card-md);
.download-button {
grid-area: download;
}
.version__title {
grid-area: title;
font-weight: bold;
svg {
vertical-align: top;
}
}
.version__metadata {
grid-area: metadata;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-xs);
}
.version__supports {
grid-area: supports;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
.version__stats {
grid-area: stats;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
}
}
@media screen and (max-width: 1024px) {
.all-versions {
.header {
grid-template: "download title";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
div:nth-child(3) {
display: none;
}
div:nth-child(4) {
display: none;
}
}
.version-button {
grid-template: "download title" "download metadata" "download supports" "download stats";
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
row-gap: var(--spacing-card-xs);
.version__supports {
display: flex;
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--spacing-card-xs);
}
.version__metadata {
margin: 0;
}
}
}
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
}
.checkbox-outer {
min-width: fit-content;
}
<style scoped>
.versions-grid-row {
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content] xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
}
</style>

View File

@ -1000,7 +1000,7 @@ useSeoMeta({
center 4rem;
background-size: cover;
padding: 6rem 1rem 12rem 1rem;
margin-top: -4rem;
margin-top: -5rem;
display: flex;
justify-content: center;
align-items: center;

View File

@ -24,7 +24,7 @@
</Button>
</template>
<template v-else-if="canEdit && isEditing === true">
<PopoutMenu class="btn" position="bottom" direction="right">
<PopoutMenu class="btn">
<EditIcon /> {{ formatMessage(messages.editIconButton) }}
<template #menu>
<span class="icon-edit-menu">

View File

@ -11,7 +11,7 @@
<XIcon />
</Button>
</div>
<Button color="primary" @click="$refs.modal_creation.show()">
<Button color="primary" @click="(event) => $refs.modal_creation.show(event)">
<PlusIcon /> {{ formatMessage(messages.createNewButton) }}
</Button>
</div>

View File

@ -76,8 +76,8 @@ if (error.value) {
});
}
const openCreateOrgModal = () => {
createOrgModal.value?.show();
const openCreateOrgModal = (event) => {
createOrgModal.value?.show(event);
};
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<div class="landing-hero">
<ModrinthIcon />
<ModrinthIcon class="modrinth-icon" />
<h1 class="main-header">
The place for Minecraft
<div class="animate-strong">
@ -22,18 +22,19 @@
community.
</h2>
<div class="button-group">
<nuxt-link to="/mods" class="iconified-button brand-button"> Discover mods </nuxt-link>
<nuxt-link
v-if="!auth.user"
to="/auth/sign-up"
class="iconified-button outline-button"
rel="noopener nofollow"
>
Sign up
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects" class="iconified-button outline-button">
Go to dashboard
</nuxt-link>
<ButtonStyled color="brand" size="large">
<nuxt-link to="/mods"> <CompassIcon /> Discover mods </nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" type="outlined">
<nuxt-link v-if="!auth.user" to="/auth/sign-up" rel="noopener nofollow">
<LogInIcon />
Sign up
</nuxt-link>
<nuxt-link v-else to="/dashboard/projects">
<DashboardIcon />
Go to dashboard
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div class="users-section-outer">
@ -411,7 +412,7 @@
viewBox="0 0 865 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="light-height"
class="light-height modrinth-icon"
>
<g clip-path="url(#clip0_419_237)">
<rect x="176" width="512" height="512" fill="url(#paint0_linear_419_237)" />
@ -455,7 +456,13 @@
</clipPath>
</defs>
</svg>
<svg v-else viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
v-else
viewBox="0 0 512 512"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="modrinth-icon"
>
<g clip-path="url(#clip0_127_331)">
<rect width="512" height="512" fill="url(#paint0_linear_127_331)" />
<g style="mix-blend-mode: overlay">
@ -493,18 +500,22 @@
Read more about <br />
<strong class="main-header-strong">Modrinth</strong>
</h2>
<a
href="https://blog.modrinth.com/?utm_source=website&utm_source=homepage&utm_campaign=newsletter"
class="iconified-button brand-button"
>
Visit the blog
</a>
<ButtonStyled color="brand">
<a
href="https://blog.modrinth.com/?utm_source=website&utm_source=homepage&utm_campaign=newsletter"
>
<NewspaperIcon />
Visit the blog
</a>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { ButtonStyled } from "@modrinth/ui";
import { CompassIcon, LogInIcon, DashboardIcon, NewspaperIcon } from "@modrinth/assets";
import SearchIcon from "~/assets/images/utils/search.svg?component";
import CalendarIcon from "~/assets/images/utils/calendar.svg?component";
import ModrinthIcon from "~/assets/images/logo.svg?component";
@ -554,7 +565,7 @@ async function updateSearchProjects() {
text-align: center;
flex-direction: column;
svg {
.modrinth-icon {
width: 13rem;
height: 13rem;
margin-bottom: 2.5rem;
@ -575,12 +586,6 @@ async function updateSearchProjects() {
gap: 1.25rem;
margin: 0 auto 5rem;
justify-content: center;
.outline-button {
color: var(--landing-color-heading);
background: none;
border: 1px var(--landing-color-heading) solid;
}
}
}
@ -1044,7 +1049,7 @@ async function updateSearchProjects() {
padding: 1rem 1rem 2rem 1rem;
overflow: hidden;
svg {
.modrinth-icon {
z-index: 2;
width: auto;
height: 32rem;
@ -1201,11 +1206,6 @@ async function updateSearchProjects() {
}
}
.iconified-button {
font-weight: 600;
min-height: 3rem;
}
@media screen and (min-width: 560px) {
.landing-hero {
h2 {
@ -1276,7 +1276,7 @@ async function updateSearchProjects() {
font-size: 1.625rem;
}
margin-top: -4rem;
margin-top: -5rem;
padding: 11.25rem 1rem 12rem;
}

View File

@ -141,7 +141,7 @@ onMounted(() => {
.main-hero {
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
var(--color-accent-contrast);
margin-top: -4rem;
margin-top: -5rem;
padding: 11.25rem 1rem 8rem;
display: flex;

View File

@ -1,10 +1,7 @@
<template>
<div
:class="{
'search-page': true,
'normal-page': true,
'alt-layout': cosmetics.searchLayout,
}"
class="new-page sidebar experimental-styles-within"
:class="{ 'alt-layout': cosmetics.searchLayout }"
>
<Head>
<Title>Search {{ projectType.display }}s - Modrinth</Title>
@ -12,206 +9,126 @@
<aside
:class="{
'normal-page__sidebar': true,
open: sidebarMenuOpen,
}"
aria-label="Filters"
>
<section class="card filters-card" role="presentation">
<div class="sidebar-menu" :class="{ 'sidebar-menu_open': sidebarMenuOpen }">
<AdPlaceholder />
<section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }">
<div class="flex items-center gap-2">
<div class="iconified-input w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
placeholder="Search filters..."
autocomplete="off"
/>
</div>
<button
:disabled="
onlyOpenSource === false &&
selectedEnvironments.length === 0 &&
selectedVersions.length === 0 &&
facets.length === 0 &&
orFacets.length === 0
v-if="
!(
onlyOpenSource === false &&
selectedEnvironments.length === 0 &&
selectedVersions.length === 0 &&
facets.length === 0 &&
orFacets.length === 0 &&
negativeFacets.length === 0
)
"
class="iconified-button"
v-tooltip="`Reset all filters`"
class="btn icon-only"
@click="clearFilters"
>
<ClearIcon aria-hidden="true" />
Clear filters
<FilterXIcon />
</button>
<section aria-label="Category filters">
<div v-for="(categories, header) in categoriesMap" :key="header">
<h3
v-if="categories.filter((x) => x.project_type === projectType.actual).length > 0"
class="sidebar-menu-heading"
>
{{ $formatCategoryHeader(header) }}
</h3>
<template v-if="header === 'resolutions'">
<SearchFilter
v-for="category in categories.filter(
(x) => x.project_type === projectType.actual,
)"
:key="category.name"
:active-filters="orFacets"
:display-name="$formatCategory(category.name)"
:facet-name="`categories:'${encodeURIComponent(category.name)}'`"
:icon="null"
@toggle="toggleOrFacet"
/>
</template>
<template v-else>
<SearchFilter
v-for="category in categories.filter(
(x) => x.project_type === projectType.actual,
)"
:key="category.name"
:active-filters="facets"
:display-name="$formatCategory(category.name)"
:facet-name="`categories:'${encodeURIComponent(category.name)}'`"
:icon="category.icon"
@toggle="toggleFacet"
/>
</template>
</div>
</section>
<section
v-if="projectType.id !== 'resourcepack' && projectType.id !== 'datapack'"
aria-label="Loader filters"
>
<h3
v-if="
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
>
Loaders
</h3>
<SearchFilter
v-for="loader in tags.loaders.filter((x) => {
if (projectType.id === 'mod') {
return (
tags.loaderData.modLoaders.includes(x.name) &&
!tags.loaderData.hiddenModLoaders.includes(x.name)
);
} else if (projectType.id === 'plugin') {
return tags.loaderData.pluginLoaders.includes(x.name);
} else if (projectType.id === 'datapack') {
return tags.loaderData.dataPackLoaders.includes(x.name);
} else {
return x.supported_project_types.includes(projectType.actual);
}
})"
:key="loader.name"
ref="loaderFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
<template v-if="projectType.id === 'mod' && showAllLoaders">
<SearchFilter
v-for="loader in tags.loaders.filter((x) => {
return (
tags.loaderData.modLoaders.includes(x.name) &&
tags.loaderData.hiddenModLoaders.includes(x.name)
);
})"
:key="loader.name"
ref="loaderFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
</template>
<Checkbox
v-if="projectType.id === 'mod'"
v-model="showAllLoaders"
:label="showAllLoaders ? 'Less' : 'More'"
description="Show all loaders"
style="margin-bottom: 0.5rem"
:border="false"
:collapsing-toggle-style="true"
/>
</section>
<section v-if="projectType.id === 'plugin'" aria-label="Platform loader filters">
<h3
v-if="
tags.loaders.filter((x) => x.supported_project_types.includes(projectType.actual))
.length > 0
"
class="sidebar-menu-heading"
>
Proxies
</h3>
<SearchFilter
v-for="loader in tags.loaders.filter((x) =>
tags.loaderData.pluginPlatformLoaders.includes(x.name),
)"
:key="loader.name"
ref="platformFilters"
:active-filters="orFacets"
:display-name="$formatCategory(loader.name)"
:facet-name="`categories:'${encodeURIComponent(loader.name)}'`"
:icon="loader.icon"
@toggle="toggleOrFacet"
/>
</section>
<section
v-if="!['resourcepack', 'plugin', 'shader', 'datapack'].includes(projectType.id)"
aria-label="Environment filters"
>
<h3 class="sidebar-menu-heading">Environments</h3>
<SearchFilter
:active-filters="selectedEnvironments"
display-name="Client"
facet-name="client"
@toggle="toggleEnv"
>
<ClientIcon aria-hidden="true" />
</SearchFilter>
<SearchFilter
:active-filters="selectedEnvironments"
display-name="Server"
facet-name="server"
@toggle="toggleEnv"
>
<ServerIcon aria-hidden="true" />
</SearchFilter>
</section>
<h3 class="sidebar-menu-heading">Minecraft versions</h3>
<Checkbox
v-model="showSnapshots"
label="Show all versions"
description="Show all versions"
style="margin-bottom: 0.5rem"
:border="false"
/>
<multiselect
v-model="selectedVersions"
:options="
showSnapshots
? tags.gameVersions.map((x) => x.version)
: tags.gameVersions
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
</div>
<div
v-for="(categories, header, index) in filters"
:key="header"
:class="`border-0 border-b border-solid border-button-bg py-2 last:border-b-0`"
>
<button
class="flex !w-full bg-transparent px-0 py-2 font-extrabold text-contrast transition-all active:scale-[0.98]"
@click="
() => {
filterAccordions[index].isOpen
? filterAccordions[index].close()
: filterAccordions[index].open();
}
"
:multiple="true"
:searchable="true"
:show-no-results="false"
:close-on-select="false"
:clear-search-on-select="false"
:show-labels="false"
:selectable="() => selectedVersions.length <= 6"
placeholder="Choose versions..."
@update:model-value="onSearchChange(1)"
/>
<h3 class="sidebar-menu-heading">Open source</h3>
<Checkbox
v-model="onlyOpenSource"
label="Open source only"
style="margin-bottom: 0.5rem"
:border="false"
@update:model-value="onSearchChange(1)"
/>
>
<template v-if="header === 'gameVersion'"> Game versions </template>
<template v-else>
{{ $formatCategoryHeader(header) }}
</template>
<DropdownIcon
class="ml-auto h-5 w-5 transition-transform"
:class="{ 'rotate-180': filterAccordions[index]?.isOpen }"
/>
</button>
<Accordion ref="filterAccordions" :open-by-default="true">
<ScrollablePanel
:class="{ 'h-[18rem]': categories.length >= 8 && header === 'gameVersion' }"
:no-max-height="header !== 'gameVersion'"
>
<div class="mr-1 flex flex-col gap-1">
<div v-for="category in categories" :key="category.name" class="group flex gap-1">
<button
:class="`flex !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all active:scale-[0.98] ${filterSelected(category) ? 'bg-brand-highlight text-contrast hover:brightness-125' : negativeFilterSelected(category) ? 'bg-highlight-red text-contrast hover:brightness-125' : 'bg-transparent text-secondary hover:bg-button-bg'}`"
@click="
negativeFilterSelected(category)
? toggleNegativeFilter(category)
: toggleFilter(category)
"
>
<ClientIcon v-if="category.name === 'client'" class="h-4 w-4" />
<ServerIcon v-else-if="category.name === 'server'" class="h-4 w-4" />
<div v-if="category.icon" class="h-4" v-html="category.icon" />
<span class="truncate text-sm">{{ $formatCategory(category.name) }}</span>
<BanIcon
v-if="negativeFilterSelected(category)"
:class="`ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${negativeFilterSelected(category) ? '' : 'opacity-0'}`"
aria-hidden="true"
/>
<CheckIcon
v-else
:class="`ml-auto h-4 w-4 shrink-0 transition-opacity group-hover:opacity-100 ${filterSelected(category) ? '' : 'opacity-0'}`"
aria-hidden="true"
/>
</button>
<button
v-if="
(category.type === 'or' || category.type === 'normal') &&
!negativeFilterSelected(category)
"
v-tooltip="negativeFilterSelected(category) ? 'Include' : 'Exclude'"
class="hidden items-center justify-center gap-2 rounded-xl bg-transparent px-2 py-1 text-sm font-semibold text-secondary transition-all hover:bg-button-bg hover:text-red active:scale-[0.96] group-hover:flex"
@click="toggleNegativeFilter(category)"
>
<BanIcon
class="h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden="true"
/>
</button>
</div>
</div>
</ScrollablePanel>
<Checkbox
v-if="header === 'gameVersion'"
v-model="showSnapshots"
class="mx-2"
:label="`Show all versions`"
/>
<Checkbox
v-if="header === 'loaders' && projectType.id === 'mod'"
v-model="showAllLoaders"
class="mx-2"
:label="`Show all loaders`"
/>
</Accordion>
</div>
</section>
</aside>
@ -288,13 +205,6 @@
</button>
</div>
</div>
<Pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-before"
@switch-page="onSearchChange"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
@ -332,38 +242,41 @@
/>
</div>
</div>
<pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-after"
@switch-page="onSearchChangeToTop"
/>
<div class="pagination-after">
<pagination
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="justify-end"
@switch-page="onSearchChangeToTop"
/>
</div>
</section>
</div>
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { Promotion } from "@modrinth/ui";
import { Promotion, Pagination, ScrollablePanel, Checkbox } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon } from "@modrinth/assets";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import Pagination from "~/components/ui/Pagination.vue";
import SearchFilter from "~/components/ui/search/SearchFilter.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
import ClientIcon from "~/assets/images/categories/client.svg?component";
import ServerIcon from "~/assets/images/categories/server.svg?component";
import SearchIcon from "~/assets/images/utils/search.svg?component";
import ClearIcon from "~/assets/images/utils/clear.svg?component";
import FilterIcon from "~/assets/images/utils/filter.svg?component";
import GridIcon from "~/assets/images/utils/grid.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import ImageIcon from "~/assets/images/utils/image.svg?component";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const sidebarMenuOpen = ref(false);
const showAllLoaders = ref(false);
const filterAccordions = ref([]);
const data = useNuxtApp();
const route = useNativeRoute();
@ -374,6 +287,7 @@ const auth = await useAuth();
const query = ref("");
const facets = ref([]);
const orFacets = ref([]);
const negativeFacets = ref([]);
const selectedVersions = ref([]);
const onlyOpenSource = ref(false);
const showSnapshots = ref(false);
@ -413,6 +327,9 @@ if (route.query.f) {
if (route.query.g) {
orFacets.value = getArrayOrString(route.query.g);
}
if (route.query.nf) {
negativeFacets.value = getArrayOrString(route.query.nf);
}
if (route.query.v) {
selectedVersions.value = getArrayOrString(route.query.v);
}
@ -477,6 +394,7 @@ const {
if (
facets.value.length > 0 ||
orFacets.value.length > 0 ||
negativeFacets.value.length > 0 ||
selectedVersions.value.length > 0 ||
selectedEnvironments.value.length > 0 ||
projectType.value
@ -486,6 +404,10 @@ const {
formattedFacets.push([facet]);
}
for (const facet of negativeFacets.value) {
formattedFacets.push([facet.replace(":", "!=")]);
}
// loaders specifier
if (orFacets.value.length > 0) {
formattedFacets.push(orFacets.value);
@ -616,6 +538,10 @@ function getSearchUrl(offset, useObj) {
queryItems.push(`g=${encodeURIComponent(orFacets.value)}`);
obj.g = orFacets.value;
}
if (negativeFacets.value.length > 0) {
queryItems.push(`nf=${encodeURIComponent(negativeFacets.value)}`);
obj.nf = negativeFacets.value;
}
if (selectedVersions.value.length > 0) {
queryItems.push(`v=${encodeURIComponent(selectedVersions.value)}`);
obj.v = selectedVersions.value;
@ -654,103 +580,16 @@ function getSearchUrl(offset, useObj) {
return useObj ? obj : url;
}
const categoriesMap = computed(() => {
const categories = {};
for (const category of data.$sortedCategories()) {
if (categories[category.header]) {
categories[category.header].push(category);
} else {
categories[category.header] = [category];
}
}
return Object.keys(categories).reduce((obj, key) => {
obj[key] = categories[key];
return obj;
}, {});
});
function clearFilters() {
for (const facet of [...facets.value]) {
toggleFacet(facet, true);
}
for (const facet of [...orFacets.value]) {
toggleOrFacet(facet, true);
}
facets.value = [];
orFacets.value = [];
negativeFacets.value = [];
onlyOpenSource.value = false;
selectedVersions.value = [];
selectedEnvironments.value = [];
onSearchChange(1);
}
function toggleFacet(elementName, doNotSendRequest = false) {
const index = facets.value.indexOf(elementName);
if (index !== -1) {
facets.value.splice(index, 1);
} else {
facets.value.push(elementName);
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
function toggleOrFacet(elementName, doNotSendRequest) {
const index = orFacets.value.indexOf(elementName);
if (index !== -1) {
orFacets.value.splice(index, 1);
} else {
if (elementName === "categories:purpur") {
if (!orFacets.value.includes("categories:paper")) {
orFacets.value.push("categories:paper");
}
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:paper") {
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:spigot") {
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:waterfall") {
if (!orFacets.value.includes("categories:bungeecord")) {
orFacets.value.push("categories:bungeecord");
}
}
orFacets.value.push(elementName);
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
function toggleEnv(environment, sendRequest) {
const index = selectedEnvironments.value.indexOf(environment);
if (index !== -1) {
selectedEnvironments.value.splice(index, 1);
} else {
selectedEnvironments.value.push(environment);
}
if (!sendRequest) {
onSearchChange(1);
}
}
function onSearchChangeToTop(newPageNumber) {
if (import.meta.client) {
window.scrollTo({ top: 0, behavior: "smooth" });
@ -796,6 +635,225 @@ function setClosestMaxResults() {
});
}
}
const queryFilter = ref("");
const filters = computed(() => {
const filters = {};
if (projectType.value.id !== "resourcepack" && projectType.value.id !== "datapack") {
const loaders = tags.value.loaders
.filter((x) => {
if (projectType.value.id === "mod" && !showAllLoaders.value) {
return (
tags.value.loaderData.modLoaders.includes(x.name) &&
!tags.value.loaderData.hiddenModLoaders.includes(x.name)
);
} else if (projectType.value.id === "mod" && showAllLoaders.value) {
return tags.value.loaderData.modLoaders.includes(x.name);
} else if (projectType.value.id === "plugin") {
return tags.value.loaderData.pluginLoaders.includes(x.name);
} else if (projectType.value.id === "datapack") {
return tags.value.loaderData.dataPackLoaders.includes(x.name);
} else {
return x.supported_project_types.includes(projectType.value.actual);
}
})
.slice();
loaders.sort((a, b) => {
const isAHidden = tags.value.loaderData.hiddenModLoaders.includes(a.name);
const isBHidden = tags.value.loaderData.hiddenModLoaders.includes(b.name);
// Sort hidden mod loaders (true) after visible ones (false)
if (isAHidden && !isBHidden) return 1;
if (!isAHidden && isBHidden) return -1;
return 0; // No sorting if both are hidden or both are visible
});
if (loaders.length > 0) {
filters.loaders = loaders.map((x) => ({
icon: x.icon,
name: x.name,
type: "or",
facet: `categories:${x.name}`,
}));
}
if (projectType.value.id === "plugin") {
const platforms = tags.value.loaders.filter((x) =>
tags.value.loaderData.pluginPlatformLoaders.includes(x.name),
);
filters.platforms = platforms.map((x) => ({
icon: x.icon,
name: x.name,
type: "or",
facet: `categories:${x.name}`,
}));
}
}
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
if (!["resourcepack", "plugin", "shader", "datapack"].includes(projectType.value.id)) {
filters.environment = [
{ name: "client", type: "env" },
{ name: "server", type: "env" },
];
}
for (const category of data.$sortedCategories()) {
if (category.project_type === projectType.value.actual) {
const parsedCategory = {
name: category.name,
icon: category.icon,
facet: `categories:${category.name}`,
type: category.header === "resolutions" ? "or" : "normal",
};
if (filters[category.header]) {
filters[category.header].push(parsedCategory);
} else {
filters[category.header] = [parsedCategory];
}
}
}
filters.license = [{ name: "Open source only", type: "license" }];
const filteredObj = {};
for (const [key, value] of Object.entries(filters)) {
const filters = queryFilter.value
? value.filter((x) => x.name.toLowerCase().includes(queryFilter.value.toLowerCase()))
: value;
if (filters.length > 0) {
filteredObj[key] = filters;
}
}
return filteredObj;
});
function filterSelected(filter) {
if (filter.type === "or") {
return orFacets.value.includes(filter.facet);
} else if (filter.type === "normal") {
return facets.value.includes(filter.facet);
} else if (filter.type === "env") {
return selectedEnvironments.value.includes(filter.name);
} else if (filter.type === "gameVersion") {
return selectedVersions.value.includes(filter.name);
} else if (filter.type === "license") {
return onlyOpenSource.value;
}
}
function negativeFilterSelected(filter) {
if (filter.type === "or" || filter.type === "normal") {
return negativeFacets.value.includes(filter.facet);
}
}
function toggleNegativeFilter(filter) {
const elementName = filter.facet;
if (filterSelected(filter)) {
if (filter.type === "or") {
const index = orFacets.value.indexOf(elementName);
orFacets.value.splice(index, 1);
} else if (filter.type === "normal") {
const index = facets.value.indexOf(elementName);
facets.value.splice(index, 1);
}
}
if (filter.type === "or" || filter.type === "normal") {
const index = negativeFacets.value.indexOf(elementName);
if (index !== -1) {
negativeFacets.value.splice(index, 1);
} else {
negativeFacets.value.push(elementName);
}
}
onSearchChange(1);
}
function toggleFilter(filter, doNotSendRequest) {
const elementName = filter.facet;
if (negativeFilterSelected(filter)) {
const index = negativeFacets.value.indexOf(elementName);
negativeFacets.value.splice(index, 1);
}
if (filter.type === "or") {
const index = orFacets.value.indexOf(elementName);
if (index !== -1) {
orFacets.value.splice(index, 1);
} else {
if (elementName === "categories:purpur") {
if (!orFacets.value.includes("categories:paper")) {
orFacets.value.push("categories:paper");
}
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:paper") {
if (!orFacets.value.includes("categories:spigot")) {
orFacets.value.push("categories:spigot");
}
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:spigot") {
if (!orFacets.value.includes("categories:bukkit")) {
orFacets.value.push("categories:bukkit");
}
} else if (elementName === "categories:waterfall") {
if (!orFacets.value.includes("categories:bungeecord")) {
orFacets.value.push("categories:bungeecord");
}
}
orFacets.value.push(elementName);
}
} else if (filter.type === "normal") {
const index = facets.value.indexOf(elementName);
if (index !== -1) {
facets.value.splice(index, 1);
} else {
facets.value.push(elementName);
}
} else if (filter.type === "env") {
const index = selectedEnvironments.value.indexOf(filter.name);
if (index !== -1) {
selectedEnvironments.value.splice(index, 1);
} else {
selectedEnvironments.value.push(filter.name);
}
} else if (filter.type === "gameVersion") {
const index = selectedVersions.value.indexOf(filter.name);
if (index !== -1) {
selectedVersions.value.splice(index, 1);
} else {
selectedVersions.value.push(filter.name);
}
} else if (filter.type === "license") {
onlyOpenSource.value = !onlyOpenSource.value;
}
if (!doNotSendRequest) {
onSearchChange(1);
}
}
</script>
<style lang="scss" scoped>
@ -816,12 +874,6 @@ function setClosestMaxResults() {
@media screen and (min-width: 1024px) {
display: block;
}
// Hide on mobile unless open
display: none;
&.open {
display: block;
}
}
.filters-card {

View File

@ -171,7 +171,7 @@
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<div v-if="false" class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
@ -190,10 +190,10 @@
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarTitle) }}
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedSearchSidebarDescription) }}
{{ formatMessage(toggleFeatures.leftAlignedSearchSidebarDescription) }}
</span>
</label>
<input
@ -206,10 +206,10 @@
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarTitle) }}
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedProjectSidebarDescription) }}
{{ formatMessage(toggleFeatures.leftAlignedProjectSidebarDescription) }}
</span>
</label>
<input
@ -368,21 +368,21 @@ const toggleFeatures = defineMessages({
defaultMessage:
'Hides the "Get Modrinth App" buttons from primary navigation. The Modrinth App page can still be found on the landing page or in the footer.',
},
rightAlignedSearchSidebarTitle: {
id: "settings.display.sidebar.right-aligned-search-sidebar.title",
defaultMessage: "Right-aligned search sidebar",
leftAlignedSearchSidebarTitle: {
id: "settings.display.sidebar.Left-aligned-search-sidebar.title",
defaultMessage: "Left-aligned search sidebar",
},
rightAlignedSearchSidebarDescription: {
id: "settings.display.sidebar.right-aligned-search-sidebar.description",
defaultMessage: "Aligns the search filters sidebar to the right of the search results.",
leftAlignedSearchSidebarDescription: {
id: "settings.display.sidebar.left-aligned-search-sidebar.description",
defaultMessage: "Aligns the search filters sidebar to the left of the search results.",
},
rightAlignedProjectSidebarTitle: {
id: "settings.display.sidebar.right-aligned-project-sidebar.title",
defaultMessage: "Right-aligned project sidebar",
leftAlignedProjectSidebarTitle: {
id: "settings.display.sidebar.left-aligned-project-sidebar.title",
defaultMessage: "Left-aligned project sidebar",
},
rightAlignedProjectSidebarDescription: {
id: "settings.display.sidebar.right-aligned-project-sidebar.description",
defaultMessage: "Aligns the project details sidebar to the right of the page's content.",
leftAlignedProjectSidebarDescription: {
id: "settings.display.sidebar.left-aligned-project-sidebar.description",
defaultMessage: "Aligns the project details sidebar to the left of the page's content.",
},
});

View File

@ -1,169 +1,81 @@
<template>
<div v-if="user">
<div v-if="user" class="experimental-styles-within">
<ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar :src="user.avatar_url" size="md" circle :alt="user.username" />
<h1 class="username">
{{ user.username }}
</h1>
</div>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<div class="card sidebar">
<h1 class="mobile-username">
{{ user.username }}
</h1>
<div class="card__overlay">
<NuxtLink
v-if="auth.user && auth.user.id === user.id"
to="/settings/profile"
class="iconified-button"
>
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
<button
v-else-if="auth.user"
class="iconified-button"
@click="() => reportUser(user.id)"
>
<ReportIcon aria-hidden="true" />
{{ formatMessage(messages.profileReportButton) }}
</button>
<nuxt-link v-else class="iconified-button" to="/auth/sign-in">
<ReportIcon aria-hidden="true" />
{{ formatMessage(messages.profileReportButton) }}
</nuxt-link>
</div>
<div class="sidebar__item">
<Badge v-if="tags.staffRoles.includes(user.role)" :type="user.role" />
<Badge v-else-if="isPermission(user.badges, 1 << 0)" type="plus" />
<Badge v-else-if="projects.length > 0" type="creator" />
</div>
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
<hr class="card-divider" />
<div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileDownloadsStats"
:values="{ count: formatCompactNumber(sumDownloads) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<IntlFormatted
:message-id="messages.profileProjectsFollowersStats"
:values="{ count: formatCompactNumber(sumFollows) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(user.created),
time: new Date(user.created),
})
"
class="secondary-stat__text date"
>
{{
formatMessage(messages.profileJoinedAt, { ago: formatRelativeTime(user.created) })
}}
</span>
</div>
<hr class="card-divider" />
<div class="stats-block__item secondary-stat">
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
<span class="secondary-stat__text">
<IntlFormatted :message-id="messages.profileUserId">
<template #~id>
<CopyCode :text="user.id" />
</template>
</IntlFormatted>
</span>
</div>
<template v-if="organizations.length > 0">
<hr class="card-divider" />
<div class="stats-block__item">
<IntlFormatted :message-id="messages.profileOrganizations" />
<div class="organizations-grid">
<nuxt-link
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="xs" />
</nuxt-link>
<div class="new-page sidebar">
<div class="normal-page__header pt-4">
<div
class="mb-4 grid grid-cols-1 gap-x-8 gap-y-6 border-0 border-b border-solid border-button-bg pb-6 lg:grid-cols-[1fr_auto]"
>
<div class="flex gap-4">
<Avatar :src="user.avatar_url" :alt="user.username" size="96px" circle />
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold leading-none text-contrast">
{{ user.username }}
</h1>
</div>
<p class="m-0 line-clamp-2 max-w-[40rem]">
{{
user.bio
? user.bio
: projects.length === 0
? "A Modrinth user."
: "A Modrinth creator."
}}
</p>
</div>
</template>
</div>
<div class="flex flex-col justify-center gap-4">
<div class="flex flex-wrap gap-2">
<ButtonStyled size="large">
<NuxtLink v-if="auth.user && auth.user.id === user.id" to="/settings/profile">
<EditIcon />
{{ formatMessage(commonMessages.editButton) }}
</NuxtLink>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:options="[
{
id: 'manage-projects',
action: () => navigateTo('/dashboard/projects'),
hoverOnly: true,
shown: auth.user && auth.user.id === user.id,
},
{ divider: true, shown: auth.user && auth.user.id === user.id },
{
id: 'report',
action: () => reportUser(user.id),
color: 'red',
hoverOnly: true,
},
{ id: 'copy-id', action: () => copyId() },
]"
>
<MoreVerticalIcon />
<template #manage-projects>
<BoxIcon />
{{ formatMessage(messages.profileManageProjectsButton) }}
</template>
<template #report>
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</template>
<template #copy-id>
<ClipboardCopyIcon />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
<NavTabs :links="navLinks" />
</div>
</div>
<div class="normal-page__content">
<Promotion v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)" :external="false" />
<nav class="navigation-card">
<NavRow
:links="[
{
label: formatMessage(commonMessages.allProjectType),
href: `/user/${user.username}`,
},
...projectTypes.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/user/${user.username}/${x}s`,
};
}),
]"
/>
<div class="input-group">
<NuxtLink
v-if="auth.user && auth.user.id === user.id"
class="iconified-button"
to="/dashboard/projects"
>
<SettingsIcon />
{{ formatMessage(messages.profileManageProjectsButton) }}
</NuxtLink>
<button
v-if="route.params.projectType !== 'collections'"
v-tooltip="
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
"
:aria-label="
formatMessage(commonMessages[`${cosmetics.searchDisplayMode.user}InputView`])
"
class="square-button"
@click="cycleSearchDisplayMode()"
>
<GridIcon v-if="cosmetics.searchDisplayMode.user === 'grid'" />
<ImageIcon v-else-if="cosmetics.searchDisplayMode.user === 'gallery'" />
<ListIcon v-else />
</button>
</div>
</nav>
<div v-if="projects.length > 0">
<div
v-if="route.params.projectType !== 'collections'"
@ -268,7 +180,10 @@
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$refs.modal_collection_creation.show()">
<a
class="link"
@click.prevent="(event) => $refs.modal_collection_creation.show(event)"
>
<component :is="() => children" />
</a>
</template>
@ -277,33 +192,135 @@
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
</div>
</div>
<div class="normal-page__sidebar">
<AdPlaceholder />
<div class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileDetails) }}</h2>
<div class="flex items-center gap-2">
<BoxIcon aria-hidden="true" class="stroke-[3] text-secondary" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileProjectsStats"
:values="{ count: formatCompactNumber(projects.length) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<DownloadIcon aria-hidden="true" class="stroke-[3] text-secondary" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileDownloadsStats"
:values="{ count: formatCompactNumber(sumDownloads) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<HeartIcon aria-hidden="true" class="text-secondary *:stroke-[3]" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileProjectsFollowersStats"
:values="{ count: formatCompactNumber(sumFollows) }"
>
<template #stat="{ children }">
<span class="font-bold text-primary">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="flex items-center gap-2">
<CalendarIcon aria-hidden="true" class="text-secondary *:stroke-[3]" />
<div class="text-secondary">
<IntlFormatted
:message-id="messages.profileJoinedAt"
:values="{ ago: formatRelativeTime(user.created) }"
>
<template #date="{ children }">
<span class="font-bold text-primary">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
</div>
</div>
<div v-if="organizations.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileOrganizations) }}</h2>
<div class="flex flex-wrap gap-2">
<nuxt-link
v-for="org in organizations"
:key="org.id"
v-tooltip="org.name"
class="organization"
:to="`/organization/${org.slug}`"
>
<Avatar :src="org.icon_url" :alt="'Icon for ' + org.name" size="3rem" />
</nuxt-link>
</div>
</div>
<div v-if="badges.length > 0" class="card flex-card">
<h2 class="text-lg text-contrast">{{ formatMessage(messages.profileBadges) }}</h2>
<div class="flex flex-wrap gap-2">
<div v-for="badge in badges" :key="badge">
<StaffBadge v-if="badge === 'staff'" class="h-14 w-14" />
<ModBadge v-else-if="badge === 'mod'" class="h-14 w-14" />
<nuxt-link v-else-if="badge === 'plus'" to="/plus">
<PlusBadge class="h-14 w-14" />
</nuxt-link>
<TenMClubBadge v-else-if="badge === '10m-club'" class="h-14 w-14" />
<EarlyAdopterBadge v-else-if="badge === 'early-adopter'" class="h-14 w-14" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from "@modrinth/assets";
import { Promotion } from "@modrinth/ui";
import {
LibraryIcon,
BoxIcon,
LinkIcon,
LockIcon,
XIcon,
CalendarIcon,
DownloadIcon,
ClipboardCopyIcon,
MoreVerticalIcon,
} from "@modrinth/assets";
import { OverflowMenu, ButtonStyled } from "@modrinth/ui";
import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import Badge from "~/components/ui/Badge.vue";
import { reportUser } from "~/utils/report-helpers.ts";
import StaffBadge from "~/assets/images/badges/staff.svg?component";
import ModBadge from "~/assets/images/badges/mod.svg?component";
import PlusBadge from "~/assets/images/badges/plus.svg?component";
import TenMClubBadge from "~/assets/images/badges/10m-club.svg?component";
import EarlyAdopterBadge from "~/assets/images/badges/early-adopter.svg?component";
import ReportIcon from "~/assets/images/utils/report.svg?component";
import SunriseIcon from "~/assets/images/utils/sunrise.svg?component";
import DownloadIcon from "~/assets/images/utils/download.svg?component";
import SettingsIcon from "~/assets/images/utils/settings.svg?component";
import UpToDate from "~/assets/images/illustrations/up_to_date.svg?component";
import UserIcon from "~/assets/images/utils/user.svg?component";
import EditIcon from "~/assets/images/utils/edit.svg?component";
import HeartIcon from "~/assets/images/utils/heart.svg?component";
import GridIcon from "~/assets/images/utils/grid.svg?component";
import ListIcon from "~/assets/images/utils/list.svg?component";
import ImageIcon from "~/assets/images/utils/image.svg?component";
import WorldIcon from "~/assets/images/utils/world.svg?component";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import NavRow from "~/components/ui/NavRow.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Avatar from "~/components/ui/Avatar.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const data = useNuxtApp();
const route = useNativeRoute();
@ -319,28 +336,41 @@ const formatCompactNumber = useCompactNumber();
const formatRelativeTime = useRelativeTime();
const messages = defineMessages({
profileProjectsStats: {
id: "profile.stats.projects",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}",
},
profileDownloadsStats: {
id: "profile.stats.downloads",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> download} other {<stat>{count}</stat> downloads}}",
"{count, plural, one {<stat>{count}</stat> project download} other {<stat>{count}</stat> project downloads}}",
},
profileProjectsFollowersStats: {
id: "profile.stats.projects-followers",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> follower} other {<stat>{count}</stat> followers}} of projects",
"{count, plural, one {<stat>{count}</stat> project follower} other {<stat>{count}</stat> project followers}}",
},
profileJoinedAt: {
id: "profile.joined-at",
defaultMessage: "Joined {ago}",
defaultMessage: "Joined <date>{ago}</date>",
},
profileUserId: {
id: "profile.user-id",
defaultMessage: "User ID: {id}",
},
profileDetails: {
id: "profile.label.details",
defaultMessage: "Details",
},
profileOrganizations: {
id: "profile.label.organizations",
defaultMessage: "Organizations",
},
profileBadges: {
id: "profile.label.badges",
defaultMessage: "Badges",
},
profileManageProjectsButton: {
id: "profile.button.manage-projects",
defaultMessage: "Manage projects",
@ -353,10 +383,6 @@ const messages = defineMessages({
id: "profile.meta.description-with-bio",
defaultMessage: "{bio} - Download {username}'s projects on Modrinth",
},
profileReportButton: {
id: "profile.button.report",
defaultMessage: "Report",
},
profileNoProjectsLabel: {
id: "profile.label.no-projects",
defaultMessage: "This user has no projects!",
@ -485,12 +511,64 @@ const sumFollows = computed(() => {
return sum;
});
function cycleSearchDisplayMode() {
cosmetics.value.searchDisplayMode.user = data.$cycleValue(
cosmetics.value.searchDisplayMode.user,
tags.value.projectViewModes,
);
const badges = computed(() => {
const badges = [];
if (user.value.role === "admin") {
badges.push("staff");
}
if (user.value.role === "moderator") {
badges.push("mod");
}
if (isPermission(user.value.badges, 1 << 0)) {
badges.push("plus");
}
if (sumDownloads.value > 10000000) {
badges.push("10m-club");
}
if (
isPermission(user.value.badges, 1 << 1) ||
isPermission(user.value.badges, 1 << 2) ||
isPermission(user.value.badges, 1 << 3)
) {
badges.push("early-adopter");
}
if (isPermission(user.value.badges, 1 << 4)) {
badges.push("alpha-tester");
}
if (isPermission(user.value.badges, 1 << 5)) {
badges.push("contributor");
}
if (isPermission(user.value.badges, 1 << 6)) {
badges.push("translator");
}
return badges;
});
async function copyId() {
await navigator.clipboard.writeText(project.value.id);
}
const navLinks = computed(() => [
{
label: formatMessage(commonMessages.allProjectType),
href: `/user/${user.value.username}`,
},
...projectTypes.value.map((x) => {
return {
label: formatMessage(getProjectTypeMessage(x, true)),
href: `/user/${user.value.username}/${x}s`,
};
}),
]);
</script>
<script>
export default defineNuxtComponent({
@ -499,16 +577,6 @@ export default defineNuxtComponent({
</script>
<style lang="scss" scoped>
.organizations-grid {
// 5 wide
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
grid-gap: var(--gap-sm);
margin-top: 0.5rem;
}
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -579,107 +647,7 @@ export default defineNuxtComponent({
}
}
.user-header-wrapper {
display: flex;
margin: 0 auto -1.5rem;
max-width: 80rem;
.user-header {
position: relative;
z-index: 4;
display: flex;
width: 100%;
padding: 0 1rem;
gap: 1rem;
align-items: center;
.username {
display: none;
font-size: 2rem;
margin-bottom: 2.5rem;
}
}
}
.mobile-username {
margin: 0.25rem 0;
}
@media screen and (min-width: 501px) {
.mobile-username {
display: none;
}
.user-header-wrapper .user-header .username {
display: block;
}
}
.sidebar {
padding-top: 2.5rem;
}
.sidebar__item:not(:last-child) {
margin: 0 0 0.75rem 0;
}
.profile-picture {
border-radius: var(--size-rounded-lg);
height: 8rem;
width: 8rem;
}
.username {
font-size: var(--font-size-xl);
}
.bio {
display: block;
overflow-wrap: break-word;
}
.secondary-stat {
align-items: center;
display: flex;
margin-bottom: 0.8rem;
}
.secondary-stat__icon {
height: 1rem;
width: 1rem;
}
.secondary-stat__text {
margin-left: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.date {
cursor: default;
}
.inputs {
margin-bottom: 1rem;
input {
margin-top: 0.5rem;
width: 100%;
}
label {
margin-bottom: 0;
}
}
.textarea-wrapper {
height: 10rem;
}
@media (max-width: 400px) {
.sidebar {
padding-top: 3rem;
}
.normal-page__header {
grid-area: header;
}
</style>

View File

@ -18,7 +18,6 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.provide("formatProjectType", formatProjectType);
nuxtApp.provide("formatCategory", formatCategory);
nuxtApp.provide("formatCategoryHeader", formatCategoryHeader);
nuxtApp.provide("formatProjectStatus", formatProjectStatus);
/*
Only use on the complete list of versions for a project, partial lists will generate
@ -240,16 +239,6 @@ export const formatCategoryHeader = (name) => {
return capitalizeString(name);
};
export const formatProjectStatus = (name) => {
if (name === "approved") {
return "Listed";
} else if (name === "processing") {
return "Under review";
}
return capitalizeString(name);
};
export const formatVersions = (tag, versionArray) => {
const allVersions = tag.value.gameVersions.slice().reverse();
const allReleases = allVersions.filter((x) => x.version_type === "release");

1
apps/frontend/src/types/modrinth.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import "@modrinth/utils";

View File

@ -68,7 +68,7 @@ export const getDefaultColor = (value) => {
return defaultColors[value % defaultColors.length];
};
export const intToRgba = (color, projectId = "Unknown", theme = "dark") => {
export const intToRgba = (color, projectId = "Unknown", theme = "dark", alpha = "1") => {
const hash = hashProjectId(projectId);
if (!color || color === 0) {
@ -110,7 +110,7 @@ export const intToRgba = (color, projectId = "Unknown", theme = "dark") => {
g = Math.min(255, Math.max(0, g));
b = Math.min(255, Math.max(0, b));
return `rgba(${r}, ${g}, ${b}, 1)`;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
const emptyAnalytics = {

View File

@ -15,6 +15,10 @@ export const commonMessages = defineMessages({
id: "button.continue",
defaultMessage: "Continue",
},
copyIdButton: {
id: "button.copy-id",
defaultMessage: "Copy ID",
},
changesSavedLabel: {
id: "label.changes-saved",
defaultMessage: "Changes saved",
@ -91,6 +95,10 @@ export const commonMessages = defineMessages({
id: "label.rejected",
defaultMessage: "Rejected",
},
reportButton: {
id: "button.report",
defaultMessage: "Report",
},
passwordLabel: {
id: "label.password",
defaultMessage: "Password",

View File

@ -16,6 +16,7 @@ module.exports = {
icon: "var(--color-icon)",
// Text
primary: "var(--color-text)",
contrast: "var(--color-contrast)",
secondary: "var(--color-secondary)",
inactive: "var(--color-text-inactive)",
dark: "var(--color-text-dark)",
@ -24,22 +25,37 @@ module.exports = {
red: "var(--color-red)",
orange: "var(--color-orange)",
purple: "var(--color-purple)",
contrast: "var(--color-contrast)",
bg: {
DEFAULT: "var(--color-bg)",
red: "var(--color-red-bg)",
orange: "var(--color-orange-bg)",
green: "var(--color-green-bg)",
blue: "var(--color-blue-bg)",
purple: "var(--color-purple-bg)",
raised: "var(--color-raised-bg)",
},
highlight: {
DEFAULT: "var(--color-brand-highlight)",
red: "var(--color-red-highlight)",
orange: "var(--color-orange-highlight)",
green: "var(--color-green-highlight)",
blue: "var(--color-blue-highlight)",
purple: "var(--color-purple-highlight)",
},
divider: {
DEFAULT: "var(--color-divider)",
dark: "var(--color-divider-dark)",
},
brand: {
DEFAULT: "var(--color-brand)",
green: "var(--color-brand-green)",
red: "var(--color-red)",
orange: "var(--color-orange)",
green: "var(--color-green)",
blue: "var(--color-blue)",
purple: "var(--color-purple)",
highlight: "var(--color-brand-highlight)",
shadow: "var(--color-brand-shadow)",
inverted: "var(--color-brand-inverted)",
inverted: "var(--color-accent-contrast)",
},
tabUnderlineHovered: "var(--tab-underline-hovered)",
button: {

View File

@ -10,7 +10,8 @@
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
"build": "turbo run build --continue",
"lint": "turbo run lint --continue",
"test": "turbo run test --continue"
"test": "turbo run test --continue",
"fix": "turbo run fix --continue"
},
"devDependencies": {
"prettier": "^3.3.2",

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-big-up-dash"><path d="M9 19h6"/><path d="M9 15v-3H5l7-7 7 7h-4v3H9z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-text"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M8 7h6"/><path d="M8 11h8"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 13v6M21 10V8c0-.7-.4-1.4-1-1.7l-7-4c-.6-.4-1.4-.4-2 0l-7 4c-.6.3-1 1-1 1.7v8c0 .7.4 1.4 1 1.7l7 4c.6.4 1.4.4 2 0l2-1.1"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m3.3 7 8.7 5 8.7-5M12 22V12M22 16l-3 3-3-3"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-braces"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5c0-1.1.9-2 2-2a2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg>

After

Width:  |  Height:  |  Size: 380 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-library"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M7 7v10"/><path d="M11 7v10"/><path d="m15 7 2 10"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-compass"><path d="m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z"/><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-crown"><path d="m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7zm3 16h14"/></svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 244 B

View File

@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-filter-x"><path d="M13.013 3H2l8 9.46V19l4 2v-8.54l.9-1.055"/><path d="m22 3-5 5"/><path d="m17 3 5 5"/></svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gamepad-2"><line x1="6" x2="10" y1="11" y2="11"/><line x1="8" x2="8" y1="9" y2="13"/><line x1="15" x2="15.01" y1="12" y2="12"/><line x1="18" x2="18.01" y1="10" y2="10"/><path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/></svg>

After

Width:  |  Height:  |  Size: 672 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-glasses"><circle cx="6" cy="15" r="4"/><circle cx="18" cy="15" r="4"/><path d="M14 15a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M2.5 13 5 7c.7-1.3 1.4-2 3-2"/><path d="M21.5 13 19 7c-.7-1.3-1.5-2-3-2"/></svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-newspaper"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building-2"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"/><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"/><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"/><path d="M10 6h4"/><path d="M10 10h4"/><path d="M10 14h4"/><path d="M10 18h4"/></svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-package-open"><path d="M12 22v-9"/><path d="M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z"/><path d="M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13"/><path d="M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z"/></svg>

After

Width:  |  Height:  |  Size: 646 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-app-window"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/></svg>

After

Width:  |  Height:  |  Size: 325 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wrench"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@ -28,12 +28,17 @@ import _YouTubeIcon from './icons/youtube.svg?component'
// Icons
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
import _BookIcon from './icons/book.svg?component'
import _BookTextIcon from './icons/book-text.svg?component'
import _BookmarkIcon from './icons/bookmark.svg?component'
import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChartIcon from './icons/chart.svg?component'
@ -47,9 +52,12 @@ import _ClientIcon from './icons/client.svg?component'
import _ClipboardCopyIcon from './icons/clipboard-copy.svg?component'
import _CodeIcon from './icons/code.svg?component'
import _CoinsIcon from './icons/coins.svg?component'
import _CollectionIcon from './icons/collection.svg?component'
import _CompassIcon from './icons/compass.svg?component'
import _ContractIcon from './icons/contract.svg?component'
import _CopyIcon from './icons/copy.svg?component'
import _CopyrightIcon from './icons/copyright.svg?component'
import _CrownIcon from './icons/crown.svg?component'
import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component'
import _DownloadIcon from './icons/download.svg?component'
@ -62,10 +70,13 @@ import _EyeOffIcon from './icons/eye-off.svg?component'
import _FileIcon from './icons/file.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GitHubIcon from './icons/github.svg?component'
import _GlassesIcon from './icons/glasses.svg?component'
import _GlobeIcon from './icons/globe.svg?component'
import _GridIcon from './icons/grid.svg?component'
import _HamburgerIcon from './icons/hamburger.svg?component'
@ -95,7 +106,10 @@ import _MicrophoneIcon from './icons/microphone.svg?component'
import _MoonIcon from './icons/moon.svg?component'
import _MoreHorizontalIcon from './icons/more-horizontal.svg?component'
import _MoreVerticalIcon from './icons/more-vertical.svg?component'
import _NewspaperIcon from './icons/newspaper.svg?component'
import _OmorphiaIcon from './icons/omorphia.svg?component'
import _OrganizationIcon from './icons/organization.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PaintBrushIcon from './icons/paintbrush.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlusIcon from './icons/plus.svg?component'
@ -139,6 +153,8 @@ import _UserXIcon from './icons/user-x.svg?component'
import _UsersIcon from './icons/users.svg?component'
import _VersionIcon from './icons/version.svg?component'
import _WikiIcon from './icons/wiki.svg?component'
import _WindowIcon from './icons/window.svg?component'
import _WrenchIcon from './icons/wrench.svg?component'
import _XIcon from './icons/x.svg?component'
import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
@ -181,12 +197,17 @@ export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const AsteriskIcon = _AsteriskIcon
export const BanIcon = _BanIcon
export const BellIcon = _BellIcon
export const BellRingIcon = _BellRingIcon
export const BookIcon = _BookIcon
export const BookTextIcon = _BookTextIcon
export const BookmarkIcon = _BookmarkIcon
export const BoxIcon = _BoxIcon
export const BoxImportIcon = _BoxImportIcon
export const BracesIcon = _BracesIcon
export const CalendarIcon = _CalendarIcon
export const ChartIcon = _ChartIcon
export const CheckIcon = _CheckIcon
@ -199,9 +220,12 @@ export const ClientIcon = _ClientIcon
export const ClipboardCopyIcon = _ClipboardCopyIcon
export const CodeIcon = _CodeIcon
export const CoinsIcon = _CoinsIcon
export const CollectionIcon = _CollectionIcon
export const CompassIcon = _CompassIcon
export const ContractIcon = _ContractIcon
export const CopyIcon = _CopyIcon
export const CopyrightIcon = _CopyrightIcon
export const CrownIcon = _CrownIcon
export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon
export const DownloadIcon = _DownloadIcon
@ -215,10 +239,13 @@ export const EyeOffIcon = _EyeOffIcon
export const FileIcon = _FileIcon
export const FileTextIcon = _FileTextIcon
export const FilterIcon = _FilterIcon
export const FilterXIcon = _FilterXIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const GapIcon = _GapIcon
export const GameIcon = _GameIcon
export const GitHubIcon = _GitHubIcon
export const GlassesIcon = _GlassesIcon
export const GlobeIcon = _GlobeIcon
export const GridIcon = _GridIcon
export const HamburgerIcon = _HamburgerIcon
@ -248,7 +275,10 @@ export const MicrophoneIcon = _MicrophoneIcon
export const MoonIcon = _MoonIcon
export const MoreHorizontalIcon = _MoreHorizontalIcon
export const MoreVerticalIcon = _MoreVerticalIcon
export const NewspaperIcon = _NewspaperIcon
export const OmorphiaIcon = _OmorphiaIcon
export const OrganizationIcon = _OrganizationIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PaintBrushIcon = _PaintBrushIcon
export const PlayIcon = _PlayIcon
export const PlusIcon = _PlusIcon
@ -291,6 +321,8 @@ export const UserXIcon = _UserXIcon
export const UsersIcon = _UsersIcon
export const VersionIcon = _VersionIcon
export const WikiIcon = _WikiIcon
export const WindowIcon = _WindowIcon
export const WrenchIcon = _WrenchIcon
export const XIcon = _XIcon
export const XCircleIcon = _XCircleIcon
export const ZoomInIcon = _ZoomInIcon

View File

@ -476,6 +476,11 @@ a,
}
}
&.btn-large-round {
padding: 0.75rem;
border-radius: 50%;
}
&.btn-dropdown-animation {
svg:last-child {
transition: transform 0.125s ease-in-out;
@ -1085,7 +1090,8 @@ select {
box-shadow: var(--shadow-inset-sm);
border: none;
cursor: pointer;
padding-left: 0.5rem;
padding-left: 7px;
padding-top: 10px;
font-size: 1rem;
transition: background-color 0.1s ease-in-out;

View File

@ -135,7 +135,7 @@ input[type='number'] {
position: relative;
input {
padding: 0 2.5rem;
padding: 0 0.5rem 0 2.5rem;
width: 100%;
}

View File

@ -45,8 +45,15 @@ html {
--color-purple: #8e32f3;
--color-gray: #595b61;
--color-red-highlight: rgba(203, 34, 69, 0.25);
--color-orange-highlight: rgba(224, 131, 37, 0.25);
--color-green-highlight: rgba(0, 175, 92, 0.25);
--color-blue-highlight: rgba(31, 104, 192, 0.25);
--color-purple-highlight: rgba(142, 50, 243, 0.25);
--color-gray-highlight: rgba(89, 91, 97, 0.25);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(0, 175, 92, 0.25);
--color-brand-highlight: var(--color-green-highlight);
--color-brand-shadow: rgba(0, 175, 92, 0.7);
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 91%, 0.1);
@ -83,6 +90,13 @@ html {
--color-purple: #c78aff;
--color-gray: #9fa4b3;
--color-red-highlight: rgba(255, 73, 110, 0.25);
--color-orange-highlight: rgba(255, 163, 71, 0.25);
--color-green-highlight: rgba(27, 217, 106, 0.25);
--color-blue-highlight: rgba(79, 156, 255, 0.25);
--color-purple-highlight: rgba(199, 138, 255, 0.25);
--color-gray-highlight: rgba(159, 164, 179, 0.25);
--color-brand: var(--color-green);
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);

View File

@ -2,9 +2,14 @@
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
:style="`--_size: ${cssSize}`"
class="`experimental-styles-within avatar"
:class="{
circle: circle,
'no-shadow': noShadow,
raised: raised,
pixelated: raised,
}"
:src="src"
:alt="alt"
:loading="loading"
@ -12,9 +17,13 @@
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
raised ? 'raised' : ''
}`"
class="`experimental-styles-within avatar"
:style="`--_size: ${cssSize}`"
:class="{
circle: circle,
'no-shadow': noShadow,
raised: raised,
}"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
@ -35,12 +44,10 @@
</template>
<script setup>
import { ref } from 'vue'
const pixelated = ref(false)
const img = ref(null)
defineProps({
const props = defineProps({
src: {
type: String,
default: null,
@ -51,10 +58,7 @@ defineProps({
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xxs', 'xs', 'sm', 'md', 'lg', 'none'].includes(value)
},
default: '2rem',
},
circle: {
type: Boolean,
@ -66,7 +70,7 @@ defineProps({
},
loading: {
type: String,
default: 'lazy',
default: 'eager',
},
raised: {
type: Boolean,
@ -74,58 +78,43 @@ defineProps({
},
})
const LEGACY_PRESETS = {
xxs: '1.25rem',
xs: '2.5rem',
sm: '3rem',
md: '6rem',
lg: '9rem',
}
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
function updatePixelated() {
pixelated.value = Boolean(img.value && img.value.naturalWidth && img.value.naturalWidth <= 96)
if (img.value && img.value.naturalWidth && img.value.naturalWidth <= 96) {
pixelated.value = true
} else {
pixelated.value = false
}
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--radius-md);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
min-height: var(--size) !important;
min-width: var(--size) !important;
@apply min-w-[--_size] min-h-[--_size] w-[--_size] h-[--_size];
--_size: 2rem;
border: 1px solid var(--color-button-border);
background-color: var(--color-button-bg);
object-fit: cover;
max-width: var(--size) !important;
max-height: var(--size) !important;
&.size-xxs {
--size: 1.25rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--radius-sm);
}
&.size-md {
--size: 6rem;
border-radius: var(--radius-lg);
}
&.size-lg {
--size: 9rem;
border-radius: var(--radius-lg);
}
&.size-none {
--size: unset;
}
object-fit: contain;
border-radius: calc(16 / 96 * var(--_size));
&.circle {
border-radius: 50%;
}
&:not(.no-shadow) {
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
}
&.no-shadow {
box-shadow: none;
}

View File

@ -80,6 +80,13 @@ const classes = computed(() => {
:class="classes"
:to="link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
@ -91,6 +98,13 @@ const classes = computed(() => {
:class="classes"
:href="link"
:target="external ? '_blank' : '_self'"
@click="
(event) => {
if (action) {
action(event)
}
}
"
>
<slot />
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />

View File

@ -156,42 +156,97 @@ const colorVariables = computed(() => {
</div>
</template>
<style scoped>
<style scoped lang="scss">
.btn-wrapper {
display: contents;
}
/* Searches up to 4 children deep for valid button */
.btn-wrapper :slotted(:is(button, a):first-child),
.btn-wrapper :slotted(*) > :is(button, a):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a):first-child,
.btn-wrapper :slotted(*) > *:first-child > *:first-child > :is(button, a):first-child {
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent active:scale-95 hover:brightness-125 focus-visible:brightness-125 bg-[--_bg] text-[--_text] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out;
}
.btn-wrapper.outline :slotted(:is(button, a):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > *:first-child > :is(button, a):first-child {
@apply border-current;
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :slotted(:is(button, a):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a):first-child > svg:first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a):first-child > svg:first-child,
.btn-wrapper :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a):first-child
> :is(button, a, .button-like):first-child {
@apply flex flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
&[disabled],
&[disabled='true'],
&.disabled,
&.looks-disabled {
@apply opacity-50;
}
&[disabled],
&[disabled='true'],
&.disabled {
@apply cursor-not-allowed;
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-125 focus-visible:brightness-125 hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
}
}
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper.outline :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper.outline
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply border-current;
}
/*noinspection CssUnresolvedCustomProperty*/
.btn-wrapper :slotted(:is(button, a, .button-like):first-child) > svg:first-child,
.btn-wrapper :slotted(*) > :is(button, a, .button-like):first-child > svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child,
.btn-wrapper
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child
> svg:first-child {
min-width: var(--_icon-size, 1rem);
min-height: var(--_icon-size, 1rem);
}
.joined-buttons {
display: flex;
gap: 1px;
> .btn-wrapper:not(:first-child) {
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
> :not(:last-child) {
:slotted(:is(button, a, .button-like):first-child),
:slotted(*) > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
:slotted(*) > *:first-child > *:first-child > :is(button, a, .button-like):first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
}
/* guys, I know this is nuts, I know */
</style>

View File

@ -191,7 +191,7 @@ const isChildOfDropdown = (element) => {
<style lang="scss" scoped>
.animated-dropdown {
width: 20rem;
min-height: 40px;
height: 40px;
position: relative;
display: inline-block;
@ -267,6 +267,10 @@ const isChildOfDropdown = (element) => {
cursor: pointer;
user-select: none;
> label {
cursor: pointer;
}
&:hover {
filter: brightness(0.85);
transition: filter 0.2s ease-in-out;

View File

@ -8,8 +8,12 @@
>
<slot></slot>
<template #menu>
<template v-for="(option, index) in options">
<div v-if="option.divider" :key="`divider-${index}`" class="card-divider"></div>
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div
v-if="option.divider"
:key="`divider-${index}`"
class="h-px mx-3 my-2 bg-button-bg"
></div>
<Button
v-else
:key="`option-${option.id}`"
@ -19,8 +23,8 @@
transparent
:action="
option.action
? () => {
option.action()
? (event) => {
option.action(event)
if (!option.remainOnClick) {
close()
}
@ -45,29 +49,56 @@
</PopoutMenu>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import Button from './Button.vue'
import PopoutMenu from './PopoutMenu.vue'
defineProps({
options: {
type: Array,
required: true,
interface BaseOption {
shown?: boolean
}
interface Divider extends BaseOption {
divider?: boolean
}
interface Item extends BaseOption {
id: string
action?: () => void
link?: string
external?: boolean
color?:
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
hoverFilled?: boolean
hoverFilledOnly?: boolean
remainOnClick?: boolean
}
type Option = Divider | Item
const props = withDefaults(
defineProps<{
options: Option[]
disabled?: boolean
position?: string
direction?: string
}>(),
{
options: () => [],
disabled: false,
position: 'auto',
direction: 'auto',
},
disabled: {
type: Boolean,
default: false,
},
position: {
type: String,
default: 'bottom',
},
direction: {
type: String,
default: 'left',
},
})
)
defineOptions({
inheritAttrs: false,
})

View File

@ -1,15 +1,14 @@
<template>
<div v-if="count > 1" class="paginates">
<a
:class="{ disabled: page === 1 }"
:tabindex="page === 1 ? -1 : 0"
class="left-arrow paginate has-icon"
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="page !== 1 ? switchPage(page - 1) : null"
>
<LeftArrowIcon />
</a>
<div v-if="count > 1" class="flex items-center gap-1">
<ButtonStyled v-if="page > 1" circular type="transparent">
<a
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="switchPage(page - 1)"
>
<ChevronLeftIcon />
</a>
</ButtonStyled>
<div
v-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
@ -19,39 +18,37 @@
}"
class="page-number-container"
>
<div v-if="item === '-'" class="has-icon">
<div v-if="item === '-'">
<GapIcon />
</div>
<a
<ButtonStyled
v-else
:class="{
'page-number current': page === item,
shrink: item > 99,
}"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
circular
:color="page === item ? 'brand' : 'standard'"
:type="page === item ? 'standard' : 'transparent'"
>
{{ item }}
</a>
<a :href="linkFunction(item)" @click.prevent="page !== item ? switchPage(item) : null">
{{ item }}
</a>
</ButtonStyled>
</div>
<a
:class="{
disabled: page === pages[pages.length - 1],
}"
:tabindex="page === pages[pages.length - 1] ? -1 : 0"
class="right-arrow paginate has-icon"
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="page !== pages[pages.length - 1] ? switchPage(page + 1) : null"
>
<RightArrowIcon />
</a>
<ButtonStyled v-if="page !== pages[pages.length - 1]" circular type="transparent">
<a
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="switchPage(page + 1)"
>
<ChevronRightIcon />
</a>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { GapIcon, LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import { GapIcon, ChevronLeftIcon, ChevronRightIcon } from '@modrinth/assets'
import Button from './Button.vue'
import ButtonStyled from './ButtonStyled.vue'
const emit = defineEmits<{
'switch-page': [page: number]
@ -61,7 +58,7 @@ const props = withDefaults(
defineProps<{
page: number
count: number
linkFunction: (page: number) => string | undefined
linkFunction?: (page: number) => string | undefined
}>(),
{
page: 1,
@ -73,24 +70,31 @@ const props = withDefaults(
const pages = computed(() => {
let pages: ('-' | number)[] = []
if (props.count > 7) {
if (props.page + 3 >= props.count) {
pages = [
1,
'-',
props.count - 4,
props.count - 3,
props.count - 2,
props.count - 1,
props.count,
]
} else if (props.page > 5) {
pages = [1, '-', props.page - 1, props.page, props.page + 1, '-', props.count]
} else {
pages = [1, 2, 3, 4, 5, '-', props.count]
}
} else {
pages = Array.from({ length: props.count }, (_, i) => i + 1)
const first = 1
const last = props.count
const current = props.page
const prev = current - 1
const next = current + 1
const gap = '-'
if (prev > first) {
pages.push(first)
}
if (prev > first + 1) {
pages.push(gap)
}
if (prev >= first) {
pages.push(prev)
}
pages.push(current)
if (next <= last) {
pages.push(next)
}
if (next < last - 1) {
pages.push(gap)
}
if (next < last) {
pages.push(last)
}
return pages
@ -100,103 +104,3 @@ function switchPage(newPage: number) {
emit('switch-page', Math.min(Math.max(newPage, 1), props.count))
}
</script>
<style lang="scss" scoped>
.paginates {
display: flex;
}
a {
color: var(--color-contrast);
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: 0.5rem 1rem;
margin: 0;
border-radius: 2rem;
background: var(--color-raised-bg);
cursor: pointer;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
&:hover {
color: inherit;
text-decoration: none;
}
&.page-number.current {
background: var(--color-brand);
color: var(--color-accent-contrast);
cursor: default;
}
&.paginate.disabled {
background-color: transparent;
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&:hover:not(&:disabled) {
filter: brightness(0.85);
}
&:active:not(&:disabled) {
transform: scale(0.95);
filter: brightness(0.8);
}
}
.has-icon {
display: flex;
align-items: center;
svg {
width: 1em;
}
}
.page-number-container,
a,
.has-icon {
display: flex;
justify-content: center;
align-items: center;
}
.paginates {
height: 2em;
margin: 0.5rem 0;
> div,
.has-icon {
margin: 0 0.3em;
}
}
.left-arrow {
margin-left: auto !important;
}
.right-arrow {
margin-right: auto !important;
}
@media screen and (max-width: 400px) {
.paginates {
font-size: 80%;
}
}
@media screen and (max-width: 530px) {
a {
width: 2.5rem;
padding: 0.5rem 0;
}
}
</style>

View File

@ -11,7 +11,7 @@
</button>
<div
class="popup-menu"
:class="`position-${position}-${direction} ${dropdownVisible ? 'visible' : ''}`"
:class="`position-${computedPosition}-${computedDirection} ${dropdownVisible ? 'visible' : ''}`"
>
<slot name="menu"> </slot>
</div>
@ -28,11 +28,11 @@ const props = defineProps({
},
position: {
type: String,
default: 'bottom',
default: 'auto',
},
direction: {
type: String,
default: 'left',
default: 'auto',
},
})
defineOptions({
@ -42,6 +42,31 @@ defineOptions({
const dropdownVisible = ref(false)
const dropdown = ref(null)
const dropdownButton = ref(null)
const computedPosition = ref('bottom')
const computedDirection = ref('left')
function updateDirection() {
if (props.direction === 'auto') {
if (dropdownButton.value) {
const x = dropdownButton.value.getBoundingClientRect().left
computedDirection.value = x < window.innerWidth / 2 ? 'right' : 'left'
} else {
computedDirection.value = 'left'
}
} else {
computedDirection.value = props.direction
}
if (props.position === 'auto') {
if (dropdownButton.value) {
const y = dropdownButton.value.getBoundingClientRect().top
computedPosition.value = y < window.innerHeight / 2 ? 'bottom' : 'top'
} else {
computedPosition.value = 'bottom'
}
} else {
computedPosition.value = props.position
}
}
const toggleDropdown = () => {
if (!props.disabled) {
@ -79,10 +104,15 @@ const handleClickOutside = (event) => {
onMounted(() => {
window.addEventListener('click', handleClickOutside)
window.addEventListener('resize', updateDirection)
window.addEventListener('scroll', updateDirection)
updateDirection()
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', updateDirection)
window.removeEventListener('scroll', updateDirection)
})
</script>

View File

@ -0,0 +1,138 @@
<template>
<div class="scrollable-pane-wrapper" :class="{ 'max-height': !props.noMaxHeight }">
<div
class="wrapper-wrapper"
:class="{
'top-fade': !scrollableAtTop && !props.noMaxHeight,
'bottom-fade': !scrollableAtBottom && !props.noMaxHeight,
}"
>
<div ref="scrollablePane" class="scrollable-pane" @scroll="onScroll">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = withDefaults(
defineProps<{
noMaxHeight?: boolean
}>(),
{
noMaxHeight: false,
},
)
const scrollableAtTop = ref(true)
const scrollableAtBottom = ref(false)
const scrollablePane = ref(null)
let resizeObserver
onMounted(() => {
resizeObserver = new ResizeObserver(function () {
if (scrollablePane.value) {
updateFade(
scrollablePane.value.scrollTop,
scrollablePane.value.offsetHeight,
scrollablePane.value.scrollHeight,
)
}
})
resizeObserver.observe(scrollablePane.value)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
}
})
function updateFade(scrollTop, offsetHeight, scrollHeight) {
scrollableAtBottom.value = Math.ceil(scrollTop + offsetHeight) >= scrollHeight
scrollableAtTop.value = scrollTop === 0
}
function onScroll({ target: { scrollTop, offsetHeight, scrollHeight } }) {
updateFade(scrollTop, offsetHeight, scrollHeight)
}
</script>
<style lang="scss" scoped>
.scrollable-pane-wrapper {
display: flex;
flex-direction: column;
position: relative;
&.max-height {
max-height: 19rem;
}
}
.wrapper-wrapper {
flex-grow: 1;
display: flex;
overflow: hidden;
position: relative;
--_fade-height: 4rem;
margin-bottom: var(--gap-sm);
&.top-fade::before,
&.bottom-fade::after {
opacity: 1;
}
&::before,
&::after {
content: '';
left: 0;
right: 0;
opacity: 0;
position: absolute;
pointer-events: none;
transition: opacity 0.125s ease;
height: var(--_fade-height);
z-index: 1;
}
&::before {
top: 0;
background-image: linear-gradient(
var(--scrollable-pane-bg, var(--color-raised-bg)),
transparent
);
}
&::after {
bottom: 0;
background-image: linear-gradient(
transparent,
var(--scrollable-pane-bg, var(--color-raised-bg))
);
}
}
.scrollable-pane {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
width: 100%;
overflow-y: auto;
overflow-x: hidden;
position: relative;
::-webkit-scrollbar {
transition: all;
}
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
padding: 4px;
border: 3px solid var(--color-bg);
}
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div class="flex items-center gap-3">
<slot></slot>
<div class="flex flex-col">
<span class="font-bold">{{ value }}</span>
<span class="text-secondary">{{ label }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
label: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
})
</script>
<style scoped>
:slotted(*) {
@apply h-6 w-6 text-secondary;
}
</style>

View File

@ -21,7 +21,9 @@ export { default as Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as Promotion } from './base/Promotion.vue'
export { default as ScrollablePanel } from './base/ScrollablePanel.vue'
export { default as Slider } from './base/Slider.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as Toggle } from './base/Toggle.vue'
// Branding
@ -51,3 +53,6 @@ export { default as SearchFilter } from './search/SearchFilter.vue'
// Billing
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
// Version
export { default as VersionChannelIndicator } from './version/VersionChannelIndicator.vue'

View File

@ -1,6 +1,11 @@
<template>
<Modal ref="modal" :header="title" :noblur="noblur">
<div class="modal-delete">
<NewModal ref="modal" :noblur="noblur" danger>
<template #title>
<slot name="title">
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot>
</template>
<div>
<div class="markdown-body" v-html="renderString(description)" />
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
@ -19,25 +24,30 @@
@input="type"
/>
</div>
<div class="input-group push-right">
<button class="btn" @click="modal.hide()">
<XIcon />
Cancel
</button>
<button class="btn btn-danger" :disabled="action_disabled" @click="proceed">
<TrashIcon />
{{ proceedLabel }}
</button>
<div class="flex gap-2 mt-6">
<ButtonStyled color="red">
<button :disabled="action_disabled" @click="proceed">
<TrashIcon />
{{ proceedLabel }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</Modal>
</NewModal>
</template>
<script setup>
import { renderString } from '@modrinth/utils'
import { ref } from 'vue'
import { TrashIcon, XIcon } from '@modrinth/assets'
import Modal from './Modal.vue'
import NewModal from './NewModal.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = defineProps({
confirmationText: {
@ -92,36 +102,3 @@ function show() {
defineExpose({ show })
</script>
<style scoped lang="scss">
.modal-delete {
padding: var(--gap-lg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
margin: 0 0.25rem;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@ -1,5 +1,8 @@
<template>
<div v-if="open">
<div
v-if="open"
:style="`${mouseX !== -1 ? `--_mouse-x: ${mouseX};` : ''} ${mouseY !== -1 ? `--_mouse-y: ${mouseY};` : ''}`"
>
<div
:class="{ shown: visible }"
class="tauri-overlay"
@ -10,15 +13,22 @@
:class="{
shown: visible,
noblur: props.noblur,
danger: danger,
}"
class="modal-overlay"
@click="() => (closable ? hide() : {})"
/>
<div class="modal-container" :class="{ shown: visible }">
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl p-6">
<div class="flex items-center pb-6 border-b-[1px] border-button-bg">
<div class="flex flex-grow items-center gap-3">
<slot name="title" />
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
<div
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-button-bg max-w-full"
>
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
<slot name="title">
<span v-if="header" class="text-lg font-extrabold text-contrast">
{{ header }}
</span>
</slot>
</div>
<ButtonStyled v-if="closable" circular>
<button @click="hide">
@ -26,8 +36,8 @@
</button>
</ButtonStyled>
</div>
<div class="overflow-y-auto">
<slot> You just lost the game. </slot>
<div class="overflow-y-auto p-6">
<slot> You just lost the game.</slot>
</div>
</div>
</div>
@ -35,27 +45,42 @@
<div v-else></div>
</template>
<script setup>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = defineProps({
noblur: {
type: Boolean,
default: false,
const props = withDefaults(
defineProps<{
noblur?: boolean
closable?: boolean
danger?: boolean
closeOnEsc?: boolean
warnOnClose?: boolean
header?: string
}>(),
{
type: true,
closable: true,
danger: false,
closeOnEsc: true,
warnOnClose: false,
},
closable: {
type: Boolean,
default: true,
},
})
)
const open = ref(false)
const visible = ref(false)
function show() {
function show(event?: MouseEvent) {
open.value = true
window.addEventListener('mousedown', updateMousePosition)
window.addEventListener('keydown', handleKeyDown)
if (event) {
updateMousePosition(event)
} else {
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2
}
setTimeout(() => {
visible.value = true
}, 50)
@ -63,6 +88,8 @@ function show() {
function hide() {
visible.value = false
window.removeEventListener('mousedown', updateMousePosition)
window.removeEventListener('keydown', handleKeyDown)
setTimeout(() => {
open.value = false
}, 300)
@ -72,6 +99,22 @@ defineExpose({
show,
hide,
})
const mouseX = ref(-1)
const mouseY = ref(-1)
function updateMousePosition(event: { clientX: number; clientY: number }) {
mouseX.value = event.clientX
mouseY.value = event.clientY
}
function handleKeyDown(event: KeyboardEvent) {
if (props.closeOnEsc && event.key === 'Escape') {
hide()
mouseX.value = window.innerWidth / 2
mouseY.value = window.innerHeight / 2
}
}
</script>
<style lang="scss" scoped>
@ -91,15 +134,18 @@ defineExpose({
}
.modal-overlay {
visibility: hidden;
position: fixed;
inset: -5rem;
z-index: 19;
opacity: 0;
transition: all 0.2s ease-out;
background: linear-gradient(to bottom, rgba(27, 48, 42, 0.52) 0%, rgba(13, 21, 26, 0.95) 100%);
transform: translateY(2rem) scale(0.8);
border-radius: 120px;
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
//transform: translate(
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
// )
// scaleX(0.8) scaleY(0.5);
border-radius: 180px;
filter: blur(5px);
@media (prefers-reduced-motion) {
@ -110,7 +156,7 @@ defineExpose({
opacity: 1;
visibility: visible;
backdrop-filter: blur(5px);
transform: translateY(0) scale(1);
transform: translate(0, 0) scaleX(1) scaleY(1);
border-radius: 0px;
}
@ -118,6 +164,10 @@ defineExpose({
backdrop-filter: none;
filter: none;
}
&.danger {
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
}
}
.modal-container {
@ -132,13 +182,19 @@ defineExpose({
z-index: 21;
visibility: hidden;
pointer-events: none;
transform: translate(
calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 16),
calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 16)
);
transition: all 0.2s ease-out;
&.shown {
visibility: visible;
transform: translate(0, 0);
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
scale: 1;
}
}
@ -149,11 +205,11 @@ defineExpose({
max-height: calc(100% - 2 * var(--gap-lg));
max-width: min(var(--_max-width, 60rem), calc(100% - 2 * var(--gap-lg)));
overflow-y: auto;
overflow-x: hidden;
width: fit-content;
pointer-events: auto;
scale: 0.97;
transform: translateY(1rem);
visibility: hidden;
opacity: 0;
transition: all 0.2s ease-in-out;

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { useVIntl, defineMessages } from '@vintl/vintl'
import { computed } from 'vue'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
channel: 'release' | 'beta' | 'alpha'
large?: boolean
}>(),
{
large: false,
},
)
const messages = defineMessages({
releaseSymbol: {
id: 'project.versions.channel.release.symbol',
defaultMessage: 'R',
},
betaSymbol: {
id: 'project.versions.channel.beta.symbol',
defaultMessage: 'B',
},
alphaSymbol: {
id: 'project.versions.channel.alpha.symbol',
defaultMessage: 'A',
},
})
</script>
<template>
<div
:style="`--_size: ${size};`"
:class="`flex ${large ? 'text-lg w-[2.625rem] h-[2.625rem]' : 'text-sm w-9 h-9'} font-bold justify-center items-center rounded-full ${channel === 'release' ? 'bg-bg-green text-brand-green' : channel === 'beta' ? 'bg-bg-orange text-brand-orange' : 'bg-bg-red text-brand-red'}`"
>
{{ channel ? formatMessage(messages[`${channel}Symbol`]) : '?' }}
</div>
</template>

246
packages/utils/index.d.ts vendored Normal file
View File

@ -0,0 +1,246 @@
const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' as const;
type Base62Char = typeof BASE62_CHARS[number];
declare global {
type ModrinthId = `${Base62Char}`[];
type Environment = 'required' | 'optional' | 'unsupported' | 'unknown';
type RequestableStatus = 'approved' | 'archived' | 'unlisted' | 'private'
type ApprovedStatus = RequestableStatus | 'scheduled'
type UnapprovedStatus = 'draft' | 'processing' | 'rejected' | 'withheld'
type ProjectStatus = ApprovedStatus | UnapprovedStatus | 'unknown'
type DonationPlatform =
{ short: "patreon", name: "Patreon" }
| { short: "bmac", name: "Buy Me A Coffee" }
| { short: "paypal", name: "PayPal" }
| { short: "github", name: "GitHub Sponsors" }
| { short: "ko-fi", name: "Ko-fi" }
|
| { short: "other", name: "Other" }
type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader'
type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
type GameVersion = string;
type Platform = string;
type Category = string;
type CategoryOrPlatform = Category | Platform
interface DonationLink<T extends DonationPlatform> {
id: T["short"],
platform: T["name"],
url: string
}
interface GalleryImage {
url: string,
featured: boolean,
created: string,
ordering: number,
title?: string,
description?: string,
}
interface Project {
id: ModrinthId,
project_type: ProjectType,
slug: string,
title: string,
description: string,
status: ProjectStatus,
requested_status: RequestableStatus,
monetization_status: MonetizationStatus,
body: string,
icon_url?: string,
color?: number,
categories: Category[],
additional_categories: Category[],
downloads: number,
followers: number,
client_side: Environment,
server_side: Environment,
team: ModrinthId,
thread_id: ModrinthId,
issues_url?: string,
source_url?: string,
wiki_url?: string,
discord_url?: string,
donation_urls: DonationLink[],
published: string,
updated: string,
approved: string,
queued: string,
game_versions: GameVersion[],
loaders: Platform[],
versions: ModrinthId[],
gallery?: GalleryImage[]
license: {
id: string,
name, string,
url?: string,
},
}
interface SearchResult {
id: ModrinthId,
project_type: ProjectType,
slug: string,
title: string,
description: string,
monetization_status: MonetizationStatus,
icon_url?: string,
color?: number,
categories: CategoryOrPlatform[],
display_categories: CategoryOrPlatform[],
versions: GameVersion[],
latest_version: GameVersion,
downloads: number,
follows: number,
client_side: Environment,
server_side: Environment,
author: string,
date_created: string,
date_modified: string,
gallery: string[]
featured_gallery?: string[]
license: string,
}
type DependencyType = 'required' | 'optional' | 'incompatible' | 'embedded'
interface VersionDependency {
dependency_type: DependencyType,
file_name?: string,
}
interface ProjectDependency {
dependency_type: DependencyType,
project_id?: string,
}
interface FileDependency {
dependency_type: DependencyType,
file_name?: string,
}
type Dependency = VersionDependency | ProjectDependency | FileDependency
type VersionChannel = 'release' | 'beta' | 'alpha'
type VersionStatus = 'listed' | 'archived' | 'draft' | 'unlisted' | 'scheduled' | 'unknown'
type FileType = 'required-resource-pack' | 'optional-resource-pack'
interface VersionFileHash {
sha512: string,
sha1: string,
}
interface VersionFile {
hashes: VersionFileHash[],
url: string,
filename: string,
primary: boolean,
size: number,
file_type?: FileType
}
interface Version {
name: string,
version_number: string,
changelog?: string,
dependencies: Dependency[],
game_versions: GameVersion[],
version_type: VersionChannel,
loaders: Platform[],
featured: boolean,
status: VersionStatus,
id: ModrinthId,
project_id: ModrinthId,
author_id: ModrinthId,
date_published: string,
downloads: number,
files: VersionFile[],
}
interface PayoutData {
balance: number,
payout_wallet: 'paypal' | 'venmo',
payout_wallet_type: 'email' | 'phone' | 'user_handle',
payout_address: string,
}
type UserRole = 'admin' | 'moderator' | 'pyro' | 'developer'
enum UserBadge {
MIDAS = 1 << 0,
EARLY_MODPACK_ADOPTER = 1 << 1,
EARLY_RESPACK_ADOPTER = 1 << 2,
EARLY_PLUGIN_ADOPTER = 1 << 3,
ALPHA_TESTER = 1 << 4,
CONTRIBUTOR = 1 << 5,
TRANSLATOR = 1 << 6,
}
type UserBadges = number;
interface User {
username: string,
email?: string,
bio?: string,
payout_data?: PayoutData,
id: ModrinthId,
avatar_url: string,
created: string,
role: UserRole,
badges: UserBadges,
auth_providers?: string[],
email_verified?: boolean,
has_password?: boolean,
has_totp?: boolean
}
enum TeamMemberPermission {
UPLOAD_VERSION = 1 << 0,
DELETE_VERSION = 1 << 1,
EDIT_DETAILS = 1 << 2,
EDIT_BODY = 1 << 3,
MANAGE_INVITES = 1 << 4,
REMOVE_MEMBER = 1 << 5,
EDIT_MEMBER = 1 << 6,
DELETE_PROJECT = 1 << 7,
VIEW_ANALYTICS = 1 << 8,
VIEW_PAYOUTS = 1 << 9,
}
type TeamMemberPermissions = number;
interface TeamMember {
team_id: ModrinthId,
user: User,
role: string,
permissions: TeamMemberPermissions,
accepted: boolean,
payouts_split: number,
ordering: number,
}
}

View File

@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"main": "./index.ts",
"types": "./index.ts",
"types": "./index.d.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."

Some files were not shown because too many files have changed in this diff Show More