You've already forked AstralRinth
forked from didirus/AstralRinth
Migrate to Turborepo (#1251)
This commit is contained in:
101
apps/frontend/src/components/brand/LogoAnimated.vue
Normal file
101
apps/frontend/src/components/brand/LogoAnimated.vue
Normal 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>
|
||||
97
apps/frontend/src/components/brand/TextLogo.vue
Normal file
97
apps/frontend/src/components/brand/TextLogo.vue
Normal 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>
|
||||
137
apps/frontend/src/components/ui/Avatar.vue
Normal file
137
apps/frontend/src/components/ui/Avatar.vue
Normal 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>
|
||||
126
apps/frontend/src/components/ui/Badge.vue
Normal file
126
apps/frontend/src/components/ui/Badge.vue
Normal 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>
|
||||
54
apps/frontend/src/components/ui/Breadcrumbs.vue
Normal file
54
apps/frontend/src/components/ui/Breadcrumbs.vue
Normal 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>
|
||||
145
apps/frontend/src/components/ui/Checkbox.vue
Normal file
145
apps/frontend/src/components/ui/Checkbox.vue
Normal 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>
|
||||
101
apps/frontend/src/components/ui/Chips.vue
Normal file
101
apps/frontend/src/components/ui/Chips.vue
Normal 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>
|
||||
119
apps/frontend/src/components/ui/CollectionCreateModal.vue
Normal file
119
apps/frontend/src/components/ui/CollectionCreateModal.vue
Normal 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>
|
||||
21
apps/frontend/src/components/ui/ConditionalNuxtLink.vue
Normal file
21
apps/frontend/src/components/ui/ConditionalNuxtLink.vue
Normal 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>
|
||||
74
apps/frontend/src/components/ui/CopyCode.vue
Normal file
74
apps/frontend/src/components/ui/CopyCode.vue
Normal 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>
|
||||
33
apps/frontend/src/components/ui/DoubleIcon.vue
Normal file
33
apps/frontend/src/components/ui/DoubleIcon.vue
Normal 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>
|
||||
92
apps/frontend/src/components/ui/DropArea.vue
Normal file
92
apps/frontend/src/components/ui/DropArea.vue
Normal 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>
|
||||
106
apps/frontend/src/components/ui/EnvironmentIndicator.vue
Normal file
106
apps/frontend/src/components/ui/EnvironmentIndicator.vue
Normal 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>
|
||||
117
apps/frontend/src/components/ui/FileInput.vue
Normal file
117
apps/frontend/src/components/ui/FileInput.vue
Normal 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>
|
||||
58
apps/frontend/src/components/ui/MessageBanner.vue
Normal file
58
apps/frontend/src/components/ui/MessageBanner.vue
Normal 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>
|
||||
151
apps/frontend/src/components/ui/Modal.vue
Normal file
151
apps/frontend/src/components/ui/Modal.vue
Normal 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>
|
||||
127
apps/frontend/src/components/ui/ModalConfirm.vue
Normal file
127
apps/frontend/src/components/ui/ModalConfirm.vue
Normal 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>
|
||||
241
apps/frontend/src/components/ui/ModalCreation.vue
Normal file
241
apps/frontend/src/components/ui/ModalCreation.vue
Normal 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>
|
||||
1048
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
1048
apps/frontend/src/components/ui/ModerationChecklist.vue
Normal file
File diff suppressed because it is too large
Load Diff
160
apps/frontend/src/components/ui/NavRow.vue
Normal file
160
apps/frontend/src/components/ui/NavRow.vue
Normal 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>
|
||||
32
apps/frontend/src/components/ui/NavStack.vue
Normal file
32
apps/frontend/src/components/ui/NavStack.vue
Normal 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>
|
||||
116
apps/frontend/src/components/ui/NavStackItem.vue
Normal file
116
apps/frontend/src/components/ui/NavStackItem.vue
Normal 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>
|
||||
570
apps/frontend/src/components/ui/NotificationItem.vue
Normal file
570
apps/frontend/src/components/ui/NotificationItem.vue
Normal 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"> </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"> </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>
|
||||
107
apps/frontend/src/components/ui/Notifications.vue
Normal file
107
apps/frontend/src/components/ui/Notifications.vue
Normal 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>
|
||||
139
apps/frontend/src/components/ui/OrganizationCreateModal.vue
Normal file
139
apps/frontend/src/components/ui/OrganizationCreateModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
192
apps/frontend/src/components/ui/Pagination.vue
Normal file
192
apps/frontend/src/components/ui/Pagination.vue
Normal 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>
|
||||
527
apps/frontend/src/components/ui/ProjectCard.vue
Normal file
527
apps/frontend/src/components/ui/ProjectCard.vue
Normal 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>
|
||||
504
apps/frontend/src/components/ui/ProjectMemberHeader.vue
Normal file
504
apps/frontend/src/components/ui/ProjectMemberHeader.vue
Normal 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>
|
||||
161
apps/frontend/src/components/ui/VersionFilterControl.vue
Normal file
161
apps/frontend/src/components/ui/VersionFilterControl.vue
Normal 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>
|
||||
490
apps/frontend/src/components/ui/charts/Chart.client.vue
Normal file
490
apps/frontend/src/components/ui/charts/Chart.client.vue
Normal 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>
|
||||
734
apps/frontend/src/components/ui/charts/ChartDisplay.vue
Normal file
734
apps/frontend/src/components/ui/charts/ChartDisplay.vue
Normal 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>
|
||||
280
apps/frontend/src/components/ui/charts/CompactChart.client.vue
Normal file
280
apps/frontend/src/components/ui/charts/CompactChart.client.vue
Normal 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>
|
||||
141
apps/frontend/src/components/ui/modrinth-loading-indicator.ts
Normal file
141
apps/frontend/src/components/ui/modrinth-loading-indicator.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
195
apps/frontend/src/components/ui/report/ReportInfo.vue
Normal file
195
apps/frontend/src/components/ui/report/ReportInfo.vue
Normal 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> </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>
|
||||
119
apps/frontend/src/components/ui/report/ReportView.vue
Normal file
119
apps/frontend/src/components/ui/report/ReportView.vue
Normal 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>
|
||||
97
apps/frontend/src/components/ui/report/ReportsList.vue
Normal file
97
apps/frontend/src/components/ui/report/ReportsList.vue
Normal 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>
|
||||
69
apps/frontend/src/components/ui/search/Categories.vue
Normal file
69
apps/frontend/src/components/ui/search/Categories.vue
Normal 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>
|
||||
77
apps/frontend/src/components/ui/search/SearchFilter.vue
Normal file
77
apps/frontend/src/components/ui/search/SearchFilter.vue
Normal 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>
|
||||
456
apps/frontend/src/components/ui/thread/ConversationThread.vue
Normal file
456
apps/frontend/src/components/ui/thread/ConversationThread.vue
Normal 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>
|
||||
341
apps/frontend/src/components/ui/thread/ThreadMessage.vue
Normal file
341
apps/frontend/src/components/ui/thread/ThreadMessage.vue
Normal 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>
|
||||
147
apps/frontend/src/components/ui/thread/ThreadSummary.vue
Normal file
147
apps/frontend/src/components/ui/thread/ThreadSummary.vue
Normal 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>
|
||||
Reference in New Issue
Block a user