You've already forked AstralRinth
forked from didirus/AstralRinth
* [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>
673 lines
20 KiB
Vue
673 lines
20 KiB
Vue
<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>
|