You've already forked AstralRinth
forked from didirus/AstralRinth
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:
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>
|
||||
Reference in New Issue
Block a user