Files
modrinth/apps/frontend/src/pages/collection/[id].vue
T
Calum H. 381ea51cce refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

* feat: store server proj id when adding to a non-linked instance

* fix: lint

* fix: i18n + qa

* fix: conflict

* qa: already installed modal + placeholders not server-specific

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

Signed-off-by: Calum H. <calum@modrinth.com>
2026-03-26 18:55:15 +00:00

877 lines
25 KiB
Vue

<template>
<div v-if="isLoading" class="flex min-h-[50vh] items-center justify-center">
<SpinnerIcon class="h-12 w-12 animate-spin text-brand" />
</div>
<template v-else-if="collection && creator">
<ConfirmModal
v-if="canEdit"
ref="deleteModal"
:title="formatMessage(messages.deleteModalTitle)"
:description="formatMessage(messages.deleteModalDescription)"
:has-to-type="false"
:proceed-label="formatMessage(commonMessages.deleteLabel)"
@proceed="deleteCollection()"
/>
<NewModal v-if="canEdit" ref="editModal" :header="formatMessage(messages.editingCollection)">
<div class="flex w-[30rem] flex-col gap-3">
<div class="flow-root">
<div class="group relative float-end ml-4">
<OverflowMenu
v-tooltip="formatMessage(messages.editIconButton)"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:options="[
{
id: 'select',
action: () => {
const input = iconInputRef?.$el?.querySelector('input')
input?.click()
},
},
{
id: 'remove',
color: 'danger',
action: () => {
deletedIcon = true
previewImage = null
},
shown: !deletedIcon && (previewImage || collection.icon_url),
},
]"
>
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : collection.icon_url"
size="108px"
class="!border-4 group-hover:brightness-75"
no-shadow
/>
<div class="absolute right-0 top-0 m-2">
<div
class="m-0 flex aspect-square items-center justify-center rounded-full border-[1px] border-solid border-button-border bg-button-bg p-2 text-primary"
>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
</div>
<template #select>
<UploadIcon />
{{
previewImage || collection.icon_url
? formatMessage(messages.replaceIcon)
: formatMessage(messages.selectIcon)
}}
</template>
<template #remove>
<XIcon />
{{ formatMessage(messages.removeIconButton) }}
</template>
</OverflowMenu>
<FileInput
id="collection-icon-input"
ref="iconInputRef"
:max-size="262144"
:show-icon="false"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
aria-label="Upload icon"
@change="showPreviewImage"
/>
</div>
<div class="overflow-hidden">
<label class="mb-2 block text-lg font-semibold text-contrast" for="collection-title">
{{ formatMessage(commonMessages.titleLabel) }}
</label>
<StyledInput
id="collection-title"
v-model="current.name"
:maxlength="255"
autocomplete="off"
wrapper-class="w-full"
/>
</div>
<label
class="mb-2 mt-4 block text-lg font-semibold text-contrast"
for="collection-description"
>
{{ formatMessage(commonMessages.descriptionLabel) }}
</label>
<StyledInput
id="collection-description"
v-model="current.description"
multiline
:maxlength="255"
wrapper-class="h-24"
/>
<label for="visibility" class="mb-2 mt-4 block text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
</label>
<RadioButtons v-model="current.status" :items="['listed', 'unlisted', 'private']">
<template #default="{ item }">
<span class="flex items-center gap-1">
{{
item === 'listed'
? formatMessage(commonMessages.publicLabel)
: formatMessage(commonMessages[`${item}Label`])
}}
</span>
</template>
</RadioButtons>
</div>
<div class="flex justify-end gap-2">
<ButtonStyled class="w-24">
<button @click="() => editModal?.hide()">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand" class="w-36">
<button :disabled="saving" @click="save()">
<SpinnerIcon v-if="saving" class="animate-spin" aria-hidden="true" />
<SaveIcon v-else aria-hidden="true" />
{{
saving
? formatMessage(commonMessages.savingButton)
: formatMessage(commonMessages.saveButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NormalPage :sidebar="cosmetics.leftContentLayout ? 'left' : 'right'">
<template #header>
<div class="flex flex-col gap-6">
<ClientOnly>
<nuxt-link
v-if="returnLink"
:to="returnLink.link"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon aria-hidden="true" />
{{ formatMessage(returnLink.message, { user: creator.username }) }}
</nuxt-link>
</ClientOnly>
<div class="grid grid-cols-[auto_1fr] gap-4 sm:grid-cols-[auto_1fr_auto]">
<Avatar :src="collection.icon_url" size="64px" />
<div class="flex flex-col gap-3">
<h1 class="heading-2xl">
{{ collection.name }}
</h1>
<div class="flex items-center gap-2">
<template v-if="canEdit || collection.status !== 'listed'">
<div class="flex items-center gap-1">
<template v-if="collection.status === 'listed'">
<GlobeIcon aria-hidden="true" />
{{ formatMessage(commonMessages.publicLabel) }}
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon aria-hidden="true" />
{{ formatMessage(commonMessages.unlistedLabel) }}
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon aria-hidden="true" />
{{ formatMessage(commonMessages.privateLabel) }}
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.rejectedLabel) }}
</template>
</div>
<span class="text-secondary"></span>
</template>
<span>
<IntlFormatted
:message-id="messages.projectsCountLabel"
:values="{
count: formatCompactNumber(projects?.length || 0),
type: formatMessage(
getProjectTypeSentenceMessage(
projectTypes.length === 1 ? projectTypes[0] : 'project',
),
{ count: formatCompactNumberPlural(projects?.length || 0) },
),
}"
>
<template #stat="{ children }">
<span class="primary-stat__counter">
<component :is="() => normalizeChildren(children)" />
</span>
</template>
</IntlFormatted>
</span>
</div>
</div>
<div class="col-span-2 flex gap-2 sm:col-span-1">
<template v-if="canEdit">
<ButtonStyled>
<button @click="openEditModal">
<EditIcon aria-hidden="true" />
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red" color-fill="text">
<button @click="() => $refs.deleteModal.show()">
<TrashIcon aria-hidden="true" />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
<HorizontalRule />
</div>
</template>
<template #sidebar>
<SidebarCard
v-if="collection.description"
:title="formatMessage(commonMessages.descriptionLabel)"
>
<p class="m-0 break-words">{{ collection.description }}</p>
</SidebarCard>
<SidebarCard
v-if="collection.id !== 'following'"
:title="formatMessage(messages.curatedByLabel)"
>
<nuxt-link
class="group flex w-fit items-center gap-2 leading-[1.2] text-primary"
:to="`/user/${creator.username}`"
>
<Avatar :src="creator.avatar_url" :alt="creator.username" size="32px" circle />
<div class="flex flex-col">
<span
class="grid w-full grid-cols-[1fr_auto] flex-nowrap items-center gap-1 group-hover:underline"
>
<span class="min-w-0 overflow-hidden truncate">{{ creator.username }}</span>
</span>
</div>
</nuxt-link>
</SidebarCard>
<AdPlaceholder v-if="!auth.user" />
<SidebarCard
v-if="collection.id !== 'following'"
:title="formatMessage(commonMessages.detailsLabel)"
>
<div class="flex flex-col gap-2">
<span
v-tooltip="formatDateTime(collection.created)"
class="flex w-fit items-center gap-2"
>
<CalendarIcon aria-hidden="true" />
{{
formatMessage(messages.createdAtLabel, {
ago: formatRelativeTime(collection.created),
})
}}
</span>
<span
v-if="showUpdatedDate"
v-tooltip="formatDateTime(collection.updated)"
class="flex w-fit items-center gap-2"
>
<UpdatedIcon aria-hidden="true" />
{{
formatMessage(messages.updatedAtLabel, {
ago: formatRelativeTime(collection.updated),
})
}}
</span>
</div>
</SidebarCard>
</template>
<NavTabs
v-if="projects && projectTypes.length > 1"
:links="[
{
label: formatMessage(commonMessages.allProjectType),
href: `/collection/${collection.id}`,
},
...projectTypes.map((x) => {
return {
label: formatMessage(getProjectTypeCategoryMessage(x)),
href: `/collection/${collection.id}/${x}s`,
}
}),
]"
/>
<ProjectCardList
v-if="projects && projects?.length > 0"
:layout="cosmetics.searchDisplayMode.collection"
>
<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)"
:key="project.id"
:link="`/${project.project_type}/${project.slug ?? project.id}`"
:title="project.title"
:icon-url="project.icon_url"
:banner="project.gallery.find((element) => element.featured)?.url"
:summary="project.description"
:date-updated="project.updated"
:downloads="project.downloads ?? 0"
:followers="project.followers ?? 0"
:tags="project.categories"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:color="project.color"
:layout="
cosmetics.searchDisplayMode.collection === 'grid' ||
cosmetics.searchDisplayMode.collection === 'gallery'
? 'grid'
: 'list'
"
>
<template v-if="canEdit || collection.id === 'following'" #actions>
<button
v-if="canEdit"
class="iconified-button remove-btn"
:disabled="removing"
@click="() => removeProject(project)"
>
<SpinnerIcon v-if="removing" class="animate-spin" aria-hidden="true" />
<XIcon v-else aria-hidden="true" />
{{ formatMessage(messages.removeProjectButton) }}
</button>
<button
v-if="collection.id === 'following'"
class="iconified-button"
@click="unfollowProject(project)"
>
<HeartMinusIcon aria-hidden="true" />
{{ formatMessage(messages.unfollowProjectButton) }}
</button>
</template>
</ProjectCard>
</ProjectCardList>
<EmptyState v-else type="empty-inbox" :heading="formatMessage(messages.noProjectsLabel)">
<template #actions>
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
<nuxt-link class="mx-auto w-min" to="/discover/mods">
<CompassIcon class="size-5" />
Discover mods
</nuxt-link>
</ButtonStyled>
</template>
</EmptyState>
</NormalPage>
</template>
</template>
<script setup>
import {
CalendarIcon,
ChevronLeftIcon,
CompassIcon,
EditIcon,
GlobeIcon,
HeartMinusIcon,
LinkIcon,
LockIcon,
SaveIcon,
SpinnerIcon,
TrashIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
commonProjectTypeCategoryMessages,
commonProjectTypeSentenceMessages,
ConfirmModal,
defineMessage,
defineMessages,
EmptyState,
FileInput,
HorizontalRule,
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
NavTabs,
NewModal,
normalizeChildren,
NormalPage,
OverflowMenu,
ProjectCard,
ProjectCardList,
RadioButtons,
SidebarCard,
StyledInput,
useCompactNumber,
useFormatDateTime,
useRelativeTime,
useSavable,
useVIntl,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
const { handleError } = injectNotificationManager()
const api = injectModrinthClient()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const { formatCompactNumber, formatCompactNumberPlural } = useCompactNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const route = useNativeRoute()
const router = useRouter()
const auth = await useAuth()
const cosmetics = useCosmetics()
const messages = defineMessages({
collectionDescription: {
id: 'collection.description',
defaultMessage: '{description} - View the collection {name} by {username} on Modrinth',
},
collectionTitle: {
id: 'collection.title',
defaultMessage: '{name} - Collection',
},
editIconButton: {
id: 'collection.button.edit-icon',
defaultMessage: 'Edit icon',
},
removeIconButton: {
id: 'collection.button.remove-icon',
defaultMessage: 'Remove icon',
},
selectIcon: {
id: 'collection.button.select-icon',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'collection.button.replace-icon',
defaultMessage: 'Replace icon',
},
editingCollection: {
id: 'collection.editing',
defaultMessage: 'Editing collection',
},
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 permanently delete this collection. 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: 'No projects in collection yet',
},
projectsCountLabel: {
id: 'collection.label.projects-count',
defaultMessage: '{count, plural, =0 {No projects yet} other {<stat>{count}</stat> {type}}}',
},
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}',
},
})
const returnLink = computed(() => {
const from = router.options?.history?.state?.back
if (from?.startsWith('/dashboard/collections')) {
return {
link: from,
message: defineMessage({
id: 'collection.return-link.dashboard-collections',
defaultMessage: 'Your collections',
}),
}
} else if (from?.startsWith('/user/')) {
return {
link: from,
message: defineMessage({
id: 'collection.return-link.user',
defaultMessage: `{user}'s profile`,
}),
}
}
return null
})
const isFollowingCollection = computed(() => route.params.id === 'following')
const collectionId = computed(() => route.params.id)
// Static collection for "following" page
const followingCollection = computed(() =>
isFollowingCollection.value
? {
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,
}
: null,
)
// Query for regular collections
const {
data: fetchedCollection,
refetch: refreshFetchedCollection,
error: collectionError,
isPending: collectionIsPending,
} = useQuery({
queryKey: computed(() => ['collection', collectionId.value]),
queryFn: () => api.labrinth.collections.get(collectionId.value),
enabled: computed(() => !isFollowingCollection.value),
})
watch(
collectionError,
(error) => {
if (error && !isFollowingCollection.value) {
const status = error.statusCode ?? error.status ?? 404
showError({
fatal: true,
statusCode: status,
message: formatMessage(messages.collectionNotFoundError),
})
}
},
{ immediate: true },
)
// Unified collection ref
const collection = computed(() => followingCollection.value ?? fetchedCollection.value)
const refreshCollection = async () => {
if (!isFollowingCollection.value) {
await refreshFetchedCollection()
}
}
// Query for creator (only for regular collections)
const { data: fetchedCreator, isPending: creatorIsPending } = useQuery({
queryKey: computed(() => ['user', collection.value?.user]),
queryFn: () => api.labrinth.users_v2.get(collection.value.user),
enabled: computed(() => !isFollowingCollection.value && !!collection.value?.user),
})
// Unified creator ref
const creator = computed(() =>
isFollowingCollection.value ? auth.value.user : fetchedCreator.value,
)
// Query for followed projects
const {
data: followedProjects,
refetch: refetchFollowedProjects,
isFetching: followedProjectsIsFetching,
} = useQuery({
queryKey: computed(() => ['user', auth.value.user?.id, 'follows']),
queryFn: async () => {
const projects = await api.labrinth.users_v2.getFollowedProjects(auth.value.user.id)
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
enabled: computed(() => isFollowingCollection.value && !!auth.value.user?.id),
placeholderData: [],
})
// Query for collection projects
const {
data: collectionProjects,
refetch: refetchCollectionProjects,
isFetching: collectionProjectsIsFetching,
} = useQuery({
queryKey: computed(() => ['projects', collection.value?.projects]),
queryFn: async () => {
const projectIds = collection.value.projects
const segmentSize = 800
const segments = []
for (let i = 0; i < projectIds.length; i += segmentSize) {
segments.push(projectIds.slice(i, i + segmentSize))
}
const results = await Promise.all(
segments.map((ids) => api.labrinth.projects_v2.getMultiple(ids)),
)
const projects = results.flat()
for (const project of projects) {
project.categories = project.categories.concat(project.loaders)
}
return projects
},
enabled: computed(() => !isFollowingCollection.value && !!collection.value?.projects?.length),
placeholderData: [],
})
// Unified projects ref
const projects = computed(() =>
isFollowingCollection.value ? followedProjects.value : collectionProjects.value,
)
// Loading state
const isLoading = computed(() => {
if (isFollowingCollection.value) {
return followedProjectsIsFetching.value
}
return collectionIsPending.value || creatorIsPending.value || collectionProjectsIsFetching.value
})
watch(
[collection, creator],
([col, cre]) => {
if (col && cre) {
useSeoMeta({
title: formatMessage(messages.collectionTitle, { name: col.name }),
description: formatMessage(messages.collectionDescription, {
name: col.name,
description: col.description,
username: cre.username,
}),
ogTitle: formatMessage(messages.collectionTitle, { name: col.name }),
ogDescription: col.description,
ogImage: col.icon_url ?? 'https://cdn.modrinth.com/placeholder.png',
robots: col.status === 'listed' ? 'all' : 'noindex',
})
}
},
{ immediate: true },
)
const canEdit = computed(
() =>
collection.value &&
auth.value.user &&
(auth.value.user.id === collection.value.user || isAdmin(auth.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)
})
function getProjectTypeSentenceMessage(type) {
return commonProjectTypeSentenceMessages[type] ?? commonProjectTypeSentenceMessages.project
}
function getProjectTypeCategoryMessage(type) {
return commonProjectTypeCategoryMessages[type] ?? commonProjectTypeCategoryMessages.project
}
const showUpdatedDate = computed(() => {
if (!collection.value?.updated || !collection.value?.created) {
return false
}
return dayjs(collection.value.updated).diff(dayjs(collection.value.created), 'minute') > 1
})
const editModal = ref(null)
const iconInputRef = ref(null)
const icon = ref(null)
const deletedIcon = ref(false)
const previewImage = ref(null)
const saving = ref(false)
const removing = ref(false)
const {
saved,
current,
reset,
save: saveCollection,
} = useSavable(
() => ({
name: collection.value?.name ?? '',
description: collection.value?.description || null,
status: collection.value?.status ?? 'private',
}),
async (changes) => {
saving.value = true
startLoading()
try {
if (deletedIcon.value) {
await api.labrinth.collections.deleteIcon(collection.value.id)
} else if (icon.value) {
const ext = icon.value?.type?.split('/').pop()
if (!ext) throw new Error('Invalid file type')
await api.labrinth.collections.editIcon(collection.value.id, icon.value, ext)
}
if (Object.keys(changes).length > 0) {
await api.labrinth.collections.edit(collection.value.id, changes)
}
await refreshCollection().then(reset)
icon.value = null
deletedIcon.value = false
previewImage.value = null
editModal.value?.hide()
} catch (err) {
handleError(err)
} finally {
await initUserCollections()
stopLoading()
saving.value = false
}
},
)
async function unfollowProject(project) {
await userFollowProject(project)
await refetchFollowedProjects()
}
async function removeProject(project) {
removing.value = true
startLoading()
try {
const currentProjects = collection.value.projects || []
const updatedProjects = currentProjects.filter((id) => id !== project.id)
await api.labrinth.collections.edit(collection.value.id, {
new_projects: updatedProjects,
})
await refreshCollection()
await refetchCollectionProjects()
await initUserCollections()
} catch (err) {
handleError(err)
} finally {
removing.value = false
stopLoading()
}
}
const save = async () => {
const hasIconChanges = deletedIcon.value || icon.value
const hasCollectionChanges = computed(() => {
const keys = Object.keys(current.value)
for (const key of keys) {
if (saved.value[key] !== current.value[key]) {
return true
}
}
return false
}).value
if (!hasCollectionChanges && !hasIconChanges) {
return
}
if (hasCollectionChanges) {
saveCollection()
} else {
saving.value = true
startLoading()
try {
if (deletedIcon.value) {
await api.labrinth.collections.deleteIcon(collection.value.id)
} else if (icon.value) {
const ext = icon.value?.type?.split('/').pop()
if (!ext) throw new Error('Invalid file type')
await api.labrinth.collections.editIcon(collection.value.id, icon.value, ext)
}
await refreshCollection().then(reset)
icon.value = null
deletedIcon.value = false
previewImage.value = null
editModal.value?.hide()
} catch (err) {
handleError(err)
} finally {
await initUserCollections()
stopLoading()
saving.value = false
}
}
}
async function deleteCollection() {
startLoading()
try {
await api.labrinth.collections.delete(collection.value.id)
if (auth.value.user.id === collection.value.user) {
await navigateTo('/dashboard/collections')
} else {
await navigateTo(`/user/${collection.value.user}/collections`)
}
} catch (err) {
handleError(err)
}
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
}
}
function openEditModal(event) {
reset()
icon.value = null
deletedIcon.value = false
previewImage.value = null
editModal.value?.show(event)
}
</script>
<style scoped lang="scss">
.animated-dropdown {
// Omorphia's dropdowns are harcoded in width, so we need to override that
width: 100% !important;
}
</style>