Files
Rocketmc/apps/frontend/src/pages/collection/[id].vue
Geometrically e91c76c10c Aditude integration (#2184)
* Aditude integration

* remove diff

* remove unused vars
2024-08-20 23:42:44 -07:00

811 lines
25 KiB
Vue

<template>
<div>
<ModalConfirm
v-if="auth.user && auth.user.id === creator.id"
ref="deleteModal"
:title="formatMessage(messages.deleteModalTitle)"
:description="formatMessage(messages.deleteModalDescription)"
:has-to-type="false"
:proceed-label="formatMessage(commonMessages.deleteLabel)"
@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 aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</Button>
<Button id="delete-collection" @click="() => $refs.deleteModal.show()">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.deleteLabel) }}
</Button>
</template>
<template v-else-if="canEdit && isEditing === true">
<PopoutMenu class="btn">
<EditIcon aria-hidden="true" /> {{ formatMessage(messages.editIconButton) }}
<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="Upload icon"
aria-label="Upload icon"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
{{ formatMessage(messages.uploadIconButton) }}
</FileInput>
<Button
v-if="!deletedIcon && (previewImage || collection.icon_url)"
style="white-space: nowrap"
transparent
@click="
() => {
deletedIcon = true;
previewImage = null;
}
"
>
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.deleteIconButton) }}
</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"> {{ formatMessage(commonMessages.titleLabel) }} </span>
</label>
<input id="collection-title" v-model="name" maxlength="255" type="text" />
<label for="collection-description">
<span class="label__title">
{{ formatMessage(commonMessages.descriptionLabel) }}
</span>
</label>
<div class="textarea-wrapper">
<textarea id="collection-description" v-model="summary" maxlength="255" />
</div>
<label for="visibility">
<span class="label__title">
{{ formatMessage(commonMessages.visibilityLabel) }}
</span>
</label>
<DropdownSelect
id="visibility"
v-model="visibility"
:options="['listed', 'unlisted', 'private']"
:disabled="visibility === 'rejected'"
:multiple="false"
:display-name="
(s) => {
if (s === 'listed') return formatMessage(commonMessages.publicLabel);
return formatMessage(commonMessages[`${s}Label`]);
}
"
:searchable="false"
/>
</div>
<div class="push-right input-group">
<Button @click="isEditing = false">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button color="primary" @click="saveChanges()">
<SaveIcon aria-hidden="true" />
{{ formatMessage(commonMessages.saveButton) }}
</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 aria-hidden="true" /> {{ formatMessage(messages.collectionLabel) }}
</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> {{ formatMessage(commonMessages.publicLabel) }} </strong>
</div>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<strong> {{ formatMessage(commonMessages.unlistedLabel) }} </strong>
</div>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<strong> {{ formatMessage(commonMessages.privateLabel) }} </strong>
</div>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
<strong> {{ formatMessage(commonMessages.rejectedLabel) }} </strong>
</div>
</template>
</div>
</div>
<div class="primary-stat">
<LibraryIcon class="primary-stat__icon" aria-hidden="true" />
<div v-if="projects" class="primary-stat__text">
<IntlFormatted
:message-id="messages.projectsCountLabel"
:values="{ count: formatCompactNumber(projects.length || 0) }"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</div>
</div>
<div class="metadata-item">
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(collection.created),
time: new Date(collection.created),
})
"
class="date"
>
<CalendarIcon aria-hidden="true" />
<label>
{{
formatMessage(messages.createdAtLabel, {
ago: formatRelativeTime(collection.created),
})
}}
</label>
</div>
</div>
<div v-if="collection.id !== 'following'" class="metadata-item">
<div
v-tooltip="
formatMessage(commonMessages.dateAtTimeTooltip, {
date: new Date(collection.updated),
time: new Date(collection.updated),
})
"
class="date"
>
<UpdatedIcon aria-hidden="true" />
<label>
{{
formatMessage(messages.updatedAtLabel, {
ago: formatRelativeTime(collection.updated),
})
}}
</label>
</div>
</div>
</div>
<hr class="card-divider" />
<div class="collection-info">
<h2 class="card-header">{{ formatMessage(messages.curatedByLabel) }}</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">{{ formatMessage(messages.ownerLabel) }}</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>
<AdPlaceholder />
</div>
<div class="normal-page__content">
<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.followers ? project.followers.toString() : '0'"
:featured-image="project.gallery.find((element) => element.featured)?.url"
: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 aria-hidden="true" />
{{ formatMessage(messages.removeProjectButton) }}
</button>
<button
v-if="collection.id === 'following'"
class="iconified-button"
@click="unfollowProject(project)"
>
<TrashIcon aria-hidden="true" />
{{ formatMessage(messages.unfollowProjectButton) }}
</button>
</ProjectCard>
</div>
<div v-else class="error">
<UpToDate class="icon" /><br />
<span v-if="auth.user && auth.user.id === creator.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.noProjectsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$router.push('/mods')">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</span>
<span v-else class="text">{{ formatMessage(messages.noProjectsLabel) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CalendarIcon,
EditIcon,
XIcon,
SaveIcon,
UploadIcon,
TrashIcon,
LinkIcon,
LockIcon,
GridIcon,
ImageIcon,
ListIcon,
UpdatedIcon,
LibraryIcon,
BoxIcon,
} from "@modrinth/assets";
import { PopoutMenu, FileInput, DropdownSelect, Avatar, Button } from "@modrinth/ui";
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";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
const vintl = useVIntl();
const { formatMessage } = vintl;
const formatRelativeTime = useRelativeTime();
const formatCompactNumber = useCompactNumber();
const messages = defineMessages({
collectionDescription: {
id: "collection.description",
defaultMessage: "{description} - View the collection {name} by {username} on Modrinth",
},
collectionLabel: {
id: "collection.label.collection",
defaultMessage: "Collection",
},
collectionTitle: {
id: "collection.title",
defaultMessage: "{name} - Collection",
},
editIconButton: {
id: "collection.button.edit-icon",
defaultMessage: "Edit icon",
},
deleteIconButton: {
id: "collection.button.delete-icon",
defaultMessage: "Delete icon",
},
createdAtLabel: {
id: "collection.label.created-at",
defaultMessage: "Created {ago}",
},
collectionNotFoundError: {
id: "collection.error.not-found",
defaultMessage: "Collection not found",
},
curatedByLabel: {
id: "collection.label.curated-by",
defaultMessage: "Curated by",
},
deleteModalDescription: {
id: "collection.delete-modal.description",
defaultMessage: "This will remove this collection forever. This action cannot be undone.",
},
deleteModalTitle: {
id: "collection.delete-modal.title",
defaultMessage: "Are you sure you want to delete this collection?",
},
followingCollectionDescription: {
id: "collection.description.following",
defaultMessage: "Auto-generated collection of all the projects you're following.",
},
noProjectsLabel: {
id: "collection.label.no-projects",
defaultMessage: "This collection has no projects!",
},
noProjectsAuthLabel: {
id: "collection.label.no-projects-auth",
defaultMessage:
"You don't have any projects.\nWould you like to <create-link>add one</create-link>?",
},
ownerLabel: {
id: "collection.label.owner",
defaultMessage: "Owner",
},
projectsCountLabel: {
id: "collection.label.projects-count",
defaultMessage:
"{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}",
},
removeProjectButton: {
id: "collection.button.remove-project",
defaultMessage: "Remove project",
},
unfollowProjectButton: {
id: "collection.button.unfollow-project",
defaultMessage: "Unfollow project",
},
updatedAtLabel: {
id: "collection.label.updated-at",
defaultMessage: "Updated {ago}",
},
uploadIconButton: {
id: "collection.button.upload-icon",
defaultMessage: "Upload icon",
},
});
const data = useNuxtApp();
const route = useNativeRoute();
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,
);
}
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: formatMessage(commonMessages.followedProjectsLabel),
description: formatMessage(messages.followingCollectionDescription),
status: "private",
user: auth.value.user.id,
created: auth.value.user.created,
updated: auth.value.user.created,
});
[{ data: projects, refresh: refreshProjects }] = await Promise.all([
useAsyncData(
`user/${auth.value.user.id}/follows`,
() => useBaseFetch(`user/${auth.value.user.id}/follows`),
{
transform: (projects) => {
for (const project of projects) {
project.categories = project.categories.concat(project.loaders);
}
return projects;
},
},
),
]);
creator = ref(auth.value.user);
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=${encodeURIComponent(JSON.stringify(collection.value.projects))}]`,
() =>
useBaseFetch(
`projects?ids=${encodeURIComponent(JSON.stringify(collection.value.projects))}`,
),
{
transform: (projects) => {
for (const project of projects) {
project.categories = project.categories.concat(project.loaders);
}
return projects;
},
},
),
]);
}
} catch (err) {
console.error(err);
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.collectionNotFoundError),
});
}
if (!collection.value) {
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.collectionNotFoundError),
});
}
const title = computed(() =>
formatMessage(messages.collectionTitle, { name: collection.value.name }),
);
useSeoMeta({
title,
description: () =>
formatMessage(messages.collectionDescription, {
name: collection.value.name,
description: collection.value.description,
username: creator.value.username,
}),
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 projectSet = new Set(
projects.value?.map((project) => project?.project_type).filter((x) => x !== undefined) || [],
);
projectSet.delete("project");
return Array.from(projectSet);
});
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 unfollowProject(project) {
await userUnfollowProject(project);
projects.value = projects.value.filter((x) => x.id !== project.id);
}
async function saveChanges() {
startLoading();
try {
if (deletedIcon.value) {
await useBaseFetch(`collection/${collection.value.id}/icon`, {
method: "DELETE",
apiVersion: 3,
});
} else if (icon.value) {
const ext = icon.value?.type?.split("/").pop();
if (!ext) throw new Error("Invalid file type");
await useBaseFetch(`collection/${collection.value.id}/icon?ext=${ext}`, {
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: formatMessage(commonMessages.errorNotificationTitle),
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: formatMessage(commonMessages.errorNotificationTitle),
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;
font-size: var(--font-size-xl);
color: var(--color-text-dark);
}
.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>