New collections (#1484)
* [WIP] Transfer collections to own branch * fixes * rewrite js * Add visibility dropdown to collection edit modal * Add visibility badges to collection page * Update visibility options and icons in collection page * Add delete functionality to collection modal * Collection project deletion flow * remove "visit project" button on overflow * Remove via checklist not individually * Update manage title in settings.vue * remove collections from settings page * hook up collections page * collection header to look like project header * Refactor layout.scss and collections.vue * fix omorphia * Update * Conform collections to old design structure * Update navigation links and remove unused code * Add collection view and collections to user page * Refactor user project display logic * Add collection creation functionality and update profile labels * Add function calls to initialize user collections * Refactor collection page layout and functionality * Add initialization of user collections in create function * Fix styling issue in collection page * Update collection status to private * remove name * Refactor card component and update grid layout * Finish collections --------- Co-authored-by: Carter <safe@fea.st>
This commit is contained in:
parent
e319d19a54
commit
3a735ea0ce
1
assets/images/utils/world.svg
Normal file
1
assets/images/utils/world.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe-2"><path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/><path d="M7 3.34V5a3 3 0 0 0 3 3v0a2 2 0 0 1 2 2v0c0 1.1.9 2 2 2v0a2 2 0 0 0 2-2v0c0-1.1.9-2 2-2h3.17"/><path d="M11 21.95V18a2 2 0 0 0-2-2v0a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
|
After Width: | Height: | Size: 472 B |
@ -49,6 +49,8 @@ html {
|
||||
|
||||
--color-ad: #d6e6f9;
|
||||
--color-ad-raised: #b1c8e4;
|
||||
--color-ad-contrast: var(--color-text);
|
||||
--color-ad-highlight: #088cdb;
|
||||
|
||||
--color-grey-link: var(--color-text);
|
||||
--color-grey-link-hover: var(--color-heading);
|
||||
@ -187,6 +189,8 @@ html {
|
||||
|
||||
--color-ad: #1f324a;
|
||||
--color-ad-raised: #2e4057;
|
||||
--color-ad-contrast: var(--color-text);
|
||||
--color-ad-highlight: #088cdb;
|
||||
|
||||
--color-link: #74b6f3;
|
||||
--color-link-hover: #92c0f5;
|
||||
|
||||
@ -69,6 +69,12 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.full-page {
|
||||
margin: 0 auto;
|
||||
max-width: min(1280px, 100vw);
|
||||
width: 80rem;
|
||||
}
|
||||
|
||||
.normal-page {
|
||||
margin: 0 auto;
|
||||
max-width: 80rem;
|
||||
@ -87,6 +93,20 @@
|
||||
'content dummy' 1fr
|
||||
/ 1fr 20rem;
|
||||
}
|
||||
|
||||
&.no-sidebar {
|
||||
grid-template:
|
||||
'header header' auto
|
||||
'content content' auto
|
||||
'info info' auto
|
||||
'dummy dummy' 1fr
|
||||
/ 1fr 1fr;
|
||||
|
||||
.normal-page__content {
|
||||
grid-area: content;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
|
||||
118
components/ui/CollectionCreateModal.vue
Normal file
118
components/ui/CollectionCreateModal.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<Modal ref="modal" header="Create a collection">
|
||||
<div class="universal-modal modal-creation universal-labels">
|
||||
<div class="markdown-body">
|
||||
<p>
|
||||
Your new collection will be created as a public collection with
|
||||
{{ projectIds.length > 0 ? projectIds.length : 'no' }}
|
||||
{{ projectIds.length !== 1 ? 'projects' : 'project' }}.
|
||||
</p>
|
||||
</div>
|
||||
<label for="name">
|
||||
<span class="label__title">Name<span class="required">*</span></span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
maxlength="64"
|
||||
:placeholder="`Enter collection name...`"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label for="additional-information">
|
||||
<span class="label__title">Summary<span class="required">*</span></span>
|
||||
<span class="label__description">This appears on your collection's page.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea id="additional-information" v-model="description" maxlength="256" />
|
||||
</div>
|
||||
<div class="push-right input-group">
|
||||
<Button @click="modal.hide()">
|
||||
<CrossIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" @click="create">
|
||||
<CheckIcon />
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon as CrossIcon, CheckIcon, Modal, Button } from 'omorphia'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
|
||||
const modal = ref()
|
||||
|
||||
const props = defineProps({
|
||||
projectIds: {
|
||||
type: Array,
|
||||
default() {
|
||||
return []
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function create() {
|
||||
startLoading()
|
||||
try {
|
||||
const result = await useBaseFetch('collection', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
projects: props.projectIds,
|
||||
},
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
await initUserCollections()
|
||||
|
||||
modal.value.hide()
|
||||
await router.push(`/collection/${result.id}`)
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err?.data?.description || err?.message || err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
stopLoading()
|
||||
}
|
||||
function show() {
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-creation {
|
||||
input {
|
||||
width: 20rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.text-input-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-top: var(--gap-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -11,7 +11,7 @@
|
||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||
<div class="modal-body">
|
||||
<div v-if="header" class="header">
|
||||
<h1>{{ header }}</h1>
|
||||
<strong>{{ header }}</strong>
|
||||
<button class="iconified-button icon-only transparent" @click="hide">
|
||||
<CrossIcon />
|
||||
</button>
|
||||
@ -127,8 +127,9 @@ export default {
|
||||
background-color: var(--color-bg);
|
||||
padding: var(--spacing-card-md) var(--spacing-card-lg);
|
||||
|
||||
h1 {
|
||||
strong {
|
||||
font-size: 1.25rem;
|
||||
margin: 0.67em 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -361,8 +361,6 @@ async function performAction(notification, actionIndex) {
|
||||
try {
|
||||
await read()
|
||||
|
||||
await userDeleteNotification(notification.id)
|
||||
|
||||
if (actionIndex !== null) {
|
||||
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
||||
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
||||
|
||||
@ -77,7 +77,7 @@
|
||||
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-else-if="showCreatedDate"
|
||||
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="stat date"
|
||||
>
|
||||
@ -198,6 +198,11 @@ export default {
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
showCreatedDate: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
hideLoaders: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
||||
@ -25,6 +25,7 @@ export const useCosmetics = () =>
|
||||
shader: 'gallery',
|
||||
datapack: 'list',
|
||||
user: 'list',
|
||||
collection: 'list',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,12 +19,12 @@ export const initUser = async () => {
|
||||
|
||||
if (auth.user && auth.user.id) {
|
||||
try {
|
||||
const [notifications, follows] = await Promise.all([
|
||||
useBaseFetch(`user/${auth.user.id}/notifications`),
|
||||
const [follows, collections] = await Promise.all([
|
||||
useBaseFetch(`user/${auth.user.id}/follows`),
|
||||
useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 }),
|
||||
])
|
||||
|
||||
user.notifications = notifications
|
||||
user.collections = collections
|
||||
user.follows = follows
|
||||
user.lastUpdated = Date.now()
|
||||
} catch (err) {
|
||||
@ -35,13 +35,13 @@ export const initUser = async () => {
|
||||
return user
|
||||
}
|
||||
|
||||
export const initUserNotifs = async () => {
|
||||
export const initUserCollections = 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`)
|
||||
user.collections = await useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 })
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
@ -74,6 +74,28 @@ export const initUserProjects = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
export const userCollectProject = async (collection, projectId) => {
|
||||
const user = (await useUser()).value
|
||||
|
||||
const add = !collection.projects.includes(projectId)
|
||||
const projects = add
|
||||
? [...collection.projects, projectId]
|
||||
: [...collection.projects].filter((x) => x !== projectId)
|
||||
|
||||
const idx = user.collections.findIndex((x) => x.id === collection.id)
|
||||
if (idx >= 0) {
|
||||
user.collections[idx].projects = projects
|
||||
}
|
||||
|
||||
await useBaseFetch(`collection/${collection.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
new_projects: projects,
|
||||
},
|
||||
apiVersion: 3,
|
||||
})
|
||||
}
|
||||
|
||||
export const userFollowProject = async (project) => {
|
||||
const user = (await useUser()).value
|
||||
|
||||
@ -100,29 +122,6 @@ export const userUnfollowProject = async (project) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const userDeleteNotification = async (id) => {
|
||||
const user = (await useUser()).value
|
||||
|
||||
user.notifications = user.notifications.filter((x) => x.id !== id)
|
||||
}
|
||||
|
||||
export const userDeleteNotifications = async (ids) => {
|
||||
const user = (await useUser()).value
|
||||
|
||||
user.notifications = user.notifications.filter((x) => !ids.includes(x.id))
|
||||
}
|
||||
|
||||
export const userReadNotifications = async (ids) => {
|
||||
const user = (await useUser()).value
|
||||
|
||||
user.notifications = user.notifications.map((x) => {
|
||||
if (ids.includes(x.id)) {
|
||||
x.read = true
|
||||
}
|
||||
return x
|
||||
})
|
||||
}
|
||||
|
||||
export const resendVerifyEmail = async () => {
|
||||
const app = useNuxtApp()
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useNuxtApp } from '#app'
|
||||
import { userReadNotifications } from '~/composables/user.js'
|
||||
|
||||
async function getBulk(type, ids) {
|
||||
if (ids.length === 0) {
|
||||
@ -168,7 +167,6 @@ export async function markAsRead(ids) {
|
||||
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
|
||||
method: 'PATCH',
|
||||
})
|
||||
await userReadNotifications(ids)
|
||||
return (notifications) => {
|
||||
const newNotifs = notifications
|
||||
newNotifs.forEach((notif) => {
|
||||
|
||||
@ -33,7 +33,6 @@
|
||||
v-if="auth.user"
|
||||
to="/dashboard/notifications"
|
||||
class="control-button button-transparent"
|
||||
:class="{ bubble: user.notifications.some((notif) => !notif.read) }"
|
||||
title="Notifications"
|
||||
>
|
||||
<NotificationIcon aria-hidden="true" />
|
||||
@ -86,9 +85,9 @@
|
||||
<ChartIcon class="icon" />
|
||||
<span class="title">Dashboard</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="item button-transparent" to="/dashboard/follows">
|
||||
<HeartIcon class="icon" />
|
||||
<span class="title">Following</span>
|
||||
<NuxtLink class="item button-transparent" to="/dashboard/collections">
|
||||
<LibraryIcon class="icon" />
|
||||
<span class="title">Collections</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="item button-transparent" to="/settings">
|
||||
<SettingsIcon class="icon" />
|
||||
@ -236,7 +235,6 @@
|
||||
to="/dashboard/notifications"
|
||||
class="tab button-animation"
|
||||
:class="{
|
||||
bubble: user.notifications.some((notif) => !notif.read),
|
||||
'no-active': isMobileMenuOpen || isBrowseMenuOpen,
|
||||
}"
|
||||
title="Notifications"
|
||||
@ -359,7 +357,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { LogInIcon, DownloadIcon } from 'omorphia'
|
||||
import { LogInIcon, DownloadIcon, LibraryIcon } from 'omorphia'
|
||||
import HamburgerIcon from '~/assets/images/utils/hamburger.svg'
|
||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||
import SearchIcon from '~/assets/images/utils/search.svg'
|
||||
@ -383,7 +381,6 @@ import Avatar from '~/components/ui/Avatar.vue'
|
||||
|
||||
const app = useNuxtApp()
|
||||
const auth = await useAuth()
|
||||
const user = await useUser()
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
|
||||
@ -47,7 +47,7 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "^13.0.1",
|
||||
"omorphia": "^0.7.1",
|
||||
"omorphia": "=0.7.1",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"vue-multiselect": "^3.0.0-alpha.2",
|
||||
|
||||
@ -140,6 +140,7 @@
|
||||
<div class="markdown-body" v-html="renderString(licenseText)" />
|
||||
</div>
|
||||
</Modal>
|
||||
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
|
||||
<div
|
||||
:class="{
|
||||
'normal-page': true,
|
||||
@ -214,6 +215,7 @@
|
||||
/>
|
||||
</Categories>
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div class="primary-stat">
|
||||
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
@ -223,6 +225,7 @@
|
||||
download<span v-if="project.downloads !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="primary-stat">
|
||||
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
@ -262,13 +265,9 @@
|
||||
<hr class="card-divider" />
|
||||
<div class="input-group">
|
||||
<template v-if="auth.user">
|
||||
<button class="iconified-button" @click="() => reportProject(project.id)">
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</button>
|
||||
<button
|
||||
v-if="!user.follows.find((x) => x.id === project.id)"
|
||||
class="iconified-button"
|
||||
class="btn"
|
||||
@click="userFollowProject(project)"
|
||||
>
|
||||
<HeartIcon aria-hidden="true" />
|
||||
@ -276,12 +275,59 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="user.follows.find((x) => x.id === project.id)"
|
||||
class="iconified-button"
|
||||
class="btn"
|
||||
@click="userUnfollowProject(project)"
|
||||
>
|
||||
<HeartIcon fill="currentColor" aria-hidden="true" />
|
||||
Unfollow
|
||||
</button>
|
||||
<PopoutMenu class="btn" direction="right" position="bottom" from="top-right">
|
||||
<BookmarkIcon aria-hidden="true" />
|
||||
Save
|
||||
<template #menu>
|
||||
<input
|
||||
v-model="displayCollectionsSearch"
|
||||
type="text"
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<Checkbox
|
||||
v-for="option in collections"
|
||||
:key="option.id"
|
||||
:model-value="option.projects.includes(project.id)"
|
||||
class="popout-checkbox"
|
||||
@update:model-value="userCollectProject(option, project.id)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div v-else class="menu-text">
|
||||
<p class="popout-text">No collections found.</p>
|
||||
</div>
|
||||
<button class="btn collection-button" @click="$refs.modal_collection.show()">
|
||||
<PlusIcon />
|
||||
Create new collection
|
||||
</button>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
<OverflowMenu
|
||||
class="btn icon-only"
|
||||
:options="[
|
||||
{
|
||||
id: 'report',
|
||||
action: () => reportProject(project.id),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
direction="right"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<template #report> <ReportIcon /> Report</template>
|
||||
<template #copy-id> <ClipboardCopyIcon /> Copy ID</template>
|
||||
</OverflowMenu>
|
||||
</template>
|
||||
<template v-else>
|
||||
<nuxt-link class="iconified-button" to="/auth/sign-in">
|
||||
@ -292,6 +338,24 @@
|
||||
<HeartIcon aria-hidden="true" />
|
||||
Follow
|
||||
</nuxt-link>
|
||||
|
||||
<OverflowMenu
|
||||
class="btn icon-only"
|
||||
:options="[
|
||||
{
|
||||
id: 'report',
|
||||
action: () => navigateTo('/auth/sign-in'),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
direction="right"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<template #report> <ReportIcon /> Report</template>
|
||||
<template #copy-id> <ClipboardCopyIcon /> Copy ID</template>
|
||||
</OverflowMenu>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@ -668,7 +732,17 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Promotion, ChartIcon } from 'omorphia'
|
||||
import {
|
||||
Promotion,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
BookmarkIcon,
|
||||
MoreHorizontalIcon,
|
||||
ClipboardCopyIcon,
|
||||
PlusIcon,
|
||||
Checkbox,
|
||||
ChartIcon,
|
||||
} from 'omorphia'
|
||||
import CalendarIcon from '~/assets/images/utils/calendar.svg'
|
||||
import ClearIcon from '~/assets/images/utils/clear.svg'
|
||||
import DownloadIcon from '~/assets/images/utils/download.svg'
|
||||
@ -711,6 +785,8 @@ import VersionIcon from '~/assets/images/utils/version.svg'
|
||||
import { renderString } from '~/helpers/parse.js'
|
||||
import { reportProject } from '~/utils/report-helpers.ts'
|
||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||
import { userCollectProject } from '~/composables/user.js'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useRoute()
|
||||
@ -721,6 +797,15 @@ const user = await useUser()
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
const displayCollectionsSearch = ref('')
|
||||
const collections = computed(() =>
|
||||
user.value && user.value.collections
|
||||
? user.value.collections.filter((x) =>
|
||||
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase())
|
||||
)
|
||||
: []
|
||||
)
|
||||
|
||||
if (
|
||||
!route.params.id ||
|
||||
!(
|
||||
@ -1132,7 +1217,6 @@ const collapsedChecklist = ref(false)
|
||||
}
|
||||
|
||||
.project__header {
|
||||
overflow: hidden;
|
||||
.project__gallery {
|
||||
display: none;
|
||||
}
|
||||
@ -1141,11 +1225,12 @@ const collapsedChecklist = ref(false)
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
background-color: var(--color-button-bg-active);
|
||||
img {
|
||||
width: 100%;
|
||||
height: 10rem;
|
||||
object-fit: cover;
|
||||
background-color: var(--color-button-bg-active);
|
||||
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
|
||||
}
|
||||
}
|
||||
.project__icon {
|
||||
@ -1160,6 +1245,9 @@ const collapsedChecklist = ref(false)
|
||||
background: none;
|
||||
border-radius: unset;
|
||||
}
|
||||
.input-group {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.project-info {
|
||||
@ -1367,4 +1455,44 @@ const collapsedChecklist = ref(false)
|
||||
.normal-page__sidebar .mod-button {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
}
|
||||
|
||||
.popout-checkbox {
|
||||
padding: var(--gap-sm) var(--gap-md);
|
||||
white-space: nowrap;
|
||||
&:hover {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.popout-heading {
|
||||
padding: var(--gap-sm) var(--gap-md);
|
||||
padding-bottom: 0;
|
||||
font-size: var(--font-size-nm);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.collection-button {
|
||||
margin: var(--gap-sm) var(--gap-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.menu-text {
|
||||
padding: 0 var(--gap-md);
|
||||
font-size: var(--font-size-nm);
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.menu-search {
|
||||
margin: var(--gap-sm) var(--gap-md);
|
||||
width: calc(100% - var(--gap-md) * 2);
|
||||
}
|
||||
|
||||
.collections-list {
|
||||
max-height: 40rem;
|
||||
overflow-y: auto;
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
margin: var(--gap-sm) var(--gap-md);
|
||||
padding: var(--gap-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -288,10 +288,10 @@ import {
|
||||
ImageIcon,
|
||||
TransferIcon,
|
||||
ConfirmModal,
|
||||
Modal,
|
||||
FileInput,
|
||||
DropArea,
|
||||
} from 'omorphia'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
|
||||
672
pages/collection/[id].vue
Normal file
672
pages/collection/[id].vue
Normal file
@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
v-if="auth.user && auth.user.id === creator.id"
|
||||
ref="deleteModal"
|
||||
title="Are you sure you want to delete this collection?"
|
||||
description="This will remove this collection forever. This action cannot be undone."
|
||||
:has-to-type="false"
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteCollection()"
|
||||
/>
|
||||
<div class="normal-page">
|
||||
<div class="normal-page__sidebar">
|
||||
<div class="card">
|
||||
<div class="card__overlay input-group">
|
||||
<template v-if="canEdit && isEditing === false">
|
||||
<Button @click="isEditing = true">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</Button>
|
||||
<Button id="delete-collection" @click="() => $refs.deleteModal.show()">
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="canEdit && isEditing === true">
|
||||
<PopoutMenu class="btn" position="bottom" direction="right">
|
||||
<EditIcon /> Edit icon
|
||||
<template #menu>
|
||||
<span class="icon-edit-menu">
|
||||
<FileInput
|
||||
id="project-icon"
|
||||
:max-size="262144"
|
||||
:show-icon="true"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
class="btn btn-transparent upload"
|
||||
style="white-space: nowrap"
|
||||
prompt=""
|
||||
@change="showPreviewImage"
|
||||
>
|
||||
<UploadIcon />
|
||||
Upload icon
|
||||
</FileInput>
|
||||
<Button
|
||||
v-if="!deletedIcon && (previewImage || collection.icon_url)"
|
||||
style="white-space: nowrap"
|
||||
transparent
|
||||
@click="
|
||||
() => {
|
||||
deletedIcon = true
|
||||
previewImage = null
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete icon
|
||||
</Button>
|
||||
</span>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Editing -->
|
||||
<template v-if="isEditing">
|
||||
<div class="inputs universal-labels">
|
||||
<div class="avatar-section">
|
||||
<Avatar
|
||||
size="md"
|
||||
:src="deletedIcon ? null : previewImage ? previewImage : collection.icon_url"
|
||||
/>
|
||||
</div>
|
||||
<label for="collection-title">
|
||||
<span class="label__title"> Title </span>
|
||||
</label>
|
||||
<input id="collection-title" v-model="name" maxlength="255" type="text" />
|
||||
<label for="collection-description">
|
||||
<span class="label__title"> Description </span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea id="collection-description" v-model="summary" maxlength="255" />
|
||||
</div>
|
||||
<label for="visibility">
|
||||
<span class="label__title"> Visibility </span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="visibility"
|
||||
v-model="visibility"
|
||||
:options="['listed', 'unlisted', 'private']"
|
||||
:disabled="visibility === 'rejected'"
|
||||
:multiple="false"
|
||||
:display-name="
|
||||
(s) => {
|
||||
if (s === 'listed') return 'Public'
|
||||
return capitalizeString(s)
|
||||
}
|
||||
"
|
||||
:searchable="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="push-right input-group">
|
||||
<Button @click="isEditing = false">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" @click="saveChanges()">
|
||||
<SaveIcon />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Content -->
|
||||
<template v-if="!isEditing">
|
||||
<div class="page-header__icon">
|
||||
<Avatar size="md" :src="collection.icon_url" />
|
||||
</div>
|
||||
<div class="page-header__text">
|
||||
<h1 class="title">{{ collection.name }}</h1>
|
||||
|
||||
<div>
|
||||
<span class="collection-label"><BoxIcon /> Collection</span>
|
||||
</div>
|
||||
|
||||
<div class="collection-info">
|
||||
<div class="metadata-item markdown-body collection-description">
|
||||
<p>{{ collection.description }}</p>
|
||||
</div>
|
||||
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div v-if="canEdit" class="primary-stat">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> Public </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> Unlisted </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> Private </strong>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<strong> Rejected </strong>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="primary-stat">
|
||||
<LibraryIcon class="primary-stat__icon" aria-hidden="true" />
|
||||
<div class="primary-stat__text">
|
||||
<span class="primary-stat__counter">
|
||||
{{ $formatNumber(projects.length || 0) }}
|
||||
</span>
|
||||
project<span v-if="projects.length !== 1">s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata-item">
|
||||
<div
|
||||
v-tooltip="$dayjs(collection.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="date"
|
||||
>
|
||||
<CalendarIcon />
|
||||
<label>
|
||||
Created
|
||||
{{ fromNow(collection.created) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="collection.id !== 'following'" class="metadata-item">
|
||||
<div
|
||||
v-tooltip="$dayjs(collection.created).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="date"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
<label>
|
||||
Updated
|
||||
{{ fromNow(collection.updated) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="card-divider" />
|
||||
|
||||
<div class="collection-info">
|
||||
<h2 class="card-header">Curated by</h2>
|
||||
<div class="metadata-item">
|
||||
<nuxt-link
|
||||
class="team-member columns button-transparent"
|
||||
:to="'/user/' + creator.username"
|
||||
>
|
||||
<Avatar :src="creator.avatar_url" :alt="creator.username" size="sm" circle />
|
||||
|
||||
<div class="member-info">
|
||||
<p class="name">{{ creator.username }}</p>
|
||||
<p class="role">Owner</p>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<!-- <hr class="card-divider" />
|
||||
<div class="input-group">
|
||||
<Button @click="() => $refs.shareModal.show()">
|
||||
<ShareIcon />
|
||||
Share
|
||||
</Button>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<Promotion />
|
||||
|
||||
<nav class="navigation-card">
|
||||
<NavRow
|
||||
:links="[
|
||||
{
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
href: `/collection/${collection.id}`,
|
||||
},
|
||||
...projectTypes.map((x) => {
|
||||
return {
|
||||
label: formatMessage(getProjectTypeMessage(x, true)),
|
||||
href: `/collection/${collection.id}/${x}s`,
|
||||
}
|
||||
}),
|
||||
]"
|
||||
/>
|
||||
<button
|
||||
v-tooltip="
|
||||
formatMessage(
|
||||
commonMessages[`${cosmetics.searchDisplayMode.collection || 'list'}InputView`]
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
formatMessage(
|
||||
commonMessages[`${cosmetics.searchDisplayMode.collection || 'list'}InputView`]
|
||||
)
|
||||
"
|
||||
class="square-button"
|
||||
@click="cycleSearchDisplayMode()"
|
||||
>
|
||||
<GridIcon v-if="cosmetics.searchDisplayMode.collection === 'grid'" />
|
||||
<ImageIcon v-else-if="cosmetics.searchDisplayMode.collection === 'gallery'" />
|
||||
<ListIcon v-else />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div
|
||||
v-if="projects && projects.length > 0"
|
||||
:class="
|
||||
'project-list display-mode--' + (cosmetics.searchDisplayMode.collection || 'list')
|
||||
"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="project in (route.params.projectType !== undefined
|
||||
? projects.filter(
|
||||
(x) =>
|
||||
x.project_type ===
|
||||
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
||||
)
|
||||
: projects
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.id"
|
||||
:key="project.id"
|
||||
:type="project.project_type"
|
||||
:categories="project.categories"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:description="project.description"
|
||||
:downloads="project.downloads ? project.downloads.toString() : '0'"
|
||||
:follows="project.follows ? project.follows.toString() : '0'"
|
||||
:icon-url="project.icon_url"
|
||||
:name="project.title"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:color="project.color"
|
||||
:show-updated-date="!canEdit && collection.id !== 'following'"
|
||||
:show-created-date="!canEdit && collection.id !== 'following'"
|
||||
>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="iconified-button remove-btn"
|
||||
@click="
|
||||
() => {
|
||||
removeProjects = [project]
|
||||
saveChanges()
|
||||
}
|
||||
"
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove project
|
||||
</button>
|
||||
<button
|
||||
v-if="collection.id === 'following'"
|
||||
class="iconified-button"
|
||||
@click="userUnfollowProject(project)"
|
||||
>
|
||||
<TrashIcon />
|
||||
Unfollow project
|
||||
</button>
|
||||
</ProjectCard>
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === creator.id" class="text">
|
||||
You don't have any projects.<br />
|
||||
Would you like to
|
||||
<a class="link" @click.prevent="$router.push('/mods')"> add one</a>?
|
||||
</span>
|
||||
<span v-else class="text">This collection has no projects!</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
capitalizeString,
|
||||
Avatar,
|
||||
Button,
|
||||
CalendarIcon,
|
||||
Promotion,
|
||||
EditIcon,
|
||||
XIcon,
|
||||
SaveIcon,
|
||||
UploadIcon,
|
||||
TrashIcon,
|
||||
PopoutMenu,
|
||||
FileInput,
|
||||
DropdownSelect,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
GridIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
UpdatedIcon,
|
||||
LibraryIcon,
|
||||
BoxIcon,
|
||||
} from 'omorphia'
|
||||
|
||||
import WorldIcon from 'assets/images/utils/world.svg'
|
||||
import UpToDate from 'assets/images/illustrations/up_to_date.svg'
|
||||
import { addNotification } from '~/composables/notifs.js'
|
||||
import ModalConfirm from '~/components/ui/ModalConfirm.vue'
|
||||
import NavRow from '~/components/ui/NavRow.vue'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
const { formatMessage } = vintl
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const auth = await useAuth()
|
||||
const cosmetics = useCosmetics()
|
||||
const tags = useTags()
|
||||
|
||||
const isEditing = ref(false)
|
||||
|
||||
function cycleSearchDisplayMode() {
|
||||
cosmetics.value.searchDisplayMode.collection = data.$cycleValue(
|
||||
cosmetics.value.searchDisplayMode.collection,
|
||||
tags.value.projectViewModes
|
||||
)
|
||||
saveCosmetics()
|
||||
}
|
||||
|
||||
let collection, refreshCollection, creator, projects, refreshProjects
|
||||
|
||||
try {
|
||||
if (route.params.id === 'following') {
|
||||
collection = ref({
|
||||
id: 'following',
|
||||
icon_url: 'https://cdn.modrinth.com/follow-collection.png',
|
||||
name: 'Followed projects',
|
||||
description: "Auto-generated collection of all the projects you're following.",
|
||||
status: 'private',
|
||||
user: auth.value.user.id,
|
||||
created: auth.value.user.created,
|
||||
updated: auth.value.user.created,
|
||||
})
|
||||
const data = await useAsyncData(`user/${auth.value.user.id}/follows`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/follows`)
|
||||
)
|
||||
projects = ref(data.data)
|
||||
|
||||
creator = ref(auth.value.user)
|
||||
refreshProjects = async () => {}
|
||||
refreshCollection = async () => {}
|
||||
} else {
|
||||
const val = await useAsyncData(`collection/${route.params.id}`, () =>
|
||||
useBaseFetch(`collection/${route.params.id}`, { apiVersion: 3 })
|
||||
)
|
||||
collection = val.data
|
||||
refreshCollection = val.refresh
|
||||
;[{ data: creator }, { data: projects, refresh: refreshProjects }] = await Promise.all([
|
||||
await useAsyncData(`user/${collection.value.user}`, () =>
|
||||
useBaseFetch(`user/${collection.value.user}`)
|
||||
),
|
||||
await useAsyncData(`projects?ids=${JSON.stringify(collection.value.projects)}]`, () =>
|
||||
useBaseFetch(`projects?ids=${JSON.stringify(collection.value.projects)}`)
|
||||
),
|
||||
])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: 'Collection not found',
|
||||
})
|
||||
}
|
||||
|
||||
if (!collection.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: 'Collection not found',
|
||||
})
|
||||
}
|
||||
|
||||
const title = `${collection.value.name} - Collection`
|
||||
const description = `${collection.value.description} - View the collection ${collection.value.description} by ${creator.value.username} on Modrinth`
|
||||
|
||||
if (!route.name.startsWith('type-id-settings')) {
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: collection.value.description,
|
||||
ogImage: collection.value.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
robots: collection.value.status === 'listed' ? 'all' : 'noindex',
|
||||
})
|
||||
}
|
||||
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
auth.value.user &&
|
||||
auth.value.user.id === collection.value.user &&
|
||||
collection.value.id !== 'following'
|
||||
)
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const obj = {}
|
||||
|
||||
for (const project of projects.value) {
|
||||
obj[project.project_type] = true
|
||||
}
|
||||
|
||||
return Object.keys(obj)
|
||||
})
|
||||
|
||||
const icon = ref(null)
|
||||
const deletedIcon = ref(false)
|
||||
const previewImage = ref(null)
|
||||
|
||||
const name = ref(collection.value.name)
|
||||
const summary = ref(collection.value.description)
|
||||
const visibility = ref(collection.value.status)
|
||||
const removeProjects = ref([])
|
||||
|
||||
async function saveChanges() {
|
||||
startLoading()
|
||||
try {
|
||||
if (deletedIcon.value) {
|
||||
await useBaseFetch(`collection/${collection.value.id}/icon`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
} else if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`collection/${collection.value.id}/icon?ext=${
|
||||
icon.value.type.split('/')[icon.value.type.split('/').length - 1]
|
||||
}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: icon.value,
|
||||
apiVersion: 3,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const projectsToRemove = removeProjects.value.map((p) => p.id)
|
||||
const newProjects = projects.value
|
||||
.filter((p) => !projectsToRemove.includes(p.id))
|
||||
.map((p) => p.id)
|
||||
const newProjectIds = projectsToRemove.length > 0 ? newProjects : undefined
|
||||
|
||||
await useBaseFetch(`collection/${collection.value.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: name.value,
|
||||
description: summary.value,
|
||||
status: visibility.value,
|
||||
new_projects: newProjectIds,
|
||||
},
|
||||
apiVersion: 3,
|
||||
})
|
||||
|
||||
await refreshCollection()
|
||||
await refreshProjects()
|
||||
|
||||
name.value = collection.value.name
|
||||
summary.value = collection.value.description
|
||||
visibility.value = collection.value.status
|
||||
removeProjects.value = []
|
||||
|
||||
isEditing.value = false
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
await initUserCollections()
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
async function deleteCollection() {
|
||||
startLoading()
|
||||
try {
|
||||
await useBaseFetch(`collection/${collection.value.id}`, {
|
||||
method: 'DELETE',
|
||||
apiVersion: 3,
|
||||
})
|
||||
await navigateTo('/dashboard/collections')
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
text: err.data.description,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
await initUserCollections()
|
||||
stopLoading()
|
||||
}
|
||||
|
||||
function showPreviewImage(files) {
|
||||
const reader = new FileReader()
|
||||
icon.value = files[0]
|
||||
deletedIcon.value = false
|
||||
reader.readAsDataURL(icon.value)
|
||||
reader.onload = (event) => {
|
||||
previewImage.value = event.target.result
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.animated-dropdown {
|
||||
// Omorphia's dropdowns are harcoded in width, so we need to override that
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.inputs {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
input {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 10rem;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.team-member {
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
.member-info {
|
||||
overflow: hidden;
|
||||
margin: auto 0 auto 0.75rem;
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--spacing-card-lg);
|
||||
|
||||
.page-header__icon {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.card__overlay {
|
||||
top: var(--spacing-card-lg);
|
||||
right: var(--spacing-card-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-nm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
cursor: default;
|
||||
|
||||
.label {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-heading);
|
||||
margin-bottom: 0.5rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: var(--gap-md) 0 var(--spacing-card-xs) 0;
|
||||
}
|
||||
|
||||
.collection-label {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.collection-description {
|
||||
margin-top: var(--spacing-card-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
1
pages/collection/[id]/[projectType].vue
Normal file
1
pages/collection/[id]/[projectType].vue
Normal file
@ -0,0 +1 @@
|
||||
<template><div /></template>
|
||||
@ -24,6 +24,9 @@
|
||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||
<ListIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/collections" label="Collections">
|
||||
<LibraryIcon />
|
||||
</NavStackItem>
|
||||
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
||||
<CurrencyIcon />
|
||||
</NavStackItem>
|
||||
@ -36,7 +39,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ChartIcon } from 'omorphia'
|
||||
import { LibraryIcon, ChartIcon } from 'omorphia'
|
||||
import NavStack from '~/components/ui/NavStack.vue'
|
||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||
|
||||
|
||||
187
pages/dashboard/collections.vue
Normal file
187
pages/dashboard/collections.vue
Normal file
@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="universal-card">
|
||||
<CollectionCreateModal ref="modal_creation" />
|
||||
<h2>Collections</h2>
|
||||
<div class="search-row">
|
||||
<div class="iconified-input">
|
||||
<label for="search-input" hidden>Search your collections</label>
|
||||
<SearchIcon />
|
||||
<input id="search-input" v-model="filterQuery" type="text" />
|
||||
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<Button color="primary" @click="$refs.modal_creation.show()">
|
||||
<PlusIcon /> Create new
|
||||
</Button>
|
||||
</div>
|
||||
<div class="collections-grid">
|
||||
<nuxt-link
|
||||
v-if="'followed projects'.includes(filterQuery)"
|
||||
:to="`/collection/following`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">Followed projects</span>
|
||||
<span class="description">
|
||||
Auto-generated collection of all the projects you're following.
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats"><BoxIcon /> {{ user.follows.length }} projects</div>
|
||||
<div class="stats"><LockIcon /> <span> Private </span></div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
>
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<span class="title">{{ collection.name }}</span>
|
||||
<span class="description">
|
||||
{{ collection.description }}
|
||||
</span>
|
||||
<div class="stat-bar">
|
||||
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon />
|
||||
<span> Public </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon />
|
||||
<span> Unlisted </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon />
|
||||
<span> Private </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon />
|
||||
<span> Rejected </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Avatar, BoxIcon, SearchIcon, XIcon, Button, PlusIcon, LinkIcon, LockIcon } from 'omorphia'
|
||||
import WorldIcon from '~/assets/images/utils/world.svg'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: 'Your collections - Modrinth',
|
||||
})
|
||||
|
||||
const user = await useUser()
|
||||
const auth = await useAuth()
|
||||
|
||||
if (process.client) {
|
||||
await initUserFollows()
|
||||
}
|
||||
|
||||
const filterQuery = ref('')
|
||||
|
||||
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
|
||||
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 })
|
||||
)
|
||||
|
||||
const orderedCollections = computed(() => {
|
||||
if (!collections.value) return []
|
||||
return collections.value
|
||||
.sort((a, b) => {
|
||||
const aUpdated = new Date(a.updated)
|
||||
const bUpdated = new Date(b.updated)
|
||||
return bUpdated - aUpdated
|
||||
})
|
||||
.filter((collection) => {
|
||||
if (!filterQuery.value) return true
|
||||
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style lang="scss">
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--gap-md);
|
||||
|
||||
.collection {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0;
|
||||
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: 6rem !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-bottom: var(--gap-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-lg) var(--gap-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
|
||||
input {
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -15,6 +15,7 @@
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:color="project.color"
|
||||
:show-updated-date="false"
|
||||
>
|
||||
<button class="iconified-button" @click="userUnfollowProject(project)">
|
||||
<HeartIcon />
|
||||
|
||||
@ -220,7 +220,6 @@ import {
|
||||
UploadIcon,
|
||||
PlusIcon,
|
||||
Avatar,
|
||||
Modal,
|
||||
XIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
@ -230,6 +229,7 @@ import {
|
||||
CopyCode,
|
||||
ConfirmModal,
|
||||
} from 'omorphia'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
import { scopeList, hasScope, toggleScope } from '~/utils/auth/scopes.ts'
|
||||
|
||||
definePageMeta({
|
||||
|
||||
@ -155,17 +155,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
PlusIcon,
|
||||
Modal,
|
||||
XIcon,
|
||||
Checkbox,
|
||||
TrashIcon,
|
||||
EditIcon,
|
||||
SaveIcon,
|
||||
ConfirmModal,
|
||||
} from 'omorphia'
|
||||
import { PlusIcon, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon, ConfirmModal } from 'omorphia'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Modal from '~/components/ui/Modal.vue'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<ModalCreation ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<div class="user-header-wrapper">
|
||||
<div class="user-header">
|
||||
<Avatar
|
||||
@ -198,51 +199,52 @@
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
v-if="projects.length > 0"
|
||||
class="project-list"
|
||||
:class="'display-mode--' + cosmetics.searchDisplayMode.user"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="project in (route.params.projectType !== undefined
|
||||
? projects.filter(
|
||||
(x) =>
|
||||
x.project_type ===
|
||||
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
||||
)
|
||||
: projects
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.title"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="
|
||||
project.gallery
|
||||
<div v-if="projects.length > 0">
|
||||
<div
|
||||
v-if="route.params.projectType !== 'collections'"
|
||||
:class="'project-list display-mode--' + cosmetics.searchDisplayMode.user"
|
||||
>
|
||||
<ProjectCard
|
||||
v-for="project in (route.params.projectType !== undefined
|
||||
? projects.filter(
|
||||
(x) =>
|
||||
x.project_type ===
|
||||
route.params.projectType.substr(0, route.params.projectType.length - 1)
|
||||
)
|
||||
: projects
|
||||
)
|
||||
.slice()
|
||||
.sort((a, b) => b.featured - a.featured)
|
||||
.map((x) => x.url)[0]
|
||||
"
|
||||
:description="project.description"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:status="
|
||||
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
||||
? project.status
|
||||
: null
|
||||
"
|
||||
:type="project.project_type"
|
||||
:color="project.color"
|
||||
/>
|
||||
.sort((a, b) => b.downloads - a.downloads)"
|
||||
:id="project.slug || project.id"
|
||||
:key="project.id"
|
||||
:name="project.title"
|
||||
:display="cosmetics.searchDisplayMode.user"
|
||||
:featured-image="
|
||||
project.gallery
|
||||
.slice()
|
||||
.sort((a, b) => b.featured - a.featured)
|
||||
.map((x) => x.url)[0]
|
||||
"
|
||||
:description="project.description"
|
||||
:created-at="project.published"
|
||||
:updated-at="project.updated"
|
||||
:downloads="project.downloads.toString()"
|
||||
:follows="project.followers.toString()"
|
||||
:icon-url="project.icon_url"
|
||||
:categories="project.categories"
|
||||
:client-side="project.client_side"
|
||||
:server-side="project.server_side"
|
||||
:status="
|
||||
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
|
||||
? project.status
|
||||
: null
|
||||
"
|
||||
:type="project.project_type"
|
||||
:color="project.color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error">
|
||||
<div v-else-if="route.params.projectType !== 'collections'" class="error">
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
|
||||
@ -255,12 +257,71 @@
|
||||
</span>
|
||||
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
|
||||
</div>
|
||||
<div v-if="['collections'].includes(route.params.projectType)" class="collections-grid">
|
||||
<nuxt-link
|
||||
v-for="collection in collections"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="card collection-item"
|
||||
>
|
||||
<div class="collection">
|
||||
<Avatar :src="collection.icon_url" class="icon" />
|
||||
<div class="details">
|
||||
<h2 class="title">{{ collection.name }}</h2>
|
||||
<div class="stats">
|
||||
<LibraryIcon />
|
||||
Collection
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
{{ collection.description }}
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon />
|
||||
<span> Public </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'unlisted'">
|
||||
<LinkIcon />
|
||||
<span> Unlisted </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'private'">
|
||||
<LockIcon />
|
||||
<span> Private </span>
|
||||
</template>
|
||||
<template v-else-if="collection.status === 'rejected'">
|
||||
<XIcon />
|
||||
<span> Rejected </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div
|
||||
v-if="route.params.projectType === 'collections' && collections.length === 0"
|
||||
class="error"
|
||||
>
|
||||
<UpToDate class="icon" /><br />
|
||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
|
||||
<template #create-link="{ children }">
|
||||
<a class="link" @click.prevent="$refs.modal_collection_creation.show()">
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Promotion } from 'omorphia'
|
||||
import { Promotion, LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from 'omorphia'
|
||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import { reportUser } from '~/utils/report-helpers.ts'
|
||||
@ -279,11 +340,13 @@ import GridIcon from '~/assets/images/utils/grid.svg'
|
||||
import ListIcon from '~/assets/images/utils/list.svg'
|
||||
import ImageIcon from '~/assets/images/utils/image.svg'
|
||||
import UploadIcon from '~/assets/images/utils/upload.svg'
|
||||
import WorldIcon from '~/assets/images/utils/world.svg'
|
||||
import FileInput from '~/components/ui/FileInput.vue'
|
||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||
import NavRow from '~/components/ui/NavRow.vue'
|
||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||
import Avatar from '~/components/ui/Avatar.vue'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
|
||||
const data = useNuxtApp()
|
||||
const route = useRoute()
|
||||
@ -354,15 +417,24 @@ const messages = defineMessages({
|
||||
defaultMessage:
|
||||
"You don't have any projects.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
profileNoCollectionsLabel: {
|
||||
id: 'profile.label.no-collections',
|
||||
defaultMessage: 'This user has no collection!',
|
||||
},
|
||||
profileNoCollectionsAuthLabel: {
|
||||
id: 'profile.label.no-collections-auth',
|
||||
defaultMessage:
|
||||
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
|
||||
},
|
||||
userNotFoundError: {
|
||||
id: 'profile.error.not-found',
|
||||
defaultMessage: 'User not found',
|
||||
},
|
||||
})
|
||||
|
||||
let user, projects
|
||||
let user, projects, collections
|
||||
try {
|
||||
;[{ data: user }, { data: projects }] = await Promise.all([
|
||||
;[{ data: user }, { data: projects }, { data: collections }] = await Promise.all([
|
||||
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||
useAsyncData(
|
||||
`user/${route.params.id}/projects`,
|
||||
@ -382,6 +454,9 @@ try {
|
||||
},
|
||||
}
|
||||
),
|
||||
useAsyncData(`user/${route.params.id}/collections`, () =>
|
||||
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
|
||||
),
|
||||
])
|
||||
} catch {
|
||||
throw createError({
|
||||
@ -403,26 +478,26 @@ if (user.value.username !== route.params.id) {
|
||||
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
|
||||
}
|
||||
|
||||
const title = `${user.value.username} - Modrinth`
|
||||
const description = ref(
|
||||
const title = computed(() => `${user.value.username} - Modrinth`)
|
||||
const description = computed(() =>
|
||||
user.value.bio
|
||||
? `${formatMessage(messages.profileMetaDescriptionWithBio, {
|
||||
? formatMessage(messages.profileMetaDescriptionWithBio, {
|
||||
bio: user.value.bio,
|
||||
username: user.value.username,
|
||||
})}`
|
||||
: `${formatMessage(messages.profileMetaDescription, { username: user.value.username })}`
|
||||
})
|
||||
: formatMessage(messages.profileMetaDescription, { username: user.value.username })
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
ogImage: user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
title: () => title.value,
|
||||
description: () => description.value,
|
||||
ogTitle: () => title.value,
|
||||
ogDescription: () => description.value,
|
||||
ogImage: () => user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||
})
|
||||
|
||||
const projectTypes = computed(() => {
|
||||
const obj = {}
|
||||
const obj = { collection: true }
|
||||
|
||||
for (const project of projects.value) {
|
||||
obj[project.project_type] = true
|
||||
@ -519,6 +594,70 @@ export default defineNuxtComponent({
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.collections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--gap-lg);
|
||||
|
||||
.collection-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
|
||||
.description {
|
||||
// Grow to take up remaining space
|
||||
flex-grow: 1;
|
||||
|
||||
color: var(--color-text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stat-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
svg {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.collection {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.icon {
|
||||
width: 100% !important;
|
||||
height: 6rem !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.user-header-wrapper {
|
||||
display: flex;
|
||||
margin: 0 auto -1.5rem;
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -38,7 +38,7 @@ dependencies:
|
||||
specifier: ^13.0.1
|
||||
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
||||
omorphia:
|
||||
specifier: ^0.7.1
|
||||
specifier: '=0.7.1'
|
||||
version: 0.7.1(vue@3.3.4)
|
||||
qrcode.vue:
|
||||
specifier: ^3.4.0
|
||||
|
||||
@ -55,6 +55,14 @@ const projectTypeMessages = defineMessages({
|
||||
id: 'project-type.project.plural',
|
||||
defaultMessage: 'Projects',
|
||||
},
|
||||
collection: {
|
||||
id: 'project-type.collection.singular',
|
||||
defaultMessage: 'Collection',
|
||||
},
|
||||
collections: {
|
||||
id: 'project-type.collection.plural',
|
||||
defaultMessage: 'Collections',
|
||||
},
|
||||
})
|
||||
|
||||
type ExtractSingulars<K extends string> = K extends `${infer T}s` ? T : never
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user