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:
Geometrically 2023-12-27 13:27:50 -05:00 committed by GitHub
parent e319d19a54
commit 3a735ea0ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1396 additions and 123 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe-2"><path d="M21.54 15H17a2 2 0 0 0-2 2v4.54"/><path d="M7 3.34V5a3 3 0 0 0 3 3v0a2 2 0 0 1 2 2v0c0 1.1.9 2 2 2v0a2 2 0 0 0 2-2v0c0-1.1.9-2 2-2h3.17"/><path d="M11 21.95V18a2 2 0 0 0-2-2v0a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05"/><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 472 B

View File

@ -49,6 +49,8 @@ html {
--color-ad: #d6e6f9;
--color-ad-raised: #b1c8e4;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-grey-link: var(--color-text);
--color-grey-link-hover: var(--color-heading);
@ -187,6 +189,8 @@ html {
--color-ad: #1f324a;
--color-ad-raised: #2e4057;
--color-ad-contrast: var(--color-text);
--color-ad-highlight: #088cdb;
--color-link: #74b6f3;
--color-link-hover: #92c0f5;

View File

@ -69,6 +69,12 @@
}
@media (min-width: 1024px) {
.full-page {
margin: 0 auto;
max-width: min(1280px, 100vw);
width: 80rem;
}
.normal-page {
margin: 0 auto;
max-width: 80rem;
@ -87,6 +93,20 @@
'content dummy' 1fr
/ 1fr 20rem;
}
&.no-sidebar {
grid-template:
'header header' auto
'content content' auto
'info info' auto
'dummy dummy' 1fr
/ 1fr 1fr;
.normal-page__content {
grid-area: content;
max-width: 100%;
}
}
}
.normal-page__sidebar {

View File

@ -0,0 +1,118 @@
<template>
<Modal ref="modal" header="Create a collection">
<div class="universal-modal modal-creation universal-labels">
<div class="markdown-body">
<p>
Your new collection will be created as a public collection with
{{ projectIds.length > 0 ? projectIds.length : 'no' }}
{{ projectIds.length !== 1 ? 'projects' : 'project' }}.
</p>
</div>
<label for="name">
<span class="label__title">Name<span class="required">*</span></span>
</label>
<input
id="name"
v-model="name"
type="text"
maxlength="64"
:placeholder="`Enter collection name...`"
autocomplete="off"
/>
<label for="additional-information">
<span class="label__title">Summary<span class="required">*</span></span>
<span class="label__description">This appears on your collection's page.</span>
</label>
<div class="textarea-wrapper">
<textarea id="additional-information" v-model="description" maxlength="256" />
</div>
<div class="push-right input-group">
<Button @click="modal.hide()">
<CrossIcon />
Cancel
</Button>
<Button color="primary" @click="create">
<CheckIcon />
Continue
</Button>
</div>
</div>
</Modal>
</template>
<script setup>
import { XIcon as CrossIcon, CheckIcon, Modal, Button } from 'omorphia'
const router = useRouter()
const name = ref('')
const description = ref('')
const modal = ref()
const props = defineProps({
projectIds: {
type: Array,
default() {
return []
},
},
})
async function create() {
startLoading()
try {
const result = await useBaseFetch('collection', {
method: 'POST',
body: {
name: name.value.trim(),
description: description.value.trim(),
projects: props.projectIds,
},
apiVersion: 3,
})
await initUserCollections()
modal.value.hide()
await router.push(`/collection/${result.id}`)
} catch (err) {
addNotification({
group: 'main',
title: 'An error occurred',
text: err?.data?.description || err?.message || err,
type: 'error',
})
}
stopLoading()
}
function show() {
name.value = ''
description.value = ''
modal.value.show()
}
defineExpose({
show,
})
</script>
<style scoped lang="scss">
.modal-creation {
input {
width: 20rem;
max-width: 100%;
}
.text-input-wrapper {
width: 100%;
}
textarea {
min-height: 5rem;
}
.input-group {
margin-top: var(--gap-md);
}
}
</style>

View File

@ -11,7 +11,7 @@
<div class="modal-container" :class="{ shown: actuallyShown }">
<div class="modal-body">
<div v-if="header" class="header">
<h1>{{ header }}</h1>
<strong>{{ header }}</strong>
<button class="iconified-button icon-only transparent" @click="hide">
<CrossIcon />
</button>
@ -127,8 +127,9 @@ export default {
background-color: var(--color-bg);
padding: var(--spacing-card-md) var(--spacing-card-lg);
h1 {
strong {
font-size: 1.25rem;
margin: 0.67em 0;
}
}

View File

@ -361,8 +361,6 @@ async function performAction(notification, actionIndex) {
try {
await read()
await userDeleteNotification(notification.id)
if (actionIndex !== null) {
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
method: notification.actions[actionIndex].action_route[0].toUpperCase(),

View File

@ -77,7 +77,7 @@
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
</div>
<div
v-else
v-else-if="showCreatedDate"
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
class="stat date"
>
@ -198,6 +198,11 @@ export default {
required: false,
default: true,
},
showCreatedDate: {
type: Boolean,
required: false,
default: true,
},
hideLoaders: {
type: Boolean,
required: false,

View File

@ -25,6 +25,7 @@ export const useCosmetics = () =>
shader: 'gallery',
datapack: 'list',
user: 'list',
collection: 'list',
},
}
}

View File

@ -19,12 +19,12 @@ export const initUser = async () => {
if (auth.user && auth.user.id) {
try {
const [notifications, follows] = await Promise.all([
useBaseFetch(`user/${auth.user.id}/notifications`),
const [follows, collections] = await Promise.all([
useBaseFetch(`user/${auth.user.id}/follows`),
useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 }),
])
user.notifications = notifications
user.collections = collections
user.follows = follows
user.lastUpdated = Date.now()
} catch (err) {
@ -35,13 +35,13 @@ export const initUser = async () => {
return user
}
export const initUserNotifs = async () => {
export const initUserCollections = async () => {
const auth = (await useAuth()).value
const user = (await useUser()).value
if (auth.user && auth.user.id) {
try {
user.notifications = await useBaseFetch(`user/${auth.user.id}/notifications`)
user.collections = await useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 })
} catch (err) {
console.error(err)
}
@ -74,6 +74,28 @@ export const initUserProjects = async () => {
}
}
export const userCollectProject = async (collection, projectId) => {
const user = (await useUser()).value
const add = !collection.projects.includes(projectId)
const projects = add
? [...collection.projects, projectId]
: [...collection.projects].filter((x) => x !== projectId)
const idx = user.collections.findIndex((x) => x.id === collection.id)
if (idx >= 0) {
user.collections[idx].projects = projects
}
await useBaseFetch(`collection/${collection.id}`, {
method: 'PATCH',
body: {
new_projects: projects,
},
apiVersion: 3,
})
}
export const userFollowProject = async (project) => {
const user = (await useUser()).value
@ -100,29 +122,6 @@ export const userUnfollowProject = async (project) => {
})
}
export const userDeleteNotification = async (id) => {
const user = (await useUser()).value
user.notifications = user.notifications.filter((x) => x.id !== id)
}
export const userDeleteNotifications = async (ids) => {
const user = (await useUser()).value
user.notifications = user.notifications.filter((x) => !ids.includes(x.id))
}
export const userReadNotifications = async (ids) => {
const user = (await useUser()).value
user.notifications = user.notifications.map((x) => {
if (ids.includes(x.id)) {
x.read = true
}
return x
})
}
export const resendVerifyEmail = async () => {
const app = useNuxtApp()

View File

@ -1,5 +1,4 @@
import { useNuxtApp } from '#app'
import { userReadNotifications } from '~/composables/user.js'
async function getBulk(type, ids) {
if (ids.length === 0) {
@ -168,7 +167,6 @@ export async function markAsRead(ids) {
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
method: 'PATCH',
})
await userReadNotifications(ids)
return (notifications) => {
const newNotifs = notifications
newNotifs.forEach((notif) => {

View File

@ -33,7 +33,6 @@
v-if="auth.user"
to="/dashboard/notifications"
class="control-button button-transparent"
:class="{ bubble: user.notifications.some((notif) => !notif.read) }"
title="Notifications"
>
<NotificationIcon aria-hidden="true" />
@ -86,9 +85,9 @@
<ChartIcon class="icon" />
<span class="title">Dashboard</span>
</NuxtLink>
<NuxtLink class="item button-transparent" to="/dashboard/follows">
<HeartIcon class="icon" />
<span class="title">Following</span>
<NuxtLink class="item button-transparent" to="/dashboard/collections">
<LibraryIcon class="icon" />
<span class="title">Collections</span>
</NuxtLink>
<NuxtLink class="item button-transparent" to="/settings">
<SettingsIcon class="icon" />
@ -236,7 +235,6 @@
to="/dashboard/notifications"
class="tab button-animation"
:class="{
bubble: user.notifications.some((notif) => !notif.read),
'no-active': isMobileMenuOpen || isBrowseMenuOpen,
}"
title="Notifications"
@ -359,7 +357,7 @@
</div>
</template>
<script setup>
import { LogInIcon, DownloadIcon } from 'omorphia'
import { LogInIcon, DownloadIcon, LibraryIcon } from 'omorphia'
import HamburgerIcon from '~/assets/images/utils/hamburger.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import SearchIcon from '~/assets/images/utils/search.svg'
@ -383,7 +381,6 @@ import Avatar from '~/components/ui/Avatar.vue'
const app = useNuxtApp()
const auth = await useAuth()
const user = await useUser()
const cosmetics = useCosmetics()
const tags = useTags()

View File

@ -47,7 +47,7 @@
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "^13.0.1",
"omorphia": "^0.7.1",
"omorphia": "=0.7.1",
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"vue-multiselect": "^3.0.0-alpha.2",

View File

@ -140,6 +140,7 @@
<div class="markdown-body" v-html="renderString(licenseText)" />
</div>
</Modal>
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
<div
:class="{
'normal-page': true,
@ -214,6 +215,7 @@
/>
</Categories>
<hr class="card-divider" />
<div class="primary-stat">
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
@ -223,6 +225,7 @@
download<span v-if="project.downloads !== 1">s</span>
</div>
</div>
<div class="primary-stat">
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
<div class="primary-stat__text">
@ -262,13 +265,9 @@
<hr class="card-divider" />
<div class="input-group">
<template v-if="auth.user">
<button class="iconified-button" @click="() => reportProject(project.id)">
<ReportIcon aria-hidden="true" />
Report
</button>
<button
v-if="!user.follows.find((x) => x.id === project.id)"
class="iconified-button"
class="btn"
@click="userFollowProject(project)"
>
<HeartIcon aria-hidden="true" />
@ -276,12 +275,59 @@
</button>
<button
v-if="user.follows.find((x) => x.id === project.id)"
class="iconified-button"
class="btn"
@click="userUnfollowProject(project)"
>
<HeartIcon fill="currentColor" aria-hidden="true" />
Unfollow
</button>
<PopoutMenu class="btn" direction="right" position="bottom" from="top-right">
<BookmarkIcon aria-hidden="true" />
Save
<template #menu>
<input
v-model="displayCollectionsSearch"
type="text"
placeholder="Search collections..."
class="search-input menu-search"
/>
<div v-if="collections.length > 0" class="collections-list">
<Checkbox
v-for="option in collections"
:key="option.id"
:model-value="option.projects.includes(project.id)"
class="popout-checkbox"
@update:model-value="userCollectProject(option, project.id)"
>
{{ option.name }}
</Checkbox>
</div>
<div v-else class="menu-text">
<p class="popout-text">No collections found.</p>
</div>
<button class="btn collection-button" @click="$refs.modal_collection.show()">
<PlusIcon />
Create new collection
</button>
</template>
</PopoutMenu>
<OverflowMenu
class="btn icon-only"
:options="[
{
id: 'report',
action: () => reportProject(project.id),
color: 'red',
hoverOnly: true,
},
{ id: 'copy-id', action: () => copyId() },
]"
direction="right"
>
<MoreHorizontalIcon />
<template #report> <ReportIcon /> Report</template>
<template #copy-id> <ClipboardCopyIcon /> Copy ID</template>
</OverflowMenu>
</template>
<template v-else>
<nuxt-link class="iconified-button" to="/auth/sign-in">
@ -292,6 +338,24 @@
<HeartIcon aria-hidden="true" />
Follow
</nuxt-link>
<OverflowMenu
class="btn icon-only"
:options="[
{
id: 'report',
action: () => navigateTo('/auth/sign-in'),
color: 'red',
hoverOnly: true,
},
{ id: 'copy-id', action: () => copyId() },
]"
direction="right"
>
<MoreHorizontalIcon />
<template #report> <ReportIcon /> Report</template>
<template #copy-id> <ClipboardCopyIcon /> Copy ID</template>
</OverflowMenu>
</template>
</div>
</div>
@ -668,7 +732,17 @@
</div>
</template>
<script setup>
import { Promotion, ChartIcon } from 'omorphia'
import {
Promotion,
OverflowMenu,
PopoutMenu,
BookmarkIcon,
MoreHorizontalIcon,
ClipboardCopyIcon,
PlusIcon,
Checkbox,
ChartIcon,
} from 'omorphia'
import CalendarIcon from '~/assets/images/utils/calendar.svg'
import ClearIcon from '~/assets/images/utils/clear.svg'
import DownloadIcon from '~/assets/images/utils/download.svg'
@ -711,6 +785,8 @@ import VersionIcon from '~/assets/images/utils/version.svg'
import { renderString } from '~/helpers/parse.js'
import { reportProject } from '~/utils/report-helpers.ts'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import { userCollectProject } from '~/composables/user.js'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
const data = useNuxtApp()
const route = useRoute()
@ -721,6 +797,15 @@ const user = await useUser()
const cosmetics = useCosmetics()
const tags = useTags()
const displayCollectionsSearch = ref('')
const collections = computed(() =>
user.value && user.value.collections
? user.value.collections.filter((x) =>
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase())
)
: []
)
if (
!route.params.id ||
!(
@ -1132,7 +1217,6 @@ const collapsedChecklist = ref(false)
}
.project__header {
overflow: hidden;
.project__gallery {
display: none;
}
@ -1141,11 +1225,12 @@ const collapsedChecklist = ref(false)
display: inline-block;
width: 100%;
height: 10rem;
background-color: var(--color-button-bg-active);
img {
width: 100%;
height: 10rem;
object-fit: cover;
background-color: var(--color-button-bg-active);
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
}
}
.project__icon {
@ -1160,6 +1245,9 @@ const collapsedChecklist = ref(false)
background: none;
border-radius: unset;
}
.input-group {
flex-wrap: nowrap;
}
}
.project-info {
@ -1367,4 +1455,44 @@ const collapsedChecklist = ref(false)
.normal-page__sidebar .mod-button {
margin-top: var(--spacing-card-sm);
}
.popout-checkbox {
padding: var(--gap-sm) var(--gap-md);
white-space: nowrap;
&:hover {
filter: brightness(0.95);
}
}
.popout-heading {
padding: var(--gap-sm) var(--gap-md);
padding-bottom: 0;
font-size: var(--font-size-nm);
color: var(--color-secondary);
}
.collection-button {
margin: var(--gap-sm) var(--gap-md);
white-space: nowrap;
}
.menu-text {
padding: 0 var(--gap-md);
font-size: var(--font-size-nm);
color: var(--color-secondary);
}
.menu-search {
margin: var(--gap-sm) var(--gap-md);
width: calc(100% - var(--gap-md) * 2);
}
.collections-list {
max-height: 40rem;
overflow-y: auto;
background-color: var(--color-bg);
border-radius: var(--radius-md);
margin: var(--gap-sm) var(--gap-md);
padding: var(--gap-sm);
}
</style>

View File

@ -288,10 +288,10 @@ import {
ImageIcon,
TransferIcon,
ConfirmModal,
Modal,
FileInput,
DropArea,
} from 'omorphia'
import Modal from '~/components/ui/Modal.vue'
const props = defineProps({
project: {

672
pages/collection/[id].vue Normal file
View 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>

View File

@ -0,0 +1 @@
<template><div /></template>

View File

@ -24,6 +24,9 @@
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
<ListIcon />
</NavStackItem>
<NavStackItem link="/dashboard/collections" label="Collections">
<LibraryIcon />
</NavStackItem>
<NavStackItem link="/dashboard/revenue" label="Revenue">
<CurrencyIcon />
</NavStackItem>
@ -36,7 +39,7 @@
</div>
</template>
<script setup>
import { ChartIcon } from 'omorphia'
import { LibraryIcon, ChartIcon } from 'omorphia'
import NavStack from '~/components/ui/NavStack.vue'
import NavStackItem from '~/components/ui/NavStackItem.vue'

View File

@ -0,0 +1,187 @@
<template>
<div class="universal-card">
<CollectionCreateModal ref="modal_creation" />
<h2>Collections</h2>
<div class="search-row">
<div class="iconified-input">
<label for="search-input" hidden>Search your collections</label>
<SearchIcon />
<input id="search-input" v-model="filterQuery" type="text" />
<Button v-if="filterQuery" class="r-btn" @click="() => (filterQuery = '')">
<XIcon />
</Button>
</div>
<Button color="primary" @click="$refs.modal_creation.show()">
<PlusIcon /> Create new
</Button>
</div>
<div class="collections-grid">
<nuxt-link
v-if="'followed projects'.includes(filterQuery)"
:to="`/collection/following`"
class="universal-card recessed collection"
>
<Avatar src="https://cdn.modrinth.com/follow-collection.png" class="icon" />
<div class="details">
<span class="title">Followed projects</span>
<span class="description">
Auto-generated collection of all the projects you're following.
</span>
<div class="stat-bar">
<div class="stats"><BoxIcon /> {{ user.follows.length }} projects</div>
<div class="stats"><LockIcon /> <span> Private </span></div>
</div>
</div>
</nuxt-link>
<nuxt-link
v-for="collection in orderedCollections"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"
>
<Avatar :src="collection.icon_url" class="icon" />
<div class="details">
<span class="title">{{ collection.name }}</span>
<span class="description">
{{ collection.description }}
</span>
<div class="stat-bar">
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<WorldIcon />
<span> Public </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> Unlisted </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> Private </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> Rejected </span>
</template>
</div>
</div>
</div>
</nuxt-link>
</div>
</div>
</template>
<script setup>
import { Avatar, BoxIcon, SearchIcon, XIcon, Button, PlusIcon, LinkIcon, LockIcon } from 'omorphia'
import WorldIcon from '~/assets/images/utils/world.svg'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Your collections - Modrinth',
})
const user = await useUser()
const auth = await useAuth()
if (process.client) {
await initUserFollows()
}
const filterQuery = ref('')
const { data: collections } = await useAsyncData(`user/${auth.value.user.id}/collections`, () =>
useBaseFetch(`user/${auth.value.user.id}/collections`, { apiVersion: 3 })
)
const orderedCollections = computed(() => {
if (!collections.value) return []
return collections.value
.sort((a, b) => {
const aUpdated = new Date(a.updated)
const bUpdated = new Date(b.updated)
return bUpdated - aUpdated
})
.filter((collection) => {
if (!filterQuery.value) return true
return collection.name.toLowerCase().includes(filterQuery.value.toLowerCase())
})
})
</script>
<style lang="scss">
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--gap-md);
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
margin-bottom: 0;
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-md);
}
.description {
color: var(--color-secondary);
font-size: var(--font-size-sm);
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
}
}
}
.search-row {
margin-bottom: var(--gap-lg);
display: flex;
align-items: center;
gap: var(--gap-lg) var(--gap-sm);
flex-wrap: wrap;
justify-content: center;
.iconified-input {
flex-grow: 1;
input {
height: 2rem;
}
}
}
</style>

View File

@ -15,6 +15,7 @@
:client-side="project.client_side"
:server-side="project.server_side"
:color="project.color"
:show-updated-date="false"
>
<button class="iconified-button" @click="userUnfollowProject(project)">
<HeartIcon />

View File

@ -220,7 +220,6 @@ import {
UploadIcon,
PlusIcon,
Avatar,
Modal,
XIcon,
Button,
Checkbox,
@ -230,6 +229,7 @@ import {
CopyCode,
ConfirmModal,
} from 'omorphia'
import Modal from '~/components/ui/Modal.vue'
import { scopeList, hasScope, toggleScope } from '~/utils/auth/scopes.ts'
definePageMeta({

View File

@ -155,17 +155,9 @@
</div>
</template>
<script setup>
import {
PlusIcon,
Modal,
XIcon,
Checkbox,
TrashIcon,
EditIcon,
SaveIcon,
ConfirmModal,
} from 'omorphia'
import { PlusIcon, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon, ConfirmModal } from 'omorphia'
import CopyCode from '~/components/ui/CopyCode.vue'
import Modal from '~/components/ui/Modal.vue'
definePageMeta({
middleware: 'auth',

View File

@ -1,6 +1,7 @@
<template>
<div v-if="user">
<ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<div class="user-header-wrapper">
<div class="user-header">
<Avatar
@ -198,51 +199,52 @@
</button>
</div>
</nav>
<div
v-if="projects.length > 0"
class="project-list"
:class="'display-mode--' + cosmetics.searchDisplayMode.user"
>
<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.slug || project.id"
:key="project.id"
:name="project.title"
:display="cosmetics.searchDisplayMode.user"
:featured-image="
project.gallery
<div v-if="projects.length > 0">
<div
v-if="route.params.projectType !== 'collections'"
:class="'project-list display-mode--' + cosmetics.searchDisplayMode.user"
>
<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.featured - a.featured)
.map((x) => x.url)[0]
"
:description="project.description"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:icon-url="project.icon_url"
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:status="
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
? project.status
: null
"
:type="project.project_type"
:color="project.color"
/>
.sort((a, b) => b.downloads - a.downloads)"
:id="project.slug || project.id"
:key="project.id"
:name="project.title"
:display="cosmetics.searchDisplayMode.user"
:featured-image="
project.gallery
.slice()
.sort((a, b) => b.featured - a.featured)
.map((x) => x.url)[0]
"
:description="project.description"
:created-at="project.published"
:updated-at="project.updated"
:downloads="project.downloads.toString()"
:follows="project.followers.toString()"
:icon-url="project.icon_url"
:categories="project.categories"
:client-side="project.client_side"
:server-side="project.server_side"
:status="
auth.user && (auth.user.id === user.id || tags.staffRoles.includes(auth.user.role))
? project.status
: null
"
:type="project.project_type"
:color="project.color"
/>
</div>
</div>
<div v-else class="error">
<div v-else-if="route.params.projectType !== 'collections'" class="error">
<UpToDate class="icon" /><br />
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
@ -255,12 +257,71 @@
</span>
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
</div>
<div v-if="['collections'].includes(route.params.projectType)" class="collections-grid">
<nuxt-link
v-for="collection in collections"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="card collection-item"
>
<div class="collection">
<Avatar :src="collection.icon_url" class="icon" />
<div class="details">
<h2 class="title">{{ collection.name }}</h2>
<div class="stats">
<LibraryIcon />
Collection
</div>
</div>
</div>
<div class="description">
{{ collection.description }}
</div>
<div class="stat-bar">
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<WorldIcon />
<span> Public </span>
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
<span> Unlisted </span>
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
<span> Private </span>
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
<span> Rejected </span>
</template>
</div>
</div>
</nuxt-link>
</div>
<div
v-if="route.params.projectType === 'collections' && collections.length === 0"
class="error"
>
<UpToDate class="icon" /><br />
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
<IntlFormatted :message-id="messages.profileNoCollectionsAuthLabel">
<template #create-link="{ children }">
<a class="link" @click.prevent="$refs.modal_collection_creation.show()">
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</span>
<span v-else class="text">{{ formatMessage(messages.profileNoCollectionsLabel) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Promotion } from 'omorphia'
import { Promotion, LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from 'omorphia'
import ProjectCard from '~/components/ui/ProjectCard.vue'
import Badge from '~/components/ui/Badge.vue'
import { reportUser } from '~/utils/report-helpers.ts'
@ -279,11 +340,13 @@ import GridIcon from '~/assets/images/utils/grid.svg'
import ListIcon from '~/assets/images/utils/list.svg'
import ImageIcon from '~/assets/images/utils/image.svg'
import UploadIcon from '~/assets/images/utils/upload.svg'
import WorldIcon from '~/assets/images/utils/world.svg'
import FileInput from '~/components/ui/FileInput.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue'
import NavRow from '~/components/ui/NavRow.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import Avatar from '~/components/ui/Avatar.vue'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
const data = useNuxtApp()
const route = useRoute()
@ -354,15 +417,24 @@ const messages = defineMessages({
defaultMessage:
"You don't have any projects.\nWould you like to <create-link>create one</create-link>?",
},
profileNoCollectionsLabel: {
id: 'profile.label.no-collections',
defaultMessage: 'This user has no collection!',
},
profileNoCollectionsAuthLabel: {
id: 'profile.label.no-collections-auth',
defaultMessage:
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
},
userNotFoundError: {
id: 'profile.error.not-found',
defaultMessage: 'User not found',
},
})
let user, projects
let user, projects, collections
try {
;[{ data: user }, { data: projects }] = await Promise.all([
;[{ data: user }, { data: projects }, { data: collections }] = await Promise.all([
useAsyncData(`user/${route.params.id}`, () => useBaseFetch(`user/${route.params.id}`)),
useAsyncData(
`user/${route.params.id}/projects`,
@ -382,6 +454,9 @@ try {
},
}
),
useAsyncData(`user/${route.params.id}/collections`, () =>
useBaseFetch(`user/${route.params.id}/collections`, { apiVersion: 3 })
),
])
} catch {
throw createError({
@ -403,26 +478,26 @@ if (user.value.username !== route.params.id) {
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
}
const title = `${user.value.username} - Modrinth`
const description = ref(
const title = computed(() => `${user.value.username} - Modrinth`)
const description = computed(() =>
user.value.bio
? `${formatMessage(messages.profileMetaDescriptionWithBio, {
? formatMessage(messages.profileMetaDescriptionWithBio, {
bio: user.value.bio,
username: user.value.username,
})}`
: `${formatMessage(messages.profileMetaDescription, { username: user.value.username })}`
})
: formatMessage(messages.profileMetaDescription, { username: user.value.username })
)
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
title: () => title.value,
description: () => description.value,
ogTitle: () => title.value,
ogDescription: () => description.value,
ogImage: () => user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
})
const projectTypes = computed(() => {
const obj = {}
const obj = { collection: true }
for (const project of projects.value) {
obj[project.project_type] = true
@ -519,6 +594,70 @@ export default defineNuxtComponent({
</script>
<style lang="scss" scoped>
.collections-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--gap-lg);
.collection-item {
display: flex;
flex-direction: column;
gap: var(--gap-md);
}
.description {
// Grow to take up remaining space
flex-grow: 1;
color: var(--color-text);
font-size: 16px;
}
.stat-bar {
display: flex;
align-items: center;
gap: var(--gap-md);
margin-top: auto;
}
.stats {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
color: var(--color-secondary);
}
}
.collection {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--gap-md);
.icon {
width: 100% !important;
height: 6rem !important;
max-width: unset !important;
max-height: unset !important;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.details {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
.title {
color: var(--color-contrast);
font-weight: 600;
font-size: var(--font-size-lg);
margin: 0;
}
}
}
}
.user-header-wrapper {
display: flex;
margin: 0 auto -1.5rem;

2
pnpm-lock.yaml generated
View File

@ -38,7 +38,7 @@ dependencies:
specifier: ^13.0.1
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
omorphia:
specifier: ^0.7.1
specifier: '=0.7.1'
version: 0.7.1(vue@3.3.4)
qrcode.vue:
specifier: ^3.4.0

View File

@ -55,6 +55,14 @@ const projectTypeMessages = defineMessages({
id: 'project-type.project.plural',
defaultMessage: 'Projects',
},
collection: {
id: 'project-type.collection.singular',
defaultMessage: 'Collection',
},
collections: {
id: 'project-type.collection.plural',
defaultMessage: 'Collections',
},
})
type ExtractSingulars<K extends string> = K extends `${infer T}s` ? T : never