Rewrite Parity (#647)

* Rewrite Parity

* Update SEO, fix modals, add dashes to changelog

* Edit create version title

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

* Deploy?

* Fix vercel config

* Fix it again

* Finish user editing

* Remove broken docker build

* Switch reports to modals

* Update project card

* Navbar line animation in most places

* Add chips

* Move to navlink query params

* remove autogen file

* Add copy code

* Fix webkit text box outlines, port report modal

* Update error page

* Switch to avatar component

* Make keyboard nav work

* Fix team member spacing

* improve project ID display (#676)

* Bug fixes

* Update OG site title

* More fixes

* Design tweaks

* Fix card wrapping on mobile

* Darken light theme color a little

* Sidebar navigation for settings, notifications, and moderation

* Change follow icon from a heart to a bell

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

This reverts commit e30b46ec5d93c57df847be88eba123c7419dd03b.

* Change follows icon in settings

* AaaaUUUUUUUGghghhhhhhhh

* Project sidebar transparent button animations

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

* Fix environments filter condition being inverted

* Remove -> revert

* Improve readability of warning banners on light mode

* Fix mobile menu button colors

* Clean up notifications page more

* Creator dashboard and monetization work

* Add processing fees declarations and acknowledgement box

* Beta badges

* Downgrade Nuxt Vercel Builder

* Update the style of button groups to be more consistent

* More button consistency

* Remove desktop navbar on mobile

* Update home page progress indicators

* Fix page jumping (Thanks @stairman06)

* Make checkbox checked style consistent with other selection indicators

* More home page updates

* Properly reset NavRows

* Move filters menu on mobile

* Stylized checkbox updated to match active styling

* Filters icon

* Respect prefers-reduced-motion

* Add most backend payouts changes (untested)

* Finish tested payouts code

* Allow monetization unenrolling

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

* More consistent button group on project page

* Rounded tables

* Fix some things (#716)

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

* Remove dummy data

* The great CSS refactor

* Remove commented out css

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

* Fix active chip size

* Remove shadow from selected chip

* Require email set for CMP

* Update styles of notifications to universal-card

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

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

* Update project creation modal

* Make beta badge less bright

* Beta badge heading styling

* Update withdraw processing fees info

* Remove redundant label

* be

* Fix inverted logic

* 2% is 0.02

* Add toggle to turn off alpha modpacks banner

* Why warning button?

* Add more footer links (#719)

* Add more footer links

* Move twitter

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

* Bump text down a little on home page

* Update favicon colors

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

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

* Ah, yes

* this doesn't actually need to be important

* Align items in input groups

* Adjust some spacings and clear creation modal on opening

* Versions now clickable

* Add link to edit page to default description

* Improve monetization information text

* Make wrapped text inputs not shrink

* Make chips work better

* smol margin on clear mod message button

* Allow non-authenticated users to access settings

* Remove settings anchors

* Fix versions page button style on firefox

* Add advanced rendering toggle

* Update slug input and icon card in project edit page

* Legal sidebar

* h1 at beginning of description no longer has top margin

* Use universal card for legal pages

* Update email addresses on legal pages

* Update various page titles and descriptions for consistency

* Various fixes and consolidation to API URL retrieval

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

Also finally fixes staging URL being jank

* Make the theme button show regardless of login state

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

* Make mobile profile dropdown ordering consistent with desktop

* Change the base url back

* Revert "Change the base url back"

This reverts commit c1da89fddb83776b39f626eab33c8dc67f8a75e4.

* constantize

* Tiny fixes (#722)

* Box-shadow chip outlines

* Show settings when signed out

* mods -> projects

* space

* Beta badge border

* Slug input overflow fix, scrollable

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

* Fix minor bugs

* fix moderation  page

* More fixes

* Temp fix for download button

* BEGONE TABLES

* Fix download button

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

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

@@ -0,0 +1,111 @@
<template>
<img
v-if="src"
ref="img"
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
:src="src"
:alt="alt"
/>
<svg
v-else
:class="`avatar size-${size} ${circle ? 'circle' : ''}`"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 104 104"
aria-hidden="true"
>
<path fill="none" d="M0 0h103.4v103.4H0z" />
<path
fill="none"
stroke="#9a9a9a"
stroke-width="5"
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
/>
</svg>
</template>
<script>
export default {
name: 'Avatar',
props: {
src: {
type: String,
default: null,
},
alt: {
type: String,
default: '',
},
size: {
type: String,
default: 'sm',
validator(value) {
return ['xs', 'sm', 'md', 'lg'].includes(value)
},
},
circle: {
type: Boolean,
default: false,
},
},
mounted() {
if (this.$refs.img && this.$refs.img.naturalWidth) {
const isPixelated = () => {
if (
this.$refs.img.naturalWidth < 96 &&
this.$refs.img.naturalWidth > 0
) {
this.$refs.img.style.imageRendering = 'pixelated'
}
}
if (this.$refs.img.naturalWidth) {
isPixelated()
} else {
this.$refs.img.onload = isPixelated
}
}
},
}
</script>
<style lang="scss" scoped>
.avatar {
border-radius: var(--size-rounded-icon);
box-shadow: var(--shadow-inset-lg), var(--shadow-card);
height: var(--size);
width: var(--size);
background-color: var(--color-button-bg);
object-fit: contain;
&.size-xs {
--size: 2.5rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-card);
border-radius: var(--size-rounded-sm);
}
&.size-md {
--size: 6rem;
border-radius: var(--size-rounded-lg);
}
&.size-lg {
--size: 9rem;
border-radius: var(--size-rounded-lg);
}
&.circle {
border-radius: 50%;
}
}
</style>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,134 @@
<template>
<Modal ref="modal" :header="title">
<div class="modal-delete">
<div class="markdown-body" v-html="$xss($md.render(description))"></div>
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<span>
<strong>To verify, type</strong>
<em class="confirmation-text">{{ confirmationText }}</em>
<strong>below:</strong>
</span>
</label>
<div class="confirmation-input">
<input
v-if="hasToType"
id="confirmation"
v-model="confirmation_typed"
type="text"
placeholder="Type here..."
@input="type"
/>
</div>
<div class="button-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button
class="iconified-button danger-button"
:disabled="action_disabled"
@click="proceed"
>
<TrashIcon />
{{ proceedLabel }}
</button>
</div>
</div>
</Modal>
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import TrashIcon from '~/assets/images/utils/trash.svg?inline'
import Modal from '~/components/ui/Modal'
export default {
name: 'ModalConfirm',
components: {
CrossIcon,
TrashIcon,
Modal,
},
props: {
confirmationText: {
type: String,
default: '',
},
hasToType: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'No title defined',
required: true,
},
description: {
type: String,
default: 'No description defined',
required: true,
},
proceedLabel: {
type: String,
default: 'Proceed',
},
},
data() {
return {
action_disabled: this.hasToType,
confirmation_typed: '',
}
},
methods: {
cancel() {
this.$refs.modal.hide()
},
proceed() {
this.$refs.modal.hide()
this.$emit('proceed')
},
type() {
if (this.hasToType) {
this.action_disabled =
this.confirmation_typed.toLowerCase() !==
this.confirmationText.toLowerCase()
}
},
show() {
this.$refs.modal.show()
},
},
}
</script>
<style scoped lang="scss">
.modal-delete {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 1rem;
}
.confirmation-label {
margin-bottom: 0.5rem;
}
.confirmation-text {
padding-right: 0.25ch;
}
.confirmation-input {
input {
width: 20rem;
max-width: 100%;
}
}
.button-group {
margin-left: auto;
margin-top: 1.5rem;
}
}
</style>

View File

@@ -0,0 +1,240 @@
<template>
<Modal ref="modal" header="Create a project">
<div class="modal-creation universal-labels">
<div class="markdown-body">
<p>
New projects are created as drafts and can be found under your profile
page.
</p>
</div>
<label for="project-type">
<span class="label__title"
>Project type<span class="required">*</span></span
>
</label>
<Chips
id="project-type"
v-model="projectType"
:items="$tag.projectTypes.map((x) => x.display)"
/>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
placeholder="Enter project name..."
autocomplete="off"
@input="updatedName()"
/>
<label for="slug">
<span class="label__title">URL<span class="required">*</span></span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
https://modrinth.com/{{
getProjectType() ? getProjectType().id : '???'
}}/
</div>
<input
id="slug"
v-model="slug"
type="text"
maxlength="64"
autocomplete="off"
@input="manualSlug = true"
/>
</div>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description"
>This appears in search and on the sidebar of your project's
page.</span
>
</label>
<div class="textarea-wrapper">
<textarea
id="additional-information"
v-model="description"
maxlength="256"
/>
</div>
<div class="push-right input-group">
<button class="iconified-button" @click="cancel">
<CrossIcon />
Cancel
</button>
<button class="iconified-button brand-button" @click="createProject">
<CheckIcon />
Continue
</button>
</div>
</div>
</Modal>
</template>
<script>
import CrossIcon from '~/assets/images/utils/x.svg?inline'
import CheckIcon from '~/assets/images/utils/right-arrow.svg?inline'
import Modal from '~/components/ui/Modal'
import Chips from '~/components/ui/Chips'
export default {
name: 'ModalCreation',
components: {
Chips,
CrossIcon,
CheckIcon,
Modal,
},
props: {
itemType: {
type: String,
default: '',
},
itemId: {
type: String,
default: '',
},
},
data() {
return {
projectType: this.$tag.projectTypes[0].display,
name: '',
slug: '',
description: '',
manualSlug: false,
}
},
methods: {
cancel() {
this.$refs.modal.hide()
},
getProjectType() {
return this.$tag.projectTypes.find((x) => this.projectType === x.display)
},
async createProject() {
this.$nuxt.$loading.start()
const projectType = this.getProjectType()
const formData = new FormData()
formData.append(
'data',
JSON.stringify({
title: this.name,
project_type: projectType.actual,
slug: this.slug,
description: this.description,
body: `# Placeholder description
This is your new ${projectType.display}, ${
this.name
}. A checklist below is provided to help prepare for release.
### Before submitting for review
- Upload at least one version
- [Edit project description](https://modrinth.com/${this.getProjectType().id}/${
this.slug
}/edit)
- Update metadata
- Select license
- Set up environments
- Choose categories
- Add source, wiki, Discord and donation links (optional)
- Add images to gallery (optional)
- Invite project team members (optional)
> Submissions are normally reviewed within 24 hours, but may take up to 48 hours
Questions? [Join the Modrinth Discord for support!](https://discord.gg/EUHuJHt)`,
initial_versions: [],
team_members: [
{
user_id: this.$auth.user.id,
name: this.$auth.user.username,
role: 'Owner',
},
],
categories: [],
client_side: 'unknown',
server_side: 'unknown',
license_id: this.$tag.licenses.map((it) => it.short).includes('arr')
? 'arr'
: this.$tag.licenses[0].short,
is_draft: true,
})
)
console.log(formData)
try {
await this.$axios({
url: 'project',
method: 'POST',
data: formData,
headers: {
'Content-Type': 'multipart/form-data',
Authorization: this.$auth.token,
},
})
this.$refs.modal.hide()
await this.$router.replace(`/${projectType.display}/${this.slug}`)
} catch (err) {
this.$notify({
group: 'main',
title: 'An error occurred',
text: err.response.data.description,
type: 'error',
})
}
this.$nuxt.$loading.finish()
},
show() {
this.projectType = this.$tag.projectTypes[0].display
this.name = ''
this.slug = ''
this.description = ''
this.manualSlug = false
this.$refs.modal.show()
},
updatedName() {
if (!this.manualSlug) {
this.slug = this.name.toLowerCase().replaceAll(' ', '-')
}
},
},
}
</script>
<style scoped lang="scss">
.modal-creation {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
.markdown-body {
margin-bottom: 0.5rem;
}
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--spacing-card-md);
}
}
</style>

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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