Migrate to Nuxt 3 (#933)

* Migrate to Nuxt 3

* Update vercel config

* remove tsconfig comment

* Changelog experiment + working proj pages

* Fix package json

* Prevent vercel complaining

* fix deploy (hopefully)

* Tag generator

* Switch to yarn

* Vercel pls 🙏

* Fix tag generation bug

* Make (most) non-logged in pages work

* fix base build

* Linting + state

* Eradicate axios, make most user pages work

* Fix checkbox state being set incorrectly

* Make most things work

* Final stretch

* Finish (most) things

* Move to update model value

* Fix modal text getting blurred from transforms (#964)

* Adjust nav-link border radius when focused (#961)

* Transition between animation states on TextLogo (#955)

* Transition between animation states on TextLogo

* Remove unused refs

* Fixes from review

* Disable tabbing to pagination arrows when disabled (#972)

* Make position of the "no results" text on grid/gallery views consistent (fixes #963) (#965)

* Fix position of the "no results" text on grid view

* fix padding

* Remove extra margin on main page, fixes #957 (#959)

* Fix layout shift and placeholder line height (#973)

* Fix a lot of issues

* Fix more nuxt 3 issues

* fix not all versions showing up (temp)

* inline inter css file

* More nuxt 3 fixes

* [skip ci] broken- backup changes

* Change modpack warnings to blue instead of red (#991)

* Fix some hydration issues

* Update nuxt

* Fix some images not showing

* Add pagination to versions page + fix lag

* Make changelog page consistent with versions page

* sync before merge

* Delete old file

* Fix actions failing

* update branch

* Fixes navbar transition animation. (#1012)

* Fixes navbar transition animation.

* Fixes Y-axis animation. Fixes mobile menu. Removes highlightjs prop.

* Changes xss call to renderString.

* Fixes renderString call.

* Removes unnecessary styling.

* Reverts mobile nav change.

* Nuxt 3 Lazy Loading Search (#1022)

* Uses lazyFetch for results. onSearchChange refreshes. Adds loading circle.

* Removes console.log

* Preserves old page when paging.

* Diagnosing filtering bugs.

* Fix single facet filtering

* Implements useAuth in settings/account.

* tiny ssr fix

* Updating nuxt.config checklist.

* Implements useAuth in revenue, moneitzation, and dashboard index pages.

* Fixes setups.

* Eliminates results when path changes. Adds animated logo.

* Ensures loading animation renders on search page.

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>

* Fix navigation issues

* Square button fix (#1023)

* Removes checklist from nuxt.config.

* Modifies Nuxt CI to build after linting.

* Fixes prettierignore file.

* bug fixes

* Update whitelist domains

* Page improvements, fix CLS

* Fix a lot of things

* Fix project type redirect

* Fix 404 errors

* Fix user settings + hydration error

* Final fixes

* fix(creator-section): border radius on icons not aligning with bg (#1027)

Co-authored-by: MagnusHJensen <magnus.holm.jensen@lego.dk>

* Improvements to the mobile navbar (#984)

* Transition between animation states on TextLogo

* Remove unused refs

* Fixes from review

* Improvements to the mobile nav menu

* fix avatar alt text

* Nevermind, got confused for a moment

* Tab bar, menu layout improvements

* Highlight search icon when menu is open

* Update layouts/default.vue

Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>

* Fix some issues

* Use caret instead

* Run prettier

* Add create a project

---------

Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>

* Fix mobile menu issues

* More issues

* Fix lint

---------

Co-authored-by: Kaeden Murphy <kmurphy@kaedenmurphy.dev>
Co-authored-by: triphora <emmaffle@modrinth.com>
Co-authored-by: Zach Baird <30800863+ZachBaird@users.noreply.github.com>
Co-authored-by: stairman06 <36215135+stairman06@users.noreply.github.com>
Co-authored-by: Zachary Baird <zdb1994@yahoo.com>
Co-authored-by: Magnus Jensen <magnushjensen.mail@gmail.com>
Co-authored-by: MagnusHJensen <magnus.holm.jensen@lego.dk>
This commit is contained in:
Geometrically
2023-03-09 10:05:32 -07:00
committed by GitHub
parent 5638f0f24b
commit 740357d120
145 changed files with 12371 additions and 37478 deletions

View File

@@ -1,4 +1,3 @@
# editorconfig.org
root = true root = true
[*] [*]
@@ -8,6 +7,7 @@ end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
max_line_length = 100
[*.md] [*.md]
trim_trailing_whitespace = false trim_trailing_whitespace = false

View File

@@ -1,2 +0,0 @@
node_modules/
jspm_packages/

View File

@@ -1,22 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
},
parserOptions: {
parser: 'babel-eslint',
},
extends: [
'@nuxtjs',
'prettier',
'plugin:prettier/recommended',
'plugin:nuxt/recommended',
],
plugins: ['prettier'],
rules: {
'no-console': 'off',
'vue/no-v-html': 'off',
'import/no-named-as-default': 'off',
},
}

27
.eslintrc.json Normal file
View File

@@ -0,0 +1,27 @@
{
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:vue/vue3-recommended",
"plugin:@typescript-eslint/recommended",
"@nuxtjs/eslint-config-typescript",
"prettier"
],
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": ["vue", "@typescript-eslint"],
"rules": {
"no-console": "off",
"vue/no-v-html": "off",
"comma-dangle": ["error", "only-multiline"],
"vue/multi-word-component-names": "off",
"import/no-named-as-default": "off"
}
}

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
*.vue text eol=lf

View File

@@ -1,4 +1,4 @@
name: Nuxt CI name: Build + Lint
on: on:
push: push:
@@ -15,18 +15,19 @@ jobs:
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 18.x
- name: Cache Node.js modules - name: Get yarn cache
uses: actions/cache@v3 id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v3
with: with:
# npm cache files are stored in `~/.npm` on Linux/macOS path: ${{ steps.yarn-cache.outputs.dir }}
path: ~/.npm key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node- ${{ runner.os }}-yarn-
- name: Install dependencies - name: Install dependencies
run: npm ci run: yarn install --immutable --immutable-cache --check-cache
- name: Build Knossos
run: npm run build
- name: Run Lint - name: Run Lint
run: npm run lint run: npm run lint
- name: Build
run: npm run build

29
.gitignore vendored
View File

@@ -1,3 +1,12 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
generated/ generated/
!.gitkeep !.gitkeep
@@ -5,7 +14,6 @@ generated/
### Node template ### Node template
# Logs # Logs
/logs /logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
@@ -39,7 +47,6 @@ bower_components
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/
jspm_packages/ jspm_packages/
# TypeScript v1 declaration files # TypeScript v1 declaration files
@@ -60,24 +67,6 @@ typings/
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories # Serverless directories
.serverless .serverless

5
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

7
.idea/discord.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
</component>
</project>

12
.idea/knossos.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/knossos.iml" filepath="$PROJECT_DIR$/.idea/knossos.iml" />
</modules>
</component>
</project>

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false

View File

@@ -1,12 +1,25 @@
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
*.md
generated/
!.gitkeep
# Created by .ignore support plugin (hsz.mobi) # Created by .ignore support plugin (hsz.mobi)
### Node template ### Node template
# Logs # Logs
/logs /logs
*.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Runtime data # Runtime data
pids pids
*.pid *.pid
@@ -35,7 +48,6 @@ bower_components
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/
jspm_packages/ jspm_packages/
# TypeScript v1 declaration files # TypeScript v1 declaration files
@@ -56,24 +68,6 @@ typings/
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories # Serverless directories
.serverless .serverless
@@ -89,4 +83,6 @@ sw.*
# Vim swap files # Vim swap files
*.swp *.swp
Dockerfile # pnpm files
pnpm-lock.yaml
/.npmrc

View File

@@ -1,4 +1,5 @@
{ {
"printWidth": 100,
"semi": false, "semi": false,
"singleQuote": true, "singleQuote": true,
"endOfLine": "auto" "endOfLine": "auto"

View File

@@ -1,26 +0,0 @@
# Dockerfile
FROM node:14.16.0-alpine
# update and install dependency
RUN apk update && apk upgrade
RUN apk add git
# create destination directory
RUN mkdir -p /usr/src/knossos
WORKDIR /usr/src/knossos
# copy the app, note .dockerignore
COPY . /usr/src/knossos/
RUN npm ci
ARG VERSION_ID=unknown
RUN npm run build
EXPOSE 3000
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
ENTRYPOINT [ "npm", "start" ]

11
app.vue Normal file
View File

@@ -0,0 +1,11 @@
<template>
<NuxtLayout>
<ModrinthLoadingIndicator />
<Notifications />
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator'
import Notifications from '~/components/ui/Notifications'
</script>

View File

@@ -1,11 +0,0 @@
export default function (to, from, savedPosition) {
if (
from == null ||
(to.name.startsWith('type-id') && from.name.startsWith('type-id')) ||
to.name === from.name
) {
return savedPosition
} else {
return { x: 0, y: 0 }
}
}

View File

@@ -1,91 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg <svg
width="48"
height="48"
viewBox="0 0 12.7 12.7" viewBox="0 0 12.7 12.7"
version="1.1" version="1.1"
id="svg3606"
sodipodi:docname="org.prismlauncher.PrismLauncher.Source.svg"
inkscape:version="1.2.1 (9c6d41e410, 2022-07-14)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg" >
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" <title id="title261">Prism Launcher Logo</title>
xmlns:cc="http://creativecommons.org/ns#" <defs id="defs3603" />
xmlns:dc="http://purl.org/dc/elements/1.1/"> <g id="layer1">
<title
id="title261">Prism Launcher Logo</title>
<sodipodi:namedview
id="namedview3608"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="false"
showguides="true"
inkscape:zoom="16"
inkscape:cx="14.9375"
inkscape:cy="13.9375"
inkscape:window-width="2560"
inkscape:window-height="1377"
inkscape:window-x="2552"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<sodipodi:guide
position="0.52916688,12.170833"
orientation="1,0"
id="guide4870"
inkscape:locked="false" />
<sodipodi:guide
position="12.170833,12.170833"
orientation="0,-1"
id="guide4872"
inkscape:locked="false" />
<sodipodi:guide
position="12.170833,0.5291669"
orientation="1,0"
id="guide4874"
inkscape:locked="false" />
<sodipodi:guide
position="0.52916688,0.5291666"
orientation="0,-1"
id="guide4876"
inkscape:locked="false" />
<sodipodi:guide
position="13.692187,21.332031"
orientation="0,-1"
id="guide6489"
inkscape:locked="false" />
<sodipodi:guide
position="6.3500002,12.170833"
orientation="1,0"
id="guide6491"
inkscape:locked="false" />
<sodipodi:guide
position="6.3500002,6.3499993"
orientation="-0.49999657,-0.86602738"
id="guide9375"
inkscape:locked="false" />
<sodipodi:guide
position="6.3500002,6.3499993"
orientation="-0.49999666,0.86602733"
id="guide9377"
inkscape:locked="false" />
</sodipodi:namedview>
<defs
id="defs3603" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g <g
id="g531" id="g531"
transform="matrix(0.1353646,0,0,0.1353646,15.301582,0.52916663)" /> transform="matrix(0.1353646,0,0,0.1353646,15.301582,0.52916663)" />
@@ -157,47 +77,4 @@
</g> </g>
</g> </g>
</g> </g>
<metadata
id="metadata259">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:title>Prism Launcher Logo</dc:title>
<dc:date>19/10/2022</dc:date>
<dc:creator>
<cc:Agent>
<dc:title>Prism Launcher</dc:title>
</cc:Agent>
</dc:creator>
<dc:contributor>
<cc:Agent>
<dc:title>AutiOne, Boba, ely, Fulmine, gon sawa, Pankakes, tobimori, Zeke</dc:title>
</cc:Agent>
</dc:contributor>
<dc:source>https://github.com/PrismLauncher/PrismLauncher</dc:source>
<dc:publisher>
<cc:Agent>
<dc:title>Prism Launcher</dc:title>
</cc:Agent>
</dc:publisher>
<cc:license
rdf:resource="http://creativecommons.org/licenses/by-sa/4.0/" />
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/licenses/by-sa/4.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Notice" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#Attribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
<cc:requires
rdf:resource="http://creativecommons.org/ns#ShareAlike" />
</cc:License>
</rdf:RDF>
</metadata>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -71,9 +71,10 @@
.multiselect__placeholder { .multiselect__placeholder {
color: var(--color-button-text); color: var(--color-button-text);
margin-left: 8px; margin-left: 8px;
margin-bottom: 8px;
opacity: 0.6; opacity: 0.6;
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 16px;
} }
} }
@@ -186,14 +187,10 @@
z-index: 2; z-index: 2;
} }
&.warning { &:where(&.warning, &.information) {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem; padding: 1.5rem;
line-height: 1.5; line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0; min-height: 0;
a { a {
/* Uses active color to increase contrast */ /* Uses active color to increase contrast */
color: var(--color-link-active); color: var(--color-link-active);
@@ -201,8 +198,20 @@
} }
} }
&.warning {
border-left: 0.5rem solid var(--color-warning-banner-side);
background-color: var(--color-warning-banner-bg);
color: var(--color-warning-banner-text);
}
&.information {
border-left: 0.5rem solid var(--color-info-banner-side);
background-color: var(--color-info-banner-bg);
color: var(--color-info-banner-text);
}
&.moderation-card { &.moderation-card {
background-color: var(--color-banner-bg); background-color: var(--color-warning-banner-bg);
} }
} }
@@ -427,11 +436,11 @@
} }
} }
> .label:first-child :where(> :first-child, .label__title), //> .label:first-child :where(> :first-child, .label__title),
> label:first-child :where(> :first-child, .label__title), //> label:first-child :where(> :first-child, .label__title),
> .adjacent-input:first-child :where(> :first-child, .label__title) { //> .adjacent-input:first-child :where(> :first-child, .label__title) {
margin-block-start: 0; // margin-block-start: 0;
} //}
} }
.universal-card { .universal-card {
@@ -734,104 +743,25 @@
} }
} }
.tooltip { .v-popper--theme-tooltip {
display: block !important; .v-popper__inner {
z-index: 10000; background: var(--color-tooltip-bg) !important;
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out; color: var(--color-tooltip-text) !important;
padding: 5px 10px 4px !important;
.tooltip-inner { border-radius: var(--size-rounded-tooltip) !important;
background: var(--color-tooltip-bg); box-shadow: var(--shadow-floating) !important;
color: var(--color-tooltip-text); font-size: 0.9rem !important;
padding: 5px 10px 4px;
border-radius: var(--size-rounded-tooltip);
box-shadow: var(--shadow-floating);
font-size: 0.9rem;
} }
.tooltip-arrow { .v-popper__arrow-outer,
width: 0; .v-popper__arrow-inner {
height: 0; border-color: var(--color-tooltip-bg) !important;
border-style: solid;
position: absolute;
margin: 5px;
border-color: var(--color-tooltip-bg);
z-index: 1;
}
&[x-placement^='top'] {
margin-bottom: 5px;
.tooltip-arrow {
border-width: 5px 5px 0 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
bottom: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^='bottom'] {
margin-top: 5px;
.tooltip-arrow {
border-width: 0 5px 5px 5px;
border-left-color: transparent !important;
border-right-color: transparent !important;
border-top-color: transparent !important;
top: -5px;
left: calc(50% - 5px);
margin-top: 0;
margin-bottom: 0;
}
}
&[x-placement^='right'] {
margin-left: 5px;
.tooltip-arrow {
border-width: 5px 5px 5px 0;
border-left-color: transparent !important;
border-top-color: transparent !important;
border-bottom-color: transparent !important;
left: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[x-placement^='left'] {
margin-right: 5px;
.tooltip-arrow {
border-width: 5px 0 5px 5px;
border-top-color: transparent !important;
border-right-color: transparent !important;
border-bottom-color: transparent !important;
right: -5px;
top: calc(50% - 5px);
margin-left: 0;
margin-right: 0;
}
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
}
&[aria-hidden='false'] {
visibility: visible;
opacity: 1;
} }
} }
.button-animation { .button-animation {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out; outline 0.2s ease-in-out;
&:active:not(&:disabled) { &:active:not(&:disabled) {
transform: scale(0.95); transform: scale(0.95);
@@ -901,8 +831,8 @@ tr.button-transparent {
} }
.button-within { .button-within {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out; outline 0.2s ease-in-out;
&:focus-visible:not(&.disabled), &:focus-visible:not(&.disabled),
&:hover:not(&.disabled) { &:hover:not(&.disabled) {
@@ -990,6 +920,7 @@ tr.button-transparent {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
height: 2.25rem; height: 2.25rem;
width: 2.25rem; width: 2.25rem;
border-radius: var(--size-rounded-sm); border-radius: var(--size-rounded-sm);
@@ -1135,9 +1066,10 @@ tr.button-transparent {
.multiselect__placeholder { .multiselect__placeholder {
color: var(--color-button-text); color: var(--color-button-text);
margin-left: 8px; margin-left: 8px;
margin-bottom: 8px;
opacity: 0.6; opacity: 0.6;
font-size: 16px; font-size: 16px;
line-height: 20px; line-height: 16px;
} }
} }
@@ -1313,76 +1245,27 @@ tr.button-transparent {
z-index: 2; z-index: 2;
} }
&.warning { &:where(&.warning, &.information) {
border-left: 0.5rem solid var(--color-banner-side);
padding: 1.5rem; padding: 1.5rem;
line-height: 1.5; line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0; min-height: 0;
a { a {
/* Uses active color to increase contrast */ /* Uses active color to increase contrast */
color: var(--color-link-active); color: var(--color-link-active);
text-decoration: underline; text-decoration: underline;
} }
} }
}
.vue-notification { &.warning {
background: var(--color-special-blue) !important; border-left: 0.5rem solid var(--color-warning-banner-side);
border-left: 5px solid var(--color-special-blue) !important; background-color: var(--color-warning-banner-bg);
color: var(--color-brand-inverted) !important; color: var(--color-warning-banner-text);
&.success {
background: var(--color-special-green) !important;
border-left-color: var(--color-special-green) !important;
} }
&.warn { &.information {
background: var(--color-special-orange) !important; border-left: 0.5rem solid var(--color-info-banner-side);
border-left-color: var(--color-special-orange) !important; background-color: var(--color-info-banner-bg);
} color: var(--color-info-banner-text);
&.error {
background: var(--color-special-red) !important;
border-left-color: var(--color-special-red) !important;
}
}
.vue-notification-group {
right: 25px !important;
bottom: 25px !important;
.vue-notification-wrapper {
margin-bottom: 10px;
.vue-notification-template {
border-radius: var(--size-rounded-card);
margin: 0;
.notification-title {
font-size: var(--font-size-lg);
margin-right: auto;
}
.notification-content {
font-size: var(--font-size-md);
}
}
&:last-child {
margin: 0;
}
}
@media screen and (max-width: 750px) {
transition: bottom 0.25s ease-in-out;
bottom: calc(var(--size-mobile-navbar-height) + 10px) !important;
&.browse-menu-open {
bottom: calc(var(--size-mobile-navbar-height-expanded) + 10px) !important;
}
} }
} }
@@ -1449,7 +1332,7 @@ h1 {
font-weight: bold; font-weight: bold;
} }
.nuxt-link-exact-active, .router-link-exact-active,
h1, h1,
h2, h2,
h3 { h3 {

View File

@@ -67,9 +67,13 @@ html {
--color-warning-bg: hsl(355, 70%, 88%); --color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%); --color-warning-text: hsl(342, 70%, 35%);
--color-banner-text: hsl(0, 11%, 16%); --color-warning-banner-text: hsl(0, 11%, 16%);
--color-banner-bg: hsl(0, 100%, 95%); --color-warning-banner-bg: hsl(0, 100%, 95%);
--color-banner-side: hsl(357, 78%, 40%); --color-warning-banner-side: hsl(357, 78%, 40%);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-special-blue);
--color-block-quote: var(--color-tooltip-bg); --color-block-quote: var(--color-tooltip-bg);
--color-header-underline: var(--color-divider-dark); --color-header-underline: var(--color-divider-dark);
@@ -86,9 +90,8 @@ html {
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15), --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), 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); 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, --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) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px; --shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -112,11 +115,7 @@ html {
rgba(66, 71, 97, 0.34) 100% rgba(66, 71, 97, 0.34) 100%
); );
--landing-border-color: rgba(129, 137, 175, 0.55); --landing-border-color: rgba(129, 137, 175, 0.55);
--landing-creator-gradient: linear-gradient( --landing-creator-gradient: linear-gradient(180deg, #f8f8f8 0%, #f8f8f8 63.19%);
180deg,
#f8f8f8 0%,
#f8f8f8 63.19%
);
--landing-blob-gradient: radial-gradient( --landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%, 50% 50% at 50% 50%,
@@ -196,9 +195,13 @@ html {
--color-warning-bg: hsl(355, 70%, 20%); --color-warning-bg: hsl(355, 70%, 20%);
--color-warning-text: hsl(342, 70%, 75%); --color-warning-text: hsl(342, 70%, 75%);
--color-banner-text: hsl(0, 100%, 96%); --color-warning-banner-text: hsl(0, 100%, 96%);
--color-banner-bg: hsl(356, 18%, 18%); --color-warning-banner-bg: hsl(356, 18%, 18%);
--color-banner-side: hsl(357, 78%, 40%); --color-warning-banner-side: hsl(357, 78%, 40%);
--color-info-banner-text: var(--color-text);
--color-info-banner-bg: var(--color-ad);
--color-info-banner-side: var(--color-special-blue);
--color-block-quote: var(--color-code-bg); --color-block-quote: var(--color-code-bg);
--color-header-underline: var(--color-divider-dark); --color-header-underline: var(--color-divider-dark);
@@ -213,18 +216,13 @@ html {
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2); --shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1); --shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, --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) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px; --shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url('https://cdn.modrinth.com/landing/landing.png'); --landing-maze-bg: url('https://cdn.modrinth.com/landing/landing.png');
--landing-maze-gradient-bg: linear-gradient( --landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
0deg,
#31375f 0%,
rgba(8, 14, 55, 0) 100%
),
url('https://cdn.modrinth.com/landing/landing-lower.png'); url('https://cdn.modrinth.com/landing/landing-lower.png');
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%); --landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -251,8 +249,7 @@ html {
rgba(44, 48, 79, 0.35) 0%, rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100% rgba(32, 35, 50, 0.2695) 100%
); );
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15); --landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16); --landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -280,10 +277,9 @@ body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, --font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, --mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
Liberation Mono, monospace;
font-family: var(--font-standard); font-family: var(--font-standard);
font-size: 16px; font-size: 16px;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
@@ -436,11 +432,12 @@ kbd {
font-size: 0.85em !important; font-size: 0.85em !important;
} }
@import '~assets/styles/highlightjs.scss'; @import '~/assets/styles/highlightjs.scss';
@import '~assets/styles/layout.scss'; @import '~/assets/styles/layout.scss';
@import '~assets/styles/utils.scss'; @import '~/assets/styles/utils.scss';
@import '~assets/styles/components.scss'; @import '~/assets/styles/components.scss';
@import '~assets/styles/normalize.scss'; @import '~/assets/styles/normalize.scss';
@import '~/assets/styles/inter.scss';
button:focus-visible, button:focus-visible,
a:focus-visible, a:focus-visible,

40
assets/styles/inter.scss Normal file
View File

@@ -0,0 +1,40 @@
@font-face {
font-family: inter;
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Regular.woff2?v=3.19') format('woff2'),
url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Regular.woff?v=3.19') format('woff');
}
@font-face {
font-family: inter;
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Medium.woff2?v=3.19') format('woff2'),
url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Medium.woff?v=3.19') format('woff');
}
@font-face {
font-family: inter;
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('https://cdn-raw.modrinth.com/fonts/inter/Inter-SemiBold.woff2?v=3.19') format('woff2'),
url('https://cdn-raw.modrinth.com/fonts/inter/Inter-SemiBold.woff?v=3.19') format('woff');
}
@font-face {
font-family: inter;
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Bold.woff2?v=3.19') format('woff2'),
url('https://cdn-raw.modrinth.com/fonts/inter/Inter-Bold.woff?v=3.19') format('woff');
}
@font-face {
font-family: inter;
font-style: normal;
font-weight: 800;
font-display: swap;
src: url('https://cdn-raw.modrinth.com/fonts/inter/Inter-ExtraBold.woff2?v=3.19') format('woff2'),
url('https://cdn-raw.modrinth.com/fonts/inter/Inter-ExtraBold.woff?v=3.19') format('woff');
}

View File

@@ -47,6 +47,10 @@
'info' 'info'
/ 100%; / 100%;
@media screen and (max-width: 1024px) {
margin-top: var(--spacing-card-md);
}
.normal-page__sidebar { .normal-page__sidebar {
grid-area: sidebar; grid-area: sidebar;
} }

View File

@@ -5,51 +5,36 @@
<div class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-2"> <div class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-2">
<a <a
href="https://exaroton.com/?utm_source=modrinth&utm_medium=text&utm_campaign=host&utm_content=top" href="https://exaroton.com/?utm_source=modrinth&utm_medium=text&utm_campaign=host&utm_content=top"
rel="noopener noreferrer nofollow sponsored" rel="noopener nofollow sponsored"
target="_blank" target="_blank"
> >
<ColorScheme> <LightIcon
<LightIcon v-if="colorMode.value === 'light'"
v-if="$colorMode.value === 'light'" class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-3"
class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-3" />
/> <DarkIcon v-else class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-3" />
<DarkIcon v-else class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-3" />
</ColorScheme>
<span> <span>
<span> Host your Minecraft server on </span> <span> Host your Minecraft server on </span>
<strong>exaroton</strong> <strong>exaroton</strong>
<span> <span> - only pay while the server is running - billed per second. </span>
- only pay while the server is running - billed per second.
</span>
</span> </span>
</a> </a>
</div> </div>
</div> </div>
<div class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-4"> <div class="MYYLVTXBPUVWMLVBPVSDLHADDRYFBF-4">
<a <a rel="noopener sponsored" target="_blank" href="https://adrinth.com"> Ads via Adrinth </a>
rel="noopener noreferrer nofollow sponsored"
target="_blank"
href="https://adrinth.com"
>
Ads via Adrinth
</a>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script setup>
import LightIcon from '~/assets/images/external/exaroton-light.svg?inline' import LightIcon from '~/assets/images/external/exaroton-light.svg'
import DarkIcon from '~/assets/images/external/exaroton-dark.svg?inline' import DarkIcon from '~/assets/images/external/exaroton-dark.svg'
export default { const colorMode = useTheme()
components: {
LightIcon,
DarkIcon,
},
}
</script> </script>
<style lang="scss"> <style>
.MYYLVTXBPUVWMLVBPVSDLHADDRYFBF { .MYYLVTXBPUVWMLVBPVSDLHADDRYFBF {
position: relative; position: relative;
margin-bottom: var(--spacing-card-md); margin-bottom: var(--spacing-card-md);

View File

@@ -5,17 +5,9 @@
width="100%" width="100%"
height="100%" height="100%"
viewBox="0 0 590 591" viewBox="0 0 590 591"
version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
> >
<g transform="matrix(1,0,0,1,652.392,-0.400578)"> <g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)"> <g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
@@ -26,23 +18,16 @@
/> />
</g> </g>
</g> </g>
</g></svg </g>
><svg </svg>
<svg
class="rotate inner" class="rotate inner"
width="100%" width="100%"
height="100%" height="100%"
viewBox="0 0 590 591" viewBox="0 0 590 591"
version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
> >
<g transform="matrix(1,0,0,1,652.392,-0.400578)"> <g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)"> <g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">
@@ -59,17 +44,9 @@
width="100%" width="100%"
height="100%" height="100%"
viewBox="0 0 590 591" viewBox="0 0 590 591"
version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve" xml:space="preserve"
xmlns:serif="http://www.serif.com/" style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
stroke-miterlimit: 2;
"
> >
<g transform="matrix(1,0,0,1,652.392,-0.400578)"> <g transform="matrix(1,0,0,1,652.392,-0.400578)">
<g transform="matrix(4.16667,0,0,4.16667,-735.553,0)"> <g transform="matrix(4.16667,0,0,4.16667,-735.553,0)">

View File

