1
0
Files
AstralRinth/pages/collection/[id].vue
Geometrically 3a735ea0ce 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>
2023-12-27 13:27:50 -05:00

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>