1
0

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
[*]
@@ -8,6 +7,7 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100
[*.md]
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:
push:
@@ -15,18 +15,19 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- name: Cache Node.js modules
uses: actions/cache@v3
node-version: 18.x
- name: Get yarn cache
id: yarn-cache
run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v3
with:
# npm cache files are stored in `~/.npm` on Linux/macOS
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
path: ${{ steps.yarn-cache.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-
${{ runner.os }}-yarn-
- name: Install dependencies
run: npm ci
- name: Build Knossos
run: npm run build
run: yarn install --immutable --immutable-cache --check-cache
- name: 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/
!.gitkeep
@@ -5,7 +14,6 @@ generated/
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
@@ -39,7 +47,6 @@ bower_components
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
@@ -60,24 +67,6 @@ typings/
# Yarn Integrity file
.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

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)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
@@ -35,7 +48,6 @@ bower_components
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
@@ -56,24 +68,6 @@ typings/
# Yarn Integrity file
.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
@@ -89,4 +83,6 @@ sw.*
# Vim swap files
*.swp
Dockerfile
# pnpm files
pnpm-lock.yaml
/.npmrc

View File

@@ -1,4 +1,5 @@
{
"printWidth": 100,
"semi": false,
"singleQuote": true,
"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
width="48"
height="48"
viewBox="0 0 12.7 12.7"
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:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<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">
>
<title id="title261">Prism Launcher Logo</title>
<defs id="defs3603" />
<g id="layer1">
<g
id="g531"
transform="matrix(0.1353646,0,0,0.1353646,15.301582,0.52916663)" />
@@ -157,47 +77,4 @@
</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>

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -71,9 +71,10 @@
.multiselect__placeholder {
color: var(--color-button-text);
margin-left: 8px;
margin-bottom: 8px;
opacity: 0.6;
font-size: 16px;
line-height: 20px;
line-height: 16px;
}
}
@@ -186,14 +187,10 @@
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
&:where(&.warning, &.information) {
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
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 {
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),
> .adjacent-input:first-child :where(> :first-child, .label__title) {
margin-block-start: 0;
}
//> .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) {
// margin-block-start: 0;
//}
}
.universal-card {
@@ -734,104 +743,25 @@
}
}
.tooltip {
display: block !important;
z-index: 10000;
transition: opacity 0.15s ease-in-out, visibility 0.15s ease-in-out;
.tooltip-inner {
background: var(--color-tooltip-bg);
color: var(--color-tooltip-text);
padding: 5px 10px 4px;
border-radius: var(--size-rounded-tooltip);
box-shadow: var(--shadow-floating);
font-size: 0.9rem;
.v-popper--theme-tooltip {
.v-popper__inner {
background: var(--color-tooltip-bg) !important;
color: var(--color-tooltip-text) !important;
padding: 5px 10px 4px !important;
border-radius: var(--size-rounded-tooltip) !important;
box-shadow: var(--shadow-floating) !important;
font-size: 0.9rem !important;
}
.tooltip-arrow {
width: 0;
height: 0;
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;
.v-popper__arrow-outer,
.v-popper__arrow-inner {
border-color: var(--color-tooltip-bg) !important;
}
}
.button-animation {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out;
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
&:active:not(&:disabled) {
transform: scale(0.95);
@@ -901,8 +831,8 @@ tr.button-transparent {
}
.button-within {
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out,
transform 0.05s ease-in-out, outline 0.2s ease-in-out;
transition: opacity 0.5s ease-in-out, filter 0.2s ease-in-out, transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
&:focus-visible:not(&.disabled),
&:hover:not(&.disabled) {
@@ -990,6 +920,7 @@ tr.button-transparent {
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
width: 2.25rem;
border-radius: var(--size-rounded-sm);
@@ -1135,9 +1066,10 @@ tr.button-transparent {
.multiselect__placeholder {
color: var(--color-button-text);
margin-left: 8px;
margin-bottom: 8px;
opacity: 0.6;
font-size: 16px;
line-height: 20px;
line-height: 16px;
}
}
@@ -1313,76 +1245,27 @@ tr.button-transparent {
z-index: 2;
}
&.warning {
border-left: 0.5rem solid var(--color-banner-side);
&:where(&.warning, &.information) {
padding: 1.5rem;
line-height: 1.5;
background-color: var(--color-banner-bg);
color: var(--color-banner-text);
min-height: 0;
a {
/* Uses active color to increase contrast */
color: var(--color-link-active);
text-decoration: underline;
}
}
}
.vue-notification {
background: var(--color-special-blue) !important;
border-left: 5px solid var(--color-special-blue) !important;
color: var(--color-brand-inverted) !important;
&.success {
background: var(--color-special-green) !important;
border-left-color: var(--color-special-green) !important;
&.warning {
border-left: 0.5rem solid var(--color-warning-banner-side);
background-color: var(--color-warning-banner-bg);
color: var(--color-warning-banner-text);
}
&.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 {
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;
}
&.information {
border-left: 0.5rem solid var(--color-info-banner-side);
background-color: var(--color-info-banner-bg);
color: var(--color-info-banner-text);
}
}
@@ -1449,7 +1332,7 @@ h1 {
font-weight: bold;
}
.nuxt-link-exact-active,
.router-link-exact-active,
h1,
h2,
h3 {

View File

@@ -67,9 +67,13 @@ html {
--color-warning-bg: hsl(355, 70%, 88%);
--color-warning-text: hsl(342, 70%, 35%);
--color-banner-text: hsl(0, 11%, 16%);
--color-banner-bg: hsl(0, 100%, 95%);
--color-banner-side: hsl(357, 78%, 40%);
--color-warning-banner-text: hsl(0, 11%, 16%);
--color-warning-banner-bg: hsl(0, 100%, 95%);
--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-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),
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px,
hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
@@ -112,11 +115,7 @@ html {
rgba(66, 71, 97, 0.34) 100%
);
--landing-border-color: rgba(129, 137, 175, 0.55);
--landing-creator-gradient: linear-gradient(
180deg,
#f8f8f8 0%,
#f8f8f8 63.19%
);
--landing-creator-gradient: linear-gradient(180deg, #f8f8f8 0%, #f8f8f8 63.19%);
--landing-blob-gradient: radial-gradient(
50% 50% at 50% 50%,
@@ -196,9 +195,13 @@ html {
--color-warning-bg: hsl(355, 70%, 20%);
--color-warning-text: hsl(342, 70%, 75%);
--color-banner-text: hsl(0, 100%, 96%);
--color-banner-bg: hsl(356, 18%, 18%);
--color-banner-side: hsl(357, 78%, 40%);
--color-warning-banner-text: hsl(0, 100%, 96%);
--color-warning-banner-bg: hsl(356, 18%, 18%);
--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-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: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px,
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--landing-maze-bg: url('https://cdn.modrinth.com/landing/landing.png');
--landing-maze-gradient-bg: linear-gradient(
0deg,
#31375f 0%,
rgba(8, 14, 55, 0) 100%
),
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
url('https://cdn.modrinth.com/landing/landing-lower.png');
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
@@ -251,8 +249,7 @@ html {
rgba(44, 48, 79, 0.35) 0%,
rgba(32, 35, 50, 0.2695) 100%
);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
--landing-card-bg: rgba(59, 63, 85, 0.15);
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
@@ -280,10 +277,9 @@ body {
// Defaults
background-color: var(--color-bg);
color: var(--color-text);
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen,
Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
font-family: var(--font-standard);
font-size: 16px;
font-weight: var(--font-weight-regular);
@@ -436,11 +432,12 @@ kbd {
font-size: 0.85em !important;
}
@import '~assets/styles/highlightjs.scss';
@import '~assets/styles/layout.scss';
@import '~assets/styles/utils.scss';
@import '~assets/styles/components.scss';
@import '~assets/styles/normalize.scss';
@import '~/assets/styles/highlightjs.scss';
@import '~/assets/styles/layout.scss';
@import '~/assets/styles/utils.scss';
@import '~/assets/styles/components.scss';
@import '~/assets/styles/normalize.scss';
@import '~/assets/styles/inter.scss';
button: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'
/ 100%;
@media screen and (max-width: 1024px) {
margin-top: var(--spacing-card-md);
}
.normal-page__sidebar {
grid-area: sidebar;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,25 +9,26 @@
class="checkbox"
role="checkbox"
:disabled="disabled"
:class="{ checked: value, collapsing: collapsingToggleStyle }"
:aria-label="description"
:aria-checked="value"
:class="{ checked: modelValue, collapsing: collapsingToggleStyle }"
:aria-label="description ?? label"
: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" />
</button>
<!-- 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 />
</div>
</template>
<script>
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import DropdownIcon from '~/assets/images/utils/dropdown.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg'
import DropdownIcon from '~/assets/images/utils/dropdown.svg'
export default {
name: 'Checkbox',
components: {
CheckIcon,
DropdownIcon,
@@ -43,9 +44,9 @@ export default {
},
description: {
type: String,
default: '',
default: null,
},
value: Boolean,
modelValue: Boolean,
clickEvent: {
type: Function,
default: () => {},
@@ -55,10 +56,11 @@ export default {
default: false,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('input', !this.value)
this.$emit('update:modelValue', !this.modelValue)
}
},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,27 +2,18 @@
<Modal ref="modal" header="Project moderation">
<div v-if="project !== null" class="moderation-modal universal-body">
<p>
A moderation message is optional, but it can be used to communicate
problems with a project's team members. The body is also optional and
supports markdown formatting!
A moderation message is optional, but it can be used to communicate problems with a
project's team members. The body is also optional and supports markdown formatting!
</p>
<div v-if="status" class="status">
<span>New project status: </span>
<Badge :type="status" />
</div>
<h3>Message title</h3>
<input
v-model="moderationMessage"
type="text"
placeholder="Enter the message..."
/>
<input v-model="moderationMessage" type="text" placeholder="Enter the message..." />
<h3>Message body</h3>
<div class="textarea-wrapper">
<Chips
v-model="bodyViewMode"
class="separator"
:items="['source', 'preview']"
/>
<Chips v-model="bodyViewMode" class="separator" :items="['source', 'preview']" />
<textarea
v-if="bodyViewMode === 'source'"
id="body"
@@ -34,20 +25,17 @@
: 'You must add a title before you add a body.'
"
/>
<div
v-else
v-highlightjs
class="markdown-body preview"
v-html="$xss($md.render(moderationMessageBody))"
></div>
<div v-else class="markdown-body preview" v-html="renderString(moderationMessageBody)" />
</div>
<div class="push-right input-group">
<button
v-if="moderationMessage || moderationMessageBody"
class="iconified-button"
@click="
moderationMessage = ''
moderationMessageBody = ''
() => {
moderationMessage = ''
moderationMessageBody = ''
}
"
>
<TrashIcon />
@@ -67,15 +55,15 @@
</template>
<script>
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
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 {
name: 'ModalModeration',
components: {
TrashIcon,
CrossIcon,
@@ -102,9 +90,7 @@ export default {
return {
bodyViewMode: 'source',
moderationMessage:
this.project && this.project.moderation_message
? this.project.moderation_message
: '',
this.project && this.project.moderation_message ? this.project.moderation_message : '',
moderationMessageBody:
this.project && this.project.moderation_message_body
? this.project.moderation_message_body
@@ -112,26 +98,23 @@ export default {
}
},
methods: {
renderString,
async saveProject() {
this.$nuxt.$loading.start()
startLoading()
try {
const data = {
moderation_message: this.moderationMessage
? this.moderationMessage
: null,
moderation_message_body: this.moderationMessageBody
? this.moderationMessageBody
: null,
moderation_message: this.moderationMessage ? this.moderationMessage : null,
moderation_message_body: this.moderationMessageBody ? this.moderationMessageBody : null,
}
if (this.status) {
data.status = this.status
}
await this.$axios.patch(
`project/${this.project.id}`,
data,
this.$defaultHeaders()
)
await useBaseFetch(`project/${this.project.id}`, {
method: 'PATCH',
body: data,
...this.$defaultHeaders(),
})
this.$refs.modal.hide()
if (this.onClose !== null) {
@@ -141,25 +124,21 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
show() {
this.$refs.modal.show()
this.moderationMessage =
this.project &&
this.project.moderator_message &&
this.project.moderator_message.message
this.project && this.project.moderator_message && this.project.moderator_message.message
? this.project.moderator_message.message
: ''
this.moderationMessageBody =
this.project &&
this.project.moderator_message &&
this.project.moderator_message.body
this.project && this.project.moderator_message && 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="markdown-body">
<p>
Modding should be safe for everyone, so we take abuse and malicious
intent seriously at Modrinth. We want to hear about harmful content on
the site that violates our
<nuxt-link to="/legal/terms">ToS</nuxt-link> and
<nuxt-link to="/legal/rules">Rules</nuxt-link>. Rest assured, well
keep your identifying information private.
Modding should be safe for everyone, so we take abuse and malicious intent seriously at
Modrinth. We want to hear about harmful content on the site that violates our
<nuxt-link to="/legal/terms"> ToS </nuxt-link> and
<nuxt-link to="/legal/rules"> Rules </nuxt-link>. Rest assured, well keep your
identifying information private.
</p>
<p v-if="itemType === 'project' || itemType === 'version'">
Please <strong>do not</strong> use this to report bugs with the
project itself. This form is only for submitting a report to Modrinth
staff. If the project has an Issues link or a Discord invite, consider
reporting it there.
Please <strong>do not</strong> use this to report bugs with the project itself. This form
is only for submitting a report to Modrinth staff. If the project has an Issues link or a
Discord invite, consider reporting it there.
</p>
</div>
<label class="report-label" for="report-type">
@@ -25,10 +23,8 @@
<multiselect
id="report-type"
v-model="reportType"
:options="$store.state.tag.reportTypes"
:custom-label="
(value) => value.charAt(0).toUpperCase() + value.slice(1)
"
:options="$tag.reportTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="false"
:searchable="false"
:show-no-results="false"
@@ -37,26 +33,14 @@
/>
<label class="report-label" for="additional-information">
<strong>Additional information</strong>
<span>
Include links and images if possible. Markdown formatting is
supported.
</span>
<span> Include links and images if possible. Markdown formatting is supported. </span>
</label>
<div class="textarea-wrapper">
<Chips
v-model="bodyViewType"
class="separator"
:items="['source', 'preview']"
/>
<Chips v-model="bodyViewType" class="separator" :items="['source', 'preview']" />
<div v-if="bodyViewType === 'source'" class="textarea-wrapper">
<textarea id="body" v-model="body" spellcheck="true" />
</div>
<div
v-else
v-highlightjs
class="preview"
v-html="$xss($md.render(body))"
></div>
<div v-else class="preview" v-html="renderString(body)" />
</div>
<div class="button-group">
<button class="iconified-button" @click="cancel">
@@ -74,13 +58,13 @@
<script>
import Multiselect from 'vue-multiselect'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import CheckIcon from '~/assets/images/utils/check.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
import { renderString } from '~/helpers/parse'
export default {
name: 'ModalReport',
components: {
Chips,
CrossIcon,
@@ -106,6 +90,7 @@ export default {
}
},
methods: {
renderString,
cancel() {
this.reportType = ''
this.body = ''
@@ -114,7 +99,7 @@ export default {
this.$refs.modal.hide()
},
async submitReport() {
this.$nuxt.$loading.start()
startLoading()
try {
const data = {
report_type: this.reportType,
@@ -122,18 +107,22 @@ export default {
item_type: this.itemType,
body: this.body,
}
await this.$axios.post('report', data, this.$defaultHeaders())
await useBaseFetch('report', {
method: 'POST',
body: data,
...this.$defaultHeaders(),
})
this.$refs.modal.hide()
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
text: err.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
show() {
this.$refs.modal.show()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
<template>
<div
v-if="getValidLoaders().length > 1 || getValidVersions().length > 1"
v-if="
loaderFilters.length > 1 || gameVersionFilters.length > 1 || versionTypeFilters.length > 1
"
class="card search-controls"
>
<Multiselect
v-if="getValidLoaders().length > 1"
v-if="loaderFilters.length > 1"
v-model="selectedLoaders"
:options="getValidLoaders()"
:options="loaderFilters"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:multiple="true"
:searchable="false"
@@ -15,19 +17,16 @@
:clear-search-on-select="false"
:show-labels="false"
:allow-empty="true"
:disabled="getValidLoaders().length === 1"
placeholder="Filter loader..."
@input="updateVersionFilters()"
></Multiselect>
@update:model-value="updateVersionFilters()"
/>
<Multiselect
v-if="getValidVersions().length > 1"
v-if="gameVersionFilters.length > 1"
v-model="selectedGameVersions"
:options="
showSnapshots
? getValidVersions().map((x) => x.version)
: getValidVersions()
.filter((it) => it.version_type === 'release')
.map((x) => x.version)
includeSnapshots
? gameVersionFilters.map((x) => x.version)
: gameVersionFilters.filter((it) => it.version_type === 'release').map((x) => x.version)
"
:multiple="true"
:searchable="true"
@@ -37,12 +36,12 @@
:hide-selected="true"
:selectable="() => selectedGameVersions.length <= 6"
placeholder="Filter versions..."
@input="updateVersionFilters()"
></Multiselect>
@update:model-value="updateVersionFilters()"
/>
<Multiselect
v-if="getValidChannels().length > 1"
v-model="selectedChannels"
:options="getValidChannels()"
v-if="versionTypeFilters.length > 1"
v-model="selectedVersionTypes"
:options="versionTypeFilters"
:custom-label="(x) => $capitalizeString(x)"
:multiple="true"
:searchable="false"
@@ -52,29 +51,30 @@
:show-labels="false"
:allow-empty="true"
placeholder="Filter channels..."
@input="updateVersionFilters()"
></Multiselect>
@update:model-value="updateVersionFilters()"
/>
<Checkbox
v-if="
getValidVersions().length > 1 &&
getValidVersions().some((v) => v.version_type !== 'release')
gameVersionFilters.length > 1 &&
gameVersionFilters.some((v) => v.version_type !== 'release')
"
v-model="showSnapshots"
v-model="includeSnapshots"
label="Include snapshots"
description="Include snapshots"
:border="false"
@input="updateQuery"
@update:model-value="updateQuery"
/>
<button
title="Clear filters"
:disabled="
selectedLoaders.length === 0 && selectedGameVersions.length === 0
"
:disabled="selectedLoaders.length === 0 && selectedGameVersions.length === 0"
class="iconified-button"
@click="
selectedLoaders = []
selectedGameVersions = []
updateVersionFilters()
() => {
selectedLoaders = []
selectedGameVersions = []
selectedVersionTypes = []
updateVersionFilters()
}
"
>
<ClearIcon />
@@ -83,125 +83,81 @@
</div>
</template>
<script>
<script setup>
import Multiselect from 'vue-multiselect'
import Checkbox from '~/components/ui/Checkbox'
import ClearIcon from '~/assets/images/utils/clear.svg?inline'
export default {
name: 'VersionFilterControl',
components: {
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
)
)
import ClearIcon from '~/assets/images/utils/clear.svg'
const temp = this.versions.filter(
(projectVersion) =>
(this.selectedGameVersions.length === 0 ||
this.selectedGameVersions.some((gameVersion) =>
projectVersion.game_versions.includes(gameVersion)
)) &&
(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 emit = defineEmits(['updateVersions'])
const props = defineProps({
versions: {
type: Array,
default() {
return []
},
},
})
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>
@@ -219,25 +175,4 @@ export default {
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>

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

View File

@@ -1,13 +1,15 @@
<template>
<Checkbox
class="filter"
:value="activeFilters.includes(facetName)"
:model-value="activeFilters.includes(facetName)"
:description="displayName"
@input="toggle()"
@update:model-value="toggle()"
>
<div class="filter-text">
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon"></div>
<div v-else class="icon"><slot /></div>
<div v-if="icon" aria-hidden="true" class="icon" v-html="icon" />
<div v-else class="icon">
<slot />
</div>
<span aria-hidden="true"> {{ displayName }}</span>
</div>
</Checkbox>
@@ -17,7 +19,6 @@
import Checkbox from '~/components/ui/Checkbox'
export default {
name: 'SearchFilter',
components: {
Checkbox,
},
@@ -41,6 +42,7 @@ export default {
},
},
},
emits: ['toggle'],
methods: {
toggle() {
this.$emit('toggle', this.facetName)
@@ -50,10 +52,10 @@ export default {
</script>
<style lang="scss" scoped>
.filter ::v-deep {
.filter {
margin-bottom: 0.5rem;
.filter-text {
:deep(.filter-text) {
display: flex;
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>
<div class="main">
<div class="error">
<Logo404 v-if="error.statusCode === 404" />
<h1 v-else>An error occurred!</h1>
<p>{{ error.message }}</p>
<div class="button-group">
<nuxt-link to="/" class="iconified-button raised-button brand-button">
Go home
</nuxt-link>
<a
href="https://discord.gg/EUHuJHt"
class="iconified-button raised-button"
rel="noopener noreferrer nofollow"
>
Get help on Discord
</a>
<NuxtLayout>
<div class="main">
<div class="error">
<Logo404 v-if="error.statusCode === '404'" />
<h1 v-else>An error occurred!</h1>
<p>{{ error.message }}</p>
<div class="button-group">
<nuxt-link to="/" class="iconified-button raised-button brand-button">
Go home
</nuxt-link>
<a
href="https://discord.gg/EUHuJHt"
class="iconified-button raised-button"
rel="noopener"
>
Get help on Discord
</a>
</div>
</div>
</div>
</div>
</NuxtLayout>
</template>
<script>
import Logo404 from '~/assets/images/404.svg?inline'
import Logo404 from './assets/images/404.svg'
export default {
components: {
Logo404,
},
layout: 'home',
props: {
error: {
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'
// Scripting
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 xml from 'highlight.js/lib/languages/xml'
import properties from 'highlight.js/lib/languages/properties'
import { md, configuredXss } from '~/helpers/parse'
/* REGISTRATION */
// Scripting
@@ -37,38 +37,27 @@ hljs.registerLanguage('properties', properties)
/* ALIASES */
// Scripting
hljs.registerAliases(['js'], 'javascript')
hljs.registerAliases(['py'], 'python')
hljs.registerAliases(['js'], { languageName: 'javascript' })
hljs.registerAliases(['py'], { languageName: 'python' })
// Coding
hljs.registerAliases(['kt'], 'kotlin')
hljs.registerAliases(['kt'], { languageName: 'kotlin' })
// Configs
hljs.registerAliases(['json5'], 'json')
hljs.registerAliases(['toml'], 'ini')
hljs.registerAliases(['yml'], 'yaml')
hljs.registerAliases(['html', 'htm', 'xhtml', 'mcui', 'fxml'], 'xml')
hljs.registerAliases(['json5'], { languageName: 'json' })
hljs.registerAliases(['toml'], { languageName: 'ini' })
hljs.registerAliases(['yml'], { languageName: 'yaml' })
hljs.registerAliases(['html', 'htm', 'xhtml', 'mcui', 'fxml'], { languageName: 'xml' })
Vue.directive('highlightjs', {
deep: true,
bind(el, binding) {
// on first bind, highlight all targets
const targets = el.querySelectorAll('pre > code')
targets.forEach((target) => {
// if a value is directly assigned to the directive, use this
// instead of the element content.
if (binding.value) {
target.textContent = binding.value
}
hljs.highlightBlock(target)
})
},
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)
}
})
},
})
export const renderHighlightedString = (string) =>
configuredXss.process(
md({
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value
} catch (__) {}
}
return ''
},
}).render(string)
)

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) {
if (!context.from) {
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
}
export default defineNuxtRouteMiddleware(async () => {
const auth = await useAuth()
if (context.route.query.code) {
const date = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days
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 })
}
if (!auth.value.user) {
return navigateTo(getAuthUrl(), { external: true })
}
// 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 { sortRoutes } from '@nuxt/utils'
import axios from 'axios'
import svgLoader from 'vite-svg-loader'
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_ARIADNE_URL = 'https://staging-ariadne.modrinth.com/v1/'
export default {
/*
** Nuxt target
** See https://nuxtjs.org/api/configuration-target
*/
target: 'server',
/*
** Headers of the page
** See https://nuxtjs.org/api/configuration-head
*/
head: {
htmlAttrs: {
lang: 'en',
export default defineNuxtConfig({
app: {
head: {
htmlAttrs: {
lang: 'en',
},
title: 'Modrinth',
meta: [
{
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.',
},
{
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: [
{
charset: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
hid: 'description',
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',
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',
},
},
vite: {
plugins: [
svgLoader({
svgoConfig: {
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false,
},
},
},
],
},
}),
eslintPlugin(),
],
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: {
locales: ['en'],
defaultLocale: 'en',
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: {
build: {
async before(nuxt, buildOptions) {
// 30 minutes
const TTL = 30 * 60 * 1000
async 'build:before'() {
// 30 minutes
const TTL = 30 * 60 * 1000
let state = {}
try {
state = JSON.parse(
await fs.readFile('./generated/state.json', 'utf8')
)
} catch {
// File doesn't exist, create folder
await fs.mkdir('./generated', { recursive: true })
}
let state = {}
try {
state = JSON.parse(await fs.readFile('./generated/state.json', 'utf8'))
} catch {
// File doesn't exist, create folder
await fs.mkdir('./generated', { recursive: true })
}
const API_URL = getApiUrl()
const API_URL = getApiUrl()
if (
// Skip regeneration if within TTL...
state.lastGenerated &&
new Date(state.lastGenerated).getTime() + TTL >
new Date().getTime() &&
// ...but only if the API URL is the same
state.apiUrl &&
state.apiUrl === API_URL
) {
return
}
if (
// Skip regeneration if within TTL...
state.lastGenerated &&
new Date(state.lastGenerated).getTime() + TTL > new Date().getTime() &&
// ...but only if the API URL is the same
state.apiUrl &&
state.apiUrl === API_URL
) {
return
}
console.log('Generating tags...')
state.lastGenerated = new Date().toISOString()
state.lastGenerated = new Date().toISOString()
state.apiUrl = API_URL
state.apiUrl = API_URL
const headers = {
headers: {
'user-agent': 'Knossos generator (support@modrinth.com)',
},
}
const headers = {
headers: {
'user-agent': `Knossos generator (admin@modrinth.com)`,
},
}
const [categories, loaders, gameVersions, donationPlatforms, reportTypes] = await Promise.all(
[
$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 [
categories,
loaders,
gameVersions,
donationPlatforms,
reportTypes,
] = (
await Promise.all([
axios.get(`${API_URL}tag/category`, headers),
axios.get(`${API_URL}tag/loader`, headers),
axios.get(`${API_URL}tag/game_version`, headers),
axios.get(`${API_URL}tag/donation_platform`, headers),
axios.get(`${API_URL}tag/report_type`, headers),
])
).map((it) => it.data)
state.categories = categories
state.loaders = loaders
state.gameVersions = gameVersions
state.donationPlatforms = donationPlatforms
state.reportTypes = reportTypes
state.categories = categories
state.loaders = loaders
state.gameVersions = gameVersions
state.donationPlatforms = donationPlatforms
state.reportTypes = reportTypes
await fs.writeFile('./generated/state.json', JSON.stringify(state))
await fs.writeFile('./generated/state.json', JSON.stringify(state))
console.log('Tags generated!')
},
console.log('Tags generated!')
},
render: {
routeDone(url, result, context) {
setTimeout(() => {
axios
.post(
`${process.env.ARIADNE_URL || STAGING_ARIADNE_URL}view`,
{
url: getDomain() + url,
ip:
context.req.headers['cf-connecting-ip'] ??
context.req.headers['x-real-ip'] ??
context.req.connection.remoteAddress,
headers: context.req.headers,
},
{
headers: {
'Modrinth-Admin': process.env.ARIADNE_ADMIN_KEY || 'feedbeef',
},
}
)
.then(() => {})
.catch((e) => {
console.error(
'An error occurred while registering the visit: ',
e.response ? e.response.data : e
)
})
})
},
'pages:extend'(routes) {
routes.splice(
routes.findIndex((x) => x.name === 'search-searchProjectType'),
1
)
routes.push({
name: 'search-mods',
path: '/mods',
file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
children: [],
})
routes.push({
name: 'search-modpacks',
path: '/modpacks',
file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
children: [],
})
routes.push({
name: 'search-plugins',
path: '/plugins',
file: resolve(__dirname, 'pages/search/[searchProjectType].vue'),
children: [],
})
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() {
return process.env.BROWSER_BASE_URL ?? STAGING_API_URL
}
function getAriadneUrl() {
return process.env.BROWSER_ARIADNE_URL ?? STAGING_ARIADNE_URL
}
function getDomain() {
if (process.env.NODE_ENV === 'production') {
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,
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"export": "nuxt export",
"serve": "nuxt serve",
"lint:js": "eslint --ext .js,.vue --ignore-path .eslintignore .",
"lint": "npm run lint:js",
"fix": "eslint --fix --ext .js,.vue --ignore-path .eslintignore ."
"build": "nuxi build",
"dev": "nuxi dev",
"generate": "nuxi generate",
"preview": "nuxi preview",
"postinstall": "nuxi prepare",
"lint:js": "eslint --ext .js,.vue,.ts,.jsx,.tsx,.html,.vue .",
"lint": "npm run lint:js && prettier --check .",
"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": {
"@iarna/toml": "^2.2.5",
"@nuxtjs/axios": "^5.13.1",
"@nuxtjs/dayjs": "^1.2.0",
"@nuxtjs/style-resources": "^1.0.0",
"cookie-universal-nuxt": "^2.1.5",
"core-js": "^3.9.1",
"highlight.js": "^10.3.2",
"dayjs": "^1.11.7",
"floating-vue": "^2.0.0-beta.20",
"highlight.js": "^11.7.0",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "^13.0.1",
"nuxt": "^2.15.3",
"sass": "^1.32.12",
"v-tooltip": "^2.0.3",
"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"
"toml": "^3.0.0",
"vue-multiselect": "^3.0.0-alpha.2",
"xss": "^1.0.14"
}
}

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>
<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
v-if="$auth.user && currentMember"
ref="modal_edit_item"
@@ -71,8 +78,8 @@
<label for="gallery-image-featured">
<span class="label__title">Featured</span>
<span class="label__description">
A featured gallery image shows up in search and your project card.
Only one gallery image can be featured.
A featured gallery image shows up in search and your project card. Only one gallery
image can be featured.
</span>
</label>
<button
@@ -94,10 +101,7 @@
Unfeature image
</button>
<div class="button-group">
<button
class="iconified-button"
@click="$refs.modal_edit_item.hide()"
>
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
<CrossIcon />
Cancel
</button>
@@ -145,11 +149,7 @@
? expandedGalleryItem.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="
expandedGalleryItem.title
? expandedGalleryItem.title
: 'gallery-image'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop=""
/>
@@ -164,10 +164,7 @@
</div>
<div class="controls">
<div class="buttons">
<button
class="close circle-button"
@click="expandedGalleryItem = null"
>
<button class="close circle-button" @click="expandedGalleryItem = null">
<CrossIcon aria-hidden="true" />
</button>
<a
@@ -220,25 +217,21 @@
<DropArea :accept="acceptFileTypes" @change="handleFiles" />
</div>
<div class="items">
<div
v-for="(item, index) in project.gallery"
:key="index"
class="card gallery-item"
>
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="
item.url
? item.url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'"
/>
</a>
<div class="gallery-body">
<div class="gallery-info">
<h2 v-if="item.title">{{ item.title }}</h2>
<p v-if="item.description">{{ item.description }}</p>
<h2 v-if="item.title">
{{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div>
</div>
<div class="gallery-bottom">
@@ -250,13 +243,15 @@
<button
class="iconified-button"
@click="
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon />
@@ -265,8 +260,10 @@
<button
class="iconified-button"
@click="
deleteIndex = index
$refs.modal_confirm.show()
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon />
@@ -280,29 +277,29 @@
</template>
<script>
import PlusIcon from '~/assets/images/utils/plus.svg?inline'
import CalendarIcon from '~/assets/images/utils/calendar.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg?inline'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg?inline'
import EditIcon from '~/assets/images/utils/edit.svg?inline'
import SaveIcon from '~/assets/images/utils/save.svg?inline'
import ExternalIcon from '~/assets/images/utils/external.svg?inline'
import ExpandIcon from '~/assets/images/utils/expand.svg?inline'
import ContractIcon from '~/assets/images/utils/contract.svg?inline'
import StarIcon from '~/assets/images/utils/star.svg?inline'
import UploadIcon from '~/assets/images/utils/upload.svg?inline'
import InfoIcon from '~/assets/images/utils/info.svg?inline'
import ImageIcon from '~/assets/images/utils/image.svg?inline'
import TransferIcon from '~/assets/images/utils/transfer.svg?inline'
import PlusIcon from '~/assets/images/utils/plus.svg'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import TrashIcon from '~/assets/images/utils/trash.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import RightArrowIcon from '~/assets/images/utils/right-arrow.svg'
import LeftArrowIcon from '~/assets/images/utils/left-arrow.svg'
import EditIcon from '~/assets/images/utils/edit.svg'
import SaveIcon from '~/assets/images/utils/save.svg'
import ExternalIcon from '~/assets/images/utils/external.svg'
import ExpandIcon from '~/assets/images/utils/expand.svg'
import ContractIcon from '~/assets/images/utils/contract.svg'
import StarIcon from '~/assets/images/utils/star.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import InfoIcon from '~/assets/images/utils/info.svg'
import ImageIcon from '~/assets/images/utils/image.svg'
import TransferIcon from '~/assets/images/utils/transfer.svg'
import FileInput from '~/components/ui/FileInput'
import DropArea from '~/components/ui/DropArea'
import ModalConfirm from '~/components/ui/ModalConfirm'
import Modal from '~/components/ui/Modal'
export default {
export default defineNuxtComponent({
components: {
CalendarIcon,
PlusIcon,
@@ -325,7 +322,6 @@ export default {
FileInput,
DropArea,
},
auth: false,
props: {
project: {
type: Object,
@@ -356,36 +352,8 @@ export default {
editFile: null,
previewImage: null,
shouldPreventActions: false,
}
},
head() {
const title = `${this.project.title} - Gallery`
const description = `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`
return {
title,
meta: [
{
hid: 'og:title',
name: 'og:title',
content: title,
},
{
hid: 'apple-mobile-web-app-title',
name: 'apple-mobile-web-app-title',
content: title,
},
{
hid: 'og:description',
name: 'og:description',
content: description,
},
{
hid: 'description',
name: 'description',
content: description,
},
],
metaDescription: `View ${this.project.gallery.length} images of ${this.project.title} on Modrinth.`,
}
},
computed: {
@@ -456,24 +424,30 @@ export default {
},
async createGalleryItem() {
this.shouldPreventActions = true
this.$nuxt.$loading.start()
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[
this.editFile.type.split('/').length - 1
]
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle)
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription)
}
if (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()
this.$refs.modal_edit_item.hide()
@@ -481,30 +455,37 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
this.$nuxt.$loading.start()
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url
)}&featured=${this.editFeatured}`
if (this.editTitle)
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
if (this.editDescription)
}
if (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()
this.$refs.modal_edit_item.hide()
@@ -512,23 +493,26 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
this.$nuxt.$loading.start()
startLoading()
try {
await this.$axios.delete(
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url
)}`,
this.$defaultHeaders()
{
method: 'DELETE',
...this.$defaultHeaders(),
}
)
await this.updateProject()
@@ -536,19 +520,25 @@ export default {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response ? err.response.data.description : err,
text: err.data ? err.data.description : err,
type: 'error',
})
}
this.$nuxt.$loading.finish()
stopLoading()
},
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()
},
},
}
})
</script>
<style lang="scss" scoped>
@@ -756,8 +746,7 @@ export default {
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm)
var(--spacing-card-md);
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
.gallery-created {
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">
<span class="label__title size-card-header">Description</span>
<span class="label__description">
You can type an extended description of your mod here. This editor
supports
You can type an extended description of your mod here. This editor supports
<a
class="text-link"
href="https://guides.github.com/features/mastering-markdown/"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
>Markdown</a
>. HTML can also be used inside your description, not including
styles, scripts, and iframes (though YouTube iframes are allowed).
>. HTML can also be used inside your description, not including styles, scripts, and
iframes (though YouTube iframes are allowed).
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and
function of the project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank"
>Content Rules</nuxt-link
>
The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
for the full requirements.
</span>
</span>
@@ -34,12 +31,9 @@
</div>
<div
v-else-if="bodyViewMode === 'preview'"
v-highlightjs
class="markdown-body"
v-html="
description ? $xss($md.render(description)) : 'No body specified.'
"
></div>
v-html="description ? renderHighlightedString(description) : 'No body specified.'"
/>
<div class="input-group">
<button
type="button"
@@ -57,10 +51,10 @@
<script>
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 {
export default defineNuxtComponent({
components: {
Chips,
SaveIcon,
@@ -100,13 +94,10 @@ export default {
},
data() {
return {
description: '',
description: this.project.body,
bodyViewMode: 'source',
}
},
fetch() {
this.description = this.project.body
},
computed: {
patchData() {
const data = {}
@@ -125,13 +116,14 @@ export default {
this.EDIT_BODY = 1 << 3
},
methods: {
renderHighlightedString,
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData)
}
},
},
}
})
</script>
<style lang="scss" scoped>
.resizable-textarea-wrapper textarea {

View File

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

View File

@@ -6,34 +6,24 @@
<span class="label__title size-card-header">License</span>
<span class="label__description">
It is very important to choose a proper license for your
{{ $formatProjectType(project.project_type).toLowerCase() }}. You
may choose one from our list or provide a custom license. You may
also provide a custom URL to your chosen license; otherwise, the
license text will be displayed.
<span
v-if="license && license.friendly === 'Custom'"
class="label__subdescription"
>
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
our list or provide a custom license. You may also provide a custom URL to your chosen
license; otherwise, the license text will be displayed.
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
Enter a valid
<a
href="https://spdx.org/licenses/"
target="_blank"
rel="noopener noreferrer"
class="text-link"
>
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
SPDX license identifier</a
>
in the marked area. If your license does not have a SPDX
identifier (for example, if you created the license yourself or if
the license is Minecraft-specific), simply check the box and enter
the name of the license instead.
in the marked area. If your license does not have a SPDX identifier (for example, if
you created the license yourself or if the license is Minecraft-specific), simply
check the box and enter the name of the license instead.
</span>
<span class="label__subdescription">
Confused? See our
<a
href="https://blog.modrinth.com/licensing-guide/"
target="_blank"
rel="noopener noreferrer"
rel="noopener"
class="text-link"
>
licensing guide</a
@@ -62,6 +52,7 @@
v-if="license.requiresOnlyOrLater"
v-model="allowOrLater"
:disabled="!hasPermission"
description="Allow later editions of this license"
>
Allow later editions of this license
</Checkbox>
@@ -69,6 +60,7 @@
v-if="license.friendly === 'Custom'"
v-model="nonSpdxLicense"
:disabled="!hasPermission"
description="License does not have a SPDX identifier"
>
License does not have a SPDX identifier
</Checkbox>
@@ -110,9 +102,9 @@
<script>
import Multiselect from 'vue-multiselect'
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: {
Multiselect,
Checkbox,
@@ -154,28 +146,110 @@ export default {
showKnownErrors: false,
}
},
fetch() {
this.licenseUrl = this.project.license.url
async setup(props) {
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
.replaceAll('-only', '')
.replaceAll('-or-later', '')
.replaceAll('LicenseRef-', '')
this.license = this.defaultLicenses.find(
(x) => x.short === trimmedLicenseId
) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
const license = ref(
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
friendly: 'Custom',
short: licenseId.replaceAll('LicenseRef-', ''),
}
)
if (licenseId === 'LicenseRef-Unknown') {
this.license = {
license.value = {
friendly: 'Unknown',
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: {
hasPermission() {
@@ -188,87 +262,18 @@ export default {
(this.nonSpdxLicense && this.license.friendly === 'Custom') ||
this.license.short === 'All-Rights-Reserved' ||
this.license.short === 'Unknown'
)
) {
id += 'LicenseRef-'
}
id += this.license.short
if (this.license.requiresOnlyOrLater)
if (this.license.requiresOnlyOrLater) {
id += this.allowOrLater ? '-or-later' : '-only'
if (this.nonSpdxLicense && this.license.friendly === 'Custom')
}
if (this.nonSpdxLicense && this.license.friendly === 'Custom') {
id = id.replaceAll(' ', '-')
}
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() {
const data = {}
@@ -292,6 +297,6 @@ export default {
}
},
},
}
})
</script>
<style lang="scss" scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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