Migrate to Turborepo (#1251)

This commit is contained in:
Evan Song
2024-07-04 21:46:29 -07:00
committed by GitHub
parent 6fa1acc461
commit 0f2ddb452c
811 changed files with 5623 additions and 7832 deletions

View File

@@ -0,0 +1,101 @@
<template>
<div>
<svg
class="rotate outer"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M134.44,316.535C145.027,441.531 249.98,539.829 377.711,539.829C474.219,539.829 557.724,483.712 597.342,402.371L645.949,419.197C599.165,520.543 496.595,590.954 377.711,590.954C221.751,590.954 93.869,469.779 83.161,316.535L134.44,316.535ZM83.946,265.645C99.012,116.762 224.88,0.401 377.711,0.401C540.678,0.401 672.987,132.71 672.987,295.677C672.987,321.817 669.583,347.168 663.194,371.313L614.709,354.529C619.381,335.689 621.862,315.971 621.862,295.677C621.862,160.926 512.461,51.526 377.711,51.526C253.133,51.526 150.223,145.03 135.392,265.645L83.946,265.645Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
class="rotate inner"
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M376.933,153.568C298.44,153.644 234.735,217.396 234.735,295.909C234.735,374.47 298.516,438.251 377.077,438.251C381.06,438.251 385.005,438.087 388.914,437.764L403.128,487.517C394.611,488.667 385.912,489.261 377.077,489.261C270.363,489.261 183.725,402.623 183.725,295.909C183.725,189.195 270.363,102.557 377.077,102.557C379.723,102.557 382.357,102.611 384.983,102.717L376.933,153.568ZM435.127,111.438C513.515,136.114 570.428,209.418 570.428,295.909C570.428,375.976 521.655,444.742 452.22,474.093L438.063,424.541C486.142,401.687 519.418,352.653 519.418,295.909C519.418,234.923 480.981,182.843 427.029,162.593L435.127,111.438Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
<svg
width="100%"
height="100%"
viewBox="0 0 590 591"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
>
<g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M300.366,311.86L283.216,266.381L336.966,211.169L404.9,196.531L424.57,220.74L393.254,252.46L365.941,261.052L346.425,281.11L355.987,307.719L375.387,328.306L402.745,321.031L422.216,299.648L464.729,286.185L477.395,314.677L433.529,368.46L360.02,391.735L327.058,355.031L138.217,468.344C129.245,456.811 118.829,440.485 112.15,424.792L300.366,311.86Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
<g transform="matrix(0.24,0,0,0.24,0,0)">
<path
d="M655.189,194.555L505.695,234.873C513.927,256.795 516.638,269.674 518.915,283.863L668.152,243.609C665.764,227.675 661.5,211.444 655.189,194.555Z"
style="fill: var(--color-brand)"
/>
</g>
</g>
</g>
</svg>
</div>
</template>
<style lang="scss" scoped>
div {
display: flex;
justify-content: center;
align-items: center;
height: 5rem;
margin-top: 1rem;
svg {
width: 5rem;
height: 5rem;
position: absolute;
&.rotate {
animation: rotate 4s infinite linear;
&.inner {
animation: rotate 6s infinite linear reverse;
}
}
@keyframes rotate {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
}
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 3307 593"
:class="{ animate: loading }"
>
<!-- modrinth -->
<path
v-if="api === 'prod'"
fill="currentColor"
fill-rule="nonzero"
d="M1053.02 205.51c35.59 0 64.27 10.1 84.98 30.81 20.72 21.25 31.34 52.05 31.34 93.48v162.53h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.55-18.6-47.27-18.6-22.3 0-40.37 7.45-53.65 21.79-13.27 14.87-20.18 36.11-20.18 63.2v143.94h-66.4V338.3c0-24.96-5.3-43.55-16.46-56.3-11.15-12.22-26.56-18.6-47.27-18.6-22.84 0-40.37 7.45-53.65 21.79-13.27 14.34-20.18 35.58-20.18 63.2v143.94h-66.4V208.7h63.21v36.12c10.63-12.75 23.9-22.3 39.84-29.21 15.93-6.9 33.46-10.1 53.11-10.1 21.25 0 40.37 3.72 56.84 11.69 16.46 8.5 29.21 20.18 38.77 35.59 11.69-14.88 26.56-26.56 45.15-35.06 18.59-7.97 38.77-12.22 61.08-12.22Zm329.84 290.54c-28.68 0-54.7-6.37-77.54-18.59a133.19 133.19 0 0 1-53.65-52.05c-13.28-21.78-19.65-46.74-19.65-74.9 0-28.14 6.37-53.1 19.65-74.88a135.4 135.4 0 0 1 53.65-51.53c22.84-12.21 48.86-18.59 77.54-18.59 29.22 0 55.24 6.38 78.08 18.6 22.84 12.21 40.9 29.74 54.18 51.52 12.75 21.77 19.12 46.74 19.12 74.89s-6.37 53.11-19.12 74.89c-13.28 22.3-31.34 39.83-54.18 52.05-22.84 12.22-48.86 18.6-78.08 18.6Zm0-56.83c24.44 0 44.62-7.97 60.55-24.43 15.94-16.47 23.9-37.72 23.9-64.27 0-26.56-7.96-47.8-23.9-64.27-15.93-16.47-36.11-24.43-60.55-24.43-24.43 0-44.61 7.96-60.02 24.43-15.93 16.46-23.9 37.71-23.9 64.27 0 26.55 7.97 47.8 23.9 64.27 15.4 16.46 35.6 24.43 60.02 24.43Zm491.32-341v394.11h-63.74v-36.65a108.02 108.02 0 0 1-40.37 30.28c-16.46 6.9-34 10.1-53.65 10.1-27.08 0-51.52-5.85-73.3-18.07-21.77-12.21-39.3-29.21-51.52-51.52-12.21-21.78-18.59-47.27-18.59-75.95s6.38-54.18 18.6-75.96c12.21-21.77 29.74-38.77 51.52-50.99 21.77-12.21 46.2-18.06 73.3-18.06 18.59 0 36.11 3.2 51.52 9.56a106.35 106.35 0 0 1 39.83 28.69V98.22h66.4Zm-149.79 341c15.94 0 30.28-3.72 43.03-11.16 12.74-6.9 22.83-17.52 30.27-30.8 7.44-13.28 11.15-29.21 11.15-46.74s-3.71-33.46-11.15-46.74c-7.44-13.28-17.53-23.9-30.27-31.34-12.75-6.9-27.1-10.62-43.03-10.62s-30.27 3.71-43.02 10.62c-12.75 7.43-22.84 18.06-30.28 31.34-7.43 13.28-11.15 29.2-11.15 46.74 0 17.53 3.72 33.46 11.15 46.74 7.44 13.28 17.53 23.9 30.28 30.8 12.75 7.44 27.09 11.16 43.02 11.16Zm298.51-189.09c19.12-29.74 52.58-44.62 100.92-44.62v63.21a84.29 84.29 0 0 0-15.4-1.6c-26.03 0-46.22 7.44-60.56 22.32-14.34 15.4-21.78 37.18-21.78 65.33v137.56h-66.39V208.7h63.2v41.43Zm155.63-41.43h66.39v283.63h-66.4V208.7Zm33.46-46.74c-12.22 0-22.31-3.72-30.28-11.68a37.36 37.36 0 0 1-12.21-28.16c0-11.15 4.25-20.71 12.21-28.68 7.97-7.43 18.06-11.15 30.28-11.15 12.21 0 22.3 3.72 30.27 10.62 7.97 7.44 12.22 16.47 12.22 27.62 0 11.69-3.72 21.25-11.69 29.21-7.96 7.97-18.59 12.22-30.8 12.22Zm279.38 43.55c35.59 0 64.27 10.63 86.05 31.34 21.78 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.52-56.3-11.69-12.22-28.15-18.6-49.93-18.6-24.43 0-43.55 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V208.7h63.21v36.65c11.16-13.28 24.97-22.84 41.43-29.74 16.47-6.9 35.59-10.1 56.3-10.1Zm371.81 271.42a78.34 78.34 0 0 1-28.15 14.34 130.83 130.83 0 0 1-35.6 4.78c-31.33 0-55.23-7.97-72.23-24.43-17-16.47-25.5-39.84-25.5-71.17V263.94h-46.73v-53.11h46.74v-64.8h66.4v64.8h75.95v53.11h-75.96v134.91c0 13.81 3.19 24.43 10.1 31.34 6.9 7.44 16.46 11.15 29.2 11.15 14.88 0 27.1-3.71 37.19-11.68l18.59 47.27Zm214.05-271.42c35.59 0 64.27 10.63 86.05 31.34 21.77 20.72 32.4 52.05 32.4 92.95v162.53h-66.4V338.3c0-24.96-5.84-43.55-17.53-56.3-11.68-12.22-28.15-18.6-49.92-18.6-24.44 0-43.56 7.45-57.9 21.79-14.34 14.87-21.24 36.11-21.24 63.73v143.41h-66.4V98.23h66.4v143.4c11.15-11.68 24.43-20.71 40.9-27.09 15.93-5.84 33.99-9.03 53.64-9.03Z"
/>
<!-- staging -->
<path
v-else-if="api === 'staging'"
fill="currentColor"
fill-rule="nonzero"
d="M782.3,488.9c-23.6,0-46.3-3.1-68-9.4s-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4 c17.6,5.4,35.3,8,52.9,8c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4 c-10.2-2.1-21.6-4.1-34.1-5.9c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8 c-6.3-10-9.5-23.2-9.5-39.6c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11 c19,0,38.3,2.1,57.7,6.4c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6 c-20.1,0-35,3-44.7,9.1c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8C831.5,485.2,808.7,488.9,782.3,488.9z M929.9,254.8v-53.6h188.3v53.6 H929.9z M1073.8,488.9c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V135.9h66.1v254.9c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C1097.4,487.3,1085.8,488.9,1073.8,488.9z M1276.9,488.9c-21.2,0-39.7-3.7-55.5-11c-15.9-7.3-28.1-17.5-36.8-30.5 c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2 c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9c9.5,7.1,22.7,10.7,39.7,10.7 c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1322.7,484.2,1302,488.9,1276.9,488.9z M1365.8,485.1v-57.8l-3.7-12.3V313.7c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1365.8z M1633,472.3 c-26.8,0-51.1-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C1678.4,467.4,1656.9,472.3,1633,472.3z M1641.4,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C1728.5,580.5,1690.8,592.8,1641.4,592.8z M1643,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S1626.4,415.5,1643,415.5z M1913.9,152c-12.3,0-22.5-3.9-30.4-11.8 c-7.9-7.8-11.9-17.3-11.9-28.4c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11 c7.9,7.3,11.9,16.5,11.9,27.6c0,11.8-3.9,21.7-11.6,29.7C1936.8,148,1926.6,152,1913.9,152z M1880.5,485.1v-286h66.1v286H1880.5z M2193.7,195.9c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42 C2147.2,200.8,2169,195.9,2193.7,195.9z M2515.9,472.3c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5 c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1 c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5 c-11.5,20.5-26.7,35.7-45.8,45.5C2561.4,467.4,2539.9,472.3,2515.9,472.3z M2524.3,592.8c-26.1,0-51.6-3.5-76.4-10.4 c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7 c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6c0,51.8-13.1,89.9-39.1,114.3 C2611.4,580.5,2573.7,592.8,2524.3,592.8z M2525.9,415.5c16.6,0,31.4-3.5,44.4-10.4c13-7,23.2-16.6,30.4-28.9 c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4c-13.1-6.6-27.9-9.9-44.4-9.9 c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6c0,16.1,3.6,30.3,10.8,42.6 c7.2,12.3,17.5,22,30.7,28.9S2509.3,415.5,2525.9,415.5z"
/>
<!-- localhost -->
<path
v-else-if="api === 'localhost'"
fill="currentColor"
fill-rule="nonzero"
d="M695,492.3V94.9h66.1v397.4H695z M974.9,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C1030.1,489.7,1004.1,496.1,974.9,496.1z M974.9,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C944.8,435.2,959,438.8,974.9,438.8z M1318.7,496.1c-29.6,0-56-6.3-79.1-19 c-23.1-12.7-41.2-30.1-54.2-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,31.1-39.2,54.2-51.7 c23.1-12.5,49.5-18.7,79.1-18.7c27.5,0,51.8,5.6,72.7,16.9c21,11.2,36.9,27.8,47.9,49.5l-50.8,30c-8.5-13.6-18.8-23.6-30.9-30 s-25.3-9.6-39.4-9.6c-16.2,0-30.9,3.6-43.9,10.7c-13.1,7.1-23.3,17.4-30.7,30.8s-11.1,29.4-11.1,47.9c0,18.6,3.7,34.5,11.1,47.9 c7.4,13.4,17.6,23.7,30.7,30.8c13,7.1,27.7,10.7,43.9,10.7c14.1,0,27.2-3.2,39.4-9.6s22.5-16.4,30.9-30l50.8,30 c-10.9,21.4-26.9,37.9-47.9,49.5C1370.5,490.3,1346.2,496.1,1318.7,496.1z M1581.6,496.1c-21.2,0-39.7-3.7-55.5-11 c-15.9-7.3-28.1-17.5-36.8-30.5c-8.6-13-13-27.8-13-44.2c0-16.1,3.8-30.5,11.4-43.4c7.6-12.9,20-23,37.3-30.5 c17.3-7.5,40.2-11.2,68.8-11.2h82v44.5h-77.2c-22.6,0-37.7,3.7-45.5,11c-7.8,7.3-11.6,16.3-11.6,27c0,12.1,4.8,21.8,14.3,28.9 c9.5,7.1,22.7,10.7,39.7,10.7c16.2,0,30.8-3.7,43.6-11.2c12.9-7.5,22.1-18.6,27.8-33.2l11.1,40.2c-6.3,16.8-17.7,29.8-34.1,39.1 C1627.4,491.4,1606.7,496.1,1581.6,496.1z M1670.5,492.3v-57.8l-3.7-12.3V320.9c0-19.6-5.8-34.9-17.5-45.8 c-11.6-10.9-29.3-16.3-52.9-16.3c-15.9,0-31.5,2.5-46.8,7.5c-15.3,5-28.3,12-38.9,20.9l-25.9-48.7c15.2-11.8,33.2-20.6,54.2-26.5 c21-5.9,42.8-8.8,65.3-8.8c40.9,0,72.6,9.9,95,29.7c22.4,19.8,33.6,50.4,33.6,91.9v167.6H1670.5z M1817.6,492.3V94.9h66.1v397.4 H1817.6z M2130.7,203.1c22.6,0,42.8,4.5,60.6,13.4c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V337 c0-25.3-5.9-44.3-17.7-56.8c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7 c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1V94.9h66.1v188.5l-14.3-23.6c9.9-18.2,24.2-32.2,43.1-42 C2084.3,208,2106.1,203.1,2130.7,203.1z M2460.3,496.1c-28.9,0-54.7-6.3-77.2-19c-22.6-12.7-40.4-30.1-53.4-52.2 c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7c22.6-12.5,48.3-18.7,77.2-18.7 c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1c0,28.2-6.4,53.4-19.3,75.5 c-12.9,22.1-30.7,39.6-53.4,52.2C2515.6,489.7,2489.6,496.1,2460.3,496.1z M2460.3,438.8c16.2,0,30.7-3.6,43.4-10.7 c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9c-7.2-13.4-17.2-23.6-29.9-30.8 c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8s-11.1,29.4-11.1,47.9 c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C2430.3,435.2,2444.5,438.8,2460.3,438.8z M2761.9,496.1 c-23.6,0-46.3-3.1-68-9.4c-21.7-6.2-38.9-13.8-51.6-22.8l25.4-50.9c12.7,8.2,27.9,15,45.5,20.4c17.6,5.4,35.3,8,52.9,8 c20.8,0,35.9-2.9,45.2-8.6c9.3-5.7,14-13.4,14-23c0-7.8-3.2-13.8-9.5-17.9c-6.3-4.1-14.6-7.2-24.9-9.4c-10.2-2.1-21.6-4.1-34.1-5.9 c-12.5-1.8-25-4.2-37.6-7.2c-12.5-3-23.9-7.5-34.1-13.4c-10.2-5.9-18.5-13.8-24.9-23.8c-6.3-10-9.5-23.2-9.5-39.6 c0-18.2,5.1-34,15.3-47.4c10.2-13.4,24.6-23.7,43.1-31.1c18.5-7.3,40.5-11,65.9-11c19,0,38.3,2.1,57.7,6.4 c19.4,4.3,35.4,10.4,48.1,18.2l-25.4,50.9c-13.4-8.2-26.9-13.8-40.5-16.9c-13.6-3-27.1-4.6-40.5-4.6c-20.1,0-35,3-44.7,9.1 c-9.7,6.1-14.5,13.8-14.5,23c0,8.6,3.2,15,9.5,19.3c6.3,4.3,14.6,7.7,24.9,10.2c10.2,2.5,21.6,4.6,34.1,6.2 c12.5,1.6,24.9,4,37.3,7.2c12.3,3.2,23.7,7.6,34.1,13.1c10.4,5.5,18.8,13.3,25.1,23.3c6.3,10,9.5,23,9.5,39.1 c0,17.9-5.2,33.4-15.6,46.6c-10.4,13.2-25.1,23.5-44.2,30.8S2788.3,496.1,2761.9,496.1z M2909.5,262v-53.6h188.3V262H2909.5z M3053.4,496.1c-31,0-55-8.1-71.9-24.4c-16.9-16.2-25.4-40.3-25.4-72V143.1h66.1V398c0,13.6,3.4,24.1,10.3,31.6 c6.9,7.5,16.5,11.2,28.8,11.2c14.8,0,27.2-3.9,37-11.8l18.5,47.7c-7.8,6.4-17.3,11.2-28.6,14.5 C3077,494.5,3065.3,496.1,3053.4,496.1z"
/>
<!-- foreign -->
<path
v-else
fill="currentColor"
fill-rule="nonzero"
d="M657.4,254.8v-53.6h188.3v53.6H657.4z M704,485.1V183c0-30,8.7-54,26.2-72c17.5-18,42.4-27,74.9-27 c11.6,0,22.7,1.3,33.1,3.7c10.4,2.5,19.1,6.4,26.2,11.8l-18,50.3c-4.9-3.9-10.6-6.9-16.9-8.8c-6.3-2-12.9-2.9-19.6-2.9 c-13.8,0-24.2,3.8-31.2,11.5c-7.1,7.7-10.6,19.2-10.6,34.5v32.1l2.1,30v238.9H704z M1012.9,488.9c-28.9,0-54.7-6.3-77.2-19 c-22.6-12.7-40.4-30.1-53.4-52.2c-13.1-22.1-19.6-47.3-19.6-75.5c0-28.6,6.5-53.8,19.6-75.8c13-22,30.9-39.2,53.4-51.7 c22.6-12.5,48.3-18.7,77.2-18.7c29.3,0,55.3,6.3,78,18.7c22.7,12.5,40.6,29.6,53.4,51.4c12.9,21.8,19.3,47.1,19.3,76.1 c0,28.2-6.4,53.4-19.3,75.5c-12.9,22.1-30.7,39.6-53.4,52.2C1068.2,482.5,1042.2,488.9,1012.9,488.9z M1012.9,431.6 c16.2,0,30.7-3.6,43.4-10.7c12.7-7.1,22.7-17.5,29.9-31.1c7.2-13.6,10.8-29.5,10.8-47.7c0-18.6-3.6-34.5-10.8-47.9 c-7.2-13.4-17.2-23.6-29.9-30.8c-12.7-7.1-27-10.7-42.9-10.7c-16.2,0-30.6,3.6-43.1,10.7c-12.5,7.1-22.5,17.4-29.9,30.8 s-11.1,29.4-11.1,47.9c0,18.2,3.7,34.1,11.1,47.7c7.4,13.6,17.4,23.9,29.9,31.1C982.9,428,997.1,431.6,1012.9,431.6z M1227.2,485.1 v-286h63v78.7l-7.4-23c8.5-19.3,21.8-33.9,39.9-43.9c18.2-10,40.8-15,68-15v63.7c-2.8-0.7-5.5-1.2-7.9-1.3c-2.5-0.2-4.9-0.3-7.4-0.3 c-25,0-45,7.4-59.8,22.2c-14.8,14.8-22.2,36.9-22.2,66.1v138.7H1227.2z M1576.9,488.9c-31.4,0-58.8-6.3-82.3-19 c-23.5-12.7-41.6-30.1-54.5-52.2c-12.9-22.1-19.3-47.3-19.3-75.5c0-28.6,6.3-53.8,18.8-75.8c12.5-22,29.8-39.2,51.8-51.7 c22-12.5,47.2-18.7,75.4-18.7c27.5,0,52,6.1,73.5,18.2c21.5,12.1,38.4,29.3,50.8,51.4c12.3,22.1,18.5,48.2,18.5,78.2 c0,2.9-0.1,6.1-0.3,9.6c-0.2,3.6-0.4,7-0.8,10.2h-235.4v-44.5h200.5l-25.9,13.9c0.3-16.4-3-30.9-10.1-43.4 c-7.1-12.5-16.7-22.3-28.8-29.5c-12.2-7.1-26.2-10.7-42.1-10.7c-16.2,0-30.4,3.6-42.6,10.7c-12.2,7.1-21.7,17.1-28.6,29.7 c-6.9,12.7-10.3,27.6-10.3,44.7v10.7c0,17.1,3.9,32.3,11.6,45.5c7.8,13.2,18.7,23.4,32.8,30.5c14.1,7.1,30.3,10.7,48.7,10.7 c15.9,0,30.2-2.5,42.9-7.5c12.7-5,24-12.9,33.9-23.6l35.4,41.2c-12.7,15-28.7,26.5-47.9,34.5 C1623.5,484.9,1601.6,488.9,1576.9,488.9z M1806.5,152c-12.3,0-22.5-3.9-30.4-11.8c-7.9-7.8-11.9-17.3-11.9-28.4 c0-11.4,4-21,11.9-28.7c7.9-7.7,18.1-11.5,30.4-11.5c12.3,0,22.5,3.7,30.4,11c7.9,7.3,11.9,16.5,11.9,27.6 c0,11.8-3.9,21.7-11.6,29.7C1829.4,148,1819.2,152,1806.5,152z M1773.1,485.1v-286h66.1v286H1773.1z M2045.6,472.3 c-26.8,0-51-5.8-72.7-17.4c-21.7-11.6-38.8-27.8-51.3-48.5c-12.5-20.7-18.8-45-18.8-72.8c0-27.5,6.3-51.6,18.8-72.3 c12.5-20.7,29.6-36.8,51.3-48.2c21.7-11.4,45.9-17.1,72.7-17.1c24,0,45.5,4.8,64.5,14.5s34.3,24.6,45.8,45 c11.5,20.4,17.2,46.4,17.2,78.2c0,31.8-5.7,57.9-17.2,78.5c-11.5,20.5-26.7,35.7-45.8,45.5C2091.1,467.4,2069.6,472.3,2045.6,472.3z M2054,592.8c-26.1,0-51.6-3.5-76.4-10.4c-24.9-7-45.2-17.1-61.1-30.3l29.6-50.3c12.3,10.4,27.9,18.7,46.8,24.9 c18.9,6.2,38,9.4,57.4,9.4c31,0,53.8-7.2,68.2-21.7c14.5-14.5,21.7-36.2,21.7-65.1v-50.9l5.3-64.8l-2.1-64.8v-69.6h63v242.6 c0,51.8-13.1,89.9-39.1,114.3C2141.2,580.5,2103.4,592.8,2054,592.8z M2055.6,415.5c16.6,0,31.4-3.5,44.4-10.4 c13-7,23.2-16.6,30.4-28.9c7.2-12.3,10.8-26.5,10.8-42.6c0-16.1-3.6-30.3-10.8-42.6c-7.2-12.3-17.4-21.8-30.4-28.4 c-13.1-6.6-27.9-9.9-44.4-9.9c-16.6,0-31.5,3.3-44.7,9.9c-13.2,6.6-23.5,16.1-30.7,28.4c-7.2,12.3-10.8,26.5-10.8,42.6 c0,16.1,3.6,30.3,10.8,42.6c7.2,12.3,17.5,22,30.7,28.9S2039.1,415.5,2055.6,415.5z M2453.5,195.9c22.6,0,42.8,4.5,60.6,13.4 c17.8,8.9,31.8,22.6,42.1,41c10.2,18.4,15.3,42,15.3,71v163.9h-66.1V329.8c0-25.3-5.9-44.3-17.7-56.8 c-11.8-12.5-28.3-18.7-49.5-18.7c-15.5,0-29.3,3.2-41.3,9.6c-12,6.4-21.3,16-27.8,28.7c-6.5,12.7-9.8,28.7-9.8,47.9v144.6h-66.1 v-286h63v77.1l-11.1-23.6c9.9-18.2,24.2-32.2,43.1-42C2407,200.8,2428.8,195.9,2453.5,195.9z"
/>
<g fill="var(--color-brand)">
<path
d="m29 424.4 188.2-112.95-17.15-45.48 53.75-55.21 67.93-14.64 19.67 24.21-31.32 31.72-27.3 8.6-19.52 20.05 9.56 26.6 19.4 20.6 27.36-7.28 19.47-21.38 42.51-13.47 12.67 28.5-43.87 53.78-73.5 23.27-32.97-36.7L55.06 467.94C46.1 456.41 35.67 440.08 29 424.4Zm543.03-230.25-149.5 40.32c8.24 21.92 10.95 34.8 13.23 49l149.23-40.26c-2.38-15.94-6.65-32.17-12.96-49.06Z"
/>
<path
d="M51.28 316.13c10.59 125 115.54 223.3 243.27 223.3 96.51 0 180.02-56.12 219.63-137.46l48.61 16.83c-46.78 101.34-149.35 171.75-268.24 171.75C138.6 590.55 10.71 469.38 0 316.13h51.28ZM.78 265.24C15.86 116.36 141.73 0 294.56 0c162.97 0 295.28 132.31 295.28 295.28 0 26.14-3.4 51.49-9.8 75.63l-48.48-16.78a244.28 244.28 0 0 0 7.15-58.85c0-134.75-109.4-244.15-244.15-244.15-124.58 0-227.49 93.5-242.32 214.11H.8Z"
class="ring ring--large"
/>
<path
d="M293.77 153.17c-78.49.07-142.2 63.83-142.2 142.34 0 78.56 63.79 142.34 142.35 142.34 3.98 0 7.93-.16 11.83-.49l14.22 49.76a194.65 194.65 0 0 1-26.05 1.74c-106.72 0-193.36-86.64-193.36-193.35 0-106.72 86.64-193.35 193.36-193.35 2.64 0 5.28.05 7.9.16l-8.05 50.85Zm58.2-42.13c78.39 24.67 135.3 97.98 135.3 184.47 0 80.07-48.77 148.83-118.2 178.18l-14.17-49.55c48.08-22.85 81.36-71.89 81.36-128.63 0-60.99-38.44-113.07-92.39-133.32l8.1-51.15Z"
class="ring ring--small"
/>
</g>
</svg>
</template>
<script setup>
const loading = useLoading()
const config = useRuntimeConfig()
const api = computed(() => {
const apiUrl = config.public.apiBaseUrl
if (apiUrl.startsWith('https://api.modrinth.com')) {
return 'prod'
} else if (apiUrl.startsWith('https://staging-api.modrinth.com')) {
return 'staging'
} else if (apiUrl.startsWith('localhost') || apiUrl.startsWith('127.0.0.1')) {
return 'localhost'
}
return 'foreign'
})
</script>
<style lang="scss" scoped>
.animate {
.ring {
transform-origin: center;
transform-box: fill-box;
animation-fill-mode: forwards;
transition: transform 2s ease-in-out;
&--large {
animation: spin 1s ease-in-out infinite forwards;
}
&--small {
animation: spin 2s ease-in-out infinite reverse;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''} ${
pixelated ? 'pixelated' : ''
} ${raised ? 'raised' : ''}`"
:src="src"
:alt="alt"
:loading="loading"
@load="updatePixelated"
/>
<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)
defineProps({
src: {
type: String,
default: null,
},
alt: {
type: String,
default: '',
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xxs', 'xs', 'sm', 'md', 'lg'].includes(value)
},
},
circle: {
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
loading: {
type: String,
default: 'eager',
},
raised: {
type: Boolean,
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);
height: var(--size);
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

@@ -0,0 +1,126 @@
<template>
<span :class="'badge ' + color + ' type--' + type">
<template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }}</template>
<!-- User roles -->
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><ListIcon /> Listed</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 === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review</template>
<!-- Team members -->
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
<template v-else-if="type === 'pending'"> <ProcessingIcon /> Pending </template>
<!-- Transaction statuses -->
<template v-else-if="type === 'success'"><CheckIcon /> Success</template>
<!-- Report status -->
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
</span>
</template>
<script setup>
import ModrinthIcon from '~/assets/images/logo.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'
import ProcessingIcon from '~/assets/images/utils/updated.svg?component'
import CheckIcon from '~/assets/images/utils/check.svg?component'
import LockIcon from '~/assets/images/utils/lock.svg?component'
import CalendarIcon from '~/assets/images/utils/calendar.svg?component'
import CloseIcon from '~/assets/images/utils/check-circle.svg?component'
defineProps({
type: {
type: String,
required: true,
},
color: {
type: String,
default: '',
},
})
</script>
<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;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
background-color: var(--badge-color);
}
svg {
vertical-align: -15%;
width: 1em;
height: 1em;
}
&.type--closed,
&.type--withheld,
&.type--rejected,
&.red {
--badge-color: var(--color-red);
}
&.type--pending,
&.type--moderator,
&.type--processing,
&.type--scheduled,
&.orange {
--badge-color: var(--color-orange);
}
&.type--accepted,
&.type--admin,
&.type--success,
&.type--approved-general,
&.green {
--badge-color: var(--color-green);
}
&.type--creator,
&.type--approved,
&.blue {
--badge-color: var(--color-blue);
}
&.type--unlisted,
&.purple {
--badge-color: var(--color-purple);
}
&.type--private,
&.gray {
--badge-color: var(--color-gray);
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<nav class="breadcrumbs">
<template v-for="(link, index) in linkStack" :key="index">
<NuxtLink
:to="link.href"
class="breadcrumb goto-link"
:class="{ trim: link.allowTrimming ? link.allowTrimming : false }"
>
{{ link.label }}
</NuxtLink>
<ChevronRightIcon />
</template>
<span class="breadcrumb">{{ currentTitle }}</span>
</nav>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
defineProps({
linkStack: {
type: Array,
default: () => [],
},
currentTitle: {
type: String,
required: true,
},
})
</script>
<style lang="scss" scoped>
.breadcrumbs {
//padding: var(--spacing-card-md) var(--spacing-card-lg);
display: flex;
margin-bottom: var(--spacing-card-bg);
align-items: center;
flex-wrap: wrap;
svg {
width: 1.25rem;
height: 1.25rem;
}
a.breadcrumb {
padding-block: var(--spacing-card-xs);
&.trim {
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<div
class="checkbox-outer button-within"
:class="{ disabled, checked: modelValue }"
role="presentation"
@click="toggle"
>
<button
class="checkbox"
role="checkbox"
:disabled="disabled"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description ?? label"
:aria-checked="modelValue"
>
<CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
</button>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<p v-if="label" aria-hidden="true">
{{ label }}
</p>
<slot v-else />
</div>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?component'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?component'
export default {
components: {
CheckIcon,
DropdownIcon,
},
props: {
label: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
description: {
type: String,
default: null,
},
modelValue: Boolean,
clickEvent: {
type: Function,
default: () => {},
},
collapsingToggleStyle: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('update:modelValue', !this.modelValue)
}
},
},
}
</script>
<style lang="scss" scoped>
.checkbox-outer {
display: flex;
align-items: center;
cursor: pointer;
p {
user-select: none;
padding: 0.2rem 0;
margin: 0;
}
&.disabled {
cursor: not-allowed;
}
&.checked {
outline: 2px solid transparent;
outline-offset: 4px;
border-radius: 0.25rem;
}
}
.checkbox {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
min-width: 1rem;
min-height: 1rem;
padding: 0;
margin: 0 0.5rem 0 0;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-control);
box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent;
&.checked {
background-color: var(--color-brand);
}
svg {
color: var(--color-brand-inverted);
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;
flex-shrink: 0;
}
&.collapsing {
background-color: transparent !important;
box-shadow: none;
svg {
color: inherit;
height: 1rem;
width: 1rem;
transition: transform 0.25s ease-in-out;
}
&.checked {
svg {
transform: rotate(180deg);
}
}
}
&:disabled {
box-shadow: none;
cursor: not-allowed;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="chips">
<button
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
<span>{{ formatLabel(item) }}</span>
</button>
</div>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?component'
export default {
components: {
CheckIcon,
},
props: {
modelValue: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
},
emits: ['update:modelValue'],
computed: {
selected: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
},
created() {
if (this.items.length > 0 && this.neverEmpty) {
this.selected = this.items[0]
}
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null
} else {
this.selected = item
}
},
},
}
</script>
<style lang="scss" scoped>
.chips {
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
.iconified-button {
&.capitalize {
text-transform: capitalize;
}
svg {
width: 1em;
height: 1em;
}
&:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}
}
.selected {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
}
}
</style>

View File

@@ -0,0 +1,119 @@
<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>
</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>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="create">
<CheckIcon />
Continue
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from '@modrinth/assets'
import { Modal, Button } from '@modrinth/ui'
const router = useNativeRouter()
const name = ref('')
const description = ref('')
const modal = ref()
const props = defineProps({
projectIds: {
type: Array,
default() {
return []
},
},
})
async function create() {
startLoading()
try {
const result = await useBaseFetch('collection', {
method: 'POST',
body: {
name: name.value.trim(),
description: description.value.trim(),
projects: props.projectIds,
},
apiVersion: 3,
})
await initUserCollections()
modal.value.hide()
await router.push(`/collection/${result.id}`)
} catch (err) {
addNotification({
group: 'main',
title: 'An error occurred',
text: err?.data?.description || err?.message || err,
type: 'error',
})
}
stopLoading()
}
function show() {
name.value = ''
description.value = ''
modal.value.show()
}
defineExpose({
show,
})
</script>
<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--gap-md);
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<nuxt-link v-if="isLink" :to="to">
<slot />
</nuxt-link>
<span v-else>
<slot />
</span>
</template>
<script setup>
defineProps({
to: {
type: String,
required: true,
},
isLink: {
type: Boolean,
required: true,
},
})
</script>

View File

@@ -0,0 +1,74 @@
<template>
<button class="code" :class="{ copied }" title="Copy code to clipboard" @click="copyText">
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
</button>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?component'
import ClipboardCopyIcon from '~/assets/images/utils/clipboard-copy.svg?component'
export default {
components: {
CheckIcon,
ClipboardCopyIcon,
},
props: {
text: {
type: String,
required: true,
},
},
data() {
return {
copied: false,
}
},
methods: {
async copyText() {
await navigator.clipboard.writeText(this.text)
this.copied = true
},
},
}
</script>
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-code-bg);
width: min-content;
border-radius: 10px;
user-select: text;
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;
span {
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<div class="double-icon">
<slot name="primary" />
<div class="secondary">
<slot name="secondary" />
</div>
</div>
</template>
<style lang="scss" scoped>
.double-icon {
position: relative;
height: fit-content;
line-height: 0;
.secondary {
position: absolute;
bottom: -4px;
right: -4px;
background-color: var(--color-bg);
padding: var(--spacing-card-xs);
border-radius: 50%;
aspect-ratio: 1 / 1;
width: fit-content;
height: fit-content;
line-height: 0;
svg {
width: 1rem;
height: 1rem;
}
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div
ref="drop_area"
class="drop-area"
@drop.stop.prevent="
(event) => {
$refs.drop_area.style.visibility = 'hidden'
if (event.dataTransfer && event.dataTransfer.files && fileAllowed) {
$emit('change', event.dataTransfer.files)
}
}
"
@dragenter.prevent="allowDrag"
@dragover.prevent="allowDrag"
@dragleave.prevent="$refs.drop_area.style.visibility = 'hidden'"
/>
</template>
<script>
export default {
props: {
accept: {
type: String,
default: '',
},
},
emits: ['change'],
data() {
return {
fileAllowed: false,
}
},
mounted() {
document.addEventListener('dragenter', this.allowDrag)
},
methods: {
allowDrag(event) {
const file = event.dataTransfer?.items[0]
if (
file &&
this.accept
.split(',')
.reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
) {
this.fileAllowed = true
event.dataTransfer.dropEffect = 'copy'
event.preventDefault()
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'visible'
}
} else {
this.fileAllowed = false
if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'hidden'
}
}
},
},
}
</script>
<style lang="scss" scoped>
.drop-area {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
visibility: hidden;
background-color: hsla(0, 0%, 0%, 0.5);
transition: visibility 0.2s ease-in-out, background-color 0.1s ease-in-out;
display: flex;
&::before {
--indent: 4rem;
content: ' ';
position: relative;
top: var(--indent);
left: var(--indent);
width: calc(100% - (2 * var(--indent)));
height: calc(100% - (2 * var(--indent)));
border-radius: 1rem;
border: 0.25rem dashed var(--color-button-bg);
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<span v-if="typeOnly" class="environment">
<InfoIcon aria-hidden="true" />
A {{ type }}
</span>
<span
v-else-if="
!['resourcepack', 'shader'].includes(type) &&
!(type === 'plugin' && search) &&
!categories.some((x) => tags.loaderData.dataPackLoaders.includes(x))
"
class="environment"
>
<template v-if="clientSide === 'optional' && serverSide === 'optional'">
<GlobeIcon aria-hidden="true" />
Client or server
</template>
<template v-else-if="clientSide === 'required' && serverSide === 'required'">
<GlobeIcon aria-hidden="true" />
Client and server
</template>
<template
v-else-if="
(clientSide === 'optional' || clientSide === 'required') &&
(serverSide === 'optional' || serverSide === 'unsupported')
"
>
<ClientIcon aria-hidden="true" />
Client
</template>
<template
v-else-if="
(serverSide === 'optional' || serverSide === 'required') &&
(clientSide === 'optional' || clientSide === 'unsupported')
"
>
<ServerIcon aria-hidden="true" />
Server
</template>
<template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
<GlobeIcon aria-hidden="true" />
Unsupported
</template>
<template v-else-if="alwaysShow">
<InfoIcon aria-hidden="true" />
A {{ type }}
</template>
</span>
</template>
<script setup>
import InfoIcon from '~/assets/images/utils/info.svg?component'
import ClientIcon from '~/assets/images/utils/client.svg?component'
import GlobeIcon from '~/assets/images/utils/globe.svg?component'
import ServerIcon from '~/assets/images/utils/server.svg?component'
defineProps({
type: {
type: String,
default: 'mod',
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
typeOnly: {
type: Boolean,
required: false,
default: false,
},
alwaysShow: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
categories: {
type: Array,
required: false,
default() {
return []
},
},
})
const tags = useTags()
</script>
<style lang="scss" scoped>
.environment {
display: flex;
color: var(--color-text) !important;
font-weight: bold;
svg {
margin-right: 0.2rem;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<label
:class="{ 'long-style': longStyle }"
:disabled="disabled"
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot />
{{ prompt }}
<input
type="file"
:multiple="multiple"
:accept="accept"
:disabled="disabled"
@change="handleChange"
/>
</label>
</template>
<script>
import { fileIsValid } from '~/helpers/fileUtils.js'
export default {
components: {},
props: {
prompt: {
type: String,
default: 'Select file',
},
multiple: {
type: Boolean,
default: false,
},
accept: {
type: String,
default: null,
},
/**
* The max file size in bytes
*/
maxSize: {
type: Number,
default: null,
},
showIcon: {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
longStyle: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['change'],
data() {
return {
files: [],
}
},
methods: {
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files
}
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
if (this.files.length > 0) {
this.$emit('change', this.files)
}
},
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files)
},
},
}
</script>
<style lang="scss" scoped>
label {
flex-direction: unset;
max-height: unset;
svg {
height: 1rem;
}
input {
display: none;
}
&.long-style {
display: flex;
padding: 1.5rem 2rem;
justify-content: center;
align-items: center;
grid-gap: 0.5rem;
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-sm);
border: dashed 0.3rem var(--color-text);
cursor: pointer;
color: var(--color-text-dark);
}
}
</style>

View File

@@ -0,0 +1,58 @@
<template>
<div class="message-banner">
<div class="message-banner__content" :class="cardClassByType" :aria-label="ariaLabelByType">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
type MessageType = 'information' | 'warning'
const props = withDefaults(defineProps<{ messageType?: MessageType }>(), {
messageType: 'information',
})
const cardClassByType = computed(() => `message-banner__content_${props.messageType}`)
const ariaLabelByType = computed(() => `Banner with ${props.messageType} message`)
</script>
<style lang="css" scoped>
.message-banner {
position: relative;
min-height: var(--font-size-2xl);
background: var(--color-raised-bg);
border-radius: var(--size-rounded-card);
overflow: hidden;
outline: 2px solid transparent;
outline-offset: -2px;
margin-bottom: var(--spacing-card-md);
box-shadow: var(--shadow-card);
line-height: 1.5;
min-height: 0;
}
:slotted(a) {
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
}
.message-banner__content {
padding: var(--spacing-card-md) var(--spacing-card-lg);
}
.message-banner__content_warning {
border-left: 0.5rem solid var(--color-warning-banner-side);
background-color: var(--color-warning-banner-bg);
color: var(--color-warning-banner-text);
}
.message-banner__content_information {
border-left: 0.5rem solid var(--color-info-banner-side);
background-color: var(--color-info-banner-bg);
color: var(--color-info-banner-text);
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div v-if="shown">
<div
:class="{
shown: actuallyShown,
noblur: !$orElse(cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<strong>{{ header }}</strong>
<button class="iconified-button icon-only transparent" @click="hide">
<CrossIcon />
</button>
</div>
<div class="content">
<slot />
</div>
</div>
</div>
</div>
<div v-else />
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg?component'
export default {
components: {
CrossIcon,
},
props: {
header: {
type: String,
default: null,
},
},
setup() {
const cosmetics = useCosmetics()
return { cosmetics }
},
data() {
return {
shown: false,
actuallyShown: false,
}
},
methods: {
show() {
this.shown = true
setTimeout(() => {
this.actuallyShown = true
}, 50)
},
hide() {
this.actuallyShown = false
setTimeout(() => {
this.shown = false
}, 300)
},
},
}
</script>
<style lang="scss" scoped>
.modal-overlay {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 20;
transition: all 0.3s ease-in-out;
&.shown {
opacity: 1;
visibility: visible;
background: hsla(0, 0%, 0%, 0.5);
backdrop-filter: blur(3px);
}
&.noblur {
backdrop-filter: none;
}
}
.modal-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21;
visibility: hidden;
pointer-events: none;
&.shown {
visibility: visible;
.modal-body {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
.modal-body {
position: fixed;
box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
outline: 3px solid transparent;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}
.content {
background-color: var(--color-raised-bg);
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<Modal ref="modal" :header="title">
<div class="modal-delete">
<div class="markdown-body" v-html="renderString(description)" />
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
<strong>To verify, type</strong>
<em class="confirmation-text">{{ confirmationText }}</em>
<strong>below:</strong>
</span>
</label>
<div class="confirmation-input">
<input
v-if="hasToType"
id="confirmation"
v-model="confirmation_typed"
type="text"
placeholder="Type here..."
@input="type"
/>
</div>
<div class="button-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button class="iconified-button danger-button" :disabled="action_disabled" @click="proceed">
<TrashIcon />
{{ proceedLabel }}
</button>
</div>
</div>
</Modal>
</template>
<script>
import { renderString } from '@modrinth/utils'
import CrossIcon from '~/assets/images/utils/x.svg?component'
import TrashIcon from '~/assets/images/utils/trash.svg?component'
import Modal from '~/components/ui/Modal.vue'
export default {
components: {
CrossIcon,
TrashIcon,
Modal,
},
props: {
confirmationText: {
type: String,
default: '',
},
hasToType: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'No title defined',
required: true,
},
description: {
type: String,
default: 'No description defined',
required: true,
},
proceedLabel: {
type: String,
default: 'Proceed',
},
},
emits: ['proceed'],
data() {
return {
action_disabled: this.hasToType,
confirmation_typed: '',
}
},
methods: {
renderString,
cancel() {
this.$refs.modal.hide()
},
proceed() {
this.$refs.modal.hide()
this.$emit('proceed')
},
type() {
if (this.hasToType) {
this.action_disabled =
this.confirmation_typed.toLowerCase() !== this.confirmationText.toLowerCase()
}
},
show() {
this.$refs.modal.show()
},
},
}
</script>
<style scoped lang="scss">
.modal-delete {
padding: var(--spacing-card-bg);
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%;
}
}
}
</style>

View File

@@ -0,0 +1,241 @@
<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>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</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>
<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>
</div>
</Modal>
</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'
export default {
components: {
CrossIcon,
CheckIcon,
Modal,
Multiselect,
},
props: {
organizationId: {
type: String,
required: false,
default: null,
},
},
setup() {
const tags = useTags()
return { tags }
},
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',
},
}
},
methods: {
cancel() {
this.$refs.modal.hide()
},
async createProject() {
startLoading()
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, '-')
}
},
},
}
</script>
<style scoped lang="scss">
.modal-creation {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 0.5rem;
}
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--spacing-card-md);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,160 @@
<template>
<nav class="navigation">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="linkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
const route = useNativeRoute()
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
})
const sliderPositionX = ref(0)
const sliderPositionY = ref(18)
const selectedElementWidth = ref(0)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
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`)
function pickLink() {
console.log('link is picking')
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === '' ? undefined : x.href) === route.path[props.query]
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path))
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderPositionX.value = 0
selectedElementWidth.value = 0
}
}
const linkElements = ref()
function startAnimation() {
const el = linkElements.value[activeIndex.value].$el
sliderPositionX.value = el.offsetLeft
sliderPositionY.value = el.offsetTop + el.offsetHeight
selectedElementWidth.value = el.offsetWidth
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => pickLink())
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
&:hover {
color: var(--color-text);
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&.router-link-exact-active {
color: var(--color-text);
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<nav>
<ul>
<slot />
</ul>
</nav>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped>
ul {
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
list-style-type: none;
margin: 0;
padding: 0;
> :first-child {
margin-top: 0;
}
}
li {
display: unset;
text-align: unset;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<NuxtLink v-if="link !== null" class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
<span v-if="chevron" class="chevron"><ChevronRightIcon /></span>
</div>
</NuxtLink>
<button
v-else-if="action"
class="nav-link button-base"
:class="{ 'danger-button': danger }"
@click="action"
>
<span class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</span>
</button>
<span v-else>i forgor 💀</span>
</template>
<script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
export default {
components: {
ChevronRightIcon,
},
props: {
link: {
default: null,
type: String,
},
action: {
default: null,
type: Function,
},
label: {
required: true,
type: String,
},
beta: {
default: false,
type: Boolean,
},
chevron: {
default: false,
type: Boolean,
},
danger: {
default: false,
type: Boolean,
},
},
}
</script>
<style lang="scss" scoped>
.nav-link {
font-weight: var(--font-weight-bold);
background-color: transparent;
color: var(--text-color);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
padding: 0;
width: 100%;
outline: none;
:where(.nav-link) {
--text-color: var(--color-text);
--background-color: var(--color-raised-bg);
}
.nav-content {
box-sizing: border-box;
padding: 0.5rem 0.75rem;
border-radius: var(--size-rounded-sm);
display: flex;
align-items: center;
gap: 0.4rem;
flex-grow: 1;
background-color: var(--background-color);
}
&:focus-visible {
.nav-content {
border-radius: 0.25rem;
}
}
&.router-link-exact-active {
outline: 2px solid transparent;
border-radius: 0.25rem;
.nav-content {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
box-shadow: none;
}
}
.beta-badge {
margin: 0;
}
.chevron {
margin-left: auto;
}
}
</style>

View File

@@ -0,0 +1,570 @@
<template>
<div
class="notification"
:class="{
'has-body': hasBody,
compact: compact,
read: notification.read,
}"
>
<nuxt-link
v-if="!type"
:to="notification.link"
class="notification__icon backed-svg"
:class="{ raised: raised }"
>
<NotificationIcon />
</nuxt-link>
<DoubleIcon v-else class="notification__icon">
<template #primary>
<nuxt-link v-if="project" :to="getProjectLink(project)" tabindex="-1">
<Avatar size="xs" :src="project.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link
v-else-if="organization"
:to="`/organization/${organization.slug}`"
tabindex="-1"
>
<Avatar size="xs" :src="organization.icon_url" :raised="raised" no-shadow />
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" tabindex="-1">
<Avatar size="xs" :src="user.avatar_url" :raised="raised" no-shadow />
</nuxt-link>
<Avatar v-else size="xs" :raised="raised" no-shadow />
</template>
<template #secondary>
<ModerationIcon
v-if="type === 'moderator_message' || type === 'status_change'"
class="moderation-color"
/>
<InvitationIcon v-else-if="type === 'team_invite' && project" class="creator-color" />
<InvitationIcon
v-else-if="type === 'organization_invite' && organization"
class="creator-color"
/>
<VersionIcon v-else-if="type === 'project_update' && project && version" />
<NotificationIcon v-else />
</template>
</DoubleIcon>
<div class="notification__title">
<template v-if="type === 'project_update' && project && version">
A project you follow,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has been updated:
</template>
<template v-else-if="type === 'team_invite' && project">
<nuxt-link :to="`/user/${invitedBy.username}`" class="iconified-link title-link">
<Avatar :src="invitedBy.avatar_url" circle size="xxs" no-shadow :raised="raised" />
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'organization_invite' && organization">
<nuxt-link :to="`/user/${invitedBy.username}`" class="iconified-link title-link">
<Avatar :src="invitedBy.avatar_url" circle size="xxs" no-shadow :raised="raised" />
<span class="space">&nbsp;</span>
<span>{{ invitedBy.username }}</span>
</nuxt-link>
<span>
has invited you to join
<nuxt-link :to="`/organization/${organization.slug}`" class="title-link">
{{ organization.name }} </nuxt-link
>.
</span>
</template>
<template v-else-if="type === 'status_change' && project">
<nuxt-link :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been <Badge :type="notification.body.new_status" />
</template>
<template v-else>
updated from
<Badge :type="notification.body.old_status" />
to
<Badge :type="notification.body.new_status" />
</template>
by the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && project && !report">
Your project,
<nuxt-link :to="getProjectLink(project)" class="title-link">{{ project.title }}</nuxt-link
>, has received
<template v-if="notification.grouped_notifs"> messages </template>
<template v-else>a message</template>
from the moderators.
</template>
<template v-else-if="type === 'moderator_message' && thread && report">
A moderator replied to your report of
<template v-if="version">
version
<nuxt-link :to="getVersionLink(project, version)" class="title-link">
{{ version.name }}
</nuxt-link>
of project
</template>
<nuxt-link v-if="project" :to="getProjectLink(project)" class="title-link">
{{ project.title }}
</nuxt-link>
<nuxt-link v-else-if="user" :to="getUserLink(user)" class="title-link">
{{ user.username }} </nuxt-link
>.
</template>
<nuxt-link v-else :to="notification.link" class="title-link">
<span v-html="renderString(notification.title)" />
</nuxt-link>
<!-- <span v-else class="known-errors">Error reading notification.</span>-->
</div>
<div v-if="hasBody" class="notification__body">
<ThreadSummary
v-if="type === 'moderator_message' && thread"
:thread="thread"
:link="threadLink"
:raised="raised"
:messages="getMessages()"
class="thread-summary"
:auth="auth"
/>
<div v-else-if="type === 'project_update'" class="version-list">
<div
v-for="notif in (notification.grouped_notifs
? [notification, ...notification.grouped_notifs]
: [notification]
).filter((x) => x.extra_data.version)"
:key="notif.id"
class="version-link"
>
<VersionIcon />
<nuxt-link
:to="getVersionLink(notif.extra_data.project, notif.extra_data.version)"
class="text-link"
>
{{ notif.extra_data.version.name }}
</nuxt-link>
<span class="version-info">
for
<Categories
:categories="notif.extra_data.version.loaders"
:type="notif.extra_data.project.project_type"
class="categories"
/>
{{ $formatVersion(notif.extra_data.version.game_versions) }}
<span
v-tooltip="
$dayjs(notif.extra_data.version.date_published).format('MMMM D, YYYY [at] h:mm A')
"
class="date"
>
{{ fromNow(notif.extra_data.version.date_published) }}
</span>
</span>
</div>
</div>
<template v-else>
{{ notification.text }}
</template>
</div>
<span class="notification__date">
<span v-if="notification.read" class="read-badge"> <ReadIcon /> Read </span>
<span v-tooltip="$dayjs(notification.created).format('MMMM D, YYYY [at] h:mm A')">
<CalendarIcon /> Received {{ fromNow(notification.created) }}
</span>
</span>
<div v-if="compact" class="notification__actions">
<template v-if="type === 'team_invite' || type === 'organization_invite'">
<button
v-tooltip="`Accept`"
class="iconified-button square-button brand-button button-transparent"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon />
</button>
<button
v-tooltip="`Decline`"
class="iconified-button square-button danger-button button-transparent"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon />
</button>
</template>
<button
v-else-if="!notification.read"
v-tooltip="`Mark as read`"
class="iconified-button square-button button-transparent"
@click="read()"
>
<CrossIcon />
</button>
</div>
<div v-else class="notification__actions">
<div v-if="type !== null" class="input-group">
<template
v-if="(type === 'team_invite' || type === 'organization_invite') && !notification.read"
>
<button
class="iconified-button brand-button"
@click="
() => {
acceptTeamInvite(notification.body.team_id)
read()
}
"
>
<CheckIcon /> Accept
</button>
<button
class="iconified-button danger-button"
@click="
() => {
removeSelfFromTeam(notification.body.team_id)
read()
}
"
>
<CrossIcon /> Decline
</button>
</template>
<button
v-else-if="!notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="read()"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
<div v-else class="input-group">
<nuxt-link
v-if="notification.link && notification.link !== '#'"
class="iconified-button"
:class="{ 'raised-button': raised }"
:to="notification.link"
target="_blank"
>
<ExternalIcon />
Open link
</nuxt-link>
<button
v-for="(action, actionIndex) in notification.actions"
:key="actionIndex"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, actionIndex)"
>
<CheckIcon v-if="action.title === 'Accept'" />
<CrossIcon v-else-if="action.title === 'Deny'" />
{{ action.title }}
</button>
<button
v-if="notification.actions.length === 0 && !notification.read"
class="iconified-button"
:class="{ 'raised-button': raised }"
@click="performAction(notification, null)"
>
<CheckIcon /> Mark as read
</button>
<CopyCode v-if="flags.developerMode" :text="notification.id" />
</div>
</div>
</div>
</template>
<script setup>
import { renderString } from '@modrinth/utils'
import InvitationIcon from '~/assets/images/utils/user-plus.svg?component'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
import NotificationIcon from '~/assets/images/sidebar/notifications.svg?component'
import ReadIcon from '~/assets/images/utils/check-circle.svg?component'
import CalendarIcon from '~/assets/images/utils/calendar.svg?component'
import VersionIcon from '~/assets/images/utils/version.svg?component'
import CheckIcon from '~/assets/images/utils/check.svg?component'
import CrossIcon from '~/assets/images/utils/x.svg?component'
import ExternalIcon from '~/assets/images/utils/external.svg?component'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import { getProjectLink, getVersionLink } from '~/helpers/projects.js'
import { getUserLink } from '~/helpers/users.js'
import { acceptTeamInvite, removeSelfFromTeam } from '~/helpers/teams.js'
import { markAsRead } from '~/helpers/notifications.js'
import DoubleIcon from '~/components/ui/DoubleIcon.vue'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import Categories from '~/components/ui/search/Categories.vue'
const app = useNuxtApp()
const emit = defineEmits(['update:notifications'])
const props = defineProps({
notification: {
type: Object,
required: true,
},
notifications: {
type: Array,
required: true,
},
raised: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const flags = useFeatureFlags()
const tags = useTags()
const type = computed(() =>
!props.notification.body || props.notification.body.type === 'legacy_markdown'
? null
: props.notification.body.type
)
const thread = computed(() => props.notification.extra_data.thread)
const report = computed(() => props.notification.extra_data.report)
const project = computed(() => props.notification.extra_data.project)
const version = computed(() => props.notification.extra_data.version)
const user = computed(() => props.notification.extra_data.user)
const organization = computed(() => props.notification.extra_data.organization)
const invitedBy = computed(() => props.notification.extra_data.invited_by)
const threadLink = computed(() => {
if (report.value) {
return `/dashboard/report/${report.value.id}`
} else if (project.value) {
return `${getProjectLink(project.value)}/moderation#messages`
}
return '#'
})
const hasBody = computed(() => !type.value || thread.value || type.value === 'project_update')
async function read() {
try {
const ids = [
props.notification.id,
...(props.notification.grouped_notifs
? props.notification.grouped_notifs.map((notif) => notif.id)
: []),
]
const updateNotifs = await markAsRead(ids)
const newNotifs = updateNotifs(props.notifications)
emit('update:notifications', newNotifs)
} catch (err) {
app.$notify({
group: 'main',
title: 'Error marking notification as read',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function performAction(notification, actionIndex) {
startLoading()
try {
await read()
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
})
}
} catch (err) {
app.$notify({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function getMessages() {
const messages = []
if (props.notification.body.message_id) {
messages.push(props.notification.body.message_id)
}
if (props.notification.grouped_notifs) {
for (const notif of props.notification.grouped_notifs) {
if (notif.body.message_id) {
messages.push(notif.body.message_id)
}
}
}
return messages
}
</script>
<style lang="scss" scoped>
.notification {
display: grid;
grid-template:
'icon title'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content min-content;
gap: var(--spacing-card-sm);
&.compact {
grid-template:
'icon title actions'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: auto min-content;
}
&.has-body {
grid-template:
'icon title'
'body body'
'actions actions'
'date date';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content auto auto min-content;
&.compact {
grid-template:
'icon title actions'
'body body body'
'date date date';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content auto min-content;
}
}
.label__title,
.label__description,
h1,
h2,
h3,
h4,
:deep(p) {
margin: 0 !important;
}
.notification__icon {
grid-area: icon;
}
.notification__title {
grid-area: title;
color: var(--color-heading);
margin-block: auto;
display: inline-block;
vertical-align: middle;
line-height: 1.25rem;
.iconified-link {
display: inline;
img {
vertical-align: middle;
position: relative;
top: -2px;
}
}
}
.notification__body {
grid-area: body;
.version-list {
margin: 0;
padding: 0;
list-style-type: none;
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: var(--spacing-card-sm);
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
.version-link {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
flex-wrap: wrap;
.version-info {
display: contents;
:deep(span) {
color: var(--color-text);
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
}
}
}
.notification__date {
grid-area: date;
color: var(--color-text-secondary);
svg {
vertical-align: top;
}
.read-badge {
font-weight: bold;
color: var(--color-text);
margin-right: var(--spacing-card-xs);
}
}
.notification__actions {
grid-area: actions;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
}
.unknown-type {
color: var(--color-red);
}
.title-link {
&:not(:hover) {
text-decoration: none;
}
font-weight: bold;
}
.moderation-color {
color: var(--color-orange);
}
.creator-color {
color: var(--color-blue);
}
}
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div class="vue-notification-group">
<transition-group name="notifs">
<div
v-for="(item, index) in notifications"
:key="item.id"
class="vue-notification-wrapper"
@click="notifications.splice(index, 1)"
@mouseenter="stopTimer(item)"
@mouseleave="setNotificationTimer(item)"
>
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
<div class="notification-title" v-html="item.title"></div>
<div class="notification-content" v-html="item.text"></div>
</div>
</div>
</transition-group>
</div>
</template>
<script setup>
const notifications = useNotifications()
function stopTimer(notif) {
clearTimeout(notif.timer)
}
</script>
<style lang="scss" scoped>
.vue-notification {
background: var(--color-blue) !important;
border-left: 5px solid var(--color-blue) !important;
color: var(--color-brand-inverted) !important;
box-sizing: border-box;
text-align: left;
font-size: 12px;
padding: 10px;
margin: 0 5px 5px;
&.success {
background: var(--color-green) !important;
border-left-color: var(--color-green) !important;
}
&.warn {
background: var(--color-orange) !important;
border-left-color: var(--color-orange) !important;
}
&.error {
background: var(--color-red) !important;
border-left-color: var(--color-red) !important;
}
}
.vue-notification-group {
position: fixed;
right: 25px;
bottom: 25px;
z-index: 99999999;
width: 300px;
.vue-notification-wrapper {
width: 100%;
overflow: hidden;
margin-bottom: 10px;
.vue-notification-template {
border-radius: var(--size-rounded-card);
margin: 0;
.notification-title {
font-size: var(--font-size-lg);
margin-right: auto;
font-weight: 600;
}
.notification-content {
margin-right: auto;
font-size: var(--font-size-md);
}
}
&:last-child {
margin: 0;
}
}
@media screen and (max-width: 750px) {
transition: bottom 0.25s ease-in-out;
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
&.browse-menu-open {
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
}
}
}
.notifs-enter-active,
.notifs-leave-active,
.notifs-move {
transition: all 0.5s;
}
.notifs-enter-from,
.notifs-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,139 @@
<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>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</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>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="createProject">
<CheckIcon />
Continue
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon } from '@modrinth/assets'
import { Modal, Button } from '@modrinth/ui'
const router = useNativeRouter()
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const modal = ref()
async function createProject() {
startLoading()
try {
const value = {
name: name.value.trim(),
description: description.value.trim(),
slug: slug.value.trim().replace(/ +/g, ''),
}
const result = await useBaseFetch('organization', {
method: 'POST',
body: JSON.stringify(value),
apiVersion: 3,
})
modal.value.hide()
await router.push(`/organization/${result.slug}`)
} catch (err) {
console.error(err)
addNotification({
group: 'main',
title: 'An error occurred',
text: err.data.description,
type: 'error',
})
}
stopLoading()
}
function show() {
name.value = ''
description.value = ''
modal.value.show()
}
function updateSlug() {
if (!manualSlug.value) {
slug.value = name.value
.trim()
.toLowerCase()
.replaceAll(' ', '-')
.replaceAll(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '')
.replaceAll(/--+/gm, '-')
}
}
defineExpose({
show,
})
</script>
<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--gap-md);
}
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<div>
<Modal ref="modalOpen" header="Transfer Projects">
<div class="universal-modal items">
<div class="table">
<div class="table-row table-head">
<div class="table-cell check-cell">
<Checkbox
:model-value="selectedProjects.length === props.projects.length"
@update:model-value="toggleSelectedProjects()"
/>
</div>
<div class="table-cell">Icon</div>
<div class="table-cell">Name</div>
<div class="table-cell">ID</div>
<div class="table-cell">Type</div>
<div class="table-cell" />
</div>
<div v-for="project in props.projects" :key="`project-${project.id}`" class="table-row">
<div class="table-cell check-cell">
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
/>
</div>
<div class="table-cell">
<nuxt-link tabindex="-1" :to="`/project/${project.slug ? project.slug : project.id}`">
<Avatar
:src="project.icon_url"
aria-hidden="true"
:alt="'Icon for ' + project.name"
no-shadow
/>
</nuxt-link>
</div>
<div class="table-cell">
<span class="project-title">
<nuxt-link
class="hover-link wrap-as-needed"
:to="`/project/${project.slug ? project.slug : project.id}`"
>
{{ project.title }}
</nuxt-link>
</span>
</div>
<div class="table-cell">
<CopyCode :text="project.id" />
</div>
<div class="table-cell">
<BoxIcon />
<span>{{
$formatProjectType(
$getProjectTypeForDisplay(
project.project_types?.[0] ?? 'project',
project.loaders
)
)
}}</span>
</div>
<div class="table-cell">
<nuxt-link
class="btn icon-only"
:to="`/project/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon />
</nuxt-link>
</div>
</div>
</div>
<div class="push-right input-group">
<Button @click="$refs.modalOpen?.hide()">
<XIcon />
Cancel
</Button>
<Button :disabled="!selectedProjects?.length" color="primary" @click="onSubmitHandler()">
<TransferIcon />
<span>
Transfer
<span>
{{
selectedProjects.length === props.projects.length
? 'All'
: selectedProjects.length
}}
</span>
<span>
{{ ' ' }}
{{ selectedProjects.length === 1 ? 'project' : 'projects' }}
</span>
</span>
</Button>
</div>
</div>
</Modal>
<Button @click="$refs.modalOpen?.show()">
<TransferIcon />
<span>Transfer projects</span>
</Button>
</div>
</template>
<script setup>
import { BoxIcon, SettingsIcon, TransferIcon, XIcon } from '@modrinth/assets'
import { Button, Modal, Checkbox, CopyCode, Avatar } from '@modrinth/ui'
const modalOpen = ref(null)
const props = defineProps({
projects: {
type: Array,
required: true,
},
})
// define emit for submission
const emit = defineEmits(['submit'])
const selectedProjects = ref([])
const toggleSelectedProjects = () => {
if (selectedProjects.value.length === props.projects.length) {
selectedProjects.value = []
} else {
selectedProjects.value = props.projects
}
}
const onSubmitHandler = () => {
if (selectedProjects.value.length === 0) {
return
}
emit('submit', selectedProjects.value)
selectedProjects.value = []
modalOpen.value?.hide()
}
</script>
<style lang="scss" scoped>
.table {
display: grid;
border-radius: var(--radius-md);
overflow: hidden;
margin-top: var(--gap-md);
border: 1px solid var(--color-button-bg);
background-color: var(--color-raised-bg);
.table-row {
grid-template-columns: 2.75rem 3.75rem 2fr 1fr 1fr 3.5rem;
}
.table-cell {
display: flex;
align-items: center;
gap: var(--gap-xs);
padding: var(--gap-md);
padding-left: 0;
}
.check-cell {
padding-left: var(--gap-md);
}
@media screen and (max-width: 750px) {
display: flex;
flex-direction: column;
.table-row {
display: grid;
grid-template: 'checkbox icon name type settings' 'checkbox icon id type settings';
grid-template-columns:
min-content min-content minmax(min-content, 2fr)
minmax(min-content, 1fr) min-content;
:nth-child(1) {
grid-area: checkbox;
}
:nth-child(2) {
grid-area: icon;
}
:nth-child(3) {
grid-area: name;
}
:nth-child(4) {
grid-area: id;
padding-top: 0;
}
:nth-child(5) {
grid-area: type;
}
:nth-child(6) {
grid-area: settings;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
:nth-child(2),
:nth-child(3),
:nth-child(4),
:nth-child(5) {
display: none;
}
}
}
@media screen and (max-width: 560px) {
.table-row {
display: grid;
grid-template: 'checkbox icon name settings' 'checkbox icon id settings' 'checkbox icon type settings' 'checkbox icon status settings';
grid-template-columns: min-content min-content minmax(min-content, 1fr) min-content;
:nth-child(5) {
padding-top: 0;
}
}
.table-head {
grid-template: 'checkbox settings';
grid-template-columns: min-content minmax(min-content, 1fr);
}
}
}
.items {
display: flex;
flex-direction: column;
gap: var(--gap-md);
padding: var(--gap-md);
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div v-if="count > 1" class="columns 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-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
:class="{
'page-number': page !== item,
shrink: item > 99,
}"
class="page-number-container"
>
<div v-if="item === '-'" class="has-icon">
<GapIcon />
</div>
<a
v-else
:class="{
'page-number current': page === item,
shrink: item > 99,
}"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
</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>
</div>
</template>
<script>
import GapIcon from '~/assets/images/utils/gap.svg?component'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg?component'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?component'
export default {
components: {
GapIcon,
LeftArrowIcon,
RightArrowIcon,
},
props: {
page: {
type: Number,
default: 1,
},
count: {
type: Number,
default: 1,
},
linkFunction: {
type: Function,
default() {
return () => '/'
},
},
},
emits: ['switch-page'],
computed: {
pages() {
let pages = []
if (this.count > 7) {
if (this.page + 3 >= this.count) {
pages = [
1,
'-',
this.count - 4,
this.count - 3,
this.count - 2,
this.count - 1,
this.count,
]
} else if (this.page > 5) {
pages = [1, '-', this.page - 1, this.page, this.page + 1, '-', this.count]
} else {
pages = [1, 2, 3, 4, 5, '-', this.count]
}
} else {
pages = Array.from({ length: this.count }, (_, i) => i + 1)
}
return pages
},
},
methods: {
switchPage(newPage) {
this.$emit('switch-page', newPage)
if (newPage !== null && newPage !== '' && !isNaN(newPage)) {
this.$emit('switch-page', Math.min(Math.max(newPage, 1), this.count))
}
},
},
}
</script>
<style scoped lang="scss">
a {
color: var(--color-button-text);
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: 0.5rem 1rem;
margin: 0;
border-radius: 2rem;
background: var(--color-raised-bg);
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;
&.page-number.current {
background: var(--color-brand);
color: var(--color-brand-inverted);
cursor: default;
outline: 2px solid transparent;
}
&.paginate.disabled {
background-color: transparent;
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
}
.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

@@ -0,0 +1,527 @@
<template>
<article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
<nuxt-link
:title="name"
class="icon"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
>
<Avatar :src="iconUrl" :alt="name" size="md" no-shadow loading="lazy" />
</nuxt-link>
<nuxt-link
class="gallery"
:class="{ 'no-image': !featuredImage }"
tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
:style="color ? `background-color: ${toColor};` : ''"
>
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</nuxt-link>
<div class="title">
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
<h2 class="name">
{{ name }}
</h2>
</nuxt-link>
<p v-if="author" class="author">
by
<nuxt-link class="title-link" :to="'/user/' + author">
{{ author }}
</nuxt-link>
</p>
<Badge v-if="status && status !== 'approved'" :type="status" class="status" />
</div>
<p class="description">
{{ description }}
</p>
<Categories
:categories="
categories.filter((x) => !hideLoaders || !tags.loaders.find((y) => y.name === x))
"
:type="type"
class="tags"
>
<EnvironmentIndicator
v-if="clientSide && serverSide"
:type-only="moderation"
:client-side="clientSide"
:server-side="serverSide"
:type="projectTypeDisplay"
:search="search"
:categories="categories"
/>
</Categories>
<div class="stats">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(downloads) }}</strong
><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
</p>
</div>
<div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" />
<p>
<strong>{{ $formatNumber(follows) }}</strong
><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
</p>
</div>
<div class="buttons">
<slot />
</div>
<div
v-if="showUpdatedDate"
v-tooltip="$dayjs(updatedAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<EditIcon aria-hidden="true" />
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
</div>
<div
v-else-if="showCreatedDate"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
<CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span>{{ fromNow(createdAt) }}
</div>
</div>
</article>
</template>
<script>
import Categories from '~/components/ui/search/Categories.vue'
import Badge from '~/components/ui/Badge.vue'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator.vue'
import CalendarIcon from '~/assets/images/utils/calendar.svg?component'
import EditIcon from '~/assets/images/utils/updated.svg?component'
import DownloadIcon from '~/assets/images/utils/download.svg?component'
import HeartIcon from '~/assets/images/utils/heart.svg?component'
import Avatar from '~/components/ui/Avatar.vue'
export default {
components: {
EnvironmentIndicator,
Avatar,
Categories,
Badge,
CalendarIcon,
EditIcon,
DownloadIcon,
HeartIcon,
},
props: {
id: {
type: String,
default: 'modrinth-0',
},
type: {
type: String,
default: 'mod',
},
name: {
type: String,
default: 'Project Name',
},
author: {
type: String,
default: null,
},
description: {
type: String,
default: 'A _type description',
},
iconUrl: {
type: String,
default: '#',
required: false,
},
downloads: {
type: String,
default: null,
required: false,
},
follows: {
type: String,
default: null,
required: false,
},
createdAt: {
type: String,
default: '0000-00-00',
},
updatedAt: {
type: String,
default: null,
},
categories: {
type: Array,
default() {
return []
},
},
status: {
type: String,
default: null,
},
hasModMessage: {
type: Boolean,
default: false,
},
serverSide: {
type: String,
required: false,
default: '',
},
clientSide: {
type: String,
required: false,
default: '',
},
moderation: {
type: Boolean,
required: false,
default: false,
},
search: {
type: Boolean,
required: false,
default: false,
},
featuredImage: {
type: String,
required: false,
default: null,
},
showUpdatedDate: {
type: Boolean,
required: false,
default: true,
},
showCreatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,
default: false,
},
color: {
type: Number,
required: false,
default: null,
},
},
setup() {
const tags = useTags()
return { tags }
},
computed: {
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(this.type, this.categories)
},
toColor() {
let color = this.color
color >>>= 0
const b = color & 0xff
const g = (color & 0xff00) >>> 8
const r = (color & 0xff0000) >>> 16
return 'rgba(' + [r, g, b, 1].join(',') + ')'
},
},
}
</script>
<style lang="scss" scoped>
.project-card {
display: inline-grid;
box-sizing: border-box;
overflow: hidden;
margin: 0;
}
.display-mode--list .project-card {
grid-template:
'icon title stats'
'icon description stats'
'icon tags stats';
grid-template-columns: min-content 1fr auto;
grid-template-rows: min-content 1fr min-content;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-sm);
width: 100%;
@media screen and (max-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
@media screen and (max-width: 550px) {
grid-template:
'icon title'
'icon description'
'tags tags'
'stats stats';
grid-template-columns: min-content auto;
grid-template-rows: min-content 1fr min-content min-content;
}
}
.display-mode--gallery .project-card,
.display-mode--grid .project-card {
padding: 0 0 var(--spacing-card-bg) 0;
grid-template: 'gallery gallery' 'icon title' 'description description' 'tags tags' 'stats stats';
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content 1fr min-content min-content;
row-gap: var(--spacing-card-sm);
.gallery {
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg-active);
&.no-image {
filter: brightness(0.7);
}
img {
box-shadow: none;
width: 100%;
height: 10rem;
object-fit: cover;
}
}
.icon {
margin-left: var(--spacing-card-bg);
margin-top: -3rem;
z-index: 1;
img,
svg {
border-radius: var(--size-rounded-lg);
box-shadow: -2px -2px 0 2px var(--color-raised-bg), 2px -2px 0 2px var(--color-raised-bg);
}
}
.title {
margin-left: var(--spacing-card-md);
margin-right: var(--spacing-card-bg);
flex-direction: column;
.name {
font-size: 1.25rem;
}
.status {
margin-top: var(--spacing-card-xs);
}
}
.description {
margin-inline: var(--spacing-card-bg);
}
.tags {
margin-inline: var(--spacing-card-bg);
}
.stats {
margin-inline: var(--spacing-card-bg);
flex-direction: row;
align-items: center;
.stat-label {
display: none;
}
.buttons {
flex-direction: row;
gap: var(--spacing-card-sm);
align-items: center;
> :first-child {
margin-left: auto;
}
&:first-child > :last-child {
margin-right: auto;
}
}
.buttons:not(:empty) + .date {
flex-basis: 100%;
}
}
}
.display-mode--grid .project-card {
.gallery {
display: none;
}
.icon {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
img,
svg {
border: none;
}
}
.title {
margin-top: calc(var(--spacing-card-bg) - var(--spacing-card-sm));
}
}
.icon {
grid-area: icon;
display: flex;
align-items: center;
}
.gallery {
display: none;
height: 10rem;
grid-area: gallery;
}
.title {
grid-area: title;
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
column-gap: var(--spacing-card-sm);
row-gap: 0;
word-wrap: anywhere;
h2,
p {
margin: 0;
}
svg {
width: auto;
color: var(--color-orange);
height: 1.5rem;
margin-bottom: -0.25rem;
}
}
.stats {
grid-area: stats;
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-card-md);
.stat {
display: flex;
flex-direction: row;
align-items: center;
width: fit-content;
gap: var(--spacing-card-xs);
--stat-strong-size: 1.25rem;
strong {
font-size: var(--stat-strong-size);
}
p {
margin: 0;
}
svg {
height: var(--stat-strong-size);
width: var(--stat-strong-size);
}
}
.date {
margin-top: auto;
}
@media screen and (max-width: 750px) {
flex-direction: row;
column-gap: var(--spacing-card-md);
margin-top: var(--spacing-card-xs);
}
@media screen and (max-width: 600px) {
margin-top: 0;
.stat-label {
display: none;
}
}
}
.environment {
color: var(--color-text) !important;
font-weight: bold;
}
.description {
grid-area: description;
margin-block: 0;
display: flex;
justify-content: flex-start;
}
.tags {
grid-area: tags;
display: flex;
flex-direction: row;
@media screen and (max-width: 550px) {
margin-top: var(--spacing-card-xs);
}
}
.buttons {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
align-items: flex-end;
flex-grow: 1;
}
.small-mode {
@media screen and (min-width: 750px) {
grid-template:
'icon title'
'icon description'
'icon tags'
'stats stats' !important;
grid-template-columns: min-content auto !important;
grid-template-rows: min-content 1fr min-content min-content !important;
.tags {
margin-top: var(--spacing-card-xs) !important;
}
.stats {
flex-direction: row;
column-gap: var(--spacing-card-md) !important;
margin-top: var(--spacing-card-xs) !important;
.stat-label {
display: none !important;
}
}
}
}
</style>

View File

@@ -0,0 +1,504 @@
<template>
<div v-if="showInvitation" class="universal-card information invited">
<h2>Invitation to join project</h2>
<p>
You've been invited be a member of this project with the role of '{{ currentMember.role }}'.
</p>
<div class="input-group">
<button class="iconified-button brand-button" @click="acceptInvite()">
<CheckIcon />Accept
</button>
<button class="iconified-button danger-button" @click="declineInvite()">
<CrossIcon />Decline
</button>
</div>
</div>
<div
v-if="
currentMember &&
nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || tags.rejectedStatuses.includes(project.status))
"
class="author-actions universal-card"
>
<div class="header__row">
<div class="header__title">
<h2>Publishing checklist</h2>
<div class="checklist">
<span class="checklist__title">Progress:</span>
<div class="checklist__items">
<div
v-for="nag in nags"
:key="`checklist-${nag.id}`"
v-tooltip="nag.title"
:aria-label="nag.title"
class="circle"
:class="'circle ' + (!nag.condition ? 'done ' : '') + nag.status"
>
<CheckIcon v-if="!nag.condition" />
<RequiredIcon v-else-if="nag.status === 'required'" />
<SuggestionIcon v-else-if="nag.status === 'suggestion'" />
<ModerationIcon v-else-if="nag.status === 'review'" />
</div>
</div>
</div>
</div>
<div class="input-group">
<button
class="square-button"
:class="{ 'not-collapsed': !collapsed }"
@click="toggleCollapsed()"
>
<DropdownIcon />
</button>
</div>
</div>
<div v-if="!collapsed" class="grid-display width-16">
<div
v-for="nag in nags.filter((x) => x.condition && !x.hide)"
:key="nag.id"
class="grid-display__item"
>
<span class="label">
<RequiredIcon
v-if="nag.status === 'required'"
v-tooltip="'Required'"
aria-label="Required"
:class="nag.status"
/>
<SuggestionIcon
v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'"
aria-label="Suggestion"
:class="nag.status"
/>
<ModerationIcon
v-else-if="nag.status === 'review'"
v-tooltip="'Review'"
aria-label="Review"
:class="nag.status"
/>{{ nag.title }}</span
>
{{ nag.description }}
<NuxtLink
v-if="nag.link"
:class="{ invisible: nag.link.hide }"
class="goto-link"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
nag.link.path
}`"
>
{{ nag.link.title }}
<ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
</NuxtLink>
<button
v-else-if="nag.action"
class="iconified-button moderation-button"
:disabled="nag.action.disabled()"
@click="nag.action.onClick"
>
<SendIcon />
{{ nag.action.title }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { formatProjectType } from '~/plugins/shorthands.js'
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?component'
import CheckIcon from '~/assets/images/utils/check.svg?component'
import CrossIcon from '~/assets/images/utils/x.svg?component'
import RequiredIcon from '~/assets/images/utils/asterisk.svg?component'
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg?component'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
import SendIcon from '~/assets/images/utils/send.svg?component'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default: null,
},
allMembers: {
type: Object,
default: null,
},
isSettings: {
type: Boolean,
default: false,
},
collapsed: {
type: Boolean,
default: false,
},
routeName: {
type: String,
default: '',
},
auth: {
type: Object,
required: true,
},
tags: {
type: Object,
required: true,
},
setProcessing: {
type: Function,
default() {
return () => {
addNotification({
group: 'main',
title: 'An error occurred',
text: 'setProcessing function not found',
type: 'error',
})
}
},
},
toggleCollapsed: {
type: Function,
default() {
return () => {
addNotification({
group: 'main',
title: 'An error occurred',
text: 'toggleCollapsed function not found',
type: 'error',
})
}
},
},
updateMembers: {
type: Function,
default() {
return () => {
addNotification({
group: 'main',
title: 'An error occurred',
text: 'updateMembers function not found',
type: 'error',
})
}
},
},
})
const featuredGalleryImage = computed(() => props.project.gallery.find((img) => img.featured))
const nags = computed(() => [
{
condition: props.versions.length < 1,
title: 'Upload a version',
id: 'upload-version',
description: 'At least one version is required for a project to be submitted for review.',
status: 'required',
link: {
path: 'versions',
title: 'Visit versions page',
hide: props.routeName === 'type-id-versions',
},
},
{
condition:
props.project.body === '' || props.project.body.startsWith('# Placeholder description'),
title: 'Add a description',
id: 'add-description',
description:
"A description that clearly describes the project's purpose and function is required.",
status: 'required',
link: {
path: 'settings/description',
title: 'Visit description settings',
hide: props.routeName === 'type-id-settings-description',
},
},
{
condition: !props.project.icon_url,
title: 'Add an icon',
id: 'add-icon',
description:
'Your project should have a nice-looking icon to uniquely identify your project at a glance.',
status: 'suggestion',
link: {
path: 'settings',
title: 'Visit general settings',
hide: props.routeName === 'type-id-settings',
},
},
{
condition: props.project.gallery.length === 0 || !featuredGalleryImage,
title: 'Feature a gallery image',
id: 'feature-gallery-image',
description: 'Featured gallery images may be the first impression of many users.',
status: 'suggestion',
link: {
path: 'gallery',
title: 'Visit gallery page',
hide: props.routeName === 'type-id-gallery',
},
},
{
hide: props.project.versions.length === 0,
condition: props.project.categories.length < 1,
title: 'Select tags',
id: 'select-tags',
description: 'Select all tags that apply to your project.',
status: 'suggestion',
link: {
path: 'settings/tags',
title: 'Visit tag settings',
hide: props.routeName === 'type-id-settings-tags',
},
},
{
condition: !(
props.project.issues_url ||
props.project.source_url ||
props.project.wiki_url ||
props.project.discord_url ||
props.project.donation_urls.length > 0
),
title: 'Add external links',
id: 'add-links',
description:
'Add any relevant links targeted outside of Modrinth, such as sources, issues, or a Discord invite.',
status: 'suggestion',
link: {
path: 'settings/links',
title: 'Visit links settings',
hide: props.routeName === 'type-id-settings-links',
},
},
{
hide:
props.project.versions.length === 0 ||
props.project.project_type === 'resourcepack' ||
props.project.project_type === 'plugin' ||
props.project.project_type === 'shader' ||
props.project.project_type === 'datapack',
condition:
props.project.client_side === 'unknown' ||
props.project.server_side === 'unknown' ||
(props.project.client_side === 'unsupported' && props.project.server_side === 'unsupported'),
title: 'Select supported environments',
id: 'select-environments',
description: `Select if the ${formatProjectType(
props.project.project_type
).toLowerCase()} functions on the client-side and/or server-side.`,
status: 'required',
link: {
path: 'settings',
title: 'Visit general settings',
hide: props.routeName === 'type-id-settings',
},
},
{
condition: props.project.license.id === 'LicenseRef-Unknown',
title: 'Select license',
id: 'select-license',
description: `Select the license your ${formatProjectType(
props.project.project_type
).toLowerCase()} is distributed under.`,
status: 'required',
link: {
path: 'settings/license',
title: 'Visit license settings',
hide: props.routeName === 'type-id-settings-license',
},
},
{
condition: props.project.status === 'draft',
title: 'Submit for review',
id: 'submit-for-review',
description:
'Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published.',
status: 'review',
link: null,
action: {
onClick: submitForReview,
title: 'Submit for review',
disabled: () => nags.value.filter((x) => x.condition && x.status === 'required').length > 0,
},
},
{
condition: props.tags.rejectedStatuses.includes(props.project.status),
title: 'Resubmit for review',
id: 'resubmit-for-review',
description: `Your project has been ${props.project.status} by
Modrinth's staff. In most cases, you can resubmit for review after
addressing the staff's message.`,
status: 'review',
link: {
path: 'moderation',
title: 'Visit moderation page',
hide: props.routeName === 'type-id-moderation',
},
},
])
const showInvitation = computed(() => {
if (props.allMembers && props.auth) {
const member = props.allMembers.find((x) => x.user.id === props.auth.user.id)
return member && !member.accepted
}
return false
})
const acceptInvite = () => {
acceptTeamInvite(props.project.team)
props.updateMembers()
}
const declineInvite = () => {
removeTeamMember(props.project.team, props.auth.user.id)
props.updateMembers()
}
const submitForReview = async () => {
if (
!props.acknowledgedMessage ||
nags.value.filter((x) => x.condition && x.status === 'required').length === 0
) {
await props.setProcessing()
}
}
</script>
<style lang="scss" scoped>
.invited {
}
.author-actions {
&:empty {
display: none;
}
.invisible {
visibility: hidden;
}
.header__row {
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
max-width: 100%;
.header__title {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: var(--spacing-card-lg);
row-gap: var(--spacing-card-md);
flex-basis: min-content;
h2 {
margin: 0 auto 0 0;
}
}
button {
svg {
transition: transform 0.25s ease-in-out;
}
&.not-collapsed svg {
transform: rotate(180deg);
}
}
}
.grid-display__item .label {
display: flex;
gap: var(--spacing-card-xs);
align-items: center;
.required {
color: var(--color-red);
}
.suggestion {
color: var(--color-purple);
}
.review {
color: var(--color-orange);
}
}
.checklist {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
flex-wrap: wrap;
max-width: 100%;
.checklist__title {
font-weight: bold;
margin-right: var(--spacing-card-xs);
color: var(--color-text-dark);
}
.checklist__items {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-card-xs);
width: fit-content;
max-width: 100%;
}
.circle {
--circle-size: 2rem;
--background-color: var(--color-bg);
--content-color: var(--color-gray);
width: var(--circle-size);
height: var(--circle-size);
border-radius: 50%;
background-color: var(--background-color);
display: flex;
justify-content: center;
align-items: center;
svg {
color: var(--content-color);
width: calc(var(--circle-size) / 2);
height: calc(var(--circle-size) / 2);
}
&.required {
--content-color: var(--color-red);
}
&.suggestion {
--content-color: var(--color-purple);
}
&.review {
--content-color: var(--color-orange);
}
&.done {
--background-color: var(--color-green);
--content-color: var(--color-brand-inverted);
}
}
}
}
</style>

