Rewrite Parity (#647)

* Rewrite Parity

* Update SEO, fix modals, add dashes to changelog

* Edit create version title

* Cache tags, SEO for search/partial noscript support, notifications fix

* Deploy?

* Fix vercel config

* Fix it again

* Finish user editing

* Remove broken docker build

* Switch reports to modals

* Update project card

* Navbar line animation in most places

* Add chips

* Move to navlink query params

* remove autogen file

* Add copy code

* Fix webkit text box outlines, port report modal

* Update error page

* Switch to avatar component

* Make keyboard nav work

* Fix team member spacing

* improve project ID display (#676)

* Bug fixes

* Update OG site title

* More fixes

* Design tweaks

* Fix card wrapping on mobile

* Darken light theme color a little

* Sidebar navigation for settings, notifications, and moderation

* Change follow icon from a heart to a bell

* Revert "Change follow icon from a heart to a bell"

This reverts commit e30b46ec5d93c57df847be88eba123c7419dd03b.

* Change follows icon in settings

* AaaaUUUUUUUGghghhhhhhhh

* Project sidebar transparent button animations

* Update file input button styling and change icon remove button text

* Fix environments filter condition being inverted

* Remove -> revert

* Improve readability of warning banners on light mode

* Fix mobile menu button colors

* Clean up notifications page more

* Creator dashboard and monetization work

* Add processing fees declarations and acknowledgement box

* Beta badges

* Downgrade Nuxt Vercel Builder

* Update the style of button groups to be more consistent

* More button consistency

* Remove desktop navbar on mobile

* Update home page progress indicators

* Fix page jumping (Thanks @stairman06)

* Make checkbox checked style consistent with other selection indicators

* More home page updates

* Properly reset NavRows

* Move filters menu on mobile

* Stylized checkbox updated to match active styling

* Filters icon

* Respect prefers-reduced-motion

* Add most backend payouts changes (untested)

* Finish tested payouts code

* Allow monetization unenrolling

* No longer use brand color for active highlights on standard nav elements

* More consistent button group on project page

* Rounded tables

* Fix some things (#716)

* Team member fixes + re-add changelog/versions stuff

* Remove dummy data

* The great CSS refactor

* Remove commented out css

* Give modals the legacy label styles and update profile edit labels

* Fix active chip size

* Remove shadow from selected chip

* Require email set for CMP

* Update styles of notifications to universal-card

* Equivalent exchange, trading some jank for some less bad jank

* Fix all gallery buttons being missing when there is only 1 image

* Update project creation modal

* Make beta badge less bright

* Beta badge heading styling

* Update withdraw processing fees info

* Remove redundant label

* be

* Fix inverted logic

* 2% is 0.02

* Add toggle to turn off alpha modpacks banner

* Why warning button?

* Add more footer links (#719)

* Add more footer links

* Move twitter

* Make items on user pages less comically large and move ad above navigation

* Bump text down a little on home page

* Update favicon colors

* Remove task list package and change default description to use bullet points

* I don't remember why I made this important but let's not

* Ah, yes

* this doesn't actually need to be important

* Align items in input groups

* Adjust some spacings and clear creation modal on opening

* Versions now clickable

* Add link to edit page to default description

* Improve monetization information text

* Make wrapped text inputs not shrink

* Make chips work better

* smol margin on clear mod message button

* Allow non-authenticated users to access settings

* Remove settings anchors

* Fix versions page button style on firefox

* Add advanced rendering toggle

* Update slug input and icon card in project edit page

* Legal sidebar

* h1 at beginning of description no longer has top margin

* Use universal card for legal pages

* Update email addresses on legal pages

* Update various page titles and descriptions for consistency

* Various fixes and consolidation to API URL retrieval

Prevents a bug where it's possible to generate the tags under one API, switch the API, and still have tags leftover from the old API

Also finally fixes staging URL being jank

* Make the theme button show regardless of login state

Also remove the change theme from the user dropdown because it's very redundant with the several other ways of changing theme

* Make mobile profile dropdown ordering consistent with desktop

* Change the base url back

* Revert "Change the base url back"

This reverts commit c1da89fddb83776b39f626eab33c8dc67f8a75e4.

* constantize

* Tiny fixes (#722)

* Box-shadow chip outlines

* Show settings when signed out

* mods -> projects

* space

* Beta badge border

* Slug input overflow fix, scrollable

* 🙈 it will all be okay 🙊 this is just temporary 🙉 😭😭 forgive me

* Fix minor bugs

* fix moderation  page

* More fixes

* Temp fix for download button

* BEGONE TABLES

* Fix download button

Co-authored-by: Ryan Cao <70191398+ryanccn@users.noreply.github.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: stairman06 <36215135+stairman06@users.noreply.github.com>
Co-authored-by: triphora <emmaffle@modrinth.com>
This commit is contained in:
Geometrically 2022-11-12 17:57:40 -07:00 committed by GitHub
parent 66d0ee8156
commit 20785926e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 7572 additions and 7284 deletions

View File

@ -1,56 +0,0 @@
name: docker-build
on:
push:
branches:
- '**'
tags:
- 'v*'
pull_request:
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Docker meta
id: docker_meta
uses: crazy-max/ghaction-docker-meta@v1
with:
images: ghcr.io/modrinth/knossos
-
name: Get branch name
id: vars
run: echo ::set-output name=short_ref::${GITHUB_REF#refs/*/}
-
name: Get short SHA
id: slug
run: echo "::set-output name=sha8::$(echo ${GITHUB_SHA} | cut -c1-8)"
-
name: Set up QEMU
uses: docker/setup-qemu-action@v1
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
VERSION_ID=${{ steps.vars.outputs.short_ref }}-${{ steps.slug.outputs.sha8 }}

14
.github/workflows/regenerate.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: Regenerate tags
on:
schedule:
- cron: '*/30 * * * *'
jobs:
regenerate:
runs-on: ubuntu-latest
steps:
- name: Re-deploy site
run: |
curl -X POST \
${{ secrets.VERCEL_DEPLOY_URL }}

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
generated/
!.gitkeep
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs

View File

@ -9,6 +9,7 @@ assets/images/text-logo-white.svg <br />
static/favicon.ico <br />
components/ui/search/LogoAnimated.vue <br />
assets/images/landing.svg <br />
assets/images/404.svg <br />
## Logo Licenses
All rights reserved. ©2020-2022 Rinth, Inc.

74
assets/images/404.svg Normal file
View File

@ -0,0 +1,74 @@
<svg width="100%" height="100%" viewBox="0 0 3247 1234" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
<g transform="matrix(1,0,0,1,-149.406,-1192.33)">
<g transform="matrix(1,0,0,1,0,-0.0649902)">
<g transform="matrix(1,0,0,1,-190.112,0.0649902)">
<g transform="matrix(1549.76,0,0,1549.76,285.034,2355.46)">
<path d="M0.386,-0L0.526,-0L0.526,-0.129L0.619,-0.129L0.619,-0.247L0.526,-0.247L0.526,-0.705L0.318,-0.705L0.035,-0.252L0.035,-0.129L0.386,-0.129L0.386,-0ZM0.164,-0.243L0.164,-0.247L0.385,-0.598L0.389,-0.598L0.389,-0.243L0.164,-0.243Z" style="fill-rule:nonzero;" />
</g>
</g>
</g>
<g transform="matrix(1,0,0,1,0,-0.0649902)">
<g transform="matrix(1,0,0,1,2151.95,0.0649902)">
<g transform="matrix(1549.76,0,0,1549.76,285.034,2355.46)">
<path d="M0.386,-0L0.526,-0L0.526,-0.129L0.619,-0.129L0.619,-0.247L0.526,-0.247L0.526,-0.705L0.318,-0.705L0.035,-0.252L0.035,-0.129L0.386,-0.129L0.386,-0ZM0.164,-0.243L0.164,-0.247L0.385,-0.598L0.389,-0.598L0.389,-0.243L0.164,-0.243Z" style="fill-rule:nonzero;" />
</g>
</g>
</g>
<g transform="matrix(1,0,0,1,0,-0.0649902)">
<g transform="matrix(1.04827,0,0,1.04827,1374.32,1499.56)">
<path d="M309.082,335.571L283.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.735L309.082,335.571Z" />
</g>
<g transform="matrix(1.6241,0,0,1.6241,3325.29,75.5995)">
<path d="M-799.272,1141.68C-827.119,1200.8 -887.255,1241.76 -956.882,1241.76C-1048.32,1241.76 -1123.39,1171.12 -1130.46,1081.49" style="fill:none;stroke:currentcolor;stroke-width:33.35px;" />
</g>
<g transform="matrix(1.6241,0,0,1.6241,3325.29,75.5995)">
<path d="M-1129.95,1048.4C-1120.36,961.34 -1046.48,893.522 -956.882,893.522C-860.784,893.522 -782.765,971.541 -782.765,1067.64C-782.765,1082.32 -784.586,1096.58 -788.016,1110.21" style="fill:none;stroke:currentcolor;stroke-width:33.35px;" />
</g>
<g transform="matrix(-0.578174,2.15777,-2.15777,-0.578174,3522.29,4490.98)">
<path d="M-799.272,1141.68C-827.119,1200.8 -887.255,1241.76 -956.882,1241.76C-1048.32,1241.76 -1123.39,1171.12 -1130.46,1081.49" style="fill:none;stroke:currentcolor;stroke-width:24.25px;" />
</g>
<g transform="matrix(-0.578174,2.15777,-2.15777,-0.578174,3522.29,4490.98)">
<path d="M-1129.95,1048.4C-1120.36,961.34 -1046.48,893.522 -956.882,893.522C-860.784,893.522 -782.765,971.541 -782.765,1067.64C-782.765,1082.32 -784.586,1096.58 -788.016,1110.21" style="fill:none;stroke:currentcolor;stroke-width:24.25px;" />
</g>
<g transform="matrix(2.06597,1.89638,-1.89638,2.06597,5772.64,1417.93)">
<path d="M-799.272,1141.68C-827.119,1200.8 -887.255,1241.76 -956.882,1241.76C-1048.32,1241.76 -1123.39,1171.12 -1130.46,1081.49" style="fill:none;stroke:currentcolor;stroke-width:19.32px;" />
</g>
<g transform="matrix(2.06597,1.89638,-1.89638,2.06597,5772.64,1417.93)">
<path d="M-1129.95,1048.4C-1120.36,961.34 -1046.48,893.522 -956.882,893.522C-860.784,893.522 -782.765,971.541 -782.765,1067.64C-782.765,1082.32 -784.586,1096.58 -788.016,1110.21" style="fill:none;stroke:currentcolor;stroke-width:19.32px;" />
</g>
<g transform="matrix(-3.26901,0.875928,-0.875928,-3.26901,-420.338,6137.6)">
<path d="M-799.272,1141.68C-827.119,1200.8 -887.255,1241.76 -956.882,1241.76C-1048.32,1241.76 -1123.39,1171.12 -1130.46,1081.49" style="fill:none;stroke:currentcolor;stroke-width:16.01px;" />
</g>
<g transform="matrix(-3.26901,0.875928,-0.875928,-3.26901,-420.338,6137.6)">
<path d="M-1129.95,1048.4C-1120.36,961.34 -1046.48,893.522 -956.882,893.522C-860.784,893.522 -782.765,971.541 -782.765,1067.64C-782.765,1082.32 -784.586,1096.58 -788.016,1110.21" style="fill:none;stroke:currentcolor;stroke-width:16.01px;" />
</g>
<g transform="matrix(1,0,0,1,2728.09,741.915)">
<path d="M-904.543,901.539C-833.986,923.78 -782.765,989.775 -782.765,1067.64C-782.765,1137.58 -824.09,1197.94 -883.633,1225.62" style="fill:none;stroke:currentcolor;stroke-width:54.17px;" />
</g>
<g transform="matrix(1,0,0,1,2728.09,741.915)">
<path d="M-937.34,1240.67C-943.756,1241.39 -950.276,1241.76 -956.882,1241.76C-1052.98,1241.76 -1131,1163.74 -1131,1067.64C-1131,971.541 -1052.98,893.522 -956.882,893.522" style="fill:none;stroke:currentcolor;stroke-width:54.17px;" />
</g>
<g transform="matrix(1,0,0,1,2728.09,741.915)">
<path d="M-904.543,901.539C-833.986,923.78 -782.765,989.775 -782.765,1067.64C-782.765,1137.58 -824.09,1197.94 -883.633,1225.62" style="fill:none;stroke:currentcolor;stroke-width:54.17px;" />
</g>
<g transform="matrix(1,0,0,1,2728.09,741.915)">
<path d="M-937.34,1240.67C-943.756,1241.39 -950.276,1241.76 -956.882,1241.76C-1052.98,1241.76 -1131,1163.74 -1131,1067.64C-1131,971.541 -1052.98,893.522 -956.882,893.522" style="fill:none;stroke:currentcolor;stroke-width:54.17px;" />
</g>
<g transform="matrix(1,0,0,1,2730.59,729.536)">
<path d="M-791.26,1034.59L-686.335,1006.23" style="fill:none;stroke:currentcolor;stroke-width:54.17px;" />
</g>
<g transform="matrix(0.730214,-0.361316,0.535157,0.830095,1995.93,464.536)">
<path d="M-791.26,1034.59L-686.335,1006.23" style="fill:none;stroke:currentcolor;stroke-width:56.77px;" />
</g>
<g transform="matrix(0.588836,0.702962,-0.731171,0.673626,3318.16,1883.42)">
<path d="M-791.26,1034.59L-686.335,1006.23" style="fill:none;stroke:currentcolor;stroke-width:55.35px;" />
</g>
<g transform="matrix(0.260851,-0.884338,0.965379,0.238953,979.167,372.085)">
<path d="M-791.26,1034.59L-686.335,1006.23" style="fill:none;stroke:currentcolor;stroke-width:55.28px;" />
</g>
<g transform="matrix(1.94443,-0.569179,-0.569179,1.34303,4301.69,-217.31)">
<path d="M-1202.21,1224.92L-999.087,1102.51" style="fill:none;stroke:currentcolor;stroke-width:48.54px;" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,7 @@
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
<path d="M2 8c0-2.2.7-4.3 2-6"></path>
<path d="M22 8a10 10 0 0 0-2-6"></path>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@ -0,0 +1,5 @@
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 300 B

View File

@ -0,0 +1,7 @@
<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="M3 3v18h18"></path>
<path d="M18 17V9"></path>
<path d="M13 17V5"></path>
<path d="M8 17v-3"></path>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /></svg>

After

Width:  |  Height:  |  Size: 299 B

View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 306 B

View File

@ -0,0 +1,7 @@
<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">
<rect x="3" y="3" width="7" height="9"></rect>
<rect x="14" y="3" width="7" height="5"></rect>
<rect x="14" y="12" width="7" height="9"></rect>
<rect x="3" y="16" width="7" height="5"></rect>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-filter"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon></svg>

After

Width:  |  Height:  |  Size: 290 B

View File

@ -0,0 +1,7 @@
<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="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z"></path>
<path d="M12 5.36 8.87 8.5a2.13 2.13 0 0 0 0 3h0a2.13 2.13 0 0 0 3 0l2.26-2.21a3 3 0 0 1 4.22 0l2.4 2.4"></path>
<path d="m18 15-2-2"></path>
<path d="m15 18-2-2"></path>
</svg>

After

Width:  |  Height:  |  Size: 517 B

View File

@ -0,0 +1,9 @@
<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">
<rect x="3" y="14" width="7" height="7"></rect>
<rect x="3" y="3" width="7" height="7"></rect>
<line x1="14" y1="4" x2="21" y2="4"></line>
<line x1="14" y1="9" x2="21" y2="9"></line>
<line x1="14" y1="15" x2="21" y2="15"></line>
<line x1="14" y1="20" x2="21" y2="20"></line>
</svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-lock"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>

After

Width:  |  Height:  |  Size: 321 B

View File

@ -0,0 +1,6 @@
<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="M18.37 2.63 14 7l-1.59-1.59a2 2 0 0 0-2.82 0L8 7l9 9 1.59-1.59a2 2 0 0 0 0-2.82L17 10l4.37-4.37a2.12 2.12 0 1 0-3-3Z"></path>
<path d="M9 8c-2 3-4 3.5-7 4l8 10c2-1 6-5 6-7"></path>
<path d="M14.5 17.5 4.5 15"></path>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-slash"><circle cx="12" cy="12" r="10"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1,7 @@
<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">
<polyline points="17 11 21 7 17 3"></polyline>
<line x1="21" y1="7" x2="9" y2="7"></line>
<polyline points="7 21 3 17 7 13"></polyline>
<line x1="15" y1="17" x2="3" y2="17"></line>
</svg>

After

Width:  |  Height:  |  Size: 378 B

View File

@ -0,0 +1,5 @@
<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="M3 7v6h6"></path>
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path>
</svg>

After

Width:  |  Height:  |  Size: 280 B

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ html {
--color-text-inactive: hsl(215, 14%, 34%);
--color-text-dark: #1a202c;
--color-heading: #2c313d;
--color-bg: hsl(220, 13%, 91%);
--color-bg: #e5e7eb;
--color-raised-bg: #ffffff;
--color-divider: hsl(220, 13%, 91%);
--color-divider-dark: #c8cdd3;
@ -19,32 +19,25 @@ html {
--color-text-inverted: var(--color-bg);
--color-bg-inverted: var(--color-text);
--color-brand: #30b27b;
--color-brand-hover: #1e9565;
--color-brand-active: #177955;
--color-brand: #00af5c;
--color-brand-highlight: rgba(0, 175, 92, 0.25);
--color-brand-shadow: rgba(0, 175, 92, 0.7);
--color-brand-inverted: #ffffff;
--tab-underline-hovered: #e2e8f0;
--color-button-bg: #e0e0e5;
--color-button-bg: hsl(220, 13%, 91%);
--color-button-text: var(--color-text-dark);
--color-button-bg-hover: #d9dce0;
--color-button-text-hover: #1b1e24;
--color-button-bg-active: #c3c6cb;
--color-button-text-active: var(--color-button-text-hover);
--color-button-bg-disabled: #cacdd2;
--color-button-text-disabled: #9da3ac;
--color-transparent-button-bg-hover: var(--color-button-bg);
--color-transparent-button-text-hover: var(--color-text-dark);
--color-transparent-button-bg-active: var(--color-button-bg-hover);
--color-transparent-button-text-active: var(--color-text-dark);
--color-toggle-handle: var(--color-icon);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-category-bg: var(--color-bg);
--color-category-text: var(--color-text-dark);
--color-tooltip-bg: var(--color-text);
--color-tooltip-text: var(--color-bg);
@ -56,14 +49,10 @@ html {
--color-ad: #d6e6f9;
--color-ad-raised: #b1c8e4;
--shadow-dropdown: 3px 3px 14px hsla(0, 0%, 0%, 0.15);
--shadow-dropdown-strong: 3px 3px 10px hsla(0, 0%, 0%, 0.3);
--shadow-tooltip: 0.2rem 0.2rem 10px rgba(0, 0, 0, 0.15);
--color-grey-link: var(--color-text);
--color-grey-link-hover: var(--color-heading);
--color-grey-link-active: var(--color-text-dark);
--color-link: #2089ff;
--color-link: #0d60bb;
--color-link-hover: #1a76e7;
--color-link-active: #146fd7;
@ -76,11 +65,11 @@ html {
--color-badge-yellow-text: #755920;
--color-badge-yellow-bg: #f7bb43;
--color-warning-text: hsl(358, 57%, 20%);
--color-warning-bg: hsl(358, 57%, 80%);
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
--color-banner-text: hsl(0, 11%, 16%);
--color-banner-bg: hsl(356, 59%, 77%);
--color-banner-bg: hsl(0, 100%, 95%);
--color-banner-side: hsl(357, 78%, 40%);
--color-block-quote: var(--color-tooltip-bg);
@ -90,12 +79,18 @@ html {
--color-table-border: #dfe2e5;
--color-table-alternate-row: #f6f8fa;
--shadow-card: 0px 2px 4px 0px hsla(221, 39%, 11%, 0.1),
inset 0px -2px 2px 0px hsla(221, 39%, 11%, 0.05);
--shadow-image-md: 0px 2px 4px 0px hsla(221, 39%, 11%, 0.2),
inset 0px -2px 2px 0px hsla(221, 39%, 11%, 0.1);
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--color-card-link-bg: rgba(0, 0, 0, 7%);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
}
.dark-mode {
@ -113,33 +108,26 @@ html {
--color-bg-inverted: var(--color-text);
--color-brand: #1bd96a;
--color-brand-hover: #2de391;
--color-brand-active: #55f5ae;
--color-brand-highlight: rgba(27, 217, 106, 0.25);
--color-brand-shadow: rgba(27, 217, 106, 0.7);
--color-brand-inverted: #000;
--tab-underline-hovered: #414146;
--color-button-bg: #3e434b;
--color-button-bg: hsl(222, 13%, 30%);
--color-button-text: var(--color-text);
--color-button-bg-hover: #494f58;
--color-button-text-hover: #ffffff;
--color-button-bg-active: #616570;
--color-button-text-active: var(--color-button-text-hover);
--color-button-bg-disabled: #3a434d;
--color-button-text-disabled: #555d65;
--color-transparent-button-bg-hover: var(--color-button-bg);
--color-transparent-button-text-hover: var(--color-text-dark);
--color-transparent-button-bg-active: var(--color-button-bg-hover);
--color-transparent-button-text-active: var(--color-text-dark);
--color-toggle-handle: var(--color-button-text);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
--color-category-bg: var(--color-button-bg);
--color-category-text: var(--color-text-dark);
--color-tooltip-bg: var(--color-text);
--color-tooltip-text: var(--color-bg);
--color-tooltip-bg: var(--color-button-bg);
--color-tooltip-text: var(--color-text);
--color-code-bg: var(--color-button-bg);
--color-code-text: var(--color-text-dark);
@ -149,10 +137,6 @@ html {
--color-ad: #1f324a;
--color-ad-raised: #2e4057;
--shadow-dropdown: 3px 3px 14px hsla(0, 0%, 0%, 0.15);
--shadow-dropdown-strong: 3px 3px 20px hsla(0, 0%, 0%, 0.15);
--shadow-tooltip: 0.2rem 0.2rem 10px rgba(0, 0, 0, 0.15);
--color-link: #74b6f3;
--color-link-hover: #92c0f5;
--color-link-active: #b5d5fd;
@ -166,8 +150,8 @@ html {
--color-badge-yellow-text: #dba22d;
--color-badge-yellow-bg: #f7bb43;
--color-warning-text: hsl(358, 57%, 85%);
--color-warning-bg: hsl(358, 57%, 25%);
--color-warning-bg: hsl(355, 70%, 20%);
--color-warning-text: hsl(342, 70%, 75%);
--color-banner-text: hsl(0, 100%, 96%);
--color-banner-bg: hsl(356, 18%, 18%);
@ -180,7 +164,16 @@ html {
--color-table-border: #4f5864;
--color-table-alternate-row: #262a30;
--color-card-link-bg: rgb(255, 255, 255, 15%);
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 1px hsla(221, 39%, 11%, 0.25);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
}
.oled-mode {
@ -193,15 +186,21 @@ html {
--color-button-bg-active: #3c3c40;
}
.midas-mode {
@extend .dark-mode;
--color-brand: #fac413;
--color-brand-shadow: rgba(250, 196, 19, 0.7);
}
body {
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen,
Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-regular);
margin: 0;
padding: 0;
@ -219,12 +218,13 @@ body {
--size-navbar-height: 3.5rem;
--size-mobile-navbar-height: 3.5rem;
--size-mobile-navbar-height-expanded: 10rem;
--size-mobile-navbar-height-expanded: 11.75rem;
--spacing-card-lg: 1.5rem;
--spacing-card-bg: 1rem;
--spacing-card-md: 0.75rem;
--spacing-card-sm: 0.5rem;
--spacing-card-xs: 0.25rem;
// Font Sizes
--font-size-xxs: 0.625rem; //10px
@ -247,14 +247,18 @@ body {
--font-weight-heading: var(--font-weight-extrabold);
--font-weight-title: var(--font-weight-extrabold);
@media screen and (min-width: 501px) {
@media screen and (min-width: 318px) {
--size-mobile-navbar-height-expanded: 9.25rem;
}
@media screen and (min-width: 517px) {
--size-mobile-navbar-height-expanded: 7rem;
}
}
svg {
height: 24px;
width: 24px;
height: 1em;
width: 1em;
}
a {
@ -262,6 +266,10 @@ a {
text-decoration: none;
}
h1 {
color: var(--color-text-dark);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
@ -269,19 +277,17 @@ h2 {
}
h3 {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-dark);
}
button {
cursor: pointer;
@extend .button;
}
input {
border-radius: 2rem;
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
}
pre {
@ -293,17 +299,15 @@ textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
border: 2px solid transparent;
font-weight: var(--font-weight-medium);
border: none;
outline: none;
box-shadow: var(--shadow-inset-sm), 0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 40px;
&:hover:not([disabled]):not(:focus) {
background: var(--color-button-bg-hover);
color: var(--color-button-text-hover);
border-color: var(--color-divider-dark);
}
&:focus {
outline: none !important;
border-color: var(--color-brand);
&:focus, &:focus-visible {
box-shadow: inset 0 0 0 transparent, 0 0 0 .25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
@ -313,26 +317,21 @@ textarea {
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
button {
padding: 0.5rem 0;
//outline: none; Bad for accessibility
&::placeholder {
color: var(--color-button-text);
background-color: var(--color-button-bg);
opacity: 0.6;
}
}
button, input[type=button] {
cursor: pointer;
border: none;
border-radius: var(--size-rounded-control);
&:focus-visible,
&:hover {
background-color: var(--color-button-bg-hover);
color: var(--color-button-text-hover);
}
&:active {
background-color: var(--color-button-bg-active);
color: var(--color-button-text-active);
}
outline: none;
}
kbd {
@ -345,10 +344,13 @@ kbd {
font-size: 0.85em !important;
}
// @import "vue-select/src/scss/vue-select.scss";
@import '~assets/styles/highlightjs.scss';
@import '~assets/styles/layout.scss';
@import '~assets/styles/utils.scss';
@import '~assets/styles/components.scss';
@import '~assets/styles/normalize.scss';
button:focus-visible, a:focus-visible, [tabindex="0"]:focus-visible {
outline: .25rem solid #ea80ff;
border-radius: .25rem;
}

View File

@ -43,6 +43,10 @@
margin: 0 0.75rem;
}
.site-header {
margin: 0 0.75rem;
}
@media (min-width: 1024px) {
.normal-page {
flex-direction: row;
@ -56,10 +60,16 @@
}
.normal-page__sidebar {
min-width: 20rem;
width: 20rem;
}
.normal-page__content {
width: 60rem;
max-width: 60rem;
}
.site-header {
margin: 0 auto;
}
}

View File

@ -165,7 +165,7 @@ textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 2px; /* 2 */
margin: 0; /* 2 */
}
/**

View File

@ -1,5 +1,5 @@
.hidden {
display: none;
display: none !important;
}
.w-100 {

111
components/ui/Avatar.vue Normal file
View File

@ -0,0 +1,111 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
:src="src"
:alt="alt"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
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>
export default {
name: 'Avatar',
props: {
src: {
type: String,
default: null,
},
alt: {
type: String,
default: '',
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xs', 'sm', 'md', 'lg'].includes(value)
},
},
circle: {
type: Boolean,
default: false,
},
},
mounted() {
if (this.$refs.img && this.$refs.img.naturalWidth) {
const isPixelated = () => {
if (
this.$refs.img.naturalWidth < 96 &&
this.$refs.img.naturalWidth > 0
) {
this.$refs.img.style.imageRendering = 'pixelated'
}
}
if (this.$refs.img.naturalWidth) {
isPixelated()
} else {
this.$refs.img.onload = isPixelated
}
}
},
}
</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-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%;
}
}
</style>

View File

@ -6,7 +6,7 @@
<script>
export default {
name: 'VersionBadge',
name: 'Badge',
props: {
type: {
type: String,

View File

@ -1,6 +1,6 @@
<template>
<div
class="checkbox-outer"
class="checkbox-outer button-within"
:class="{ disabled }"
role="presentation"
@click="toggle"
@ -71,58 +71,14 @@ export default {
align-items: center;
cursor: pointer;
&.disabled {
opacity: 0.6;
cursor: not-allowed;
button {
cursor: not-allowed;
&:active,
&:hover,
&:focus {
background-color: var(--color-button-bg);
}
}
}
p {
user-select: none;
padding: 0.2rem 0rem;
margin: 0;
}
&:focus-visible,
&:hover {
color: var(--color-heading);
.checkbox.collapsing svg {
color: var(--color-heading);
}
button {
background-color: var(--color-button-bg-hover);
&.checked {
background-color: var(--color-brand-hover);
}
}
}
&:active {
color: var(--color-text-dark);
.checkbox.collapsing svg {
color: var(--color-text-dark);
}
button {
background-color: var(--color-button-bg-active);
&.checked {
background-color: var(--color-brand-active);
}
}
&.disabled {
cursor: not-allowed;
}
}
@ -132,18 +88,23 @@ export default {
justify-content: center;
cursor: pointer;
width: 1rem;
height: 1rem;
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-text-inverted);
color: var(--color-brand-inverted);
stroke-width: 0.2rem;
height: 0.8rem;
width: 0.8rem;
@ -152,6 +113,7 @@ export default {
&.collapsing {
background-color: transparent !important;
box-shadow: none;
svg {
color: inherit;
@ -166,5 +128,10 @@ export default {
}
}
}
&:disabled {
box-shadow: none;
cursor: not-allowed;
}
}
</style>

95
components/ui/Chips.vue Normal file
View File

@ -0,0 +1,95 @@
<template>
<div class="chips">
<button
v-for="item in items"
:key="item"
class="iconified-button"
:class="{ selected: selected === item }"
@click="toggleItem(item)"
>
<CheckIcon v-if="selected === item" />
<span>{{ formatLabel(item) }}</span>
</button>
</div>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?inline'
export default {
name: 'Chips',
components: {
CheckIcon,
},
props: {
value: {
required: true,
type: String,
},
items: {
required: true,
type: Array,
},
neverEmpty: {
default: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
},
computed: {
selected: {
get() {
return this.value
},
set(value) {
this.$emit('input', 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 {
text-transform: capitalize;
svg {
width: 1em;
height: 1em;
}
&:focus-visible {
outline: 0.25rem solid #ea80ff;
border-radius: 0.25rem;
}
}
.selected {
color: var(--color-button-text-active);
background-color: var(--color-brand-highlight);
box-shadow: inset 0 0 0 transparent, 0 0 0 2px var(--color-brand);
}
}
</style>

View File

@ -1,157 +0,0 @@
<template>
<Popup :show-popup="display">
<div class="popup-delete">
<span class="title">{{ title }}</span>
<span class="description">
{{ description }}
</span>
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
To confirm your action, please type
<span class="confirmation-text">{{ confirmationText }}</span>
to continue
</span>
</label>
<input
v-if="hasToType"
id="confirmation"
v-model="confirmation_typed"
type="text"
placeholder="Type the input needed to continue"
@input="type"
/>
<div class="actions">
<button class="button" @click="cancel">Cancel</button>
<button
class="button warn-button"
:disabled="action_disabled"
@click="proceed"
>
{{ proceedLabel }}
</button>
</div>
</div>
</Popup>
</template>
<script>
import Popup from '~/components/ui/Popup'
export default {
name: 'ConfirmPopup',
components: {
Popup,
},
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',
},
},
data() {
return {
action_disabled: this.hasToType,
confirmation_typed: '',
display: false,
}
},
methods: {
cancel() {
this.display = false
},
proceed() {
this.display = false
this.$emit('proceed')
},
type() {
if (this.hasToType) {
this.action_disabled =
this.confirmation_typed.toLowerCase() !==
this.confirmationText.toLowerCase()
}
},
show() {
this.display = true
},
},
}
</script>
<style scoped lang="scss">
.popup-delete {
padding: 1.5rem;
display: flex;
flex-direction: column;
@media screen and (min-width: 900px) {
}
@media screen and (min-width: 1024px) {
max-width: 40vw;
}
.title {
font-size: 1.25rem;
align-self: stretch;
font-weight: bold;
text-align: center;
margin-bottom: 1.5rem;
}
.description {
word-wrap: break-word;
padding-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
font-weight: bold;
padding-right: 0;
}
.actions {
display: flex;
flex-direction: row;
margin-top: 1.5rem;
button {
flex-grow: 1;
width: 100%;
margin: 0.75rem 1rem;
padding: 0.75rem 0;
}
.warn-button {
transition: background-color 1s, color 1s;
color: var(--color-brand-inverted);
background-color: var(--color-badge-red-bg);
&:disabled {
background-color: var(--color-button-bg);
color: var(--color-button-text-disabled);
}
}
}
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<button
class="code"
:class="{ copied }"
title="Copy code to clipboard"
@click="copyText"
>
{{ text }}
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
</button>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import ClipboardCopyIcon from '~/assets/images/utils/clipboard-copy.svg?inline'
export default {
name: 'CopyCode',
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: 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;
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@ -1,15 +1,17 @@
<template>
<div class="columns">
<label class="button" @drop.prevent="addFile" @dragover.prevent>
<span>
<label
class="iconified-button"
@drop.prevent="handleDrop"
@dragover.prevent
>
<UploadIcon v-if="showIcon" />
{{ prompt }}
</span>
<input
type="file"
:multiple="multiple"
:accept="accept"
@change="onChange"
@change="handleChange"
/>
</label>
</div>
@ -20,7 +22,7 @@ import { fileIsValid } from '~/plugins/fileUtils'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
export default {
name: 'SmartFileInput',
name: 'FileInput',
components: {
UploadIcon,
},
@ -48,6 +50,10 @@ export default {
type: Boolean,
default: true,
},
shouldAlwaysReset: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -55,8 +61,8 @@ export default {
}
},
methods: {
onChange(files, shouldNotReset) {
if (!shouldNotReset) this.files = files.target.files
addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) this.files = files
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) =>
@ -67,19 +73,11 @@ export default {
this.$emit('change', this.files)
}
},
addFile(e) {
const droppedFiles = e.dataTransfer.files
if (!this.multiple) this.files = []
if (!droppedFiles) return
;[...droppedFiles].forEach((f) => {
this.files.push(f)
})
if (!this.multiple && this.files.length > 0) this.files = [this.files[0]]
if (this.files.length > 0) this.onChange(null, true)
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
handleChange(e) {
this.addFiles(e.target.files)
},
},
}
@ -87,26 +85,12 @@ export default {
<style lang="scss" scoped>
label {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--spacing-card-sm) var(--spacing-card-md);
}
span {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
border: 2px dashed var(--color-divider-dark);
border-radius: var(--size-rounded-control);
padding: var(--spacing-card-md) var(--spacing-card-lg);
flex-direction: unset;
margin-bottom: 0;
max-height: unset;
svg {
height: 1.25rem;
height: 1rem;
}
}

121
components/ui/Modal.vue Normal file
View File

@ -0,0 +1,121 @@
<template>
<div>
<div
:class="{
shown: shown,
noblur: !$orElse($store.state.cosmetics.advancedRendering, true),
}"
class="modal-overlay"
@click="hide"
/>
<div class="modal-body" :class="{ shown: shown }">
<div v-if="header" class="header">
<h1>{{ header }}</h1>
<button class="iconified-button icon-only transparent" @click="hide">
<CrossIcon />
</button>
</div>
<div class="content">
<slot></slot>
</div>
</div>
</div>
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg?inline'
export default {
name: 'Modal',
components: {
CrossIcon,
},
props: {
header: {
type: String,
default: null,
},
},
data() {
return {
shown: false,
}
},
methods: {
show() {
this.shown = true
},
hide() {
this.shown = false
},
},
}
</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-body {
position: fixed;
left: 50%;
transform: translate(-50%, -50%);
z-index: 21;
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;
.header {
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
h1 {
font-size: 1.25rem;
}
}
.content {
background-color: var(--color-raised-bg);
}
top: calc(100% + 400px);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
&.shown {
opacity: 1;
visibility: visible;
top: 50%;
}
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<Modal ref="modal" :header="title">
<div class="modal-delete">
<div class="markdown-body" v-html="$xss($md.render(description))"></div>
<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 CrossIcon from '~/assets/images/utils/x.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import Modal from '~/components/ui/Modal'
export default {
name: 'ModalConfirm',
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',
},
},
data() {
return {
action_disabled: this.hasToType,
confirmation_typed: '',
}
},
methods: {
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;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@ -0,0 +1,240 @@
<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="project-type">
<span class="label__title"
>Project type<span class="required">*</span></span
>
</label>
<Chips
id="project-type"
v-model="projectType"
:items="$tag.projectTypes.map((x) => x.display)"
/>
<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/{{
getProjectType() ? getProjectType().id : '???'
}}/
</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 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 CrossIcon from '~/assets/images/utils/x.svg?inline'
import CheckIcon from '~/assets/images/utils/right-arrow.svg?inline'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
export default {
name: 'ModalCreation',
components: {
Chips,
CrossIcon,
CheckIcon,
Modal,
},
props: {
itemType: {
type: String,
default: '',
},
itemId: {
type: String,
default: '',
},
},
data() {
return {
projectType: this.$tag.projectTypes[0].display,
name: '',
slug: '',
description: '',
manualSlug: false,
}
},
methods: {
cancel() {
this.$refs.modal.hide()
},
getProjectType() {
return this.$tag.projectTypes.find((x) => this.projectType === x.display)
},
async createProject() {
this.$nuxt.$loading.start()
const projectType = this.getProjectType()
const formData = new FormData()
formData.append(
'data',
JSON.stringify({
title: this.name,
project_type: projectType.actual,
slug: this.slug,
description: this.description,
body: `# Placeholder description
This is your new ${projectType.display}, ${
this.name
}. A checklist below is provided to help prepare for release.
### Before submitting for review
- Upload at least one version
- [Edit project description](https://modrinth.com/${this.getProjectType().id}/${
this.slug
}/edit)
- Update metadata
- Select license
- Set up environments
- Choose categories
- Add source, wiki, Discord and donation links (optional)
- Add images to gallery (optional)
- Invite project team members (optional)
> Submissions are normally reviewed within 24 hours, but may take up to 48 hours
Questions? [Join the Modrinth Discord for support!](https://discord.gg/EUHuJHt)`,
initial_versions: [],
team_members: [
{
user_id: this.$auth.user.id,
name: this.$auth.user.username,
role: 'Owner',
},
],
categories: [],
client_side: 'unknown',
server_side: 'unknown',
license_id: this.$tag.licenses.map((it) => it.short).includes('arr')
? 'arr'
: this.$tag.licenses[0].short,
is_draft: true,
})
)
console.log(formData)
try {
await this.$axios({
url: 'project',
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
Authorization: this.$auth.token,
},
})
this.$refs.modal.hide()
await this.$router.replace(`/${projectType.display}/${this.slug}`)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
show() {
this.projectType = this.$tag.projectTypes[0].display
this.name = ''
this.slug = ''
this.description = ''
this.manualSlug = false
this.$refs.modal.show()
},
updatedName() {
if (!this.manualSlug) {
this.slug = this.name.toLowerCase().replaceAll(' ', '-')
}
},
},
}
</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>

View File

@ -0,0 +1,179 @@
<template>
<Modal ref="modal" :header="`Report ${itemType}`">
<div class="modal-report legacy-label-styles">
<div class="markdown-body">
<p>
Modding should be safe for everyone, so we take abuse and malicious
intent seriously at Modrinth. We want to hear about harmful content on
the site that violates our
<nuxt-link to="/legal/terms">ToS</nuxt-link> and
<nuxt-link to="/legal/rules">Rules</nuxt-link>. Rest assured, well
keep your identifying information private.
</p>
</div>
<label class="report-label" for="report-type">
<span>
<strong>Reason</strong>
</span>
</label>
<multiselect
id="report-type"
v-model="reportType"
:options="$store.state.tag.reportTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose report type"
/>
<label class="report-label" for="additional-information">
<strong>Additional information</strong>
<span>
Include links and images if possible. Markdown formatting is
supported.
</span>
</label>
<div class="textarea-wrapper">
<Chips
v-model="bodyViewType"
class="separator"
:items="['source', 'preview']"
/>
<div v-if="bodyViewType === 'source'" class="textarea-wrapper">
<textarea id="body" v-model="body" spellcheck="true" />
</div>
<div
v-else
v-highlightjs
class="preview"
v-html="$xss($md.render(body))"
></div>
</div>
<div class="button-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="submitReport">
<CheckIcon />
Report
</button>
</div>
</div>
</Modal>
</template>
<script>
import Multiselect from 'vue-multiselect'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
export default {
name: 'ModalReport',
components: {
Chips,
CrossIcon,
CheckIcon,
Modal,
Multiselect,
},
props: {
itemType: {
type: String,
default: '',
},
itemId: {
type: String,
default: '',
},
},
data() {
return {
reportType: '',
body: '',
bodyViewType: 'source',
}
},
methods: {
cancel() {
this.reportType = ''
this.body = ''
this.bodyViewType = 'source'
this.$refs.modal.hide()
},
async submitReport() {
this.$nuxt.$loading.start()
try {
const data = {
report_type: this.reportType,
item_id: this.itemId,
item_type: this.itemType,
body: this.body,
}
await this.$axios.post('report', data, this.$defaultHeaders())
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
show() {
this.$refs.modal.show()
},
},
}
</script>
<style scoped lang="scss">
.modal-report {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.multiselect {
max-width: 20rem;
margin-bottom: 1rem;
}
.report-label {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
.textarea-wrapper {
margin-top: 1rem;
height: 12rem;
textarea {
// here due to a bug in safari
max-height: 9rem;
}
.preview {
overflow-y: auto;
}
}
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<Modal ref="modal" :header="'Transfer to ' + $formatWallet(wallet)">
<div class="modal-transfer">
<span
>You are initiating a transfer of your revenue from Modrinth's Creator
Monetization Program. How much of your
<strong>${{ balance }}</strong> balance would you like to
transfer?</span
>
<div class="confirmation-input">
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
</div>
<div class="confirm-text">
<Checkbox
v-if="
isValidInput() &&
parseInput() >= minWithdraw &&
parseInput() <= balance
"
v-model="consentedFee"
>
<template v-if="wallet === 'venmo'"
>I acknowledge that $0.25 will be deducted from the amount I receive
to cover {{ $formatWallet(wallet) }} processing fees.</template
>
<template v-else
>I acknowledge that an estimated {{ calcProcessingFees() }} will be
deducted from the amount I receive to cover
{{ $formatWallet(wallet) }} processing fees and that any excess will
be returned to my Modrinth balance.</template
>
</Checkbox>
<Checkbox
v-if="
isValidInput() &&
parseInput() >= minWithdraw &&
parseInput() <= balance
"
v-model="consentedAccount"
>
I confirm that I an initiating a transfer to the following
{{ $formatWallet(wallet) }} account: {{ account }}
</Checkbox>
<span
v-else-if="validInput && parseInput() < minWithdraw"
class="invalid"
>
The amount must be at least ${{ minWithdraw }}</span
>
<span v-else-if="validInput && parseInput() > balance" class="invalid">
The amount must be no more than ${{ balance }}</span
>
<span v-else-if="amount.length > 0" class="invalid">
{{ amount }} is not a valid amount</span
>
</div>
<div class="button-group">
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Monetization settings
</NuxtLink>
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button
class="iconified-button brand-button"
:disabled="!consentedFee || !consentedAccount"
@click="proceed"
>
<TransferIcon />
Transfer
</button>
</div>
</div>
</Modal>
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import Modal from '~/components/ui/Modal'
import Checkbox from '~/components/ui/Checkbox'
export default {
name: 'ModalTransfer',
components: {
Checkbox,
CrossIcon,
SettingsIcon,
TransferIcon,
Modal,
},
props: {
wallet: {
type: String,
required: true,
},
accountType: {
type: String,
required: true,
},
account: {
type: String,
required: true,
},
balance: {
type: Number,
required: true,
},
minWithdraw: {
type: Number,
required: true,
},
},
data() {
return {
consentedFee: false,
consentedAccount: false,
amount: '',
validInput: false,
}
},
methods: {
cancel() {
this.amount = ''
this.consentedFee = false
this.consentedAccount = false
this.validInput = false
this.$refs.modal.hide()
},
async proceed() {
this.$nuxt.$loading.start()
try {
await this.$axios.post(
`user/${this.$auth.user.id}/payouts`,
{
amount: Number(this.amount.replace('$', '')),
},
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
show() {
this.$refs.modal.show()
},
isValidInput() {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
this.validInput = regex.test(this.amount) && this.amount.length > 0
return this.validInput
},
parseInput() {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(this.amount)
return parseFloat(matches[1])
},
calcProcessingFees() {
if (this.wallet === 'venmo') {
return 0.25
} else {
return Math.max(0.25, Math.min(this.parseInput() * 0.02, 20))
}
},
},
}
</script>
<style scoped lang="scss">
.modal-transfer {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
.confirmation-input {
input {
width: 14rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
strong {
color: var(--color-text-dark);
font-weight: 500;
}
.invalid {
color: var(--color-badge-red-bg);
}
.confirm-text {
margin-top: var(--spacing-card-sm);
min-height: 6rem;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
}
</style>

200
components/ui/NavRow.vue Normal file
View File

@ -0,0 +1,200 @@
<template>
<nav class="navigation" :class="{ 'use-animation': useAnimation }">
<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"
:class="{ 'is-active': index === activeIndex }"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="`visibility: ${
useAnimation && activeIndex !== -1 ? 'visible' : 'hidden'
}; left: ${indicator.left}px; right: ${indicator.right}px;
top: ${indicator.top}px; transition: left 350ms ${
indicator.direction === 'left'
? 'cubic-bezier(1,0,.3,1) -140ms'
: 'cubic-bezier(.75,-0.01,.24,.99) -40ms'
},right 350ms ${
indicator.direction === 'right'
? 'cubic-bezier(1,0,.3,1) -140ms'
: 'cubic-bezier(.75,-0.01,.24,.99) -40ms'
}, top 100ms ease-in-out`"
/>
</nav>
</template>
<script>
export default {
name: 'NavRow',
props: {
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
},
data() {
return {
useAnimation: false,
oldIndex: -1,
activeIndex: -1,
indicator: {
left: 0,
right: 0,
top: 22,
direction: 'right',
},
}
},
computed: {
filteredLinks() {
return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
},
},
watch: {
'$route.path': {
handler() {
this.pickLink()
},
},
'$route.query': {
handler() {
if (this.query) this.pickLink()
},
},
},
mounted() {
this.pickLink()
},
methods: {
pickLink() {
if (this.oldIndex === -1) {
this.useAnimation = false
setTimeout(() => {
this.useAnimation = true
}, 300)
}
this.activeIndex = this.query
? this.filteredLinks.findIndex(
(x) =>
(x.href === '' ? undefined : x.href) ===
this.$route.query[this.query]
)
: this.filteredLinks.findIndex(
(x) => x.href === decodeURIComponent(this.$route.path)
)
if (this.activeIndex !== -1) {
this.startAnimation()
} else {
this.oldIndex = -1
}
},
startAnimation() {
if (this.$refs.linkElements[this.activeIndex]) {
this.indicator.direction =
this.activeIndex < this.oldIndex ? 'left' : 'right'
this.indicator.left =
this.$refs.linkElements[this.activeIndex].$el.offsetLeft
this.indicator.right =
this.$refs.linkElements[this.activeIndex].$el.parentElement
.offsetWidth -
this.$refs.linkElements[this.activeIndex].$el.offsetLeft -
this.$refs.linkElements[this.activeIndex].$el.offsetWidth
this.indicator.top =
this.$refs.linkElements[this.activeIndex].$el.offsetTop +
this.$refs.linkElements[this.activeIndex].$el.offsetHeight +
1
}
this.oldIndex = this.activeIndex
},
},
}
</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;
&::after {
content: '';
display: block;
position: absolute;
bottom: -5px;
width: 100%;
border-radius: var(--size-rounded-max);
height: 0.25rem;
transition: opacity 0.1s ease-in-out;
background-color: var(--color-brand);
opacity: 0;
}
&:hover {
color: var(--color-text);
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&.is-active {
color: var(--color-text);
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
transition-property: left, right, top;
transition-duration: 350ms;
visibility: hidden;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<nav class="navigation">
<slot />
</nav>
</template>
<script>
export default {
name: 'NavStack',
}
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: column;
grid-gap: var(--spacing-card-xs);
flex-wrap: wrap;
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<NuxtLink class="nav-link button-base" :to="link">
<div class="nav-content">
<slot />
<span>{{ label }}</span>
<span v-if="beta" class="beta-badge">BETA</span>
</div>
</NuxtLink>
</template>
<script>
export default {
name: 'NavStackItem',
props: {
link: {
required: true,
type: String,
},
label: {
required: true,
type: String,
},
beta: {
default: false,
type: Boolean,
},
},
}
</script>
<style lang="scss" scoped>
.nav-link {
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
display: flex;
flex-direction: row;
gap: 0.25rem;
box-shadow: none;
.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(--color-raised-bg);
}
&.nuxt-link-exact-active {
.nav-content {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
box-shadow: none;
}
}
.beta-badge {
margin: 0;
}
}
</style>

View File

@ -1,18 +1,19 @@
<template>
<div v-if="pages.length > 1" class="columns paginates">
<button
:class="{ disabled: currentPage === 1 }"
<div v-if="count > 1" class="columns paginates">
<a
:class="{ disabled: page === 1 }"
class="left-arrow paginate has-icon"
aria-label="Previous Page"
@click="currentPage !== 1 ? switchPage(currentPage - 1) : null"
:href="linkFunction(page - 1)"
@click.prevent="page !== 1 ? switchPage(page - 1) : null"
>
<LeftArrowIcon />
</button>
</a>
<div
v-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
:class="{
'page-number': currentPage !== item,
'page-number': page !== item,
shrink: item > 99,
}"
class="page-number-container"
@ -20,32 +21,32 @@
<div v-if="item === '-'" class="has-icon">
<GapIcon />
</div>
<button
<a
v-else
:class="{
'page-number current': currentPage === item,
'page-number current': page === item,
shrink: item > 99,
}"
@click="currentPage !== item ? switchPage(item) : null"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</button>
</a>
</div>
<button
<a
:class="{
disabled: currentPage === pages[pages.length - 1],
disabled: page === pages[pages.length - 1],
}"
class="right-arrow paginate has-icon"
aria-label="Next Page"
@click="
currentPage !== pages[pages.length - 1]
? switchPage(currentPage + 1)
: null
:href="linkFunction(page + 1)"
@click.prevent="
page !== pages[pages.length - 1] ? switchPage(page + 1) : null
"
>
<RightArrowIcon />
</button>
</a>
</div>
</template>
@ -62,15 +63,54 @@ export default {
RightArrowIcon,
},
props: {
currentPage: {
page: {
type: Number,
default: 1,
},
pages: {
type: Array,
default() {
return []
count: {
type: Number,
default: 1,
},
linkFunction: {
type: Function,
default() {
return () => '/'
},
},
},
computed: {
pages() {
let pages = []
if (this.count > 4) {
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 > 4) {
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: {
@ -82,16 +122,18 @@ export default {
</script>
<style scoped lang="scss">
button {
box-shadow: var(--shadow-card);
a {
color: var(--color-button-text);
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: 0;
padding: 0.5rem 1rem;
margin: 0;
width: 2rem;
height: 2rem;
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);
@ -100,40 +142,35 @@ button {
&.paginate.disabled {
background-color: transparent;
cursor: default;
color: var(--color-button-text-disabled);
box-shadow: inset 0 0 0 1px var(--color-button-bg-disabled);
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
&:focus-visible,
&:hover {
background-color: var(--color-button-bg-hover);
color: var(--color-button-text-hover);
&:hover:not(&:disabled) {
filter: brightness(0.85);
}
&:active {
background-color: var(--color-button-bg-active);
color: var(--color-button-text-active);
&:active:not(&:disabled) {
transform: scale(0.95);
filter: brightness(0.8);
}
}
.has-icon {
display: flex;
align-items: center;
height: 2em;
svg {
width: 1em;
}
}
.page-number-container,
button,
a,
.has-icon {
display: flex;
justify-content: center;
align-items: center;
height: 2em;
width: 2em;
}
.paginates {
@ -143,16 +180,6 @@ button,
.has-icon {
margin: 0 0.3em;
}
font-size: 80%;
@media screen and (min-width: 350px) {
font-size: 100%;
}
}
.shrink {
font-size: 0.9rem;
height: 2.225em;
width: 2.225em;
}
.left-arrow {
@ -162,4 +189,17 @@ button,
.right-arrow {
margin-right: auto !important;
}
@media screen and (max-width: 400px) {
.paginates {
font-size: 80%;
}
}
@media screen and (max-width: 530px) {
a {
width: 2.5rem;
padding: 0.5rem 0;
}
}
</style>

View File

@ -1,48 +0,0 @@
<template>
<div v-if="showPopup">
<div class="popup-overlay" />
<div class="popup-body">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'Popup',
props: {
showPopup: {
type: Boolean,
default: false,
},
},
}
</script>
<style lang="scss" scoped>
.popup-overlay {
top: 0;
left: 0;
z-index: 10;
position: fixed;
width: 100%;
height: 100%;
background-color: var(--color-button-bg);
border: none;
opacity: 0.6;
overflow-x: hidden;
}
.popup-body {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 11;
box-shadow: 0 2px 3px 1px var(--color-button-bg);
border-radius: var(--size-rounded-lg);
max-height: 80%;
overflow-y: auto;
background-color: var(--color-raised-bg);
}
</style>

View File

@ -3,17 +3,8 @@
<div class="columns">
<div class="icon">
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
<img
:src="iconUrl || 'https://cdn.modrinth.com/placeholder.svg?inline'"
:alt="name"
loading="lazy"
/>
<Avatar :src="iconUrl" :alt="name" size="md" />
</nuxt-link>
<Categories
:categories="categories"
:type="type"
class="left-categories"
/>
</div>
<div class="card-content">
<div class="info">
@ -103,6 +94,8 @@
</div>
</div>
</div>
</div>
</div>
<div class="right-side">
<div v-if="downloads" class="stat">
<DownloadIcon aria-hidden="true" />
@ -122,6 +115,16 @@
>
</p>
</div>
<div class="mobile-dates">
<div class="date">
<CalendarIcon aria-hidden="true" />
Created {{ $dayjs(createdAt).fromNow() }}
</div>
<div class="date">
<EditIcon aria-hidden="true" />
Updated {{ $dayjs(updatedAt).fromNow() }}
</div>
</div>
<div v-if="status" class="status">
<Badge
v-if="status === 'approved'"
@ -144,8 +147,6 @@
<slot />
</div>
</div>
</div>
</div>
</article>
</template>
@ -158,10 +159,12 @@ import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import EditIcon from '~/assets/images/utils/updated.svg?inline'
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import Avatar from '~/components/ui/Avatar'
export default {
name: 'ProjectCard',
components: {
Avatar,
Categories,
Badge,
InfoIcon,
@ -263,6 +266,7 @@ export default {
flex-direction: row;
padding: var(--spacing-card-bg);
width: calc(100% - 2 * var(--spacing-card-bg));
overflow: hidden;
@media screen and (min-width: 1024px) {
flex-direction: row;
@ -270,19 +274,14 @@ export default {
}
.icon {
img {
width: 6rem;
height: 6rem;
margin: 0 var(--spacing-card-md) var(--spacing-card-md) 0;
border-radius: var(--size-rounded-icon);
object-fit: contain;
}
}
.card-content {
display: flex;
justify-content: space-between;
flex-grow: 1;
overflow: hidden;
.info {
display: flex;
@ -301,11 +300,13 @@ export default {
overflow-wrap: anywhere;
color: var(--color-text-dark);
font-size: var(--font-size-xl);
word-wrap: break-word;
}
.author {
margin: auto 0 0 0;
color: var(--color-text);
line-break: anywhere;
}
}
@ -315,8 +316,7 @@ export default {
font-weight: bolder;
font-size: var(--font-size-sm);
margin-top: 0.125rem;
margin-bottom: 0.5rem;
margin: 0.125rem 0;
svg {
width: auto;
@ -352,10 +352,10 @@ export default {
}
}
}
}
.right-side {
min-width: 12rem;
text-align: right;
min-width: fit-content;
.stat {
display: flex;
@ -393,53 +393,84 @@ export default {
margin-right: 0;
margin-left: auto;
margin-bottom: 0.5rem;
}
&:last-child {
margin-bottom: 0;
}
}
}
.left-categories {
.mobile-dates {
display: none;
}
}
@media screen and (max-width: 800px) {
flex-wrap: wrap;
@media screen and (max-width: 560px) {
.card-content {
flex-direction: column;
margin-left: 0.75rem;
.info {
.top {
flex-direction: column;
}
.dates {
.date {
margin-bottom: 0.5rem;
display: none;
}
}
}
.right-side {
padding-top: var(--spacing-card-sm);
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
text-align: left;
.stat {
margin-bottom: 0;
}
.stat svg {
margin-left: 0;
}
.buttons {
flex: 1 1 100%;
}
.buttons button,
a {
margin-left: unset;
margin-right: unset;
}
}
.status {
margin-bottom: 0;
}
.left-categories {
.mobile-dates {
display: flex;
margin: 0 0 0.75rem 0;
width: 7rem;
}
flex-wrap: wrap;
gap: 0.5rem 0.5rem;
color: var(--color-icon);
font-size: var(--font-size-nm);
.right-categories {
display: none;
.date {
display: flex;
align-items: center;
cursor: default;
svg {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
}
}
}
}
}

View File

@ -1,116 +0,0 @@
<template>
<div class="columns">
<label class="button" @drop.prevent="handleDrop" @dragover.prevent>
<span>
<UploadIcon v-if="showIcon" />
{{ prompt }}
</span>
<input
type="file"
:multiple="multiple"
:accept="accept"
@change="handleChange"
/>
</label>
</div>
</template>
<script>
import { fileIsValid } from '~/plugins/fileUtils'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
export default {
name: 'StatelessFileInput',
components: {
UploadIcon,
},
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,
},
},
methods: {
onChange(addedFiles) {
this.$emit('change', addedFiles)
},
/**
* @param {FileList} filesToAdd
*/
addFiles(filesToAdd) {
if (!filesToAdd) return
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
const validFiles = [...filesToAdd].filter((file) =>
fileIsValid(file, validationOptions)
)
if (validFiles.length > 0) {
this.onChange(this.multiple ? validFiles : [validFiles[0]])
}
},
/**
* @param {DragEvent} e
*/
handleDrop(e) {
this.addFiles(e.dataTransfer.files)
},
/**
* @param {Event} e native file input event
*/
handleChange(e) {
this.addFiles(e.target.files)
},
},
}
</script>
<style lang="scss" scoped>
label {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--spacing-card-sm) var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
}
span {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.5rem;
border: 2px dashed var(--color-divider-dark);
border-radius: var(--size-rounded-control);
padding: var(--spacing-card-md) var(--spacing-card-lg);
svg {
height: 1.25rem;
}
}
input {
display: none;
}
</style>

View File

@ -1,57 +0,0 @@
<template>
<div v-if="items.length !== 1" class="styled-tabs">
<button
v-for="item in items"
:key="item"
class="tab"
:class="{ selected: selected === item }"
@click="toggleItem(item)"
>
<span>{{ item }}</span>
</button>
</div>
</template>
<script>
export default {
name: 'ThisOrThat',
props: {
items: {
required: true,
type: Array,
},
},
data() {
return {
selected: '',
}
},
created() {
if (this.items.length > 0) {
this.selected = this.items[0]
this.$emit('input', this.selected)
}
},
methods: {
toggleItem(item) {
this.selected = item
this.$emit('input', item)
},
},
}
</script>
<style scoped>
button {
text-transform: capitalize;
margin: 0;
padding: 0;
background-color: transparent;
border-radius: 0;
color: inherit;
}
button span::first-letter {
text-transform: uppercase;
}
</style>

View File

@ -71,7 +71,6 @@
import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox'
import ClearIcon from '~/assets/images/utils/clear.svg?inline'
export default {
name: 'VersionFilterControl',
components: {
@ -144,16 +143,13 @@ export default {
gap: var(--spacing-card-md);
align-items: center;
flex-wrap: wrap;
.multiselect {
flex: 1;
}
.checkbox-outer {
min-width: fit-content;
}
}
.circle-button {
display: flex;
max-width: 2rem;
@ -161,18 +157,15 @@ export default {
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
box-shadow: inset 0px -1px 1px rgba(17, 24, 39, 0.1);
&:hover,
&:focus-visible {
background-color: var(--color-button-bg-hover);
color: var(--color-button-text-hover);
}
&:active {
background-color: var(--color-button-bg-active);
color: var(--color-button-text-active);
}
svg {
height: 1rem;
width: 1rem;

View File

@ -49,11 +49,11 @@ export default {
align-items: center;
flex-direction: row;
color: var(--color-icon);
margin-right: 1em;
margin-right: var(--spacing-card-md);
svg {
width: 1rem;
margin-right: 0.125rem;
margin-right: 0.2rem;
}
}
}

0
generated/.gitkeep Normal file
View File

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
<template>
<div ref="layout" class="layout">
<div class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
<header class="site-header" role="presentation">
<section class="navbar columns" role="navigation">
<section class="navbar card columns" role="navigation">
<section class="skip column" role="presentation">
<a href="#main">Skip to Main Content</a>
<a
@ -11,31 +11,47 @@
>
</section>
<section class="logo column" role="presentation">
<NuxtLink to="/" aria-label="Modrinth home page">
<NuxtLink class="button-base" to="/" aria-label="Modrinth home page">
<ModrinthLogo aria-hidden="true" class="text-logo" />
</NuxtLink>
</section>
<section class="nav-group columns" role="presentation">
<section class="nav" aria-label="Page links">
<div class="styled-tabs">
<NuxtLink to="/mods" class="tab">
<span>Mods</span>
</NuxtLink>
<NuxtLink to="/plugins" class="tab">
<span>Plugins</span>
</NuxtLink>
<NuxtLink to="/resourcepacks" class="tab">
<span>Resource Packs</span>
</NuxtLink>
<NuxtLink to="/modpacks" class="tab">
<span>Modpacks</span>
</NuxtLink>
</div>
<NavRow
class="navigation"
:links="[
{
label: 'Mods',
href: '/mods',
},
{
label: 'Plugins',
href: '/plugins',
},
{
label: 'Resource Packs',
href: '/resourcepacks',
},
{
label: 'Modpacks',
href: '/modpacks',
},
]"
/>
</section>
<section class="column-grow user-outer" aria-label="Account links">
<section class="user-controls">
<nuxt-link
v-if="$auth.user"
to="/notifications"
class="control-button button-transparent"
:class="{ bubble: $user.notifications.length > 0 }"
title="Notifications"
>
<NotificationIcon aria-hidden="true" />
</nuxt-link>
<button
class="control-button"
class="control-button button-transparent"
title="Switch theme"
@click="changeTheme"
>
@ -45,123 +61,87 @@
/>
<SunIcon v-else aria-hidden="true" />
</button>
<nuxt-link
<div
v-if="$auth.user"
to="/create/project"
class="control-button"
title="Create project"
class="dropdown"
:class="{ closed: !isDropdownOpen }"
tabindex="0"
@mouseover="isDropdownOpen = true"
@focus="isDropdownOpen = true"
@mouseleave="isDropdownOpen = false"
>
<PlusIcon aria-hidden="true" />
</nuxt-link>
<nuxt-link
v-if="$auth.user"
to="/notifications"
class="control-button"
title="Notifications"
>
<NotificationIcon aria-hidden="true" />
<div v-if="$user.notifications.length > 0" class="bubble">
{{ $user.notifications.length }}
</div>
</nuxt-link>
<nuxt-link
v-if="
$auth.user &&
($auth.user.role === 'moderator' ||
$auth.user.role === 'admin')
"
to="/moderation"
class="control-button"
title="Moderation"
>
<ModerationIcon aria-hidden="true" />
<div v-if="moderationNotifications > 0" class="bubble">
{{ moderationNotifications }}
</div>
</nuxt-link>
<div v-if="$auth.user" ref="mobileMenu" class="dropdown">
<button class="control" value="Profile Dropdown">
<img
<Avatar
:src="$auth.user.avatar_url"
class="user-icon"
aria-hidden="true"
alt="Your avatar"
aria-hidden="true"
circle
/>
<DropdownIcon class="caret" />
</button>
<ul class="content card" @click="removeFocus">
<li>
<div class="content card">
<NuxtLink
class="item"
class="item button-transparent"
:to="`/user/${$auth.user.username}`"
@click="removeFocus"
>
<div class="title profile-link">
<div class="username">@{{ $auth.user.username }}</div>
<div class="prompt">Go to my profile</div>
</div>
</NuxtLink>
</li>
<hr class="divider" />
<li>
<NuxtLink class="item" to="/create/project">
<button
class="item button-transparent"
@click="$refs.modal_creation.show()"
>
<PlusIcon class="icon" />
<span class="title">Create a project</span>
</NuxtLink>
</li>
</button>
<hr class="divider" />
<li>
<NuxtLink class="item" to="/notifications">
<NuxtLink class="item button-transparent" to="/notifications">
<NotificationIcon class="icon" />
<span class="title">Notifications</span>
</NuxtLink>
</li>
<li>
<NuxtLink class="item" to="/settings/follows">
<NuxtLink class="item button-transparent" to="/dashboard">
<ChartIcon class="icon" />
<span class="title">Dashboard</span
><span class="beta-badge">BETA</span>
</NuxtLink>
<NuxtLink
class="item button-transparent"
to="/settings/follows"
>
<HeartIcon class="icon" />
<span class="title">Following</span>
</NuxtLink>
</li>
<li>
<NuxtLink class="item" to="/settings">
<NuxtLink class="item button-transparent" to="/settings">
<SettingsIcon class="icon" />
<span class="title">Settings</span>
</NuxtLink>
</li>
<li>
<NuxtLink
v-if="
$auth.user.role === 'moderator' ||
$auth.user.role === 'admin'
"
class="item"
class="item button-transparent"
to="/moderation"
>
<ModerationIcon class="icon" />
<span class="title">Moderation</span>
</NuxtLink>
</li>
<li>
<button class="item" @click="changeTheme">
<MoonIcon
v-if="$colorMode.value === 'light'"
class="icon"
/>
<SunIcon v-else class="icon" />
<span class="dropdown-item__text">Change theme</span>
</button>
</li>
<hr class="divider" />
<li>
<button class="item" @click="logout">
<button class="item button-transparent" @click="logout">
<LogOutIcon class="icon" />
<span class="dropdown-item__text">Log out</span>
</button>
</li>
</ul>
</div>
</div>
<section v-else class="auth-prompt">
<a :href="authUrl" class="log-in-button">
<a
:href="authUrl"
class="log-in-button header-button brand-button"
>
<GitHubIcon aria-hidden="true" />
Sign in with GitHub</a
>
@ -170,39 +150,46 @@
</section>
</section>
</section>
<section ref="mobileNavBar" class="mobile-navbar">
<section class="mobile-navbar" :class="{ expanded: isBrowseMenuOpen }">
<div class="top-row">
<NuxtLink to="/" class="tab" @click.native="closeBrowseMenu()">
<NuxtLink
to="/"
class="tab button-animation"
@click.native="isBrowseMenuOpen = false"
>
<HomeIcon />
</NuxtLink>
<div class="spacer"></div>
<button class="tab browse" @click="toggleBrowseMenu()">
<button
class="tab browse button-animation"
@click="toggleBrowseMenu()"
>
<DropdownIcon :class="{ closed: !isBrowseMenuOpen }" />
<span>Browse</span>
</button>
<div class="spacer"></div>
<button class="tab" @click="toggleMobileMenu()">
<button class="tab button-animation" @click="toggleMobileMenu()">
<HamburgerIcon v-if="!isMobileMenuOpen" />
<CrossIcon v-else />
</button>
</div>
<div
:class="{ 'disable-childern': !isBrowseMenuOpen }"
:class="{ 'disable-children': !isBrowseMenuOpen }"
class="project-types"
>
<NuxtLink
:tabindex="isBrowseMenuOpen ? 0 : -1"
to="/mods"
class="tab"
@click.native="closeBrowseMenu()"
class="tab iconified-button"
@click.native="isBrowseMenuOpen = false"
>
<span>Mods</span>
</NuxtLink>
<NuxtLink
:tabindex="isBrowseMenuOpen ? 0 : -1"
to="/plugins"
class="tab"
@click.native="closeBrowseMenu()"
class="tab iconified-button"
@click.native="isBrowseMenuOpen = false"
>
<span>Plugins</span>
</NuxtLink>
@ -210,72 +197,102 @@
<NuxtLink
:tabindex="isBrowseMenuOpen ? 0 : -1"
to="/resourcepacks"
class="tab"
@click.native="closeBrowseMenu()"
class="tab iconified-button"
@click.native="isBrowseMenuOpen = false"
>
<span>Resource Packs</span>
</NuxtLink>
<NuxtLink
:tabindex="isBrowseMenuOpen ? 0 : -1"
to="/modpacks"
class="tab"
@click.native="closeBrowseMenu()"
class="tab iconified-button"
@click.native="isBrowseMenuOpen = false"
>
<span>Modpacks</span>
</NuxtLink>
</div>
</section>
<section ref="mobileMenu" class="mobile-menu">
<section class="mobile-menu" :class="{ active: isMobileMenuOpen }">
<div class="mobile-menu-wrapper">
<div class="items-container rows">
<NuxtLink
v-if="$auth.user"
class="item user-item"
class="iconified-button raised-button user-item"
:to="`/user/${$auth.user.username}`"
>
<img :src="$auth.user.avatar_url" class="user-icon" />
<img
:src="$auth.user.avatar_url"
class="user-icon"
aria-hidden="true"
alt="User profile icon"
/>
<div class="profile-link">
<div class="username">@{{ $auth.user.username }}</div>
<div class="prompt">Go to my profile</div>
</div>
</NuxtLink>
<button v-if="$auth.user" class="item log-out" @click="logout">
<LogOutIcon class="icon" />
<span class="dropdown-item__text">Log out</span>
</button>
<NuxtLink v-if="$auth.user" class="item" to="/create/project">
<button
v-if="$auth.user"
class="iconified-button raised-button"
@click="$refs.modal_creation.show()"
>
<PlusIcon class="icon" />
<span class="title">Create a project</span>
<span class="dropdown-item__text">Create a project</span>
</button>
<NuxtLink
v-if="$auth.user"
class="iconified-button raised-button"
to="/notifications"
>
<NotificationIcon class="icon" />
<span class="dropdown-item__text">Notifications</span>
</NuxtLink>
<NuxtLink v-if="$auth.user" class="item" to="/settings">
<NuxtLink
v-if="$auth.user"
class="iconified-button raised-button"
to="/dashboard"
>
<ChartIcon class="icon" />
<span class="dropdown-item__text">Dashboard</span>
<span class="beta-badge">BETA</span>
</NuxtLink>
<NuxtLink
v-if="$auth.user"
class="iconified-button raised-button"
to="/settings/follows"
>
<HeartIcon class="icon" />
<span class="dropdown-item__text">Following</span>
</NuxtLink>
<NuxtLink class="iconified-button raised-button" to="/settings">
<SettingsIcon class="icon" />
<span class="title">Settings</span>
<span class="dropdown-item__text">Settings</span>
</NuxtLink>
<NuxtLink
v-if="
$auth.user &&
($auth.user.role === 'moderator' || $auth.user.role === 'admin')
"
class="item"
class="iconified-button raised-button"
to="/moderation"
>
<ModerationIcon class="icon" />
<span class="title">Moderation</span>
<span class="dropdown-item__text">Moderation</span>
</NuxtLink>
<NuxtLink v-if="$auth.user" class="item" to="/settings/follows">
<HeartIcon class="icon" />
<span class="title">Following</span>
</NuxtLink>
<NuxtLink v-if="$auth.user" class="item" to="/notifications">
<NotificationIcon class="icon" />
<span class="title">Notifications</span>
</NuxtLink>
<button class="item" @click="changeTheme">
<button class="iconified-button raised-button" @click="changeTheme">
<MoonIcon v-if="$colorMode.value === 'light'" class="icon" />
<SunIcon v-else class="icon" />
<span class="dropdown-item__text">Change theme</span>
</button>
<a v-if="!$auth.user" :href="authUrl" class="item log-in">
<button
v-if="$auth.user"
class="iconified-button danger-button"
@click="logout"
>
<LogOutIcon class="icon" />
<span class="dropdown-item__text">Log out</span>
</button>
<a v-else :href="authUrl" class="iconified-button brand-button">
<GitHubIcon aria-hidden="true" />
Sign in with GitHub</a
>
@ -284,6 +301,7 @@
</section>
</header>
<main>
<ModalCreation ref="modal_creation" />
<notifications
group="main"
position="bottom right"
@ -332,16 +350,31 @@
<div class="links links-2" role="region" aria-label="Resources">
<h4 aria-hidden="true">Resources</h4>
<a target="_blank" href="https://blog.modrinth.com">Blog</a>
<a target="_blank" href="https://discord.gg/EUHuJHt">Discord</a>
<a target="_blank" href="https://github.com/modrinth/knossos">GitHub</a>
<a target="_blank" href="https://docs.modrinth.com">Docs</a>
<a target="_blank" href="https://status.modrinth.com">Status</a>
<a target="_blank" href="https://github.com/modrinth">GitHub</a>
</div>
<div class="links links-3" role="region" aria-label="Interact">
<h4 aria-hidden="true">Interact</h4>
<a target="_blank" href="https://discord.gg/EUHuJHt">Discord</a>
<a target="_blank" href="https://twitter.com/modrinth">Twitter</a>
<a target="_blank" rel="me" href="https://floss.social/@modrinth">
Mastodon
</a>
<a target="_blank" href="https://crowdin.com/project/modrinth">
Crowdin
</a>
</div>
<div class="buttons">
<button class="iconified-button" @click="changeTheme">
<button class="iconified-button raised-button" @click="changeTheme">
<MoonIcon v-if="$colorMode.value === 'light'" aria-hidden="true" />
<SunIcon v-else aria-hidden="true" />
Change theme
</button>
<nuxt-link class="iconified-button raised-button" to="/settings">
<SettingsIcon aria-hidden="true" />
Settings
</nuxt-link>
</div>
</footer>
</div>
@ -366,13 +399,18 @@ import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import LogOutIcon from '~/assets/images/utils/log-out.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import GitHubIcon from '~/assets/images/utils/github.svg?inline'
const overflowStyle = 'scroll'
import NavRow from '~/components/ui/NavRow'
import ModalCreation from '~/components/ui/ModalCreation'
import Avatar from '~/components/ui/Avatar'
export default {
components: {
Avatar,
ModalCreation,
NavRow,
ModrinthLogo,
MoonIcon,
SunIcon,
@ -387,6 +425,7 @@ export default {
PlusIcon,
DropdownIcon,
HeartIcon,
ChartIcon,
},
directives: {
ClickOutside,
@ -401,27 +440,32 @@ export default {
isMobileMenuOpen: false,
isBrowseMenuOpen: false,
registeredSkipLink: null,
moderationNotifications: 0,
hideDropdown: false,
}
},
async fetch() {
await Promise.all([
this.$store.dispatch('user/fetchAll', { force: true }),
this.$store.dispatch('tag/fetchAllTags'),
this.$store.dispatch('cosmetics/fetchCosmetics', this.$cookies),
])
if (
(this.$auth.user && this.$auth.user.role === 'moderator') ||
(this.$auth.user && this.$auth.user.role === 'admin')
) {
const [projects, reports] = (
await Promise.all([
this.$axios.get(`moderation/projects`, this.$defaultHeaders()),
this.$axios.get(`report`, this.$defaultHeaders()),
])
).map((it) => it.data)
},
head() {
const link = process.env.domain + this.$route.path.replace(/\/+$/, '')
this.moderationNotifications = projects.length + reports.length
return {
link: [
{
rel: 'canonical',
href: link,
},
],
meta: [
{
hid: 'og:url',
name: 'og:url',
content: link,
},
],
}
},
computed: {
@ -431,13 +475,13 @@ export default {
},
watch: {
$route() {
this.$refs.mobileMenu.className = 'mobile-menu'
this.isMobileMenuOpen =
this.$refs.mobileMenu.className === 'mobile-menu active'
document.body.style.overflowY = overflowStyle
this.isMobileMenuOpen = false
document.body.style.overflowY = 'scroll'
this.$store.dispatch('user/fetchAll')
document.body.setAttribute('tabindex', '-1')
document.body.removeAttribute('tabindex')
},
},
beforeCreate() {
@ -453,54 +497,29 @@ export default {
methods: {
toggleMobileMenu() {
window.scrollTo(0, 0)
const currentlyActive =
this.$refs.mobileMenu.className === 'mobile-menu active'
this.$refs.mobileMenu.className = `mobile-menu${
currentlyActive ? '' : ' active'
}`
document.body.scrollTop = 0
document.body.style.overflowY =
document.body.style.overflowY !== 'hidden' ? 'hidden' : overflowStyle
this.isMobileMenuOpen = !currentlyActive
this.isMobileMenuOpen = !this.isMobileMenuOpen
if (this.isMobileMenuOpen) {
this.$refs.mobileNavBar.className = `mobile-navbar`
this.$refs.layout.className = `layout`
document.body.style.overflowY = 'hidden'
this.isBrowseMenuOpen = false
} else {
document.body.style.overflowY = 'scroll'
}
},
toggleBrowseMenu() {
const currentlyActive =
this.$refs.mobileNavBar.className === 'mobile-navbar expanded'
this.$refs.mobileNavBar.className = `mobile-navbar${
currentlyActive ? '' : ' expanded'
}`
this.$refs.layout.className = `layout${
currentlyActive ? '' : ' expanded-mobile-nav'
}`
this.isBrowseMenuOpen = !currentlyActive
this.isBrowseMenuOpen = !this.isBrowseMenuOpen
if (this.isBrowseMenuOpen) {
this.$refs.mobileMenu.className = `mobile-menu`
this.isMobileMenuOpen = false
}
},
closeBrowseMenu() {
this.$refs.mobileNavBar.className = `mobile-navbar`
this.$refs.layout.className = `layout`
this.isBrowseMenuOpen = false
},
async logout() {
this.$cookies.set('auth-token-reset', true)
// If users logs out on dashboard, force redirect on the home page to clear cookies
if (this.$route.path.startsWith('/settings')) {
window.location.href = '/'
if (this.$route.path.startsWith('/settings/')) {
window.location.href = '/settings'
} else {
await this.$router.go(null)
@ -516,9 +535,6 @@ export default {
this.$colorMode.preference =
this.$colorMode.value === 'dark' ? 'light' : 'dark'
},
removeFocus() {
document.activeElement.blur() // This doesn't work, sadly. Help
},
},
}
</script>
@ -575,7 +591,6 @@ export default {
.navbar {
margin: 0 var(--spacing-card-lg);
padding: 0 var(--spacing-card-lg);
max-width: 1280px;
margin-left: auto;
margin-right: auto;
@ -631,12 +646,12 @@ export default {
section.nav {
flex-grow: 5;
.styled-tabs {
.navigation {
display: flex;
width: fit-content;
position: relative;
top: 50%;
transform: translateY(-50%);
margin-top: 3px;
margin-left: 2rem;
a {
@ -664,7 +679,7 @@ export default {
}
.user-outer {
z-index: 20;
z-index: 5;
}
section.user-controls {
@ -675,50 +690,43 @@ export default {
position: relative;
top: 50%;
transform: translateY(-50%);
min-width: 12rem;
min-width: 6rem;
.control-button {
position: relative;
display: flex;
max-width: 2rem;
padding: 0.5rem;
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-max);
margin: 0 0.5rem 0 0;
box-shadow: inset 0px -1px 1px rgba(17, 24, 39, 0.1);
&:hover,
&:focus-visible {
background-color: var(--color-button-bg-hover);
color: var(--color-button-text-hover);
}
&:active {
background-color: var(--color-button-bg-active);
color: var(--color-button-text-active);
}
padding: 0.5rem 0.5rem;
margin: 0 1rem 0 0;
color: var(--color-text);
border-radius: 2rem;
transition: filter 0.1s ease-in-out;
border: 2px solid transparent;
box-sizing: border-box;
svg {
height: 1rem;
width: 1rem;
height: 1.25rem;
width: 1.25rem;
}
.bubble {
position: absolute;
margin-left: 0.5rem;
bottom: 1rem;
border-radius: 0.9rem;
height: 0.8rem;
padding: 0 0.25rem;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.6rem;
&.bubble {
&::after {
background-color: var(--color-brand);
color: var(--color-brand-inverted);
border-radius: var(--size-rounded-max);
content: '';
height: 0.5rem;
position: absolute;
right: 0.25rem;
top: 0.5rem;
width: 0.5rem;
}
}
//&.nuxt-link-exact-active {
// color: var(--color-button-text-active);
// background-color: var(--color-button-bg);
//}
}
.hide-desktop {
display: none;
}
@ -734,14 +742,14 @@ export default {
padding: 0;
.user-icon {
border-radius: 100%;
height: 2rem;
outline: 2px solid var(--color-raised-bg);
width: 2rem;
outline: 2px solid var(--color-raised-bg);
transition: outline-color 0.1s ease-in-out;
}
.caret {
color: inherit;
color: var(--color-button-text);
margin-left: 0.25rem;
width: 1rem;
}
@ -765,6 +773,7 @@ export default {
visibility: hidden;
width: max-content;
z-index: 1;
box-shadow: var(--shadow-floating);
.divider {
background-color: var(--color-divider-dark);
@ -776,7 +785,6 @@ export default {
.item {
align-items: center;
background: none;
border-radius: 0.5rem;
box-sizing: border-box;
color: inherit;
@ -789,11 +797,20 @@ export default {
height: 20px;
width: 20px;
}
}
.item:hover,
.item:focus {
background-color: var(--color-bg);
&.nuxt-link-exact-active {
color: var(--color-button-text-active);
background-color: var(--color-button-bg);
.profile-link {
.username {
margin-block: 0.7rem;
}
.prompt {
display: none;
}
}
}
}
.profile-link {
@ -811,13 +828,13 @@ export default {
}
}
.dropdown:hover .user-icon,
.dropdown:focus .user-icon,
.dropdown:focus-within .user-icon {
outline-color: var(--color-raised-bg-hover);
.dropdown:hover .user-icon {
outline-color: var(--color-brand);
}
.dropdown:hover .content {
.dropdown:hover:not(.closed) .content,
.dropdown:focus:not(.closed) .content,
.dropdown:focus-within:not(.closed) .content {
opacity: 1;
transform: scaleY(1);
visibility: visible;
@ -832,43 +849,12 @@ export default {
.log-in-button {
margin: 0 auto;
display: flex;
align-items: center;
text-align: center;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
white-space: nowrap;
//outline: none; Bad for accessibility
color: var(--color-brand-inverted);
padding: 0.5rem 0.75rem;
svg {
vertical-align: middle;
margin-right: 0.5rem;
}
&:hover,
&:focus {
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-brand-active);
}
}
}
}
@media screen and (max-width: 750px) {
section.nav-group {
display: none;
.hide-desktop {
display: unset;
}
}
}
}
@ -953,7 +939,7 @@ export default {
}
}
.disable-childern {
.disable-children {
a {
pointer-events: none;
}
@ -965,17 +951,18 @@ export default {
justify-content: center;
flex-wrap: wrap;
row-gap: 0.5rem;
margin-inline: var(--spacing-card-sm);
.tab {
flex: 0 0 fit-content;
background-color: var(--color-button-bg);
padding: 0.5rem 1.25rem;
padding: 0.4rem 1.25rem;
margin: 0 0.25rem;
border-radius: var(--size-rounded-max);
max-height: unset;
&.nuxt-link-exact-active {
background-color: var(--color-brand);
color: var(--color-brand-inverted);
background-color: var(--color-brand);
}
}
}
@ -994,6 +981,7 @@ export default {
display: none;
position: absolute;
top: 0;
left: 0;
background-color: var(--color-bg);
height: 100%;
width: 100%;
@ -1008,45 +996,47 @@ export default {
.items-container {
margin: 1rem 2rem;
button {
box-sizing: unset;
}
.item {
padding: 1rem 2rem;
background-color: var(--color-raised-bg);
border-radius: var(--size-rounded-md);
.iconified-button {
box-sizing: border-box;
padding: 0.85rem 1.5rem;
align-items: center;
justify-content: center;
display: flex;
column-gap: 0.25rem;
width: calc(100% - 4rem);
max-width: 18rem;
&.nuxt-link-exact-active {
color: var(--color-button-text-active);
width: 100%;
max-width: 20rem;
max-height: unset;
svg {
color: var(--color-brand);
}
height: 1.25rem;
width: 1.25rem;
}
&.log-in {
&.nuxt-link-exact-active {
color: var(--color-brand-inverted);
background-color: var(--color-brand);
.profile-link {
.prompt {
display: none;
}
}
&.log-out {
color: white;
background-color: var(--color-badge-red-bg);
.beta-badge {
background-color: var(--color-brand-inverted);
box-sizing: border-box;
outline: none;
color: var(--color-text-dark);
}
}
&.user-item {
flex-direction: column;
row-gap: 0.5rem;
//width: 8rem;
max-width: 18rem;
width: fit-content;
max-width: 16rem;
flex-grow: 0;
padding-inline: 3rem;
.profile-link {
text-align: center;
@ -1091,10 +1081,10 @@ export default {
text-align: center;
display: grid;
grid-template:
'logo-info logo-info' auto
'links-1 links-2' auto
'buttons buttons' auto
/ 1fr 1fr;
'logo-info logo-info logo-info' auto
'links-1 links-2 links-3' auto
'buttons buttons buttons' auto
/ 1fr 1fr 1fr;
.logo-info {
margin-left: auto;
@ -1130,6 +1120,10 @@ export default {
&.links-2 {
grid-area: links-2;
}
&.links-3 {
grid-area: links-3;
}
}
.buttons {
@ -1139,16 +1133,9 @@ export default {
button,
a {
background-color: var(--color-raised-bg);
margin-bottom: 0.5rem;
margin-left: auto;
margin-right: auto;
&:hover,
&:focus-visible {
background-color: var(--color-button-bg-hover);
}
}
}

View File

@ -1,25 +1,38 @@
<template>
<div class="main">
<div class="card">
<NuxtLink to="/">
<h2>{{ error.message }}</h2>
<p>
An error occurred! Click this text to go back home, and find your way
back!
</p>
</NuxtLink>
<div class="error">
<Logo404 v-if="error.statusCode === 404" />
<h1 v-else>An error occured!</h1>
<p>{{ error.message }}</p>
<div class="button-group">
<nuxt-link to="/" class="iconified-button raised-button brand-button">
Go home
</nuxt-link>
<a
href="https://discord.gg/EUHuJHt"
class="iconified-button raised-button"
>
Get help on Discord
</a>
</div>
</div>
</div>
</template>
<script>
import Logo404 from '~/assets/images/404.svg?inline'
export default {
components: {
Logo404,
},
layout: 'home',
props: {
error: {
type: Object,
default() {
return {
statusCode: 1000,
message: 'Unknown error',
}
},
@ -30,7 +43,25 @@ export default {
<style lang="scss" scoped>
.main {
margin: var(--spacing-card-sm) auto;
max-width: 800px;
margin: var(--spacing-card-lg) auto;
width: calc(100% - 4rem);
@media screen and (min-width: 800px) {
width: 600px;
}
}
.error {
h1 {
margin-bottom: 0.25rem;
}
svg {
fill: var(--color-text);
color: var(--color-text);
width: 100%;
height: auto;
}
}
</style>

View File

@ -3,7 +3,11 @@ export default function (context) {
return
}
if (context.from.path === context.route.path) {
if (
context.from &&
context.route &&
context.from.path === context.route.path
) {
return
}

View File

@ -1,6 +1,10 @@
import { promises as fs } from 'fs'
import { sortRoutes } from '@nuxt/utils'
import axios from 'axios'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/'
export default {
/*
** Nuxt target
@ -15,7 +19,7 @@ export default {
htmlAttrs: {
lang: 'en',
},
title: 'Modrinth: Download and publish Minecraft Mods',
title: 'Modrinth',
meta: [
{
charset: 'utf-8',
@ -28,14 +32,18 @@ export default {
hid: 'description',
name: 'description',
content:
'Download Minecraft Fabric and Forge mods on Modrinth. Discover and publish projects on Modrinth with a modern, easy to use interface and API.',
'Download Minecraft mods, plugins, resource packs, and modpacks on Modrinth. Discover and publish projects on Modrinth with a modern, easy to use interface and API.',
},
{
hid: 'publisher',
name: 'publisher',
content: 'Rinth, Inc.',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Modrinth',
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
@ -44,14 +52,13 @@ export default {
{
hid: 'theme-color',
name: 'theme-color',
content: '#30b27b',
content: '#1bd96a',
},
{
hid: 'color-scheme',
name: 'color-scheme',
content: 'light dark',
},
{
hid: 'og:site_name',
name: 'og:site_name',
@ -62,11 +69,6 @@ export default {
name: 'og:description',
content: 'An open source modding platform',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Modrinth',
},
{
hid: 'og:type',
name: 'og:type',
@ -75,12 +77,12 @@ export default {
{
hid: 'og:url',
name: 'og:url',
content: 'https://www.modrinth.com',
content: 'https://modrinth.com',
},
{
hid: 'og:image',
name: 'og:image',
content: 'https://cdn.modrinth.com/modrinth-new.png',
content: 'https://cdn.modrinth.com/modrinth-new.png?',
},
{
hid: 'twitter:card',
@ -94,10 +96,23 @@ export default {
},
],
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon-light.ico',
media: '(prefers-color-scheme:no-preference)',
},
{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon.ico',
media: '(prefers-color-scheme:dark)',
},
{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon-light.ico',
media: '(prefers-color-scheme:light)',
},
{
rel: 'stylesheet',
@ -114,7 +129,7 @@ export default {
vue: {
config: {
devtools: false,
devtools: true,
},
},
router: {
@ -194,7 +209,6 @@ export default {
'@nuxtjs/dayjs',
'@nuxtjs/axios',
'@nuxtjs/robots',
'@nuxtjs/sitemap',
'@nuxtjs/style-resources',
'@nuxtjs/markdownit',
'cookie-universal-nuxt',
@ -207,24 +221,12 @@ export default {
robots: {
Sitemap: 'https://modrinth.com/sitemap.xml',
},
sitemap: {
exclude: [
'/settings/**',
'/settings',
'/notifications',
'/moderation',
'/search',
'/search/**',
'/create/**',
],
routes: ['mods', 'modpacks', 'resourcepacks', 'plugins'],
},
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {
baseURL: 'https://staging-api.modrinth.com/v2/',
baseURL: getApiUrl(),
headers: {
common: {
Accept: 'application/json',
@ -260,7 +262,6 @@ export default {
},
},
markdownit: {
runtime: true,
preset: 'default',
html: true,
linkify: true,
@ -276,8 +277,7 @@ export default {
branch: process.env.VERCEL_GIT_COMMIT_REF || 'master',
hash: process.env.VERCEL_GIT_COMMIT_SHA || 'unknown',
domain: getDomain(),
authURLBase:
process.env.BROWSER_BASE_URL || 'https://staging-api.modrinth.com/v2/',
authURLBase: getApiUrl(),
},
publicRuntimeConfig: {
axios: {
@ -287,9 +287,7 @@ export default {
ethicalAds: process.env.ETHICAL_ADS,
},
analytics: {
base_url:
process.env.BROWSER_ARIADNE_URL ||
'https://staging-ariadne.modrinth.com/v1/',
base_url: process.env.BROWSER_ARIADNE_URL || STAGING_ARIADNE_URL,
},
},
privateRuntimeConfig: {
@ -303,15 +301,83 @@ export default {
},
},
hooks: {
build: {
async before(nuxt, buildOptions) {
// 30 minutes
const TTL = 30 * 60 * 1000
let state = {}
try {
state = JSON.parse(
await fs.readFile('./generated/state.json', 'utf8')
)
} catch {
// File doesn't exist, create folder
await fs.mkdir('./generated', { recursive: true })
}
const API_URL = getApiUrl()
if (
// Skip regeneration if within TTL...
state.lastGenerated &&
new Date(state.lastGenerated).getTime() + TTL >
new Date().getTime() &&
// ...but only if the API URL is the same
state.apiUrl &&
state.apiUrl === API_URL
) {
return
}
console.log('Generating tags...')
state.lastGenerated = new Date().toISOString()
state.apiUrl = API_URL
const headers = {
headers: {
'user-agent': `Knossos generator (admin@modrinth.com)`,
},
}
const [
categories,
loaders,
gameVersions,
licenses,
donationPlatforms,
reportTypes,
] = (
await Promise.all([
axios.get(`${API_URL}tag/category`, headers),
axios.get(`${API_URL}tag/loader`, headers),
axios.get(`${API_URL}tag/game_version`, headers),
axios.get(`${API_URL}tag/license`, headers),
axios.get(`${API_URL}tag/donation_platform`, headers),
axios.get(`${API_URL}tag/report_type`, headers),
])
).map((it) => it.data)
state.categories = categories
state.loaders = loaders
state.gameVersions = gameVersions
state.licenses = licenses
state.donationPlatforms = donationPlatforms
state.reportTypes = reportTypes
await fs.writeFile('./generated/state.json', JSON.stringify(state))
console.log('Tags generated!')
},
},
render: {
routeDone(url, result, context) {
setTimeout(() => {
axios
.post(
`${
process.env.ARIADNE_URL ||
'https://staging-ariadne.modrinth.com/v1/'
}view`,
`${process.env.ARIADNE_URL || STAGING_ARIADNE_URL}view`,
{
url: getDomain() + url,
},
@ -340,6 +406,10 @@ export default {
},
}
function getApiUrl() {
return process.env.BROWSER_BASE_URL ?? STAGING_API_URL
}
function getDomain() {
if (process.env.NODE_ENV === 'production') {
if (process.env.SITE_URL) {
@ -348,6 +418,8 @@ function getDomain() {
return `https://${process.env.HEROKU_APP_NAME}.herokuapp.com`
} else if (process.env.VERCEL_URL) {
return `https://${process.env.VERCEL_URL}`
} else if (getApiUrl() === STAGING_API_URL) {
return 'https://staging.modrinth.com'
} else {
return 'https://modrinth.com'
}

167
package-lock.json generated
View File

@ -10,10 +10,8 @@
"dependencies": {
"@nuxtjs/axios": "^5.13.1",
"@nuxtjs/dayjs": "^1.2.0",
"@nuxtjs/google-adsense": "^1.3.0",
"@nuxtjs/markdownit": "^2.0.0",
"@nuxtjs/robots": "^2.4.2",
"@nuxtjs/sitemap": "^2.4.0",
"@nuxtjs/style-resources": "^1.0.0",
"cookie-universal-nuxt": "^2.1.5",
"core-js": "^3.9.1",
@ -2947,11 +2945,6 @@
"integrity": "sha512-t2MZGLf1V2rV4VBZbWIaXKdX/mUcYW0n2znQZoADBkGGxYL8EWqCuCZBmJPJ/Yy9fofJkyuuSuo5GSwo0XdEgw==",
"dev": true
},
"node_modules/@nuxtjs/google-adsense": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/google-adsense/-/google-adsense-1.4.0.tgz",
"integrity": "sha512-C5PfE/IE/VhqhomZWNNbTmVIPjB2o+2iRXZA16QoPMA0n2cotfGqt1CkECTYPyFhN89beMa7ioOic9/+ESLPXg=="
},
"node_modules/@nuxtjs/markdownit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/markdownit/-/markdownit-2.0.0.tgz",
@ -2990,26 +2983,6 @@
"resolved": "https://registry.npmjs.org/@nuxtjs/robots/-/robots-2.5.0.tgz",
"integrity": "sha512-z1F3HXb05NiZga8Cuq6k5bbowfJOScPtbSOakip0nege+1aI9pGoajzap8eR5s1qwLXAk9Ts+NcgetoUn5lwrQ=="
},
"node_modules/@nuxtjs/sitemap": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/sitemap/-/sitemap-2.4.0.tgz",
"integrity": "sha512-TVgIYOtPp7KAfaUo76WRpGbO20j4D/xi/A7shFIGjARHs+FvfAWXNCtBT87dTwe/RoYzAsEKtijFFUTaSu5bUA==",
"dependencies": {
"async-cache": "^1.1.0",
"consola": "^2.13.0",
"etag": "^1.8.1",
"fresh": "^0.5.2",
"fs-extra": "^8.1.0",
"is-https": "^2.0.2",
"lodash.unionby": "^4.8.0",
"minimatch": "^3.0.4",
"sitemap": "^4.1.1"
},
"engines": {
"node": ">=8.9.0",
"npm": ">=5.0.0"
}
},
"node_modules/@nuxtjs/style-resources": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@nuxtjs/style-resources/-/style-resources-1.2.1.tgz",
@ -3120,14 +3093,6 @@
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz",
"integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ=="
},
"node_modules/@types/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@ -3968,15 +3933,6 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
"integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ=="
},
"node_modules/async-cache": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/async-cache/-/async-cache-1.1.0.tgz",
"integrity": "sha512-YDQc4vBn5NFhY6g6HhVshyi3Fy9+SQ5ePnE7JLDJn1DoL+i7ER+vMwtTNOYk9leZkYMnOwpBCWqyLDPw8Aig8g==",
"deprecated": "No longer maintained. Use [lru-cache](http://npm.im/lru-cache) version 7.6 or higher, and provide an asynchronous `fetchMethod` option.",
"dependencies": {
"lru-cache": "^4.0.0"
}
},
"node_modules/async-each": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
@ -8859,11 +8815,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-https": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-https/-/is-https-2.0.2.tgz",
"integrity": "sha512-UfUCKVQH/6PQRCh5Qk9vNu4feLZiFmV/gr8DjbtJD0IrCRIDTA6E+d/AVFGPulI5tqK5W45fYbn1Nir1O99rFw=="
},
"node_modules/is-negative-zero": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
@ -9348,11 +9299,6 @@
"integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
"dev": true
},
"node_modules/lodash.unionby": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/lodash.unionby/-/lodash.unionby-4.8.0.tgz",
"integrity": "sha512-e60kn4GJIunNkw6v9MxRnUuLYI/Tyuanch7ozoCtk/1irJTYBj+qNTxr5B3qVflmJhwStJBv387Cb+9VOfABMg=="
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@ -13216,35 +13162,6 @@
"node": ">= 10"
}
},
"node_modules/sitemap": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-4.1.1.tgz",
"integrity": "sha512-+8yd66IxyIFEMFkFpVoPuoPwBvdiL7Ap/HS5YD7igqO4phkyTPFIprCAE9NMHehAY5ZGN3MkAze4lDrOAX3sVQ==",
"dependencies": {
"@types/node": "^12.0.2",
"@types/sax": "^1.2.0",
"arg": "^4.1.1",
"sax": "^1.2.4",
"xmlbuilder": "^13.0.0"
},
"bin": {
"sitemap": "dist/cli.js"
},
"engines": {
"node": ">=8.9.0",
"npm": ">=5.6.0"
}
},
"node_modules/sitemap/node_modules/@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
},
"node_modules/sitemap/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
},
"node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -16476,14 +16393,6 @@
}
}
},
"node_modules/xmlbuilder": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xss": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz",
@ -18788,11 +18697,6 @@
}
}
},
"@nuxtjs/google-adsense": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/google-adsense/-/google-adsense-1.4.0.tgz",
"integrity": "sha512-C5PfE/IE/VhqhomZWNNbTmVIPjB2o+2iRXZA16QoPMA0n2cotfGqt1CkECTYPyFhN89beMa7ioOic9/+ESLPXg=="
},
"@nuxtjs/markdownit": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/markdownit/-/markdownit-2.0.0.tgz",
@ -18833,22 +18737,6 @@
"resolved": "https://registry.npmjs.org/@nuxtjs/robots/-/robots-2.5.0.tgz",
"integrity": "sha512-z1F3HXb05NiZga8Cuq6k5bbowfJOScPtbSOakip0nege+1aI9pGoajzap8eR5s1qwLXAk9Ts+NcgetoUn5lwrQ=="
},
"@nuxtjs/sitemap": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nuxtjs/sitemap/-/sitemap-2.4.0.tgz",
"integrity": "sha512-TVgIYOtPp7KAfaUo76WRpGbO20j4D/xi/A7shFIGjARHs+FvfAWXNCtBT87dTwe/RoYzAsEKtijFFUTaSu5bUA==",
"requires": {
"async-cache": "^1.1.0",
"consola": "^2.13.0",
"etag": "^1.8.1",
"fresh": "^0.5.2",
"fs-extra": "^8.1.0",
"is-https": "^2.0.2",
"lodash.unionby": "^4.8.0",
"minimatch": "^3.0.4",
"sitemap": "^4.1.1"
}
},
"@nuxtjs/style-resources": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@nuxtjs/style-resources/-/style-resources-1.2.1.tgz",
@ -18954,14 +18842,6 @@
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.5.tgz",
"integrity": "sha512-L28j2FcJfSZOnL1WBjDYp2vUHCeIFlyYI/53EwD/rKUBQ7MtUUfbQWiyKJGpcnv4/WgrhWsFKrcPstcAt/J0tQ=="
},
"@types/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==",
"requires": {
"@types/node": "*"
}
},
"@types/source-list-map": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
@ -19632,14 +19512,6 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz",
"integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ=="
},
"async-cache": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/async-cache/-/async-cache-1.1.0.tgz",
"integrity": "sha512-YDQc4vBn5NFhY6g6HhVshyi3Fy9+SQ5ePnE7JLDJn1DoL+i7ER+vMwtTNOYk9leZkYMnOwpBCWqyLDPw8Aig8g==",
"requires": {
"lru-cache": "^4.0.0"
}
},
"async-each": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.3.tgz",
@ -23332,11 +23204,6 @@
"is-extglob": "^2.1.1"
}
},
"is-https": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-https/-/is-https-2.0.2.tgz",
"integrity": "sha512-UfUCKVQH/6PQRCh5Qk9vNu4feLZiFmV/gr8DjbtJD0IrCRIDTA6E+d/AVFGPulI5tqK5W45fYbn1Nir1O99rFw=="
},
"is-negative-zero": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
@ -23702,11 +23569,6 @@
"integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
"dev": true
},
"lodash.unionby": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/lodash.unionby/-/lodash.unionby-4.8.0.tgz",
"integrity": "sha512-e60kn4GJIunNkw6v9MxRnUuLYI/Tyuanch7ozoCtk/1irJTYBj+qNTxr5B3qVflmJhwStJBv387Cb+9VOfABMg=="
},
"lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@ -26762,30 +26624,6 @@
"totalist": "^1.0.0"
}
},
"sitemap": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-4.1.1.tgz",
"integrity": "sha512-+8yd66IxyIFEMFkFpVoPuoPwBvdiL7Ap/HS5YD7igqO4phkyTPFIprCAE9NMHehAY5ZGN3MkAze4lDrOAX3sVQ==",
"requires": {
"@types/node": "^12.0.2",
"@types/sax": "^1.2.0",
"arg": "^4.1.1",
"sax": "^1.2.4",
"xmlbuilder": "^13.0.0"
},
"dependencies": {
"@types/node": {
"version": "12.20.55",
"resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz",
"integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="
},
"arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
}
}
},
"slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -29290,11 +29128,6 @@
"integrity": "sha512-ri1Id1WinAX5Jqn9HejiGb8crfRio0Qgu8+MtL36rlTA6RLsMdWt1Az/19A2Qij6uSHUMphEFaTKa4WG+UNHNw==",
"requires": {}
},
"xmlbuilder": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ=="
},
"xss": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.13.tgz",

View File

@ -15,10 +15,8 @@
"dependencies": {
"@nuxtjs/axios": "^5.13.1",
"@nuxtjs/dayjs": "^1.2.0",
"@nuxtjs/google-adsense": "^1.3.0",
"@nuxtjs/markdownit": "^2.0.0",
"@nuxtjs/robots": "^2.4.2",
"@nuxtjs/sitemap": "^2.4.0",
"@nuxtjs/style-resources": "^1.0.0",
"cookie-universal-nuxt": "^2.1.5",
"core-js": "^3.9.1",

View File

@ -1,11 +1,17 @@
<template>
<div class="page-container">
<div>
<ModalReport
ref="modal_project_report"
:item-id="project.id"
item-type="project"
/>
<div
:class="{
'page-contents': true,
'alt-layout': $store.state.cosmetics.projectLayout,
'normal-page': true,
'alt-layout': $store.state.cosmetics.searchLayout,
}"
>
<aside class="normal-page__sidebar">
<div class="header card">
<nuxt-link
:to="
@ -15,15 +21,8 @@
(project.slug ? project.slug : project.id)
"
>
<img
class="icon"
:src="
project.icon_url
? project.icon_url
: 'https://cdn.modrinth.com/placeholder.svg?inline'
"
alt="project - icon"
/></nuxt-link>
<Avatar :src="project.icon_url" :alt="project.title" size="md" />
</nuxt-link>
<nuxt-link
:to="
'/' +
@ -104,7 +103,9 @@
>
<CalendarIcon aria-hidden="true" />
<span class="label">Created</span>
<span class="value">{{ $dayjs(project.published).fromNow() }}</span>
<span class="value">{{
$dayjs(project.published).fromNow()
}}</span>
</div>
<div
v-tooltip="
@ -118,16 +119,17 @@
</div>
</div>
<hr class="card-divider" />
<div class="buttons">
<nuxt-link
:to="`/create/report?id=${project.id}&t=project`"
<div class="input-group">
<template v-if="$auth.user">
<button
class="iconified-button"
@click="$refs.modal_project_report.show()"
>
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
</button>
<button
v-if="$auth.user && !$user.follows.find((x) => x.id === project.id)"
v-if="!$user.follows.find((x) => x.id === project.id)"
class="iconified-button"
@click="$store.dispatch('user/followProject', project)"
>
@ -135,21 +137,29 @@
Follow
</button>
<button
v-if="$auth.user && $user.follows.find((x) => x.id === project.id)"
v-if="$user.follows.find((x) => x.id === project.id)"
class="iconified-button"
@click="$store.dispatch('user/unfollowProject', project)"
>
<FollowIcon fill="currentColor" aria-hidden="true" />
Unfollow
</button>
</template>
<template v-else>
<a class="iconified-button" :href="authUrl">
<ReportIcon aria-hidden="true" />
Report
</a>
<a class="iconified-button" :href="authUrl">
<FollowIcon fill="currentColor" aria-hidden="true" />
Follow
</a>
</template>
</div>
</div>
<div
v-if="
(currentMember ||
($auth.user &&
($auth.user.role === 'moderator' ||
$auth.user.role === 'admin'))) &&
currentMember &&
(project.status !== 'approved' ||
(project.moderator_message &&
(project.moderator_message.message ||
@ -182,15 +192,15 @@
</p>
<div class="message">
<p v-if="project.status === 'processing'">
Your project is currently not viewable by people who are not part of
your team. Please wait for our moderators to manually review your
project to see if it abides by our
Your project is currently not viewable by people who are not part
of your team. Please wait for our moderators to manually review
your project to see if it abides by our
<nuxt-link to="/legal/rules">content rules!</nuxt-link>
</p>
<p v-if="project.status === 'draft'">
Your project is currently not viewable by people who are not part of
your team. If your project is ready for review, click the button
below to make your mod public!
Your project is currently not viewable by people who are not part
of your team. If your project is ready for review, click the
button below to make your mod public!
</p>
<p v-if="project.moderator_message">
{{ project.moderator_message.message }}
@ -209,7 +219,7 @@
project.status === 'unlisted' ||
project.status === 'abandoned'
"
class="iconified-button brand-button-colors"
class="iconified-button brand-button"
@click="submitForReview"
>
<CheckIcon />
@ -217,7 +227,7 @@
</button>
<button
v-if="project.status === 'draft'"
class="iconified-button brand-button-colors"
class="iconified-button brand-button"
@click="submitForReview"
>
<CheckIcon />
@ -225,7 +235,7 @@
</button>
<button
v-if="project.status === 'approved'"
class="iconified-button"
class="clear-mod-message iconified-button"
@click="clearMessage"
>
<ClearIcon />
@ -238,7 +248,8 @@
Your project must have a body to submit for review.
</li>
<li v-if="project.versions.length < 1">
Your project must have at least one version to submit for review.
Your project must have at least one version to submit for
review.
</li>
</ul>
</div>
@ -247,7 +258,7 @@
message!
</p>
</div>
<div class="extra-info card">
<div class="extra-info-desktop card">
<template
v-if="
project.issues_url ||
@ -326,7 +337,9 @@
<span v-else-if="donation.id === 'patreon'">Patreon</span>
<span v-else-if="donation.id === 'paypal'">PayPal</span>
<span v-else-if="donation.id === 'ko-fi'">Ko-fi</span>
<span v-else-if="donation.id === 'github'">GitHub Sponsors</span>
<span v-else-if="donation.id === 'github'"
>GitHub Sponsors</span
>
<span v-else>Donate</span>
</a>
</div>
@ -340,7 +353,7 @@
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/versions`"
class="all-link"
class="goto-link"
>
See all
<ChevronRightIcon
@ -352,7 +365,14 @@
<div
v-for="version in featuredVersions"
:key="version.id"
class="featured-version"
class="featured-version button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
)
"
>
<a
v-tooltip="
@ -362,8 +382,9 @@
')'
"
:href="findPrimary(version).url"
class="download"
class="download download-button"
:title="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
@ -372,7 +393,7 @@
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
class="top title-link"
class="top"
>
{{ version.name }}
</nuxt-link>
@ -380,7 +401,9 @@
v-if="version.game_versions.length > 0"
class="game-version item"
>
{{ version.loaders.map((x) => $formatCategory(x)).join(', ') }}
{{
version.loaders.map((x) => $formatCategory(x)).join(', ')
}}
{{ $formatVersion(version.game_versions) }}
</div>
<VersionBadge
@ -406,14 +429,19 @@
<div
v-for="member in members"
:key="member.user.id"
class="team-member columns"
class="team-member columns button-transparent"
@click="$router.push('/user/' + member.user.username)"
>
<nuxt-link :to="'/user/' + member.user.username" class="name">
<img :src="member.avatar_url" alt="profile-picture" />
</nuxt-link>
<Avatar
:src="member.avatar_url"
:alt="member.username"
size="sm"
circle
/>
<div class="member-info">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p class="title-link">{{ member.name }}</p>
<p>{{ member.name }}</p>
</nuxt-link>
<p class="role">{{ member.role }}</p>
</div>
@ -456,13 +484,13 @@
<div class="info">
<div class="key">Project ID</div>
<div class="value lowercase">
{{ project.id }}
<CopyCode :text="project.id" />
</div>
</div>
</div>
</div>
<div class="content">
<div class="project-main">
</aside>
<section class="normal-page__content">
<div
v-if="project.status === 'unlisted'"
class="card warning"
@ -472,8 +500,8 @@
author has marked it as such or because it has been found to be in
violation of one of
<nuxt-link to="/legal/rules">Modrinth's content rules</nuxt-link>.
Modrinth makes no guarantees as to whether {{ project.title }} is
safe for use in a multiplayer context.
Modrinth makes no guarantees as to whether {{ project.title }} is safe
for use in a multiplayer context.
</div>
<div
v-if="project.status === 'archived'"
@ -505,69 +533,57 @@
>our documentation</a
>
which provides instructions on using
<a href="https://atlauncher.com/about" target="_blank">ATLauncher</a
>, <a href="https://multimc.org/" target="_blank">MultiMC</a>, and
<a href="https://prismlauncher.org" target="_blank"
>Prism Launcher</a
<a href="https://atlauncher.com/about" target="_blank">ATLauncher</a>,
<a href="https://multimc.org/" target="_blank">MultiMC</a>, and
<a href="https://prismlauncher.org" target="_blank">Prism Launcher</a
>.
</div>
<Advertisement
v-if="
project.status === 'approved' || project.status === 'unlisted'
"
v-if="project.status === 'approved' || project.status === 'unlisted'"
type="banner"
small-screen="square"
ethical-ads-small
ethical-ads-big
/>
<div class="card styled-tabs">
<nuxt-link
:to="`/${project.project_type}/${
<NavRow
:links="[
{
label: 'Description',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}`"
class="tab"
exact
>
<span>Description</span>
</nuxt-link>
<nuxt-link
v-if="project.gallery.length > 0 || currentMember"
:to="`/${project.project_type}/${
}`,
},
{
label: 'Gallery',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/gallery`"
class="tab"
>
<span>Gallery</span>
</nuxt-link>
<nuxt-link
v-if="project.versions.length > 0"
:to="`/${project.project_type}/${
}/gallery`,
shown: project.gallery.length > 0 || !!currentMember,
},
{
label: 'Changelog',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/changelog`"
class="tab"
>
<span>Changelog</span>
</nuxt-link>
<nuxt-link
v-if="project.versions.length > 0 || currentMember"
:to="`/${project.project_type}/${
}/changelog`,
shown: project.versions.length > 0,
},
{
label: 'Versions',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/versions`"
class="tab"
>
<span>Versions</span>
</nuxt-link>
<nuxt-link
v-if="currentMember"
:to="`/${project.project_type}/${
}/versions`,
shown: project.versions.length > 0 || !!currentMember,
},
{
label: 'Settings',
href: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/settings`"
class="tab"
>
<span>Settings</span>
</nuxt-link>
</div>
<div class="project-content">
}/settings`,
shown: !!currentMember,
},
]"
class="card"
/>
<NuxtChild
:project.sync="project"
:versions.sync="versions"
@ -577,10 +593,239 @@
:all-members.sync="allMembers"
:dependencies.sync="dependencies"
/>
<div class="extra-info-mobile card">
<template
v-if="
project.issues_url ||
project.source_url ||
project.wiki_url ||
project.discord_url ||
project.donation_urls.length > 0
"
>
<h3 class="card-header">External resources</h3>
<div class="links">
<a
v-if="project.issues_url"
:href="project.issues_url"
class="title"
target="_blank"
>
<IssuesIcon aria-hidden="true" />
<span>Issues</span>
</a>
<a
v-if="project.source_url"
:href="project.source_url"
class="title"
target="_blank"
>
<CodeIcon aria-hidden="true" />
<span>Source</span>
</a>
<a
v-if="project.wiki_url"
:href="project.wiki_url"
class="title"
target="_blank"
>
<WikiIcon aria-hidden="true" />
<span>Wiki</span>
</a>
<a
v-if="project.discord_url"
:href="project.discord_url"
target="_blank"
>
<DiscordIcon class="shrink" aria-hidden="true" />
<span>Discord</span>
</a>
<a
v-for="(donation, index) in project.donation_urls"
:key="index"
:href="donation.url"
target="_blank"
>
<BuyMeACoffeeLogo
v-if="donation.id === 'bmac'"
aria-hidden="true"
/>
<PatreonIcon
v-else-if="donation.id === 'patreon'"
aria-hidden="true"
/>
<KoFiIcon
v-else-if="donation.id === 'ko-fi'"
aria-hidden="true"
/>
<PayPalIcon
v-else-if="donation.id === 'paypal'"
aria-hidden="true"
/>
<OpenCollectiveIcon
v-else-if="donation.id === 'open-collective'"
aria-hidden="true"
/>
<FollowIcon v-else-if="donation.id === 'github'" />
<UnknownIcon v-else />
<span v-if="donation.id === 'bmac'">Buy Me a Coffee</span>
<span v-else-if="donation.id === 'patreon'">Patreon</span>
<span v-else-if="donation.id === 'paypal'">PayPal</span>
<span v-else-if="donation.id === 'ko-fi'">Ko-fi</span>
<span v-else-if="donation.id === 'github'"
>GitHub Sponsors</span
>
<span v-else>Donate</span>
</a>
</div>
<hr class="card-divider" />
</template>
<template v-if="featuredVersions.length > 0">
<div class="featured-header">
<h3 class="card-header">Featured versions</h3>
<nuxt-link
v-if="project.versions.length > 0 || currentMember"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/versions`"
class="goto-link"
>
See all
<ChevronRightIcon
class="featured-header-chevron"
aria-hidden="true"
/>
</nuxt-link>
</div>
<div
v-for="version in featuredVersions"
:key="version.id"
class="featured-version button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
)
"
>
<a
v-tooltip="
findPrimary(version).filename +
' (' +
$formatBytes(findPrimary(version).size) +
')'
"
:href="findPrimary(version).url"
class="download download-button"
:title="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
<div class="info">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
class="top"
>
{{ version.name }}
</nuxt-link>
<div
v-if="version.game_versions.length > 0"
class="game-version item"
>
{{
version.loaders.map((x) => $formatCategory(x)).join(', ')
}}
{{ $formatVersion(version.game_versions) }}
</div>
<VersionBadge
v-if="version.version_type === 'release'"
type="release"
color="green"
/>
<VersionBadge
v-else-if="version.version_type === 'beta'"
type="beta"
color="yellow"
/>
<VersionBadge
v-else-if="version.version_type === 'alpha'"
type="alpha"
color="red"
/>
</div>
</div>
<hr class="card-divider" />
</template>
<h3 class="card-header">Project members</h3>
<div
v-for="member in members"
:key="member.user.id"
class="team-member columns button-transparent"
@click="$router.push('/user/' + member.user.username)"
>
<Avatar
:src="member.avatar_url"
:alt="member.username"
size="sm"
circle
/>
<div class="member-info">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.name }}</p>
</nuxt-link>
<p class="role">{{ member.role }}</p>
</div>
</div>
<hr class="card-divider" />
<h3 class="card-header">Technical information</h3>
<div class="infos">
<div class="info">
<div class="key">License</div>
<div class="value uppercase">
<a class="text-link" :href="project.license.url || null">{{
project.license.id
}}</a>
</div>
</div>
<div
v-if="
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin'
"
class="info"
>
<div class="key">Client side</div>
<div class="value">
{{ project.client_side }}
</div>
</div>
<div
v-if="
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin'
"
class="info"
>
<div class="key">Server side</div>
<div class="value">
{{ project.server_side }}
</div>
</div>
<div class="info">
<div class="key">Project ID</div>
<div class="value lowercase">
<CopyCode :text="project.id" />
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
@ -607,11 +852,19 @@ import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
import Advertisement from '~/components/ads/Advertisement'
import VersionBadge from '~/components/ui/Badge'
import Categories from '~/components/ui/search/Categories'
import ModalReport from '~/components/ui/ModalReport'
import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar'
export default {
components: {
Avatar,
CopyCode,
NavRow,
VersionBadge,
Advertisement,
ModalReport,
IssuesIcon,
DownloadIcon,
CalendarIcon,
@ -634,10 +887,11 @@ export default {
ChevronRightIcon,
},
async asyncData(data) {
const projectTypes = ['mod', 'modpack', 'resourcepack', 'plugin', 'project']
try {
if (!data.params.id || !projectTypes.includes(data.params.type)) {
if (
!data.params.id ||
!data.$tag.projectTypes.find((x) => x.id === data.params.type)
) {
data.error({
statusCode: 404,
message: 'The page could not be found',
@ -705,10 +959,28 @@ export default {
members[index].name = it.user.username
})
const currentMember = data.$auth.user
let currentMember = data.$auth.user
? members.find((x) => x.user.id === data.$auth.user.id)
: null
if (
!currentMember &&
data.$auth.user &&
(data.$auth.user.role === 'admin' ||
data.$auth.user.role === 'moderator')
) {
currentMember = {
team_id: project.team_id,
user: data.$auth.user,
role: data.$auth.role,
permissions: data.$auth.user.role === 'admin' ? 1023 : 12,
accepted: true,
payouts_split: 0,
avatar_url: data.$auth.user.avatar_url,
name: data.$auth.user.username,
}
}
if (project.body_url && !project.body) {
project.body = (await data.$axios.get(project.body_url)).data
}
@ -750,41 +1022,38 @@ export default {
this.featuredVersions = this.$computeVersions(this.featuredVersions)
},
head() {
const title = `${this.project.title} - Minecraft ${
this.projectTypeDisplay.charAt(0).toUpperCase() +
this.projectTypeDisplay.slice(1)
}`
const description = `${this.project.description} - Download the Minecraft ${
this.projectTypeDisplay
} ${this.project.title} by ${
this.members.find((x) => x.role === 'Owner').user.username
} on Modrinth`
return {
title: `${this.project.title} - ${
this.project.project_type.charAt(0).toUpperCase() +
this.project.project_type.slice(1)
}s - Modrinth`,
title,
meta: [
{
hid: 'og:type',
name: 'og:type',
content: 'website',
},
{
hid: 'og:title',
name: 'og:title',
content: this.project.title,
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: this.project.title,
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: this.project.description,
content: description,
},
{
hid: 'description',
name: 'description',
content: `${this.project.title}: ${this.project.description} View other minecraft mods on Modrinth today! Modrinth is a new and modern Minecraft modding platform supporting both the Forge and Fabric mod loaders.`,
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/${this.project.project_type}/${this.project.id}`,
content: description,
},
{
hid: 'og:image',
@ -796,12 +1065,19 @@ export default {
{
hid: 'robots',
name: 'robots',
content: this.project.status !== 'approved' ? 'noindex' : 'all',
content:
this.project.status === 'approved' ||
this.project.status === 'archived'
? 'all'
: 'noindeex',
},
],
}
},
computed: {
authUrl() {
return `${process.env.authURLBase}auth/init?url=${process.env.domain}${this.$route.path}`
},
projectTypeDisplay() {
return this.$getProjectTypeForDisplay(
this.project.project_type,
@ -880,47 +1156,8 @@ export default {
}
</script>
<style lang="scss" scoped>
.page-contents {
display: grid;
grid-template:
'header'
'project-status'
'content'
'extra-info'
/ 100%;
@media screen and (min-width: 1024px) {
grid-template:
'header content' auto
'project-status content' auto
'extra-info content' auto
'dummy content' 1fr
/ 20rem calc(100% - 20rem);
&.alt-layout {
grid-template:
'content header' auto
'content project-status' auto
'content extra-info' auto
'content dummy' 1fr
/ 1fr 20rem;
}
}
column-gap: var(--spacing-card-md);
}
.header {
grid-area: header;
.icon {
width: 6rem;
height: 6rem;
object-fit: contain;
border-radius: var(--size-rounded-icon);
}
.title {
margin: 0.25rem 0;
color: var(--color-text-dark);
@ -941,20 +1178,11 @@ export default {
}
}
.buttons {
display: flex;
flex-direction: row;
button,
a {
display: flex;
}
}
.description {
line-height: 1.3;
margin-top: var(--spacing-card-sm);
margin-bottom: 0.5rem;
color: var(--color-text-dark);
font-size: var(--font-size-nm);
}
@ -998,18 +1226,6 @@ export default {
}
}
.project-status {
grid-area: project-status;
}
.extra-info {
grid-area: extra-info;
}
.content {
grid-area: content;
}
.project-info {
height: auto;
overflow: hidden;
@ -1020,7 +1236,6 @@ export default {
color: var(--color-heading);
margin-bottom: 0.3rem;
width: fit-content;
display: inline;
}
.featured-header {
@ -1032,54 +1247,25 @@ export default {
height: 23px;
}
.all-link {
display: inline-flex;
align-items: center;
gap: 3px;
border-radius: 5px;
color: var(--color-link);
}
.all-link:hover,
.all-link:focus-visible {
color: var(--color-link-hover);
}
.all-link:active {
color: var(--color-link-active);
.goto-link {
margin-bottom: 0.3rem;
}
}
.featured-version {
display: flex;
flex-direction: row;
margin-top: var(--spacing-card-md);
padding: 0.5rem;
.download {
display: flex;
align-items: center;
height: 2.5rem;
width: 2.5rem;
border-radius: 1.5rem;
color: var(--color-brand-inverted);
background-color: var(--color-brand);
margin-right: var(--spacing-card-sm);
&:hover {
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-brand-active);
}
margin-right: 0.75rem;
svg {
width: 1.5rem;
margin: auto;
height: 1.5rem;
}
flex-shrink: 0;
}
.info {
@ -1088,6 +1274,8 @@ export default {
.top {
font-weight: bold;
word-wrap: break-word;
overflow-wrap: anywhere;
}
}
}
@ -1136,17 +1324,11 @@ export default {
.team-member {
align-items: center;
margin-bottom: 0.25rem;
img {
border-radius: var(--size-rounded-sm);
height: 50px;
width: 50px;
}
padding: 0.25rem 0.5rem;
.member-info {
overflow: hidden;
margin: auto 0 auto 0.5rem;
margin: auto 0 auto 0.75rem;
.name {
font-weight: bold;
@ -1209,5 +1391,19 @@ export default {
.content {
max-width: calc(1280px - 21rem);
}
.extra-info-mobile {
display: none;
}
}
@media screen and (max-width: 1024px) {
.extra-info-desktop {
display: none;
}
}
.clear-mod-message {
margin-top: var(--spacing-card-sm);
}
</style>

View File

@ -6,11 +6,20 @@
@updateVersions="updateVersions"
/>
<div class="card">
<div v-for="version in filteredVersions" :key="version.id">
<div
v-for="version in filteredVersions"
:key="version.id"
class="changelog-item"
>
<div
:class="`changelog-bar ${version.version_type} ${
version.duplicate ? 'duplicate' : ''
}`"
></div>
<div class="version-wrapper">
<div class="version-header">
<span :class="'circle ' + version.version_type" />
<div class="version-header-text">
<h2 class="name title-link">
<h2 class="name">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
@ -48,17 +57,15 @@
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
v-highlightjs
:class="'markdown-body ' + version.version_type"
v-html="
version.changelog
? $xss($md.render(version.changelog))
: 'No changelog specified.'
"
class="markdown-body"
v-html="$xss($md.render(version.changelog))"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import DownloadIcon from '~/assets/images/utils/download.svg?inline'
@ -66,8 +73,8 @@ import VersionFilterControl from '~/components/ui/VersionFilterControl'
export default {
components: {
DownloadIcon,
VersionFilterControl,
DownloadIcon,
},
props: {
project: {
@ -92,21 +99,145 @@ export default {
data() {
return {
filteredVersions: this.versions,
currentPage: 1,
}
},
fetch() {
if (this.$route.query.page)
this.currentPage = parseInt(this.$route.query.page)
this.filteredVersions = this.versions.map((version, index) => {
const nextVersion = this.versions[index + 1]
if (
nextVersion &&
version.changelog &&
nextVersion.changelog === version.changelog
) {
return { duplicate: true, ...version }
} else {
return { duplicate: false, ...version }
}
})
},
head() {
const title = `${this.project.title} - Changelog`
const description = `Explore the changelog of ${this.project.title}'s ${this.versions.length} versions.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
auth: false,
methods: {
switchPage(page, toTop) {
this.currentPage = page
this.$router.replace(this.getPageLink(page))
if (toTop) {
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 50)
setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 50)
}
},
getPageLink(page) {
if (page === 1) {
return this.$route.path
} else {
return `${this.$route.path}?page=${this.currentPage}`
}
},
updateVersions(updatedVersions) {
this.filteredVersions = updatedVersions
},
},
auth: false,
}
</script>
<style lang="scss" scoped>
.changelog-item {
display: flex;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
.changelog-bar {
--color: var(--color-badge-green-bg);
&.alpha {
--color: var(--color-badge-red-bg);
}
&.release {
--color: var(--color-badge-green-bg);
}
&.beta {
--color: var(--color-badge-yellow-bg);
}
left: 0;
top: 0.5rem;
width: 0.2rem;
min-width: 0.2rem;
position: absolute;
margin: 0 0.4rem;
border-radius: var(--size-rounded-max);
min-height: 100%;
background-color: var(--color);
&:before {
content: '';
width: 1rem;
height: 1rem;
position: absolute;
top: 0;
left: -0.4rem;
border-radius: var(--size-rounded-max);
background-color: var(--color);
}
&.duplicate {
background: linear-gradient(
to bottom,
transparent,
transparent 30%,
var(--color) 30%,
var(--color)
);
background-size: 100% 10px;
}
&.duplicate {
height: calc(100% + 1.5rem);
}
}
}
.version-header {
display: flex;
align-items: center;
margin-top: 0.2rem;
.circle {
min-width: 0.75rem;
@ -114,24 +245,11 @@ export default {
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
&.alpha {
background-color: var(--color-badge-red-bg);
}
&.release {
background-color: var(--color-badge-green-bg);
}
&.beta {
background-color: var(--color-badge-yellow-bg);
}
}
.version-header-text {
display: flex;
align-items: baseline;
margin: 0 0.75rem;
flex-wrap: wrap;
h2 {
@ -155,20 +273,6 @@ export default {
}
.markdown-body {
margin: 0.5rem 0.5rem 1rem calc(0.375rem - 1px);
padding-left: 1.275rem;
border-left: 2px solid var(--color-text);
&.alpha {
border-left-color: var(--color-badge-red-bg);
}
&.release {
border-left-color: var(--color-badge-green-bg);
}
&.beta {
border-left-color: var(--color-badge-yellow-bg);
}
margin: 0.5rem 0.5rem 0 0;
}
</style>

View File

@ -1,8 +1,9 @@
<template>
<div class="page-contents">
<header class="card">
<div class="columns">
<h3 class="column-grow-1">Edit project</h3>
<div class="page-contents legacy-label-styles">
<header class="header-card">
<div class="header__row">
<h2 class="header__title">Edit project</h2>
<div class="input-group">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
@ -28,7 +29,7 @@
</button>
<button
title="Save"
class="iconified-button brand-button-colors column"
class="iconified-button brand-button column"
:disabled="!$nuxt.$loading"
@click="saveProjectNotForReview"
>
@ -36,6 +37,7 @@
Save changes
</button>
</div>
</div>
<div v-if="showKnownErrors" class="known-errors">
<ul>
<li v-if="newProject.title === ''">Your project must have a name.</li>
@ -43,7 +45,7 @@
Your project must have a summary.
</li>
<li v-if="newProject.slug === ''">
Your project must have a vanity URL.
Your project cannot have an empty URL suffix.
</li>
<li v-if="!savingAsDraft && newProject.body === ''">
Your project must have a body to submit for review.
@ -74,6 +76,7 @@
:class="{ 'known-error': newProject.title === '' && showKnownErrors }"
type="text"
placeholder="Enter the name"
maxlength="64"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@ -94,6 +97,7 @@
}"
type="text"
placeholder="Enter the summary"
maxlength="256"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@ -164,45 +168,49 @@
@input="setCategories"
/>
</label>
<label class="vertical-input">
<span>
<h3>Vanity URL (slug)<span class="required">*</span></h3>
<span class="slug-description"
>https://modrinth.com/{{ project.project_type.toLowerCase() }}/{{
newProject.slug ? newProject.slug : 'your-slug'
}}
</span>
</span>
<input
id="name"
v-model="newProject.slug"
<div class="universal-labels">
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div
class="text-input-wrapper"
:class="{ 'known-error': newProject.slug === '' && showKnownErrors }"
>
<div class="text-input-wrapper__before">
https://modrinth.com/{{ project.project_type.toLowerCase() }}/
</div>
<!-- this is a textarea so it is horizontally scrollable on mobile -->
<textarea
id="slug"
v-model="newProject.slug"
type="text"
placeholder="Enter the vanity URL"
maxlength="64"
autocorrect="off"
autocomplete="off"
autocapitalize="none"
rows="1"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@input="manualSlug = true"
/>
</label>
</div>
</div>
</section>
<section class="card project-icon">
<h3>Icon</h3>
<img
:src="
previewImage
? previewImage
: newProject.icon_url && !iconChanged
? newProject.icon_url
: 'https://cdn.modrinth.com/placeholder.svg'
"
<Avatar
size="lg"
class="avatar"
:src="previewImage ? previewImage : newProject.icon_url"
alt="preview-image"
/>
<SmartFileInput
<FileInput
:max-size="262144"
:show-icon="false"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image"
prompt="Choose image or drag it here"
prompt="Choose image"
:disabled="(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS"
@change="showPreviewImage"
/>
@ -212,11 +220,11 @@
@click="
icon = null
previewImage = null
iconChanged = true
iconChanged = false
"
>
<TrashIcon />
Reset
<RevertIcon />
Revert
</button>
</section>
<section
@ -289,7 +297,7 @@
>. HTML can also be used inside your description, not including styles,
scripts, and iframes (though YouTube iframes are allowed).
</span>
<ThisOrThat
<Chips
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
@ -329,6 +337,7 @@
v-model="newProject.issues_url"
type="url"
placeholder="Enter a valid URL"
maxlength="2048"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@ -341,6 +350,7 @@
<input
v-model="newProject.source_url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
/>
</label>
@ -351,17 +361,22 @@
<input
v-model="newProject.wiki_url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
/>
</label>
<label title="An invitation link to your Discord server.">
<label
class="no-margin"
title="An invitation link to your Discord server."
>
<span>Discord invite</span>
<input
v-model="newProject.discord_url"
type="url"
maxlength="2048"
placeholder="Enter a valid URL"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
@ -389,7 +404,7 @@
>
for more information.
</span>
<div class="input-group">
<div class="legacy-input-group">
<Multiselect
v-model="license"
placeholder="Choose license..."
@ -407,7 +422,11 @@
<input
v-model="license_url"
type="url"
maxlength="2048"
placeholder="License URL"
:class="{
'known-error': newProject.license_url === '' && showKnownErrors,
}"
:disabled="
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
"
@ -415,9 +434,10 @@
</div>
</label>
</section>
<section class="card donations">
<div class="title">
<h3>Donation links</h3>
<section class="header-card donations">
<div class="header__row">
<h3 class="header__title">Donation links</h3>
<div class="input-group">
<button
title="Add a link"
class="iconified-button"
@ -431,6 +451,7 @@
Add a link
</button>
</div>
</div>
<div v-for="(item, index) in donationPlatforms" :key="index">
<label title="The donation link.">
<span>Donation Link</span>
@ -438,6 +459,7 @@
v-model="donationLinks[index]"
type="url"
placeholder="Enter a valid URL"
class="donation-link-input"
/>
</label>
<label title="The donation platform of the link.">
@ -482,29 +504,24 @@ import CheckIcon from '~/assets/images/utils/check.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import RevertIcon from '~/assets/images/utils/undo.svg?inline'
import ThisOrThat from '~/components/ui/ThisOrThat'
import SmartFileInput from '~/components/ui/SmartFileInput'
import Chips from '~/components/ui/Chips'
import FileInput from '~/components/ui/FileInput'
import Avatar from '~/components/ui/Avatar'
export default {
components: {
SmartFileInput,
ThisOrThat,
Avatar,
FileInput,
Chips,
Multiselect,
CrossIcon,
CheckIcon,
PlusIcon,
SaveIcon,
TrashIcon,
},
beforeRouteLeave(to, from, next) {
if (
this.isEditing &&
!window.confirm('Are you sure that you want to leave without saving?')
) {
return
}
next()
RevertIcon,
},
props: {
project: {
@ -550,6 +567,7 @@ export default {
showKnownErrors: false,
savingAsDraft: false,
manualSlug: false,
}
},
fetch() {
@ -601,16 +619,6 @@ export default {
}
},
},
mounted() {
function preventLeave(e) {
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', preventLeave)
this.$once('hook:beforeDestroy', () => {
window.removeEventListener('beforeunload', preventLeave)
})
},
created() {
this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1
@ -643,7 +651,7 @@ export default {
const reviewConditions =
this.newProject.body !== '' && this.newProject.versions.length > 0
if (
this.newProject.name !== '' &&
this.newProject.title !== '' &&
this.newProject.description !== '' &&
this.newProject.slug !== '' &&
this.license.short !== null &&
@ -789,13 +797,13 @@ label {
input,
.multiselect,
.input-group {
.legacy-input-group {
flex: 3;
height: fit-content;
}
}
.input-group {
.legacy-input-group {
display: flex;
flex-direction: column;
@ -849,24 +857,10 @@ label {
header {
grid-area: header;
padding: var(--spacing-card-md) var(--spacing-card-lg);
h3 {
margin: auto 0;
color: var(--color-text-dark);
font-weight: var(--font-weight-extrabold);
}
button {
margin-left: 0.5rem;
}
}
section.essentials {
grid-area: essentials;
label {
margin-bottom: 0.5rem;
}
@media screen and (min-width: 1024px) {
input {
@ -877,15 +871,17 @@ section.essentials {
section.project-icon {
grid-area: project-icon;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-card-sm);
img {
max-width: 100%;
margin-bottom: 0.25rem;
border-radius: var(--size-rounded-lg);
.avatar {
margin-bottom: var(--spacing-card-sm);
}
.iconified-button {
margin-top: 0.5rem;
margin-top: var(--spacing-card-sm);
}
}
@ -901,10 +897,6 @@ section.game-sides {
.labeled-control {
margin-left: var(--spacing-card-lg);
h3 {
margin-bottom: var(--spacing-card-sm);
}
}
}
}
@ -959,10 +951,6 @@ section.donations {
flex: 1;
}
}
button {
margin: 0.5rem 0;
}
}
.footer {
@ -973,7 +961,9 @@ section.donations {
cursor: pointer;
}
.card {
.card,
.universal-card,
.header-card {
margin-bottom: 0;
}
@ -991,4 +981,35 @@ section.donations {
margin-left: 0 !important;
}
}
.legacy-input-group {
display: flex;
flex-direction: column;
* {
margin-bottom: var(--spacing-card-sm);
}
}
.text-input-wrapper {
width: 100%;
display: flex;
align-items: center;
textarea {
width: 100%;
height: 100%;
margin-left: 0 !important;
white-space: nowrap;
overflow-x: auto;
overflow-y: none;
resize: none;
min-height: 0;
}
margin-bottom: var(--spacing-card-md);
}
.donation-link-input {
width: 100%;
}
</style>

View File

@ -32,7 +32,7 @@
</p>
</div>
<div class="controls">
<div v-if="gallery.length > 1" class="buttons">
<div class="buttons">
<button
class="close circle-button"
@click="expandedGalleryItem = null"
@ -54,10 +54,18 @@
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
<ContractIcon v-else aria-hidden="true" />
</button>
<button class="previous circle-button" @click="previousImage()">
<button
v-if="gallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button class="next circle-button" @click="nextImage()">
<button
v-if="gallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
<RightArrowIcon aria-hidden="true" />
</button>
</div>
@ -66,6 +74,32 @@
</div>
</div>
<div v-if="currentMember" class="card buttons header-buttons">
<button
class="iconified-button"
:class="{
'brand-button':
newGalleryItems.length === 0 &&
editGalleryIndexes.length === 0 &&
deleteGalleryUrls.length === 0,
}"
@click="
newGalleryItems.push({
title: '',
description: '',
featured: false,
url: '',
})
"
>
<PlusIcon />
{{
newGalleryItems.length === 0 &&
editGalleryIndexes.length === 0 &&
deleteGalleryUrls.length === 0
? 'Add an image'
: 'Add another image'
}}
</button>
<button
v-if="
newGalleryItems.length > 0 ||
@ -84,26 +118,12 @@
editGalleryIndexes.length > 0 ||
deleteGalleryUrls.length > 0
"
class="action brand-button-colors iconified-button"
class="action brand-button iconified-button"
@click="saveGallery"
>
<CheckIcon />
Save changes
</button>
<button
class="iconified-button"
@click="
newGalleryItems.push({
title: '',
description: '',
featured: false,
url: '',
})
"
>
<PlusIcon />
Add an image
</button>
</div>
<div class="items">
<div
@ -147,7 +167,7 @@
<CalendarIcon />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons">
<div v-if="currentMember" class="gallery-buttons input-group">
<button
v-if="editGalleryIndexes.includes(index)"
class="iconified-button"
@ -213,7 +233,7 @@
</div>
</div>
<div class="gallery-bottom">
<SmartFileInput
<FileInput
:max-size="5242880"
accept="image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp"
prompt="Choose image or drag it here"
@ -249,7 +269,7 @@ import ExternalIcon from '~/assets/images/utils/external.svg?inline'
import ExpandIcon from '~/assets/images/utils/expand.svg?inline'
import ContractIcon from '~/assets/images/utils/contract.svg?inline'
import SmartFileInput from '~/components/ui/SmartFileInput'
import FileInput from '~/components/ui/FileInput'
import Checkbox from '~/components/ui/Checkbox'
export default {
@ -260,7 +280,7 @@ export default {
EditIcon,
TrashIcon,
CheckIcon,
SmartFileInput,
FileInput,
CrossIcon,
RightArrowIcon,
LeftArrowIcon,
@ -302,6 +322,36 @@ export default {
fetch() {
this.gallery = JSON.parse(JSON.stringify(this.project.gallery))
},
head() {
const title = `${this.project.title} - Gallery`
const description = `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
@ -456,6 +506,7 @@ export default {
line-height: 1;
display: flex;
max-width: 2rem;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
margin: 0;
@ -563,18 +614,6 @@ export default {
button {
margin-right: 0.5rem;
&.brand-button-colors {
background-color: var(--color-brand);
&:hover {
background-color: var(--color-brand-hover);
}
&:active {
background-color: var(--color-brand-active);
}
}
}
}

View File

@ -22,6 +22,8 @@ export default {
<style lang="scss" scoped>
.markdown-body {
max-width: calc(100% - (2 * var(--spacing-card-lg)));
max-width: calc(
60rem - 2 * var(--spacing-card-lg) - 9px
); // $2.50 to anyone who can figure out why the 9px is needed
}
</style>

View File

@ -1,74 +1,59 @@
<template>
<div>
<ConfirmPopup
ref="delete_popup"
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete this project?"
description="If you proceed, all versions and any attached data will be removed from our servers. This may break other projects, so be careful."
:has-to-type="true"
:confirmation-text="project.title"
proceed-label="Delete project"
proceed-label="Delete"
@proceed="deleteProject"
/>
<div class="card">
<h3>General</h3>
</div>
<section class="card main-settings">
<div class="universal-card">
<h2>General settings</h2>
<div class="adjacent-input">
<label>
<span>
<h3>Edit project</h3>
<span>
This leads you to a page where you can edit your project.
<span class="label__title">Edit project information</span>
<span class="label__description">
Edit your project's name, description, categories, and more.
</span>
</span>
<div>
</label>
<nuxt-link class="iconified-button" to="edit"
><EditIcon />Edit</nuxt-link
>
</div>
</label>
<label>
<span>
<h3>Create a version</h3>
<span>
This leads to a page where you can create a version for your
project.
</span>
</span>
<div>
<nuxt-link
class="iconified-button"
to="version/create"
:disabled="
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
"
><PlusIcon />Create a version</nuxt-link
>
</div>
</label>
<label>
<span>
<h3>Delete project</h3>
<span>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Delete project</span>
<span class="label__description">
Removes your project from Modrinth's servers and search. Clicking on
this will delete your project, so be extra careful!
</span>
</span>
<div
class="iconified-button"
<button
class="iconified-button danger-button"
:disabled="
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
"
@click="showPopup"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />Delete project
</button>
</div>
</label>
</section>
<div class="card columns team-invite">
<h3>Team members</h3>
</div>
<div class="universal-card">
<h2>Manage members</h2>
<div class="adjacent-input">
<span class="label">
<span class="label__title">Invite a member</span>
<span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be
a member of this project.
</span>
</span>
<div
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
class="column"
class="input-group"
>
<input
id="username"
@ -78,7 +63,7 @@
/>
<label for="username" class="hidden">Username</label>
<button
class="iconified-button brand-button-colors column"
class="iconified-button brand-button"
@click="inviteTeamMember"
>
<PlusIcon />
@ -86,18 +71,24 @@
</button>
</div>
</div>
</div>
<div
v-for="(member, index) in allTeamMembers"
:key="member.user.id"
class="card member"
class="universal-card member"
:class="{ open: openTeamMembers.includes(member.user.id) }"
>
<div class="member-header">
<div class="info">
<img :src="member.avatar_url" :alt="member.name" />
<Avatar
:src="member.avatar_url"
:alt="member.username"
size="sm"
circle
/>
<div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name">
<p class="title-link">{{ member.name }}</p>
<p>{{ member.name }}</p>
</nuxt-link>
<p>{{ member.role }}</p>
</div>
@ -106,7 +97,6 @@
<Badge v-if="member.accepted" type="accepted" color="green" />
<Badge v-else type="pending" color="yellow" />
<button
v-if="member.role !== 'Owner'"
class="dropdown-icon"
@click="
openTeamMembers.indexOf(member.user.id) === -1
@ -121,10 +111,15 @@
</div>
</div>
<div class="content">
<div class="main-info">
<label>
Role:
<div v-if="member.oldRole !== 'Owner'" class="adjacent-input">
<label :for="`member-${allTeamMembers[index].user.username}-role`">
<span class="label__title">Role</span>
<span class="label__description">
The title of the role that this member plays for this project.
</span>
</label>
<input
:id="`member-${allTeamMembers[index].user.username}-role`"
v-model="allTeamMembers[index].role"
type="text"
:class="{ 'known-error': member.role === 'Owner' }"
@ -132,12 +127,37 @@
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
/>
</div>
<div class="adjacent-input">
<label
:for="`member-${allTeamMembers[index].user.username}-monetization-weight`"
>
<span class="label__title">Monetization weight</span>
<span class="label__description">
Relative to all other members' monetization weights, this
determines what portion of this project's revenue goes to this
member.
</span>
</label>
<ul v-if="member.role === 'Owner'" class="known-errors">
<input
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
v-model="allTeamMembers[index].payouts_split"
type="number"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
/>
</div>
<ul
v-if="member.role === 'Owner' && member.oldRole !== 'Owner'"
class="known-errors"
>
<li>A project can only have one 'Owner'.</li>
</ul>
</div>
<h3>Permissions</h3>
<template v-if="member.oldRole !== 'Owner'">
<span class="label">
<span class="label__title">Permissions</span>
</span>
<div class="permissions">
<Checkbox
:value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
@ -210,9 +230,29 @@
label="Delete project"
@input="allTeamMembers[index].permissions ^= DELETE_PROJECT"
/>
<Checkbox
:value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
"
label="View analytics"
@input="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
/>
<Checkbox
:value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
"
label="View revenue"
@input="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
/>
</div>
<div class="actions">
</template>
<div class="button-group push-right">
<button
v-if="member.oldRole !== 'Owner'"
class="iconified-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
@ -224,7 +264,7 @@
</button>
<button
v-if="
member.role !== 'Owner' &&
member.oldRole !== 'Owner' &&
currentMember.role === 'Owner' &&
member.accepted
"
@ -235,10 +275,9 @@
Transfer ownership
</button>
<button
class="iconified-button brand-button-colors"
class="iconified-button brand-button"
:disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
member.role === 'Owner'
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="updateTeamMember(index)"
>
@ -252,7 +291,7 @@
</template>
<script>
import ConfirmPopup from '~/components/ui/ConfirmPopup'
import ModalConfirm from '~/components/ui/ModalConfirm'
import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge'
@ -262,11 +301,13 @@ import CheckIcon from '~/assets/images/utils/check.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import Avatar from '~/components/ui/Avatar'
export default {
components: {
Avatar,
DropdownIcon,
ConfirmPopup,
ModalConfirm,
Checkbox,
Badge,
PlusIcon,
@ -304,6 +345,8 @@ export default {
},
fetch() {
this.allTeamMembers = this.allMembers
this.allTeamMembers.forEach((x) => (x.oldRole = x.role))
},
created() {
this.UPLOAD_VERSION = 1 << 0
@ -314,6 +357,8 @@ export default {
this.REMOVE_MEMBER = 1 << 5
this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7
this.VIEW_ANALYTICS = 1 << 8
this.VIEW_PAYOUTS = 1 << 9
},
methods: {
async inviteTeamMember() {
@ -368,9 +413,15 @@ export default {
this.$nuxt.$loading.start()
try {
const data = {
const data =
this.allTeamMembers[index].oldRole !== 'Owner'
? {
permissions: this.allTeamMembers[index].permissions,
role: this.allTeamMembers[index].role,
payouts_split: this.allTeamMembers[index].payouts_split,
}
: {
payouts_split: this.allTeamMembers[index].payouts_split,
}
await this.$axios.patch(
@ -413,14 +464,6 @@ export default {
this.$nuxt.$loading.finish()
},
showPopup() {
if (
(this.currentMember.permissions & this.DELETE_PROJECT) ===
this.DELETE_PROJECT
) {
this.$refs.delete_popup.show()
}
},
async deleteProject() {
await this.$axios.delete(
`project/${this.project.id}`,
@ -444,6 +487,7 @@ export default {
).data.map((it) => ({
avatar_url: it.user.avatar_url,
name: it.user.username,
oldRole: it.role,
...it,
}))
},
@ -452,26 +496,12 @@ export default {
</script>
<style lang="scss" scoped>
.card {
h3 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
}
.member {
margin-bottom: var(--spacing-card-md);
.member-header {
display: flex;
justify-content: space-between;
.info {
display: flex;
img {
border-radius: var(--size-rounded-icon);
height: 50px;
width: 50px;
}
.text {
margin: auto 0 auto 0.5rem;
font-size: var(--font-size-sm);
@ -499,33 +529,18 @@ export default {
.content {
display: none;
flex-direction: column;
padding-top: var(--spacing-card-md);
.main-info {
margin-bottom: var(--spacing-card-lg);
@media screen and (min-width: 1024px) {
label {
align-items: center;
input {
margin-left: 1rem;
}
}
}
}
.permissions {
margin: 1rem 0;
margin-bottom: var(--spacing-card-md);
max-width: 45rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
grid-gap: 0.5rem;
label {
flex-direction: row;
input {
flex: none;
margin-right: 0.5rem;
}
}
}
}
@ -536,90 +551,8 @@ export default {
}
}
.content {
display: unset;
margin: var(--spacing-card-lg);
}
}
}
input,
button {
&:disabled {
cursor: not-allowed !important;
}
}
section {
margin-bottom: var(--spacing-card-md);
label {
display: flex;
span {
flex: 2;
padding-right: var(--spacing-card-lg);
}
div {
flex: none;
}
input {
flex: 3;
height: fit-content;
}
}
}
.team-invite {
gap: 0.5rem;
@media screen and (max-width: 1024px) {
flex-direction: column;
h3 {
margin-bottom: 0.5rem;
}
}
h3 {
margin: auto auto auto 0;
}
> div {
display: flex;
align-items: center;
input {
margin-right: 1rem;
}
@media screen and (max-width: 500px) {
display: flex;
flex-direction: column;
input {
margin: 0;
}
button {
margin-top: 0.5rem;
}
}
}
}
.actions {
display: flex;
button {
margin-right: 0.5rem;
&:first-child {
margin-left: auto;
}
}
}
.main-settings span {
margin-bottom: 1rem;
}
</style>

View File

@ -22,26 +22,31 @@
</ul>
</div>
<div class="content card">
<ConfirmPopup
ref="delete_version_popup"
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete this version?"
description="This will remove this version forever (like really forever)."
:has-to-type="false"
proceed-label="Delete version"
proceed-label="Delete"
@proceed="deleteVersion()"
/>
<ModalReport
ref="modal_version_report"
:item-id="version.id"
item-type="version"
/>
<div class="columns">
<nuxt-link
v-if="mode === 'version'"
class="iconified-button back-button"
:to="`/${project.project_type}/${
:to="`${
$nuxt.context.from &&
($nuxt.context.from.name === 'type-id-changelog' ||
$nuxt.context.from.name === 'type-id-versions')
? $nuxt.context.from.fullPath
: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/${
$nuxt.context.from
? $nuxt.context.from.name === 'type-id-changelog'
? 'changelog'
: 'versions'
: 'versions'
}/versions`
}`"
>
<BackIcon aria-hidden="true" />
@ -73,11 +78,12 @@
class="full-width-input"
type="text"
placeholder="Enter an optional version name..."
maxlength="64"
/>
<Checkbox v-model="version.featured" label="Featured" />
<hr class="card-divider" />
</div>
<div v-if="mode === 'edit'" class="header-buttons buttons columns">
<div v-if="mode === 'edit'" class="header-buttons button-group columns">
<h3 class="column-grow-1">Edit version</h3>
<nuxt-link
v-if="$auth.user"
@ -90,7 +96,7 @@
Cancel
</nuxt-link>
<button
class="iconified-button brand-button-colors"
class="iconified-button brand-button"
@click="saveEditedVersion"
>
<SaveIcon aria-hidden="true" />
@ -99,7 +105,7 @@
</div>
<div
v-else-if="mode === 'create'"
class="header-buttons buttons columns"
class="header-buttons button-group columns"
>
<h3 class="column-grow-1">Create version</h3>
<nuxt-link
@ -112,42 +118,36 @@
<CrossIcon aria-hidden="true" />
Cancel
</nuxt-link>
<button
class="iconified-button brand-button-colors"
@click="createVersion"
>
<button class="iconified-button brand-button" @click="createVersion">
<CheckIcon aria-hidden="true" />
Create
</button>
</div>
<div v-else class="buttons">
<div v-else class="button-group">
<a
v-if="primaryFile"
v-tooltip="
primaryFile.filename + ' (' + $formatBytes(primaryFile.size) + ')'
"
:href="primaryFile.url"
class="bold-button iconified-button brand-button-colors"
class="bold-button iconified-button brand-button"
:title="`Download ${primaryFile.filename}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
<nuxt-link
:to="`/create/report?id=${version.id}&t=version`"
<button
v-if="$auth.user"
class="action iconified-button"
@click="$refs.modal_version_report.show()"
>
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
<button
v-if="currentMember"
class="action iconified-button"
@click="$refs.delete_version_popup.show()"
>
<TrashIcon aria-hidden="true" />
Delete
</button>
<a v-else class="action iconified-button" :href="authUrl">
<ReportIcon aria-hidden="true" />
Report
</a>
<nuxt-link
v-if="currentMember"
class="action iconified-button"
@ -159,11 +159,20 @@
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
<button
v-if="currentMember"
class="action iconified-button danger-button"
@click="$refs.modal_confirm.show()"
>
<TrashIcon aria-hidden="true" />
Delete
</button>
</div>
<section v-if="mode === 'edit' || mode === 'create'">
<h3>Changelog</h3>
<ThisOrThat
<Chips
v-model="changelogViewMode"
class="separator"
:items="['source', 'preview']"
/>
<div v-if="changelogViewMode === 'source'" class="textarea-wrapper">
@ -289,6 +298,7 @@
v-model="version.version_number"
type="text"
placeholder="Enter the version number..."
maxlength="32"
/>
<p v-else class="value">{{ version.version_number }}</p>
</div>
@ -358,7 +368,7 @@
</div>
<div v-if="mode === 'version'" class="data">
<p class="title">Version ID</p>
<p class="value">{{ version.id }}</p>
<p class="value"><CopyCode :text="version.id" /></p>
</div>
</div>
<hr class="card-divider" />
@ -380,16 +390,10 @@
:key="index"
class="dependency"
>
<img
class="icon"
:src="
dependency.project
? dependency.project.icon_url
? dependency.project.icon_url
: 'https://cdn.modrinth.com/placeholder.svg?inline'
: 'https://cdn.modrinth.com/placeholder.svg?inline'
"
<Avatar
:src="dependency.project ? dependency.project.icon_url : null"
alt="dependency-icon"
size="sm"
/>
<div class="info">
<nuxt-link
@ -443,8 +447,9 @@
class="edit-dependency"
>
<h4>Add dependency</h4>
<ThisOrThat
<Chips
v-model="dependencyAddMode"
class="separator"
:items="['project', 'version']"
/>
<div class="edit-info">
@ -582,9 +587,10 @@
</button>
</div>
</div>
<StatelessFileInput
<FileInput
v-if="mode === 'edit' || mode === 'create'"
multiple
should-always-reset
class="choose-files"
:accept="
project.actualProjectType.toLowerCase() === 'modpack'
@ -629,8 +635,7 @@
</template>
<script>
import Multiselect from 'vue-multiselect'
import ConfirmPopup from '~/components/ui/ConfirmPopup'
import StatelessFileInput from '~/components/ui/StatelessFileInput'
import FileInput from '~/components/ui/FileInput'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
@ -645,11 +650,20 @@ import StarIcon from '~/assets/images/utils/star.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import VersionBadge from '~/components/ui/Badge'
import Checkbox from '~/components/ui/Checkbox'
import ThisOrThat from '~/components/ui/ThisOrThat'
import Chips from '~/components/ui/Chips'
import ModalConfirm from '~/components/ui/ModalConfirm'
import ModalReport from '~/components/ui/ModalReport'
import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar'
export default {
components: {
ThisOrThat,
Avatar,
CopyCode,
ModalConfirm,
ModalReport,
FileInput,
Chips,
Checkbox,
VersionBadge,
DownloadIcon,
@ -657,29 +671,14 @@ export default {
EditIcon,
ReportIcon,
BackIcon,
ConfirmPopup,
StarIcon,
CheckIcon,
Multiselect,
SaveIcon,
PlusIcon,
CrossIcon,
StatelessFileInput,
InfoIcon,
},
beforeRouteLeave(to, from, next) {
if (this.mode === 'create') {
if (
!window.confirm('Are you sure that you want to leave without saving?')
) {
return
}
}
this.setVersion()
next()
},
props: {
project: {
type: Object,
@ -738,8 +737,57 @@ export default {
}
},
async fetch() {
console.log(this.$nuxt.context.from)
await this.setVersion()
},
head() {
if (!this.version.game_versions) {
return {}
}
const title = `${
this.mode === 'create' ? 'Create Version' : this.version.name
} - ${this.project.title}`
const description = `Download ${this.project.title} ${
this.version.version_number
} on Modrinth. Supports ${this.$formatVersion(
this.version.game_versions
)} ${this.version.loaders
.map((x) => x.charAt(0).toUpperCase() + x.slice(1))
.join(' & ')}. Published on ${this.$dayjs(
this.version.date_published
).format('MMM D, YYYY')}. ${this.version.downloads} downloads.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
computed: {
authUrl() {
return `${process.env.authURLBase}auth/init?url=${process.env.domain}${this.$route.path}`
},
},
watch: {
'$route.path': {
async handler() {
@ -747,31 +795,15 @@ export default {
},
},
},
mounted() {
if (this.mode === 'create') {
function preventLeave(e) {
e.preventDefault()
e.returnValue = ''
}
window.addEventListener('beforeunload', preventLeave)
this.$once('hook:beforeDestroy', () => {
window.removeEventListener('beforeunload', preventLeave)
})
}
},
methods: {
checkFields() {
if (
return !(
this.version.version_number === '' ||
this.version.game_versions.length === 0 ||
(this.version.loaders.length === 0 &&
this.project.project_type !== 'resourcepack') ||
(this.newFiles.length === 0 && this.version.files.length === 0)
) {
return false
}
return true
)
},
reset() {
this.changelogViewMode = 'source'
@ -1112,8 +1144,12 @@ export default {
section {
margin: 1rem 0;
h3 {
margin-bottom: 0.5rem;
.separator {
margin: var(--spacing-card-sm) 0;
}
.choose-files {
margin-bottom: var(--spacing-card-sm);
}
}
@ -1126,10 +1162,6 @@ section {
flex-wrap: wrap;
row-gap: 0.5rem;
.bold-button {
font-weight: bold;
}
@media screen and (min-width: 1024px) {
margin-left: auto;
}
@ -1186,19 +1218,12 @@ section {
.dependency {
align-items: center;
display: flex;
gap: 0.25rem;
@media screen and (min-width: 800px) {
flex-basis: 30%;
}
.icon {
width: 3rem;
height: 3rem;
margin-right: 0.5rem;
border-radius: var(--size-rounded-xs);
object-fit: contain;
}
.info {
display: flex;
flex-direction: column;
@ -1279,10 +1304,6 @@ section {
margin-bottom: var(--spacing-card-sm);
}
.styled-tabs {
margin-bottom: var(--spacing-card-sm);
}
.textarea-wrapper {
display: inline-block;
width: 100%;

View File

@ -1,10 +1,7 @@
<template>
<div class="content">
<div v-if="currentMember" class="card header-buttons">
<nuxt-link
to="version/create"
class="brand-button-colors iconified-button"
>
<nuxt-link to="version/create" class="brand-button iconified-button">
<PlusIcon />
Create a version
</nuxt-link>
@ -14,19 +11,25 @@
:versions="versions"
@updateVersions="updateVersions"
/>
<div v-if="versions.length > 0" class="card">
<table>
<thead>
<tr>
<th role="presentation"></th>
<th>Version</th>
<th>Supports</th>
<th>Stats</th>
</tr>
</thead>
<tbody>
<tr v-for="version in filteredVersions" :key="version.id">
<td>
<div v-if="versions.length > 0" class="universal-card all-versions">
<div class="header">
<div></div>
<div>Version</div>
<div>Supports</div>
<div>Stats</div>
</div>
<div
v-for="version in filteredVersions"
:key="version.id + '-new'"
class="version-button button-transparent"
@click="
$router.push(
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
)
"
>
<a
v-tooltip="
$parent.findPrimary(version).filename +
@ -38,22 +41,12 @@
class="download-button"
:class="version.version_type"
:title="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()"
>
<DownloadIcon aria-hidden="true" />
</a>
</td>
<td>
<div class="info">
<div class="top title-link">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</div>
<div class="bottom">
<span class="version__title">{{ version.name }}</span>
<div class="version__metadata">
<VersionBadge
v-if="version.version_type === 'release'"
type="release"
@ -70,55 +63,27 @@
color="red"
/>
<span class="divider" />
<span class="version_number">{{
version.version_number
}}</span>
<span class="version_number">{{ version.version_number }}</span>
</div>
<div class="mobile-info">
<p>
{{
version.loaders
.map((x) => $formatCategory(x))
.join(', ') +
' ' +
$formatVersion(version.game_versions)
}}
</p>
<p></p>
<p>
<div class="version__supports">
<span>
{{ version.loaders.map((x) => $formatCategory(x)).join(', ') }}
</span>
<span>{{ $formatVersion(version.game_versions) }}</span>
</div>
<div class="version__stats">
<span>
<strong>{{ $formatNumber(version.downloads) }}</strong>
downloads
</p>
<p>
</span>
<span>
Published on
<strong>{{
$dayjs(version.date_published).format('MMM D, YYYY')
}}</strong>
</p>
</span>
</div>
</div>
</td>
<td>
<p>
{{ version.loaders.map((x) => $formatCategory(x)).join(', ') }}
</p>
<p>{{ $formatVersion(version.game_versions) }}</p>
</td>
<td>
<p>
<span>{{ $formatNumber(version.downloads) }}</span>
downloads
</p>
<p>
Published on
<span>{{
$dayjs(version.date_published).format('MMM D, YYYY')
}}</span>
</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@ -161,6 +126,46 @@ export default {
filteredVersions: this.versions,
}
},
fetch() {
if (this.$route.query.page)
this.currentPage = parseInt(this.$route.query.page)
},
head() {
const title = `${this.project.title} - Versions`
const description = `Download and browse ${this.versions.length} ${
this.project.title
} versions. ${this.$formatNumber(
this.project.downloads
)} total downloads. Last updated ${this.$dayjs(
this.versions[0] ? this.versions[0].date_published : null
).format('MMM D, YYYY')}.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
}
},
methods: {
updateVersions(updatedVersions) {
this.filteredVersions = updatedVersions
@ -170,89 +175,116 @@ export default {
</script>
<style lang="scss" scoped>
table {
border-collapse: separate;
border-spacing: 0 0.75rem;
th {
text-align: left;
font-size: var(--font-size-md);
&:nth-child(3),
&:nth-child(4) {
display: none;
}
}
tr {
td:nth-child(2) {
padding-right: 2rem;
min-width: 16rem;
.top {
font-weight: bold;
}
.bottom {
display: flex;
flex-direction: row;
align-items: center;
text-overflow: ellipsis;
margin-top: 0.25rem;
.divider {
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
display: inline-block;
margin: 0 0.25rem;
background-color: var(--color-text);
}
}
.mobile-info {
p {
margin: 0.25rem 0 0;
}
}
}
td:nth-child(3) {
display: none;
width: 100%;
p {
margin: 0.25rem 0;
}
}
td:nth-child(4) {
display: none;
min-width: 15rem;
p {
margin: 0.25rem 0;
span {
font-weight: bold;
}
}
}
}
}
@media screen and (min-width: 1024px) {
table {
tr {
th:nth-child(3),
td:nth-child(3),
th:nth-child(4),
td:nth-child(4) {
display: table-cell;
}
}
}
.mobile-info {
display: none;
}
}
.header-buttons {
display: flex;
justify-content: right;
}
.all-versions {
display: flex;
flex-direction: column;
.header {
display: grid;
grid-template: 'download title supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr;
color: var(--color-text-dark);
font-size: var(--font-size-md);
font-weight: bold;
justify-content: left;
margin-inline: var(--spacing-card-md);
margin-bottom: var(--spacing-card-sm);
column-gap: var(--spacing-card-sm);
div:first-child {
grid-area: download;
}
div:nth-child(2) {
grid-area: title;
}
div:nth-child(3) {
grid-area: supports;
}
div:nth-child(4) {
grid-area: stats;
}
}
.version-button {
display: grid;
grid-template: 'download title supports stats' 'download metadata supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr;
column-gap: var(--spacing-card-sm);
justify-content: left;
padding: var(--spacing-card-md);
.download-button {
grid-area: download;
}
.version__title {
grid-area: title;
font-weight: bold;
}
.version__metadata {
grid-area: metadata;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-card-xs);
margin-top: var(--spacing-card-xs);
}
.version__supports {
grid-area: supports;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
.version__stats {
grid-area: stats;
display: flex;
flex-direction: column;
gap: var(--spacing-card-xs);
}
&:active:not(&:disabled) {
transform: scale(0.99) !important;
}
}
}
@media screen and (max-width: 1024px) {
.all-versions {
.header {
grid-template: 'download title';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
div:nth-child(3) {
display: none;
}
div:nth-child(4) {
display: none;
}
}
.version-button {
grid-template: 'download title' 'download metadata' 'download supports' 'download stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr;
row-gap: var(--spacing-card-xs);
.version__supports {
display: flex;
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--spacing-card-xs);
}
.version__metadata {
margin: 0;
}
}
}
}
</style>

View File

@ -61,12 +61,6 @@ export default {
head: {
title: 'About - Modrinth',
meta: [
{
hid: 'description',
name: 'description',
content:
'View information about Modrinth, an open source modding platform here! Modrinth currently supports Minecraft, including the forge and fabric mod loaders.',
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',

File diff suppressed because it is too large Load Diff

View File

@ -1,284 +0,0 @@
<template>
<div class="page-container">
<div class="page-contents">
<header class="card columns">
<h3 class="column-grow-1">File a report</h3>
<button
title="Create"
class="brand-button-colors iconified-button column"
:disabled="!$nuxt.$loading"
@click="createReport"
>
<CheckIcon />
Submit
</button>
</header>
<section class="card info">
<label>
<span>
<h3>Item ID</h3>
<span>
The ID of the item you are reporting. For example, the item ID of
a project would be its project ID, found on the right side of that
project's page under "Project ID".
</span>
</span>
<input v-model="itemId" type="text" placeholder="Enter the item ID" />
</label>
<label>
<span>
<h3>Item type</h3>
<span class="no-padding"
>The type of the item that is being reported.</span
>
</span>
<multiselect
id="item-type"
v-model="itemType"
:options="['project', 'version', 'user']"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose item type"
/>
</label>
<label>
<span>
<h3>Report type</h3>
<span class="no-padding">
The type of report. This is the category that this report falls
under.
</span>
</span>
<multiselect
id="report-type"
v-model="reportType"
:options="reportTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:multiple="false"
:searchable="false"
:show-no-results="false"
:show-labels="false"
placeholder="Choose report type"
/>
</label>
</section>
<section class="card description">
<h3>
<label
for="body"
title="You can type the of the long form of your description here."
>
Body
</label>
</h3>
<span>
You can type the of the long form of your description here. This
editor supports
<a
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>Markdown</a
>.
</span>
<ThisOrThat
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<div class="edit-wrapper">
<div v-if="bodyViewMode === 'source'" class="textarea-wrapper">
<textarea id="body" v-model="body" />
</div>
<div
v-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body"
v-html="body ? $xss($md.render(body)) : 'No body specified.'"
></div>
</div>
</section>
</div>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import ThisOrThat from '~/components/ui/ThisOrThat'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
export default {
components: {
Multiselect,
ThisOrThat,
CheckIcon,
},
async asyncData(data) {
const reportTypes = (await data.$axios.get(`tag/report_type`)).data
return {
reportTypes,
}
},
data() {
return {
itemId: '',
itemType: '',
reportType: '',
body: '',
bodyViewMode: 'source',
reportTypes: ['aaaa'],
}
},
fetch() {
if (this.$route.query.id) this.itemId = this.$route.query.id
if (this.$route.query.t) this.itemType = this.$route.query.t
},
methods: {
async createReport() {
this.$nuxt.$loading.start()
try {
const data = {
report_type: this.reportType,
item_id: this.itemId,
item_type: this.itemType,
body: this.body,
}
await this.$axios.post('report', data, this.$defaultHeaders())
switch (this.itemType) {
case 'version': {
const version = (await this.$axios.get(`version/${this.itemId}`))
.data
const project = (
await this.$axios.get(`project/${version.project_id}`)
).data
await this.$router.replace(
`/${project.project_type}/${project.slug || project.id}/version/${
this.itemId
}`
)
break
}
case 'project': {
const project = (await this.$axios.get(`project/${this.itemId}`))
.data
await this.$router.replace(
`/${project.project_type}/${project.slug || project.id}`
)
break
}
default:
await this.$router.replace(`/${this.itemType}/${this.itemId}`)
}
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped>
.title {
* {
display: inline;
}
.button {
margin-left: 1rem;
}
}
.textarea-wrapper {
display: flex;
flex-direction: column;
align-items: stretch;
textarea {
flex: 1;
overflow-y: auto;
resize: none;
max-width: 100%;
}
}
.page-contents {
display: grid;
grid-template:
'header header header' auto
'info info info' auto
'description description description' auto
'footer footer footer' auto
/ 4fr 1fr 4fr;
column-gap: var(--spacing-card-md);
row-gap: var(--spacing-card-md);
}
header {
grid-area: header;
h3 {
margin: auto 0;
color: var(--color-text-dark);
font-weight: var(--font-weight-extrabold);
}
button {
margin-left: 0.5rem;
}
}
section.info {
grid-area: info;
}
section.description {
grid-area: description;
.separator {
margin: var(--spacing-card-sm) 0;
}
.edit-wrapper * {
min-height: 10rem;
max-height: 40rem;
}
.markdown-body {
overflow-y: auto;
padding: 0 var(--spacing-card-sm);
}
}
.card {
margin-bottom: 0;
}
.card span {
margin-bottom: 1rem;
}
label {
align-items: center;
}
</style>

61
pages/dashboard.vue Normal file
View File

@ -0,0 +1,61 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Dashboard<span class="beta-badge">BETA</span></h1>
<NavStack>
<NavStackItem link="/dashboard" label="Overview">
<DashboardIcon />
</NavStackItem>
<!-- <NavStackItem link="/dashboard/projects" label="Projects">-->
<!-- <ListIcon />-->
<!-- </NavStackItem>-->
<!-- <NavStackItem link="/dashboard/analytics" label="Analytics">-->
<!-- <ChartIcon />-->
<!-- </NavStackItem>-->
<NavStackItem
v-if="hasMonetization()"
link="/dashboard/revenue"
label="Revenue"
>
<CurrencyIcon />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtChild />
</div>
</div>
</template>
<script>
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import DashboardIcon from '~/assets/images/utils/dashboard.svg?inline'
// import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline'
// import ListIcon from '~/assets/images/utils/list.svg?inline'
const monetization = true
export default {
name: 'Dashboard',
components: {
NavStack,
NavStackItem,
DashboardIcon,
// ChartIcon,
CurrencyIcon,
// ListIcon,
},
methods: {
hasMonetization() {
return monetization
},
},
}
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,22 @@
<template>
<div>
<section class="universal-card">
<h2>Analytics</h2>
</section>
</div>
</template>
<script>
export default {
components: {},
data() {
return {}
},
fetch() {},
head: {
title: 'Analytics - Modrinth',
},
methods: {},
}
</script>
<style lang="scss" scoped></style>

152
pages/dashboard/index.vue Normal file
View File

@ -0,0 +1,152 @@
<template>
<div>
<section class="universal-card">
<h2>Overview</h2>
<div class="metrics">
<div class="metric">
<div class="label">Total downloads</div>
<div class="value">
{{
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.downloads, 0)
)
}}
</div>
<span
>from {{ $user.projects.length }} project{{
$user.projects.length === 1 ? '' : 's'
}}</span
>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="metric">
<div class="label">Total followers</div>
<div class="value">
{{
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.followers, 0)
)
}}
</div>
<span>
<span
>from {{ $user.projects.length }} project{{
$user.projects.length === 1 ? '' : 's'
}}</span
></span
>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="metric">
<div class="label">Total revenue</div>
<div class="value">${{ $formatNumber(payouts.all_time) }}</div>
<span>${{ $formatNumber(payouts.last_month) }} this month</span>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown-->
<!-- <ChevronRightIcon-->
<!-- class="featured-header-chevron"-->
<!-- aria-hidden="true"-->
<!-- /></NuxtLink>-->
</div>
<div class="metric">
<div class="label">Current balance</div>
<div class="value">
${{ $formatNumber($auth.user.payout_data.balance) }}
</div>
<NuxtLink
v-if="$auth.user.payout_data.balance >= minWithdraw"
class="goto-link"
to="/dashboard/revenue"
>Withdraw earnings
<ChevronRightIcon
class="featured-header-chevron"
aria-hidden="true"
/></NuxtLink>
<span v-else>${{ minWithdraw }} is the withdraw minimum</span>
</div>
</div>
</section>
<section class="universal-card more-soon">
<h2>More coming soon!</h2>
<p>
Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀)
coming to the creators dashboard soon!
</p>
</section>
</div>
</template>
<script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline'
export default {
components: { ChevronRightIcon },
async asyncData(data) {
const [payouts] = (
await Promise.all([
data.$axios.get(
`user/${data.$auth.user.id}/payouts`,
data.$defaultHeaders()
),
])
).map((it) => it.data)
payouts.all_time = Math.floor(payouts.all_time * 100) / 100
payouts.last_month = Math.floor(payouts.last_month * 100) / 100
return {
payouts,
}
},
data() {
return {
minWithdraw: 0.26,
}
},
fetch() {},
head: {
title: 'Creator dashboard - Modrinth',
},
methods: {},
}
</script>
<style lang="scss" scoped>
.metrics {
display: grid;
grid-gap: var(--spacing-card-md);
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
.metric {
display: flex;
flex-grow: 1;
flex-direction: column;
justify-content: center;
background-color: var(--color-bg);
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-lg);
gap: var(--spacing-card-md);
.label {
color: var(--color-heading);
font-weight: bold;
font-size: 1rem;
}
.value {
color: var(--color-text-dark);
font-weight: bold;
font-size: 2rem;
}
}
}
</style>

View File

@ -0,0 +1,22 @@
<template>
<div>
<section class="universal-card">
<h2>Projects</h2>
</section>
</div>
</template>
<script>
export default {
components: {},
data() {
return {}
},
fetch() {},
head: {
title: 'Projects - Modrinth',
},
methods: {},
}
</script>
<style lang="scss" scoped></style>

131
pages/dashboard/revenue.vue Normal file
View File

@ -0,0 +1,131 @@
<template>
<div>
<ModalTransfer
v-if="enrolled"
ref="modal_transfer"
:wallet="$auth.user.payout_data.payout_wallet"
:account-type="$auth.user.payout_data.payout_wallet_type"
:account="$auth.user.payout_data.payout_address"
:balance="$auth.user.payout_data.balance"
:min-withdraw="minWithdraw"
/>
<section class="universal-card">
<h2>Withdraw</h2>
<div v-if="$auth.user.payout_data.balance >= minWithdraw">
<p>
You have
<strong>${{ $auth.user.payout_data.balance }}</strong>
available to withdraw.
<span v-if="!enrolled"
>Enroll in the Creator Monetization Program to withdraw your
revenue.</span
>
</p>
<div v-if="enrolled" class="buttons">
<button
class="iconified-button brand-button"
@click="$refs.modal_transfer.show()"
>
<TransferIcon /> Transfer to
{{ $formatWallet($auth.user.payout_data.payout_wallet) }}
</button>
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Monetization settings
</NuxtLink>
</div>
</div>
<p v-else-if="$auth.user.payout_data.balance > 0">
You have made
<strong>${{ $auth.user.payout_data.balance }}</strong
>, however you have not yet met the minimum of ${{ minWithdraw }} to
withdraw.
</p>
<p v-else>
You have made
<strong>${{ $auth.user.payout_data.balance }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
</p>
<div v-if="!enrolled">
<NuxtLink class="iconified-button" to="/settings/monetization">
<SettingsIcon /> Enroll in the Creator Monetization Program
</NuxtLink>
</div>
</section>
<section class="universal-card">
<h2>Processing fees</h2>
<p>
To avoid paying unnecessary fee deductions, you may want to wait to
transfer your money out after it accumulates for a bit rather than
transferring as soon as you reach the minimum of ${{ minWithdraw }}.
</p>
<h3>PayPal</h3>
<ul>
<li>
In the <strong>United States</strong>, PayPal charges a flat
<strong>$0.25</strong>
fee per transaction.
</li>
<li>
In the rest of the world, PayPal charges a <strong>2%</strong> (up to
$20) fee per transaction.
</li>
</ul>
<p>
Modrinth will deduct <strong>2%</strong> for the fee (minimum of $0.25
and maximum of $20) from <strong>all transfers</strong> and if the fee
PayPal charges is less than the amount we deducted, the difference will
be added back to your Modrinth balance. This happens as Modrinth cannot
determine if a transaction will be in the United States or international
or not until after the transaction has been made.
</p>
<h3>Venmo (United States only)</h3>
<p>
Venmo will charge a $0.25 processing fee per transaction, which will be
deducted from the amount you choose to transfer.
</p>
<h2>Currency conversions</h2>
<p>
All revenue generated by Modrinth is in United States dollars. Any
conversions to your local currency will happen at withdrawal and is not
handled by Modrinth. Modrinth cannot guarantee any exchange rate, so
only USD is displayed in the creator dashboard.
</p>
</section>
</div>
</template>
<script>
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import ModalTransfer from '~/components/ui/ModalTransfer'
export default {
components: { TransferIcon, SettingsIcon, ModalTransfer },
data() {
return {
minWithdraw: 0.26,
enrolled:
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address,
}
},
head: {
title: 'Revenue - Modrinth',
},
methods: {},
}
</script>
<style lang="scss" scoped>
strong {
color: var(--color-text-dark);
font-weight: 500;
}
.buttons {
display: flex;
flex-direction: row;
gap: var(--spacing-card-sm);
}
</style>

View File

@ -8,6 +8,7 @@
alt="cover-image"
/>
<div class="text">
<ModrinthLogo aria-hidden="true" class="modrinth-logo" />
<h1>Discover, Play, and Create Minecraft content</h1>
<h3>
Find enjoyable, quality content through our
@ -31,7 +32,7 @@
autocomplete="off"
/>
</div>
<button class="iconified-button brand-button-colors" type="submit">
<button class="iconified-button brand-button" type="submit">
<RightArrowIcon />
Search
</button>
@ -76,10 +77,10 @@
<h1>Constantly evolving and improving</h1>
<p>
We are always adding new features and working towards making
Modrinth have the friendliest user experience possible. Right now,
we're working on finishing modpacks and finally paying out
creators on our site. If you have any more feature ideas, feel
free to join our
Modrinth have the best possible user experience. Right now, we're
working on giving creators more analytics, adding new types of
projects, our launcher, and much more! If you have any more
feature ideas, feel free to join our
<a href="https://discord.gg/EUHuJHt" target="_blank">Discord</a>!
</p>
<div class="features">
@ -95,14 +96,6 @@
<CheckIcon />
<p>Real-time search</p>
</div>
<div class="feature completed">
<CheckIcon />
<p>Customizable project pages</p>
</div>
<div class="feature completed">
<CheckIcon />
<p>Robust team management</p>
</div>
<div class="feature completed">
<CheckIcon />
<p>
@ -115,22 +108,30 @@
<CheckIcon />
<p>Dependency management</p>
</div>
<div class="feature in-progress">
<div class="feature completed">
<CheckIcon />
<p>Modpacks</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>Creator payouts</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>More project types</p>
<div class="feature completed">
<CheckIcon />
<p>Creator monetization</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>Creator analytics</p>
</div>
<div class="feature in-progress blurred">
<InProgressIcon />
<p>[Redacted]</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>More types of projects</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>Game launcher</p>
</div>
<div class="feature in-progress">
<InProgressIcon />
<p>In-house authentication</p>
@ -166,7 +167,7 @@
<span>14</span>
<span>15</span>
</div>
<div class="text" v-highlightjs>
<div v-highlightjs class="text">
<span class="command">curl</span>
<span>https://api.modrinth.com/v2/project/sodium</span>
<pre><code class="language-json">
@ -211,6 +212,7 @@ import InProgressIcon from '~/assets/images/utils/updated.svg?inline'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline'
import FeaturesIllustration from '~/assets/images/illustrations/features.svg?inline'
import ModrinthLogo from '~/assets/images/text-logo.svg?inline'
export default {
components: {
@ -219,6 +221,7 @@ export default {
CheckIcon,
InProgressIcon,
FeaturesIllustration,
ModrinthLogo,
},
auth: false,
data() {
@ -240,6 +243,7 @@ export default {
height: 40rem;
object-fit: cover;
object-position: 15% 12.5%;
box-shadow: var(--shadow-card);
@media screen and (max-width: 1280px) {
border-radius: 0;
@ -248,13 +252,17 @@ export default {
.text {
position: absolute;
top: calc(5rem + var(--size-navbar-height));
top: calc(8rem + var(--size-navbar-height));
max-width: 30rem;
text-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3);
padding-left: 6rem;
color: #fff;
.modrinth-logo {
display: none;
}
h1 {
color: #fff;
margin: 1rem 0;
@ -279,13 +287,23 @@ export default {
gap: 0.25rem;
box-sizing: border-box;
input {
box-sizing: content-box;
button {
max-height: unset;
min-height: 40px;
}
}
@media screen and (max-width: 750px) {
padding: 1rem;
padding: 1rem 1.5rem;
top: 5rem;
.modrinth-logo {
--color-brand: currentColor;
display: unset;
width: 14rem;
max-width: 100%;
height: auto;
}
}
}
}
@ -344,7 +362,15 @@ export default {
&.in-progress {
svg {
color: var(--color-badge-yellow-text);
color: var(--color-badge-yellow-bg);
}
}
&.blurred {
p {
user-select: none;
color: transparent;
text-shadow: 0 0 12px var(--color-badge-yellow-bg);
}
}
}

59
pages/legal.vue Normal file
View File

@ -0,0 +1,59 @@
<template>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Legal</h1>
<NavStack>
<NavStackItem link="/legal/terms" label="Terms and Conditions">
<TermsIcon />
</NavStackItem>
<NavStackItem link="/legal/privacy" label="Privacy Policy">
<PrivacyIcon />
</NavStackItem>
<NavStackItem link="/legal/rules" label="Content Rules">
<RulesIcon />
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtChild class="universal-card" />
</div>
</div>
</template>
<script>
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import TermsIcon from '~/assets/images/utils/heart-handshake.svg?inline'
import PrivacyIcon from '~/assets/images/utils/lock.svg?inline'
import RulesIcon from '~/assets/images/sidebar/admin.svg?inline'
export default {
name: 'Settings',
components: {
NavStack,
NavStackItem,
TermsIcon,
PrivacyIcon,
RulesIcon,
},
}
</script>
<style lang="scss" scoped>
.normal-page__content ::v-deep a {
color: var(--color-link);
text-decoration: underline;
&:focus-visible,
&:hover {
color: var(--color-link-hover);
}
&:active {
color: var(--color-link-active);
}
}
</style>

View File

@ -1,6 +1,5 @@
<template>
<div class="main">
<div class="card">
<div>
<h1>Privacy Policy</h1>
<p>
@ -19,10 +18,9 @@
<p>We (Modrinth) are a Data Controller of your information.</p>
<p>
Rinth, Inc. legal basis for collecting and using the personal
information described in this Privacy Policy depends on the Personal
Information we collect and the specific context in which we collect the
information:
Rinth, Inc. legal basis for collecting and using the personal information
described in this Privacy Policy depends on the Personal Information we
collect and the specific context in which we collect the information:
</p>
<ul>
<li>Rinth, Inc. needs to perform a contract with you</li>
@ -36,16 +34,17 @@
<p>
Rinth, Inc. will retain your personal information only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
retain and use your information to the extent necessary to comply with
our legal obligations, resolve disputes, and enforce our policies.
necessary for the purposes set out in this Privacy Policy. We will retain
and use your information to the extent necessary to comply with our legal
obligations, resolve disputes, and enforce our policies.
</p>
<p>
If you are a resident of the European Economic Area (EEA), you have
certain data protection rights. If you wish to be informed what Personal
Information we hold about you and if you want it to be removed from our
systems, please contact us.
systems, please contact us by email at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>.
</p>
<p>
@ -53,38 +52,37 @@
</p>
<ul>
<li>
The right to access, update or to delete the information we have on
you. (please email admin@modrinth.com for data requests)
The right to access, update or to delete the information we have on you.
</li>
<li>The right of rectification.</li>
<li>The right to object.</li>
<li>The right of restriction.</li>
<li>The right to data portability</li>
<li>The right to withdraw consent</li>
<li>The right to data portability.</li>
<li>The right to withdraw consent.</li>
</ul>
<h2>Log Files</h2>
<p>
Modrinth follows a standard procedure of using log files. These files
log visitors when they visit websites. All hosting companies do this and
a part of hosting services' analytics. The information collected by log
Modrinth follows a standard procedure of using log files. These files log
visitors when they visit websites. All hosting companies do this and a
part of hosting services' analytics. The information collected by log
files include internet protocol (IP) addresses, browser type, Internet
Service Provider (ISP), date and time stamp, referring/exit pages, and
possibly the number of clicks. These are not linked to any information
that is personally identifiable. The purpose of the information is for
analyzing trends, administering the site, tracking users' movement on
the website, and gathering demographic information.
analyzing trends, administering the site, tracking users' movement on the
website, and gathering demographic information.
</p>
<h2>Cookies and Web Beacons</h2>
<p>
Like any other website, Modrinth uses 'cookies'. These cookies are used
to store information including visitors' preferences, and the pages on
the website that the visitor accessed or visited. The information is
used to optimize the users' experience by customizing our web page
content based on visitors' browser type and/or other information.
Like any other website, Modrinth uses 'cookies'. These cookies are used to
store information including visitors' preferences, and the pages on the
website that the visitor accessed or visited. The information is used to
optimize the users' experience by customizing our web page content based
on visitors' browser type and/or other information.
</p>
<p>
@ -104,33 +102,31 @@
<p>
Third-party ad servers or ad networks uses technologies like cookies,
JavaScript, or Web Beacons that are used in their respective
advertisements and links that appear on Modrinth, which are sent
directly to users' browser. They automatically receive your IP address
when this occurs. These technologies are used to measure the
effectiveness of their advertising campaigns and/or to personalize the
advertising content that you see on websites that you visit.
advertisements and links that appear on Modrinth, which are sent directly
to users' browser. They automatically receive your IP address when this
occurs. These technologies are used to measure the effectiveness of their
advertising campaigns and/or to personalize the advertising content that
you see on websites that you visit.
</p>
<p>
Note that Modrinth has no access to or control over these cookies that
are used by third-party advertisers.
Note that Modrinth has no access to or control over these cookies that are
used by third-party advertisers.
</p>
<h2>Third Party Privacy Policies</h2>
<p>
Modrinth's Privacy Policy does not apply to other advertisers or
websites. Thus, we are advising you to consult the respective Privacy
Policies of these third-party ad servers for more detailed information.
It may include their practices and instructions about how to opt-out of
certain options.
Modrinth's Privacy Policy does not apply to other advertisers or websites.
Thus, we are advising you to consult the respective Privacy Policies of
these third-party ad servers for more detailed information. It may include
their practices and instructions about how to opt-out of certain options.
</p>
<p>
You can choose to disable cookies through your individual browser
options. To know more detailed information about cookie management with
specific web browsers, it can be found at the browsers' respective
websites.
You can choose to disable cookies through your individual browser options.
To know more detailed information about cookie management with specific
web browsers, it can be found at the browsers' respective websites.
</p>
<p>
@ -154,25 +150,25 @@
<h2>Children's Information</h2>
<p>
Another part of our priority is adding protection for children while
using the internet. We encourage parents and guardians to observe,
participate in, and/or monitor and guide their online activity.
Another part of our priority is adding protection for children while using
the internet. We encourage parents and guardians to observe, participate
in, and/or monitor and guide their online activity.
</p>
<p>
Modrinth does not knowingly collect any Personal Identifiable
Information from children under the age of 13. If you think that your
child provided this kind of information on our website, we strongly
encourage you to contact us immediately and we will do our best efforts
to promptly remove such information from our records.
Modrinth does not knowingly collect any Personal Identifiable Information
from children under the age of 13. If you think that your child provided
this kind of information on our website, we strongly encourage you to
contact us immediately and we will do our best efforts to promptly remove
such information from our records.
</p>
<h2>Online Privacy Policy Only</h2>
<p>
This Privacy Policy applies only to our online activities and is valid
for visitors to our website with regards to the information that they
shared and/or collect in Modrinth. This policy is not applicable to any
This Privacy Policy applies only to our online activities and is valid for
visitors to our website with regards to the information that they shared
and/or collect in Modrinth. This policy is not applicable to any
information collected offline or via channels other than this website.
</p>
@ -183,7 +179,6 @@
to its Terms and Conditions.
</p>
</div>
</div>
</template>
<script>
@ -196,7 +191,7 @@ export default {
hid: 'description',
name: 'description',
content:
'The privacy policy of Modrinth, an open source modding platform. Modrinth currently supports Minecraft, including the forge and fabric mod loaders.',
'The Privacy Policy of Modrinth, an open source modding platform focused on Minecraft.',
},
{
hid: 'apple-mobile-web-app-title',
@ -218,14 +213,4 @@ export default {
}
</script>
<style lang="scss" scoped>
.main {
margin: var(--spacing-card-sm) auto;
max-width: 800px;
}
a {
text-decoration: underline;
color: var(--color-link);
}
</style>
<style lang="scss" scoped></style>

View File

@ -1,29 +1,28 @@
<template>
<div class="main">
<div class="card">
<div>
<h1>Content Rules</h1>
<p>
In order to facilitate Modrinth's
<nuxt-link to="/legal/terms">Terms and Conditions</nuxt-link>, all
Content must obey the following Rules. For more information on what
exactly Content is, please refer to the Content section of the Terms.
<nuxt-link to="/legal/terms">Terms and Conditions</nuxt-link>, all Content
must obey the following Rules. For more information on what exactly
Content is, please refer to the Content section of the Terms.
</p>
<p>
Please note that these are general rules and will not be enforced "to
the letter". We reserve the right to modify and/or remove any file,
project, or other Content uploaded to our platform for any reason. We
reserve the right to introduce new rules at any time, which may or may
not retroactively apply to already uploaded Content at the discretion of
our moderators.
Please note that these are general rules and will not be enforced "to the
letter". We reserve the right to modify and/or remove any file, project,
or other Content uploaded to our platform for any reason. We reserve the
right to introduce new rules at any time, which may or may not
retroactively apply to already uploaded Content at the discretion of our
moderators.
</p>
<p>
If you find any violations of these Rules on our website, it is your
responsibility to report it. You may use the Report button on any
project, version, or user page, or you may email us at
admin@modrinth.com.
responsibility to report it. You may use the Report button on any project,
version, or user page, or you may email us at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p>
<h2 id="malicious-content">1. Malicious Content</h2>
@ -38,8 +37,8 @@
directly choose to connect to in-game) without clear disclosure
</li>
<li>
to disrupt, damage, or otherwise cause harm or damage to an
individual, computer, or network
to disrupt, damage, or otherwise cause harm or damage to an individual,
computer, or network
</li>
</ul>
@ -60,8 +59,8 @@
<h3 id="general-expectations">2.1. General expectations</h3>
<p>
Projects in particular must attempt to describe the following three
things within their description:
Projects in particular must attempt to describe the following three things
within their description:
</p>
<ul>
<li>what a project specifically does or adds</li>
@ -78,9 +77,9 @@
</p>
<p>
Projects which don't meet of these expectations may be removed from
search rather than removed from the platform altogether, at the
moderators' discretion.
Projects which don't meet of these expectations may be removed from search
rather than removed from the platform altogether, at the moderators'
discretion.
</p>
<h2 id="cheats-and-hacks">3. Cheats and Hacks</h2>
@ -97,8 +96,8 @@
server-side opt-out
</li>
<li>
contains any of the following functions without requiring a
server-side opt-in:
contains any of the following functions without requiring a server-side
opt-in:
<ul>
<li>X-ray or the ability to see through opaque blocks</li>
<li>aim bot or aim assist</li>
@ -124,12 +123,12 @@
</p>
<p>
Content may not be directly "reuploaded" from another platform without
the permission of the author or copyright holder, even with the
appropriate licensing or other rights. This restriction does not apply
to content within modpacks or to so called "forks" - that is, modified
copies of a project which have diverged substantially enough from the
original project, at the discretion of Modrinth's moderators.
Content may not be directly "reuploaded" from another platform without the
permission of the author or copyright holder, even with the appropriate
licensing or other rights. This restriction does not apply to content
within modpacks or to so called "forks" - that is, modified copies of a
project which have diverged substantially enough from the original
project, at the discretion of Modrinth's moderators.
</p>
<p>
@ -137,8 +136,8 @@
</p>
<p>
Content must abide by the laws which govern Rinth, Inc., i.e. those of
the United States and of the State of Delaware.
Content must abide by the laws which govern Rinth, Inc., i.e. those of the
United States and of the State of Delaware.
</p>
<h2 id="prohibited-content">5. Prohibited Content</h2>
@ -153,13 +152,11 @@
<li>Content containing sexual or explicit material</li>
<li>Content promoting or sharing harmful or hateful behavior</li>
<li>
Content with a focus on items such as alcohol, tobacco, and other
drugs
Content with a focus on items such as alcohol, tobacco, and other drugs
</li>
<li>Content with an excessive amount of profane language</li>
</ul>
</div>
</div>
</template>
<script>
@ -194,14 +191,4 @@ export default {
}
</script>
<style lang="scss" scoped>
.main {
margin: var(--spacing-card-sm) auto;
max-width: 800px;
}
a {
text-decoration: underline;
color: var(--color-link);
}
</style>
<style lang="scss" scoped></style>

View File

@ -1,6 +1,5 @@
<template>
<div class="main">
<div class="card">
<div>
<h1>Terms and Conditions</h1>
<h2>1. Terms</h2>
@ -8,17 +7,17 @@
<p>
By accessing this Website, accessible from https://modrinth.com, you are
agreeing to be bound by these Website Terms and Conditions of Use and
agree that you are responsible for the agreement with any applicable
local laws. If you disagree with any of these terms, you are prohibited
from accessing this site. The materials contained in this Website are
protected by copyright and trade mark law.
agree that you are responsible for the agreement with any applicable local
laws. If you disagree with any of these terms, you are prohibited from
accessing this site. The materials contained in this Website are protected
by copyright and trade mark law.
</p>
<h2>2. Use License</h2>
<p>
Permission is granted to temporarily download one copy of the materials
on Rinth, Inc.'s Website for personal, non-commercial transitory viewing
Permission is granted to temporarily download one copy of the materials on
Rinth, Inc.'s Website for personal, non-commercial transitory viewing
only. This is the grant of a license, not a transfer of title, and under
this license you may not:
</p>
@ -26,16 +25,15 @@
<ul>
<li>modify or copy the materials;</li>
<li>
use the materials for any commercial purpose or for any public
display;
use the materials for any commercial purpose or for any public display;
</li>
<li>
attempt to reverse engineer any software contained on Rinth, Inc.'s
Website;
</li>
<li>
remove any copyright or other proprietary notations from the
materials; or
remove any copyright or other proprietary notations from the materials;
or
</li>
<li>
transferring the materials to another person or "mirror" the materials
@ -45,9 +43,9 @@
<p>
This will let Rinth, Inc. to terminate upon violations of any of these
restrictions. Upon termination, your viewing right will also be
terminated and you should destroy any downloaded materials in your
possession whether it is printed or electronic format.
restrictions. Upon termination, your viewing right will also be terminated
and you should destroy any downloaded materials in your possession whether
it is printed or electronic format.
</p>
<h2>3. Disclaimer</h2>
@ -64,13 +62,13 @@
<h2>4. Limitations</h2>
<p>
Rinth, Inc. or its suppliers will not be hold accountable for any
damages that will arise with the use or inability to use the materials
on Rinth, Inc.s Website, even if Rinth, Inc. or an authorize
representative of this Website has been notified, orally or written, of
the possibility of such damage. Some jurisdiction does not allow
limitations on implied warranties or limitations of liability for
incidental damages, these limitations may not apply to you.
Rinth, Inc. or its suppliers will not be hold accountable for any damages
that will arise with the use or inability to use the materials on Rinth,
Inc.s Website, even if Rinth, Inc. or an authorize representative of this
Website has been notified, orally or written, of the possibility of such
damage. Some jurisdiction does not allow limitations on implied warranties
or limitations of liability for incidental damages, these limitations may
not apply to you.
</p>
<h2>5. Revisions and Errata</h2>
@ -79,26 +77,26 @@
The materials appearing on Rinth, Inc.s Website may include technical,
typographical, or photographic errors. Rinth, Inc. will not promise that
any of the materials in this Website are accurate, complete, or current.
Rinth, Inc. may change the materials contained on its Website at any
time without notice. Rinth, Inc. does not make any commitment to update
the materials.
Rinth, Inc. may change the materials contained on its Website at any time
without notice. Rinth, Inc. does not make any commitment to update the
materials.
</p>
<h2>6. Links</h2>
<p>
Rinth, Inc. has not reviewed all of the sites linked to its Website and
is not responsible for the contents of any such linked site. The
presence of any link does not imply endorsement by Rinth, Inc. of the
site. The use of any linked website is at the users own risk.
Rinth, Inc. has not reviewed all of the sites linked to its Website and is
not responsible for the contents of any such linked site. The presence of
any link does not imply endorsement by Rinth, Inc. of the site. The use of
any linked website is at the users own risk.
</p>
<h2>7. Site Terms of Use Modifications</h2>
<p>
Rinth, Inc. may revise these Terms of Use for its Website at any time
without prior notice. By using this Website, you are agreeing to be
bound by the current version of these Terms and Conditions of Use.
without prior notice. By using this Website, you are agreeing to be bound
by the current version of these Terms and Conditions of Use.
</p>
<h2>8. Your Privacy</h2>
@ -119,35 +117,34 @@
<p>
When you upload text, software, mods, scripts, graphics, photos, audio,
videos, links, interactive features and other materials that may be
viewed on or accessed through Modrinth, we refer to it as "Content".
videos, links, interactive features and other materials that may be viewed
on or accessed through Modrinth, we refer to it as "Content".
</p>
<ul>
<li>
You are responsible for all activity and Content that is uploaded
under your Modrinth account.
You are responsible for all activity and Content that is uploaded under
your Modrinth account.
</li>
<li>
You retain all of your ownership rights to your Content. We do not
claim any ownership in or to any of your Content.
You retain all of your ownership rights to your Content. We do not claim
any ownership in or to any of your Content.
</li>
<li>
To enable us to provide the services of Modrinth, you hereby grant us
a worldwide, non-exclusive, royalty-free, and unrestricted license to
use, reproduce, distribute copies, prepare derivative works of, or
display Content in connection with Modrinth in any medium and for any
purpose (including commercial purposes).
To enable us to provide the services of Modrinth, you hereby grant us a
worldwide, non-exclusive, royalty-free, and unrestricted license to use,
reproduce, distribute copies, prepare derivative works of, or display
Content in connection with Modrinth in any medium and for any purpose
(including commercial purposes).
</li>
</ul>
<p>
All Content on Modrinth must obey the
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>. Please be aware
of these Rules before uploading any Content to Modrinth.
<nuxt-link to="/legal/rules">Content Rules</nuxt-link>. Please be aware of
these Rules before uploading any Content to Modrinth.
</p>
</div>
</div>
</template>
<script>
@ -160,17 +157,17 @@ export default {
hid: 'description',
name: 'description',
content:
'The Terms of Service of Modrinth, an open source modding platform. Modrinth currently supports Minecraft, including the forge and fabric mod loaders.',
'The Terms and Conditions of Modrinth, an open source modding platform focused on Minecraft.',
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Terms of Service',
content: 'Terms and Conditions',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Terms of Service',
content: 'Terms and Conditions',
},
{
hid: 'og:url',
@ -182,14 +179,4 @@ export default {
}
</script>
<style lang="scss" scoped>
.main {
margin: var(--spacing-card-sm) auto;
max-width: 800px;
}
a {
text-decoration: underline;
color: var(--color-link);
}
</style>
<style lang="scss" scoped></style>

View File

@ -1,8 +1,7 @@
<template>
<div class="page-container">
<Popup v-if="currentProject" :show-popup="true">
<div class="moderation-popup">
<h2>Moderation form</h2>
<div>
<Modal ref="modal" header="Moderation Form">
<div v-if="currentProject !== null" class="moderation-modal">
<p>
Both of these fields are optional, but can be used to communicate
problems with a project's team members. The body supports markdown
@ -37,46 +36,63 @@
placeholder="Enter the message..."
/>
<h3>Body</h3>
<ThisOrThat v-model="bodyViewMode" :items="['source', 'preview']" />
<div v-if="bodyViewMode === 'source'" class="textarea-wrapper">
<div class="textarea-wrapper">
<Chips
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<textarea
v-if="bodyViewMode === 'source'"
id="body"
v-model="currentProject.moderation_message_body"
/>
</div>
<div
v-if="bodyViewMode === 'preview'"
v-else
v-highlightjs
class="markdown-body"
class="markdown-body preview"
v-html="$xss($md.render(currentProject.moderation_message_body))"
></div>
</div>
<div class="buttons">
<button class="iconified-button" @click="currentProject = null">
<button
class="iconified-button"
@click="
$refs.modal.hide()
currentProject = null
"
>
<CrossIcon />
Cancel
</button>
<button
class="iconified-button brand-button-colors"
@click="saveProject"
>
<button class="iconified-button brand-button" @click="saveProject">
<CheckIcon />
Confirm
</button>
</div>
</div>
</Popup>
<div class="page-contents">
<div class="content">
</Modal>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Moderation</h1>
<ThisOrThat
v-model="selectedType"
class="card"
:items="moderationTypes"
/>
<NavStack>
<NavStackItem link="" label="All"> </NavStackItem>
<NavStackItem
v-for="type in moderationTypes"
:key="type"
:link="'?type=' + type"
:label="$formatProjectType(type) + 's'"
>
</NavStackItem>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<div class="projects">
<ProjectCard
v-for="project in selectedType !== 'all'
? projects.filter((x) => x.project_type === selectedType)
v-for="project in $route.query.type !== undefined
? projects.filter((x) => x.project_type === $route.query.type)
: projects"
:id="project.slug || project.id"
:key="project.id"
@ -115,49 +131,48 @@
</ProjectCard>
</div>
<div
v-if="selectedType === 'report' || selectedType === 'all'"
v-if="
$route.query.type === 'report' || $route.query.type === undefined
"
class="reports"
>
<div
v-for="(report, index) in reports"
:key="report.id"
class="report card"
v-for="(item, index) in reports"
:key="index"
class="card report"
>
<div class="header">
<h5 class="title">
Report for {{ report.item_type }}
<nuxt-link
:to="
'/' +
report.item_type +
'/' +
report.item_id.replace(/\W/g, '')
"
>{{ report.item_id }}
</nuxt-link>
</h5>
<p
v-tooltip="
$dayjs(report.created).format(
'[Created at] YYYY-MM-DD [at] HH:mm A'
)
"
class="date"
>
Created {{ $dayjs(report.created).fromNow() }}
</p>
<button
class="delete iconified-button"
@click="deleteReport(index)"
>
Delete
</button>
<div class="info">
<div class="title">
<h3>
{{ item.item_type }}
<a :href="item.url">{{ item.item_id }}</a>
</h3>
reported by
<a :href="`/user/${item.reporter}`">{{ item.reporter }}</a>
</div>
<div
v-highlightjs
class="markdown-body"
v-html="$xss($md.render(report.body))"
></div>
v-html="$xss($md.render(item.body))"
/>
<Badge :type="`Marked as ${item.report_type}`" color="yellow" />
</div>
<div class="actions">
<button class="iconified-button" @click="deleteReport(index)">
<TrashIcon /> Delete report
</button>
<span
v-tooltip="
$dayjs(item.created).format(
'[Created at] YYYY-MM-DD [at] HH:mm A'
)
"
class="stat"
>
<CalendarIcon />
Created {{ $dayjs(item.created).fromNow() }}
</span>
</div>
</div>
</div>
<div v-if="reports.length === 0 && projects.length === 0" class="error">
@ -171,27 +186,35 @@
</template>
<script>
import ThisOrThat from '~/components/ui/ThisOrThat'
import Chips from '~/components/ui/Chips'
import ProjectCard from '~/components/ui/ProjectCard'
import Popup from '~/components/ui/Popup'
import Modal from '~/components/ui/Modal'
import Badge from '~/components/ui/Badge'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import UnlistIcon from '~/assets/images/utils/eye-off.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import Security from '~/assets/images/illustrations/security.svg?inline'
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
export default {
name: 'Moderation',
components: {
ThisOrThat,
NavStack,
NavStackItem,
Chips,
ProjectCard,
CheckIcon,
CrossIcon,
UnlistIcon,
Popup,
Modal,
Badge,
Security,
TrashIcon,
CalendarIcon,
},
async asyncData(data) {
const [projects, reports] = (
@ -201,10 +224,76 @@ export default {
])
).map((it) => it.data)
const newReports = await Promise.all(
reports.map(async (report) => {
try {
report.item_id = report.item_id?.replace
? report.item_id.replace(/"/g, '')
: report.item_id
let url = ''
if (report.item_type === 'user') {
const user = (
await data.$axios.get(
`user/${report.item_id}`,
data.$defaultHeaders()
)
).data
url = `/user/${user.username}`
report.item_id = user.username
} else if (report.item_type === 'project') {
const project = (
await data.$axios.get(
`project/${report.item_id}`,
data.$defaultHeaders()
)
).data
report.item_id = project.slug || report.item_id
url = `/${project.project_type}/${report.item_id}`
} else if (report.item_type === 'version') {
const version = (
await data.$axios.get(
`version/${report.item_id}`,
data.$defaultHeaders()
)
).data
const project = (
await data.$axios.get(
`project/${version.project_id}`,
data.$defaultHeaders()
)
).data
report.item_id = version.version_number || report.item_id
url = `/${project.project_type}/${
project.slug || project.id
}/version/${report.item_id}`
}
report.reporter = (
await data.$axios.get(
`user/${report.reporter}`,
data.$defaultHeaders()
)
).data.username
return {
...report,
moderation_type: 'report',
url,
}
} catch (err) {
return {
...report,
url: 'error',
moderation_type: 'report',
}
}
})
)
return {
projects,
reports,
selectedType: 'all',
reports: newReports,
}
},
data() {
@ -218,7 +307,7 @@ export default {
},
computed: {
moderationTypes() {
const obj = { all: true }
const obj = {}
for (const project of this.projects) {
obj[project.project_type] = true
@ -238,6 +327,7 @@ export default {
project.newStatus = status
this.currentProject = project
this.$refs.modal.show()
},
async saveProject() {
this.$nuxt.$loading.start()
@ -262,6 +352,7 @@ export default {
1
)
this.currentProject = null
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
@ -299,7 +390,7 @@ export default {
</script>
<style lang="scss" scoped>
.moderation-popup {
.moderation-modal {
width: auto;
padding: var(--spacing-card-md) var(--spacing-card-lg);
@ -313,6 +404,19 @@ export default {
}
}
.textarea-wrapper {
margin-top: 0.5rem;
height: 15rem;
.preview {
overflow-y: auto;
}
}
.separator {
margin: var(--spacing-card-sm) 0;
}
.buttons {
display: flex;
margin-top: 0.5rem;
@ -325,28 +429,70 @@ export default {
h1 {
color: var(--color-text-dark);
margin: 0 0 1rem 1.5rem;
}
.report {
.header {
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
.title {
font-size: var(--font-size-lg);
margin: 0 0.5rem 0 0;
justify-content: space-between;
gap: 1rem;
padding: 1rem;
> div {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info {
display: flex;
flex-wrap: wrap;
.title {
display: flex;
align-items: baseline;
gap: 0.25rem;
flex-wrap: wrap;
h3 {
margin: 0;
text-transform: capitalize;
a {
text-transform: none;
}
}
a {
text-decoration: underline;
}
}
}
.actions {
min-width: fit-content;
.iconified-button {
margin-left: auto;
width: fit-content;
}
.stat {
margin-top: auto;
display: flex;
align-items: center;
grid-gap: 0.5rem;
svg {
width: 1em;
}
}
}
}
@media screen and (min-width: 1024px) {
.page-contents {
max-width: calc(1280px - 20rem) !important;
max-width: 800px;
}
}
</style>

View File

@ -1,47 +1,73 @@
<template>
<div class="page-container">
<div class="page-contents">
<div class="content">
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Notifications</h1>
<div class="divider card">
<ThisOrThat
v-model="selectedNotificationType"
:items="notificationTypes"
/>
<button class="iconified-button" @click="clearNotifications">
<NavStack>
<NavStackItem link="" label="All"> </NavStackItem>
<NavStackItem
v-for="type in notificationTypes"
:key="type"
:link="'?type=' + type"
:label="NOTIFICATION_TYPES[type]"
>
</NavStackItem>
</NavStack>
<h3>Manage</h3>
<div class="input-group">
<NuxtLink class="iconified-button" to="/settings/follows">
<SettingsIcon />
Followed projects
</NuxtLink>
<button
v-if="$user.notifications.length > 0"
class="iconified-button danger-button"
@click="clearNotifications"
>
<ClearIcon />
Clear all
</button>
</div>
</aside>
</div>
<div class="normal-page__content">
<div class="notifications">
<div
v-for="notification in selectedNotificationType !== 'all'
? $user.notifications.filter(
(x) => x.type === NOTIFICATION_TYPES[selectedNotificationType]
)
v-for="notification in $route.query.type !== undefined
? $user.notifications.filter((x) => x.type === $route.query.type)
: $user.notifications"
:key="notification.id"
class="card notification"
class="universal-card adjacent-input"
>
<div class="icon">
<UpdateIcon v-if="notification.type === 'project_update'" />
<UsersIcon v-else-if="notification.type === 'team_invite'" />
</div>
<div class="text">
<nuxt-link :to="notification.link" class="top">
<div class="label">
<span class="label__title">
<nuxt-link :to="notification.link">
<h3 v-html="$xss($md.render(notification.title))" />
<span>
Notified {{ $dayjs(notification.created).fromNow() }}</span
>
</nuxt-link>
</span>
<div class="label__description">
<p>{{ notification.text }}</p>
<span
v-tooltip="
$dayjs(notification.created).format(
'MMMM D, YYYY [at] h:mm:ss A'
)
"
class="date"
>
<CalendarIcon />
Received {{ $dayjs(notification.created).fromNow() }}</span
>
</div>
<div class="buttons">
</div>
<div class="input-group">
<button
v-for="(action, actionIndex) in notification.actions"
:key="actionIndex"
class="iconified-button"
:class="`action-button-${action.title
.toLowerCase()
.replaceAll(' ', '-')}`"
@click="
performAction(notification, notificationIndex, actionIndex)
"
@ -65,36 +91,34 @@
</div>
</div>
</div>
</div>
</template>
<script>
import ClearIcon from '~/assets/images/utils/clear.svg?inline'
import UpdateIcon from '~/assets/images/utils/updated.svg?inline'
import UsersIcon from '~/assets/images/utils/users.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?inline'
import ThisOrThat from '~/components/ui/ThisOrThat'
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
const NOTIFICATION_TYPES = {
'Team invites': 'team_invite',
'Project updates': 'project_update',
team_invite: 'Team invites',
project_update: 'Project updates',
}
export default {
name: 'Notifications',
components: {
ThisOrThat,
NavStack,
NavStackItem,
ClearIcon,
UpdateIcon,
UsersIcon,
SettingsIcon,
CalendarIcon,
UpToDate,
},
data() {
return {
selectedNotificationType: 'all',
}
},
async fetch() {
this.NOTIFICATION_TYPES = NOTIFICATION_TYPES
await this.$store.dispatch('user/fetchNotifications')
},
head: {
@ -102,24 +126,17 @@ export default {
},
computed: {
notificationTypes() {
const obj = { all: true }
const obj = {}
for (const notification of this.$user.notifications.filter(
(it) => it.type !== null
)) {
obj[
Object.keys(NOTIFICATION_TYPES).find(
(key) => NOTIFICATION_TYPES[key] === notification.type
)
] = true
obj[notification.type] = true
}
return Object.keys(obj)
},
},
created() {
this.NOTIFICATION_TYPES = NOTIFICATION_TYPES
},
methods: {
async clearNotifications() {
try {
@ -176,97 +193,37 @@ export default {
</script>
<style lang="scss" scoped>
h1 {
color: var(--color-text-dark);
margin: 0 0 1rem 1.5rem;
}
.divider {
align-items: center;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
row-gap: 0.5rem;
}
.notifications {
.notification {
display: flex;
flex-wrap: wrap;
padding: var(--spacing-card-sm) var(--spacing-card-lg);
.icon {
display: flex;
flex-direction: column;
justify-content: center;
svg {
height: calc(3rem - var(--spacing-card-sm));
width: auto;
margin-right: 1rem;
}
}
.text {
display: flex;
flex-direction: column;
justify-content: space-between;
.top {
.label {
.label__title {
display: flex;
gap: var(--spacing-card-sm);
align-items: baseline;
flex-direction: column;
margin-block-start: 0;
h3 ::v-deep {
font-size: var(--font-size-lg);
margin: 0 0.5rem 0 0;
margin: 0;
p {
margin: 0;
strong {
color: var(--color-brand);
}
}
}
}
p {
padding: 0;
.label__description {
margin: 0;
}
}
.buttons {
.date {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: auto;
text-align: right;
button {
margin-left: auto;
margin-bottom: 0.25rem;
}
}
}
align-items: center;
gap: var(--spacing-card-xs);
color: var(--color-heading);
font-weight: 500;
font-size: 1rem;
width: fit-content;
}
@media screen and (min-width: 1024px) {
.page-contents {
max-width: calc(1280px - 20rem) !important;
}
.notifications {
.notification {
flex-wrap: nowrap;
.text {
flex-direction: column;
.top {
flex-direction: row;
}
p {
margin-block: 0 var(--spacing-card-md);
}
}
}

View File

@ -1,20 +1,19 @@
<template>
<div
:class="{
'search-page': true,
'normal-page': true,
'alt-layout': $store.state.cosmetics.searchLayout,
}"
>
<aside class="normal-page__sidebar" aria-label="Filters">
<section class="card filters-card" role="presentation">
<button
class="iconified-button sidebar-menu-close-button"
@click="sidebarMenuOpen = !sidebarMenuOpen"
<aside
:class="{
'normal-page__sidebar': true,
open: sidebarMenuOpen,
}"
aria-label="Filters"
>
<EyeOffIcon v-if="sidebarMenuOpen" aria-hidden="true" />
<EyeIcon v-else aria-hidden="true" />
{{ sidebarMenuOpen ? 'Hide filters' : 'Show filters' }}
</button>
<section class="card filters-card" role="presentation">
<div
class="sidebar-menu"
:class="{ 'sidebar-menu_open': sidebarMenuOpen }"
@ -37,8 +36,9 @@
<div v-for="(categories, header) in categoriesMap" :key="header">
<h3
v-if="
categories.filter((x) => x.project_type === projectType)
.length > 0
categories.filter(
(x) => x.project_type === projectType.actual
).length > 0
"
class="sidebar-menu-heading"
>
@ -47,7 +47,7 @@
<SearchFilter
v-for="category in categories
.filter((x) => x.project_type === projectType)
.filter((x) => x.project_type === projectType.actual)
.sort((a, b) => {
if (header === 'resolutions') {
return (
@ -68,13 +68,13 @@
</div>
</section>
<section
v-if="projectType !== 'resourcepack'"
v-if="projectType.id !== 'resourcepack'"
aria-label="Loader filters"
>
<h3
v-if="
$tag.loaders.filter((x) =>
x.supported_project_types.includes(projectType)
x.supported_project_types.includes(projectType.actual)
).length > 0
"
class="sidebar-menu-heading"
@ -84,8 +84,7 @@
<SearchFilter
v-for="loader in $tag.loaders.filter((x) => {
if (
projectType === 'mod' &&
!isPlugins &&
projectType.id === 'mod' &&
!showAllLoaders &&
x.name !== 'forge' &&
x.name !== 'fabric' &&
@ -94,13 +93,13 @@
return false
}
if (projectType === 'mod' && showAllLoaders) {
if (projectType.id === 'mod' && showAllLoaders) {
return $tag.loaderData.modLoaders.includes(x.name)
}
return isPlugins
return projectType.id === 'plugin'
? $tag.loaderData.pluginLoaders.includes(x.name)
: x.supported_project_types.includes(projectType)
: x.supported_project_types.includes(projectType.actual)
})"
:key="loader.name"
ref="loaderFilters"
@ -111,7 +110,7 @@
@toggle="toggleOrFacet"
/>
<Checkbox
v-if="projectType === 'mod' && !isPlugins"
v-if="projectType.id === 'mod'"
v-model="showAllLoaders"
:label="showAllLoaders ? 'Less' : 'More'"
description="Show all loaders"
@ -120,11 +119,14 @@
:collapsing-toggle-style="true"
/>
</section>
<section v-if="isPlugins" aria-label="Platform loader filters">
<section
v-if="projectType.id === 'plugin'"
aria-label="Platform loader filters"
>
<h3
v-if="
$tag.loaders.filter((x) =>
x.supported_project_types.includes(projectType)
x.supported_project_types.includes(projectType.actual)
).length > 0
"
class="sidebar-menu-heading"
@ -145,7 +147,7 @@
/>
</section>
<section
v-if="projectType !== 'resourcepack' && !isPlugins"
v-if="!['resourcepack', 'plugin'].includes(projectType.id)"
aria-label="Environment filters"
>
<h3 class="sidebar-menu-heading">Environments</h3>
@ -211,12 +213,16 @@
</aside>
<section class="normal-page__content">
<div
v-if="projectType === 'modpack'"
v-if="
projectType.id === 'modpack' &&
$orElse($store.state.cosmetics.modpacksAlphaNotice, true)
"
class="card warning"
aria-label="Warning"
>
Modpack support is currently in alpha, and you may encounter issues. Our
documentation includes instructions on
Modpack support is currently in alpha, and can only be created and
installed through third party tools. Our documentation includes
instructions on
<a
href="https://docs.modrinth.com/docs/modpacks/playing_modpacks/"
target="_blank"
@ -242,6 +248,15 @@
ethical-ads-big
/>
<div class="card search-controls">
<div class="search-filter-container">
<button
class="iconified-button sidebar-menu-close-button"
:class="{ open: sidebarMenuOpen }"
@click="sidebarMenuOpen = !sidebarMenuOpen"
>
<FilterIcon aria-hidden="true" />
Filters...
</button>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
@ -250,11 +265,12 @@
v-model="query"
type="search"
name="search"
placeholder="Search..."
:placeholder="`Search ${projectType.display}s...`"
autocomplete="off"
@input="onSearchChange(1)"
/>
</div>
</div>
<div class="sort-controls">
<div class="labeled-control">
<span class="labeled-control__label">Sort by</span>
@ -293,12 +309,14 @@
</div>
</div>
<pagination
:current-page="currentPage"
:pages="pages"
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-before"
@switch-page="onSearchChange"
></pagination>
<div>
<div v-if="$fetchState.pending" class="no-results">
<div class="search-results-container">
<div v-if="isLoading" class="no-results">
<LogoAnimated aria-hidden="true" />
<p>Loading...</p>
</div>
@ -327,8 +345,10 @@
</div>
</div>
<pagination
:current-page="currentPage"
:pages="pages"
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="pagination-after"
@switch-page="onSearchChangeToTop"
></pagination>
</section>
@ -348,8 +368,7 @@ import ServerSide from '~/assets/images/categories/server.svg?inline'
import SearchIcon from '~/assets/images/utils/search.svg?inline'
import ClearIcon from '~/assets/images/utils/clear.svg?inline'
import EyeIcon from '~/assets/images/utils/eye.svg?inline'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg?inline'
import FilterIcon from '~/assets/images/utils/filter.svg?inline'
import Advertisement from '~/components/ads/Advertisement'
@ -366,8 +385,7 @@ export default {
ServerSide,
SearchIcon,
ClearIcon,
EyeIcon,
EyeOffIcon,
FilterIcon,
LogoAnimated,
},
data() {
@ -384,11 +402,10 @@ export default {
facets: [],
orFacets: [],
results: null,
pages: [],
pageCount: 1,
currentPage: 1,
projectType: 'mod',
isPlugins: false,
projectType: { id: 'mod', display: 'mod', actual: 'mod' },
sortTypes: [
{ display: 'Relevance', name: 'relevance' },
@ -405,6 +422,8 @@ export default {
showAllLoaders: false,
skipLink: '#search-results',
isLoading: true,
}
},
async fetch() {
@ -453,17 +472,37 @@ export default {
if (this.$route.query.o)
this.currentPage = Math.ceil(this.$route.query.o / this.maxResults) + 1
this.projectType = this.$route.name.substring(
0,
this.$route.name.length - 1
this.projectType = this.$tag.projectTypes.find(
(x) => x.id === this.$route.name.substring(0, this.$route.name.length - 1)
)
if (this.projectType === 'plugin') {
this.projectType = 'mod'
this.isPlugins = true
}
await this.onSearchChange(this.currentPage)
this.isLoading = false
},
head() {
const name = this.$route.name.substring(0, this.$route.name.length - 1)
return {
title: `Search ${this.$formatProjectType(name)}s - Modrinth`,
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: `Search ${this.$formatProjectType(name)}s`,
},
{
hid: 'og:title',
name: 'og:title',
content: `Search ${this.$formatProjectType(name)}s`,
},
{
hid: 'description',
name: 'description',
content: `Search and browse thousands of Minecraft ${name}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${name}s.\n`,
},
],
}
},
computed: {
categoriesMap() {
@ -494,20 +533,14 @@ export default {
watch: {
'$route.path': {
async handler() {
this.projectType = this.$route.name.substring(
0,
this.$route.name.length - 1
this.isLoading = true
this.projectType = this.$tag.projectTypes.find(
(x) =>
x.id === this.$route.name.substring(0, this.$route.name.length - 1)
)
if (this.projectType === 'plugin') {
this.projectType = 'mod'
this.isPlugins = true
} else {
this.isPlugins = false
}
this.results = null
this.pages = []
this.pageCount = 1
this.currentPage = 1
this.query = ''
this.maxResults = 20
@ -516,6 +549,8 @@ export default {
this.sidebarMenuOpen = false
await this.clearFilters()
this.isLoading = false
},
},
},
@ -596,6 +631,8 @@ export default {
await this.onSearchChange(newPageNumber)
},
async onSearchChange(newPageNumber) {
this.currentPage = newPageNumber
if (this.query === null) return
try {
@ -623,13 +660,13 @@ export default {
// loaders specifier
if (this.orFacets.length > 0) {
formattedFacets.push(this.orFacets)
} else if (this.isPlugins) {
} else if (this.projectType.id === 'plugin') {
formattedFacets.push(
this.$tag.loaderData.allPluginLoaders.map(
(x) => `categories:'${encodeURIComponent(x)}'`
)
)
} else if (this.projectType === 'mod') {
} else if (this.projectType.id === 'mod') {
formattedFacets.push(
this.$tag.loaderData.modLoaders.map(
(x) => `categories:'${encodeURIComponent(x)}'`
@ -682,7 +719,7 @@ export default {
}
if (this.projectType)
formattedFacets.push([`project_type:${this.projectType}`])
formattedFacets.push([`project_type:${this.projectType.actual}`])
params.push(`facets=${JSON.stringify(formattedFacets)}`)
}
@ -703,38 +740,19 @@ export default {
const res = await this.$axios.get(url, this.$defaultHeaders())
this.results = res.data.hits
const pageAmount = Math.ceil(res.data.total_hits / res.data.limit)
this.currentPage = newPageNumber
if (pageAmount > 4) {
if (this.currentPage + 3 >= pageAmount) {
this.pages = [
1,
'-',
pageAmount - 4,
pageAmount - 3,
pageAmount - 2,
pageAmount - 1,
pageAmount,
]
} else if (this.currentPage > 4) {
this.pages = [
1,
'-',
this.currentPage - 1,
this.currentPage,
this.currentPage + 1,
'-',
pageAmount,
]
} else {
this.pages = [1, 2, 3, 4, 5, '-', pageAmount]
}
} else {
this.pages = Array.from({ length: pageAmount }, (_, i) => i + 1)
}
this.pageCount = Math.ceil(res.data.total_hits / res.data.limit)
if (process.client) {
url = this.getSearchUrl(offset)
await this.$router.replace({ path: url })
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
}
},
getSearchUrl(offset) {
const queryItems = []
if (this.query) queryItems.push(`q=${encodeURIComponent(this.query)}`)
@ -747,17 +765,15 @@ export default {
queryItems.push(`v=${encodeURIComponent(this.selectedVersions)}`)
if (this.selectedLicenses.length > 0)
queryItems.push(`l=${encodeURIComponent(this.selectedLicenses)}`)
if (this.showSnapshots) url += `h=true`
if (this.showSnapshots) queryItems.push(`h=true`)
if (this.selectedEnvironments.length > 0)
queryItems.push(
`e=${encodeURIComponent(this.selectedEnvironments)}`
)
queryItems.push(`e=${encodeURIComponent(this.selectedEnvironments)}`)
if (this.sortType.name !== 'relevance')
queryItems.push(`s=${encodeURIComponent(this.sortType.name)}`)
if (this.maxResults !== 20)
queryItems.push(`m=${encodeURIComponent(this.maxResults)}`)
url = `${this.$route.path}`
let url = `${this.$route.path}`
if (queryItems.length > 0) {
url += `?${queryItems[0]}`
@ -767,25 +783,60 @@ export default {
}
}
await this.$router.replace({ path: url })
}
} catch (err) {
// eslint-disable-next-line no-console
console.error(err)
}
return url
},
},
}
</script>
<style lang="scss" scoped>
// Mobile-first CSS: search page is grid on mobile...
.search-page {
display: grid;
grid-template-rows: auto;
grid-template-columns: 100%;
// ...and flex on desktop
@media screen and (min-width: 1024px) {
display: flex;
// Note that the actual flex layout properties come from .normal-page
}
}
.normal-page__content {
// Passthrough children as grid items on mobile
display: contents;
@media screen and (min-width: 1024px) {
display: block;
}
}
// Move the filters "sidebar" on mobile underneath the search card
.normal-page__sidebar {
grid-row: 3;
// Hide on mobile unless open
@media screen and (max-width: 1024px) {
display: none;
&.open {
display: block;
}
}
}
.filters-card {
padding: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
padding: var(--spacing-card-lg);
}
}
.sidebar-menu {
display: none;
margin-top: 1rem;
}
.sidebar-menu_open {
@ -796,11 +847,47 @@ export default {
margin: 1.5rem 0 0.5rem 0;
}
// EthicalAds
.content-wrapper {
grid-row: 1;
}
.search-controls {
display: flex;
flex-direction: row;
gap: var(--spacing-card-md);
flex-wrap: wrap;
padding: var(--spacing-card-md);
grid-row: 2;
.search-filter-container {
display: flex;
width: 100%;
align-items: center;
.sidebar-menu-close-button {
max-height: none;
// match height of the search field
height: 40px;
transition: box-shadow 0.1s ease-in-out;
margin-right: var(--spacing-card-md);
&.open {
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);
}
}
.iconified-input {
flex: 1;
input {
width: 100%;
margin: 0;
}
}
}
.sort-controls {
width: 100%;
@ -822,15 +909,6 @@ export default {
}
}
}
.iconified-input {
flex: 1;
input {
min-width: 15rem;
width: 100%;
}
}
}
.search-controls__sorting {
@ -842,11 +920,23 @@ export default {
display: block;
}
.pagination-before {
grid-row: 4;
}
.search-results-container {
grid-row: 5;
}
.pagination-after {
grid-row: 6;
}
.no-results {
text-align: center;
}
@media screen and (min-width: 700px) {
@media screen and (min-width: 750px) {
.search-controls {
flex-wrap: nowrap;
flex-direction: row;

View File

@ -5,26 +5,6 @@
<script>
export default {
name: 'Modpacks',
head: {
title: 'Modpacks - Modrinth',
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Modpacks',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Modpacks',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/modpacks`,
},
],
},
}
</script>

View File

@ -5,26 +5,6 @@
<script>
export default {
name: 'Mods',
head: {
title: 'Mods - Modrinth',
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Mods',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Mods',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/mods`,
},
],
},
}
</script>

View File

@ -5,26 +5,6 @@
<script>
export default {
name: 'Plugins',
head: {
title: 'Plugins - Modrinth',
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Plugins - Modrinth',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Plugins - Modrinth',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/plugins`,
},
],
},
}
</script>

View File

@ -5,26 +5,6 @@
<script>
export default {
name: 'ResourcePacks',
head: {
title: 'Resource packs - Modrinth',
meta: [
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Resource packs - Modrinth',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Resource packs - Modrinth',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/resourcepacks`,
},
],
},
}
</script>

View File

@ -1,46 +1,57 @@
<template>
<div class="page-container">
<div class="page-contents">
<div class="content">
<h1 v-if="$auth.user">Settings for {{ $auth.user.username }}</h1>
<h1 v-else>Settings</h1>
<div class="card styled-tabs">
<nuxt-link v-if="$auth.user" class="tab" to="/settings" exact
><span>General</span></nuxt-link
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="universal-card">
<h1>Settings</h1>
<NavStack>
<NavStackItem link="/settings" label="Appearance">
<PaintbrushIcon />
</NavStackItem>
<template v-if="$auth.user">
<h3>User settings</h3>
<NavStackItem link="/settings/account" label="Account">
<UserIcon />
</NavStackItem>
<NavStackItem link="/settings/follows" label="Followed projects">
<HeartIcon />
</NavStackItem>
<NavStackItem
link="/settings/monetization"
label="Monetization"
beta
>
<nuxt-link v-if="$auth.user" class="tab" to="/settings/follows">
<span>Followed projects</span>
</nuxt-link>
<nuxt-link v-if="$auth.user" class="tab" to="/settings/security">
<span>Security</span>
</nuxt-link>
<CurrencyIcon />
</NavStackItem>
</template>
</NavStack>
</aside>
</div>
<div class="normal-page__content">
<NuxtChild />
</div>
</div>
</div>
</template>
<script>
import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem'
import PaintbrushIcon from '~/assets/images/utils/paintbrush.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline'
export default {
name: 'Settings',
components: {
NavStack,
NavStackItem,
PaintbrushIcon,
UserIcon,
HeartIcon,
CurrencyIcon,
},
}
</script>
<style lang="scss" scoped>
.page-contents {
display: flex;
flex-direction: column;
h1 {
color: var(--color-text-dark);
margin: 0 0 0.5rem 1.5rem;
}
}
@media screen and (min-width: 1024px) {
.page-contents {
max-width: 60rem !important;
}
}
</style>
<style lang="scss" scoped></style>

274
pages/settings/account.vue Normal file
View File

@ -0,0 +1,274 @@
<template>
<div>
<ModalConfirm
ref="modal_confirm"
title="Are you sure you want to delete your account?"
description="This will **immediately delete all of your user data and follows**. This will not delete your projects. Deleting your account cannot be reversed.<br><br>If you need help with your account, get support on the [Modrinth Discord](https://discord.gg/EUHuJHt)."
proceed-label="Delete my account"
:confirmation-text="$auth.user.username"
:has-to-type="true"
@proceed="deleteAccount"
/>
<Modal ref="modal_revoke_token" header="Revoke your Modrinth token">
<div class="modal-revoke-token markdown-body">
<p>
Revoking your Modrinth token can have unintended consequences. Please
be aware that the following could break:
</p>
<ul>
<li>Any application that uses your token to access the API.</li>
<li>
Gradle - if Minotaur is given a incorrect token, your Gradle builds
could fail.
</li>
<li>
GitHub - if you use a GitHub action that uses the Modrinth API, it
will cause errors.
</li>
</ul>
<p>If you are willing to continue, complete the following steps:</p>
<ol>
<li>
<a
href="https://github.com/settings/connections/applications/3acffb2e808d16d4b226"
target="_blank"
>
Head to the Modrinth Application page on GitHub.
</a>
Make sure to be logged into the GitHub account you used for
Modrinth!
</li>
<li>
Press the big red "Revoke Access" button next to the "Permissions"
header.
</li>
</ol>
<p>
Once you have completed those steps, press the continue button below.
</p>
<p>
<strong>
This will log you out of Modrinth, however, when you log back in,
your token will be regenerated.
</strong>
</p>
<div class="button-group">
<button
class="iconified-button"
@click="$refs.modal_revoke_token.hide()"
>
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="logout">
<RightArrowIcon />
Log out
</button>
</div>
</div>
</Modal>
<section class="universal-card">
<h2>User profile</h2>
<p>Visit your user profile to edit your profile information.</p>
<NuxtLink class="iconified-button" :to="`/user/${$auth.user.username}`">
<UserIcon /> Visit your profile
</NuxtLink>
</section>
<section class="universal-card">
<h2>Account information</h2>
<p>Your account information is not displayed publicly.</p>
<ul class="known-errors">
<li v-if="hasMonetizationEnabled() && !email">
You must have an email address set since you are enrolled in the
Creator Monetization Program.
</li>
</ul>
<label for="email-input"
><span class="label__title">Email address</span>
</label>
<input
id="email-input"
v-model="email"
maxlength="2048"
type="email"
:placeholder="`Enter your email address...`"
/>
<div class="button-group">
<button
type="button"
class="iconified-button brand-button"
:disabled="hasMonetizationEnabled() && !email"
@click="saveChanges()"
>
<SaveIcon />
Save changes
</button>
</div>
</section>
<section class="universal-card">
<h2>Authorization token</h2>
<p>
Your authorization token can be used with the Modrinth API, the Minotaur
Gradle plugin, and other applications that interact with Modrinth's API.
Be sure to keep this secret!
</p>
<div class="input-group">
<button
type="button"
class="iconified-button"
value="Copy to clipboard"
@click="copyToken"
>
<template v-if="copied">
<CheckIcon />
Copied token to clipboard
</template>
<template v-else><CopyIcon />Copy token to clipboard</template>
</button>
<button
type="button"
class="iconified-button"
@click="$refs.modal_revoke_token.show()"
>
<SlashIcon />
Revoke token
</button>
</div>
</section>
<section id="delete-account" class="universal-card">
<h2>Delete account</h2>
<p>
Once you delete your account, there is no going back. Deleting your
account will remove all attached data, excluding projects, from our
servers.
</p>
<button
type="button"
class="iconified-button danger-button"
@click="$refs.modal_confirm.show()"
>
<TrashIcon />
Delete account
</button>
</section>
</div>
</template>
<script>
import ModalConfirm from '~/components/ui/ModalConfirm'
import Modal from '~/components/ui/Modal'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import CopyIcon from '~/assets/images/utils/clipboard-copy.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import SlashIcon from '~/assets/images/utils/slash.svg?inline'
export default {
components: {
Modal,
ModalConfirm,
CrossIcon,
RightArrowIcon,
CheckIcon,
SaveIcon,
UserIcon,
CopyIcon,
TrashIcon,
SlashIcon,
},
data() {
return {
copied: false,
email: this.$auth.user.email,
showKnownErrors: false,
}
},
head: {
title: 'Account settings - Modrinth',
},
methods: {
async copyToken() {
this.copied = true
await navigator.clipboard.writeText(this.$auth.token)
},
async deleteAccount() {
this.$nuxt.$loading.start()
try {
await this.$axios.delete(
`user/${this.$auth.user.id}`,
this.$defaultHeaders()
)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
logout() {
this.$refs.modal_revoke_token.hide()
this.$cookies.set('auth-token-reset', true)
window.location.href = `${this.$axios.defaults.baseURL}auth/init?url=${process.env.domain}`
},
hasMonetizationEnabled() {
return (
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address
)
},
async saveChanges() {
if (this.hasMonetizationEnabled() && !this.email) {
this.showKnownErrors = true
return
}
this.$nuxt.$loading.start()
try {
const data = {
email: this.email ? this.email : null,
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped>
.modal-revoke-token {
padding: var(--spacing-card-bg);
.button-group {
width: fit-content;
margin-left: auto;
}
}
</style>

View File

@ -28,7 +28,7 @@
<FollowIllustration class="icon" />
<br />
<span class="text"
>You don't have any followed mods. <br />
>You don't have any followed projects. <br />
Why don't you <nuxt-link class="link" to="/mods">search</nuxt-link> for
new ones?</span
>
@ -51,7 +51,7 @@ export default {
await this.$store.dispatch('user/fetchFollows')
},
head: {
title: 'Followed Projects - Modrinth',
title: 'Followed projects - Modrinth',
},
}
</script>

View File

@ -1,84 +1,16 @@
<template>
<div class="edit-page">
<section class="card account-settings">
<div class="header">
<h2 class="title">Account settings</h2>
<div class="controls">
<button
class="brand-button-colors iconified-button"
title="Save account settings changes"
@click="saveChanges()"
<div>
<section class="universal-card">
<h2>Themes</h2>
<div class="adjacent-input">
<label for="theme-selector">
<span class="label__title">Color theme</span>
<span class="label__description"
>Change the global site color theme.</span
>
<SaveIcon />
Save changes
</button>
</div>
</div>
<div class="left-side">
<h3>Profile picture</h3>
<div class="profile-picture">
<img :src="previewImage ? previewImage : $auth.user.avatar_url" />
<div class="uploader">
<SmartFileInput
:show-icon="false"
:max-size="2097152"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image"
prompt="Choose image or drag it here"
@change="showPreviewImage"
/>
<button
class="iconified-button"
@click="
icon = null
previewImage = null
"
>
<TrashIcon />
Reset
</button>
</div>
</div>
</div>
<div class="right-side">
<label>
<span>
<h3>Username</h3>
<span>This must be unique.</span>
</span>
<input
v-model="username"
type="text"
placeholder="Enter your username"
/>
</label>
<label>
<span>
<h3>Email (optional)</h3>
<span>This is kept private.</span>
</span>
<input v-model="email" type="email" placeholder="Enter your email" />
</label>
<label>
<span>
<h3>Bio</h3>
<span>Describe yourself to other users!</span>
</span>
<input v-model="bio" type="text" placeholder="Enter your bio" />
</label>
</div>
</section>
<section class="card">
<div class="header">
<h2 class="title">Display settings</h2>
</div>
<label>
<span>
<h3>Theme</h3>
<span>Change the global site theme.</span>
</span>
<Multiselect
id="theme-selector"
v-model="$colorMode.preference"
:options="['system', 'light', 'dark', 'oled']"
:custom-label="
@ -92,78 +24,118 @@
:show-labels="false"
:allow-empty="false"
/>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">Search sidebar on the right</span>
<span class="label__description"
>Enabling this will put the search page's filters sidebar on the
right side.</span
>
</label>
<label>
<span>
<h3>Search sidebar on the right</h3>
<span>
Enabling this will put the search page's filters sidebar on the
right side.
</span>
</span>
<input
id="search-layout-toggle"
v-model="searchLayout"
class="switch stylized-toggle"
type="checkbox"
@change="changeLayout"
@change="saveCosmeticSettings"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">Project sidebar on the right</span>
<span class="label__description"
>Enabling this will put the project pages' info sidebars on the
right side.</span
>
</label>
<label>
<span>
<h3>Project sidebar on the right</h3>
<span>
Enabling this will put the project pages' info sidebars on the right
side.
</span>
</span>
<input
id="project-layout-toggle"
v-model="projectLayout"
class="switch stylized-toggle"
type="checkbox"
@change="changeLayout"
@change="saveCosmeticSettings"
/>
</div>
</section>
<section class="universal-card">
<h2>Feature flags</h2>
<div class="adjacent-input small">
<label for="advanced-rendering">
<span class="label__title">Advanced rendering</span>
<span class="label__description"
>Enables advanced rendering such as blur effects that may cause
performance issues without hardware-accelerated rendering.</span
>
</label>
<input
id="advanced-rendering"
v-model="advancedRendering"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
/>
</div>
<div class="adjacent-input small">
<label for="modpacks-alpha-notice">
<span class="label__title">Modpacks alpha notice</span>
<span class="label__description"
>Shows a banner stating that modpacks are in alpha.</span
>
</label>
<input
id="modpacks-alpha-notice"
v-model="modpacksAlphaNotice"
class="switch stylized-toggle"
type="checkbox"
@change="saveCosmeticSettings"
/>
</div>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import SmartFileInput from '~/components/ui/SmartFileInput'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
export default {
components: {
TrashIcon,
SaveIcon,
SmartFileInput,
Multiselect,
},
asyncData(ctx) {
return {
username: ctx.$auth.user.username,
email: ctx.$auth.user.email,
bio: ctx.$auth.user.bio,
}
},
auth: false,
data() {
return {
icon: null,
previewImage: null,
searchLayout: false,
projectLayout: false,
modpacksAlphaNotice: true,
advancedRendering: true,
}
},
fetch() {
this.searchLayout = this.$store.state.cosmetics.searchLayout
this.projectLayout = this.$store.state.cosmetics.projectLayout
this.searchLayout =
this.$store.state.cosmetics.searchLayout ?? this.searchLayout
this.projectLayout =
this.$store.state.cosmetics.projectLayout ?? this.projectLayout
this.modpacksAlphaNotice =
this.$store.state.cosmetics.modpacksAlphaNotice ??
this.modpacksAlphaNotice
this.advancedRendering =
this.$store.state.cosmetics.advancedRendering ?? this.advancedRendering
},
head: {
title: 'Settings - Modrinth',
title: 'Display settings - Modrinth',
},
methods: {
async saveCosmeticSettings() {
await this.$store.dispatch('cosmetics/save', {
searchLayout: this.searchLayout,
projectLayout: this.projectLayout,
modpacksAlphaNotice: this.modpacksAlphaNotice,
advancedRendering: this.advancedRendering,
$cookies: this.$cookies,
})
},
changeTheme() {
const shift = event.shiftKey
switch (this.$colorMode.preference) {
@ -177,141 +149,7 @@ export default {
this.$colorMode.preference = shift ? 'oled' : 'dark'
}
},
showPreviewImage(files) {
const reader = new FileReader()
this.icon = files[0]
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
sumDownloads() {
let sum = 0
for (const projects of this.$user.projects) {
sum += projects.downloads
}
return this.$formatNumber(sum)
},
async changeLayout() {
await this.$store.dispatch('cosmetics/save', {
searchLayout: this.searchLayout,
projectLayout: this.projectLayout,
$cookies: this.$cookies,
})
},
async saveChanges() {
this.$nuxt.$loading.start()
try {
if (this.icon) {
await this.$axios.patch(
`user/${this.$auth.user.id}/icon?ext=${
this.icon.type.split('/')[this.icon.type.split('/').length - 1]
}`,
this.icon,
this.$defaultHeaders()
)
}
const data = {
email: this.email,
bio: this.bio,
}
if (this.username !== this.$auth.user.username) {
data.username = this.username
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped>
.account-settings {
display: grid;
grid-template: 'header header' auto 'left-side left-side' auto 'right-side right-side' auto;
@media screen and (min-width: 1024px) {
grid-template:
'header header' auto
'left-side right-side' auto;
}
.left-side {
grid-area: left-side;
min-width: 20rem;
.profile-picture {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
img {
box-shadow: var(--shadow-card);
border-radius: var(--size-rounded-md);
width: 10rem;
height: 10rem;
object-fit: contain;
margin-bottom: 0.5rem;
}
.uploader {
text-align: center;
.iconified-button {
margin-top: 0.5rem;
}
}
}
}
.right-side {
grid-area: right-side;
margin-left: var(--spacing-card-lg);
}
}
.card span {
margin-bottom: 1rem;
}
label {
align-items: center;
}
.header {
display: flex;
align-items: center;
padding-bottom: 1rem;
grid-area: header;
.title {
flex-grow: 1;
margin: 0;
}
.controls {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
}
</style>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,211 @@
<template>
<div>
<section v-if="enrolled" class="universal-card">
<h2>Revenue and metrics</h2>
<p>View your revenue and metrics in the creator dashboard:</p>
<NuxtLink class="iconified-button" to="/dashboard/revenue">
<ChartIcon /> Visit creator dashboard
</NuxtLink>
</section>
<section class="universal-card">
<h2 class="title">Enrollment</h2>
<template v-if="!enrolled && !$auth.user.email">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization
Program. In order to enroll, you must first add a valid email address
to your account.
</p>
<NuxtLink class="iconified-button" to="/settings/account">
<SettingsIcon /> Visit account settings
</NuxtLink>
</template>
<template v-else-if="editing || !enrolled">
<p v-if="!enrolled">
You are not currently enrolled in Modrinth's Creator Monetization
Program. Setup a method of receiving payments below to enable
monetization.
</p>
<div class="enroll">
<Chips
v-model="selectedWallet"
:starting-value="selectedWallet"
:items="wallets"
:format-label="$formatWallet"
@input="onChangeWallet()"
/>
<p>
Enter the information for the
{{ $formatWallet(selectedWallet) }} account you would like to
receive your revenue from the Creator Monetization Program:
</p>
<div class="input-group">
<Multiselect
v-model="accountType"
:options="getAccountTypes()"
:custom-label="(value) => formatAccountType(value)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<label class="hidden" for="account-input"
>{{ $formatWallet(selectedWallet) }}
{{ formatAccountType(accountType).toLowerCase() }} input
field</label
>
<input
id="account-input"
v-model="account"
:placeholder="`Enter your ${$formatWallet(
selectedWallet
)} ${formatAccountType(accountType).toLowerCase()}...`"
:type="accountType === 'email' ? 'email' : ''"
/>
<span v-if="accountType === 'phone'">
Format: +18888888888 or +1-888-888-8888
</span>
</div>
<div class="input-group">
<button
class="iconified-button brand-button"
@click="updatePayoutData(false)"
>
<SaveIcon /> Save information
</button>
<button
v-if="enrolled"
class="iconified-button danger-button"
@click="updatePayoutData(true)"
>
<TrashIcon /> Remove enrollment
</button>
</div>
</div>
</template>
<template v-else>
<p>
You are currently enrolled in the Creator Monetization Program with a
{{ $formatWallet(selectedWallet) }} account.
</p>
<button class="iconified-button brand-button" @click="editing = true">
<EditIcon /> Edit information
</button>
</template>
</section>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import ChartIcon from '~/assets/images/utils/chart.svg?inline'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
export default {
components: {
Multiselect,
Chips,
SaveIcon,
TrashIcon,
EditIcon,
ChartIcon,
SettingsIcon,
},
data() {
return {
editing: false,
enrolled:
this.$auth.user.payout_data.payout_wallet &&
this.$auth.user.payout_data.payout_wallet_type &&
this.$auth.user.payout_data.payout_address,
wallets: ['paypal', 'venmo'],
selectedWallet: this.$auth.user.payout_data.payout_wallet ?? 'paypal',
accountType:
this.$auth.user.payout_data.payout_wallet_type ??
this.getAccountTypes()[0],
account: this.$auth.user.payout_data.payout_address ?? '',
}
},
head: {
title: 'Monetization settings - Modrinth',
},
methods: {
getAccountTypes() {
const types = []
if (this.selectedWallet === 'venmo') {
types.push('user_handle')
}
types.push('email')
types.push('phone')
return types
},
formatAccountType(value) {
switch (value) {
case 'email':
return 'Email address'
case 'phone':
return 'Phone number'
case 'user_handle':
return 'Username'
default:
return value.charAt(0).toUpperCase() + value.slice(1)
}
},
onChangeWallet() {
this.account = ''
// Set default account type for each wallet
if (this.selectedWallet === 'paypal') {
this.accountType = 'email'
} else if (this.selectedWallet === 'venmo') {
this.accountType = 'user_handle'
}
},
async updatePayoutData(unenroll) {
this.$nuxt.$loading.start()
if (unenroll) {
this.selectedWallet = 'paypal'
this.accountType = this.getAccountTypes()[0]
this.account = ''
}
try {
const data = {
payout_data: unenroll
? null
: {
payout_wallet: this.selectedWallet,
payout_wallet_type: this.accountType,
payout_address: this.account,
},
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
this.editing = false
this.enrolled = !unenroll
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped></style>

View File

@ -1,86 +0,0 @@
<template>
<div>
<section class="card essentials pad-maker">
<h3>Revoke your Modrinth token</h3>
<p>
Revoking your Modrinth token can have unintended consequences. Please be
aware that the following could break:
</p>
<ul>
<li>Any application that uses your token to access the API.</li>
<li>
Gradle - if Minotaur is given a incorrect token, your Gradle builds
could fail.
</li>
<li>
GitHub - if you use a GitHub action that uses the Modrinth API, it
will cause errors.
</li>
</ul>
<p>If you are willing to continue, complete the following steps:</p>
<ol>
<li>
<a
href="https://github.com/settings/connections/applications/3acffb2e808d16d4b226"
target="_blank"
>
Head to the Modrinth Application page on GitHub.
</a>
Make sure to be logged into the GitHub account you used for Modrinth!
</li>
<li>
Press the big red "Revoke Access" button next to the "Permissions"
header.
</li>
</ol>
<p>
Once you have completed those steps, press the continue button below.
</p>
<p>
<strong>
This will log you out of Modrinth, however, when you log back in, your
token will be regenerated.
</strong>
</p>
<button class="iconified-button brand-button-colors" @click="logout">
<CheckIcon />
Continue
</button>
</section>
</div>
</template>
<script>
import CheckIcon from '~/assets/images/utils/right-arrow.svg?inline'
export default {
components: {
CheckIcon,
},
head: {
title: 'Revoke Token - Modrinth',
},
methods: {
async logout() {
this.$cookies.set('auth-token-reset', true)
await this.$router.replace(
`auth/init?url=${process.env.domain}${this.$route.fullPath}`
)
},
},
}
</script>
<style lang="scss" scoped>
section {
a {
color: var(--color-link);
text-decoration: underline;
}
button {
padding: 0.5rem 0.75rem;
margin-left: auto;
}
}
</style>

View File

@ -1,130 +0,0 @@
<template>
<div>
<ConfirmPopup
ref="delete_popup"
title="Are you sure you want to delete your account?"
description="If you proceed, your user and all attached data will be removed from our
servers. This cannot be reversed, so be careful!"
proceed-label="Delete account"
:confirmation-text="$auth.user.username"
:has-to-type="true"
@proceed="deleteAccount"
/>
<section class="card">
<div class="header">
<h2 class="title">Security settings</h2>
</div>
<label>
<span>
<h3>Authorization token</h3>
<span>
Your authorization token can be used with the Modrinth API, the
Minotaur Gradle plugin, and other applications that interact with
Modrinth's API. Be sure to keep this secret!
</span>
</span>
<input
type="button"
class="iconified-button"
value="Copy to clipboard"
@click="copyToken"
/>
</label>
<label>
<span>
<h3>Revoke your token</h3>
<span
>This will log you out of Modrinth, and you will have to log in
again to access Modrinth with a new token.</span
>
</span>
<input
type="button"
class="iconified-button"
value="Revoke token"
@click="$router.replace('/settings/revoke-token')"
/>
</label>
<label>
<span>
<h3>Delete your account</h3>
<span
>Clicking on this WILL delete your account. Do not click on this
unless you want your account deleted. If you delete your account,
all attached data, including projects, will be removed from our
servers. This cannot be reversed, so be careful!</span
>
</span>
<input
value="Delete account"
type="button"
class="iconified-button"
@click="$refs.delete_popup.show()"
/>
</label>
</section>
</div>
</template>
<script>
import ConfirmPopup from '~/components/ui/ConfirmPopup'
export default {
components: { ConfirmPopup },
head: {
title: 'Security - Modrinth',
},
methods: {
async copyToken() {
await navigator.clipboard.writeText(this.$auth.token)
this.$notify({
group: 'main',
title: 'Copied to clipboard.',
text: 'Copied your Modrinth token to the clipboard.',
type: 'success',
})
},
async deleteAccount() {
this.$nuxt.$loading.start()
try {
await this.$axios.delete(
`user/${this.$auth.user.id}`,
this.$defaultHeaders()
)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style lang="scss" scoped>
.card span {
margin-bottom: 1rem;
}
.header {
display: flex;
align-items: center;
padding-bottom: 1rem;
grid-area: header;
.title {
flex-grow: 1;
margin: 0;
}
.controls {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
}
</style>

View File

@ -1,13 +1,96 @@
<template>
<div>
<ModalCreation ref="modal_creation" />
<ModalReport ref="modal_report" :item-id="user.id" item-type="user" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar
:src="previewImage ? previewImage : user.avatar_url"
size="md"
circle
:alt="user.username"
/>
<h1 class="username">{{ user.username }}</h1>
</div>
</div>
<div class="normal-page">
<div class="normal-page__sidebar">
<aside class="card sidebar">
<img
class="sidebar__item profile-picture"
:src="user.avatar_url"
:alt="user.username"
<h1 class="mobile-username">{{ user.username }}</h1>
<div class="card__overlay">
<FileInput
v-if="isEditing"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image"
prompt="Upload avatar"
@change="showPreviewImage"
/>
<h1 class="sidebar__item username">{{ user.username }}</h1>
<button
v-else-if="$auth.user && $auth.user.id === user.id"
class="iconified-button"
@click="isEditing = true"
>
<EditIcon />
Edit
</button>
<button
v-else-if="$auth.user"
class="iconified-button"
@click="$refs.modal_report.show()"
>
<ReportIcon aria-hidden="true" />
Report
</button>
<a v-else class="iconified-button" :href="authUrl">
<ReportIcon aria-hidden="true" />
Report
</a>
</div>
<template v-if="isEditing">
<div class="inputs universal-labels">
<label for="user-username"
><span class="label__title">Username</span></label
>
<input
id="user-username"
v-model="user.username"
maxlength="39"
type="text"
/>
<label for="user-bio"
><span class="label__title">Bio</span></label
>
<div class="textarea-wrapper">
<textarea
id="user-bio"
v-model="user.bio"
maxlength="160"
></textarea>
</div>
</div>
<div class="button-group">
<button
class="iconified-button"
@click="
isEditing = false
user = JSON.parse(JSON.stringify($auth.user))
previewImage = null
icon = null
"
>
<CrossIcon /> Cancel
</button>
<button
class="iconified-button brand-button"
@click="saveChanges"
>
<SaveIcon /> Save
</button>
</div>
</template>
<template v-else>
<div class="sidebar__item">
<Badge v-if="user.role === 'admin'" type="admin" color="red" />
<Badge
@ -17,18 +100,24 @@
/>
<Badge v-else type="developer" color="green" />
</div>
<span v-if="user.bio" class="sidebar__item bio">{{
user.bio
}}</span>
<hr class="card-divider" />
<h3 class="sidebar__item">About me</h3>
<span v-if="user.bio" class="sidebar__item bio">{{ user.bio }}</span>
<a
:href="githubUrl"
target="_blank"
class="sidebar__item report-button iconified-button"
>
<GitHubIcon aria-hidden="true" />
View GitHub profile
</a>
<div class="sidebar__item stats-block">
<div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">{{ sumDownloads() }}</span>
<span class="primary-stat__label">downloads</span>
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">{{ sumFollows() }}</span>
<span class="primary-stat__label">followers of projects</span>
</div>
</div>
<div class="stats-block__item secondary-stat">
<SunriseIcon class="secondary-stat__icon" aria-hidden="true" />
<span
@ -40,54 +129,60 @@
Joined {{ $dayjs(user.created).fromNow() }}
</span>
</div>
<hr class="card-divider" />
<div class="stats-block__item secondary-stat">
<UserIcon class="secondary-stat__icon" aria-hidden="true" />
<span class="secondary-stat__text">User ID: {{ user.id }}</span>
<span class="secondary-stat__text">
User ID: <CopyCode :text="user.id" />
</span>
</div>
</div>
<div class="sidebar__item stats-block">
<div class="stats-block__item primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<span class="primary-stat__counter">{{ sumDownloads() }}</span>
<span class="primary-stat__label">downloads</span>
</div>
</div>
</div>
<template
v-if="!$auth.user || ($auth.user && $auth.user.id !== user.id)"
<a
:href="githubUrl"
target="_blank"
class="sidebar__item github-button iconified-button"
>
<hr class="card-divider" />
<nuxt-link
:to="`/create/report?id=${user.id}&t=user`"
class="sidebar__item report-button iconified-button"
>
<ReportIcon aria-hidden="true" />
Report
</nuxt-link>
<GitHubIcon aria-hidden="true" />
View GitHub profile
</a>
</template>
</aside>
</div>
<div class="normal-page__content">
<Advertisement />
<Advertisement
type="banner"
small-screen="square"
ethical-ads-small
ethical-ads-big
/>
<nav class="card user-navigation">
<ThisOrThat v-model="selectedProjectType" :items="projectTypes" />
<nuxt-link
<NavRow
query="type"
:links="[
{
label: 'all',
href: '',
},
...projectTypes.map((x) => {
return {
label: x === 'resourcepack' ? 'Resource Packs' : x + 's',
href: x,
}
}),
]"
/>
<button
v-if="$auth.user && $auth.user.id === user.id"
to="/create/project"
class="iconified-button brand-button-colors"
class="iconified-button brand-button"
@click="$refs.modal_creation.show()"
>
<PlusIcon />
Create a project
</nuxt-link>
</button>
</nav>
<div v-if="projects.length > 0">
<ProjectCard
v-for="project in selectedProjectType !== 'all'
? projects.filter(
(x) =>
x.project_type === convertProjectType(selectedProjectType)
)
v-for="project in $route.query.type !== undefined
? projects.filter((x) => x.project_type === $route.query.type)
: projects"
:id="project.slug || project.id"
:key="project.id"
@ -101,7 +196,12 @@
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:status="project.status"
:status="
$auth.user &&
($auth.user.role === 'admin' || $auth.user.role === 'moderator')
? project.status
: null
"
:type="project.project_type"
>
<nuxt-link
@ -118,21 +218,22 @@
</div>
<div v-else class="error">
<UpToDate class="icon" /><br />
<span v-if="$auth.user && $auth.user.id === user.id" class="text"
>You don't have any projects.<br />
<span v-if="$auth.user && $auth.user.id === user.id" class="text">
You don't have any projects.<br />
Would you like to
<nuxt-link class="link" to="/create/project">create one</nuxt-link
>?</span
>
<a class="link" @click.prevent="$refs.modal_creation.show()">
create one</a
>?
</span>
<span v-else class="text">This user has no projects!</span>
</div>
</div>
</div>
</div>
</template>
<script>
import ProjectCard from '~/components/ui/ProjectCard'
import ThisOrThat from '~/components/ui/ThisOrThat'
import Badge from '~/components/ui/Badge'
import Advertisement from '~/components/ads/Advertisement'
@ -144,10 +245,26 @@ import SettingsIcon from '~/assets/images/utils/settings.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?inline'
import UserIcon from '~/assets/images/utils/user.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import HeartIcon from '~/assets/images/utils/heart.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import FileInput from '~/components/ui/FileInput'
import ModalReport from '~/components/ui/ModalReport'
import ModalCreation from '~/components/ui/ModalCreation'
import NavRow from '~/components/ui/NavRow'
import CopyCode from '~/components/ui/CopyCode'
import Avatar from '~/components/ui/Avatar'
export default {
auth: false,
components: {
Avatar,
CopyCode,
NavRow,
ModalCreation,
ModalReport,
FileInput,
ProjectCard,
SunriseIcon,
DownloadIcon,
@ -156,10 +273,13 @@ export default {
Badge,
SettingsIcon,
PlusIcon,
ThisOrThat,
UpToDate,
UserIcon,
EditIcon,
Advertisement,
HeartIcon,
CrossIcon,
SaveIcon,
},
async asyncData(data) {
try {
@ -173,6 +293,12 @@ export default {
])
).map((it) => it.data)
if (user.username !== data.params.id) {
data.redirect(301, `/user/${user.username}`)
return
}
const [gitHubUser, versions] = (
await Promise.all([
data.$axios.get(`https://api.github.com/user/` + user.github_id),
@ -213,7 +339,6 @@ export default {
}
return {
selectedProjectType: 'all',
user,
projects,
githubUrl: gitHubUser.html_url,
@ -225,6 +350,13 @@ export default {
})
}
},
data() {
return {
isEditing: false,
icon: null,
previewImage: null,
}
},
head() {
return {
title: this.user.username + ' - Modrinth',
@ -247,19 +379,12 @@ export default {
{
hid: 'og:description',
name: 'og:description',
content: this.user.bio,
content: `${this.user.bio} - Download ${this.user.username}'s projects on Modrinth`,
},
{
hid: 'description',
name: 'description',
content:
this.user.bio +
' - View Minecraft mods on Modrinth today! Modrinth is a new and modern Minecraft modding platform.',
},
{
hid: 'og:url',
name: 'og:url',
content: `https://modrinth.com/user/${this.user.id}`,
content: `${this.user.bio} - Download ${this.user.username}'s projects on Modrinth`,
},
{
hid: 'og:image',
@ -271,28 +396,20 @@ export default {
}
},
computed: {
authUrl() {
return `${process.env.authURLBase}auth/init?url=${process.env.domain}${this.$route.path}`
},
projectTypes() {
const obj = { all: true }
const obj = {}
for (const project of this.projects) {
if (project.project_type === 'resourcepack') {
obj['Resource Packs'] = true
} else {
obj[project.project_type + 's'] = true
}
obj[project.project_type] = true
}
return Object.keys(obj)
},
},
methods: {
convertProjectType(name) {
if (name === 'Resource Packs') {
return 'resourcepack'
} else {
return name.slice(0, -1)
}
},
sumDownloads() {
let sum = 0
@ -302,11 +419,105 @@ export default {
return this.$formatNumber(sum)
},
sumFollows() {
let sum = 0
for (const projects of this.projects) {
sum += projects.followers
}
return this.$formatNumber(sum)
},
showPreviewImage(files) {
const reader = new FileReader()
this.icon = files[0]
reader.readAsDataURL(this.icon)
reader.onload = (event) => {
this.previewImage = event.target.result
}
},
async saveChanges() {
this.$nuxt.$loading.start()
try {
if (this.icon) {
await this.$axios.patch(
`user/${this.$auth.user.id}/icon?ext=${
this.icon.type.split('/')[this.icon.type.split('/').length - 1]
}`,
this.icon,
this.$defaultHeaders()
)
}
const data = {
email: this.user.email,
bio: this.user.bio,
}
if (this.user.username !== this.$auth.user.username) {
data.username = this.user.username
}
await this.$axios.patch(
`user/${this.$auth.user.id}`,
data,
this.$defaultHeaders()
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
})
this.isEditing = false
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
},
}
</script>
<style scoped>
<style lang="scss" scoped>
.user-header-wrapper {
display: flex;
margin: 0 auto -1.5rem;
max-width: 80rem;
.user-header {
position: relative;
z-index: 4;
display: flex;
width: 100%;
padding: 0 1rem;
gap: 1rem;
align-items: center;
.username {
display: none;
font-size: 2rem;
margin-bottom: 2.5rem;
}
}
}
.mobile-username {
margin: 0.25rem 0;
}
@media screen and (min-width: 501px) {
.mobile-username {
display: none;
}
.user-header-wrapper .user-header .username {
display: block;
}
}
.user-navigation {
align-items: center;
display: flex;
@ -314,6 +525,11 @@ export default {
flex-wrap: wrap;
row-gap: 0.5rem;
}
.sidebar {
padding-top: 2.5rem;
}
.sidebar__item:not(:last-child) {
margin: 0 0 0.75rem 0;
}
@ -328,22 +544,14 @@ export default {
font-size: var(--font-size-xl);
}
.report-button {
display: inline-flex;
}
.bio {
display: block;
}
.stats-block__item {
margin-bottom: 0.25rem;
}
.secondary-stat {
align-items: center;
color: var(--color-text-secondary);
display: flex;
margin-bottom: 0.8rem;
}
.secondary-stat__icon {
@ -352,29 +560,58 @@ export default {
}
.secondary-stat__text {
margin-left: 0.25rem;
margin-left: 0.4rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.primary-stat {
align-items: center;
display: flex;
margin-bottom: 0.6rem;
}
.primary-stat__icon {
height: 1.25rem;
width: 1.25rem;
height: 1rem;
width: 1rem;
}
.primary-stat__text {
margin-left: 0.25rem;
margin-left: 0.4rem;
}
.primary-stat__counter {
font-size: var(--font-size-lg);
font-size: var(--font-size-md);
font-weight: bold;
}
.date {
cursor: default;
}
.github-button {
display: inline-flex;
}
.inputs {
margin-bottom: 1rem;
input {
margin-top: 0.5rem;
width: 100%;
}
label {
margin-bottom: 0;
}
}
.button-group:first-child {
margin-left: auto;
}
.textarea-wrapper {
height: 10rem;
}
</style>

View File

@ -19,7 +19,9 @@ export default (ctx, inject) => {
inject('formatVersion', (versionsArray) =>
formatVersions(versionsArray, ctx.store)
)
inject('orElse', (first, otherwise) => first ?? otherwise)
inject('formatBytes', formatBytes)
inject('formatWallet', formatWallet)
inject('formatProjectType', formatProjectType)
inject('formatCategory', formatCategory)
inject('formatCategoryHeader', formatCategoryHeader)
@ -192,9 +194,16 @@ export const formatBytes = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export const formatWallet = (name) => {
if (name === 'paypal') {
return 'PayPal'
}
return name.charAt(0).toUpperCase() + name.slice(1)
}
export const formatProjectType = (name) => {
if (name === 'resourcepack') {
return 'resource pack'
return 'Resource Pack'
}
return name.charAt(0).toUpperCase() + name.slice(1)
}

View File

@ -1 +0,0 @@
google.com, pub-4615302805870170, DIRECT, f08c47fec0942fa0

BIN
static/favicon-light.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -30,6 +30,11 @@ export const actions = {
})
).data
if (user.payout_data && user.payout_data.balance) {
user.payout_data.balance =
Math.floor(user.payout_data.balance * 100) / 100
}
commit('SET_USER', user)
commit('SET_TOKEN', token)
commit('SET_HEADERS', {

View File

@ -9,6 +9,8 @@ const parameters = {
export const state = () => ({
searchLayout: false,
projectLayout: false,
modpacksAlphaNotice: true,
advancedRendering: true,
notUsingBlockers: false,
})
@ -19,6 +21,12 @@ export const mutations = {
SET_PROJECT_LAYOUT(state, projectLayout) {
state.projectLayout = projectLayout
},
SET_MODPACKS_ALPHA_NOTICE(state, modpacksAlphaNotice) {
state.modpacksAlphaNotice = modpacksAlphaNotice
},
SET_ADVANCED_RENDERING(state, advancedRendering) {
state.advancedRendering = advancedRendering
},
SET_NOT_USING_BLOCKERS(state, notUsingBlockers) {
state.notUsingBlockers = notUsingBlockers
},
@ -28,12 +36,27 @@ export const actions = {
fetchCosmetics({ commit }, $cookies) {
commit('SET_PROJECT_LAYOUT', $cookies.get('project-layout'))
commit('SET_SEARCH_LAYOUT', $cookies.get('search-layout'))
commit('SET_MODPACKS_ALPHA_NOTICE', $cookies.get('modpacks-alpha-notice'))
commit('SET_ADVANCED_RENDERING', $cookies.get('advanced-rendering'))
},
save({ commit }, { projectLayout, searchLayout, $cookies }) {
save(
{ commit },
{
projectLayout,
searchLayout,
modpacksAlphaNotice,
advancedRendering,
$cookies,
}
) {
commit('SET_PROJECT_LAYOUT', projectLayout)
commit('SET_SEARCH_LAYOUT', searchLayout)
commit('SET_MODPACKS_ALPHA_NOTICE', modpacksAlphaNotice)
commit('SET_ADVANCED_RENDERING', advancedRendering)
$cookies.set('project-layout', projectLayout, parameters)
$cookies.set('search-layout', searchLayout, parameters)
$cookies.set('modpacks-alpha-notice', modpacksAlphaNotice, parameters)
$cookies.set('advanced-rendering', advancedRendering, parameters)
},
}

View File

@ -1,9 +1,34 @@
import tags from '~/generated/state.json'
export const state = () => ({
categories: [],
loaders: [],
gameVersions: [],
licenses: [],
donationPlatforms: [],
categories: tags.categories,
loaders: tags.loaders,
gameVersions: tags.gameVersions,
licenses: tags.licenses,
donationPlatforms: tags.donationPlatforms,
reportTypes: tags.reportTypes,
projectTypes: [
{
actual: 'mod',
id: 'mod',
display: 'mod',
},
{
actual: 'mod',
id: 'plugin',
display: 'plugin',
},
{
actual: 'resourcepack',
id: 'resourcepack',
display: 'resource pack',
},
{
actual: 'modpack',
id: 'modpack',
display: 'modpack',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge'],
pluginPlatformLoaders: ['bungeecord', 'waterfall', 'velocity'],
@ -20,49 +45,3 @@ export const state = () => ({
modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift'],
},
})
export const mutations = {
SET_CATEGORIES(state, categories) {
state.categories = categories
},
SET_LOADERS(state, loaders) {
state.loaders = loaders
},
SET_GAME_VERSIONS(state, gameVersions) {
state.gameVersions = gameVersions
},
SET_LICENSES(state, licenses) {
state.licenses = licenses
},
SET_DONATION_PLATFORMS(state, donationPlatforms) {
state.donationPlatforms = donationPlatforms
},
}
export const actions = {
async fetchAllTags({ commit }) {
const headers = {
headers: {
'x-ratelimit-key': process.server
? process.env.RATE_LIMIT_IGNORE_KEY || ''
: '',
},
}
const [categories, loaders, gameVersions, licenses, donationPlatforms] = (
await Promise.all([
this.$axios.get(`tag/category`, headers),
this.$axios.get(`tag/loader`, headers),
this.$axios.get(`tag/game_version`, headers),
this.$axios.get(`tag/license`, headers),
this.$axios.get(`tag/donation_platform`, headers),
])
).map((it) => it.data)
commit('SET_CATEGORIES', categories)
commit('SET_LOADERS', loaders)
commit('SET_GAME_VERSIONS', gameVersions)
commit('SET_LICENSES', licenses)
commit('SET_DONATION_PLATFORMS', donationPlatforms)
},
}

View File

@ -2,7 +2,7 @@
"builds": [
{
"src": "nuxt.config.js",
"use": "@nuxtjs/vercel-builder",
"use": "@nuxtjs/vercel-builder@0.21.3",
"config": {}
}
],