@@ -7,7 +7,7 @@
stroke-miterlimit="2" stroke-miterlimit="2"
clip-rule="evenodd" clip-rule="evenodd"
viewBox="0 0 3307 593" viewBox="0 0 3307 593"
:class="{ animate: $nuxt.$loading ? $nuxt.$loading.show : false }" :class="{ animate: loading }"
> >
<path <path
fill-rule="nonzero" fill-rule="nonzero"
@@ -30,6 +30,10 @@
</svg> </svg>
</template> </template>
<script setup>
const loading = useLoading()
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.animate { .animate {
.ring { .ring {

View File

@@ -2,18 +2,14 @@
<img <img
v-if="src" v-if="src"
ref="img" ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${ :class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''}`"
noShadow ? 'no-shadow' : ''
}`"
:src="src" :src="src"
:alt="alt" :alt="alt"
:loading="loading" :loading="loading"
/> />
<svg <svg
v-else v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''} ${ :class="`avatar size-${size} ${circle ? 'circle' : ''} ${noShadow ? 'no-shadow' : ''}`"
noShadow ? 'no-shadow' : ''
}`"
xml:space="preserve" xml:space="preserve"
fill-rule="evenodd" fill-rule="evenodd"
stroke-linecap="round" stroke-linecap="round"
@@ -35,7 +31,6 @@
<script> <script>
export default { export default {
name: 'Avatar',
props: { props: {
src: { src: {
type: String, type: String,
@@ -68,10 +63,7 @@ export default {
mounted() { mounted() {
if (this.$refs.img && this.$refs.img.naturalWidth) { if (this.$refs.img && this.$refs.img.naturalWidth) {
const isPixelated = () => { const isPixelated = () => {
if ( if (this.$refs.img.naturalWidth < 96 && this.$refs.img.naturalWidth > 0) {
this.$refs.img.naturalWidth < 96 &&
this.$refs.img.naturalWidth > 0
) {
this.$refs.img.style.imageRendering = 'pixelated' this.$refs.img.style.imageRendering = 'pixelated'
} }
} }

View File

@@ -1,16 +1,10 @@
<template> <template>
<span :class="'version-badge ' + color + ' type--' + type"> <span :class="'version-badge ' + color + ' type--' + type">
<template v-if="color"> <template v-if="color"> <span class="circle" /> {{ $capitalizeString(type) }} </template>
<span class="circle" /> {{ $capitalizeString(type) }}
</template>
<!-- User roles --> <!-- User roles -->
<template v-else-if="type === 'admin'"> <template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team </template>
<ModrinthIcon /> Modrinth Team <template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator </template>
</template>
<template v-else-if="type === 'moderator'">
<ModeratorIcon /> Moderator
</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template> <template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<!-- Project statuses --> <!-- Project statuses -->
@@ -18,45 +12,34 @@
<template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template> <template v-else-if="type === 'unlisted'"><EyeOffIcon /> Unlisted</template>
<template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template> <template v-else-if="type === 'withheld'"><EyeOffIcon /> Withheld</template>
<template v-else-if="type === 'private'"><LockIcon /> Private</template> <template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled </template>
<CalendarIcon /> Scheduled
</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template> <template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"> <template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived </template>
<ArchiveIcon /> Archived
</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template> <template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"> <template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review </template>
<ProcessingIcon /> Under review
</template>
<!-- Team members --> <!-- Team members -->
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template> <template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
<template v-else-if="type === 'pending'"> <template v-else-if="type === 'pending'"> <ProcessingIcon /> Pending </template>
<ProcessingIcon /> Pending <template v-else> <span class="circle" /> {{ $capitalizeString(type) }} </template>
</template>
<template v-else>
<span class="circle" /> {{ $capitalizeString(type) }}
</template>
</span> </span>
</template> </template>
<script> <script>
import ModrinthIcon from '~/assets/images/logo.svg?inline' import ModrinthIcon from '~/assets/images/logo.svg'
import ModeratorIcon from '~/assets/images/sidebar/admin.svg?inline' import ModeratorIcon from '~/assets/images/sidebar/admin.svg'
import CreatorIcon from '~/assets/images/utils/box.svg?inline' import CreatorIcon from '~/assets/images/utils/box.svg'
import ListIcon from '~/assets/images/utils/list.svg?inline' import ListIcon from '~/assets/images/utils/list.svg'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg?inline' import EyeOffIcon from '~/assets/images/utils/eye-off.svg'
import DraftIcon from '~/assets/images/utils/file-text.svg?inline' import DraftIcon from '~/assets/images/utils/file-text.svg'
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import ArchiveIcon from '~/assets/images/utils/archive.svg?inline' import ArchiveIcon from '~/assets/images/utils/archive.svg'
import ProcessingIcon from '~/assets/images/utils/updated.svg?inline' import ProcessingIcon from '~/assets/images/utils/updated.svg'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import LockIcon from '~/assets/images/utils/lock.svg?inline' import LockIcon from '~/assets/images/utils/lock.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline' import CalendarIcon from '~/assets/images/utils/calendar.svg'
export default { export default {
name: 'Badge',
components: { components: {
ModrinthIcon, ModrinthIcon,
ListIcon, ListIcon,

View File

@@ -9,25 +9,26 @@
class="checkbox" class="checkbox"
role="checkbox" role="checkbox"
:disabled="disabled" :disabled="disabled"
:class="{ checked: value, collapsing: collapsingToggleStyle }" :class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description" :aria-label="description ?? label"
:aria-checked="value" :aria-checked="modelValue"
> >
<CheckIcon v-if="value && !collapsingToggleStyle" aria-hidden="true" /> <CheckIcon v-if="modelValue && !collapsingToggleStyle" aria-hidden="true" />
<DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" /> <DropdownIcon v-else-if="collapsingToggleStyle" aria-hidden="true" />
</button> </button>
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label --> <!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
<p v-if="label" aria-hidden="true">{{ label }}</p> <p v-if="label" aria-hidden="true">
{{ label }}
</p>
<slot v-else /> <slot v-else />
</div> </div>
</template> </template>
<script> <script>
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline' import DropdownIcon from '~/assets/images/utils/dropdown.svg'
export default { export default {
name: 'Checkbox',
components: { components: {
CheckIcon, CheckIcon,
DropdownIcon, DropdownIcon,
@@ -43,9 +44,9 @@ export default {
}, },
description: { description: {
type: String, type: String,
default: '', default: null,
}, },
value: Boolean, modelValue: Boolean,
clickEvent: { clickEvent: {
type: Function, type: Function,
default: () => {}, default: () => {},
@@ -55,10 +56,11 @@ export default {
default: false, default: false,
}, },
}, },
emits: ['update:modelValue'],
methods: { methods: {
toggle() { toggle() {
if (!this.disabled) { if (!this.disabled) {
this.$emit('input', !this.value) this.$emit('update:modelValue', !this.modelValue)
} }
}, },
}, },

View File

@@ -14,15 +14,14 @@
</template> </template>
<script> <script>
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
export default { export default {
name: 'Chips',
components: { components: {
CheckIcon, CheckIcon,
}, },
props: { props: {
value: { modelValue: {
required: true, required: true,
type: String, type: String,
}, },
@@ -39,13 +38,14 @@ export default {
type: Function, type: Function,
}, },
}, },
emits: ['update:modelValue'],
computed: { computed: {
selected: { selected: {
get() { get() {
return this.value return this.modelValue
}, },
set(value) { set(value) {
this.$emit('input', value) this.$emit('update:modelValue', value)
}, },
}, },
}, },

View File

@@ -1,10 +1,5 @@
<template> <template>
<button <button class="code" :class="{ copied }" title="Copy code to clipboard" @click="copyText">
class="code"
:class="{ copied }"
title="Copy code to clipboard"
@click="copyText"
>
{{ text }} {{ text }}
<CheckIcon v-if="copied" /> <CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else /> <ClipboardCopyIcon v-else />
@@ -12,11 +7,10 @@
</template> </template>
<script> <script>
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import ClipboardCopyIcon from '~/assets/images/utils/clipboard-copy.svg?inline' import ClipboardCopyIcon from '~/assets/images/utils/clipboard-copy.svg'
export default { export default {
name: 'CopyCode',
components: { components: {
CheckIcon, CheckIcon,
ClipboardCopyIcon, ClipboardCopyIcon,
@@ -54,8 +48,8 @@ export default {
width: min-content; width: min-content;
border-radius: 10px; border-radius: 10px;
user-select: text; user-select: text;
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out; outline 0.2s ease-in-out;
svg { svg {
width: 1em; width: 1em;

View File

@@ -19,13 +19,13 @@
<script> <script>
export default { export default {
name: 'DropArea',
props: { props: {
accept: { accept: {
type: String, type: String,
default: '', default: '',
}, },
}, },
emits: ['change'],
data() { data() {
return { return {
fileAllowed: false, fileAllowed: false,
@@ -38,28 +38,25 @@ export default {
allowDrag(event) { allowDrag(event) {
const file = event.dataTransfer?.items[0] const file = event.dataTransfer?.items[0]
console.log(file)
if ( if (
file && file &&
this.accept this.accept
.split(',') .split(',')
.reduce( .reduce((acc, t) => acc || file.type.startsWith(t) || file.type === t || t === '*', false)
(acc, t) =>
acc || file.type.startsWith(t) || file.type === t || t === '*',
false
)
) { ) {
this.fileAllowed = true this.fileAllowed = true
event.dataTransfer.dropEffect = 'copy' event.dataTransfer.dropEffect = 'copy'
event.preventDefault() event.preventDefault()
if (this.$refs.drop_area) if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'visible' this.$refs.drop_area.style.visibility = 'visible'
}
} else { } else {
this.fileAllowed = false this.fileAllowed = false
if (this.$refs.drop_area) if (this.$refs.drop_area) {
this.$refs.drop_area.style.visibility = 'hidden' this.$refs.drop_area.style.visibility = 'hidden'
}
} }
}, },
}, },

View File

@@ -15,9 +15,7 @@
<GlobeIcon aria-hidden="true" /> <GlobeIcon aria-hidden="true" />
Client or server Client or server
</template> </template>
<template <template v-else-if="clientSide === 'required' && serverSide === 'required'">
v-else-if="clientSide === 'required' && serverSide === 'required'"
>
<GlobeIcon aria-hidden="true" /> <GlobeIcon aria-hidden="true" />
Client and server Client and server
</template> </template>
@@ -39,9 +37,7 @@
<ServerIcon aria-hidden="true" /> <ServerIcon aria-hidden="true" />
Server Server
</template> </template>
<template <template v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'">
v-else-if="serverSide === 'unsupported' && clientSide === 'unsupported'"
>
<GlobeIcon aria-hidden="true" /> <GlobeIcon aria-hidden="true" />
Unsupported Unsupported
</template> </template>
@@ -52,12 +48,11 @@
</span> </span>
</template> </template>
<script> <script>
import InfoIcon from '~/assets/images/utils/info.svg?inline' import InfoIcon from '~/assets/images/utils/info.svg'
import ClientIcon from '~/assets/images/utils/client.svg?inline' import ClientIcon from '~/assets/images/utils/client.svg'
import GlobeIcon from '~/assets/images/utils/globe.svg?inline' import GlobeIcon from '~/assets/images/utils/globe.svg'
import ServerIcon from '~/assets/images/utils/server.svg?inline' import ServerIcon from '~/assets/images/utils/server.svg'
export default { export default {
name: 'EnvironmentIndicator',
components: { components: {
InfoIcon, InfoIcon,
ClientIcon, ClientIcon,

View File

@@ -1,25 +1,15 @@
<template> <template>
<label <label :class="{ 'long-style': longStyle }" @drop.prevent="handleDrop" @dragover.prevent>
:class="{ 'long-style': longStyle }" <slot />
@drop.prevent="handleDrop"
@dragover.prevent
>
<slot></slot>
{{ prompt }} {{ prompt }}
<input <input type="file" :multiple="multiple" :accept="accept" @change="handleChange" />
type="file"
:multiple="multiple"
:accept="accept"
@change="handleChange"
/>
</label> </label>
</template> </template>
<script> <script>
import { fileIsValid } from '~/plugins/fileUtils' import { fileIsValid } from '~/helpers/fileUtils'
export default { export default {
name: 'FileInput',
components: {}, components: {},
props: { props: {
prompt: { prompt: {
@@ -54,6 +44,7 @@ export default {
default: false, default: false,
}, },
}, },
emits: ['change'],
data() { data() {
return { return {
files: [], files: [],
@@ -61,12 +52,12 @@ export default {
}, },
methods: { methods: {
addFiles(files, shouldNotReset) { addFiles(files, shouldNotReset) {
if (!shouldNotReset || this.shouldAlwaysReset) this.files = files if (!shouldNotReset || this.shouldAlwaysReset) {
this.files = files
}
const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true } const validationOptions = { maxSize: this.maxSize, alertOnInvalid: true }
this.files = [...this.files].filter((file) => this.files = [...this.files].filter((file) => fileIsValid(file, validationOptions))
fileIsValid(file, validationOptions)
)
if (this.files.length > 0) { if (this.files.length > 0) {
this.$emit('change', this.files) this.$emit('change', this.files)

View File

@@ -8,25 +8,26 @@
class="modal-overlay" class="modal-overlay"
@click="hide" @click="hide"
/> />
<div class="modal-body" :class="{ shown: shown }"> <div class="modal-container" :class="{ shown }">
<div v-if="header" class="header"> <div class="modal-body">
<h1>{{ header }}</h1> <div v-if="header" class="header">
<button class="iconified-button icon-only transparent" @click="hide"> <h1>{{ header }}</h1>
<CrossIcon /> <button class="iconified-button icon-only transparent" @click="hide">
</button> <CrossIcon />
</div> </button>
<div class="content"> </div>
<slot></slot> <div class="content">
<slot />
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
export default { export default {
name: 'Modal',
components: { components: {
CrossIcon, CrossIcon,
}, },
@@ -61,7 +62,6 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 20; z-index: 20;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
&.shown { &.shown {
@@ -76,46 +76,61 @@ export default {
} }
} }
.modal-body { .modal-container {
position: fixed; position: fixed;
left: 50%; top: 0;
transform: translate(-50%, -50%); left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
z-index: 21; z-index: 21;
box-shadow: var(--shadow-raised), var(--shadow-inset); visibility: hidden;
border-radius: var(--size-rounded-lg); pointer-events: none;
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
.header { &.shown {
display: flex; visibility: visible;
justify-content: space-between; .modal-body {
align-items: center; opacity: 1;
background-color: var(--color-bg); visibility: visible;
padding: var(--spacing-card-md) var(--spacing-card-lg); transform: translateY(0);
h1 {
font-size: 1.25rem;
} }
} }
.content { .modal-body {
background-color: var(--color-raised-bg); position: fixed;
} box-shadow: var(--shadow-raised), var(--shadow-inset);
border-radius: var(--size-rounded-lg);
max-height: calc(100% - 2 * var(--spacing-card-bg));
overflow-y: auto;
width: 600px;
pointer-events: auto;
top: calc(100% + 400px); .header {
visibility: hidden; display: flex;
opacity: 0; justify-content: space-between;
transition: all 0.25s ease-in-out; align-items: center;
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
&.shown { h1 {
opacity: 1; font-size: 1.25rem;
visibility: visible; }
top: 50%; }
}
@media screen and (max-width: 650px) { .content {
width: calc(100% - 2 * var(--spacing-card-bg)); background-color: var(--color-raised-bg);
}
transform: translateY(50vh);
visibility: hidden;
opacity: 0;
transition: all 0.25s ease-in-out;
@media screen and (max-width: 650px) {
width: calc(100% - 2 * var(--spacing-card-bg));
}
} }
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<Modal ref="modal" :header="title"> <Modal ref="modal" :header="title">
<div class="modal-delete"> <div class="modal-delete">
<div class="markdown-body" v-html="$xss($md.render(description))"></div> <div class="markdown-body" v-html="renderString(description)" />
<label v-if="hasToType" for="confirmation" class="confirmation-label"> <label v-if="hasToType" for="confirmation" class="confirmation-label">
<span> <span>
<strong>To verify, type</strong> <strong>To verify, type</strong>
@@ -24,11 +24,7 @@
<CrossIcon /> <CrossIcon />
Cancel Cancel
</button> </button>
<button <button class="iconified-button danger-button" :disabled="action_disabled" @click="proceed">
class="iconified-button danger-button"
:disabled="action_disabled"
@click="proceed"
>
<TrashIcon /> <TrashIcon />
{{ proceedLabel }} {{ proceedLabel }}
</button> </button>
@@ -38,12 +34,12 @@
</template> </template>
<script> <script>
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import TrashIcon from '~/assets/images/utils/trash.svg'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import { renderString } from '~/helpers/parse'
export default { export default {
name: 'ModalConfirm',
components: { components: {
CrossIcon, CrossIcon,
TrashIcon, TrashIcon,
@@ -73,6 +69,7 @@ export default {
default: 'Proceed', default: 'Proceed',
}, },
}, },
emits: ['proceed'],
data() { data() {
return { return {
action_disabled: this.hasToType, action_disabled: this.hasToType,
@@ -80,6 +77,7 @@ export default {
} }
}, },
methods: { methods: {
renderString,
cancel() { cancel() {
this.$refs.modal.hide() this.$refs.modal.hide()
}, },
@@ -90,8 +88,7 @@ export default {
type() { type() {
if (this.hasToType) { if (this.hasToType) {
this.action_disabled = this.action_disabled =
this.confirmation_typed.toLowerCase() !== this.confirmation_typed.toLowerCase() !== this.confirmationText.toLowerCase()
this.confirmationText.toLowerCase()
} }
}, },
show() { show() {

View File

@@ -2,15 +2,10 @@
<Modal ref="modal" header="Create a project"> <Modal ref="modal" header="Create a project">
<div class="modal-creation universal-labels"> <div class="modal-creation universal-labels">
<div class="markdown-body"> <div class="markdown-body">
<p> <p>New projects are created as drafts and can be found under your profile page.</p>
New projects are created as drafts and can be found under your profile
page.
</p>
</div> </div>
<label for="project-type"> <label for="project-type">
<span class="label__title" <span class="label__title">Project type<span class="required">*</span></span>
>Project type<span class="required">*</span></span
>
</label> </label>
<Chips <Chips
id="project-type" id="project-type"
@@ -34,9 +29,7 @@
</label> </label>
<div class="text-input-wrapper"> <div class="text-input-wrapper">
<div class="text-input-wrapper__before"> <div class="text-input-wrapper__before">
https://modrinth.com/{{ https://modrinth.com/{{ getProjectType() ? getProjectType().id : '???' }}/
getProjectType() ? getProjectType().id : '???'
}}/
</div> </div>
<input <input
id="slug" id="slug"
@@ -50,16 +43,11 @@
<label for="additional-information"> <label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span> <span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description" <span class="label__description"
>This appears in search and on the sidebar of your project's >This appears in search and on the sidebar of your project's page.</span
page.</span
> >
</label> </label>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<textarea <textarea id="additional-information" v-model="description" maxlength="256" />
id="additional-information"
v-model="description"
maxlength="256"
/>
</div> </div>
<div class="push-right input-group"> <div class="push-right input-group">
<button class="iconified-button" @click="cancel"> <button class="iconified-button" @click="cancel">
@@ -76,13 +64,12 @@
</template> </template>
<script> <script>
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import CheckIcon from '~/assets/images/utils/right-arrow.svg?inline' import CheckIcon from '~/assets/images/utils/right-arrow.svg'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips' import Chips from '~/components/ui/Chips'
export default { export default {
name: 'ModalCreation',
components: { components: {
Chips, Chips,
CrossIcon, CrossIcon,
@@ -144,7 +131,7 @@ export default {
} }
}, },
async createProject() { async createProject() {
this.$nuxt.$loading.start() startLoading()
const projectType = this.getProjectType() const projectType = this.getProjectType()
@@ -175,12 +162,11 @@ export default {
) )
try { try {
await this.$axios({ await useBaseFetch('project', {
url: 'project',
method: 'POST', method: 'POST',
data: formData, body: formData,
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Disposition': formData,
Authorization: this.$auth.token, Authorization: this.$auth.token,
}, },
}) })
@@ -191,6 +177,8 @@ export default {
params: { params: {
type: projectType.id, type: projectType.id,
id: this.slug, id: this.slug,
},
state: {
overrideProjectType: projectType.id, overrideProjectType: projectType.id,
}, },
}) })
@@ -198,11 +186,11 @@ export default {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
show() { show() {
this.projectType = this.$tag.projectTypes[0].display this.projectType = this.$tag.projectTypes[0].display

View File

@@ -2,27 +2,18 @@
<Modal ref="modal" header="Project moderation"> <Modal ref="modal" header="Project moderation">
<div v-if="project !== null" class="moderation-modal universal-body"> <div v-if="project !== null" class="moderation-modal universal-body">
<p> <p>
A moderation message is optional, but it can be used to communicate A moderation message is optional, but it can be used to communicate problems with a
problems with a project's team members. The body is also optional and project's team members. The body is also optional and supports markdown formatting!
supports markdown formatting!
</p> </p>
<div v-if="status" class="status"> <div v-if="status" class="status">
<span>New project status: </span> <span>New project status: </span>
<Badge :type="status" /> <Badge :type="status" />
</div> </div>
<h3>Message title</h3> <h3>Message title</h3>
<input <input v-model="moderationMessage" type="text" placeholder="Enter the message..." />
v-model="moderationMessage"
type="text"
placeholder="Enter the message..."
/>
<h3>Message body</h3> <h3>Message body</h3>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<Chips <Chips v-model="bodyViewMode" class="separator" :items="['source', 'preview']" />
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<textarea <textarea
v-if="bodyViewMode === 'source'" v-if="bodyViewMode === 'source'"
id="body" id="body"
@@ -34,20 +25,17 @@
: 'You must add a title before you add a body.' : 'You must add a title before you add a body.'
" "
/> />
<div <div v-else class="markdown-body preview" v-html="renderString(moderationMessageBody)" />
v-else
v-highlightjs
class="markdown-body preview"
v-html="$xss($md.render(moderationMessageBody))"
></div>
</div> </div>
<div class="push-right input-group"> <div class="push-right input-group">
<button <button
v-if="moderationMessage || moderationMessageBody" v-if="moderationMessage || moderationMessageBody"
class="iconified-button" class="iconified-button"
@click=" @click="
moderationMessage = '' () => {
moderationMessageBody = '' moderationMessage = ''
moderationMessageBody = ''
}
" "
> >
<TrashIcon /> <TrashIcon />
@@ -67,15 +55,15 @@
</template> </template>
<script> <script>
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import TrashIcon from '~/assets/images/utils/trash.svg'
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips' import Chips from '~/components/ui/Chips'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import { renderString } from '~/helpers/parse'
export default { export default {
name: 'ModalModeration',
components: { components: {
TrashIcon, TrashIcon,
CrossIcon, CrossIcon,
@@ -102,9 +90,7 @@ export default {
return { return {
bodyViewMode: 'source', bodyViewMode: 'source',
moderationMessage: moderationMessage:
this.project && this.project.moderation_message this.project && this.project.moderation_message ? this.project.moderation_message : '',
? this.project.moderation_message
: '',
moderationMessageBody: moderationMessageBody:
this.project && this.project.moderation_message_body this.project && this.project.moderation_message_body
? this.project.moderation_message_body ? this.project.moderation_message_body
@@ -112,26 +98,23 @@ export default {
} }
}, },
methods: { methods: {
renderString,
async saveProject() { async saveProject() {
this.$nuxt.$loading.start() startLoading()
try { try {
const data = { const data = {
moderation_message: this.moderationMessage moderation_message: this.moderationMessage ? this.moderationMessage : null,
? this.moderationMessage moderation_message_body: this.moderationMessageBody ? this.moderationMessageBody : null,
: null,
moderation_message_body: this.moderationMessageBody
? this.moderationMessageBody
: null,
} }
if (this.status) { if (this.status) {
data.status = this.status data.status = this.status
} }
await this.$axios.patch( await useBaseFetch(`project/${this.project.id}`, {
`project/${this.project.id}`, method: 'PATCH',
data, body: data,
this.$defaultHeaders() ...this.$defaultHeaders(),
) })
this.$refs.modal.hide() this.$refs.modal.hide()
if (this.onClose !== null) { if (this.onClose !== null) {
@@ -141,25 +124,21 @@ export default {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response ? err.response.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
show() { show() {
this.$refs.modal.show() this.$refs.modal.show()
this.moderationMessage = this.moderationMessage =
this.project && this.project && this.project.moderator_message && this.project.moderator_message.message
this.project.moderator_message &&
this.project.moderator_message.message
? this.project.moderator_message.message ? this.project.moderator_message.message
: '' : ''
this.moderationMessageBody = this.moderationMessageBody =
this.project && this.project && this.project.moderator_message && this.project.moderator_message.body
this.project.moderator_message &&
this.project.moderator_message.body
? this.project.moderator_message.body ? this.project.moderator_message.body
: '' : ''
}, },

View File

@@ -3,18 +3,16 @@
<div class="modal-report legacy-label-styles"> <div class="modal-report legacy-label-styles">
<div class="markdown-body"> <div class="markdown-body">
<p> <p>
Modding should be safe for everyone, so we take abuse and malicious Modding should be safe for everyone, so we take abuse and malicious intent seriously at
intent seriously at Modrinth. We want to hear about harmful content on Modrinth. We want to hear about harmful content on the site that violates our
the site that violates our <nuxt-link to="/legal/terms"> ToS </nuxt-link> and
<nuxt-link to="/legal/terms">ToS</nuxt-link> and <nuxt-link to="/legal/rules"> Rules </nuxt-link>. Rest assured, well keep your
<nuxt-link to="/legal/rules">Rules</nuxt-link>. Rest assured, well identifying information private.
keep your identifying information private.
</p> </p>
<p v-if="itemType === 'project' || itemType === 'version'"> <p v-if="itemType === 'project' || itemType === 'version'">
Please <strong>do not</strong> use this to report bugs with the Please <strong>do not</strong> use this to report bugs with the project itself. This form
project itself. This form is only for submitting a report to Modrinth is only for submitting a report to Modrinth staff. If the project has an Issues link or a
staff. If the project has an Issues link or a Discord invite, consider Discord invite, consider reporting it there.
reporting it there.
</p> </p>
</div> </div>
<label class="report-label" for="report-type"> <label class="report-label" for="report-type">
@@ -25,10 +23,8 @@
<multiselect <multiselect
id="report-type" id="report-type"
v-model="reportType" v-model="reportType"
:options="$store.state.tag.reportTypes" :options="$tag.reportTypes"
:custom-label=" :custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:multiple="false" :multiple="false"
:searchable="false" :searchable="false"
:show-no-results="false" :show-no-results="false"
@@ -37,26 +33,14 @@
/> />
<label class="report-label" for="additional-information"> <label class="report-label" for="additional-information">
<strong>Additional information</strong> <strong>Additional information</strong>
<span> <span> Include links and images if possible. Markdown formatting is supported. </span>
Include links and images if possible. Markdown formatting is
supported.
</span>
</label> </label>
<div class="textarea-wrapper"> <div class="textarea-wrapper">
<Chips <Chips v-model="bodyViewType" class="separator" :items="['source', 'preview']" />
v-model="bodyViewType"
class="separator"
:items="['source', 'preview']"
/>
<div v-if="bodyViewType === 'source'" class="textarea-wrapper"> <div v-if="bodyViewType === 'source'" class="textarea-wrapper">
<textarea id="body" v-model="body" spellcheck="true" /> <textarea id="body" v-model="body" spellcheck="true" />
</div> </div>
<div <div v-else class="preview" v-html="renderString(body)" />
v-else
v-highlightjs
class="preview"
v-html="$xss($md.render(body))"
></div>
</div> </div>
<div class="button-group"> <div class="button-group">
<button class="iconified-button" @click="cancel"> <button class="iconified-button" @click="cancel">
@@ -74,13 +58,13 @@
<script> <script>
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips' import Chips from '~/components/ui/Chips'
import { renderString } from '~/helpers/parse'
export default { export default {
name: 'ModalReport',
components: { components: {
Chips, Chips,
CrossIcon, CrossIcon,
@@ -106,6 +90,7 @@ export default {
} }
}, },
methods: { methods: {
renderString,
cancel() { cancel() {
this.reportType = '' this.reportType = ''
this.body = '' this.body = ''
@@ -114,7 +99,7 @@ export default {
this.$refs.modal.hide() this.$refs.modal.hide()
}, },
async submitReport() { async submitReport() {
this.$nuxt.$loading.start() startLoading()
try { try {
const data = { const data = {
report_type: this.reportType, report_type: this.reportType,
@@ -122,18 +107,22 @@ export default {
item_type: this.itemType, item_type: this.itemType,
body: this.body, body: this.body,
} }
await this.$axios.post('report', data, this.$defaultHeaders()) await useBaseFetch('report', {
method: 'POST',
body: data,
...this.$defaultHeaders(),
})
this.$refs.modal.hide() this.$refs.modal.hide()
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
show() { show() {
this.$refs.modal.show() this.$refs.modal.show()

View File

@@ -2,9 +2,8 @@
<Modal ref="modal" :header="'Transfer to ' + $formatWallet(wallet)"> <Modal ref="modal" :header="'Transfer to ' + $formatWallet(wallet)">
<div class="modal-transfer"> <div class="modal-transfer">
<span <span
>You are initiating a transfer of your revenue from Modrinth's Creator >You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
Monetization Program. How much of your How much of your <strong>{{ $formatMoney(balance) }}</strong> balance would you like to
<strong>{{ $formatMoney(balance) }}</strong> balance would you like to
transfer?</span transfer?</span
> >
<div class="confirmation-input"> <div class="confirmation-input">
@@ -19,40 +18,30 @@
</div> </div>
<div class="confirm-text"> <div class="confirm-text">
<Checkbox <Checkbox
v-if=" v-if="isValidInput() && parseInput() >= minWithdraw && parseInput() <= balance"
isValidInput() &&
parseInput() >= minWithdraw &&
parseInput() <= balance
"
v-model="consentedFee" v-model="consentedFee"
description="Consent to fee"
> >
<template v-if="wallet === 'venmo'" <template v-if="wallet === 'venmo'">
>I acknowledge that $0.25 will be deducted from the amount I receive I acknowledge that $0.25 will be deducted from the amount I receive to cover
to cover {{ $formatWallet(wallet) }} processing fees.</template {{ $formatWallet(wallet) }} processing fees.
> </template>
<template v-else <template v-else>
>I acknowledge that an estimated I acknowledge that an estimated
{{ $formatMoney(calcProcessingFees()) }} will be deducted from the {{ $formatMoney(calcProcessingFees()) }} will be deducted from the amount I receive to
amount I receive to cover {{ $formatWallet(wallet) }} processing cover {{ $formatWallet(wallet) }} processing fees and that any excess will be returned
fees and that any excess will be returned to my Modrinth to my Modrinth balance.
balance.</template </template>
>
</Checkbox> </Checkbox>
<Checkbox <Checkbox
v-if=" v-if="isValidInput() && parseInput() >= minWithdraw && parseInput() <= balance"
isValidInput() &&
parseInput() >= minWithdraw &&
parseInput() <= balance
"
v-model="consentedAccount" v-model="consentedAccount"
description="Confirm transfer"
> >
I confirm that I an initiating a transfer to the following I confirm that I an initiating a transfer to the following
{{ $formatWallet(wallet) }} account: {{ account }} {{ $formatWallet(wallet) }} account: {{ account }}
</Checkbox> </Checkbox>
<span <span v-else-if="validInput && parseInput() < minWithdraw" class="invalid">
v-else-if="validInput && parseInput() < minWithdraw"
class="invalid"
>
The amount must be at least {{ $formatMoney(minWithdraw) }}</span The amount must be at least {{ $formatMoney(minWithdraw) }}</span
> >
<span v-else-if="validInput && parseInput() > balance" class="invalid"> <span v-else-if="validInput && parseInput() > balance" class="invalid">
@@ -84,14 +73,13 @@
</template> </template>
<script> <script>
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline' import TransferIcon from '~/assets/images/utils/transfer.svg'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline' import SettingsIcon from '~/assets/images/utils/settings.svg'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
export default { export default {
name: 'ModalTransfer',
components: { components: {
Checkbox, Checkbox,
CrossIcon, CrossIcon,
@@ -138,29 +126,27 @@ export default {
this.$refs.modal.hide() this.$refs.modal.hide()
}, },
async proceed() { async proceed() {
this.$nuxt.$loading.start() startLoading()
try { try {
await this.$axios.post( await useBaseFetch(`user/${this.$auth.user.id}/payouts`, {
`user/${this.$auth.user.id}/payouts`, method: 'POST',
{ body: {
amount: Number(this.amount.replace('$', '')), amount: Number(this.amount.replace('$', '')),
}, },
this.$defaultHeaders() ...this.$defaultHeaders(),
)
await this.$store.dispatch('auth/fetchUser', {
token: this.$auth.token,
}) })
await useAuth(this.$auth.token)
this.$refs.modal.hide() this.$refs.modal.hide()
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
show() { show() {
this.$refs.modal.show() this.$refs.modal.show()

View File

@@ -1,5 +1,5 @@
<template> <template>
<nav class="navigation" :class="{ 'use-animation': useAnimation }"> <nav class="navigation">
<NuxtLink <NuxtLink
v-for="(link, index) in filteredLinks" v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown" v-show="link.shown === undefined ? true : link.shown"
@@ -7,32 +7,24 @@
ref="linkElements" ref="linkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href" :to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation" class="nav-link button-animation"
:class="{ 'is-active': index === activeIndex }"
> >
<span>{{ link.label }}</span> <span>{{ link.label }}</span>
</NuxtLink> </NuxtLink>
<div <div
class="nav-indicator" class="nav-indicator"
:style="`visibility: ${ :style="{
useAnimation && activeIndex !== -1 ? 'visible' : 'hidden' left: positionToMoveX,
}; left: ${indicator.left}px; right: ${indicator.right}px; top: positionToMoveY,
top: ${indicator.top}px; transition: left 350ms ${ width: sliderWidth,
indicator.direction === 'left' opacity: activeIndex === -1 ? 0 : 1,
? 'cubic-bezier(1,0,.3,1) -140ms' }"
: 'cubic-bezier(.75,-0.01,.24,.99) -40ms' aria-hidden="true"
},right 350ms ${ ></div>
indicator.direction === 'right'
? 'cubic-bezier(1,0,.3,1) -140ms'
: 'cubic-bezier(.75,-0.01,.24,.99) -40ms'
}, top 100ms ease-in-out`"
/>
</nav> </nav>
</template> </template>
<script> <script>
export default { export default {
name: 'NavRow',
props: { props: {
links: { links: {
default: () => [], default: () => [],
@@ -45,21 +37,26 @@ export default {
}, },
data() { data() {
return { return {
useAnimation: false, sliderPositionX: 0,
oldIndex: -1, sliderPositionY: 18,
selectedElementWidth: 0,
activeIndex: -1, activeIndex: -1,
indicator: { oldIndex: -1,
left: 0,
right: 0,
top: 22,
direction: 'right',
},
} }
}, },
computed: { computed: {
filteredLinks() { filteredLinks() {
return this.links.filter((x) => (x.shown === undefined ? true : x.shown)) return this.links.filter((x) => (x.shown === undefined ? true : x.shown))
}, },
positionToMoveX() {
return `${this.sliderPositionX}px`
},
positionToMoveY() {
return `${this.sliderPositionY}px`
},
sliderWidth() {
return `${this.selectedElementWidth}px`
},
}, },
watch: { watch: {
'$route.path': { '$route.path': {
@@ -74,53 +71,34 @@ export default {
}, },
}, },
mounted() { mounted() {
window.addEventListener('resize', this.pickLink)
this.pickLink() this.pickLink()
}, },
unmounted() {
window.removeEventListener('resize', this.pickLink)
},
methods: { methods: {
pickLink() { pickLink() {
if (this.oldIndex === -1) {
this.useAnimation = false
setTimeout(() => {
this.useAnimation = true
}, 300)
}
this.activeIndex = this.query this.activeIndex = this.query
? this.filteredLinks.findIndex( ? this.filteredLinks.findIndex(
(x) => (x) => (x.href === '' ? undefined : x.href) === this.$route.path[this.query]
(x.href === '' ? undefined : x.href) ===
this.$route.query[this.query]
)
: this.filteredLinks.findIndex(
(x) => x.href === decodeURIComponent(this.$route.path)
) )
: this.filteredLinks.findIndex((x) => x.href === decodeURIComponent(this.$route.path))
if (this.activeIndex !== -1) { if (this.activeIndex !== -1) {
this.startAnimation() this.startAnimation()
} else { } else {
this.oldIndex = -1 this.oldIndex = -1
this.sliderPositionX = 0
this.selectedElementWidth = 0
} }
}, },
startAnimation() { startAnimation() {
if (this.$refs.linkElements[this.activeIndex]) { const el = this.$refs.linkElements[this.activeIndex].$el
this.indicator.direction =
this.activeIndex < this.oldIndex ? 'left' : 'right'
this.indicator.left = this.sliderPositionX = el.offsetLeft
this.$refs.linkElements[this.activeIndex].$el.offsetLeft this.sliderPositionY = el.offsetTop + el.offsetHeight
this.indicator.right = this.selectedElementWidth = el.offsetWidth
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
}, },
}, },
} }
@@ -141,19 +119,6 @@ export default {
color: var(--color-text); color: var(--color-text);
position: relative; 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 { &:hover {
color: var(--color-text); color: var(--color-text);
@@ -166,7 +131,7 @@ export default {
opacity: 0.2; opacity: 0.2;
} }
&.is-active { &.router-link-exact-active {
color: var(--color-text); color: var(--color-text);
&::after { &::after {
@@ -186,11 +151,12 @@ export default {
.nav-indicator { .nav-indicator {
position: absolute; position: absolute;
height: 0.25rem; height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max); border-radius: var(--size-rounded-max);
background-color: var(--color-brand); background-color: var(--color-brand);
transition-property: left, right, top;
transition-duration: 350ms;
visibility: hidden;
@media (prefers-reduced-motion) { @media (prefers-reduced-motion) {
transition: none !important; transition: none !important;

View File

@@ -7,9 +7,7 @@
</template> </template>
<script> <script>
export default { export default {}
name: 'NavStack',
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -23,10 +23,9 @@
</template> </template>
<script> <script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline' import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
export default { export default {
name: 'NavStackItem',
components: { components: {
ChevronRightIcon, ChevronRightIcon,
}, },
@@ -88,7 +87,13 @@ export default {
background-color: var(--background-color); background-color: var(--background-color);
} }
&.nuxt-link-exact-active { &:focus-visible {
.nav-content {
border-radius: 0.25rem;
}
}
&.router-link-exact-active {
.nav-content { .nav-content {
color: var(--color-button-text-active); color: var(--color-button-text-active);
background-color: var(--color-button-bg); background-color: var(--color-button-bg);

View File

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

View File

@@ -2,6 +2,7 @@
<div v-if="count > 1" class="columns paginates"> <div v-if="count > 1" class="columns paginates">
<a <a
:class="{ disabled: page === 1 }" :class="{ disabled: page === 1 }"
:tabindex="page === 1 ? -1 : 0"
class="left-arrow paginate has-icon" class="left-arrow paginate has-icon"
aria-label="Previous Page" aria-label="Previous Page"
:href="linkFunction(page - 1)" :href="linkFunction(page - 1)"
@@ -38,12 +39,11 @@
:class="{ :class="{
disabled: page === pages[pages.length - 1], disabled: page === pages[pages.length - 1],
}" }"
:tabindex="page === pages[pages.length - 1] ? -1 : 0"
class="right-arrow paginate has-icon" class="right-arrow paginate has-icon"
aria-label="Next Page" aria-label="Next Page"
:href="linkFunction(page + 1)" :href="linkFunction(page + 1)"
@click.prevent=" @click.prevent="page !== pages[pages.length - 1] ? switchPage(page + 1) : null"
page !== pages[pages.length - 1] ? switchPage(page + 1) : null
"
> >
<RightArrowIcon /> <RightArrowIcon />
</a> </a>
@@ -51,12 +51,11 @@
</template> </template>
<script> <script>
import GapIcon from '~/assets/images/utils/gap.svg?inline' import GapIcon from '~/assets/images/utils/gap.svg'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg?inline' import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline' import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
export default { export default {
name: 'Pagination',
components: { components: {
GapIcon, GapIcon,
LeftArrowIcon, LeftArrowIcon,
@@ -78,6 +77,7 @@ export default {
}, },
}, },
}, },
emits: ['switch-page'],
computed: { computed: {
pages() { pages() {
let pages = [] let pages = []
@@ -94,15 +94,7 @@ export default {
this.count, this.count,
] ]
} else if (this.page > 4) { } else if (this.page > 4) {
pages = [ pages = [1, '-', this.page - 1, this.page, this.page + 1, '-', this.count]
1,
'-',
this.page - 1,
this.page,
this.page + 1,
'-',
this.count,
]
} else { } else {
pages = [1, 2, 3, 4, 5, '-', this.count] pages = [1, 2, 3, 4, 5, '-', this.count]
} }
@@ -131,8 +123,8 @@ a {
border-radius: 2rem; border-radius: 2rem;
background: var(--color-raised-bg); background: var(--color-raised-bg);
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out; outline 0.2s ease-in-out;
&.page-number.current { &.page-number.current {
background: var(--color-brand); background: var(--color-brand);

View File

@@ -1,10 +1,7 @@
<template> <template>
<article <article class="project-card base-card padding-bg" :aria-label="name" role="listitem">
class="project-card base-card padding-bg"
:aria-label="name"
role="listitem"
>
<nuxt-link <nuxt-link
:title="name"
class="icon" class="icon"
tabindex="-1" tabindex="-1"
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`" :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
@@ -18,7 +15,7 @@
:to="`/${$getProjectTypeForUrl(type, categories)}/${id}`" :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"
:style="color ? `background-color: ${toColor};` : ''" :style="color ? `background-color: ${toColor};` : ''"
> >
<img v-if="featuredImage" :src="featuredImage" alt="gallery image" /> <img v-if="featuredImage" :src="featuredImage" alt="gallery image" loading="lazy" />
</nuxt-link> </nuxt-link>
<div class="title"> <div class="title">
<nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`"> <nuxt-link :to="`/${$getProjectTypeForUrl(type, categories)}/${id}`">
@@ -28,24 +25,18 @@
</nuxt-link> </nuxt-link>
<p v-if="author" class="author"> <p v-if="author" class="author">
by by
<nuxt-link class="title-link" :to="'/user/' + author" <nuxt-link class="title-link" :to="'/user/' + author">
>{{ author }} {{ author }}
</nuxt-link> </nuxt-link>
</p> </p>
<Badge <Badge v-if="status && status !== 'approved'" :type="status" class="status" />
v-if="status && status !== 'approved'"
:type="status"
class="status"
/>
</div> </div>
<p class="description"> <p class="description">
{{ description }} {{ description }}
</p> </p>
<Categories <Categories
:categories=" :categories="
categories.filter( categories.filter((x) => !hideLoaders || !$tag.loaders.find((y) => y.name === x))
(x) => !hideLoaders || !$tag.loaders.find((y) => y.name === x)
)
" "
:type="type" :type="type"
class="tags" class="tags"
@@ -64,18 +55,14 @@
<DownloadIcon aria-hidden="true" /> <DownloadIcon aria-hidden="true" />
<p> <p>
<strong>{{ $formatNumber(downloads) }}</strong <strong>{{ $formatNumber(downloads) }}</strong
><span class="stat-label"> ><span class="stat-label"> download<span v-if="downloads !== '1'">s</span></span>
download<span v-if="downloads !== '1'">s</span></span
>
</p> </p>
</div> </div>
<div v-if="follows" class="stat"> <div v-if="follows" class="stat">
<HeartIcon aria-hidden="true" /> <HeartIcon aria-hidden="true" />
<p> <p>
<strong>{{ $formatNumber(follows) }}</strong <strong>{{ $formatNumber(follows) }}</strong
><span class="stat-label"> ><span class="stat-label"> follower<span v-if="follows !== '1'">s</span></span>
follower<span v-if="follows !== '1'">s</span></span
>
</p> </p>
</div> </div>
<div class="buttons"> <div class="buttons">
@@ -87,8 +74,7 @@
class="stat date" class="stat date"
> >
<EditIcon aria-hidden="true" /> <EditIcon aria-hidden="true" />
<span class="date-label">Updated </span <span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
>{{ $dayjs(updatedAt).fromNow() }}
</div> </div>
<div <div
v-else v-else
@@ -96,8 +82,7 @@
class="stat date" class="stat date"
> >
<CalendarIcon aria-hidden="true" /> <CalendarIcon aria-hidden="true" />
<span class="date-label">Published </span <span class="date-label">Published </span>{{ fromNow(createdAt) }}
>{{ $dayjs(createdAt).fromNow() }}
</div> </div>
</div> </div>
</article> </article>
@@ -108,14 +93,13 @@ import Categories from '~/components/ui/search/Categories'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator' import EnvironmentIndicator from '~/components/ui/EnvironmentIndicator'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline' import CalendarIcon from '~/assets/images/utils/calendar.svg'
import EditIcon from '~/assets/images/utils/updated.svg?inline' import EditIcon from '~/assets/images/utils/updated.svg'
import DownloadIcon from '~/assets/images/utils/download.svg?inline' import DownloadIcon from '~/assets/images/utils/download.svg'
import HeartIcon from '~/assets/images/utils/heart.svg?inline' import HeartIcon from '~/assets/images/utils/heart.svg'
import Avatar from '~/components/ui/Avatar' import Avatar from '~/components/ui/Avatar'
export default { export default {
name: 'ProjectCard',
components: { components: {
EnvironmentIndicator, EnvironmentIndicator,
Avatar, Avatar,

View File

@@ -4,8 +4,7 @@
$auth.user && $auth.user &&
currentMember && currentMember &&
nags.filter((x) => x.condition).length > 0 && nags.filter((x) => x.condition).length > 0 &&
(project.status === 'draft' || (project.status === 'draft' || $tag.rejectedStatuses.includes(project.status))
$tag.rejectedStatuses.includes(project.status))
" "
class="author-actions universal-card" class="author-actions universal-card"
> >
@@ -19,6 +18,7 @@
v-for="nag in nags" v-for="nag in nags"
:key="`checklist-${nag.id}`" :key="`checklist-${nag.id}`"
v-tooltip="nag.title" v-tooltip="nag.title"
:aria-label="nag.title"
class="circle" class="circle"
:class="'circle ' + (!nag.condition ? 'done ' : '') + nag.status" :class="'circle ' + (!nag.condition ? 'done ' : '') + nag.status"
> >
@@ -41,25 +41,24 @@
</div> </div>
</div> </div>
<div v-if="!collapsed" class="grid-display width-16"> <div v-if="!collapsed" class="grid-display width-16">
<div <div v-for="nag in nags.filter((x) => x.condition)" :key="nag.id" class="grid-display__item">
v-for="nag in nags.filter((x) => x.condition)"
:key="nag.id"
class="grid-display__item"
>
<span class="label"> <span class="label">
<RequiredIcon <RequiredIcon
v-if="nag.status === 'required'" v-if="nag.status === 'required'"
v-tooltip="'Required'" v-tooltip="'Required'"
aria-label="Required"
:class="nag.status" :class="nag.status"
/> />
<SuggestionIcon <SuggestionIcon
v-else-if="nag.status === 'suggestion'" v-else-if="nag.status === 'suggestion'"
v-tooltip="'Suggestion'" v-tooltip="'Suggestion'"
aria-label="Suggestion"
:class="nag.status" :class="nag.status"
/> />
<ModerationIcon <ModerationIcon
v-else-if="nag.status === 'review'" v-else-if="nag.status === 'review'"
v-tooltip="'Review'" v-tooltip="'Review'"
aria-label="Review"
:class="nag.status" :class="nag.status"
/>{{ nag.title }}</span />{{ nag.title }}</span
> >
@@ -71,6 +70,7 @@
$tag.rejectedStatuses.includes(project.status) $tag.rejectedStatuses.includes(project.status)
" "
v-model="acknowledgedMessage" v-model="acknowledgedMessage"
description="Acknowledge staff message in sidebar"
> >
I acknowledge that I have addressed the staff's message on the sidebar I acknowledge that I have addressed the staff's message on the sidebar
</Checkbox> </Checkbox>
@@ -78,15 +78,12 @@
v-if="nag.link" v-if="nag.link"
:class="{ invisible: nag.link.hide }" :class="{ invisible: nag.link.hide }"
class="goto-link" class="goto-link"
:to="`/${project.project_type}/${ :to="`/${project.project_type}/${project.slug ? project.slug : project.id}/${
project.slug ? project.slug : project.id nag.link.path
}/${nag.link.path}`" }`"
> >
{{ nag.link.title }} {{ nag.link.title }}
<ChevronRightIcon <ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
class="featured-header-chevron"
aria-hidden="true"
/>
</NuxtLink> </NuxtLink>
<button <button
v-else-if="nag.action" v-else-if="nag.action"
@@ -103,17 +100,16 @@
</template> </template>
<script> <script>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline' import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline' import DropdownIcon from '~/assets/images/utils/dropdown.svg'
import CheckIcon from '~/assets/images/utils/check.svg?inline' import CheckIcon from '~/assets/images/utils/check.svg'
import RequiredIcon from '~/assets/images/utils/asterisk.svg?inline' import RequiredIcon from '~/assets/images/utils/asterisk.svg'
import SuggestionIcon from '~/assets/images/utils/lightbulb.svg?inline' import SuggestionIcon from '~/assets/images/utils/lightbulb.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg?inline' import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import SendIcon from '~/assets/images/utils/send.svg?inline' import SendIcon from '~/assets/images/utils/send.svg'
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
export default { export default {
name: 'ProjectPublishingChecklist',
components: { components: {
Checkbox, Checkbox,
ChevronRightIcon, ChevronRightIcon,
@@ -131,7 +127,9 @@ export default {
}, },
versions: { versions: {
type: Array, type: Array,
required: true, default() {
return []
},
}, },
currentMember: { currentMember: {
type: Object, type: Object,
@@ -189,8 +187,7 @@ export default {
return [ return [
{ {
condition: condition:
this.project.body === '' || this.project.body === '' || this.project.body.startsWith('# Placeholder description'),
this.project.body.startsWith('# Placeholder description'),
title: 'Add a description', title: 'Add a description',
id: 'add-description', id: 'add-description',
description: description:
@@ -219,8 +216,7 @@ export default {
condition: !this.featuredGalleryImage, condition: !this.featuredGalleryImage,
title: 'Feature a gallery image', title: 'Feature a gallery image',
id: 'feature-gallery-image', id: 'feature-gallery-image',
description: description: 'Featured gallery images may be the first impression of many users.',
'Featured gallery images may be the first impression for many users.',
status: 'suggestion', status: 'suggestion',
link: { link: {
path: 'gallery', path: 'gallery',
@@ -232,8 +228,7 @@ export default {
condition: this.versions.length < 1, condition: this.versions.length < 1,
title: 'Upload a version', title: 'Upload a version',
id: 'upload-version', id: 'upload-version',
description: description: 'At least one version is required for a project to be submitted for review.',
'At least one version is required for a project to be submitted for review.',
status: 'required', status: 'required',
link: { link: {
path: 'versions', path: 'versions',
@@ -279,8 +274,7 @@ export default {
this.project.project_type === 'shader' || this.project.project_type === 'shader' ||
this.project.project_type === 'datapack', this.project.project_type === 'datapack',
condition: condition:
this.project.client_side === 'unknown' || this.project.client_side === 'unknown' || this.project.server_side === 'unknown',
this.project.server_side === 'unknown',
title: 'Select supported environments', title: 'Select supported environments',
id: 'select-environments', id: 'select-environments',
description: `Select if the ${this.$formatProjectType( description: `Select if the ${this.$formatProjectType(
@@ -320,8 +314,7 @@ export default {
onClick: this.submitForReview, onClick: this.submitForReview,
title: 'Submit for review', title: 'Submit for review',
disabled: () => disabled: () =>
this.nags.filter((x) => x.condition && x.status === 'required') this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
.length > 0,
}, },
}, },
{ {
@@ -339,8 +332,7 @@ export default {
title: 'Resubmit for review', title: 'Resubmit for review',
disabled: () => disabled: () =>
!this.acknowledgedMessage || !this.acknowledgedMessage ||
this.nags.filter((x) => x.condition && x.status === 'required') this.nags.filter((x) => x.condition && x.status === 'required').length > 0,
.length > 0,
}, },
}, },
] ]
@@ -380,8 +372,7 @@ export default {
async submitForReview() { async submitForReview() {
if ( if (
!this.acknowledgedMessage || !this.acknowledgedMessage ||
this.nags.filter((x) => x.condition && x.status === 'required') this.nags.filter((x) => x.condition && x.status === 'required').length === 0
.length === 0
) { ) {
await this.setProcessing() await this.setProcessing()
} }

View File

@@ -1,12 +1,14 @@
<template> <template>
<div <div
v-if="getValidLoaders().length > 1 || getValidVersions().length > 1" v-if="
loaderFilters.length > 1 || gameVersionFilters.length > 1 || versionTypeFilters.length > 1
"
class="card search-controls" class="card search-controls"
> >
<Multiselect <Multiselect
v-if="getValidLoaders().length > 1" v-if="loaderFilters.length > 1"
v-model="selectedLoaders" v-model="selectedLoaders"
:options="getValidLoaders()" :options="loaderFilters"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)" :custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="true" :multiple="true"
:searchable="false" :searchable="false"
@@ -15,19 +17,16 @@
:clear-search-on-select="false" :clear-search-on-select="false"
:show-labels="false" :show-labels="false"
:allow-empty="true" :allow-empty="true"
:disabled="getValidLoaders().length === 1"
placeholder="Filter loader..." placeholder="Filter loader..."
@input="updateVersionFilters()" @update:model-value="updateVersionFilters()"
></Multiselect> />
<Multiselect <Multiselect
v-if="getValidVersions().length > 1" v-if="gameVersionFilters.length > 1"
v-model="selectedGameVersions" v-model="selectedGameVersions"
:options=" :options="
showSnapshots includeSnapshots
? getValidVersions().map((x) => x.version) ? gameVersionFilters.map((x) => x.version)
: getValidVersions() : gameVersionFilters.filter((it) => it.version_type === 'release').map((x) => x.version)
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
" "
:multiple="true" :multiple="true"
:searchable="true" :searchable="true"
@@ -37,12 +36,12 @@
:hide-selected="true" :hide-selected="true"
:selectable="() => selectedGameVersions.length <= 6" :selectable="() => selectedGameVersions.length <= 6"
placeholder="Filter versions..." placeholder="Filter versions..."
@input="updateVersionFilters()" @update:model-value="updateVersionFilters()"
></Multiselect> />
<Multiselect <Multiselect
v-if="getValidChannels().length > 1" v-if="versionTypeFilters.length > 1"
v-model="selectedChannels" v-model="selectedVersionTypes"
:options="getValidChannels()" :options="versionTypeFilters"
:custom-label="(x) => $capitalizeString(x)" :custom-label="(x) => $capitalizeString(x)"
:multiple="true" :multiple="true"
:searchable="false" :searchable="false"
@@ -52,29 +51,30 @@
:show-labels="false" :show-labels="false"
:allow-empty="true" :allow-empty="true"
placeholder="Filter channels..." placeholder="Filter channels..."
@input="updateVersionFilters()" @update:model-value="updateVersionFilters()"
></Multiselect> />
<Checkbox <Checkbox
v-if=" v-if="
getValidVersions().length > 1 && gameVersionFilters.length > 1 &&
getValidVersions().some((v) => v.version_type !== 'release') gameVersionFilters.some((v) => v.version_type !== 'release')
" "
v-model="showSnapshots" v-model="includeSnapshots"
label="Include snapshots" label="Include snapshots"
description="Include snapshots" description="Include snapshots"
:border="false" :border="false"
@input="updateQuery" @update:model-value="updateQuery"
/> />
<button <button
title="Clear filters" title="Clear filters"
:disabled=" :disabled="selectedLoaders.length === 0 && selectedGameVersions.length === 0"
selectedLoaders.length === 0 && selectedGameVersions.length === 0
"
class="iconified-button" class="iconified-button"
@click=" @click="
selectedLoaders = [] () => {
selectedGameVersions = [] selectedLoaders = []
updateVersionFilters() selectedGameVersions = []
selectedVersionTypes = []
updateVersionFilters()
}
" "
> >
<ClearIcon /> <ClearIcon />
@@ -83,125 +83,81 @@
</div> </div>
</template> </template>
<script> <script setup>
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
import ClearIcon from '~/assets/images/utils/clear.svg?inline' import ClearIcon from '~/assets/images/utils/clear.svg'
export default {
name: 'VersionFilterControl',
components: {
Multiselect,
Checkbox,
ClearIcon,
},
props: {
versions: {
type: Array,
required: true,
},
},
data() {
return {
query: '',
showSnapshots: false,
cachedValidChannels: null,
cachedValidVersions: null,
cachedValidLoaders: null,
selectedGameVersions: [],
selectedLoaders: [],
selectedChannels: [],
}
},
fetch() {
this.selectedLoaders = this.$route.query.l?.split(',') || []
this.selectedGameVersions = this.$route.query.g?.split(',') || []
this.selectedChannels = this.$route.query.c?.split(',') || []
this.showSnapshots = this.$route.query.s === 'true'
this.updateVersionFilters()
},
methods: {
getValidChannels() {
if (!this.cachedValidChannels) {
this.cachedValidChannels = ['release', 'beta', 'alpha'].filter(
(channel) =>
this.versions.some((projVer) => projVer.version_type === channel)
)
}
return this.cachedValidChannels
},
getValidVersions() {
if (!this.cachedValidVersions) {
this.cachedValidVersions = this.$tag.gameVersions.filter((gameVer) =>
this.versions.some((projVer) =>
projVer.game_versions.includes(gameVer.version)
)
)
}
return this.cachedValidVersions
},
getValidLoaders() {
if (!this.cachedValidLoaders) {
const temp = new Set()
for (const version of this.versions) {
version.loaders.forEach((v) => {
temp.add(v)
})
}
this.cachedValidLoaders = Array.from(temp)
this.cachedValidLoaders.sort()
}
return this.cachedValidLoaders
},
async updateVersionFilters() {
this.selectedChannels = this.selectedChannels.filter((channel) =>
this.getValidChannels().includes(channel)
)
this.selectedLoaders = this.selectedLoaders.filter((loader) =>
this.getValidLoaders().includes(loader)
)
this.selectedGameVersions = this.selectedGameVersions.filter((version) =>
this.getValidVersions().some(
(validVersion) => validVersion.version === version
)
)
const temp = this.versions.filter( const emit = defineEmits(['updateVersions'])
(projectVersion) => const props = defineProps({
(this.selectedGameVersions.length === 0 || versions: {
this.selectedGameVersions.some((gameVersion) => type: Array,
projectVersion.game_versions.includes(gameVersion) default() {
)) && return []
(this.selectedLoaders.length === 0 ||
this.selectedLoaders.some((loader) =>
projectVersion.loaders.includes(loader)
)) &&
(this.selectedChannels.length === 0 ||
this.selectedChannels.includes(projectVersion.version_type))
)
await this.updateQuery()
this.$emit('updateVersions', temp)
},
async updateQuery() {
await this.$router.replace({
query: {
...this.$route.query,
l:
this.selectedLoaders.length === 0
? undefined
: this.selectedLoaders.join(','),
g:
this.selectedGameVersions.length === 0
? undefined
: this.selectedGameVersions.join(','),
c:
this.selectedChannels.length === 0
? undefined
: this.selectedChannels.join(','),
s: this.showSnapshots ? true : undefined,
},
})
}, },
}, },
})
const data = useNuxtApp()
const route = useRoute()
const tempLoaders = new Set()
let tempVersions = new Set()
const tempReleaseChannels = new Set()
for (const version of props.versions) {
for (const loader of version.loaders) {
tempLoaders.add(loader)
}
for (const gameVersion of version.game_versions) {
tempVersions.add(gameVersion)
}
tempReleaseChannels.add(version.version_type)
}
tempVersions = Array.from(tempVersions)
const loaderFilters = shallowRef(Array.from(tempLoaders))
const gameVersionFilters = shallowRef(
data.$tag.gameVersions.filter((gameVer) => tempVersions.includes(gameVer.version))
)
const versionTypeFilters = shallowRef(Array.from(tempReleaseChannels))
const includeSnapshots = ref(route.query.s === 'true')
const selectedGameVersions = shallowRef(route.query.g ?? [])
const selectedLoaders = shallowRef(route.query.l ?? [])
const selectedVersionTypes = shallowRef(route.query.c ?? [])
async function updateVersionFilters() {
const temp = props.versions.filter(
(projectVersion) =>
(selectedGameVersions.value.length === 0 ||
selectedGameVersions.value.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion)
)) &&
(selectedLoaders.value.length === 0 ||
selectedLoaders.value.some((loader) => projectVersion.loaders.includes(loader))) &&
(selectedVersionTypes.value.length === 0 ||
selectedVersionTypes.value.includes(projectVersion.version_type))
)
await updateQuery()
emit('updateVersions', temp)
}
async function updateQuery() {
const router = useRouter()
const route = useRoute()
await router.replace({
query: {
...route.query,
l: selectedLoaders.value.length === 0 ? undefined : selectedLoaders.value,
g: selectedGameVersions.value.length === 0 ? undefined : selectedGameVersions.value,
c: selectedVersionTypes.value.length === 0 ? undefined : selectedVersionTypes.value,
s: includeSnapshots.value ? true : undefined,
},
})
} }
</script> </script>
@@ -219,25 +175,4 @@ export default {
min-width: fit-content; min-width: fit-content;
} }
} }
.circle-button {
display: flex;
max-width: 2rem;
padding: 0.5rem;
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;
}
}
</style> </style>

View File

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

View File

@@ -11,7 +11,6 @@
<script> <script>
export default { export default {
name: 'Categories',
props: { props: {
categories: { categories: {
type: Array, type: Array,
@@ -30,8 +29,7 @@ export default {
.concat(this.$tag.loaders) .concat(this.$tag.loaders)
.filter( .filter(
(x) => (x) =>
this.categories.includes(x.name) && this.categories.includes(x.name) && (!x.project_type || x.project_type === this.type)
(!x.project_type || x.project_type === this.type)
) )
}, },
}, },
@@ -44,7 +42,7 @@ export default {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
span ::v-deep { :deep(span) {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;

View File

@@ -1,13 +1,15 @@
<template> <template>
<Checkbox <Checkbox
class="filter" class="filter"
:value="activeFilters.includes(facetName)" :model-value="activeFilters.includes(facetName)"
:description="displayName" :description="displayName"
@input="toggle()" @update:model-value="toggle()"
> >
<div class="filter-text"> <div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon"></div> <div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-else class="icon"><slot /></div> <div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span> <span aria-hidden="true"> {{ displayName }}</span>
</div> </div>
</Checkbox> </Checkbox>
@@ -17,7 +19,6 @@
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
export default { export default {
name: 'SearchFilter',
components: { components: {
Checkbox, Checkbox,
}, },
@@ -41,6 +42,7 @@ export default {
}, },
}, },
}, },
emits: ['toggle'],
methods: { methods: {
toggle() { toggle() {
this.$emit('toggle', this.facetName) this.$emit('toggle', this.facetName)
@@ -50,10 +52,10 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.filter ::v-deep { .filter {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
.filter-text { :deep(.filter-text) {
display: flex; display: flex;
align-items: center; align-items: center;

63
composables/auth.js Normal file
View File

@@ -0,0 +1,63 @@
export const useAuth = async (oldToken = null) => {
const auth = useState('auth', () => ({
user: null,
token: '',
headers: {},
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
}
return auth
}
export const initAuth = async (oldToken = null) => {
const auth = {
user: null,
token: '',
headers: {},
}
const route = useRoute()
const authCookie = useCookie('auth-token', {
secure: true,
sameSite: 'Strict',
httpOnly: false,
expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
path: '/',
})
if (oldToken) {
authCookie.value = oldToken
}
if (route.query.code) {
authCookie.value = route.query.code
}
if (authCookie.value) {
auth.token = authCookie.value
try {
auth.user = await useBaseFetch('user', {
headers: {
Authorization: auth.token,
},
})
} catch {}
auth.headers = {
headers: {
Authorization: auth.token,
},
}
}
return auth
}
export const getAuthUrl = () => {
const config = useRuntimeConfig()
const route = useRoute()
return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.fullPath}`
}

46
composables/cosmetics.js Normal file
View File

@@ -0,0 +1,46 @@
export const useCosmetics = () =>
useState('cosmetics', () => {
const cosmetics = useCookie('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'Strict',
secure: true,
httpOnly: false,
path: '/',
})
if (!cosmetics.value) {
cosmetics.value = {
searchLayout: false,
projectLayout: false,
modpacksAlphaNotice: true,
advancedRendering: true,
externalLinksNewTab: true,
notUsingBlockers: false,
searchDisplayMode: {
mod: 'list',
plugin: 'list',
resourcepack: 'gallery',
modpack: 'list',
shader: 'gallery',
datapack: 'list',
user: 'list',
},
}
}
return cosmetics.value
})
export const saveCosmetics = () => {
const cosmetics = useCosmetics()
const cosmeticsCookie = useCookie('cosmetics', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'Strict',
secure: true,
httpOnly: false,
path: '/',
})
cosmeticsCookie.value = cosmetics.value
}

18
composables/date.js Normal file
View File

@@ -0,0 +1,18 @@
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
// eslint-disable-next-line import/no-named-as-default-member
dayjs.extend(relativeTime)
export const useCurrentDate = () => useState('currentDate', () => Date.now())
export const updateCurrentDate = () => {
const currentDate = useCurrentDate()
currentDate.value = Date.now()
}
export const fromNow = (date) => {
const currentDate = useCurrentDate()
return dayjs(date).from(currentDate.value)
}

14
composables/fetch.js Normal file
View File

@@ -0,0 +1,14 @@
export const useBaseFetch = async (url, options = {}) => {
const config = useRuntimeConfig()
const base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (options.headers && process.server) {
options.headers['x-ratelimit-key'] = config.rateLimitKey
} else if (process.server) {
options.headers = {
'x-ratelimit-key': config.rateLimitKey,
}
}
return await $fetch(`${base}${url}`, options)
}

13
composables/loading.js Normal file
View File

@@ -0,0 +1,13 @@
export const useLoading = () => useState('loading', () => false)
export const startLoading = () => {
const loading = useLoading()
loading.value = true
}
export const stopLoading = () => {
const loading = useLoading()
loading.value = false
}

34
composables/notifs.js Normal file
View File

@@ -0,0 +1,34 @@
export const useNotifications = () => useState('notifications', () => [])
export const addNotification = (notification) => {
const notifications = useNotifications()
const existingNotif = notifications.value.find(
(x) =>
x.text === notification.text && x.title === notification.title && x.type === notification.type
)
if (existingNotif) {
setNotificationTimer(existingNotif)
return
}
notification.id = new Date()
setNotificationTimer(notification)
notifications.value.push(notification)
}
export const setNotificationTimer = (notification) => {
if (!notification) return
const notifications = useNotifications()
if (notification.timer) {
clearTimeout(notification.timer)
}
notification.timer = setTimeout(() => {
notifications.value.splice(notifications.value.indexOf(notification), 1)
}, 30000)
}

62
composables/tag.js Normal file
View File

@@ -0,0 +1,62 @@
import tags from '~/generated/state.json'
export const useTags = () =>
useState('tags', () => ({
categories: tags.categories,
loaders: tags.loaders,
gameVersions: tags.gameVersions,
donationPlatforms: tags.donationPlatforms,
reportTypes: tags.reportTypes,
projectTypes: [
{
actual: 'mod',
id: 'mod',
display: 'mod',
},
{
actual: 'mod',
id: 'plugin',
display: 'plugin',
},
{
actual: 'mod',
id: 'datapack',
display: 'data pack',
},
{
actual: 'shader',
id: 'shader',
display: 'shader',
},
{
actual: 'resourcepack',
id: 'resourcepack',
display: 'resource pack',
},
{
actual: 'modpack',
id: 'modpack',
display: 'modpack',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge'],
pluginPlatformLoaders: ['bungeecord', 'waterfall', 'velocity'],
allPluginLoaders: [
'bukkit',
'spigot',
'paper',
'purpur',
'sponge',
'bungeecord',
'waterfall',
'velocity',
],
dataPackLoaders: ['datapack'],
modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift'],
},
projectViewModes: ['list', 'grid', 'gallery'],
approvedStatuses: ['approved', 'archived', 'unlisted', 'private'],
rejectedStatuses: ['rejected', 'withheld'],
staffRoles: ['moderator', 'admin'],
}))

55
composables/theme.js Normal file
View File

@@ -0,0 +1,55 @@
export const useTheme = () =>
useState('theme', () => {
const colorMode = useCookie('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'Strict',
secure: true,
httpOnly: false,
path: '/',
})
if (!colorMode.value) {
colorMode.value = {
value: 'dark',
preference: 'system',
}
}
if (colorMode.value.preference !== 'system') {
colorMode.value.value = colorMode.value.preference
}
return colorMode.value
})
export const updateTheme = (value, updatePreference = false) => {
const theme = useTheme()
const themeCookie = useCookie('color-mode', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'Strict',
secure: true,
httpOnly: false,
path: '/',
})
if (value === 'system') {
theme.value.preference = 'system'
const colorSchemeQueryList = window.matchMedia('(prefers-color-scheme: light)')
if (colorSchemeQueryList.matches) {
theme.value.value = 'light'
} else {
theme.value.value = 'dark'
}
} else {
theme.value.value = value
if (updatePreference) theme.value.preference = value
}
if (process.client) {
document.documentElement.className = `${theme.value.value}-mode`
}
themeCookie.value = theme.value
}

112
composables/user.js Normal file
View File

@@ -0,0 +1,112 @@
export const useUser = async (force = false) => {
const user = useState('user', () => {})
if (!user.value || force || (user.value && Date.now() - user.value.lastUpdated > 300000)) {
user.value = await initUser()
}
return user
}
export const initUser = async () => {
const auth = (await useAuth()).value
const user = {
notifications: [],
follows: [],
projects: [],
lastUpdated: 0,
}
if (auth.user && auth.user.id) {
try {
const [notifications, follows, projects] = await Promise.all([
useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers),
useBaseFetch(`user/${auth.user.id}/follows`, auth.headers),
useBaseFetch(`user/${auth.user.id}/projects`, auth.headers),
])
user.notifications = notifications
user.follows = follows
user.projects = projects
user.lastUpdated = Date.now()
} catch (err) {
console.error(err)
}
}
return user
}
export const initUserNotifs = async () => {
const auth = (await useAuth()).value
const user = (await useUser()).value
if (auth.user && auth.user.id) {
try {
user.notifications = await useBaseFetch(`user/${auth.user.id}/notifications`, auth.headers)
} catch (err) {
console.error(err)
}
}
}
export const initUserFollows = async () => {
const auth = (await useAuth()).value
const user = (await useUser()).value
if (auth.user && auth.user.id) {
try {
user.follows = await useBaseFetch(`user/${auth.user.id}/follows`, auth.headers)
} catch (err) {
console.error(err)
}
}
}
export const initUserProjects = async () => {
const auth = (await useAuth()).value
const user = (await useUser()).value
if (auth.user && auth.user.id) {
try {
user.projects = await useBaseFetch(`user/${auth.user.id}/projects`, auth.headers)
} catch (err) {
console.error(err)
}
}
}
export const userFollowProject = async (project) => {
const auth = (await useAuth()).value
const user = (await useUser()).value
user.follows = user.follows.concat(project)
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: 'POST',
...auth.headers,
})
})
}
export const userUnfollowProject = async (project) => {
const auth = (await useAuth()).value
const user = (await useUser()).value
user.follows = user.follows.filter((x) => x.id !== project.id)
setTimeout(() => {
useBaseFetch(`project/${project.id}/follow`, {
method: 'DELETE',
...auth.headers,
})
})
}
export const userDeleteNotification = async (id) => {
const user = (await useUser()).value
user.notifications = user.notifications.filter((x) => x.id !== id)
}

View File

@@ -1,33 +1,34 @@
<template> <template>
<div class="main"> <NuxtLayout>
<div class="error"> <div class="main">
<Logo404 v-if="error.statusCode === 404" /> <div class="error">
<h1 v-else>An error occurred!</h1> <Logo404 v-if="error.statusCode === '404'" />
<p>{{ error.message }}</p> <h1 v-else>An error occurred!</h1>
<div class="button-group"> <p>{{ error.message }}</p>
<nuxt-link to="/" class="iconified-button raised-button brand-button"> <div class="button-group">
Go home <nuxt-link to="/" class="iconified-button raised-button brand-button">
</nuxt-link> Go home
<a </nuxt-link>
href="https://discord.gg/EUHuJHt" <a
class="iconified-button raised-button" href="https://discord.gg/EUHuJHt"
rel="noopener noreferrer nofollow" class="iconified-button raised-button"
> rel="noopener"
Get help on Discord >
</a> Get help on Discord
</a>
</div>
</div> </div>
</div> </div>
</div> </NuxtLayout>
</template> </template>
<script> <script>
import Logo404 from '~/assets/images/404.svg?inline' import Logo404 from './assets/images/404.svg'
export default { export default {
components: { components: {
Logo404, Logo404,
}, },
layout: 'home',
props: { props: {
error: { error: {
type: Object, type: Object,

32
helpers/fileUtils.js Normal file
View File

@@ -0,0 +1,32 @@
import { formatBytes } from '~/plugins/shorthands'
export const fileIsValid = (file, validationOptions) => {
const { maxSize, alertOnInvalid } = validationOptions
if (maxSize !== null && maxSize !== undefined && file.size > maxSize) {
if (alertOnInvalid) {
alert(`File ${file.name} is too big! Must be less than ${formatBytes(maxSize)}`)
}
return false
}
return true
}
export const acceptFileFromProjectType = (projectType) => {
switch (projectType) {
case 'mod':
return '.jar,.zip,.litemod,application/java-archive,application/x-java-archive,application/zip'
case 'plugin':
return '.jar,.zip,application/java-archive,application/x-java-archive,application/zip'
case 'resourcepack':
return '.zip,application/zip'
case 'shader':
return '.zip,application/zip'
case 'datapack':
return '.zip,application/zip'
case 'modpack':
return '.mrpack,application/x-modrinth-modpack+zip,application/zip'
default:
return '*'
}
}

View File

@@ -1,4 +1,3 @@
import Vue from 'vue'
import hljs from 'highlight.js/lib/core' import hljs from 'highlight.js/lib/core'
// Scripting // Scripting
import javascript from 'highlight.js/lib/languages/javascript' import javascript from 'highlight.js/lib/languages/javascript'
@@ -16,6 +15,7 @@ import ini from 'highlight.js/lib/languages/ini'
import yaml from 'highlight.js/lib/languages/yaml' import yaml from 'highlight.js/lib/languages/yaml'
import xml from 'highlight.js/lib/languages/xml' import xml from 'highlight.js/lib/languages/xml'
import properties from 'highlight.js/lib/languages/properties' import properties from 'highlight.js/lib/languages/properties'
import { md, configuredXss } from '~/helpers/parse'
/* REGISTRATION */ /* REGISTRATION */
// Scripting // Scripting
@@ -37,38 +37,27 @@ hljs.registerLanguage('properties', properties)
/* ALIASES */ /* ALIASES */
// Scripting // Scripting
hljs.registerAliases(['js'], 'javascript') hljs.registerAliases(['js'], { languageName: 'javascript' })
hljs.registerAliases(['py'], 'python') hljs.registerAliases(['py'], { languageName: 'python' })
// Coding // Coding
hljs.registerAliases(['kt'], 'kotlin') hljs.registerAliases(['kt'], { languageName: 'kotlin' })
// Configs // Configs
hljs.registerAliases(['json5'], 'json') hljs.registerAliases(['json5'], { languageName: 'json' })
hljs.registerAliases(['toml'], 'ini') hljs.registerAliases(['toml'], { languageName: 'ini' })
hljs.registerAliases(['yml'], 'yaml') hljs.registerAliases(['yml'], { languageName: 'yaml' })
hljs.registerAliases(['html', 'htm', 'xhtml', 'mcui', 'fxml'], 'xml') hljs.registerAliases(['html', 'htm', 'xhtml', 'mcui', 'fxml'], { languageName: 'xml' })
Vue.directive('highlightjs', { export const renderHighlightedString = (string) =>
deep: true, configuredXss.process(
bind(el, binding) { md({
// on first bind, highlight all targets highlight: function (str, lang) {
const targets = el.querySelectorAll('pre > code') if (lang && hljs.getLanguage(lang)) {
targets.forEach((target) => { try {
// if a value is directly assigned to the directive, use this return hljs.highlight(str, { language: lang }).value
// instead of the element content. } catch (__) {}
if (binding.value) { }
target.textContent = binding.value
} return ''
hljs.highlightBlock(target) },
}) }).render(string)
}, )
componentUpdated(el, binding) {
// after an update, re-fill the content and then highlight
const targets = el.querySelectorAll('pre > code')
targets.forEach((target) => {
if (binding.value) {
target.textContent = binding.value
hljs.highlightBlock(target)
}
})
},
})

305
helpers/infer.js Normal file
View File

@@ -0,0 +1,305 @@
import TOML from 'toml'
import JSZip from 'jszip'
import yaml from 'js-yaml'
export const inferVersionInfo = async function (rawFile, project, gameVersions) {
function versionType(number) {
if (number.includes('alpha')) {
return 'alpha'
} else if (
number.includes('beta') ||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
) {
return 'beta'
} else {
return 'release'
}
}
// TODO: This func does not handle accurate semver parsing. We should eventually
function gameVersionRange(gameVersionString, gameVersions) {
if (!gameVersionString) {
return []
}
// Truncate characters after `-` & `+`
const gameString = gameVersionString.replace(/-|\+.*$/g, '')
let prefix = ''
if (gameString.includes('~')) {
// Include minor versions
// ~1.2.3 -> 1.2
prefix = gameString.replace('~', '').split('.').slice(0, 2).join('.')
} else if (gameString.includes('>=')) {
// Include minor versions
// >=1.2.3 -> 1.2
prefix = gameString.replace('>=', '').split('.').slice(0, 2).join('.')
} else if (gameString.includes('^')) {
// Include major versions
// ^1.2.3 -> 1
prefix = gameString.replace('^', '').split('.')[0]
} else if (gameString.includes('x')) {
// Include versions that match `x.x.x`
// 1.2.x -> 1.2
prefix = gameString.replace(/\.x$/, '')
} else {
// Include exact version
// 1.2.3 -> 1.2.3
prefix = gameString
}
const simplified = gameVersions
.filter((it) => it.version_type === 'release')
.map((it) => it.version)
return simplified.filter((version) => version.startsWith(prefix))
}
const inferFunctions = {
// Forge 1.13+
'META-INF/mods.toml': async (file, zip) => {
const metadata = TOML.parse(file)
// TODO: Parse minecraft version ranges, handle if version is set to value from manifest
if (metadata.mods && metadata.mods.length > 0) {
let versionNum = metadata.mods[0].version
// ${file.jarVersion} -> Implementation-Version from manifest
const manifestFile = zip.file('META-INF/MANIFEST.MF')
if (
// eslint-disable-next-line no-template-curly-in-string
metadata.mods[0].version.includes('${file.jarVersion}') &&
manifestFile !== null
) {
const manifestText = await manifestFile.async('text')
const regex = /Implementation-Version: (.*)$/m
const match = manifestText.match(regex)
if (match) {
// eslint-disable-next-line no-template-curly-in-string
versionNum = versionNum.replace('${file.jarVersion}', match[1])
}
}
return {
name: `${project.title} ${versionNum}`,
version_number: versionNum,
version_type: versionType(versionNum),
loaders: ['forge'],
}
} else {
return {}
}
},
// Old Forge
'mcmod.info': (file) => {
const metadata = JSON.parse(file)
return {
name: metadata.version ? `${project.title} ${metadata.version}` : '',
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['forge'],
game_versions: gameVersions
.filter((x) => x.version.startsWith(metadata.mcversion) && x.version_type === 'release')
.map((x) => x.version),
}
},
// Fabric
'fabric.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders: ['fabric'],
version_type: versionType(metadata.version),
game_versions: metadata.depends
? gameVersionRange(metadata.depends.minecraft, gameVersions)
: [],
}
},
// Quilt
'quilt.mod.json': (file) => {
const metadata = JSON.parse(file)
return {
name: `${project.title} ${metadata.quilt_loader.version}`,
version_number: metadata.quilt_loader.version,
loaders: ['quilt'],
version_type: versionType(metadata.quilt_loader.version),
game_versions: metadata.quilt_loader.depends
? gameVersionRange(
metadata.quilt_loader.depends.find((x) => x.id === 'minecraft')
? metadata.quilt_loader.depends.find((x) => x.id === 'minecraft').versions
: [],
gameVersions
)
: [],
}
},
// Bukkit + Other Forks
'plugin.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
// We don't know which fork of Bukkit users are using
loaders: [],
game_versions: gameVersions
.filter(
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release'
)
.map((x) => x.version),
}
},
// Bungeecord + Waterfall
'bungee.yml': (file) => {
const metadata = yaml.load(file)
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
version_type: versionType(metadata.version),
loaders: ['bungeecord'],
}
},
// Modpacks
'modrinth.index.json': (file) => {
const metadata = JSON.parse(file)
const loaders = []
if ('forge' in metadata.dependencies) {
loaders.push('forge')
}
if ('fabric-loader' in metadata.dependencies) {
loaders.push('fabric')
}
if ('quilt-loader' in metadata.dependencies) {
loaders.push('quilt')
}
return {
name: `${project.title} ${metadata.versionId}`,
version_number: metadata.versionId,
version_type: versionType(metadata.versionId),
loaders,
game_versions: gameVersions
.filter((x) => x.version === metadata.dependencies.minecraft)
.map((x) => x.version),
}
},
// Resource Packs + Data Packs
'pack.mcmeta': (file) => {
const metadata = JSON.parse(file)
function getRange(versionA, versionB) {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
const final = []
const filterOnlyRelease = gameVersions[startingIndex].version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
const loaders = []
let newGameVersions = []
if (project.actualProjectType === 'mod') {
loaders.push('datapack')
switch (metadata.pack.pack_format) {
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.1')
break
case 9:
newGameVersions.push('1.18.2')
break
case 10:
newGameVersions = getRange('1.19', '1.19.3')
break
default:
}
}
if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
switch (metadata.pack.pack_format) {
case 1:
newGameVersions = getRange('1.6.1', '1.8.9')
break
case 2:
newGameVersions = getRange('1.9', '1.10.2')
break
case 3:
newGameVersions = getRange('1.11', '1.12.2')
break
case 4:
newGameVersions = getRange('1.13', '1.14.4')
break
case 5:
newGameVersions = getRange('1.15', '1.16.1')
break
case 6:
newGameVersions = getRange('1.16.2', '1.16.5')
break
case 7:
newGameVersions = getRange('1.17', '1.17.1')
break
case 8:
newGameVersions = getRange('1.18', '1.18.2')
break
case 9:
newGameVersions = getRange('1.19', '1.19.2')
break
case 11:
newGameVersions = getRange('22w42a', '22w44a')
break
case 12:
newGameVersions.push('1.19.3')
break
default:
}
}
return {
loaders,
game_versions: newGameVersions,
}
},
}
const zipReader = new JSZip()
const zip = await zipReader.loadAsync(rawFile)
for (const fileName in inferFunctions) {
const file = zip.file(fileName)
if (file !== null) {
const text = await file.async('text')
return inferFunctions[fileName](text, zip)
}
}
}

198
helpers/package.js Normal file
View File

@@ -0,0 +1,198 @@
import JSZip from 'jszip'
import TOML from 'toml'
export const createDataPackVersion = async function (
project,
version,
primaryFile,
members,
allGameVersions,
loaders
) {
// force version to start with number, as required by FML
const newVersionNumber = version.version_number.match(/^\d/)
? version.version_number
: `1-${version.version_number}`
const targetStartingDigitsRegex = /^(\d+)(\D+)$/g
const newSlug = `${project.slug
.replace('-', '_')
.replace(/\W/g, '')
.replace(targetStartingDigitsRegex, '$2')
.replace(/^(\d+)$/g, project.id.replace(targetStartingDigitsRegex, '$2'))
.substring(0, 63)}_mr`
const iconPath = `${project.slug}_pack.png`
const fabricModJson = {
schemaVersion: 1,
id: newSlug,
version: newVersionNumber,
name: project.title,
description: project.description,
authors: members.map((x) => x.name),
contact: {
homepage: `${process.env.domain}/${project.project_type}/${project.slug ?? project.id}`,
},
license: project.license.id,
icon: iconPath,
environment: '*',
depends: {
'fabric-resource-loader-v0': '*',
},
}
const quiltModJson = {
schema_version: 1,
quilt_loader: {
group: 'com.modrinth',
id: newSlug,
version: newVersionNumber,
metadata: {
name: project.title,
description: project.description,
contributors: members.reduce(
(acc, x) => ({
...acc,
[x.name]: x.role,
}),
{}
),
contact: {
homepage: `${process.env.domain}/${project.project_type}/${project.slug ?? project.id}`,
},
icon: iconPath,
},
intermediate_mappings: 'net.fabricmc:intermediary',
depends: [
{
id: 'quilt_resource_loader',
versions: '*',
unless: 'fabric-resource-loader-v0',
},
],
},
}
const cutoffIndex = allGameVersions.findIndex((x) => x.version === '1.18.2')
let maximumIndex = Number.MIN_VALUE
for (const val of version.game_versions) {
const index = allGameVersions.findIndex((x) => x.version === val)
if (index > maximumIndex) {
maximumIndex = index
}
}
const newForge = maximumIndex < cutoffIndex
const forgeModsToml = {
modLoader: newForge ? 'lowcodefml' : 'javafml',
loaderVersion: newForge ? '[40,)' : '[25,)',
license: project.license.id,
showAsResourcePack: false,
mods: [
{
modId: newSlug,
version: newVersionNumber,
displayName: project.title,
description: project.description,
logoFile: iconPath,
updateJSONURL: `${getAuthUrl().replace('/v2/', '')}/updates/${
project.id
}/forge_updates.json`,
credits: 'Generated by Modrinth',
authors: members.map((x) => x.name).join(', '),
displayURL: `${process.env.domain}/${project.project_type}/${project.slug ?? project.id}`,
},
],
}
if (project.source_url) {
quiltModJson.quilt_loader.metadata.contact.sources = project.source_url
fabricModJson.contact.sources = project.source_url
}
if (project.issues_url) {
quiltModJson.quilt_loader.metadata.contact.issues = project.issues_url
fabricModJson.contact.issues = project.issues_url
forgeModsToml.issueTrackerURL = project.issues_url
}
const primaryFileData = await (await fetch(primaryFile.url)).blob()
const primaryZipReader = new JSZip()
await primaryZipReader.loadAsync(primaryFileData)
if (loaders.includes('fabric')) {
primaryZipReader.file('fabric.mod.json', JSON.stringify(fabricModJson))
}
if (loaders.includes('quilt')) {
primaryZipReader.file('quilt.mod.json', JSON.stringify(quiltModJson))
}
if (loaders.includes('forge')) {
primaryZipReader.file('META-INF/mods.toml', TOML.stringify(forgeModsToml))
}
if (!newForge && loaders.includes('forge')) {
const classFile = new Uint8Array(
await (
await fetch('https://cdn.modrinth.com/wrapper/ModrinthWrapperRestiched.class')
).arrayBuffer()
)
let binary = ''
for (let i = 0; i < classFile.byteLength; i++) {
binary += String.fromCharCode(classFile[i])
}
let sanitizedId = project.id
if (project.id.match(/^(\d+)/g)) {
sanitizedId = '_' + sanitizedId
}
sanitizedId = sanitizedId.substring(0, 8)
binary = binary
.replace(
String.fromCharCode(32) + 'needs1to1be1changed1modrinth1mod',
String.fromCharCode(newSlug.length) + newSlug
)
.replace('/wrappera/', `/${sanitizedId}/`)
const newArr = []
for (let i = 0; i < binary.length; i++) {
newArr.push(binary.charCodeAt(i))
}
primaryZipReader.file(
`com/modrinth/${sanitizedId}/ModrinthWrapper.class`,
new Uint8Array(newArr)
)
}
const resourcePack = version.files.find((x) => x.file_type === 'required-resource-pack')
const resourcePackData = resourcePack ? await (await fetch(resourcePack.url)).blob() : null
if (resourcePackData) {
const resourcePackReader = new JSZip()
await resourcePackReader.loadAsync(resourcePackData)
for (const [path, file] of Object.entries(resourcePackReader.files)) {
if (!primaryZipReader.file(path) && !path.includes('.mcassetsroot')) {
primaryZipReader.file(path, await file.async('uint8array'))
}
}
}
if (primaryZipReader.file('pack.png')) {
primaryZipReader.file(iconPath, await primaryZipReader.file('pack.png').async('uint8array'))
}
return await primaryZipReader.generateAsync({
type: 'blob',
mimeType: 'application/java-archive',
})
}

131
helpers/parse.js Normal file
View File

@@ -0,0 +1,131 @@
import MarkdownIt from 'markdown-it'
import xss from 'xss'
export const configuredXss = new xss.FilterXSS({
whiteList: {
...xss.whiteList,
summary: [],
h1: ['id'],
h2: ['id'],
h3: ['id'],
h4: ['id'],
h5: ['id'],
h6: ['id'],
kbd: ['id'],
input: ['checked', 'disabled', 'type'],
iframe: ['width', 'height', 'allowfullscreen', 'frameborder', 'start', 'end'],
img: [...xss.whiteList.img, 'style'],
a: [...xss.whiteList.a, 'rel'],
},
css: {
whiteList: {
'image-rendering': /^pixelated$/,
},
},
onIgnoreTagAttr: (tag, name, value) => {
// Allow iframes from acceptable sources
if (tag === 'iframe' && name === 'src') {
const allowedSources = [
{
regex:
/^https?:\/\/(www\.)?youtube(-nocookie)?\.com\/embed\/[a-zA-Z0-9_-]{11}(\?&autoplay=[0-1]{1})?$/,
remove: ['&autoplay=1'], // Prevents autoplay
},
]
for (const source of allowedSources) {
if (source.regex.test(value)) {
for (const remove of source.remove) {
value = value.replace(remove, '')
}
return name + '="' + xss.escapeAttrValue(value) + '"'
}
}
}
// For Highlight.JS
if (
name === 'class' &&
['pre', 'code', 'span'].includes(tag) &&
(value.startsWith('hljs-') || value.startsWith('language-'))
) {
return name + '="' + xss.escapeAttrValue(value) + '"'
}
},
})
export const md = (options = {}) => {
const md = new MarkdownIt('default', {
html: true,
linkify: true,
breaks: false,
...options,
})
const defaultLinkOpenRenderer =
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const index = token.attrIndex('href')
if (index !== -1) {
const href = token.attrs[index][1]
try {
const url = new URL(href)
const allowedHostnames = ['modrinth.com']
if (allowedHostnames.includes(url.hostname)) {
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
}
} catch (err) {}
}
tokens[idx].attrSet('rel', 'noopener nofollow ugc')
return defaultLinkOpenRenderer(tokens, idx, options, env, self)
}
const defaultImageRenderer =
md.renderer.rules.image ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options)
}
md.renderer.rules.image = function (tokens, idx, options, env, self) {
const token = tokens[idx]
const index = token.attrIndex('src')
if (index !== -1) {
const src = token.attrs[index][1]
const url = new URL(src)
try {
const allowedHostnames = [
'i.imgur.com',
'cdn-raw.modrinth.com',
'cdn.modrinth.com',
'staging-cdn-raw.modrinth.com',
'staging-cdn.modrinth.com',
'raw.githubusercontent.com',
'img.shields.io',
]
if (allowedHostnames.includes(url.hostname)) {
return defaultImageRenderer(tokens, idx, options, env, self)
}
} catch (err) {}
token.attrs[index][1] = `//wsrv.nl/?url=${encodeURIComponent(src)}`
}
return defaultImageRenderer(tokens, idx, options, env, self)
}
return md
}
export const renderString = (string) => configuredXss.process(md().render(string))

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
export default function (context) {
if (!process.client) {
return
}
if (
context.from &&
context.route &&
context.from.path === context.route.path
) {
return
}
setTimeout(() => {
context.$axios
.post(`${context.$config.analytics.base_url}view`, {
url: process.env.domain + context.route.fullPath,
})
.then(() => {})
.catch((e) => {
console.error('An error occurred while registering the visit: ', e)
})
})
}

View File

@@ -1,73 +1,7 @@
export default async function (context) { export default defineNuxtRouteMiddleware(async () => {
if (!context.from) { const auth = await useAuth()
if (context.app.$cookies.get('auth-token-reset')) {
// Only remove the cookie related to the auth, instead of removing everything
context.app.$cookies.remove('auth-token')
context.app.$cookies.remove('auth-token-reset')
return
}
if (context.route.query.code) { if (!auth.value.user) {
const date = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days return navigateTo(getAuthUrl(), { external: true })
context.app.$cookies.set('auth-token', context.route.query.code, {
secure: true,
sameSite: 'Strict',
httpOnly: true,
expires: date,
path: '/',
})
await context.store.dispatch('auth/fetchUser', {
token: context.route.query.code,
})
} else if (context.app.$cookies.get('auth-token')) {
const cookie = context.app.$cookies.get('auth-token')
await context.store.dispatch('auth/fetchUser', { token: cookie })
}
} }
})
// Disable middleware if options: { auth: false } is set on the route
if (routeOption(context.route, 'auth', false)) return
// Disable middleware if no route was matched to allow 404/error page
if (!getMatchedComponents(context.route, []).length) {
return
}
if (!context.$auth.user) {
return context.redirect(
`${process.env.authURLBase}auth/init?url=${process.env.domain}${context.route.fullPath}`
)
}
}
function routeOption(route, key, value) {
return route.matched.some((m) => {
if (process.client) {
// Client
return Object.values(m.components).some(
(component) => component.options && component.options[key] === value
)
} else {
// SSR
return Object.values(m.components).some((component) =>
Object.values(component._Ctor).some(
(ctor) => ctor.options && ctor.options[key] === value
)
)
}
})
}
function getMatchedComponents(route, matches) {
return [].concat(
...[],
...route.matched.map((m, index) => {
return Object.keys(m.components).map((key) => {
matches.push(index)
return m.components[key]
})
})
)
}

View File

@@ -1,414 +1,288 @@
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { sortRoutes } from '@nuxt/utils' import svgLoader from 'vite-svg-loader'
import axios from 'axios' import eslintPlugin from 'vite-plugin-eslint'
import { resolve } from 'pathe'
import { defineNuxtConfig } from 'nuxt/config'
import { $fetch } from 'ofetch'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/' const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const STAGING_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/' const STAGING_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/'
export default { export default defineNuxtConfig({
/* app: {
** Nuxt target head: {
** See https://nuxtjs.org/api/configuration-target htmlAttrs: {
*/ lang: 'en',
target: 'server', },
/* title: 'Modrinth',
** Headers of the page meta: [
** See https://nuxtjs.org/api/configuration-head {
*/ name: 'description',
head: { content:
htmlAttrs: { 'Download Minecraft mods, plugins, datapacks, shaders, resourcepacks, and modpacks on Modrinth. Discover and publish projects on Modrinth with a modern, easy to use interface and API.',
lang: 'en', },
{
name: 'publisher',
content: 'Rinth, Inc.',
},
{
name: 'og:title',
content: 'Modrinth',
},
{
name: 'apple-mobile-web-app-title',
content: 'Modrinth',
},
{
name: 'theme-color',
content: '#1bd96a',
},
{
name: 'color-scheme',
content: 'dark light',
},
{
name: 'og:site_name',
content: 'Modrinth',
},
{
name: 'og:description',
content: 'An open source modding platform',
},
{
name: 'og:type',
content: 'website',
},
{
name: 'og:url',
content: 'https://modrinth.com',
},
{
name: 'og:image',
content: 'https://cdn.modrinth.com/modrinth-new.png?',
},
{
name: 'twitter:card',
content: 'summary',
},
{
name: 'twitter:site',
content: '@modrinth',
},
],
link: [
{
rel: 'preload',
href: 'https://cdn-raw.modrinth.com/fonts/inter/Inter-Regular.woff2?v=3.19',
as: 'font',
type: 'font/woff2',
crossorigin: true,
},
{
rel: 'preload',
href: 'https://cdn-raw.modrinth.com/fonts/inter/Inter-Medium.woff2?v=3.19',
as: 'font',
type: 'font/woff2',
crossorigin: true,
},
{
rel: 'preload',
href: 'https://cdn-raw.modrinth.com/fonts/inter/Inter-SemiBold.woff2?v=3.19',
as: 'font',
type: 'font/woff2',
crossorigin: true,
},
{
rel: 'preload',
href: 'https://cdn-raw.modrinth.com/fonts/inter/Inter-Bold.woff2?v=3.19',
as: 'font',
type: 'font/woff2',
crossorigin: true,
},
{
rel: 'preload',
href: 'https://cdn-raw.modrinth.com/fonts/inter/Inter-ExtraBold.woff2?v=3.19',
as: 'font',
type: 'font/woff2',
crossorigin: true,
},
{
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: 'search',
type: 'application/opensearchdescription+xml',
href: '/opensearch.xml',
title: 'Modrinth mods',
},
],
}, },
title: 'Modrinth', },
meta: [ vite: {
{ plugins: [
charset: 'utf-8', svgLoader({
}, svgoConfig: {
{ plugins: [
name: 'viewport', {
content: 'width=device-width, initial-scale=1', name: 'preset-default',
}, params: {
{ overrides: {
hid: 'description', removeViewBox: false,
name: 'description', },
content: },
'Download Minecraft mods, plugins, datapacks, shaders, resourcepacks, and modpacks on Modrinth. Discover and publish projects on Modrinth with a modern, easy to use interface and API.', },
}, ],
{ },
hid: 'publisher', }),
name: 'publisher', eslintPlugin(),
content: 'Rinth, Inc.',
},
{
hid: 'og:title',
name: 'og:title',
content: 'Modrinth',
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: 'Modrinth',
},
{
hid: 'theme-color',
name: 'theme-color',
content: '#1bd96a',
},
{
hid: 'color-scheme',
name: 'color-scheme',
content: 'light dark',
},
{
hid: 'og:site_name',
name: 'og:site_name',
content: 'Modrinth',
},
{
hid: 'og:description',
name: 'og:description',
content: 'An open source modding platform',
},
{
hid: 'og:type',
name: 'og:type',
content: 'website',
},
{
hid: 'og:url',
name: 'og:url',
content: 'https://modrinth.com',
},
{
hid: 'og:image',
name: 'og:image',
content: 'https://cdn.modrinth.com/modrinth-new.png?',
},
{
hid: 'twitter:card',
name: 'twitter:card',
content: 'summary',
},
{
hid: 'twitter:site',
name: 'twitter:site',
content: '@modrinth',
},
], ],
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',
href: 'https://cdn-raw.modrinth.com/fonts/inter/inter.css',
},
{
rel: 'search',
type: 'application/opensearchdescription+xml',
href: '/opensearch.xml',
title: 'Modrinth mods',
},
],
},
vue: {
config: {
devtools: true,
},
},
router: {
extendRoutes(routes, resolve) {
routes.splice(
routes.findIndex((x) => x.name === 'search'),
1
)
routes.push({
path: '/search',
component: resolve(__dirname, 'pages/search.vue'),
name: 'search',
chunkName: 'pages/search',
children: [
{
path: '/mods',
component: resolve(__dirname, 'pages/search/mods.vue'),
name: 'mods',
},
{
path: '/modpacks',
component: resolve(__dirname, 'pages/search/modpacks.vue'),
name: 'modpacks',
},
{
path: '/plugins',
component: resolve(__dirname, 'pages/search/plugins.vue'),
name: 'plugins',
},
{
path: '/resourcepacks',
component: resolve(__dirname, 'pages/search/resourcepacks.vue'),
name: 'resourcepacks',
},
{
path: '/shaders',
component: resolve(__dirname, 'pages/search/shaders.vue'),
name: 'shaders',
},
{
path: '/datapacks',
component: resolve(__dirname, 'pages/search/datapacks.vue'),
name: 'datapacks',
},
],
})
sortRoutes(routes)
},
middleware: ['auth', 'analytics'],
},
/*
** Global CSS
*/
css: ['~assets/styles/global.scss'],
/*
** Plugins to load before mounting the App
** https://nuxtjs.org/guide/plugins
*/
plugins: [
'~/plugins/vue-tooltip.js',
'~/plugins/vue-notification.js',
'~/plugins/xss.js',
'~/plugins/vue-syntax.js',
'~/plugins/shorthands.js',
'~/plugins/markdown.js',
],
/*
** Auto import components
** See https://nuxtjs.org/api/configuration-components
*/
components: true,
/*
** Nuxt.js dev-modules
*/
buildModules: [
// Doc: https://github.com/nuxt-community/eslint-module
'@nuxtjs/eslint-module',
'@nuxtjs/svg',
'@nuxtjs/color-mode',
],
/*
** Nuxt.js modules
*/
modules: [
// Doc: https://axios.nuxtjs.org/usage
'@nuxtjs/dayjs',
'@nuxtjs/axios',
'@nuxtjs/style-resources',
'cookie-universal-nuxt',
],
ads: {
// Module options
ghostMode: true,
geoEdgeId: '',
},
robots: {
Sitemap: 'https://modrinth.com/sitemap.xml',
},
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {
baseURL: getApiUrl(),
headers: {
common: {
Accept: 'application/json',
},
},
}, },
dayjs: { dayjs: {
locales: ['en'], locales: ['en'],
defaultLocale: 'en', defaultLocale: 'en',
plugins: ['relativeTime'], plugins: ['relativeTime'],
}, },
/*
** Build configuration
** See https://nuxtjs.org/api/configuration-build/
*/
build: {
transpile: ['vue-tooltip', 'vue-notification'],
html: {
minify: {
collapseWhitespace: true, // as @dario30186 mentioned
removeComments: true, // 👈 add this line
},
},
babel: {
plugins: [
[
'@babel/plugin-proposal-private-methods',
{
loose: true,
},
],
],
},
},
loading: {
color: '#1bd96a',
height: '2px',
},
env: {
owner: process.env.VERCEL_GIT_REPO_OWNER || 'modrinth',
slug: process.env.VERCEL_GIT_REPO_SLUG || 'knossos',
branch: process.env.VERCEL_GIT_COMMIT_REF || 'master',
hash: process.env.VERCEL_GIT_COMMIT_SHA || 'unknown',
domain: getDomain(),
authURLBase: getApiUrl(),
},
publicRuntimeConfig: {
axios: {
browserBaseURL: process.env.BROWSER_BASE_URL,
},
ads: {
ethicalAds: process.env.ETHICAL_ADS,
},
analytics: {
base_url: process.env.BROWSER_ARIADNE_URL || STAGING_ARIADNE_URL,
},
},
privateRuntimeConfig: {
axios: {
baseURL: process.env.BASE_URL,
headers: {
common: {
'x-ratelimit-key': process.env.RATE_LIMIT_IGNORE_KEY || '',
},
},
},
},
hooks: { hooks: {
build: { async 'build:before'() {
async before(nuxt, buildOptions) { // 30 minutes
// 30 minutes const TTL = 30 * 60 * 1000
const TTL = 30 * 60 * 1000
let state = {} let state = {}
try { try {
state = JSON.parse( state = JSON.parse(await fs.readFile('./generated/state.json', 'utf8'))
await fs.readFile('./generated/state.json', 'utf8') } catch {
) // File doesn't exist, create folder
} catch { await fs.mkdir('./generated', { recursive: true })
// File doesn't exist, create folder }
await fs.mkdir('./generated', { recursive: true })
}
const API_URL = getApiUrl() const API_URL = getApiUrl()
if ( if (
// Skip regeneration if within TTL... // Skip regeneration if within TTL...
state.lastGenerated && state.lastGenerated &&
new Date(state.lastGenerated).getTime() + TTL > new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
new Date().getTime() && // ...but only if the API URL is the same
// ...but only if the API URL is the same state.apiUrl &&
state.apiUrl && state.apiUrl === API_URL
state.apiUrl === API_URL ) {
) { return
return }
}
console.log('Generating tags...') state.lastGenerated = new Date().toISOString()
state.lastGenerated = new Date().toISOString() state.apiUrl = API_URL
state.apiUrl = API_URL const headers = {
headers: {
'user-agent': 'Knossos generator (support@modrinth.com)',
},
}
const headers = { const [categories, loaders, gameVersions, donationPlatforms, reportTypes] = await Promise.all(
headers: { [
'user-agent': `Knossos generator (admin@modrinth.com)`, $fetch(`${API_URL}tag/category`, headers),
}, $fetch(`${API_URL}tag/loader`, headers),
} $fetch(`${API_URL}tag/game_version`, headers),
$fetch(`${API_URL}tag/donation_platform`, headers),
$fetch(`${API_URL}tag/report_type`, headers),
]
)
const [ state.categories = categories
categories, state.loaders = loaders
loaders, state.gameVersions = gameVersions
gameVersions, state.donationPlatforms = donationPlatforms
donationPlatforms, state.reportTypes = reportTypes
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/donation_platform`, headers),
axios.get(`${API_URL}tag/report_type`, headers),
])
).map((it) => it.data)
state.categories = categories await fs.writeFile('./generated/state.json', JSON.stringify(state))
state.loaders = loaders
state.gameVersions = gameVersions
state.donationPlatforms = donationPlatforms
state.reportTypes = reportTypes
await fs.writeFile('./generated/state.json', JSON.stringify(state)) console.log('Tags generated!')
console.log('Tags generated!')
},
}, },
render: { 'pages:extend'(routes) {
routeDone(url, result, context) { routes.splice(
setTimeout(() => { routes.findIndex((x) => x.name === 'search-searchProjectType'),
axios 1
.post( )
`${process.env.ARIADNE_URL || STAGING_ARIADNE_URL}view`,
{ routes.push({
url: getDomain() + url, name: 'search-mods',
ip: path: '/mods',
context.req.headers['cf-connecting-ip'] ?? file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
context.req.headers['x-real-ip'] ?? children: [],
context.req.connection.remoteAddress, })
headers: context.req.headers, routes.push({
}, name: 'search-modpacks',
{ path: '/modpacks',
headers: { file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
'Modrinth-Admin': process.env.ARIADNE_ADMIN_KEY || 'feedbeef', children: [],
}, })
} routes.push({
) name: 'search-plugins',
.then(() => {}) path: '/plugins',
.catch((e) => { file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
console.error( children: [],
'An error occurred while registering the visit: ', })
e.response ? e.response.data : e routes.push({
) name: 'search-resourcepacks',
}) path: '/resourcepacks',
}) file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
}, children: [],
})
routes.push({
name: 'search-shaders',
path: '/shaders',
file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
children: [],
})
routes.push({
name: 'search-datapacks',
path: '/datapacks',
file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
children: [],
})
}, },
}, },
} runtimeConfig: {
apiBaseUrl: process.env.BASE_URL ?? getApiUrl(),
ariadneBaseUrl: process.env.ARIADNE_URL ?? getAriadneUrl(),
ariadneAdminKey: process.env.ARIADNE_ADMIN_KEY,
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY,
public: {
apiBaseUrl: getApiUrl(),
ariadneBaseUrl: getAriadneUrl(),
siteUrl: getDomain(),
owner: process.env.VERCEL_GIT_REPO_OWNER || 'modrinth',
slug: process.env.VERCEL_GIT_REPO_SLUG || 'knossos',
branch: process.env.VERCEL_GIT_COMMIT_REF || 'master',
hash: process.env.VERCEL_GIT_COMMIT_SHA || 'unknown',
},
},
})
function getApiUrl() { function getApiUrl() {
return process.env.BROWSER_BASE_URL ?? STAGING_API_URL return process.env.BROWSER_BASE_URL ?? STAGING_API_URL
} }
function getAriadneUrl() {
return process.env.BROWSER_ARIADNE_URL ?? STAGING_ARIADNE_URL
}
function getDomain() { function getDomain() {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
if (process.env.SITE_URL) { if (process.env.SITE_URL) {

29410
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,38 @@
{ {
"name": "knossos",
"version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nuxt", "build": "nuxi build",
"build": "nuxt build", "dev": "nuxi dev",
"start": "nuxt start", "generate": "nuxi generate",
"export": "nuxt export", "preview": "nuxi preview",
"serve": "nuxt serve", "postinstall": "nuxi prepare",
"lint:js": "eslint --ext .js,.vue --ignore-path .eslintignore .", "lint:js": "eslint --ext .js,.vue,.ts,.jsx,.tsx,.html,.vue .",
"lint": "npm run lint:js", "lint": "npm run lint:js && prettier --check .",
"fix": "eslint --fix --ext .js,.vue --ignore-path .eslintignore ." "fix": "eslint --fix --ext .js,.vue,.ts,.jsx,.tsx,.html,.vue ."
},
"devDependencies": {
"@nuxtjs/eslint-config-typescript": "^12.0.0",
"@typescript-eslint/eslint-plugin": "^5.50.0",
"@typescript-eslint/parser": "^5.50.0",
"eslint": "^8.33.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-vue": "^9.9.0",
"nuxt": "^3.2.3",
"prettier": "^2.8.3",
"sass": "^1.58.0",
"typescript": "^4.9.5",
"vite-plugin-eslint": "^1.8.1",
"vite-svg-loader": "^4.0.0"
}, },
"dependencies": { "dependencies": {
"@iarna/toml": "^2.2.5", "dayjs": "^1.11.7",
"@nuxtjs/axios": "^5.13.1", "floating-vue": "^2.0.0-beta.20",
"@nuxtjs/dayjs": "^1.2.0", "highlight.js": "^11.7.0",
"@nuxtjs/style-resources": "^1.0.0",
"cookie-universal-nuxt": "^2.1.5",
"core-js": "^3.9.1",
"highlight.js": "^10.3.2",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"nuxt": "^2.15.3", "toml": "^3.0.0",
"sass": "^1.32.12", "vue-multiselect": "^3.0.0-alpha.2",
"v-tooltip": "^2.0.3", "xss": "^1.0.14"
"vue-click-outside": "^1.1.0",
"vue-highlightjs": "^1.3.3",
"vue-multiselect": "^2.1.6",
"vue-notification": "^1.3.20",
"xss": "^1.0.8"
},
"devDependencies": {
"@nuxtjs/color-mode": "^2.1.1",
"@nuxtjs/eslint-config": "^6.0.0",
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/svg": "^0.1.12",
"babel-eslint": "^10.1.0",
"eslint": "^7.22.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-nuxt": "^2.0.0",
"eslint-plugin-prettier": "^3.3.1",
"prettier": "^2.2.1",
"sass-loader": "^10.1.1"
},
"optionalDependencies": {
"fsevents": "^2.3.2"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
<template>
<div class="content">
<Head>
<Title> {{ project.title }} - Changelog </Title>
<Meta name="og:title" :content="`${props.project.title} - Changelog`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${props.project.title} - Changelog`" />
<Meta name="og:description" :content="metaDescription" />
</Head>
<VersionFilterControl
:versions="props.versions"
@update-versions="
(v) => {
filteredVersions = v
switchPage(1)
}
"
/>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div class="card">
<div
v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id"
class="changelog-item"
>
<div
:class="`changelog-bar ${version.version_type} ${version.duplicate ? 'duplicate' : ''}`"
/>
<div class="version-wrapper">
<div class="version-header">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>
{{ version.name }}
</nuxt-link>
</h2>
<span v-if="version.author">
by
<nuxt-link class="text-link" :to="'/user/' + version.author.user.username">{{
version.author.user.username
}}</nuxt-link>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="version.primaryFile.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
class="markdown-body"
v-html="renderHighlightedString(version.changelog)"
/>
</div>
</div>
</div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div>
</template>
<script setup>
import DownloadIcon from '~/assets/images/utils/download.svg'
import { renderHighlightedString } from '~/helpers/highlight'
import VersionFilterControl from '~/components/ui/VersionFilterControl'
import Pagination from '~/components/ui/Pagination'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
})
const metaDescription = computed(
() => `View the changelog of ${props.project.title}'s ${props.versions.length} versions.`
)
const route = useRoute()
const currentPage = ref(Number(route.query.p ?? 1))
const filteredVersions = shallowRef(props.versions)
async function switchPage(page) {
currentPage.value = page
const router = useRouter()
const route = useRoute()
await router.replace({
query: {
...route.query,
p: currentPage.value !== 1 ? currentPage.value : undefined,
},
})
}
</script>
<style lang="scss">
.changelog-item {
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
.changelog-bar {
--color: var(--color-special-green);
&.alpha {
--color: var(--color-special-red);
}
&.release {
--color: var(--color-special-green);
}
&.beta {
--color: var(--color-special-orange);
}
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;
min-height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
}
.version-header-text {
display: flex;
align-items: baseline;
flex-wrap: wrap;
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
h2,
span {
padding-right: 0.25rem;
}
}
.download {
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
}
}
</style>

View File

@@ -1,5 +1,12 @@
<template> <template>
<div> <div>
<Head>
<Title> {{ project.title }} - Gallery </Title>
<Meta name="og:title" :content="`${project.title} - Gallery`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${project.title} - Gallery`" />
<Meta name="og:description" :contcent="metaDescription" />
</Head>
<Modal <Modal
v-if="$auth.user && currentMember" v-if="$auth.user && currentMember"
ref="modal_edit_item" ref="modal_edit_item"
@@ -71,8 +78,8 @@
<label for="gallery-image-featured"> <label for="gallery-image-featured">
<span class="label__title">Featured</span> <span class="label__title">Featured</span>
<span class="label__description"> <span class="label__description">
A featured gallery image shows up in search and your project card. A featured gallery image shows up in search and your project card. Only one gallery
Only one gallery image can be featured. image can be featured.
</span> </span>
</label> </label>
<button <button
@@ -94,10 +101,7 @@
Unfeature image Unfeature image
</button> </button>
<div class="button-group"> <div class="button-group">
<button <button class="iconified-button" @click="$refs.modal_edit_item.hide()">
class="iconified-button"
@click="$refs.modal_edit_item.hide()"
>
<CrossIcon /> <CrossIcon />
Cancel Cancel
</button> </button>
@@ -145,11 +149,7 @@
? expandedGalleryItem.url ? expandedGalleryItem.url
: 'https://cdn.modrinth.com/placeholder-banner.svg' : 'https://cdn.modrinth.com/placeholder-banner.svg'
" "
:alt=" :alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
expandedGalleryItem.title
? expandedGalleryItem.title
: 'gallery-image'
"
@click.stop="" @click.stop=""
/> />
@@ -164,10 +164,7 @@
</div> </div>
<div class="controls"> <div class="controls">
<div class="buttons"> <div class="buttons">
<button <button class="close circle-button" @click="expandedGalleryItem = null">
class="close circle-button"
@click="expandedGalleryItem = null"
>
<CrossIcon aria-hidden="true" /> <CrossIcon aria-hidden="true" />
</button> </button>
<a <a
@@ -220,25 +217,21 @@
<DropArea :accept="acceptFileTypes" @change="handleFiles" /> <DropArea :accept="acceptFileTypes" @change="handleFiles" />
</div> </div>
<div class="items"> <div class="items">
<div <div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
v-for="(item, index) in project.gallery"
:key="index"
class="card gallery-item"
>
<a class="gallery-thumbnail" @click="expandImage(item, index)"> <a class="gallery-thumbnail" @click="expandImage(item, index)">
<img <img
:src=" :src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
item.url
? item.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="item.title ? item.title : 'gallery-image'" :alt="item.title ? item.title : 'gallery-image'"
/> />
</a> </a>
<div class="gallery-body"> <div class="gallery-body">
<div class="gallery-info"> <div class="gallery-info">
<h2 v-if="item.title">{{ item.title }}</h2> <h2 v-if="item.title">
<p v-if="item.description">{{ item.description }}</p> {{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div> </div>
</div> </div>
<div class="gallery-bottom"> <div class="gallery-bottom">
@@ -250,13 +243,15 @@
<button <button
class="iconified-button" class="iconified-button"
@click=" @click="
resetEdit() () => {
editIndex = index resetEdit()
editTitle = item.title editIndex = index
editDescription = item.description editTitle = item.title
editFeatured = item.featured editDescription = item.description
editOrder = item.ordering editFeatured = item.featured
$refs.modal_edit_item.show() editOrder = item.ordering
$refs.modal_edit_item.show()
}
" "
> >
<EditIcon /> <EditIcon />
@@ -265,8 +260,10 @@
<button <button
class="iconified-button" class="iconified-button"
@click=" @click="
deleteIndex = index () => {
$refs.modal_confirm.show() deleteIndex = index
$refs.modal_confirm.show()
}
" "
> >
<TrashIcon /> <TrashIcon />
@@ -280,29 +277,29 @@
</template> </template>
<script> <script>
import PlusIcon from '~/assets/images/utils/plus.svg?inline' import PlusIcon from '~/assets/images/utils/plus.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline' import CalendarIcon from '~/assets/images/utils/calendar.svg'
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import TrashIcon from '~/assets/images/utils/trash.svg'
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline' import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg?inline' import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg'
import EditIcon from '~/assets/images/utils/edit.svg?inline' import EditIcon from '~/assets/images/utils/edit.svg'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
import ExternalIcon from '~/assets/images/utils/external.svg?inline' import ExternalIcon from '~/assets/images/utils/external.svg'
import ExpandIcon from '~/assets/images/utils/expand.svg?inline' import ExpandIcon from '~/assets/images/utils/expand.svg'
import ContractIcon from '~/assets/images/utils/contract.svg?inline' import ContractIcon from '~/assets/images/utils/contract.svg'
import StarIcon from '~/assets/images/utils/star.svg?inline' import StarIcon from '~/assets/images/utils/star.svg'
import UploadIcon from '~/assets/images/utils/upload.svg?inline' import UploadIcon from '~/assets/images/utils/upload.svg'
import InfoIcon from '~/assets/images/utils/info.svg?inline' import InfoIcon from '~/assets/images/utils/info.svg'
import ImageIcon from '~/assets/images/utils/image.svg?inline' import ImageIcon from '~/assets/images/utils/image.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline' import TransferIcon from '~/assets/images/utils/transfer.svg'
import FileInput from '~/components/ui/FileInput' import FileInput from '~/components/ui/FileInput'
import DropArea from '~/components/ui/DropArea' import DropArea from '~/components/ui/DropArea'
import ModalConfirm from '~/components/ui/ModalConfirm' import ModalConfirm from '~/components/ui/ModalConfirm'
import Modal from '~/components/ui/Modal' import Modal from '~/components/ui/Modal'
export default { export default defineNuxtComponent({
components: { components: {
CalendarIcon, CalendarIcon,
PlusIcon, PlusIcon,
@@ -325,7 +322,6 @@ export default {
FileInput, FileInput,
DropArea, DropArea,
}, },
auth: false,
props: { props: {
project: { project: {
type: Object, type: Object,
@@ -356,36 +352,8 @@ export default {
editFile: null, editFile: null,
previewImage: null, previewImage: null,
shouldPreventActions: false, shouldPreventActions: false,
}
},
head() {
const title = `${this.project.title} - Gallery`
const description = `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`
return { metaDescription: `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`,
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: { computed: {
@@ -456,24 +424,30 @@ export default {
}, },
async createGalleryItem() { async createGalleryItem() {
this.shouldPreventActions = true this.shouldPreventActions = true
this.$nuxt.$loading.start() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?ext=${ let url = `project/${this.project.id}/gallery?ext=${
this.editFile this.editFile
? this.editFile.type.split('/')[ ? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
this.editFile.type.split('/').length - 1
]
: null : null
}&featured=${this.editFeatured}` }&featured=${this.editFeatured}`
if (this.editTitle) if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription) }
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(this.editDescription)}`
if (this.editOrder) url += `&ordering=${this.editOrder}` }
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await this.$axios.post(url, this.editFile, this.$defaultHeaders()) await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
...this.$defaultHeaders(),
})
await this.updateProject() await this.updateProject()
this.$refs.modal_edit_item.hide() this.$refs.modal_edit_item.hide()
@@ -481,30 +455,37 @@ export default {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response ? err.response.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
this.shouldPreventActions = false this.shouldPreventActions = false
}, },
async editGalleryItem() { async editGalleryItem() {
this.shouldPreventActions = true this.shouldPreventActions = true
this.$nuxt.$loading.start() startLoading()
try { try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent( let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url this.project.gallery[this.editIndex].url
)}&featured=${this.editFeatured}` )}&featured=${this.editFeatured}`
if (this.editTitle) if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}` url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription) }
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}` url += `&description=${encodeURIComponent(this.editDescription)}`
if (this.editOrder) url += `&ordering=${this.editOrder}` }
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await this.$axios.patch(url, {}, this.$defaultHeaders()) await useBaseFetch(url, {
method: 'PATCH',
...this.$defaultHeaders(),
})
await this.updateProject() await this.updateProject()
this.$refs.modal_edit_item.hide() this.$refs.modal_edit_item.hide()
@@ -512,23 +493,26 @@ export default {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response ? err.response.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
this.shouldPreventActions = false this.shouldPreventActions = false
}, },
async deleteGalleryImage() { async deleteGalleryImage() {
this.$nuxt.$loading.start() startLoading()
try { try {
await this.$axios.delete( await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent( `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url this.project.gallery[this.deleteIndex].url
)}`, )}`,
this.$defaultHeaders() {
method: 'DELETE',
...this.$defaultHeaders(),
}
) )
await this.updateProject() await this.updateProject()
@@ -536,19 +520,25 @@ export default {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response ? err.response.data.description : err, text: err.data ? err.data.description : err,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
async updateProject() { async updateProject() {
await this.$parent.resetProject() const project = await useBaseFetch(`project/${this.project.id}`, this.$defaultHeaders())
project.actualProjectType = JSON.parse(JSON.stringify(project.project_type))
project.project_type = this.$getProjectTypeForUrl(project.project_type, project.loaders)
this.$emit('update:project', project)
this.resetEdit() this.resetEdit()
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -756,8 +746,7 @@ export default {
.gallery-bottom { .gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md)); width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
var(--spacing-card-md);
.gallery-created { .gallery-created {
display: flex; display: flex;

View File

@@ -0,0 +1,21 @@
<template>
<div class="markdown-body card" v-html="renderHighlightedString(project.body)" />
</template>
<script>
import { renderHighlightedString } from '~/helpers/highlight'
export default defineNuxtComponent({
props: {
project: {
type: Object,
default() {
return {}
},
},
},
methods: { renderHighlightedString },
})
</script>
<style lang="scss" scoped></style>

View File

@@ -4,22 +4,19 @@
<label for="project-description"> <label for="project-description">
<span class="label__title size-card-header">Description</span> <span class="label__title size-card-header">Description</span>
<span class="label__description"> <span class="label__description">
You can type an extended description of your mod here. This editor You can type an extended description of your mod here. This editor supports
supports
<a <a
class="text-link" class="text-link"
href="https://guides.github.com/features/mastering-markdown/" href="https://guides.github.com/features/mastering-markdown/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
>Markdown</a >Markdown</a
>. HTML can also be used inside your description, not including >. HTML can also be used inside your description, not including styles, scripts, and
styles, scripts, and iframes (though YouTube iframes are allowed). iframes (though YouTube iframes are allowed).
<span class="label__subdescription"> <span class="label__subdescription">
The description must clearly and honestly describe the purpose and The description must clearly and honestly describe the purpose and function of the
function of the project. See section 2.1 of the project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank" <nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
>Content Rules</nuxt-link
>
for the full requirements. for the full requirements.
</span> </span>
</span> </span>
@@ -34,12 +31,9 @@
</div> </div>
<div <div
v-else-if="bodyViewMode === 'preview'" v-else-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body" class="markdown-body"
v-html=" v-html="description ? renderHighlightedString(description) : 'No body specified.'"
description ? $xss($md.render(description)) : 'No body specified.' />
"
></div>
<div class="input-group"> <div class="input-group">
<button <button
type="button" type="button"
@@ -57,10 +51,10 @@
<script> <script>
import Chips from '~/components/ui/Chips' import Chips from '~/components/ui/Chips'
import SaveIcon from '~/assets/images/utils/save.svg'
import { renderHighlightedString } from '~/helpers/highlight'
import SaveIcon from '~/assets/images/utils/save.svg?inline' export default defineNuxtComponent({
export default {
components: { components: {
Chips, Chips,
SaveIcon, SaveIcon,
@@ -100,13 +94,10 @@ export default {
}, },
data() { data() {
return { return {
description: '', description: this.project.body,
bodyViewMode: 'source', bodyViewMode: 'source',
} }
}, },
fetch() {
this.description = this.project.body
},
computed: { computed: {
patchData() { patchData() {
const data = {} const data = {}
@@ -125,13 +116,14 @@ export default {
this.EDIT_BODY = 1 << 3 this.EDIT_BODY = 1 << 3
}, },
methods: { methods: {
renderHighlightedString,
saveChanges() { saveChanges() {
if (this.hasChanges) { if (this.hasChanges) {
this.patchProject(this.patchData) this.patchProject(this.patchData)
} }
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.resizable-textarea-wrapper textarea { .resizable-textarea-wrapper textarea {

View File

@@ -20,9 +20,7 @@
</label> </label>
<div class="input-group"> <div class="input-group">
<Avatar <Avatar
:src=" :src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
deletedIcon ? null : previewImage ? previewImage : project.icon_url
"
:alt="project.title" :alt="project.title"
size="md" size="md"
class="project__icon" class="project__icon"
@@ -86,7 +84,7 @@
v-model="summary" v-model="summary"
maxlength="256" maxlength="256"
:disabled="!hasPermission" :disabled="!hasPermission"
></textarea> />
</div> </div>
<template <template
v-if=" v-if="
@@ -101,9 +99,9 @@
<span class="label__title">Client-side</span> <span class="label__title">Client-side</span>
<span class="label__description"> <span class="label__description">
Select based on if the Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has {{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
functionality on the client side. Just because a mod works in client side. Just because a mod works in Singleplayer doesn't mean it has actual
Singleplayer doesn't mean it has actual client-side functionality. client-side functionality.
</span> </span>
</label> </label>
<Multiselect <Multiselect
@@ -111,9 +109,7 @@
v-model="clientSide" v-model="clientSide"
placeholder="Select one" placeholder="Select one"
:options="sideTypes" :options="sideTypes"
:custom-label=" :custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false" :searchable="false"
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
@@ -126,9 +122,9 @@
<span class="label__title">Server-side</span> <span class="label__title">Server-side</span>
<span class="label__description"> <span class="label__description">
Select based on if the Select based on if the
{{ $formatProjectType(project.project_type).toLowerCase() }} has {{ $formatProjectType(project.project_type).toLowerCase() }} has functionality on the
functionality on the <strong>logical</strong> server. Remember <strong>logical</strong> server. Remember that Singleplayer contains an integrated
that Singleplayer contains an integrated server. server.
</span> </span>
</label> </label>
<Multiselect <Multiselect
@@ -136,9 +132,7 @@
v-model="serverSide" v-model="serverSide"
placeholder="Select one" placeholder="Select one"
:options="sideTypes" :options="sideTypes"
:custom-label=" :custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:searchable="false" :searchable="false"
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
@@ -151,10 +145,9 @@
<label for="project-visibility"> <label for="project-visibility">
<span class="label__title">Visibility</span> <span class="label__title">Visibility</span>
<span class="label__description"> <span class="label__description">
Set the visibility of your project. Listed and archived projects are Set the visibility of your project. Listed and archived projects are visible in search.
visible in search. Unlisted projects are published, but not visible Unlisted projects are published, but not visible in search or on user profiles. Private
in search or on user profiles. Private projects are only accessible projects are only accessible by members of the project.
by members of the project.
</span> </span>
</label> </label>
<Multiselect <Multiselect
@@ -190,8 +183,8 @@
</h3> </h3>
</div> </div>
<p> <p>
Removes your project from Modrinth's servers and search. Clicking on Removes your project from Modrinth's servers and search. Clicking on this will delete your
this will delete your project, so be extra careful! project, so be extra careful!
</p> </p>
<button <button
type="button" type="button"
@@ -212,11 +205,11 @@ import Avatar from '~/components/ui/Avatar'
import ModalConfirm from '~/components/ui/ModalConfirm' import ModalConfirm from '~/components/ui/ModalConfirm'
import FileInput from '~/components/ui/FileInput' import FileInput from '~/components/ui/FileInput'
import UploadIcon from '~/assets/images/utils/upload.svg?inline' import UploadIcon from '~/assets/images/utils/upload.svg'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import TrashIcon from '~/assets/images/utils/trash.svg'
export default { export default defineNuxtComponent({
components: { components: {
Avatar, Avatar,
ModalConfirm, ModalConfirm,
@@ -281,27 +274,19 @@ export default {
}, },
data() { data() {
return { return {
name: '', name: this.project.title,
slug: '', slug: this.project.slug,
summary: '', summary: this.project.description,
icon: null, icon: null,
previewImage: null, previewImage: null,
clientSide: '', clientSide: this.project.client_side,
serverSide: '', serverSide: this.project.server_side,
deletedIcon: false, deletedIcon: false,
visibility: '', visibility: this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status,
} }
}, },
fetch() {
this.name = this.project.title
this.slug = this.project.slug
this.summary = this.project.description
this.clientSide = this.project.client_side
this.serverSide = this.project.server_side
this.visibility = this.$tag.approvedStatuses.includes(this.project.status)
? this.project.status
: this.project.requested_status
},
computed: { computed: {
hasPermission() { hasPermission() {
const EDIT_DETAILS = 1 << 2 const EDIT_DETAILS = 1 << 2
@@ -309,9 +294,7 @@ export default {
}, },
hasDeletePermission() { hasDeletePermission() {
const DELETE_PROJECT = 1 << 7 const DELETE_PROJECT = 1 << 7
return ( return (this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
(this.currentMember.permissions & DELETE_PROJECT) === DELETE_PROJECT
)
}, },
sideTypes() { sideTypes() {
return ['required', 'optional', 'unsupported'] return ['required', 'optional', 'unsupported']
@@ -345,9 +328,7 @@ export default {
return data return data
}, },
hasChanges() { hasChanges() {
return ( return Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
Object.keys(this.patchData).length > 0 || this.deletedIcon || this.icon
)
}, },
}, },
methods: { methods: {
@@ -374,12 +355,12 @@ export default {
} }
}, },
async deleteProject() { async deleteProject() {
await this.$axios.delete( await useBaseFetch(`project/${this.project.id}`, {
`project/${this.project.id}`, method: 'DELETE',
this.$defaultHeaders() ...this.$defaultHeaders(),
) })
await this.$store.dispatch('user/fetchProjects') await initUserProjects()
await this.$router.push(`/dashboard/projects`) await this.$router.push('/dashboard/projects')
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'Project deleted', title: 'Project deleted',
@@ -393,10 +374,10 @@ export default {
this.previewImage = null this.previewImage = null
}, },
async deleteIcon() { async deleteIcon() {
await this.$axios.delete( await useBaseFetch(`project/${this.project.id}/icon`, {
`project/${this.project.id}/icon`, method: 'DELETE',
this.$defaultHeaders() ...this.$defaultHeaders(),
) })
await this.updateIcon() await this.updateIcon()
this.$notify({ this.$notify({
group: 'main', group: 'main',
@@ -406,7 +387,7 @@ export default {
}) })
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.summary-input { .summary-input {

View File

@@ -6,34 +6,24 @@
<span class="label__title size-card-header">License</span> <span class="label__title size-card-header">License</span>
<span class="label__description"> <span class="label__description">
It is very important to choose a proper license for your It is very important to choose a proper license for your
{{ $formatProjectType(project.project_type).toLowerCase() }}. You {{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
may choose one from our list or provide a custom license. You may our list or provide a custom license. You may also provide a custom URL to your chosen
also provide a custom URL to your chosen license; otherwise, the license; otherwise, the license text will be displayed.
license text will be displayed. <span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
<span
v-if="license && license.friendly === 'Custom'"
class="label__subdescription"
>
Enter a valid Enter a valid
<a <a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
href="https://spdx.org/licenses/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>
SPDX license identifier</a SPDX license identifier</a
> >
in the marked area. If your license does not have a SPDX in the marked area. If your license does not have a SPDX identifier (for example, if
identifier (for example, if you created the license yourself or if you created the license yourself or if the license is Minecraft-specific), simply
the license is Minecraft-specific), simply check the box and enter check the box and enter the name of the license instead.
the name of the license instead.
</span> </span>
<span class="label__subdescription"> <span class="label__subdescription">
Confused? See our Confused? See our
<a <a
href="https://blog.modrinth.com/licensing-guide/" href="https://blog.modrinth.com/licensing-guide/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener"
class="text-link" class="text-link"
> >
licensing guide</a licensing guide</a
@@ -62,6 +52,7 @@
v-if="license.requiresOnlyOrLater" v-if="license.requiresOnlyOrLater"
v-model="allowOrLater" v-model="allowOrLater"
:disabled="!hasPermission" :disabled="!hasPermission"
description="Allow later editions of this license"
> >
Allow later editions of this license Allow later editions of this license
</Checkbox> </Checkbox>
@@ -69,6 +60,7 @@
v-if="license.friendly === 'Custom'" v-if="license.friendly === 'Custom'"
v-model="nonSpdxLicense" v-model="nonSpdxLicense"
:disabled="!hasPermission" :disabled="!hasPermission"
description="License does not have a SPDX identifier"
> >
License does not have a SPDX identifier License does not have a SPDX identifier
</Checkbox> </Checkbox>
@@ -110,9 +102,9 @@
<script> <script>
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
export default { export default defineNuxtComponent({
components: { components: {
Multiselect, Multiselect,
Checkbox, Checkbox,
@@ -154,28 +146,110 @@ export default {
showKnownErrors: false, showKnownErrors: false,
} }
}, },
fetch() { async setup(props) {
this.licenseUrl = this.project.license.url const defaultLicenses = shallowRef([
{ friendly: 'Custom', short: '' },
{
friendly: 'All Rights Reserved/No License',
short: 'All-Rights-Reserved',
},
{ friendly: 'Apache License 2.0', short: 'Apache-2.0' },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: 'BSD-2-Clause',
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: 'BSD-3-Clause',
},
{
friendly: 'CC Zero (Public Domain equivalent)',
short: 'CC0-1.0',
},
{ friendly: 'CC-BY 4.0', short: 'CC-BY-4.0' },
{
friendly: 'CC-BY-SA 4.0',
short: 'CC-BY-SA-4.0',
},
{
friendly: 'CC-BY-NC 4.0',
short: 'CC-BY-NC-4.0',
},
{
friendly: 'CC-BY-NC-SA 4.0',
short: 'CC-BY-NC-SA-4.0',
},
{
friendly: 'CC-BY-ND 4.0',
short: 'CC-BY-ND-4.0',
},
{
friendly: 'CC-BY-NC-ND 4.0',
short: 'CC-BY-NC-ND-4.0',
},
{
friendly: 'GNU Affero General Public License v3',
short: 'AGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v2.1',
short: 'LGPL-2.1',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v3',
short: 'LGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v2',
short: 'GPL-2.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v3',
short: 'GPL-3.0',
requiresOnlyOrLater: true,
},
{ friendly: 'ISC License', short: 'ISC' },
{ friendly: 'MIT License', short: 'MIT' },
{ friendly: 'Mozilla Public License 2.0', short: 'MPL-2.0' },
{ friendly: 'zlib License', short: 'Zlib' },
])
const licenseId = this.project.license.id const licenseUrl = ref(props.project.license.url)
const licenseId = props.project.license.id
const trimmedLicenseId = licenseId const trimmedLicenseId = licenseId
.replaceAll('-only', '') .replaceAll('-only', '')
.replaceAll('-or-later', '') .replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '') .replaceAll('LicenseRef-', '')
this.license = this.defaultLicenses.find(
(x) => x.short === trimmedLicenseId const license = ref(
) ?? { defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom', friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''), short: licenseId.replaceAll('LicenseRef-', ''),
} }
)
if (licenseId === 'LicenseRef-Unknown') { if (licenseId === 'LicenseRef-Unknown') {
this.license = { license.value = {
friendly: 'Unknown', friendly: 'Unknown',
short: licenseId.replaceAll('LicenseRef-', ''), short: licenseId.replaceAll('LicenseRef-', ''),
} }
} }
this.allowOrLater = licenseId.includes('-or-later')
this.nonSpdxLicense = licenseId.includes('LicenseRef-') const allowOrLater = computed(() => props.project.license.id.includes('-or-later'))
const nonSpdxLicense = computed(() => props.project.license.id.includes('LicenseRef-'))
return {
defaultLicenses,
licenseUrl,
license,
allowOrLater,
nonSpdxLicense,
}
}, },
computed: { computed: {
hasPermission() { hasPermission() {
@@ -188,87 +262,18 @@ export default {
(this.nonSpdxLicense && this.license.friendly === 'Custom') || (this.nonSpdxLicense && this.license.friendly === 'Custom') ||
this.license.short === 'All-Rights-Reserved' || this.license.short === 'All-Rights-Reserved' ||
this.license.short === 'Unknown' this.license.short === 'Unknown'
) ) {
id += 'LicenseRef-' id += 'LicenseRef-'
}
id += this.license.short id += this.license.short
if (this.license.requiresOnlyOrLater) if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? '-or-later' : '-only' id += this.allowOrLater ? '-or-later' : '-only'
if (this.nonSpdxLicense && this.license.friendly === 'Custom') }
if (this.nonSpdxLicense && this.license.friendly === 'Custom') {
id = id.replaceAll(' ', '-') id = id.replaceAll(' ', '-')
}
return id return id
}, },
defaultLicenses() {
return [
{ friendly: 'Custom', short: '' },
{
friendly: 'All Rights Reserved/No License',
short: 'All-Rights-Reserved',
},
{ friendly: 'Apache License 2.0', short: 'Apache-2.0' },
{
friendly: 'BSD 2-Clause "Simplified" License',
short: 'BSD-2-Clause',
},
{
friendly: 'BSD 3-Clause "New" or "Revised" License',
short: 'BSD-3-Clause',
},
{
friendly: 'CC Zero (Public Domain equivalent)',
short: 'CC0-1.0',
},
{ friendly: 'CC-BY 4.0', short: 'CC-BY-4.0' },
{
friendly: 'CC-BY-SA 4.0',
short: 'CC-BY-SA-4.0',
},
{
friendly: 'CC-BY-NC 4.0',
short: 'CC-BY-NC-4.0',
},
{
friendly: 'CC-BY-NC-SA 4.0',
short: 'CC-BY-NC-SA-4.0',
},
{
friendly: 'CC-BY-ND 4.0',
short: 'CC-BY-ND-4.0',
},
{
friendly: 'CC-BY-NC-ND 4.0',
short: 'CC-BY-NC-ND-4.0',
},
{
friendly: 'GNU Affero General Public License v3',
short: 'AGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v2.1',
short: 'LGPL-2.1',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU Lesser General Public License v3',
short: 'LGPL-3.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v2',
short: 'GPL-2.0',
requiresOnlyOrLater: true,
},
{
friendly: 'GNU General Public License v3',
short: 'GPL-3.0',
requiresOnlyOrLater: true,
},
{ friendly: 'ISC License', short: 'ISC' },
{ friendly: 'MIT License', short: 'MIT' },
{ friendly: 'Mozilla Public License 2.0', short: 'MPL-2.0' },
{ friendly: 'zlib License', short: 'Zlib' },
]
},
patchData() { patchData() {
const data = {} const data = {}
@@ -292,6 +297,6 @@ export default {
} }
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -9,8 +9,7 @@
> >
<span class="label__title">Issue tracker</span> <span class="label__title">Issue tracker</span>
<span class="label__description"> <span class="label__description">
A place for users to report bugs, issues, and concerns about your A place for users to report bugs, issues, and concerns about your project.
project.
</span> </span>
</label> </label>
<input <input
@@ -48,8 +47,7 @@
> >
<span class="label__title">Wiki page</span> <span class="label__title">Wiki page</span>
<span class="label__description"> <span class="label__description">
A page containing information, documentation, and help for the A page containing information, documentation, and help for the project.
project.
</span> </span>
</label> </label>
<input <input
@@ -62,14 +60,9 @@
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label id="project-discord-invite" title="An invitation link to your Discord server.">
id="project-discord-invite"
title="An invitation link to your Discord server."
>
<span class="label__title">Discord invite</span> <span class="label__title">Discord invite</span>
<span class="label__description"> <span class="label__description"> An invitation link to your Discord server. </span>
An invitation link to your Discord server.
</span>
</label> </label>
<input <input
id="project-discord-invite" id="project-discord-invite"
@@ -100,7 +93,7 @@
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
:disabled="!hasPermission" :disabled="!hasPermission"
@input="updateDonationLinks" @update:model-value="updateDonationLinks"
/> />
<input <input
v-model="donationLink.url" v-model="donationLink.url"
@@ -128,9 +121,9 @@
<script> <script>
import Multiselect from 'vue-multiselect' import Multiselect from 'vue-multiselect'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
export default { export default defineNuxtComponent({
components: { components: {
Multiselect, Multiselect,
SaveIcon, SaveIcon,
@@ -163,23 +156,22 @@ export default {
}, },
}, },
data() { data() {
const donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
donationLinks.push({
id: null,
platform: null,
url: null,
})
return { return {
issuesUrl: '', issuesUrl: this.project.issues_url,
sourceUrl: '', sourceUrl: this.project.source_url,
wikiUrl: '', wikiUrl: this.project.wiki_url,
discordUrl: '', discordUrl: this.project.discord_url,
donationLinks: [], donationLinks,
} }
}, },
fetch() {
this.issuesUrl = this.project.issues_url
this.sourceUrl = this.project.source_url
this.wikiUrl = this.project.wiki_url
this.discordUrl = this.project.discord_url
this.resetDonationLinks()
},
computed: { computed: {
hasPermission() { hasPermission() {
const EDIT_DETAILS = 1 << 2 const EDIT_DETAILS = 1 << 2
@@ -198,13 +190,10 @@ export default {
data.wiki_url = this.wikiUrl === '' ? null : this.wikiUrl.trim() data.wiki_url = this.wikiUrl === '' ? null : this.wikiUrl.trim()
} }
if (this.checkDifference(this.discordUrl, this.project.discord_url)) { if (this.checkDifference(this.discordUrl, this.project.discord_url)) {
data.discord_url = data.discord_url = this.discordUrl === '' ? null : this.discordUrl.trim()
this.discordUrl === '' ? null : this.discordUrl.trim()
} }
const donationLinks = this.donationLinks.filter( const donationLinks = this.donationLinks.filter((link) => link.url && link.platform)
(link) => link.url && link.platform
)
donationLinks.forEach((link) => { donationLinks.forEach((link) => {
link.id = this.$tag.donationPlatforms.find( link.id = this.$tag.donationPlatforms.find(
(platform) => platform.name === link.platform (platform) => platform.name === link.platform
@@ -230,7 +219,12 @@ export default {
methods: { methods: {
async saveChanges() { async saveChanges() {
if (this.patchData && (await this.patchProject(this.patchData))) { if (this.patchData && (await this.patchProject(this.patchData))) {
this.resetDonationLinks() this.donationLinks = JSON.parse(JSON.stringify(this.project.donation_urls))
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
} }
}, },
updateDonationLinks() { updateDonationLinks() {
@@ -258,16 +252,6 @@ export default {
}) })
} }
}, },
resetDonationLinks() {
this.donationLinks = JSON.parse(
JSON.stringify(this.project.donation_urls)
)
this.donationLinks.push({
id: null,
platform: null,
url: null,
})
},
checkDifference(newLink, existingLink) { checkDifference(newLink, existingLink) {
if (newLink === '' && existingLink !== null) { if (newLink === '' && existingLink !== null) {
return true return true
@@ -278,7 +262,7 @@ export default {
return newLink !== existingLink return newLink !== existingLink
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.donation-link-group { .donation-link-group {

View File

@@ -9,20 +9,15 @@
<span class="label"> <span class="label">
<span class="label__title">Invite a member</span> <span class="label__title">Invite a member</span>
<span class="label__description"> <span class="label__description">
Enter the Modrinth username of the person you'd like to invite to be a Enter the Modrinth username of the person you'd like to invite to be a member of this
member of this project. project.
</span> </span>
</span> </span>
<div <div
v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES" v-if="(currentMember.permissions & MANAGE_INVITES) === MANAGE_INVITES"
class="input-group" class="input-group"
> >
<input <input id="username" v-model="currentUsername" type="text" placeholder="Username" />
id="username"
v-model="currentUsername"
type="text"
placeholder="Username"
/>
<label for="username" class="hidden">Username</label> <label for="username" class="hidden">Username</label>
<button class="iconified-button brand-button" @click="inviteTeamMember"> <button class="iconified-button brand-button" @click="inviteTeamMember">
<UserPlusIcon /> <UserPlusIcon />
@@ -38,12 +33,7 @@
> >
<div class="member-header"> <div class="member-header">
<div class="info"> <div class="info">
<Avatar <Avatar :src="member.avatar_url" :alt="member.username" size="sm" circle />
:src="member.avatar_url"
:alt="member.username"
size="sm"
circle
/>
<div class="text"> <div class="text">
<nuxt-link :to="'/user/' + member.user.username" class="name"> <nuxt-link :to="'/user/' + member.user.username" class="name">
<p>{{ member.name }}</p> <p>{{ member.name }}</p>
@@ -59,9 +49,7 @@
@click=" @click="
openTeamMembers.indexOf(member.user.id) === -1 openTeamMembers.indexOf(member.user.id) === -1
? openTeamMembers.push(member.user.id) ? openTeamMembers.push(member.user.id)
: (openTeamMembers = openTeamMembers.filter( : (openTeamMembers = openTeamMembers.filter((it) => it !== member.user.id))
(it) => it !== member.user.id
))
" "
> >
<DropdownIcon /> <DropdownIcon />
@@ -81,37 +69,27 @@
v-model="allTeamMembers[index].role" v-model="allTeamMembers[index].role"
type="text" type="text"
:class="{ 'known-error': member.role === 'Owner' }" :class="{ 'known-error': member.role === 'Owner' }"
:disabled=" :disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
/> />
</div> </div>
<div class="adjacent-input"> <div class="adjacent-input">
<label <label :for="`member-${allTeamMembers[index].user.username}-monetization-weight`">
:for="`member-${allTeamMembers[index].user.username}-monetization-weight`"
>
<span class="label__title">Monetization weight</span> <span class="label__title">Monetization weight</span>
<span class="label__description"> <span class="label__description">
Relative to all other members' monetization weights, this Relative to all other members' monetization weights, this determines what portion of
determines what portion of this project's revenue goes to this this project's revenue goes to this member.
member.
</span> </span>
</label> </label>
<input <input
:id="`member-${allTeamMembers[index].user.username}-monetization-weight`" :id="`member-${allTeamMembers[index].user.username}-monetization-weight`"
v-model="allTeamMembers[index].payouts_split" v-model="allTeamMembers[index].payouts_split"
type="number" type="number"
:disabled=" :disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
/> />
</div> </div>
<p <p v-if="member.role === 'Owner' && member.oldRole !== 'Owner'" class="known-errors">
v-if="member.role === 'Owner' && member.oldRole !== 'Owner'" A project can only have one 'Owner'. Use the 'Transfer ownership' button below if you no
class="known-errors" longer wish to be owner.
>
A project can only have one 'Owner'. Use the 'Transfer ownership'
button below if you no longer wish to be owner.
</p> </p>
<template v-if="member.oldRole !== 'Owner'"> <template v-if="member.oldRole !== 'Owner'">
<span class="label"> <span class="label">
@@ -119,102 +97,98 @@
</span> </span>
<div class="permissions"> <div class="permissions">
<Checkbox <Checkbox
:value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION" :model-value="(member.permissions & UPLOAD_VERSION) === UPLOAD_VERSION"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION (currentMember.permissions & UPLOAD_VERSION) !== UPLOAD_VERSION
" "
label="Upload version" label="Upload version"
@input="allTeamMembers[index].permissions ^= UPLOAD_VERSION" @update:model-value="allTeamMembers[index].permissions ^= UPLOAD_VERSION"
/> />
<Checkbox <Checkbox
:value="(member.permissions & DELETE_VERSION) === DELETE_VERSION" :model-value="(member.permissions & DELETE_VERSION) === DELETE_VERSION"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_VERSION) !== DELETE_VERSION (currentMember.permissions & DELETE_VERSION) !== DELETE_VERSION
" "
label="Delete version" label="Delete version"
@input="allTeamMembers[index].permissions ^= DELETE_VERSION" @update:model-value="allTeamMembers[index].permissions ^= DELETE_VERSION"
/> />
<Checkbox <Checkbox
:value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS" :model-value="(member.permissions & EDIT_DETAILS) === EDIT_DETAILS"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS (currentMember.permissions & EDIT_DETAILS) !== EDIT_DETAILS
" "
label="Edit details" label="Edit details"
@input="allTeamMembers[index].permissions ^= EDIT_DETAILS" @update:model-value="allTeamMembers[index].permissions ^= EDIT_DETAILS"
/> />
<Checkbox <Checkbox
:value="(member.permissions & EDIT_BODY) === EDIT_BODY" :model-value="(member.permissions & EDIT_BODY) === EDIT_BODY"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & EDIT_BODY) !== EDIT_BODY (currentMember.permissions & EDIT_BODY) !== EDIT_BODY
" "
label="Edit body" label="Edit body"
@input="allTeamMembers[index].permissions ^= EDIT_BODY" @update:model-value="allTeamMembers[index].permissions ^= EDIT_BODY"
/> />
<Checkbox <Checkbox
:value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES" :model-value="(member.permissions & MANAGE_INVITES) === MANAGE_INVITES"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES (currentMember.permissions & MANAGE_INVITES) !== MANAGE_INVITES
" "
label="Manage invites" label="Manage invites"
@input="allTeamMembers[index].permissions ^= MANAGE_INVITES" @update:model-value="allTeamMembers[index].permissions ^= MANAGE_INVITES"
/> />
<Checkbox <Checkbox
:value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER" :model-value="(member.permissions & REMOVE_MEMBER) === REMOVE_MEMBER"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER (currentMember.permissions & REMOVE_MEMBER) !== REMOVE_MEMBER
" "
label="Remove member" label="Remove member"
@input="allTeamMembers[index].permissions ^= REMOVE_MEMBER" @update:model-value="allTeamMembers[index].permissions ^= REMOVE_MEMBER"
/> />
<Checkbox <Checkbox
:value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER" :model-value="(member.permissions & EDIT_MEMBER) === EDIT_MEMBER"
:disabled=" :disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
label="Edit member" label="Edit member"
@input="allTeamMembers[index].permissions ^= EDIT_MEMBER" @update:model-value="allTeamMembers[index].permissions ^= EDIT_MEMBER"
/> />
<Checkbox <Checkbox
:value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT" :model-value="(member.permissions & DELETE_PROJECT) === DELETE_PROJECT"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT (currentMember.permissions & DELETE_PROJECT) !== DELETE_PROJECT
" "
label="Delete project" label="Delete project"
@input="allTeamMembers[index].permissions ^= DELETE_PROJECT" @update:model-value="allTeamMembers[index].permissions ^= DELETE_PROJECT"
/> />
<Checkbox <Checkbox
:value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS" :model-value="(member.permissions & VIEW_ANALYTICS) === VIEW_ANALYTICS"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS (currentMember.permissions & VIEW_ANALYTICS) !== VIEW_ANALYTICS
" "
label="View analytics" label="View analytics"
@input="allTeamMembers[index].permissions ^= VIEW_ANALYTICS" @update:model-value="allTeamMembers[index].permissions ^= VIEW_ANALYTICS"
/> />
<Checkbox <Checkbox
:value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS" :model-value="(member.permissions & VIEW_PAYOUTS) === VIEW_PAYOUTS"
:disabled=" :disabled="
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER || (currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER ||
(currentMember.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS (currentMember.permissions & VIEW_PAYOUTS) !== VIEW_PAYOUTS
" "
label="View revenue" label="View revenue"
@input="allTeamMembers[index].permissions ^= VIEW_PAYOUTS" @update:model-value="allTeamMembers[index].permissions ^= VIEW_PAYOUTS"
/> />
</div> </div>
</template> </template>
<div class="input-group"> <div class="input-group">
<button <button
class="iconified-button brand-button" class="iconified-button brand-button"
:disabled=" :disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="updateTeamMember(index)" @click="updateTeamMember(index)"
> >
<SaveIcon /> <SaveIcon />
@@ -223,20 +197,14 @@
<button <button
v-if="member.oldRole !== 'Owner'" v-if="member.oldRole !== 'Owner'"
class="iconified-button danger-button" class="iconified-button danger-button"
:disabled=" :disabled="(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
(currentMember.permissions & EDIT_MEMBER) !== EDIT_MEMBER
"
@click="removeTeamMember(index)" @click="removeTeamMember(index)"
> >
<UserRemoveIcon /> <UserRemoveIcon />
Remove member Remove member
</button> </button>
<button <button
v-if=" v-if="member.oldRole !== 'Owner' && currentMember.role === 'Owner' && member.accepted"
member.oldRole !== 'Owner' &&
currentMember.role === 'Owner' &&
member.accepted
"
class="iconified-button" class="iconified-button"
@click="transferOwnership(index)" @click="transferOwnership(index)"
> >
@@ -253,14 +221,14 @@
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
import Badge from '~/components/ui/Badge' import Badge from '~/components/ui/Badge'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline' import DropdownIcon from '~/assets/images/utils/dropdown.svg'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline' import TransferIcon from '~/assets/images/utils/transfer.svg'
import UserPlusIcon from '~/assets/images/utils/user-plus.svg?inline' import UserPlusIcon from '~/assets/images/utils/user-plus.svg'
import UserRemoveIcon from '~/assets/images/utils/user-x.svg?inline' import UserRemoveIcon from '~/assets/images/utils/user-x.svg'
import Avatar from '~/components/ui/Avatar' import Avatar from '~/components/ui/Avatar'
export default { export default defineNuxtComponent({
components: { components: {
Avatar, Avatar,
DropdownIcon, DropdownIcon,
@@ -295,14 +263,12 @@ export default {
return { return {
currentUsername: '', currentUsername: '',
openTeamMembers: [], openTeamMembers: [],
allTeamMembers: [], allTeamMembers: this.allMembers.map((x) => {
x.oldRole = x.role
return x
}),
} }
}, },
fetch() {
this.allTeamMembers = this.allMembers
this.allTeamMembers.forEach((x) => (x.oldRole = x.role))
},
created() { created() {
this.UPLOAD_VERSION = 1 << 0 this.UPLOAD_VERSION = 1 << 0
this.DELETE_VERSION = 1 << 1 this.DELETE_VERSION = 1 << 1
@@ -317,55 +283,57 @@ export default {
}, },
methods: { methods: {
async inviteTeamMember() { async inviteTeamMember() {
this.$nuxt.$loading.start() startLoading()
try { try {
const user = (await this.$axios.get(`user/${this.currentUsername}`)) const user = await useBaseFetch(`user/${this.currentUsername}`)
.data
const data = { const data = {
user_id: user.id.trim(), user_id: user.id.trim(),
} }
await this.$axios.post( await useBaseFetch(`team/${this.project.team}/members`, {
`team/${this.project.team}/members`, method: 'POST',
data, body: data,
this.$defaultHeaders() ...this.$defaultHeaders(),
) })
await this.updateMembers() await this.updateMembers()
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
async removeTeamMember(index) { async removeTeamMember(index) {
this.$nuxt.$loading.start() startLoading()
try { try {
await this.$axios.delete( await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`, `team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
this.$defaultHeaders() {
method: 'DELETE',
...this.$defaultHeaders(),
}
) )
await this.updateMembers() await this.updateMembers()
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
async updateTeamMember(index) { async updateTeamMember(index) {
this.$nuxt.$loading.start() startLoading()
try { try {
const data = const data =
@@ -379,59 +347,59 @@ export default {
payouts_split: this.allTeamMembers[index].payouts_split, payouts_split: this.allTeamMembers[index].payouts_split,
} }
await this.$axios.patch( await useBaseFetch(
`team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`, `team/${this.project.team}/members/${this.allTeamMembers[index].user.id}`,
data, {
this.$defaultHeaders() method: 'PATCH',
body: data,
...this.$defaultHeaders(),
}
) )
await this.updateMembers() await this.updateMembers()
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'Member(s) updated', title: 'Member(s) updated',
text: `Your project's member(s) has been updated.`, text: "Your project's member(s) has been updated.",
type: 'success', type: 'success',
}) })
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
async transferOwnership(index) { async transferOwnership(index) {
this.$nuxt.$loading.start() startLoading()
try { try {
await this.$axios.patch( await useBaseFetch(`team/${this.project.team}/owner`, {
`team/${this.project.team}/owner`, method: 'PATCH',
{ body: {
user_id: this.allTeamMembers[index].user.id, user_id: this.allTeamMembers[index].user.id,
}, },
this.$defaultHeaders() ...this.$defaultHeaders(),
) })
await this.updateMembers() await this.updateMembers()
} catch (err) { } catch (err) {
this.$notify({ this.$notify({
group: 'main', group: 'main',
title: 'An error occurred', title: 'An error occurred',
text: err.response.data.description, text: err.data.description,
type: 'error', type: 'error',
}) })
} }
this.$nuxt.$loading.finish() stopLoading()
}, },
async updateMembers() { async updateMembers() {
this.allTeamMembers = ( this.allTeamMembers = (
await this.$axios.get( await useBaseFetch(`team/${this.project.team}/members`, this.$defaultHeaders())
`team/${this.project.team}/members`, ).map((it) => ({
this.$defaultHeaders()
)
).data.map((it) => ({
avatar_url: it.user.avatar_url, avatar_url: it.user.avatar_url,
name: it.user.username, name: it.user.username,
oldRole: it.role, oldRole: it.role,
@@ -439,7 +407,7 @@ export default {
})) }))
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -8,15 +8,13 @@
</div> </div>
<p> <p>
Accurate tagging is important to help people find your Accurate tagging is important to help people find your
{{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure {{ $formatProjectType(project.project_type).toLowerCase() }}. Make sure to select all tags
to select all tags that apply. that apply.
</p> </p>
<template v-for="header in Object.keys(categoryLists)"> <template v-for="header in Object.keys(categoryLists)" :key="`categories-${header}`">
<div :key="`categories-${header}`" class="label"> <div class="label">
<h4> <h4>
<span class="label__title">{{ <span class="label__title">{{ $formatCategoryHeader(header) }}</span>
$formatCategoryHeader(header)
}}</span>
</h4> </h4>
<span class="label__description"> <span class="label__description">
<template v-if="header === 'categories'"> <template v-if="header === 'categories'">
@@ -25,8 +23,7 @@
</template> </template>
<template v-else-if="header === 'features'"> <template v-else-if="header === 'features'">
Select all of the features that your Select all of the features that your
{{ $formatProjectType(project.project_type).toLowerCase() }} makes {{ $formatProjectType(project.project_type).toLowerCase() }} makes use of.
use of.
</template> </template>
<template v-else-if="header === 'resolutions'"> <template v-else-if="header === 'resolutions'">
Select the resolution(s) of textures in your Select the resolution(s) of textures in your
@@ -34,21 +31,20 @@
</template> </template>
<template v-else-if="header === 'performance impact'"> <template v-else-if="header === 'performance impact'">
Select the realistic performance impact of your Select the realistic performance impact of your
{{ $formatProjectType(project.project_type).toLowerCase() }}. {{ $formatProjectType(project.project_type).toLowerCase() }}. Select multiple if the
Select multiple if the {{ $formatProjectType(project.project_type).toLowerCase() }} is configurable to
{{ $formatProjectType(project.project_type).toLowerCase() }} is different levels of performance impact.
configurable to different levels of performance impact.
</template> </template>
</span> </span>
</div> </div>
<div :key="`categories-${header}-list`" class="category-list input-div"> <div class="category-list input-div">
<Checkbox <Checkbox
v-for="category in categoryLists[header]" v-for="category in categoryLists[header]"
:key="`category-${header}-${category.name}`" :key="`category-${header}-${category.name}`"
:value="selectedTags.includes(category)" :model-value="selectedTags.includes(category)"
:description="$formatCategory(category.name)" :description="$formatCategory(category.name)"
class="category-selector" class="category-selector"
@input="toggleCategory(category)" @update:model-value="toggleCategory(category)"
> >
<div class="category-selector__label"> <div class="category-selector__label">
<div <div
@@ -56,10 +52,8 @@
aria-hidden="true" aria-hidden="true"
class="icon" class="icon"
v-html="category.icon" v-html="category.icon"
></div> />
<span aria-hidden="true"> <span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
{{ $formatCategory(category.name) }}</span
>
</div> </div>
</Checkbox> </Checkbox>
</div> </div>
@@ -69,8 +63,8 @@
<span class="label__title"><StarIcon /> Featured tags</span> <span class="label__title"><StarIcon /> Featured tags</span>
</h4> </h4>
<span class="label__description"> <span class="label__description">
You can feature up to 3 of your most relevant tags. Other tags may be You can feature up to 3 of your most relevant tags. Other tags may be promoted to featured
promoted to featured if you do not select all 3. if you do not select all 3.
</span> </span>
</div> </div>
<p v-if="selectedTags.length < 1"> <p v-if="selectedTags.length < 1">
@@ -81,12 +75,10 @@
v-for="category in selectedTags" v-for="category in selectedTags"
:key="`featured-category-${category.name}`" :key="`featured-category-${category.name}`"
class="category-selector" class="category-selector"
:value="featuredTags.includes(category)" :model-value="featuredTags.includes(category)"
:description="$formatCategory(category.name)" :description="$formatCategory(category.name)"
:disabled=" :disabled="featuredTags.length >= 3 && !featuredTags.includes(category)"
featuredTags.length >= 3 && !featuredTags.includes(category) @update:model-value="toggleFeaturedCategory(category)"
"
@input="toggleFeaturedCategory(category)"
> >
<div class="category-selector__label"> <div class="category-selector__label">
<div <div
@@ -94,10 +86,8 @@
aria-hidden="true" aria-hidden="true"
class="icon" class="icon"
v-html="category.icon" v-html="category.icon"
></div> />
<span aria-hidden="true"> <span aria-hidden="true"> {{ $formatCategory(category.name) }}</span>
{{ $formatCategory(category.name) }}</span
>
</div> </div>
</Checkbox> </Checkbox>
</div> </div>
@@ -118,10 +108,10 @@
<script> <script>
import Checkbox from '~/components/ui/Checkbox' import Checkbox from '~/components/ui/Checkbox'
import StarIcon from '~/assets/images/utils/star.svg?inline' import StarIcon from '~/assets/images/utils/star.svg'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
export default { export default defineNuxtComponent({
components: { components: {
Checkbox, Checkbox,
SaveIcon, SaveIcon,
@@ -162,23 +152,19 @@ export default {
}, },
data() { data() {
return { return {
selectedTags: [], selectedTags: this.$sortedCategories.filter(
featuredTags: [], (x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
),
featuredTags: this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
),
} }
}, },
fetch() {
this.selectedTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
(this.project.categories.includes(x.name) ||
this.project.additional_categories.includes(x.name))
)
this.featuredTags = this.$sortedCategories.filter(
(x) =>
x.project_type === this.project.actualProjectType &&
this.project.categories.includes(x.name)
)
},
computed: { computed: {
categoryLists() { categoryLists() {
const lists = {} const lists = {}
@@ -197,19 +183,11 @@ export default {
const data = {} const data = {}
// Promote selected categories to featured if there are less than 3 featured // Promote selected categories to featured if there are less than 3 featured
const newFeaturedTags = this.featuredTags.slice() const newFeaturedTags = this.featuredTags.slice()
if ( if (newFeaturedTags.length < 1 && this.selectedTags.length > newFeaturedTags.length) {
newFeaturedTags.length < 1 && const nonFeaturedCategories = this.selectedTags.filter((x) => !newFeaturedTags.includes(x))
this.selectedTags.length > newFeaturedTags.length
) {
const nonFeaturedCategories = this.selectedTags.filter(
(x) => !newFeaturedTags.includes(x)
)
nonFeaturedCategories nonFeaturedCategories
.slice( .slice(0, Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length))
0,
Math.min(nonFeaturedCategories.length, 3 - newFeaturedTags.length)
)
.forEach((x) => newFeaturedTags.push(x)) .forEach((x) => newFeaturedTags.push(x))
} }
// Convert selected and featured categories to backend-usable arrays // Convert selected and featured categories to backend-usable arrays
@@ -226,11 +204,8 @@ export default {
} }
if ( if (
additionalCategories.length !== additionalCategories.length !== this.project.additional_categories.length ||
this.project.additional_categories.length || additionalCategories.some((value) => !this.project.additional_categories.includes(value))
additionalCategories.some(
(value) => !this.project.additional_categories.includes(value)
)
) { ) {
data.additional_categories = additionalCategories data.additional_categories = additionalCategories
} }
@@ -265,7 +240,7 @@ export default {
} }
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.label__title { .label__title {
@@ -281,7 +256,7 @@ export default {
column-gap: var(--spacing-card-lg); column-gap: var(--spacing-card-lg);
margin-bottom: var(--spacing-card-md); margin-bottom: var(--spacing-card-md);
.category-selector ::v-deep { :deep(.category-selector) {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
.category-selector__label { .category-selector__label {
display: flex; display: flex;

View File

@@ -0,0 +1,8 @@
<template>
<div />
</template>
<script setup>
definePageMeta({
middleware: 'auth',
})
</script>

View File

@@ -1,5 +1,12 @@
<template> <template>
<div class="content"> <div class="content">
<Head>
<Title> {{ project.title }} - Versions </Title>
<Meta name="og:title" :content="`${project.title} - Versions`" />
<Meta name="description" :content="metaDescription" />
<Meta name="apple-mobile-web-app-title" :content="`${project.title} - Versions`" />
<Meta name="og:description" :content="metaDescription" />
</Head>
<div v-if="currentMember" class="card header-buttons"> <div v-if="currentMember" class="card header-buttons">
<FileInput <FileInput
:max-size="524288000" :max-size="524288000"
@@ -13,25 +20,33 @@
<span class="indicator"> <span class="indicator">
<InfoIcon /> Click to choose a file or drag one onto this page <InfoIcon /> Click to choose a file or drag one onto this page
</span> </span>
<DropArea <DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
:accept="acceptFileFromProjectType(project.project_type)"
@change="handleFiles"
/>
</div> </div>
<VersionFilterControl <VersionFilterControl
class="card" :versions="props.versions"
:versions="versions" @update-versions="
@updateVersions="updateVersions" (v) => {
filteredVersions = v
switchPage(1)
}
"
/> />
<div v-if="versions.length > 0" class="universal-card all-versions"> <Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<div v-if="filteredVersions.length > 0" class="universal-card all-versions">
<div class="header"> <div class="header">
<div></div> <div />
<div>Version</div> <div>Version</div>
<div>Supports</div> <div>Supports</div>
<div>Stats</div> <div>Stats</div>
</div> </div>
<div <div
v-for="version in filteredVersions" v-for="version in filteredVersions.slice((currentPage - 1) * 20, currentPage * 20)"
:key="version.id" :key="version.id"
class="version-button button-transparent" class="version-button button-transparent"
@click=" @click="
@@ -44,15 +59,12 @@
> >
<a <a
v-tooltip=" v-tooltip="
$parent.findPrimary(version).filename + version.primaryFile.filename + ' (' + $formatBytes(version.primaryFile.size) + ')'
' (' +
$formatBytes($parent.findPrimary(version).size) +
')'
" "
:href="$parent.findPrimary(version).url" :href="version.primaryFile.url"
class="download-button square-button brand-button" class="download-button square-button brand-button"
:class="version.version_type" :class="version.version_type"
:title="`Download ${version.name}`" :aria-label="`Download ${version.name}`"
@click.stop="(event) => event.stopPropagation()" @click.stop="(event) => event.stopPropagation()"
> >
<DownloadIcon aria-hidden="true" /> <DownloadIcon aria-hidden="true" />
@@ -64,24 +76,11 @@
class="version__title" class="version__title"
> >
{{ version.name }} {{ version.name }}
<FeaturedIcon v-if="featuredVersionIds.includes(version.id)" />
</nuxt-link> </nuxt-link>
<div class="version__metadata"> <div class="version__metadata">
<VersionBadge <VersionBadge v-if="version.version_type === 'release'" type="release" color="green" />
v-if="version.version_type === 'release'" <VersionBadge v-else-if="version.version_type === 'beta'" type="beta" color="orange" />
type="release" <VersionBadge v-else-if="version.version_type === 'alpha'" type="alpha" color="red" />
color="green"
/>
<VersionBadge
v-else-if="version.version_type === 'beta'"
type="beta"
color="orange"
/>
<VersionBadge
v-else-if="version.version_type === 'alpha'"
type="alpha"
color="red"
/>
<span class="divider" /> <span class="divider" />
<span class="version_number">{{ version.version_number }}</span> <span class="version_number">{{ version.version_number }}</span>
</div> </div>
@@ -98,130 +97,99 @@
</span> </span>
<span> <span>
Published on Published on
<strong>{{ <strong>{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</strong>
$dayjs(version.date_published).format('MMM D, YYYY')
}}</strong>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<Pagination
:page="currentPage"
:count="Math.ceil(filteredVersions.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
</div> </div>
</template> </template>
<script> <script setup>
import { acceptFileFromProjectType } from '~/plugins/fileUtils' import { acceptFileFromProjectType } from '~/helpers/fileUtils'
import DownloadIcon from '~/assets/images/utils/download.svg?inline' import DownloadIcon from '~/assets/images/utils/download.svg'
import UploadIcon from '~/assets/images/utils/upload.svg?inline' import UploadIcon from '~/assets/images/utils/upload.svg'
import InfoIcon from '~/assets/images/utils/info.svg?inline' import InfoIcon from '~/assets/images/utils/info.svg'
import FeaturedIcon from '~/assets/images/utils/star.svg?inline'
import VersionBadge from '~/components/ui/Badge' import VersionBadge from '~/components/ui/Badge'
import FileInput from '~/components/ui/FileInput' import FileInput from '~/components/ui/FileInput'
import DropArea from '~/components/ui/DropArea'
import Pagination from '~/components/ui/Pagination'
import VersionFilterControl from '~/components/ui/VersionFilterControl' import VersionFilterControl from '~/components/ui/VersionFilterControl'
import DropArea from '~/components/ui/DropArea.vue'
export default { const props = defineProps({
components: { project: {
DropArea, type: Object,
DownloadIcon, default() {
UploadIcon, return {}
InfoIcon,
FeaturedIcon,
VersionBadge,
VersionFilterControl,
FileInput,
},
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
featuredVersions: {
type: Array,
default() {
return []
},
},
currentMember: {
type: Object,
default() {
return null
},
}, },
}, },
data() { versions: {
return { type: Array,
filteredVersions: this.versions, default() {
} return []
},
}, },
fetch() { members: {
if (this.$route.query.page) type: Array,
this.currentPage = parseInt(this.$route.query.page) default() {
return []
},
}, },
head() { currentMember: {
const title = `${this.project.title} - Versions` type: Object,
const description = `Download and browse ${this.versions.length} ${ default() {
this.project.title return {}
} 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 { const data = useNuxtApp()
title, const metaDescription = computed(
meta: [ () =>
{ `Download and browse ${props.versions.length} ${
hid: 'og:title', props.project.title
name: 'og:title', } versions. ${data.$formatNumber(props.project.downloads)} total downloads. Last updated ${data
content: title, .$dayjs(props.project.updated)
}, .format('MMM D, YYYY')}.`
{ )
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title', const route = useRoute()
content: title, const currentPage = ref(Number(route.query.p ?? 1))
}, const filteredVersions = shallowRef(props.versions)
{
hid: 'og:description', async function switchPage(page) {
name: 'og:description', currentPage.value = page
content: description,
}, const router = useRouter()
{ const route = useRoute()
hid: 'description',
name: 'description', await router.replace({
content: description, query: {
}, ...route.query,
], p: currentPage.value !== 1 ? currentPage.value : undefined,
}
},
computed: {
featuredVersionIds() {
return this.featuredVersions.map((x) => x.id)
}, },
}, })
methods: { }
acceptFileFromProjectType,
updateVersions(updatedVersions) { async function handleFiles(files) {
this.filteredVersions = updatedVersions const router = useRouter()
await router.push({
name: 'type-id-version-version',
params: {
type: props.project.project_type,
id: props.project.slug ? props.project.slug : props.project.id,
version: 'create',
}, },
async handleFiles(files) { state: {
await this.$router.push({ newPrimaryFile: files[0],
name: 'type-id-version-create',
params: {
type: this.project.project_type,
id: this.project.slug ? this.project.slug : this.project.id,
newPrimaryFile: files[0],
},
})
}, },
}, })
} }
</script> </script>
@@ -246,7 +214,7 @@ export default {
.header { .header {
display: grid; display: grid;
grid-template: 'download title supports stats'; grid-template: 'download title supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr; grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
color: var(--color-text-dark); color: var(--color-text-dark);
font-size: var(--font-size-md); font-size: var(--font-size-md);
font-weight: bold; font-weight: bold;
@@ -278,7 +246,7 @@ export default {
'download title supports stats' 'download title supports stats'
'download metadata supports stats' 'download metadata supports stats'
'download dummy supports stats'; 'download dummy supports stats';
grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1fr 1fr 1fr; grid-template-columns: calc(2.25rem + var(--spacing-card-sm)) 1.25fr 1fr 1fr;
column-gap: var(--spacing-card-sm); column-gap: var(--spacing-card-sm);
justify-content: left; justify-content: left;
padding: var(--spacing-card-md); padding: var(--spacing-card-md);
@@ -354,13 +322,17 @@ export default {
} }
} }
.modal-create { .search-controls {
padding: var(--spacing-card-bg); display: flex;
flex-direction: row;
.input-group { gap: var(--spacing-card-md);
width: fit-content; align-items: center;
margin-left: auto; flex-wrap: wrap;
margin-top: 1.5rem; .multiselect {
flex: 1;
}
.checkbox-outer {
min-width: fit-content;
} }
} }
</style> </style>

View File

@@ -1,266 +0,0 @@
<template>
<div class="content">
<VersionFilterControl
class="card"
:versions="versions"
@updateVersions="updateVersions"
/>
<div class="card">
<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">
<div class="version-header-text">
<h2 class="name">
<nuxt-link
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`"
>{{ version.name }}</nuxt-link
>
</h2>
<span v-if="members.find((x) => x.user.id === version.author_id)">
by
<nuxt-link
class="text-link"
:to="
'/user/' +
members.find((x) => x.user.id === version.author_id).user
.username
"
>{{
members.find((x) => x.user.id === version.author_id).user
.username
}}</nuxt-link
>
</span>
<span>
on
{{ $dayjs(version.date_published).format('MMM D, YYYY') }}</span
>
</div>
<a
:href="$parent.findPrimary(version).url"
class="iconified-button download"
:title="`Download ${version.name}`"
>
<DownloadIcon aria-hidden="true" />
Download
</a>
</div>
<div
v-if="version.changelog && !version.duplicate"
v-highlightjs
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'
import VersionFilterControl from '~/components/ui/VersionFilterControl'
export default {
components: {
VersionFilterControl,
DownloadIcon,
},
props: {
project: {
type: Object,
default() {
return {}
},
},
versions: {
type: Array,
default() {
return []
},
},
members: {
type: Array,
default() {
return []
},
},
},
data() {
return {
filteredVersions: this.$calculateDuplicates(this.versions),
currentPage: 1,
}
},
fetch() {
if (this.$route.query.page) {
this.currentPage = parseInt(this.$route.query.page)
}
},
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,
},
],
}
},
methods: {
async switchPage(page, toTop) {
this.currentPage = page
await 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 = this.$calculateDuplicates(updatedVersions)
},
},
auth: false,
}
</script>
<style lang="scss" scoped>
.changelog-item {
display: block;
margin-bottom: 1rem;
position: relative;
padding-left: 1.8rem;
.changelog-bar {
--color: var(--color-special-green);
&.alpha {
--color: var(--color-special-red);
}
&.release {
--color: var(--color-special-green);
}
&.beta {
--color: var(--color-special-orange);
}
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;
min-height: 0.75rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
}
.version-header-text {
display: flex;
align-items: baseline;
flex-wrap: wrap;
h2 {
margin: 0;
font-size: var(--font-size-lg);
}
h2,
span {
padding-right: 0.25rem;
}
}
.download {
display: none;
@media screen and (min-width: 800px) {
display: flex;
}
}
}
.markdown-body {
margin: 0.5rem 0.5rem 0 0;
}
</style>

View File

@@ -1,23 +0,0 @@
<template>
<div
v-highlightjs
class="markdown-body card"
v-html="$xss($md.render(project.body))"
></div>
</template>
<script>
export default {
auth: false,
props: {
project: {
type: Object,
default() {
return {}
},
},
},
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,8 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,10 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,8 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {}
</script>
<style lang="scss" scoped></style>

View File

@@ -1,10 +0,0 @@
<template>
<div></div>
</template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped></style>

View File

@@ -13,49 +13,27 @@
<!-- <NavStackItem link="/dashboard/analytics" label="Analytics">--> <!-- <NavStackItem link="/dashboard/analytics" label="Analytics">-->
<!-- <ChartIcon />--> <!-- <ChartIcon />-->
<!-- </NavStackItem>--> <!-- </NavStackItem>-->
<NavStackItem <NavStackItem link="/dashboard/revenue" label="Revenue">
v-if="hasMonetization()"
link="/dashboard/revenue"
label="Revenue"
>
<CurrencyIcon /> <CurrencyIcon />
</NavStackItem> </NavStackItem>
</NavStack> </NavStack>
</aside> </aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<NuxtChild /> <NuxtPage />
</div> </div>
</div> </div>
</template> </template>
<script setup>
<script>
import NavStack from '~/components/ui/NavStack' import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem' import NavStackItem from '~/components/ui/NavStackItem'
import DashboardIcon from '~/assets/images/utils/dashboard.svg?inline' import DashboardIcon from '~/assets/images/utils/dashboard.svg'
// import ChartIcon from '~/assets/images/utils/chart.svg?inline' import CurrencyIcon from '~/assets/images/utils/currency.svg'
import CurrencyIcon from '~/assets/images/utils/currency.svg?inline' import ListIcon from '~/assets/images/utils/list.svg'
import ListIcon from '~/assets/images/utils/list.svg?inline'
const monetization = true definePageMeta({
middleware: 'auth',
export default { })
name: 'Dashboard',
components: {
NavStack,
NavStackItem,
DashboardIcon,
// ChartIcon,
CurrencyIcon,
ListIcon,
},
methods: {
hasMonetization() {
return monetization
},
},
}
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -3,24 +3,16 @@
<section class="universal-card"> <section class="universal-card">
<h2>Analytics</h2> <h2>Analytics</h2>
<p>You found a secret!</p> <p>You found a secret!</p>
<nuxt-link to="/frog" class="goto-link" <nuxt-link to="/frog" class="goto-link"> Click here for fancy graphs! </nuxt-link>
>Click here for fancy graphs!</nuxt-link
>
</section> </section>
</div> </div>
</template> </template>
<script> <script>
export default { export default defineNuxtComponent({
components: {},
data() {
return {}
},
fetch() {},
head: { head: {
title: 'Analytics - Modrinth', title: 'Analytics - Modrinth',
}, },
methods: {}, })
}
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -6,11 +6,7 @@
<div class="grid-display__item"> <div class="grid-display__item">
<div class="label">Total downloads</div> <div class="label">Total downloads</div>
<div class="value"> <div class="value">
{{ {{ $formatNumber(user.projects.reduce((agg, x) => agg + x.downloads, 0)) }}
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.downloads, 0)
)
}}
</div> </div>
<span <span
>from >from
@@ -27,11 +23,7 @@
<div class="grid-display__item"> <div class="grid-display__item">
<div class="label">Total followers</div> <div class="label">Total followers</div>
<div class="value"> <div class="value">
{{ {{ $formatNumber(user.projects.reduce((agg, x) => agg + x.followers, 0)) }}
$formatNumber(
$user.projects.reduce((agg, x) => agg + x.followers, 0)
)
}}
</div> </div>
<span> <span>
<span <span
@@ -49,7 +41,9 @@
</div> </div>
<div class="grid-display__item"> <div class="grid-display__item">
<div class="label">Total revenue</div> <div class="label">Total revenue</div>
<div class="value">{{ $formatMoney(payouts.all_time) }}</div> <div class="value">
{{ $formatMoney(payouts.all_time) }}
</div>
<span>{{ $formatMoney(payouts.last_month) }} this month</span> <span>{{ $formatMoney(payouts.last_month) }} this month</span>
<!-- <NuxtLink class="goto-link" to="/dashboard/analytics"--> <!-- <NuxtLink class="goto-link" to="/dashboard/analytics"-->
<!-- >View breakdown--> <!-- >View breakdown-->
@@ -61,17 +55,16 @@
<div class="grid-display__item"> <div class="grid-display__item">
<div class="label">Current balance</div> <div class="label">Current balance</div>
<div class="value"> <div class="value">
{{ $formatMoney($auth.user.payout_data.balance) }} {{ $formatMoney(auth.user.payout_data.balance) }}
</div> </div>
<NuxtLink <NuxtLink
v-if="$auth.user.payout_data.balance >= minWithdraw" v-if="auth.user.payout_data.balance >= minWithdraw"
class="goto-link" class="goto-link"
to="/dashboard/revenue" to="/dashboard/revenue"
>Withdraw earnings >
<ChevronRightIcon Withdraw earnings
class="featured-header-chevron" <ChevronRightIcon class="featured-header-chevron" aria-hidden="true" />
aria-hidden="true" </NuxtLink>
/></NuxtLink>
<span v-else>${{ minWithdraw }} is the withdraw minimum</span> <span v-else>${{ minWithdraw }} is the withdraw minimum</span>
</div> </div>
</div> </div>
@@ -79,55 +72,37 @@
<section class="universal-card more-soon"> <section class="universal-card more-soon">
<h2>More coming soon!</h2> <h2>More coming soon!</h2>
<p> <p>
Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀) Stay tuned for more metrics and analytics (pretty graphs, anyone? 👀) coming to the creators
coming to the creators dashboard soon! dashboard soon!
</p> </p>
</section> </section>
</div> </div>
</template> </template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
<script> useHead({
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg?inline' title: 'Creator dashboard - Modrinth',
})
export default { const auth = await useAuth()
components: { ChevronRightIcon }, const app = useNuxtApp()
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 const [raw] = await Promise.all([
payouts.last_month = Math.floor(payouts.last_month * 100) / 100 useBaseFetch(`user/${auth.value.user.id}/payouts`, app.$defaultHeaders()),
])
const user = await useUser()
return { raw.all_time = Math.floor(raw.all_time * 100) / 100
payouts, raw.last_month = Math.floor(raw.last_month * 100) / 100
}
}, const payouts = ref(raw)
data() { const minWithdraw = ref(0.26)
return {
minWithdraw: 0.26, const downloadsProjectCount = computed(
} () => user.value.projects.filter((project) => project.downloads > 0).length
}, )
fetch() {}, const followersProjectCount = computed(
head: { () => user.value.projects.filter((project) => project.followers > 0).length
title: 'Creator dashboard - Modrinth', )
},
computed: {
downloadsProjectCount() {
return this.$user.projects.filter((project) => project.downloads > 0)
.length
},
followersProjectCount() {
return this.$user.projects.filter((project) => project.followers > 0)
.length
},
},
methods: {},
}
</script> </script>
<style lang="scss" scoped></style>

View File

@@ -3,9 +3,9 @@
<Modal ref="editLinksModal" header="Edit links"> <Modal ref="editLinksModal" header="Edit links">
<div class="universal-modal links-modal"> <div class="universal-modal links-modal">
<p> <p>
Any links you specify below will be overwritten on each of the Any links you specify below will be overwritten on each of the selected projects. Any you
selected projects. Any you leave blank will be ignored. You can clear leave blank will be ignored. You can clear a link from all selected projects using the
a link from all selected projects using the trash can button. trash can button.
</p> </p>
<section class="links"> <section class="links">
<label <label
@@ -21,14 +21,13 @@
:disabled="editLinks.issues.clear" :disabled="editLinks.issues.clear"
type="url" type="url"
:placeholder=" :placeholder="
editLinks.issues.clear editLinks.issues.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
? 'Existing link will be cleared'
: 'Enter a valid URL'
" "
maxlength="2048" maxlength="2048"
/> />
<button <button
v-tooltip="'Clear link'" v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button" class="square-button label-button"
:data-active="editLinks.issues.clear" :data-active="editLinks.issues.clear"
@click="editLinks.issues.clear = !editLinks.issues.clear" @click="editLinks.issues.clear = !editLinks.issues.clear"
@@ -50,13 +49,12 @@
type="url" type="url"
maxlength="2048" maxlength="2048"
:placeholder=" :placeholder="
editLinks.source.clear editLinks.source.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
? 'Existing link will be cleared'
: 'Enter a valid URL'
" "
/> />
<button <button
v-tooltip="'Clear link'" v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button" class="square-button label-button"
:data-active="editLinks.source.clear" :data-active="editLinks.source.clear"
@click="editLinks.source.clear = !editLinks.source.clear" @click="editLinks.source.clear = !editLinks.source.clear"
@@ -78,13 +76,12 @@
type="url" type="url"
maxlength="2048" maxlength="2048"
:placeholder=" :placeholder="
editLinks.wiki.clear editLinks.wiki.clear ? 'Existing link will be cleared' : 'Enter a valid URL'
? 'Existing link will be cleared'
: 'Enter a valid URL'
" "
/> />
<button <button
v-tooltip="'Clear link'" v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button" class="square-button label-button"
:data-active="editLinks.wiki.clear" :data-active="editLinks.wiki.clear"
@click="editLinks.wiki.clear = !editLinks.wiki.clear" @click="editLinks.wiki.clear = !editLinks.wiki.clear"
@@ -92,10 +89,7 @@
<TrashIcon /> <TrashIcon />
</button> </button>
</div> </div>
<label <label for="discord-invite-input" title="An invitation link to your Discord server.">
for="discord-invite-input"
title="An invitation link to your Discord server."
>
<span class="label__title">Discord invite</span> <span class="label__title">Discord invite</span>
</label> </label>
<div class="input-group shrink-first"> <div class="input-group shrink-first">
@@ -113,6 +107,7 @@
/> />
<button <button
v-tooltip="'Clear link'" v-tooltip="'Clear link'"
aria-label="Clear link"
class="square-button label-button" class="square-button label-button"
:data-active="editLinks.discord.clear" :data-active="editLinks.discord.clear"
@click="editLinks.discord.clear = !editLinks.discord.clear" @click="editLinks.discord.clear = !editLinks.discord.clear"
@@ -154,10 +149,7 @@
<CrossIcon /> <CrossIcon />
Cancel Cancel
</button> </button>
<button <button class="iconified-button brand-button" @click="bulkEditLinks()">
class="iconified-button brand-button"
@click="bulkEditLinks()"
>
<SaveIcon /> <SaveIcon />
Save changes Save changes
</button> </button>
@@ -169,10 +161,7 @@
<div class="header__row"> <div class="header__row">
<h2 class="header__title">Projects</h2> <h2 class="header__title">Projects</h2>
<div class="input-group"> <div class="input-group">
<button <button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
class="iconified-button brand-button"
@click="$refs.modal_creation.show()"
>
<PlusIcon /> <PlusIcon />
Create a project Create a project
</button> </button>
@@ -203,8 +192,8 @@
:close-on-select="true" :close-on-select="true"
:show-labels="false" :show-labels="false"
:allow-empty="false" :allow-empty="false"
@input="updateSort()" @update:model-value="projects = updateSort(projects, sortBy)"
></Multiselect> />
</div> </div>
</div> </div>
</div> </div>
@@ -212,8 +201,8 @@
<div class="grid-table__row grid-table__header"> <div class="grid-table__row grid-table__header">
<div> <div>
<Checkbox <Checkbox
:value="selectedProjects === projects" :model-value="selectedProjects === projects"
@input=" @update:model-value="
selectedProjects === projects selectedProjects === projects
? (selectedProjects = []) ? (selectedProjects = [])
: (selectedProjects = projects) : (selectedProjects = projects)
@@ -225,33 +214,22 @@
<div>ID</div> <div>ID</div>
<div>Type</div> <div>Type</div>
<div>Status</div> <div>Status</div>
<div></div> <div />
</div> </div>
<div <div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
v-for="project in projects"
:key="`project-${project.id}`"
class="grid-table__row"
>
<div> <div>
<Checkbox <Checkbox
:disabled=" :disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
(project.permissions & EDIT_DETAILS) === EDIT_DETAILS :model-value="selectedProjects.includes(project)"
" @update:model-value="
:value="selectedProjects.includes(project)"
@input="
selectedProjects.includes(project) selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter( ? (selectedProjects = selectedProjects.filter((it) => it !== project))
(it) => it !== project
))
: selectedProjects.push(project) : selectedProjects.push(project)
" "
/> />
</div> </div>
<div> <div>
<nuxt-link <nuxt-link tabindex="-1" :to="`/${project.project_type}/${project.slug}`">
tabindex="-1"
:to="`/${project.project_type}/${project.slug}`"
>
<Avatar <Avatar
:src="project.icon_url" :src="project.icon_url"
aria-hidden="true" aria-hidden="true"
@@ -265,9 +243,6 @@
<span class="project-title"> <span class="project-title">
<IssuesIcon <IssuesIcon
v-if="project.moderator_message" v-if="project.moderator_message"
v-tooltip="
'Project has a message from the moderators. View the project to see more.'
"
aria-label="Project has a message from the moderators. View the project to see more." aria-label="Project has a message from the moderators. View the project to see more."
/> />
@@ -285,15 +260,11 @@
</div> </div>
<div> <div>
{{ $formatProjectType(project.project_type) }} {{ $formatProjectType($getProjectTypeForUrl(project.project_type, project.loaders)) }}
</div> </div>
<div> <div>
<Badge <Badge v-if="project.status" :type="project.status" class="status" />
v-if="project.status"
:type="project.status"
class="status"
/>
</div> </div>
<div> <div>
@@ -317,20 +288,19 @@ import Multiselect from 'vue-multiselect'
import Badge from '~/components/ui/Badge.vue' import Badge from '~/components/ui/Badge.vue'
import Checkbox from '~/components/ui/Checkbox.vue' import Checkbox from '~/components/ui/Checkbox.vue'
import Modal from '~/components/ui/Modal.vue' import Modal from '~/components/ui/Modal.vue'
// import ModalConfirm from '~/components/ui/ModalConfirm.vue'
import Avatar from '~/components/ui/Avatar.vue' import Avatar from '~/components/ui/Avatar.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue' import ModalCreation from '~/components/ui/ModalCreation.vue'
import CopyCode from '~/components/ui/CopyCode.vue' import CopyCode from '~/components/ui/CopyCode.vue'
import SettingsIcon from '~/assets/images/utils/settings.svg?inline' import SettingsIcon from '~/assets/images/utils/settings.svg'
import TrashIcon from '~/assets/images/utils/trash.svg?inline' import TrashIcon from '~/assets/images/utils/trash.svg'
import IssuesIcon from '~/assets/images/utils/issues.svg?inline' import IssuesIcon from '~/assets/images/utils/issues.svg'
import PlusIcon from '~/assets/images/utils/plus.svg?inline' import PlusIcon from '~/assets/images/utils/plus.svg'
import CrossIcon from '~/assets/images/utils/x.svg?inline' import CrossIcon from '~/assets/images/utils/x.svg'
import EditIcon from '~/assets/images/utils/edit.svg?inline' import EditIcon from '~/assets/images/utils/edit.svg'
import SaveIcon from '~/assets/images/utils/save.svg?inline' import SaveIcon from '~/assets/images/utils/save.svg'
export default { export default defineNuxtComponent({
components: { components: {
Avatar, Avatar,
Badge, Badge,
@@ -343,14 +313,21 @@ export default {
EditIcon, EditIcon,
SaveIcon, SaveIcon,
Modal, Modal,
// ModalConfirm,
ModalCreation, ModalCreation,
Multiselect, Multiselect,
CopyCode, CopyCode,
}, },
async setup() {
const user = await useUser()
if (process.client) {
await initUserProjects()
}
return { user: ref(user) }
},
data() { data() {
return { return {
projects: [], projects: this.updateSort(this.user.projects, 'Name'),
versions: [], versions: [],
selectedProjects: [], selectedProjects: [],
sortBy: 'Name', sortBy: 'Name',
@@ -375,10 +352,6 @@ export default {
}, },
} }
}, },
fetch() {
this.projects = this.$user.projects
this.updateSort()
},
head: { head: {
title: 'Projects - Modrinth', title: 'Projects - Modrinth',
}, },
@@ -392,12 +365,11 @@ export default {
this.EDIT_MEMBER = 1 << 6 this.EDIT_MEMBER = 1 << 6
this.DELETE_PROJECT = 1 << 7 this.DELETE_PROJECT = 1 << 7
}, },
mounted() {},
methods: { methods: {
updateSort() { updateSort(projects, sort) {
switch (this.sortBy) { switch (sort) {
case 'Name': case 'Name':
this.projects = this.projects.slice().sort((a, b) => { return projects.slice().sort((a, b) => {
if (a.title < b.title) { if (a.title < b.title) {
return -1 return -1
} }
@@ -406,9 +378,8 @@ export default {
} }
return 0 return 0
}) })
break
case 'Status': case 'Status':
this.projects = this.projects.slice().sort((a, b) => { return projects.slice().sort((a, b) => {
if (a.status < b.status) { if (a.status < b.status) {
return -1 return -1
} }
@@ -417,9 +388,8 @@ export default {
} }
return 0 return 0
}) })
break
case 'Type': case 'Type':
this.projects = this.projects.slice().sort((a, b) => { return projects.slice().sort((a, b) => {
if (a.project_type < b.project_type) { if (a.project_type < b.project_type) {
return -1 return -1
} }
@@ -428,7 +398,6 @@ export default {
} }
return 0 return 0
}) })
break
default: default:
break break
} }
@@ -437,13 +406,11 @@ export default {
try { try {
const baseData = { const baseData = {
issues_url: issues_url:
!this.editLinks.issues.clear && !this.editLinks.issues.clear && this.editLinks.issues.val.trim() !== ''
this.editLinks.issues.val.trim() !== ''
? this.editLinks.issues.val ? this.editLinks.issues.val
: null, : null,
source_url: source_url:
!this.editLinks.source.clear && !this.editLinks.source.clear && this.editLinks.source.val.trim() !== ''
this.editLinks.source.val.trim() !== ''
? this.editLinks.source.val ? this.editLinks.source.val
: null, : null,
wiki_url: wiki_url:
@@ -451,18 +418,18 @@ export default {
? this.editLinks.wiki.val ? this.editLinks.wiki.val
: null, : null,
discord_url: discord_url:
!this.editLinks.discord.clear && !this.editLinks.discord.clear && this.editLinks.discord.val.trim() !== ''
this.editLinks.discord.val.trim() !== ''
? this.editLinks.discord.val ? this.editLinks.discord.val
: null, : null,
} }
await this.$axios.patch( await useBaseFetch(
`projects?ids=${JSON.stringify( `projects?ids=${JSON.stringify(this.selectedProjects.map((x) => x.id))}`,
this.selectedProjects.map((x) => x.id) {
)}`, method: 'PATCH',
baseData, body: baseData,
this.$defaultHeaders() ...this.$defaultHeaders(),
}
) )
this.$refs.editLinksModal.hide() this.$refs.editLinksModal.hide()
@@ -483,7 +450,7 @@ export default {
} }
}, },
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.grid-table { .grid-table {

View File

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

View File

@@ -9,12 +9,6 @@
</div> </div>
</template> </template>
<script>
export default {
auth: false,
}
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.card { .card {
width: calc(100% - 2 * var(--spacing-card-md)); width: calc(100% - 2 * var(--spacing-card-md));

File diff suppressed because one or more lines are too long

View File

@@ -20,7 +20,7 @@
</aside> </aside>
</div> </div>
<div class="normal-page__content"> <div class="normal-page__content">
<NuxtChild class="universal-card" /> <NuxtPage class="universal-card" />
</div> </div>
</div> </div>
</template> </template>
@@ -29,13 +29,12 @@
import NavStack from '~/components/ui/NavStack' import NavStack from '~/components/ui/NavStack'
import NavStackItem from '~/components/ui/NavStackItem' import NavStackItem from '~/components/ui/NavStackItem'
import TermsIcon from '~/assets/images/utils/heart-handshake.svg?inline' import TermsIcon from '~/assets/images/utils/heart-handshake.svg'
import PrivacyIcon from '~/assets/images/utils/lock.svg?inline' import PrivacyIcon from '~/assets/images/utils/lock.svg'
import RulesIcon from '~/assets/images/sidebar/admin.svg?inline' import RulesIcon from '~/assets/images/sidebar/admin.svg'
import ShieldIcon from '~/assets/images/utils/shield.svg?inline' import ShieldIcon from '~/assets/images/utils/shield.svg'
export default { export default defineNuxtComponent({
name: 'Settings',
components: { components: {
NavStack, NavStack,
NavStackItem, NavStackItem,
@@ -44,11 +43,11 @@ export default {
RulesIcon, RulesIcon,
ShieldIcon, ShieldIcon,
}, },
} })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.normal-page__content ::v-deep a { .normal-page__content :deep(a) {
color: var(--color-link); color: var(--color-link);
text-decoration: underline; text-decoration: underline;

View File

@@ -7,31 +7,27 @@
<h2>Foreword</h2> <h2>Foreword</h2>
<p> <p>
The following document was created as required by several laws, including The following document was created as required by several laws, including but not limited to:
but not limited to:
</p> </p>
<ul> <ul>
<li> <li>
the California Consumer Privacy Act (CA CCPA), more information about the California Consumer Privacy Act (CA CCPA), more information about which can be found on
which can be found on
<a href="https://oag.ca.gov/privacy/ccpa">oag.ca.gov</a> <a href="https://oag.ca.gov/privacy/ccpa">oag.ca.gov</a>
</li> </li>
<li> <li>
the European Union General Data Protection Regulation (EU GDPR), more the European Union General Data Protection Regulation (EU GDPR), more information about
information about which can be found on which can be found on
<a href="https://gdpr.eu/">gdpr.eu</a> <a href="https://gdpr.eu/">gdpr.eu</a>
</li> </li>
</ul> </ul>
<p> <p>
<a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("us", <a href="https://modrinth.com">Modrinth</a> is part of Rinth, Inc. ("us", "we", "our"). This
"we", "our"). This privacy policy explains how we collect data, process privacy policy explains how we collect data, process it, and your rights relative to your
it, and your rights relative to your data. data.
</p> </p>
<p> <p>Rinth, Inc. is the data controller for data collected through Modrinth.</p>
Rinth, Inc. is the data controller for data collected through Modrinth.
</p>
<h2>What data do we collect?</h2> <h2>What data do we collect?</h2>
@@ -45,14 +41,12 @@
<li>Your GitHub ID</li> <li>Your GitHub ID</li>
</ul> </ul>
<p> <p>
This data is used to identify you and display your profile. It will be This data is used to identify you and display your profile. It will be linked to your
linked to your projects. projects.
</p> </p>
<h3>View data and download data</h3> <h3>View data and download data</h3>
<p> <p>When you view a project page or download a file from Modrinth, we collect:</p>
When you view a project page or download a file from Modrinth, we collect:
</p>
<ul> <ul>
<li>Your IP address</li> <li>Your IP address</li>
<li>Your user ID (if applicable)</li> <li>Your user ID (if applicable)</li>
@@ -60,10 +54,7 @@
<li>Your country</li> <li>Your country</li>
<li>Some additional metadata about your connection (HTTP headers)</li> <li>Some additional metadata about your connection (HTTP headers)</li>
</ul> </ul>
<p> <p>This data is used to monitor automated access to our service and deliver statistics.</p>
This data is used to monitor automated access to our service and deliver
statistics.
</p>
<h3>Creator Monetization Program data</h3> <h3>Creator Monetization Program data</h3>
<p> <p>
@@ -77,44 +68,37 @@
<li>Your PayPal email address (if applicable)</li> <li>Your PayPal email address (if applicable)</li>
<li>Your Venmo username (if applicable)</li> <li>Your Venmo username (if applicable)</li>
</ul> </ul>
<p> <p>This data is used to carry out the CMP. It will be linked to your transactions.</p>
This data is used to carry out the CMP. It will be linked to your
transactions.
</p>
<h2>Data retention</h2> <h2>Data retention</h2>
<p> <p>
View data and download data are anonymized 24 months after being recorded. View data and download data are anonymized 24 months after being recorded. All personal
All personal information will be removed from those records during information will be removed from those records during anonymization.<br />
anonymization.<br /> Data is retained indefinitely. We do not delete any data unless you request it.
Data is retained indefinitely. We do not delete any data unless you
request it.
</p> </p>
<h2>Third-party services</h2> <h2>Third-party services</h2>
<p> <p>
We use some third-party services to make Modrinth run. Please refer to We use some third-party services to make Modrinth run. Please refer to each of their privacy
each of their privacy policies for more information: policies for more information:
</p> </p>
<ul> <ul>
<li> <li>
<a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> <a href="https://www.cloudflare.com/en-gb/gdpr/introduction/"> Cloudflare </a>
Cloudflare
</a>
</li> </li>
<li><a href="https://sentry.io/trust/privacy/">Sentry</a></li> <li><a href="https://sentry.io/trust/privacy/">Sentry</a></li>
</ul> </ul>
<p> <p>
Data that we specifically collect isn't shared with any other third party. Data that we specifically collect isn't shared with any other third party. We do not sell any
We do not sell any data. data.
</p> </p>
<h2>Data Governance</h2> <h2>Data Governance</h2>
<p> <p>
Database access is limited to the minimum amount of Rinth, Inc. employees Database access is limited to the minimum amount of Rinth, Inc. employees required to run the
required to run the service.<br /> service.<br />
Data is stored in a jurisdiction that is part of the European Economic Data is stored in a jurisdiction that is part of the European Economic Area (EEA), encrypted
Area (EEA), encrypted both in storage and in transit. both in storage and in transit.
</p> </p>
<h2>Marketing and advertising</h2> <h2>Marketing and advertising</h2>
@@ -124,107 +108,94 @@
</p> </p>
<h2>Cookies</h2> <h2>Cookies</h2>
<p>We use cookies to log you into your account and save your cosmetic preferences.</p>
<p> <p>
We use cookies to log you into your account and save your cosmetic Cookies are text files placed on your computer to collect standard Internet information. For
preferences. more information, please visit
</p>
<p>
Cookies are text files placed on your computer to collect standard
Internet information. For more information, please visit
<a href="https://allaboutcookies.org/">allaboutcookies.org</a>. <a href="https://allaboutcookies.org/">allaboutcookies.org</a>.
</p> </p>
<p> <p>
You can set your browser not to accept cookies, and the above website You can set your browser not to accept cookies, and the above website tells you how to remove
tells you how to remove cookies from your browser. However, in a few cookies from your browser. However, in a few cases, some of our website features may not
cases, some of our website features may not function as a result. function as a result.
</p> </p>
<h2> <h2>Access, rectification, erasure, restriction, portability, and objection</h2>
Access, rectification, erasure, restriction, portability, and objection
</h2>
<p>Every user is entitled to the following:</p> <p>Every user is entitled to the following:</p>
<ul> <ul>
<li> <li>
<strong>The right to access</strong> You have the right to request <strong>The right to access</strong> You have the right to request copies of your personal
copies of your personal data. We may charge you a small fee for this data. We may charge you a small fee for this service.
service.
</li> </li>
<li> <li>
<strong>The right to rectification</strong> You have the right to <strong>The right to rectification</strong> You have the right to request that we correct
request that we correct any information you believe is inaccurate. You any information you believe is inaccurate. You also have the right to request us to complete
also have the right to request us to complete the information you the information you believe is incomplete.
believe is incomplete.
</li> </li>
<li> <li>
<strong>The right to erasure</strong> You have the right to request <strong>The right to erasure</strong> You have the right to request that we erase your
that we erase your personal data, under certain conditions. personal data, under certain conditions.
</li> </li>
<li> <li>
<strong>The right to restrict processing</strong> You have the right <strong>The right to restrict processing</strong> You have the right to request that we
to request that we restrict the processing of your personal data, under restrict the processing of your personal data, under certain conditions.
</li>
<li>
<strong>The right to data portability</strong> You have the right to request that we
transfer the data that we have collected to another organization, or directly to you, under
certain conditions. certain conditions.
</li> </li>
<li> <li>
<strong>The right to data portability</strong> You have the right to <strong>The right to object to processing</strong> You have the right to object to our
request that we transfer the data that we have collected to another processing of your personal data, under certain conditions.
organization, or directly to you, under certain conditions.
</li>
<li>
<strong>The right to object to processing</strong> You have the right
to object to our processing of your personal data, under certain
conditions.
</li> </li>
</ul> </ul>
<p> <p>
If you would like to exercise those rights, contact us at If you would like to exercise those rights, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>. We may ask you <a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a>. We may ask you to verify your
to verify your identity before proceeding and will respond to your request identity before proceeding and will respond to your request within 30 days as required by law,
within 30 days as required by law, or notify you of an extended reply or notify you of an extended reply time.
time.
</p> </p>
<h2>Children's Information</h2> <h2>Children's Information</h2>
<p> <p>
Another part of our priority is adding protection for children while using Another part of our priority is adding protection for children while using the Internet. We
the Internet. We encourage parents and guardians to observe, participate encourage parents and guardians to observe, participate in, and/or monitor and guide their
in, and/or monitor and guide their online activity. online activity.
</p> </p>
<p> <p>
Modrinth does not knowingly collect any Personal Identifiable Information Modrinth does not knowingly collect any Personal Identifiable Information from children under
from children under the age of 13. If you think that your child provided the age of 13. If you think that your child provided this kind of information on our website,
this kind of information on our website, we strongly encourage you to we strongly encourage you to contact us immediately and we will do our best efforts to
contact us immediately and we will do our best efforts to promptly remove promptly remove such information from our records.
such information from our records.
</p> </p>
<h2>Online Privacy Policy Only</h2> <h2>Online Privacy Policy Only</h2>
<p> <p>
This Privacy Policy applies only to our online activities and is valid for This Privacy Policy applies only to our online activities and is valid for visitors to our
visitors to our website with regards to the information that they shared website with regards to the information that they shared and/or collect in Modrinth. This
and/or collect in Modrinth. This policy is not applicable to any policy is not applicable to any information collected offline or via channels other than this
information collected offline or via channels other than this website. website.
</p> </p>
<h2>Consent</h2> <h2>Consent</h2>
<p> <p>
By using our website, you hereby consent to our Privacy Policy and agree By using our website, you hereby consent to our Privacy Policy and agree to its Terms and
to its Terms and Conditions. Conditions.
</p> </p>
<h2>Changes to the Privacy Policy</h2> <h2>Changes to the Privacy Policy</h2>
<p> <p>
We keep this privacy policy under regular review and place any updates on We keep this privacy policy under regular review and place any updates on this web page. If we
this web page. If we do this, we will post the changes on this page and do this, we will post the changes on this page and update the "Last edited" date at the top of
update the "Last edited" date at the top of this page, after which such this page, after which such changes will become effective immediately. We will make an effort
changes will become effective immediately. We will make an effort to keep to keep users updated on any such changes, but because most changes do not affect how we
users updated on any such changes, but because most changes do not affect process existing data, a notice will not be sent for all changes.
how we process existing data, a notice will not be sent for all changes.
</p> </p>
<h2>Contact</h2> <h2>Contact</h2>
<p> <p>
If you have any questions about this privacy policy or how we process your If you have any questions about this privacy policy or how we process your data, contact us at
data, contact us at
<a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a> or write us at: <a href="mailto:gdpr@modrinth.com">gdpr@modrinth.com</a> or write us at:
</p> </p>
<p> <p>
@@ -236,8 +207,8 @@
<h3>How to contact the appropriate authority</h3> <h3>How to contact the appropriate authority</h3>
<p> <p>
Should you wish to fill a complaint or if you feel like we haven't Should you wish to fill a complaint or if you feel like we haven't addressed your concerns or
addressed your concerns or request, you may contact the request, you may contact the
<a href="https://ico.org.uk/">Information Commissioner's Office</a> <a href="https://ico.org.uk/">Information Commissioner's Office</a>
using their online form or by writing at: using their online form or by writing at:
</p> </p>
@@ -251,15 +222,14 @@
United Kingdom United Kingdom
</p> </p>
<p> <p>
You do not need to be a citizen of the United Kingdom to use this method You do not need to be a citizen of the United Kingdom to use this method of lodging
of lodging complaints. complaints.
</p> </p>
</div> </div>
</template> </template>
<script> <script>
export default { export default defineNuxtComponent({
auth: false,
head: { head: {
title: 'Privacy - Modrinth', title: 'Privacy - Modrinth',
meta: [ meta: [
@@ -282,11 +252,11 @@ export default {
{ {
hid: 'og:url', hid: 'og:url',
name: 'og:url', name: 'og:url',
content: `https://modrinth.com/legal/privacy`, content: 'https://modrinth.com/legal/privacy',
}, },
], ],
}, },
} })
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

@@ -4,165 +4,138 @@
<p> <p>
In order to facilitate Modrinth's In order to facilitate Modrinth's
<nuxt-link to="/legal/terms">Terms and Conditions</nuxt-link>, all Content <nuxt-link to="/legal/terms"> Terms and Conditions </nuxt-link>, all Content must obey the
must obey the following Rules. For more information on what exactly following Rules. For more information on what exactly Content is, please refer to the Content
Content is, please refer to the Content section of the Terms. section of the Terms.
</p> </p>
<p> <p>
Please note that these are general rules and will not be enforced "to the Please note that these are general rules and will not be enforced "to the letter". We reserve
letter". We reserve the right to modify and/or remove any file, project, the right to modify and/or remove any file, project, or other Content uploaded to our platform
or other Content uploaded to our platform for any reason. We reserve the for any reason. We reserve the right to introduce new rules at any time, which may or may not
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.
retroactively apply to already uploaded Content at the discretion of our
moderators.
</p> </p>
<p> <p>
If you find any violations of these Rules on our website, it is your If you find any violations of these Rules on our website, it is your responsibility to report
responsibility to report it. You may use the Report button on any project, it. You may use the Report button on any project, version, or user page, or you may email us
version, or user page, or you may email us at at
<a href="mailto:support@modrinth.com">support@modrinth.com</a>. <a href="mailto:support@modrinth.com">support@modrinth.com</a>.
</p> </p>
<h2 id="malicious-content">1. Malicious Content</h2> <h2 id="malicious-content">1. Malicious Content</h2>
<p> <p>Content cannot contain or download malware, which we define as anything that is designed:</p>
Content cannot contain or download malware, which we define as anything
that is designed:
</p>
<ul> <ul>
<li> <li>
to upload any data to a remote server (i.e. one that the user does not to upload any data to a remote server (i.e. one that the user does not directly choose to
directly choose to connect to in-game) without clear disclosure connect to in-game) without clear disclosure
</li> </li>
<li> <li>
to disrupt, damage, or otherwise cause harm or damage to an individual, to disrupt, damage, or otherwise cause harm or damage to an individual, computer, or network
computer, or network
</li> </li>
</ul> </ul>
<h2 id="clear-and-honest-function">2. Clear and honest function</h2> <h2 id="clear-and-honest-function">2. Clear and honest function</h2>
<p> <p>
Content, especially projects, must make a clear and honest attempt to Content, especially projects, must make a clear and honest attempt to describe their purpose
describe their purpose on the page(s) where it may be found. on the page(s) where it may be found.
</p> </p>
<p> <p>
Content must not make or share intentionally wrong or misleading claims. Content must not make or share intentionally wrong or misleading claims. This includes but is
This includes but is not limited to claims regarding the Content itself, not limited to claims regarding the Content itself, claims regarding other Content, and claims
claims regarding other Content, and claims not relating to Content on not relating to Content on Modrinth.
Modrinth.
</p> </p>
<h3 id="general-expectations">2.1. General expectations</h3> <h3 id="general-expectations">2.1. General expectations</h3>
<p> <p>
Projects in particular must attempt to describe the following three things Projects in particular must attempt to describe the following three things within their
within their description: description:
</p> </p>
<ul> <ul>
<li>what a project specifically does or adds</li> <li>what a project specifically does or adds</li>
<li>why someone should want to download the project</li> <li>why someone should want to download the project</li>
<li> <li>any other critical information the user must know before downloading</li>
any other critical information the user must know before downloading
</li>
</ul> </ul>
<p> <p>
Project descriptions must also be accessible. For the most part, this Project descriptions must also be accessible. For the most part, this means that descriptions
means that descriptions cannot mostly consist of text within images, and cannot mostly consist of text within images, and necessary information cannot be obscured.
necessary information cannot be obscured.
</p> </p>
<p> <p>
Projects which don't meet of these expectations may be removed from search Projects which don't meet of these expectations may be removed from search rather than removed
rather than removed from the platform altogether, at the moderators' from the platform altogether, at the moderators' discretion.
discretion.
</p> </p>
<h2 id="cheats-and-hacks">3. Cheats and Hacks</h2> <h2 id="cheats-and-hacks">3. Cheats and Hacks</h2>
<p> <p>
Projects cannot contain or download "cheats", which we define as a Projects cannot contain or download "cheats", which we define as a client-side modification
client-side modification that: that:
</p> </p>
<ul> <ul>
<li>is advertised as a "cheat", "hack", or "hacked client"</li> <li>is advertised as a "cheat", "hack", or "hacked client"</li>
<li> <li>
gives an unfair advantage in a multiplayer setting over other players gives an unfair advantage in a multiplayer setting over other players that do not have a
that do not have a comparable modification and does not provide a comparable modification and does not provide a server-side opt-out
server-side opt-out
</li> </li>
<li> <li>
contains any of the following functions without requiring a server-side contains any of the following functions without requiring a server-side opt-in:
opt-in:
<ul> <ul>
<li>X-ray or the ability to see through opaque blocks</li> <li>X-ray or the ability to see through opaque blocks</li>
<li>aim bot or aim assist</li> <li>aim bot or aim assist</li>
<li>flight, speed, or other movement modifications</li> <li>flight, speed, or other movement modifications</li>
<li>automatic PvP</li> <li>automatic PvP</li>
<li> <li>
active client-side hiding of third party modifications that have active client-side hiding of third party modifications that have server-side opt-outs
server-side opt-outs
</li> </li>
<li>item duplication</li> <li>item duplication</li>
</ul> </ul>
</li> </li>
</ul> </ul>
<h2 id="copyright-and-legality-of-content"> <h2 id="copyright-and-legality-of-content">4. Copyright and legality of Content</h2>
4. Copyright and legality of Content
</h2>
<p> <p>
You must own or have the necessary licenses, rights, consents, and You must own or have the necessary licenses, rights, consents, and permissions to store,
permissions to store, share, or distribute the Content that is uploaded share, or distribute the Content that is uploaded under your Modrinth account.
under your Modrinth account.
</p> </p>
<p> <p>
Content may not be directly "reuploaded" from another platform without the Content may not be directly "reuploaded" from another platform without the permission of the
permission of the author or copyright holder, even with the appropriate author or copyright holder, even with the appropriate licensing or other rights. This
licensing or other rights. This restriction does not apply to content restriction does not apply to content within modpacks or to so called "forks" - that is,
within modpacks or to so called "forks" - that is, modified copies of a modified copies of a project which have diverged substantially enough from the original
project which have diverged substantially enough from the original
project, at the discretion of Modrinth's moderators. project, at the discretion of Modrinth's moderators.
</p> </p>
<p> <p>Content must not infringe upon anyone's rights or intellectual property.</p>
Content must not infringe upon anyone's rights or intellectual property.
</p>
<p> <p>
Content must abide by the laws which govern Rinth, Inc., i.e. those of the Content must abide by the laws which govern Rinth, Inc., i.e. those of the United States and
United States and of the State of Delaware. of the State of Delaware.
</p> </p>
<h2 id="prohibited-content">5. Prohibited Content</h2> <h2 id="prohibited-content">5. Prohibited Content</h2>
<p> <p>Content on Modrinth is meant to be appropriate for audiences 13 years of age and above.</p>
Content on Modrinth is meant to be appropriate for audiences 13 years of
age and above.
</p>
<p>This means that the following Content is not allowed:</p> <p>This means that the following Content is not allowed:</p>
<ul> <ul>
<li>Content containing sexual or explicit material</li> <li>Content containing sexual or explicit material</li>
<li>Content promoting or sharing harmful or hateful behavior</li> <li>Content promoting or sharing harmful or hateful behavior</li>
<li> <li>Content themed around or containing real-life drugs or illicit substances</li>
Content themed around or containing real-life drugs or illicit
substances
</li>
<li>Content with an excessive amount of profane language</li> <li>Content with an excessive amount of profane language</li>
</ul> </ul>
</div> </div>
</template> </template>
<script> <script>
export default { export default defineNuxtComponent({
auth: false,
head: { head: {
title: 'Rules - Modrinth', title: 'Rules - Modrinth',
meta: [ meta: [
@@ -185,11 +158,11 @@ export default {
{ {
hid: 'og:url', hid: 'og:url',
name: 'og:url', name: 'og:url',
content: `https://modrinth.com/legal/rules`, content: 'https://modrinth.com/legal/rules',
}, },
], ],
}, },
} })
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

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