View File

@@ -0,0 +1,161 @@
<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()
}
"
>
<ClearIcon />
Clear filters
</button>
</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'
const props = defineProps({
versions: {
type: Array,
default() {
return []
},
},
})
const emit = defineEmits(['switch-page'])
const router = useNativeRouter()
const route = useNativeRoute()
const tags = useTags()
const tempLoaders = new Set()
let 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)
}
tempVersions = Array.from(tempVersions)
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,
},
})
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;
}
.checkbox-outer {
min-width: fit-content;
}
}
</style>

View File

@@ -0,0 +1,490 @@
<script setup>
import dayjs from 'dayjs'
import { formatNumber, formatMoney } from '@modrinth/utils'
import VueApexCharts from 'vue3-apexcharts'
const props = defineProps({
name: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
data: {
type: Array,
required: true,
},
formatLabels: {
type: Function,
default: (label) => dayjs(label).format('MMM D'),
},
colors: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
hideToolbar: {
type: Boolean,
default: false,
},
hideLegend: {
type: Boolean,
default: false,
},
stacked: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'bar',
},
hideTotal: {
type: Boolean,
default: false,
},
isMoney: {
type: Boolean,
default: false,
},
legendPosition: {
type: String,
default: 'right',
},
xAxisType: {
type: String,
default: 'datetime',
},
percentStacked: {
type: Boolean,
default: false,
},
horizontalBar: {
type: Boolean,
default: false,
},
disableAnimations: {
type: Boolean,
default: false,
},
})
function formatTooltipValue(value, props) {
return props.isMoney ? formatMoney(value, false) : formatNumber(value, false)
}
function generateListEntry(value, index, _, w, props) {
const color = w.globals.colors?.[index]
return `<div class="list-entry">
<span class="circle" style="background-color: ${color}"></span>
<div class="label">
${w.globals.seriesNames[index]}
</div>
<div class="value">
${props.prefix}${formatTooltipValue(value, props)}${props.suffix}
</div>
</div>`
}
function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
const label = w.globals.lastXAxis.categories?.[dataPointIndex]
const formattedLabel = props.formatLabels(label)
let tooltip = `<div class="bar-tooltip">
<div class="seperated-entry title">
<div class="label">${formattedLabel}</div>`
// Logic for total and percent stacked
if (!props.hideTotal) {
if (props.percentStacked) {
const total = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
const percentValue = (100 * series[seriesIndex][dataPointIndex]) / total
tooltip += `<div class="value">${props.prefix}${formatNumber(percentValue)}%${
props.suffix
}</div>`
} else {
const totalValue = series.reduce((a, b) => a + (b?.[dataPointIndex] || 0), 0)
tooltip += `<div class="value">${props.prefix}${formatTooltipValue(totalValue, props)}${
props.suffix
}</div>`
}
}
tooltip += '</div><hr class="card-divider" />'
// Logic for generating list entries
if (props.percentStacked) {
tooltip += generateListEntry(
series[seriesIndex][dataPointIndex],
seriesIndex,
seriesIndex,
w,
props
)
} else {
const returnTopN = 5
const listEntries = series
.map((value, index) => [
value[dataPointIndex],
generateListEntry(value[dataPointIndex], index, seriesIndex, w, props),
])
.filter((value) => value[0] > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, returnTopN) // Return only the top X entries
.map((value) => value[1])
.join('')
tooltip += listEntries
}
tooltip += '</div>'
return tooltip
}
const chartOptions = computed(() => {
return {
chart: {
id: props.name,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
selection: {
enabled: true,
fill: {
color: 'var(--color-brand)',
},
},
toolbar: {
show: false,
},
stacked: props.stacked,
stackType: props.percentStacked ? '100%' : 'normal',
zoom: {
autoScaleYaxis: true,
},
animations: {
enabled: props.disableAnimations,
},
},
xaxis: {
type: props.xAxisType,
categories: props.labels,
labels: {
style: {
borderRadius: 'var(--radius-sm)',
},
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
tooltip: {
enabled: false,
},
},
colors: props.colors,
dataLabels: {
enabled: false,
background: {
enabled: true,
borderRadius: 20,
},
},
grid: {
borderColor: 'var(--color-button-bg)',
tickColor: 'var(--color-button-bg)',
},
legend: {
show: !props.hideLegend,
position: props.legendPosition,
showForZeroSeries: false,
showForSingleSeries: false,
showForNullSeries: false,
fontSize: 'var(--font-size-nm)',
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
onItemClick: {
toggleDataSeries: true,
},
},
markers: {
size: 0,
strokeColor: 'var(--color-contrast)',
strokeWidth: 3,
strokeOpacity: 1,
fillOpacity: 1,
hover: {
size: 6,
},
},
plotOptions: {
bar: {
horizontal: props.horizontalBar,
columnWidth: '80%',
endingShape: 'rounded',
borderRadius: 5,
borderRadiusApplication: 'end',
borderRadiusWhenStacked: 'last',
},
},
stroke: {
curve: 'smooth',
width: 2,
},
tooltip: {
custom: (d) => generateTooltip(d, props),
},
fill:
props.type === 'area'
? {
colors: props.colors,
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: props.colors,
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
}
: {},
}
})
const chart = ref(null)
const legendValues = ref(
[...props.data].map((project, index) => {
return { name: project.name, visible: true, color: props.colors[index] }
})
)
const flipLegend = (legend, newVal) => {
legend.visible = newVal
chart.value.toggleSeries(legend.name)
}
const resetChart = () => {
if (!chart.value) return
chart.value.updateSeries([...props.data])
chart.value.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value.resetSeries()
legendValues.value.forEach((legend) => {
legend.visible = true
})
}
defineExpose({
resetChart,
flipLegend,
})
</script>
<template>
<VueApexCharts ref="chart" :type="type" :options="chartOptions" :series="data" class="chart" />
</template>
<style scoped lang="scss">
.chart {
width: 100%;
height: 100%;
}
svg {
width: 100%;
height: 100%;
}
.btn {
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bar-chart {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.title-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
}
.toolbar {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
z-index: 1;
margin-left: auto;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-yaxistooltip),
:deep(.apexcharts-xaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
.apexcharts-xaxistooltip-text {
font-size: var(--font-size-nm) !important;
color: var(--color-base) !important;
}
}
:deep(.apexcharts-yaxistooltip-left:after) {
border-left-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-yaxistooltip-left:before) {
border-left-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:after) {
border-bottom-color: var(--color-raised-bg) !important;
}
:deep(.apexcharts-xaxistooltip-bottom:before) {
border-bottom-color: var(--color-button-bg) !important;
}
:deep(.apexcharts-menu-item) {
border-radius: var(--radius-sm) !important;
padding: var(--gap-xs) var(--gap-sm) !important;
&:hover {
transition: all 0.3s !important;
color: var(--color-accent-contrast) !important;
background: var(--color-brand) !important;
}
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
min-width: 10rem;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.seperated-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: bolder;
}
.label {
margin-right: var(--gap-xl);
color: var(--color-contrast);
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
font-size: var(--font-size-sm);
.value {
margin-left: auto;
}
}
.circle {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
border: 2px solid var(--color-base);
}
svg {
height: 1em;
width: 1em;
}
}
}
.legend {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-items: center;
gap: var(--gap-lg);
justify-content: center;
}
:deep(.checkbox) {
white-space: nowrap;
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>

View File

@@ -0,0 +1,734 @@
<template>
<div>
<div v-if="analytics.error.value" class="universal-card">
<h2>
<span class="label__title">Error</span>
</h2>
<div>
{{ analytics.error.value }}
</div>
</div>
<div v-else class="graphs">
<div class="graphs__vertical-bar">
<client-only>
<CompactChart
v-if="analytics.formattedData.value.downloads"
ref="tinyDownloadChart"
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-brand)"
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
:data="analytics.formattedData.value.downloads.chart.sumData"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<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'><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>"
:class="`clickable chart-button-base button-base ${
selectedChart === 'downloads'
? 'chart-button-base__selected button-base__selected'
: ''
}`"
:onclick="() => (selectedChart = 'downloads')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.views"
ref="tinyViewChart"
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-blue)"
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
:data="analytics.formattedData.value.views.chart.sumData"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<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'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:class="`clickable chart-button-base button-base ${
selectedChart === 'views' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'views')"
role="button"
/>
</client-only>
<client-only>
<CompactChart
v-if="analytics.formattedData.value.revenue"
ref="tinyRevenueChart"
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
color="var(--color-purple)"
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
:data="analytics.formattedData.value.revenue.chart.sumData"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
:class="`clickable chart-button-base button-base ${
selectedChart === 'revenue' ? 'chart-button-base__selected button-base__selected' : ''
}`"
:onclick="() => (selectedChart = 'revenue')"
role="button"
/>
</client-only>
</div>
<div class="graphs__main-graph">
<div class="universal-card">
<div class="chart-controls">
<h2>
<span class="label__title">
{{ formatCategoryHeader(selectedChart) }}
</span>
</h2>
<div class="chart-controls__buttons">
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
<PaletteIcon />
</Button>
<Button v-tooltip="'Download this data as CSV'" icon-only @click="onDownloadSetAsCSV">
<DownloadIcon />
</Button>
<Button v-tooltip="'Refresh the chart'" icon-only @click="resetCharts">
<UpdatedIcon />
</Button>
<DropdownSelect
v-model="selectedRange"
:options="selectableRanges"
name="Time range"
:display-name="(o: typeof selectableRanges[number] | undefined) => o?.label || 'Custom'"
/>
</div>
</div>
<div class="chart-area">
<div class="chart">
<client-only>
<Chart
v-if="analytics.formattedData.value.downloads && selectedChart === 'downloads'"
ref="downloadsChart"
type="line"
name="Download data"
:hide-legend="true"
:data="analytics.formattedData.value.downloads.chart.data"
:labels="analytics.formattedData.value.downloads.chart.labels"
suffix="<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'><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>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.downloads.chart.colors
: analytics.formattedData.value.downloads.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.views && selectedChart === 'views'"
ref="viewsChart"
type="line"
name="View data"
:hide-legend="true"
:data="analytics.formattedData.value.views.chart.data"
:labels="analytics.formattedData.value.views.chart.labels"
suffix="<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'><path d='M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z'/><circle cx='12' cy='12' r='3'/></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.views.chart.colors
: analytics.formattedData.value.views.chart.defaultColors
"
/>
<Chart
v-if="analytics.formattedData.value.revenue && selectedChart === 'revenue'"
ref="revenueChart"
type="line"
name="Revenue data"
:hide-legend="true"
:data="analytics.formattedData.value.revenue.chart.data"
:labels="analytics.formattedData.value.revenue.chart.labels"
is-money
suffix="<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'><line x1='12' y1='2' x2='12' y2='22'></line><path d='M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6'></path></svg>"
:colors="
isUsingProjectColors
? analytics.formattedData.value.revenue.chart.colors
: analytics.formattedData.value.revenue.chart.defaultColors
"
/>
</client-only>
</div>
<div class="legend">
<div class="legend__items">
<template v-for="project in selectedDataSetProjects" :key="project">
<button
v-tooltip="project.title"
:class="`legend__item button-base btn-transparent ${
!projectIsOnDisplay(project.id) ? 'btn-dimmed' : ''
}`"
@click="
() =>
projectIsOnDisplay(project.id) &&
analytics.validProjectIds.value.includes(project.id)
? removeProjectFromDisplay(project.id)
: addProjectToDisplay(project.id)
"
>
<div
:style="{
'--color-brand': isUsingProjectColors
? intToRgba(project.color, project.id, theme ?? undefined)
: getDefaultColor(project.id),
}"
class="legend__item__color"
></div>
<div class="legend__item__text">{{ project.title }}</div>
</button>
</template>
</div>
</div>
</div>
</div>
<div class="country-data">
<Card
v-if="
analytics.formattedData.value?.downloadsByCountry &&
selectedChart === 'downloads' &&
analytics.formattedData.value.downloadsByCountry.data.length > 0
"
class="country-downloads"
>
<label>
<span class="label__title">Downloads by region</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.downloadsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name"
><template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
formatPercent(count, analytics.formattedData.value.downloadsByCountry.sum)
"
class="percentage-bar"
>
<span
:style="{
width: formatPercent(
count,
analytics.formattedData.value.downloadsByCountry.sum
),
backgroundColor: 'var(--color-brand)',
}"
></span>
</div>
</div>
</div>
</Card>
<Card
v-if="
analytics.formattedData.value?.viewsByCountry &&
selectedChart === 'views' &&
analytics.formattedData.value.viewsByCountry.data.length > 0
"
class="country-downloads"
>
<label>
<span class="label__title">Page views by region</span>
</label>
<div class="country-values">
<div
v-for="[name, count] in analytics.formattedData.value.viewsByCountry.data"
:key="name"
class="country-value"
>
<div class="country-flag-container">
<img
:src="
name.toLowerCase() === 'xx' || !name
? 'https://cdn.modrinth.com/placeholder-banner.svg'
: countryCodeToFlag(name)
"
alt="Hidden country"
class="country-flag"
/>
</div>
<div class="country-text">
<strong class="country-name">
<template v-if="name.toLowerCase() === 'xx' || !name">Hidden</template>
<template v-else>{{ countryCodeToName(name) }}</template>
</strong>
<span class="data-point">{{ formatNumber(count) }}</span>
</div>
<div
v-tooltip="
`${
Math.round(
(count / analytics.formattedData.value.viewsByCountry.sum) * 10000
) / 100
}%`
"
class="percentage-bar"
>
<span
:style="{
width: `${(count / analytics.formattedData.value.viewsByCountry.sum) * 100}%`,
backgroundColor: 'var(--color-blue)',
}"
></span>
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatMoney, formatNumber, formatCategoryHeader } from '@modrinth/utils'
import { UpdatedIcon, DownloadIcon } from '@modrinth/assets'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { analyticsSetToCSVString, intToRgba } from '~/utils/analytics.js'
import { UiChartsCompactChart as CompactChart, UiChartsChart as Chart } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
const router = useNativeRouter()
const theme = useTheme()
const props = withDefaults(
defineProps<{
projects?: any[]
/**
* @deprecated Use `ranges` instead
*/
resoloutions?: Record<string, number>
ranges?: Record<number, [string, number] | string>
personal?: boolean
}>(),
{
projects: undefined,
resoloutions: () => defaultResoloutions,
ranges: () => defaultRanges,
personal: false,
}
)
const projects = ref(props.projects || [])
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
label: typeof extra === 'string' ? extra : extra[0],
value: Number(duration),
res: typeof extra === 'string' ? Number(duration) : extra[1],
}))
// const selectedChart = ref('downloads')
const selectedChart = computed({
get: () => {
const id = (router.currentRoute.value.query?.chart as string | undefined) || 'downloads'
// if the id is anything but the 3 charts we have or undefined, throw an error
if (!['downloads', 'views', 'revenue'].includes(id)) {
throw new Error(`Unknown chart ${id}`)
}
return id
},
set: (chart) => {
router.push({
query: {
...router.currentRoute.value.query,
chart,
},
})
},
})
// Chart refs
const downloadsChart = ref()
const viewsChart = ref()
const revenueChart = ref()
const tinyDownloadChart = ref()
const tinyViewChart = ref()
const tinyRevenueChart = ref()
const selectedDisplayProjects = ref(props.projects || [])
const removeProjectFromDisplay = (id: string) => {
selectedDisplayProjects.value = selectedDisplayProjects.value.filter((p) => p.id !== id)
}
const addProjectToDisplay = (id: string) => {
selectedDisplayProjects.value = [
...selectedDisplayProjects.value,
props.projects?.find((p) => p.id === id),
].filter(Boolean)
}
const projectIsOnDisplay = (id: string) => {
return selectedDisplayProjects.value?.some((p) => p.id === id) ?? false
}
const resetCharts = () => {
downloadsChart.value?.resetChart()
viewsChart.value?.resetChart()
revenueChart.value?.resetChart()
tinyDownloadChart.value?.resetChart()
tinyViewChart.value?.resetChart()
tinyRevenueChart.value?.resetChart()
}
const isUsingProjectColors = computed({
get: () => {
return (
router.currentRoute.value.query?.colors === 'true' ||
router.currentRoute.value.query?.colors === undefined
)
},
set: (newValue) => {
router.push({
query: {
...router.currentRoute.value.query,
colors: newValue ? 'true' : 'false',
},
})
},
})
const analytics = useFetchAllAnalytics(
resetCharts,
projects,
selectedDisplayProjects,
props.personal
)
const { startDate, endDate, timeRange, timeResolution } = analytics
const selectedRange = computed({
get: () => {
return (
selectableRanges.find((option) => option.value === timeRange.value) || {
label: 'Custom',
value: timeRange.value,
}
)
},
set: (newRange: { label: string; value: number; res?: number }) => {
timeRange.value = newRange.value
startDate.value = Date.now() - timeRange.value * 60 * 1000
endDate.value = Date.now()
if (newRange?.res) {
timeResolution.value = newRange.res
}
},
})
const selectedDataSet = computed(() => {
switch (selectedChart.value) {
case 'downloads':
return analytics.totalData.value.downloads
case 'views':
return analytics.totalData.value.views
case 'revenue':
return analytics.totalData.value.revenue
default:
throw new Error(`Unknown chart ${selectedChart.value}`)
}
})
const selectedDataSetProjects = computed(() => {
return selectedDataSet.value.projectIds
.map((id) => props.projects?.find((p) => p?.id === id))
.filter(Boolean)
})
const downloadSelectedSetAsCSV = () => {
const selectedChartName = selectedChart.value
const csv = analyticsSetToCSVString(selectedDataSet.value)
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${selectedChartName}-data.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
}
const onDownloadSetAsCSV = useClientTry(async () => await downloadSelectedSetAsCSV())
const onToggleColors = () => {
isUsingProjectColors.value = !isUsingProjectColors.value
}
</script>
<script lang="ts">
const defaultResoloutions: Record<string, number> = {
'5 minutes': 5,
'30 minutes': 30,
'An hour': 60,
'12 hours': 720,
'A day': 1440,
'A week': 10080,
}
const defaultRanges: Record<number, [string, number] | string> = {
30: ['Last 30 minutes', 1],
60: ['Last hour', 5],
720: ['Last 12 hours', 15],
1440: ['Last day', 60],
10080: ['Last week', 720],
43200: ['Last month', 1440],
129600: ['Last quarter', 10080],
525600: ['Last year', 20160],
1051200: ['Last two years', 40320],
}
</script>
<style scoped lang="scss">
.chart-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: var(--gap-md);
.chart-controls__buttons {
display: flex;
flex-direction: row;
gap: var(--gap-xs);
* {
width: auto;
min-height: auto;
}
}
}
.chart-area {
display: flex;
flex-direction: row;
gap: var(--gap-md);
height: 100%;
.chart {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 24px;
overflow: hidden;
max-width: 26ch;
width: fit-content;
.legend__items {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
.legend__item {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
font-size: var(--font-size-sm);
width: 100%;
.legend__item__text {
white-space: nowrap;
text-overflow: ellipsis;
}
.legend__item__color {
height: var(--font-size-xs);
width: var(--font-size-xs);
border-radius: var(--radius-sm);
background-color: var(--color-brand);
flex-grow: 0;
flex-shrink: 0;
}
}
}
}
}
.btn-transparent {
background-color: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-weight: var(--font-weight-regular);
}
.btn-dimmed {
opacity: 0.5;
}
.chart-button-base {
overflow: hidden;
}
.chart-button-base__selected {
color: var(--color-contrast);
background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
&:hover {
background-color: var(--color-brand-highlight);
}
}
.graphs {
// Pages clip so we need to add a margin
margin-left: 0.25rem;
margin-top: 0.25rem;
display: flex;
flex-direction: column;
.graphs__vertical-bar {
flex-grow: 0;
flex-shrink: 0;
gap: 0.75rem;
display: flex;
margin-right: 0.1rem;
}
}
.country-flag-container {
width: 40px;
height: 27px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xs);
}
.country-flag {
object-fit: cover;
min-width: 100%;
min-height: 100%;
}
.spark-data {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--gap-md);
}
.country-data {
display: grid;
grid-template-columns: 1fr;
gap: var(--gap-md);
}
.country-values {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-button-bg);
gap: var(--gap-md);
padding: var(--gap-md);
margin-top: var(--gap-md);
overflow-y: auto;
max-height: 24rem;
}
.country-value {
display: grid;
grid-template-areas: 'flag text bar';
grid-template-columns: auto 1fr 10rem;
align-items: center;
justify-content: space-between;
width: 100%;
gap: var(--gap-sm);
.country-text {
grid-area: text;
display: flex;
flex-direction: column;
gap: var(--gap-xs);
}
.percentage-bar {
grid-area: bar;
width: 100%;
height: 1rem;
background-color: var(--color-raised-bg);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-sm);
overflow: hidden;
span {
display: block;
height: 100%;
}
}
}
@media (max-width: 768px) {
.chart-area {
flex-direction: column;
gap: var(--gap-md);
}
.chart-controls {
flex-direction: column;
gap: var(--gap-md);
}
.chart {
flex-direction: column;
gap: var(--gap-md);
}
.legend {
margin-top: 0px;
width: 100%;
max-width: 100%;
}
.graphs {
margin-left: 0px;
margin-top: 0px;
.graphs__vertical-bar {
flex-direction: column;
gap: 0;
margin-right: 0px;
}
}
.country-data {
display: block;
}
.country-value {
grid-template-columns: auto 1fr 5rem;
}
}
</style>

View File

@@ -0,0 +1,280 @@
<script setup>
import { Card } from '@modrinth/ui'
import VueApexCharts from 'vue3-apexcharts'
// let VueApexCharts
// if (process.client) {
// VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
// }
const props = defineProps({
value: {
type: String,
default: '',
},
title: {
type: String,
default: '',
},
data: {
type: Array,
default: () => [],
},
labels: {
type: Array,
default: () => [],
},
prefix: {
type: String,
default: '',
},
suffix: {
type: String,
default: '',
},
isMoney: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'var(--color-brand)',
},
})
// no grid lines, no toolbar, no legend, no data labels
const chartOptions = {
chart: {
id: props.title,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif',
foreColor: 'var(--color-base)',
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
sparkline: {
enabled: true,
},
parentHeightOffset: 0,
},
stroke: {
curve: 'smooth',
width: 2,
},
fill: {
colors: [props.color],
type: 'gradient',
opacity: 1,
gradient: {
shade: 'light',
type: 'vertical',
shadeIntensity: 0,
gradientToColors: [props.color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: {
show: false,
},
legend: {
show: false,
},
colors: [props.color],
dataLabels: {
enabled: false,
},
xaxis: {
type: 'datetime',
categories: props.labels,
labels: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
yaxis: {
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
tooltip: {
enabled: false,
},
},
tooltip: {
enabled: false,
},
}
const chart = ref(null)
const resetChart = () => {
chart.value?.updateSeries([...props.data])
chart.value?.updateOptions({
xaxis: {
categories: props.labels,
},
})
chart.value?.resetSeries()
}
defineExpose({
resetChart,
})
</script>
<template>
<Card class="compact-chart">
<h1 class="value">
{{ value }}
</h1>
<div class="subtitle">
{{ title }}
</div>
<div class="chart">
<VueApexCharts ref="chart" type="area" :options="chartOptions" :series="data" height="70" />
</div>
</Card>
</template>
<style scoped lang="scss">
.compact-chart {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
border: 1px solid var(--color-button-bg);
border-radius: var(--radius-md);
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-floating);
color: var(--color-base);
font-size: var(--font-size-nm);
width: 100%;
padding-top: var(--gap-xl);
padding-bottom: 0;
.value {
margin: 0;
}
}
.chart {
// width: calc(100% + 3rem);
margin: 0 -1.5rem 0.25rem -1.5rem;
}
svg {
width: 100%;
height: 100%;
}
:deep(.apexcharts-menu),
:deep(.apexcharts-tooltip),
:deep(.apexcharts-yaxistooltip) {
background: var(--color-raised-bg) !important;
border-radius: var(--radius-sm) !important;
border: 1px solid var(--color-button-bg) !important;
box-shadow: var(--shadow-floating) !important;
font-size: var(--font-size-nm) !important;
}
:deep(.apexcharts-graphical) {
width: 100%;
}
:deep(.apexcharts-tooltip) {
.bar-tooltip {
display: flex;
flex-direction: column;
gap: var(--gap-xs);
padding: var(--gap-sm);
.card-divider {
margin: var(--gap-xs) 0;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
}
.value {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-xs);
color: var(--color-base);
}
.list-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: var(--gap-sm);
}
svg {
height: 1em;
width: 1em;
}
.divider {
font-size: var(--font-size-lg);
font-weight: 400;
}
}
}
.legend {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-md);
justify-content: center;
}
:deep(.apexcharts-grid-borders) {
line {
stroke: var(--color-button-bg) !important;
}
}
:deep(.apexcharts-xaxis) {
line {
stroke: none;
}
}
.legend-checkbox :deep(.checkbox.checked) {
background-color: var(--color);
}
</style>

View File

@@ -0,0 +1,141 @@
import { computed, defineComponent, h, onBeforeUnmount, ref, watch } from 'vue'
import { startLoading, stopLoading, useNuxtApp } from '#imports'
export default defineComponent({
name: 'ModrinthLoadingIndicator',
props: {
throttle: {
type: Number,
default: 50,
},
duration: {
type: Number,
default: 500,
},
height: {
type: Number,
default: 3,
},
color: {
type: [String, Boolean],
default:
'repeating-linear-gradient(to right, var(--color-brand-green) 0%, var(--landing-green-label) 100%)',
},
},
setup(props, { slots }) {
const indicator = useLoadingIndicator({
duration: props.duration,
throttle: props.throttle,
})
const nuxtApp = useNuxtApp()
nuxtApp.hook('page:start', () => {
startLoading()
indicator.start()
})
nuxtApp.hook('page:finish', () => {
stopLoading()
indicator.finish()
})
onBeforeUnmount(() => indicator.clear)
const loading = useLoading()
watch(loading, (newValue) => {
if (newValue) {
indicator.start()
} else {
indicator.finish()
}
})
return () =>
h(
'div',
{
class: 'nuxt-loading-indicator',
style: {
position: 'fixed',
top: 0,
right: 0,
left: 0,
pointerEvents: 'none',
width: `${indicator.progress.value}%`,
height: `${props.height}px`,
opacity: indicator.isLoading.value ? 1 : 0,
background: props.color || undefined,
backgroundSize: `${(100 / indicator.progress.value) * 100}% auto`,
transition: 'width 0.1s, height 0.4s, opacity 0.4s',
zIndex: 999999,
},
},
slots
)
},
})
function useLoadingIndicator(opts: { duration: number; throttle: number }) {
const progress = ref(0)
const isLoading = ref(false)
const step = computed(() => 10000 / opts.duration)
let _timer: any = null
let _throttle: any = null
function start() {
clear()
progress.value = 0
if (opts.throttle && process.client) {
_throttle = setTimeout(() => {
isLoading.value = true
_startTimer()
}, opts.throttle)
} else {
isLoading.value = true
_startTimer()
}
}
function finish() {
progress.value = 100
_hide()
}
function clear() {
clearInterval(_timer)
clearTimeout(_throttle)
_timer = null
_throttle = null
}
function _increase(num: number) {
progress.value = Math.min(100, progress.value + num)
}
function _hide() {
clear()
if (process.client) {
setTimeout(() => {
isLoading.value = false
setTimeout(() => {
progress.value = 0
}, 400)
}, 500)
}
}
function _startTimer() {
if (process.client) {
_timer = setInterval(() => {
_increase(step.value)
}, 100)
}
}
return {
progress,
isLoading,
start,
finish,
clear,
}
}

View File

@@ -0,0 +1,195 @@
<template>
<div class="report">
<div v-if="report.item_type === 'project'" class="item-info">
<nuxt-link
:to="`/${$getProjectTypeForUrl(report.project.project_type, report.project.loaders)}/${
report.project.slug
}`"
class="iconified-stacked-link"
>
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'user'" class="item-info">
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link">
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.user.username }}</span>
<span>User</span>
</div>
</nuxt-link>
</div>
<div v-else-if="report.item_type === 'version'" class="item-info">
<nuxt-link
:to="`/project/${report.project.slug}/version/${report.version.id}`"
class="iconified-link"
>
<div class="backed-svg" :class="{ raised: raised }"><VersionIcon /></div>
<span class="title">{{ report.version.name }}</span>
</nuxt-link>
of
<nuxt-link :to="`/project/${report.project.slug}`" class="iconified-stacked-link">
<Avatar :src="report.project.icon_url" size="xs" no-shadow :raised="raised" />
<div class="stacked">
<span class="title">{{ report.project.title }}</span>
<span>{{
$formatProjectType(
$getProjectTypeForUrl(report.project.project_type, report.project.loaders)
)
}}</span>
</div>
</nuxt-link>
</div>
<div v-else class="item-info">
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
<span>Unknown report type</span>
</div>
<div class="report-type">
<Badge v-if="report.closed" type="closed" />
<Badge :type="`Reported for ${report.report_type}`" color="orange" />
</div>
<div v-if="showMessage" class="markdown-body" v-html="renderHighlightedString(report.body)" />
<ThreadSummary
v-if="thread"
:thread="thread"
class="thread-summary"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth"
/>
<div class="reporter-info">
<ReportIcon class="inline-svg" /> Reported by
<span v-if="auth.user.id === report.reporterUser.id">you</span>
<nuxt-link v-else :to="`/user/${report.reporterUser.username}`" class="iconified-link">
<Avatar
:src="report.reporterUser.avatar_url"
circle
size="xxs"
no-shadow
:raised="raised"
/>
<span>{{ report.reporterUser.username }}</span>
</nuxt-link>
<span>&nbsp;</span>
<span v-tooltip="$dayjs(report.created).format('MMMM D, YYYY [at] h:mm A')">{{
fromNow(report.created)
}}</span>
<CopyCode v-if="flags.developerMode" :text="report.id" class="report-id" />
</div>
</div>
</template>
<script setup>
import { renderHighlightedString } from '~/helpers/highlight.js'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ReportIcon from '~/assets/images/utils/report.svg?component'
import UnknownIcon from '~/assets/images/utils/unknown.svg?component'
import VersionIcon from '~/assets/images/utils/version.svg?component'
import ThreadSummary from '~/components/ui/thread/ThreadSummary.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
defineProps({
report: {
type: Object,
required: true,
},
raised: {
type: Boolean,
default: false,
},
thread: {
type: Object,
default: null,
},
showMessage: {
type: Boolean,
default: true,
},
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const flags = useFeatureFlags()
</script>
<style lang="scss" scoped>
.report {
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
flex-wrap: wrap;
.report-type {
grid-area: type;
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
margin-top: var(--spacing-card-xs);
}
.item-info {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
grid-area: title;
img,
.backed-svg {
margin-right: var(--spacing-card-xs);
}
}
.markdown-body {
grid-area: body;
}
.reporter-info {
grid-area: reporter;
gap: var(--spacing-card-xs);
color: var(--color-text-secondary);
img {
vertical-align: middle;
position: relative;
top: -1px;
margin-right: var(--spacing-card-xs);
}
a {
gap: var(--spacing-card-xs);
}
}
.action {
grid-area: action;
}
.thread-summary {
grid-area: summary;
}
&:not(:last-child) {
margin-bottom: var(--spacing-card-md);
}
.report-id {
margin-left: var(--spacing-card-sm);
}
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<section class="universal-card">
<Breadcrumbs
v-if="breadcrumbsStack"
:current-title="`Report ${reportId}`"
:link-stack="breadcrumbsStack"
/>
<h2>Report details</h2>
<ReportInfo :report="report" :show-thread="false" :show-message="false" :auth="auth" />
</section>
<section class="universal-card">
<h2>Messages</h2>
<ConversationThread
:thread="thread"
:report="report"
:auth="auth"
@update-thread="updateThread"
/>
</section>
</div>
</template>
<script setup>
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import ConversationThread from '~/components/ui/thread/ConversationThread.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
const props = defineProps({
reportId: {
type: String,
required: true,
},
breadcrumbsStack: {
type: Array,
default: null,
},
auth: {
type: Object,
required: true,
},
})
const report = ref(null)
await fetchReport().then((result) => {
report.value = result
})
const { data: rawThread } = await useAsyncData(`thread/${report.value.thread_id}`, () =>
useBaseFetch(`thread/${report.value.thread_id}`)
)
const thread = computed(() => addReportMessage(rawThread.value, report.value))
async function updateThread(newThread) {
rawThread.value = newThread
report.value = await fetchReport()
}
async function fetchReport() {
const { data: rawReport } = await useAsyncData(`report/${props.reportId}`, () =>
useBaseFetch(`report/${props.reportId}`)
)
rawReport.value.item_id = rawReport.value.item_id.replace(/"/g, '')
const userIds = []
userIds.push(rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
userIds.push(rawReport.value.item_id)
}
const versionId = rawReport.value.item_type === 'version' ? rawReport.value.item_id : null
let users = []
if (userIds.length > 0) {
const { data: usersVal } = await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`)
)
users = usersVal.value
}
let version = null
if (versionId) {
const { data: versionVal } = await useAsyncData(`version/${versionId}`, () =>
useBaseFetch(`version/${versionId}`)
)
version = versionVal.value
}
const projectId = version
? version.project_id
: rawReport.value.item_type === 'project'
? rawReport.value.item_id
: null
let project = null
if (projectId) {
const { data: projectVal } = await useAsyncData(`project/${projectId}`, () =>
useBaseFetch(`project/${projectId}`)
)
project = projectVal.value
}
const reportData = rawReport.value
reportData.project = project
reportData.version = version
reportData.reporterUser = users.find((user) => user.id === rawReport.value.reporter)
if (rawReport.value.item_type === 'user') {
reportData.user = users.find((user) => user.id === rawReport.value.item_id)
}
return reportData
}
</script>
<style lang="scss" scoped>
.stacked {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo
v-for="report in reports.filter(
(x) =>
(moderation || x.reporterUser.id === auth.user.id) &&
(viewMode === 'open' ? x.open : !x.open)
)"
:key="report.id"
:report="report"
:thread="report.thread"
:moderation="moderation"
raised
:auth="auth"
class="universal-card recessed"
/>
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import ReportInfo from '~/components/ui/report/ReportInfo.vue'
import { addReportMessage } from '~/helpers/threads.js'
defineProps({
moderation: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const viewMode = ref('open')
const reports = ref([])
let { data: rawReports } = await useAsyncData('report', () => useBaseFetch('report'))
rawReports = rawReports.value.map((report) => {
report.item_id = report.item_id.replace(/"/g, '')
return report
})
const reporterUsers = rawReports.map((report) => report.reporter)
const reportedUsers = rawReports
.filter((report) => report.item_type === 'user')
.map((report) => report.item_id)
const versionReports = rawReports.filter((report) => report.item_type === 'version')
const versionIds = [...new Set(versionReports.map((report) => report.item_id))]
const userIds = [...new Set(reporterUsers.concat(reportedUsers))]
const threadIds = [
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
]
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`)
),
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
useBaseFetch(`versions?ids=${encodeURIComponent(JSON.stringify(versionIds))}`)
),
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
useBaseFetch(`threads?ids=${encodeURIComponent(JSON.stringify(threadIds))}`)
),
])
const reportedProjects = rawReports
.filter((report) => report.item_type === 'project')
.map((report) => report.item_id)
const versionProjects = versions.value.map((version) => version.project_id)
const projectIds = [...new Set(reportedProjects.concat(versionProjects))]
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
useBaseFetch(`projects?ids=${encodeURIComponent(JSON.stringify(projectIds))}`)
)
reports.value = rawReports.map((report) => {
report.reporterUser = users.value.find((user) => user.id === report.reporter)
if (report.item_type === 'user') {
report.user = users.value.find((user) => user.id === report.item_id)
} else if (report.item_type === 'project') {
report.project = projects.value.find((project) => project.id === report.item_id)
} else if (report.item_type === 'version') {
report.version = versions.value.find((version) => version.id === report.item_id)
report.project = projects.value.find((project) => project.id === report.version.project_id)
}
if (report.thread_id) {
report.thread = addReportMessage(
threads.value.find((thread) => report.thread_id === thread.id),
report
)
}
report.open = true
return report
})
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="categories">
<slot />
<span
v-for="category in categoriesFiltered"
:key="category.name"
v-html="category.icon + $formatCategory(category.name)"
/>
</div>
</template>
<script>
export default {
props: {
categories: {
type: Array,
default() {
return []
},
},
type: {
type: String,
required: true,
},
},
setup() {
const tags = useTags()
return { tags }
},
computed: {
categoriesFiltered() {
return this.tags.categories
.concat(this.tags.loaders)
.filter(
(x) =>
this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type)
)
},
},
}
</script>
<style lang="scss" scoped>
.categories {
display: flex;
flex-direction: row;
flex-wrap: wrap;
:deep(span) {
display: flex;
align-items: center;
flex-direction: row;
&:not(:last-child) {
margin-right: var(--spacing-card-md);
}
&:not(.badge) {
color: var(--color-icon);
}
svg {
width: 1rem;
margin-right: 0.2rem;
}
}
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<Checkbox
class="filter"
:model-value="activeFilters.includes(facetName)"
:description="displayName"
@update:model-value="toggle()"
>
<div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span>
</div>
</Checkbox>
</template>
<script>
import Checkbox from '~/components/ui/Checkbox.vue'
export default {
components: {
Checkbox,
},
props: {
facetName: {
type: String,
default: '',
},
displayName: {
type: String,
default: '',
},
icon: {
type: String,
default: '',
},
activeFilters: {
type: Array,
default() {
return []
},
},
},
emits: ['toggle'],
methods: {
toggle() {
this.$emit('toggle', this.facetName)
},
},
}
</script>
<style lang="scss" scoped>
.filter {
margin-bottom: 0.5rem;
:deep(.filter-text) {
display: flex;
align-items: center;
.icon {
height: 1rem;
svg {
margin-right: 0.25rem;
width: 1rem;
height: 1rem;
}
}
}
span {
user-select: none;
}
}
</style>

View File

@@ -0,0 +1,456 @@
<template>
<div>
<Modal
ref="modalSubmit"
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
>
<div class="modal-submit universal-body">
<span>
You're submitting <span class="project-title">{{ project.title }}</span> to be reviewed
again by the moderators.
</span>
<span>
Make sure you have addressed the comments from the moderation team.
<span class="known-errors">
Repeated submissions without addressing the moderators' comments may result in an
account suspension.
</span>
</span>
<Checkbox
v-model="submissionConfirmation"
description="Confirm I have addressed the messages from the moderators"
>
I confirm that I have properly addressed the moderators' comments.
</Checkbox>
<div class="input-group push-right">
<button
class="iconified-button moderation-button"
:disabled="!submissionConfirmation"
@click="resubmit()"
>
<ModerationIcon /> Resubmit for review
</button>
</div>
</div>
</Modal>
<div v-if="flags.developerMode" class="thread-id">
Thread ID: <CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="report && report.closed">
<p>This thread is closed and new messages cannot be sent to it.</p>
<button v-if="isStaff(auth.user)" class="iconified-button" @click="reopenReport()">
<CloseIcon /> Reopen thread
</button>
</template>
<template v-else-if="!report || !report.closed">
<div class="markdown-editor-spacing">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div class="input-group">
<button
v-if="sortedMessages.length > 0"
class="btn btn-primary"
:disabled="!replyBody"
@click="sendReply()"
>
<ReplyIcon /> Reply
</button>
<button v-else class="btn btn-primary" :disabled="!replyBody" @click="sendReply()">
<SendIcon /> Send
</button>
<button
v-if="isStaff(auth.user)"
class="btn"
:disabled="!replyBody"
@click="sendReply(null, true)"
>
<ModerationIcon /> Add private note
</button>
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<button
v-if="replyBody"
class="iconified-button moderation-button"
@click="openResubmitModal(true)"
>
<ModerationIcon /> Resubmit for review with reply
</button>
<button
v-else
class="iconified-button moderation-button"
@click="openResubmitModal(false)"
>
<ModerationIcon /> Resubmit for review
</button>
</template>
</template>
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="iconified-button danger-button"
@click="closeReport(true)"
>
<CloseIcon /> Close with reply
</button>
<button v-else class="iconified-button danger-button" @click="closeReport()">
<CloseIcon /> Close thread
</button>
</template>
</template>
<template v-if="project">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="btn btn-green"
:disabled="isApproved(project)"
@click="sendReply(requestedStatus)"
>
<CheckIcon /> Approve with reply
</button>
<button
v-else
class="btn btn-green"
:disabled="isApproved(project)"
@click="setStatus(requestedStatus)"
>
<CheckIcon /> Approve
</button>
<div class="joined-buttons">
<button
v-if="replyBody"
class="btn btn-danger"
:disabled="project.status === 'rejected'"
@click="sendReply('rejected')"
>
<CrossIcon /> Reject with reply
</button>
<button
v-else
class="btn btn-danger"
:disabled="project.status === 'rejected'"
@click="setStatus('rejected')"
>
<CrossIcon /> Reject
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
position="top"
direction="left"
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () => {
sendReply('withheld')
},
hoverFilled: true,
disabled: project.status === 'withheld',
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () => {
setStatus('withheld')
},
hoverFilled: true,
disabled: project.status === 'withheld',
},
]
"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold-reply> <EyeOffIcon /> Withhold with reply </template>
<template #withhold> <EyeOffIcon /> Withhold </template>
</OverflowMenu>
</div>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { OverflowMenu, MarkdownEditor } from '@modrinth/ui'
import { DropdownIcon } from '@modrinth/assets'
import { useImageUpload } from '~/composables/image-upload.ts'
import CopyCode from '~/components/ui/CopyCode.vue'
import ReplyIcon from '~/assets/images/utils/reply.svg?component'
import SendIcon from '~/assets/images/utils/send.svg?component'
import CloseIcon from '~/assets/images/utils/check-circle.svg?component'
import CrossIcon from '~/assets/images/utils/x.svg?component'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg?component'
import CheckIcon from '~/assets/images/utils/check.svg?component'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?component'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { isStaff } from '~/helpers/users.js'
import { isApproved, isRejected } from '~/helpers/projects.js'
import Modal from '~/components/ui/Modal.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
project: {
type: Object,
required: false,
default: null,
},
setStatus: {
type: Function,
required: false,
default: () => {},
},
currentMember: {
type: Object,
default() {
return null
},
},
auth: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update-thread'])
const app = useNuxtApp()
const flags = useFeatureFlags()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
return members
})
const replyBody = ref('')
const sortedMessages = computed(() => {
if (props.thread !== null) {
return props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
}
return []
})
const modalSubmit = ref(null)
async function updateThreadLocal() {
let threadId = null
if (props.project) {
threadId = props.project.thread_id
} else if (props.report) {
threadId = props.report.thread_id
}
let thread = null
if (threadId) {
thread = await useBaseFetch(`thread/${threadId}`)
}
emit('update-thread', thread)
}
const imageIDs = ref([])
async function onUploadImage(file) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
// Keep the last 10 entries of image IDs
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReply(status = null, privateMessage = false) {
try {
const body = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
}
} catch (err) {
app.$notify({
group: 'main',
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function closeReport(reply) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
await updateThreadLocal()
} catch (err) {
app.$notify({
group: 'main',
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
await updateThreadLocal()
} catch (err) {
app.$notify({
group: 'main',
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const replyWithSubmission = ref(false)
const submissionConfirmation = ref(false)
function openResubmitModal(reply) {
submissionConfirmation.value = false
replyWithSubmission.value = reply
modalSubmit.value.show()
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply('processing')
} else {
await props.setStatus('processing')
}
modalSubmit.value.hide()
}
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
</script>
<style lang="scss" scoped>
.markdown-editor-spacing {
margin-bottom: var(--gap-md);
}
.messages {
display: flex;
flex-direction: column;
padding: var(--spacing-card-md);
}
.resizable-textarea-wrapper {
margin-bottom: var(--spacing-card-sm);
textarea {
padding: var(--spacing-card-bg);
width: 100%;
}
.chips {
margin-bottom: var(--spacing-card-md);
}
.preview {
overflow-y: auto;
}
}
.thread-id {
margin-bottom: var(--spacing-card-md);
font-weight: bold;
color: var(--color-heading);
}
.input-group {
.spacer {
flex-grow: 1;
flex-shrink: 1;
}
.extra-options {
flex-basis: fit-content;
}
}
.modal-submit {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-lg);
.project-title {
font-weight: bold;
}
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<div
class="message"
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
}"
>
<template v-if="members[message.author_id]">
<ConditionalNuxtLink
class="message__icon"
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
tabindex="-1"
aria-hidden="true"
>
<Avatar
class="message__icon"
:src="members[message.author_id].avatar_url"
circle
:raised="raised"
/>
</ConditionalNuxtLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<LockIcon
v-if="message.body.private"
v-tooltip="'Only visible to moderators'"
class="private-icon"
/>
<ConditionalNuxtLink
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
>
{{ members[message.author_id].username }}
</ConditionalNuxtLink>
<ScaleIcon v-if="members[message.author_id].role === 'moderator'" v-tooltip="'Moderator'" />
<ModrinthIcon
v-else-if="members[message.author_id].role === 'admin'"
v-tooltip="'Modrinth Team'"
/>
<MicrophoneIcon
v-if="report && message.author_id === report.reporterUser.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<ScaleIcon />
</div>
<span class="message__author moderation-color">
Moderator
<ScaleIcon v-tooltip="'Moderator'" />
</span>
</template>
<div
v-if="message.body.type === 'text'"
class="message__body markdown-body"
v-html="formattedMessage"
/>
<div v-else class="message__body status-message">
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
<template v-else-if="message.body.type === 'status_change'">
<span v-if="message.body.new_status === 'processing'">
submitted the project for review.
</span>
<span v-else>
changed the project's status from <Badge :type="message.body.old_status" /> to
<Badge :type="message.body.new_status" />.
</span>
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
<span v-else-if="message.body.type === 'thread_reopen'">reopened the thread.</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
{{ timeSincePosted }}
</span>
</span>
<div v-if="isStaff(auth.user) && message.author_id === auth.user.id" class="message__actions">
<OverflowMenu
class="btn btn-transparent icon-only"
:options="[
{
id: 'delete',
action: () => deleteMessage(),
color: 'red',
hoverFilled: true,
},
]"
>
<MoreHorizontalIcon />
<template #delete> <TrashIcon /> Delete </template>
</OverflowMenu>
</div>
</div>
</template>
<script setup>
import {
MoreHorizontalIcon,
TrashIcon,
MicrophoneIcon,
LockIcon,
ModrinthIcon,
ScaleIcon,
} from '@modrinth/assets'
import { OverflowMenu, ConditionalNuxtLink } from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import { isStaff } from '~/helpers/users.js'
const props = defineProps({
message: {
type: Object,
required: true,
},
report: {
type: Object,
default: null,
},
members: {
type: Object,
default: () => {},
},
forceCompact: {
type: Boolean,
default: false,
},
noLinks: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
auth: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update-thread'])
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
if (props.forceCompact) {
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
return 'sent an image.'
} else {
return 'sent a message.'
}
}
return body
})
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
async function deleteMessage() {
await useBaseFetch(`message/${props.message.id}`, {
method: 'DELETE',
})
emit('update-thread')
}
</script>
<style lang="scss" scoped>
.message {
--gap-size: var(--spacing-card-xs);
display: flex;
flex-direction: row;
gap: var(--gap-size);
flex-wrap: wrap;
align-items: center;
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-md);
word-break: break-word;
.avatar,
.backed-svg {
--size: 1.5rem;
}
&.has-body {
--gap-size: var(--spacing-card-sm);
display: grid;
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
column-gap: var(--gap-size);
row-gap: var(--spacing-card-xs);
.message__icon {
margin-bottom: auto;
}
.avatar,
.backed-svg {
--size: 3rem;
}
}
&:not(.no-actions):hover,
&:not(.no-actions):focus-within {
background-color: var(--color-table-alternate-row);
.message__actions {
opacity: 1;
}
}
&.no-actions {
padding: 0;
.message__actions {
display: none;
}
}
}
.message__icon {
grid-area: icon;
}
.message__author {
grid-area: author;
font-weight: bold;
display: flex;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
flex-shrink: 0;
}
.message__date {
grid-area: date;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.message__actions {
grid-area: actions;
margin-left: auto;
@media (hover: hover) {
opacity: 0;
}
}
.message__body {
grid-area: body;
}
.status-message > span {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
}
a {
display: flex;
align-items: center;
text-decoration: none;
}
a:focus-visible + .message__author a,
a:hover + .message__author a,
.message__author a:focus-visible,
.message__author a:hover {
text-decoration: underline;
filter: var(--hover-filter);
}
a:active + .message__author a,
.message__author a:active {
filter: var(--active-filter);
}
.moderation-color,
role-moderator {
color: var(--color-orange);
}
.role-admin {
color: var(--color-brand-green);
}
.reporter-icon {
color: var(--color-purple);
}
.private-icon {
color: var(--color-gray);
}
@media screen and (min-width: 600px) {
.message {
//grid-template:
// 'icon author body'
// 'date date date';
//grid-template-columns: min-content auto 1fr;
&.has-body {
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
}
}
}
@media screen and (min-width: 1024px) {
.message {
//grid-template: 'icon author body date';
//grid-template-columns: min-content auto 1fr auto;
&.has-body {
grid-template:
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
.private {
color: var(--color-icon);
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<nuxt-link :to="link" class="thread-summary" :class="{ raised: raised }">
<div class="thread-title-row">
<span v-if="report" class="thread-title">Report thread</span>
<span v-else class="thread-title">Thread</span>
<span class="thread-messages"
>{{ props.thread.messages.length }} messages <ChevronRightIcon
/></span>
</div>
<template v-if="displayMessages.length > 0">
<ThreadMessage
v-for="message in displayMessages"
:key="message.id"
:message="message"
:report="report"
:members="members"
:auth="auth"
force-compact
no-links
/>
</template>
<span v-else>There are no messages in this thread yet.</span>
</nuxt-link>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?component'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
raised: {
type: Boolean,
default: false,
},
link: {
type: String,
required: true,
},
messages: {
type: Array,
required: false,
default() {
return []
},
},
auth: {
type: Object,
required: true,
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
members[props.auth.user.id] = props.auth.user
return members
})
const displayMessages = computed(() => {
const sortedMessages = props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
}
})
</script>
<style lang="scss" scoped>
.thread-summary {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
padding: var(--spacing-card-bg);
border-radius: var(--size-rounded-card);
border: 1px solid var(--color-divider-dark);
gap: var(--spacing-card-sm);
.thread-title-row {
display: flex;
flex-direction: row;
align-items: center;
.thread-title {
font-weight: bold;
color: var(--color-heading);
}
.thread-messages {
margin-left: auto;
color: var(--color-link);
svg {
vertical-align: top;
}
}
}
.thread-message {
.user {
font-weight: bold;
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
.thread-message,
.thread-message > span {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
}
&.raised {
background-color: var(--color-raised-bg);
}
&:hover .thread-title-row,
&:focus-visible .thread-title-row {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active .thread-title-row {
filter: var(--active-filter);
}
}
</style>