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:
parent
e319d19a54
commit
3a735ea0ce
1
assets/images/utils/world.svg
Normal file
1
assets/images/utils/world.svg
Normal 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 |
@ -49,6 +49,8 @@ html {
|
|||||||
|
|
||||||
--color-ad: #d6e6f9;
|
--color-ad: #d6e6f9;
|
||||||
--color-ad-raised: #b1c8e4;
|
--color-ad-raised: #b1c8e4;
|
||||||
|
--color-ad-contrast: var(--color-text);
|
||||||
|
--color-ad-highlight: #088cdb;
|
||||||
|
|
||||||
--color-grey-link: var(--color-text);
|
--color-grey-link: var(--color-text);
|
||||||
--color-grey-link-hover: var(--color-heading);
|
--color-grey-link-hover: var(--color-heading);
|
||||||
@ -187,6 +189,8 @@ html {
|
|||||||
|
|
||||||
--color-ad: #1f324a;
|
--color-ad: #1f324a;
|
||||||
--color-ad-raised: #2e4057;
|
--color-ad-raised: #2e4057;
|
||||||
|
--color-ad-contrast: var(--color-text);
|
||||||
|
--color-ad-highlight: #088cdb;
|
||||||
|
|
||||||
--color-link: #74b6f3;
|
--color-link: #74b6f3;
|
||||||
--color-link-hover: #92c0f5;
|
--color-link-hover: #92c0f5;
|
||||||
|
|||||||
@ -69,6 +69,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
|
.full-page {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: min(1280px, 100vw);
|
||||||
|
width: 80rem;
|
||||||
|
}
|
||||||
|
|
||||||
.normal-page {
|
.normal-page {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
max-width: 80rem;
|
max-width: 80rem;
|
||||||
@ -87,6 +93,20 @@
|
|||||||
'content dummy' 1fr
|
'content dummy' 1fr
|
||||||
/ 1fr 20rem;
|
/ 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 {
|
.normal-page__sidebar {
|
||||||
|
|||||||
118
components/ui/CollectionCreateModal.vue
Normal file
118
components/ui/CollectionCreateModal.vue
Normal 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>
|
||||||
@ -11,7 +11,7 @@
|
|||||||
<div class="modal-container" :class="{ shown: actuallyShown }">
|
<div class="modal-container" :class="{ shown: actuallyShown }">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div v-if="header" class="header">
|
<div v-if="header" class="header">
|
||||||
<h1>{{ header }}</h1>
|
<strong>{{ header }}</strong>
|
||||||
<button class="iconified-button icon-only transparent" @click="hide">
|
<button class="iconified-button icon-only transparent" @click="hide">
|
||||||
<CrossIcon />
|
<CrossIcon />
|
||||||
</button>
|
</button>
|
||||||
@ -127,8 +127,9 @@ export default {
|
|||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
padding: var(--spacing-card-md) var(--spacing-card-lg);
|
padding: var(--spacing-card-md) var(--spacing-card-lg);
|
||||||
|
|
||||||
h1 {
|
strong {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
margin: 0.67em 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -361,8 +361,6 @@ async function performAction(notification, actionIndex) {
|
|||||||
try {
|
try {
|
||||||
await read()
|
await read()
|
||||||
|
|
||||||
await userDeleteNotification(notification.id)
|
|
||||||
|
|
||||||
if (actionIndex !== null) {
|
if (actionIndex !== null) {
|
||||||
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
await useBaseFetch(`${notification.actions[actionIndex].action_route[1]}`, {
|
||||||
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
method: notification.actions[actionIndex].action_route[0].toUpperCase(),
|
||||||
|
|||||||
@ -77,7 +77,7 @@
|
|||||||
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
|
<span class="date-label">Updated </span>{{ fromNow(updatedAt) }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="showCreatedDate"
|
||||||
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(createdAt).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="stat date"
|
class="stat date"
|
||||||
>
|
>
|
||||||
@ -198,6 +198,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
showCreatedDate: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
hideLoaders: {
|
hideLoaders: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export const useCosmetics = () =>
|
|||||||
shader: 'gallery',
|
shader: 'gallery',
|
||||||
datapack: 'list',
|
datapack: 'list',
|
||||||
user: 'list',
|
user: 'list',
|
||||||
|
collection: 'list',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,12 +19,12 @@ export const initUser = async () => {
|
|||||||
|
|
||||||
if (auth.user && auth.user.id) {
|
if (auth.user && auth.user.id) {
|
||||||
try {
|
try {
|
||||||
const [notifications, follows] = await Promise.all([
|
const [follows, collections] = await Promise.all([
|
||||||
useBaseFetch(`user/${auth.user.id}/notifications`),
|
|
||||||
useBaseFetch(`user/${auth.user.id}/follows`),
|
useBaseFetch(`user/${auth.user.id}/follows`),
|
||||||
|
useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 }),
|
||||||
])
|
])
|
||||||
|
|
||||||
user.notifications = notifications
|
user.collections = collections
|
||||||
user.follows = follows
|
user.follows = follows
|
||||||
user.lastUpdated = Date.now()
|
user.lastUpdated = Date.now()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -35,13 +35,13 @@ export const initUser = async () => {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
export const initUserNotifs = async () => {
|
export const initUserCollections = async () => {
|
||||||
const auth = (await useAuth()).value
|
const auth = (await useAuth()).value
|
||||||
const user = (await useUser()).value
|
const user = (await useUser()).value
|
||||||
|
|
||||||
if (auth.user && auth.user.id) {
|
if (auth.user && auth.user.id) {
|
||||||
try {
|
try {
|
||||||
user.notifications = await useBaseFetch(`user/${auth.user.id}/notifications`)
|
user.collections = await useBaseFetch(`user/${auth.user.id}/collections`, { apiVersion: 3 })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(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) => {
|
export const userFollowProject = async (project) => {
|
||||||
const user = (await useUser()).value
|
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 () => {
|
export const resendVerifyEmail = async () => {
|
||||||
const app = useNuxtApp()
|
const app = useNuxtApp()
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { useNuxtApp } from '#app'
|
import { useNuxtApp } from '#app'
|
||||||
import { userReadNotifications } from '~/composables/user.js'
|
|
||||||
|
|
||||||
async function getBulk(type, ids) {
|
async function getBulk(type, ids) {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
@ -168,7 +167,6 @@ export async function markAsRead(ids) {
|
|||||||
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
|
await useBaseFetch(`notifications?ids=${JSON.stringify([...new Set(ids)])}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
})
|
})
|
||||||
await userReadNotifications(ids)
|
|
||||||
return (notifications) => {
|
return (notifications) => {
|
||||||
const newNotifs = notifications
|
const newNotifs = notifications
|
||||||
newNotifs.forEach((notif) => {
|
newNotifs.forEach((notif) => {
|
||||||
|
|||||||
@ -33,7 +33,6 @@
|
|||||||
v-if="auth.user"
|
v-if="auth.user"
|
||||||
to="/dashboard/notifications"
|
to="/dashboard/notifications"
|
||||||
class="control-button button-transparent"
|
class="control-button button-transparent"
|
||||||
:class="{ bubble: user.notifications.some((notif) => !notif.read) }"
|
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
>
|
>
|
||||||
<NotificationIcon aria-hidden="true" />
|
<NotificationIcon aria-hidden="true" />
|
||||||
@ -86,9 +85,9 @@
|
|||||||
<ChartIcon class="icon" />
|
<ChartIcon class="icon" />
|
||||||
<span class="title">Dashboard</span>
|
<span class="title">Dashboard</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="item button-transparent" to="/dashboard/follows">
|
<NuxtLink class="item button-transparent" to="/dashboard/collections">
|
||||||
<HeartIcon class="icon" />
|
<LibraryIcon class="icon" />
|
||||||
<span class="title">Following</span>
|
<span class="title">Collections</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="item button-transparent" to="/settings">
|
<NuxtLink class="item button-transparent" to="/settings">
|
||||||
<SettingsIcon class="icon" />
|
<SettingsIcon class="icon" />
|
||||||
@ -236,7 +235,6 @@
|
|||||||
to="/dashboard/notifications"
|
to="/dashboard/notifications"
|
||||||
class="tab button-animation"
|
class="tab button-animation"
|
||||||
:class="{
|
:class="{
|
||||||
bubble: user.notifications.some((notif) => !notif.read),
|
|
||||||
'no-active': isMobileMenuOpen || isBrowseMenuOpen,
|
'no-active': isMobileMenuOpen || isBrowseMenuOpen,
|
||||||
}"
|
}"
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
@ -359,7 +357,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { LogInIcon, DownloadIcon } from 'omorphia'
|
import { LogInIcon, DownloadIcon, LibraryIcon } from 'omorphia'
|
||||||
import HamburgerIcon from '~/assets/images/utils/hamburger.svg'
|
import HamburgerIcon from '~/assets/images/utils/hamburger.svg'
|
||||||
import CrossIcon from '~/assets/images/utils/x.svg'
|
import CrossIcon from '~/assets/images/utils/x.svg'
|
||||||
import SearchIcon from '~/assets/images/utils/search.svg'
|
import SearchIcon from '~/assets/images/utils/search.svg'
|
||||||
@ -383,7 +381,6 @@ import Avatar from '~/components/ui/Avatar.vue'
|
|||||||
|
|
||||||
const app = useNuxtApp()
|
const app = useNuxtApp()
|
||||||
const auth = await useAuth()
|
const auth = await useAuth()
|
||||||
const user = await useUser()
|
|
||||||
const cosmetics = useCosmetics()
|
const cosmetics = useCosmetics()
|
||||||
const tags = useTags()
|
const tags = useTags()
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@
|
|||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"omorphia": "^0.7.1",
|
"omorphia": "=0.7.1",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"vue-multiselect": "^3.0.0-alpha.2",
|
"vue-multiselect": "^3.0.0-alpha.2",
|
||||||
|
|||||||
@ -140,6 +140,7 @@
|
|||||||
<div class="markdown-body" v-html="renderString(licenseText)" />
|
<div class="markdown-body" v-html="renderString(licenseText)" />
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
|
||||||
<div
|
<div
|
||||||
:class="{
|
:class="{
|
||||||
'normal-page': true,
|
'normal-page': true,
|
||||||
@ -214,6 +215,7 @@
|
|||||||
/>
|
/>
|
||||||
</Categories>
|
</Categories>
|
||||||
<hr class="card-divider" />
|
<hr class="card-divider" />
|
||||||
|
|
||||||
<div class="primary-stat">
|
<div class="primary-stat">
|
||||||
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
|
<DownloadIcon class="primary-stat__icon" aria-hidden="true" />
|
||||||
<div class="primary-stat__text">
|
<div class="primary-stat__text">
|
||||||
@ -223,6 +225,7 @@
|
|||||||
download<span v-if="project.downloads !== 1">s</span>
|
download<span v-if="project.downloads !== 1">s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="primary-stat">
|
<div class="primary-stat">
|
||||||
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
|
<HeartIcon class="primary-stat__icon" aria-hidden="true" />
|
||||||
<div class="primary-stat__text">
|
<div class="primary-stat__text">
|
||||||
@ -262,13 +265,9 @@
|
|||||||
<hr class="card-divider" />
|
<hr class="card-divider" />
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<template v-if="auth.user">
|
<template v-if="auth.user">
|
||||||
<button class="iconified-button" @click="() => reportProject(project.id)">
|
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
Report
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
v-if="!user.follows.find((x) => x.id === project.id)"
|
v-if="!user.follows.find((x) => x.id === project.id)"
|
||||||
class="iconified-button"
|
class="btn"
|
||||||
@click="userFollowProject(project)"
|
@click="userFollowProject(project)"
|
||||||
>
|
>
|
||||||
<HeartIcon aria-hidden="true" />
|
<HeartIcon aria-hidden="true" />
|
||||||
@ -276,12 +275,59 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="user.follows.find((x) => x.id === project.id)"
|
v-if="user.follows.find((x) => x.id === project.id)"
|
||||||
class="iconified-button"
|
class="btn"
|
||||||
@click="userUnfollowProject(project)"
|
@click="userUnfollowProject(project)"
|
||||||
>
|
>
|
||||||
<HeartIcon fill="currentColor" aria-hidden="true" />
|
<HeartIcon fill="currentColor" aria-hidden="true" />
|
||||||
Unfollow
|
Unfollow
|
||||||
</button>
|
</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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<nuxt-link class="iconified-button" to="/auth/sign-in">
|
<nuxt-link class="iconified-button" to="/auth/sign-in">
|
||||||
@ -292,6 +338,24 @@
|
|||||||
<HeartIcon aria-hidden="true" />
|
<HeartIcon aria-hidden="true" />
|
||||||
Follow
|
Follow
|
||||||
</nuxt-link>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -668,7 +732,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<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 CalendarIcon from '~/assets/images/utils/calendar.svg'
|
||||||
import ClearIcon from '~/assets/images/utils/clear.svg'
|
import ClearIcon from '~/assets/images/utils/clear.svg'
|
||||||
import DownloadIcon from '~/assets/images/utils/download.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 { renderString } from '~/helpers/parse.js'
|
||||||
import { reportProject } from '~/utils/report-helpers.ts'
|
import { reportProject } from '~/utils/report-helpers.ts'
|
||||||
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
|
||||||
|
import { userCollectProject } from '~/composables/user.js'
|
||||||
|
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||||
|
|
||||||
const data = useNuxtApp()
|
const data = useNuxtApp()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -721,6 +797,15 @@ const user = await useUser()
|
|||||||
const cosmetics = useCosmetics()
|
const cosmetics = useCosmetics()
|
||||||
const tags = useTags()
|
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 (
|
if (
|
||||||
!route.params.id ||
|
!route.params.id ||
|
||||||
!(
|
!(
|
||||||
@ -1132,7 +1217,6 @@ const collapsedChecklist = ref(false)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project__header {
|
.project__header {
|
||||||
overflow: hidden;
|
|
||||||
.project__gallery {
|
.project__gallery {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@ -1141,11 +1225,12 @@ const collapsedChecklist = ref(false)
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 10rem;
|
height: 10rem;
|
||||||
background-color: var(--color-button-bg-active);
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 10rem;
|
height: 10rem;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
background-color: var(--color-button-bg-active);
|
||||||
|
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.project__icon {
|
.project__icon {
|
||||||
@ -1160,6 +1245,9 @@ const collapsedChecklist = ref(false)
|
|||||||
background: none;
|
background: none;
|
||||||
border-radius: unset;
|
border-radius: unset;
|
||||||
}
|
}
|
||||||
|
.input-group {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-info {
|
.project-info {
|
||||||
@ -1367,4 +1455,44 @@ const collapsedChecklist = ref(false)
|
|||||||
.normal-page__sidebar .mod-button {
|
.normal-page__sidebar .mod-button {
|
||||||
margin-top: var(--spacing-card-sm);
|
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>
|
</style>
|
||||||
|
|||||||
@ -288,10 +288,10 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
TransferIcon,
|
TransferIcon,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
Modal,
|
|
||||||
FileInput,
|
FileInput,
|
||||||
DropArea,
|
DropArea,
|
||||||
} from 'omorphia'
|
} from 'omorphia'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
672
pages/collection/[id].vue
Normal file
672
pages/collection/[id].vue
Normal 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>
|
||||||
1
pages/collection/[id]/[projectType].vue
Normal file
1
pages/collection/[id]/[projectType].vue
Normal file
@ -0,0 +1 @@
|
|||||||
|
<template><div /></template>
|
||||||
@ -24,6 +24,9 @@
|
|||||||
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
<NavStackItem v-if="true" link="/dashboard/projects" label="Projects">
|
||||||
<ListIcon />
|
<ListIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
|
<NavStackItem link="/dashboard/collections" label="Collections">
|
||||||
|
<LibraryIcon />
|
||||||
|
</NavStackItem>
|
||||||
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
<NavStackItem link="/dashboard/revenue" label="Revenue">
|
||||||
<CurrencyIcon />
|
<CurrencyIcon />
|
||||||
</NavStackItem>
|
</NavStackItem>
|
||||||
@ -36,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ChartIcon } from 'omorphia'
|
import { LibraryIcon, ChartIcon } from 'omorphia'
|
||||||
import NavStack from '~/components/ui/NavStack.vue'
|
import NavStack from '~/components/ui/NavStack.vue'
|
||||||
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
import NavStackItem from '~/components/ui/NavStackItem.vue'
|
||||||
|
|
||||||
|
|||||||
187
pages/dashboard/collections.vue
Normal file
187
pages/dashboard/collections.vue
Normal 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>
|
||||||
@ -15,6 +15,7 @@
|
|||||||
:client-side="project.client_side"
|
:client-side="project.client_side"
|
||||||
:server-side="project.server_side"
|
:server-side="project.server_side"
|
||||||
:color="project.color"
|
:color="project.color"
|
||||||
|
:show-updated-date="false"
|
||||||
>
|
>
|
||||||
<button class="iconified-button" @click="userUnfollowProject(project)">
|
<button class="iconified-button" @click="userUnfollowProject(project)">
|
||||||
<HeartIcon />
|
<HeartIcon />
|
||||||
|
|||||||
@ -220,7 +220,6 @@ import {
|
|||||||
UploadIcon,
|
UploadIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Modal,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@ -230,6 +229,7 @@ import {
|
|||||||
CopyCode,
|
CopyCode,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
} from 'omorphia'
|
} from 'omorphia'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import { scopeList, hasScope, toggleScope } from '~/utils/auth/scopes.ts'
|
import { scopeList, hasScope, toggleScope } from '~/utils/auth/scopes.ts'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|||||||
@ -155,17 +155,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { PlusIcon, XIcon, Checkbox, TrashIcon, EditIcon, SaveIcon, ConfirmModal } from 'omorphia'
|
||||||
PlusIcon,
|
|
||||||
Modal,
|
|
||||||
XIcon,
|
|
||||||
Checkbox,
|
|
||||||
TrashIcon,
|
|
||||||
EditIcon,
|
|
||||||
SaveIcon,
|
|
||||||
ConfirmModal,
|
|
||||||
} from 'omorphia'
|
|
||||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||||
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="user">
|
<div v-if="user">
|
||||||
<ModalCreation ref="modal_creation" />
|
<ModalCreation ref="modal_creation" />
|
||||||
|
<CollectionCreateModal ref="modal_collection_creation" />
|
||||||
<div class="user-header-wrapper">
|
<div class="user-header-wrapper">
|
||||||
<div class="user-header">
|
<div class="user-header">
|
||||||
<Avatar
|
<Avatar
|
||||||
@ -198,10 +199,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div v-if="projects.length > 0">
|
||||||
<div
|
<div
|
||||||
v-if="projects.length > 0"
|
v-if="route.params.projectType !== 'collections'"
|
||||||
class="project-list"
|
:class="'project-list display-mode--' + cosmetics.searchDisplayMode.user"
|
||||||
:class="'display-mode--' + cosmetics.searchDisplayMode.user"
|
|
||||||
>
|
>
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
v-for="project in (route.params.projectType !== undefined
|
v-for="project in (route.params.projectType !== undefined
|
||||||
@ -242,7 +243,8 @@
|
|||||||
:color="project.color"
|
:color="project.color"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="error">
|
</div>
|
||||||
|
<div v-else-if="route.params.projectType !== 'collections'" class="error">
|
||||||
<UpToDate class="icon" /><br />
|
<UpToDate class="icon" /><br />
|
||||||
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
<span v-if="auth.user && auth.user.id === user.id" class="preserve-lines text">
|
||||||
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
|
<IntlFormatted :message-id="messages.profileNoProjectsAuthLabel">
|
||||||
@ -255,12 +257,71 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
|
<span v-else class="text">{{ formatMessage(messages.profileNoProjectsLabel) }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Promotion } from 'omorphia'
|
import { Promotion, LibraryIcon, BoxIcon, LinkIcon, LockIcon, XIcon } from 'omorphia'
|
||||||
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
import ProjectCard from '~/components/ui/ProjectCard.vue'
|
||||||
import Badge from '~/components/ui/Badge.vue'
|
import Badge from '~/components/ui/Badge.vue'
|
||||||
import { reportUser } from '~/utils/report-helpers.ts'
|
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 ListIcon from '~/assets/images/utils/list.svg'
|
||||||
import ImageIcon from '~/assets/images/utils/image.svg'
|
import ImageIcon from '~/assets/images/utils/image.svg'
|
||||||
import UploadIcon from '~/assets/images/utils/upload.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 FileInput from '~/components/ui/FileInput.vue'
|
||||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||||
import NavRow from '~/components/ui/NavRow.vue'
|
import NavRow from '~/components/ui/NavRow.vue'
|
||||||
import CopyCode from '~/components/ui/CopyCode.vue'
|
import CopyCode from '~/components/ui/CopyCode.vue'
|
||||||
import Avatar from '~/components/ui/Avatar.vue'
|
import Avatar from '~/components/ui/Avatar.vue'
|
||||||
|
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||||
|
|
||||||
const data = useNuxtApp()
|
const data = useNuxtApp()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -354,15 +417,24 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
"You don't have any projects.\nWould you like to <create-link>create one</create-link>?",
|
"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: {
|
userNotFoundError: {
|
||||||
id: 'profile.error.not-found',
|
id: 'profile.error.not-found',
|
||||||
defaultMessage: 'User not found',
|
defaultMessage: 'User not found',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let user, projects
|
let user, projects, collections
|
||||||
try {
|
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}`, () => useBaseFetch(`user/${route.params.id}`)),
|
||||||
useAsyncData(
|
useAsyncData(
|
||||||
`user/${route.params.id}/projects`,
|
`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 {
|
} catch {
|
||||||
throw createError({
|
throw createError({
|
||||||
@ -403,26 +478,26 @@ if (user.value.username !== route.params.id) {
|
|||||||
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
|
await navigateTo(`/user/${user.value.username}`, { redirectCode: 301 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = `${user.value.username} - Modrinth`
|
const title = computed(() => `${user.value.username} - Modrinth`)
|
||||||
const description = ref(
|
const description = computed(() =>
|
||||||
user.value.bio
|
user.value.bio
|
||||||
? `${formatMessage(messages.profileMetaDescriptionWithBio, {
|
? formatMessage(messages.profileMetaDescriptionWithBio, {
|
||||||
bio: user.value.bio,
|
bio: user.value.bio,
|
||||||
username: user.value.username,
|
username: user.value.username,
|
||||||
})}`
|
})
|
||||||
: `${formatMessage(messages.profileMetaDescription, { username: user.value.username })}`
|
: formatMessage(messages.profileMetaDescription, { username: user.value.username })
|
||||||
)
|
)
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title,
|
title: () => title.value,
|
||||||
description,
|
description: () => description.value,
|
||||||
ogTitle: title,
|
ogTitle: () => title.value,
|
||||||
ogDescription: description,
|
ogDescription: () => description.value,
|
||||||
ogImage: user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
ogImage: () => user.value.avatar_url ?? 'https://cdn.modrinth.com/placeholder.png',
|
||||||
})
|
})
|
||||||
|
|
||||||
const projectTypes = computed(() => {
|
const projectTypes = computed(() => {
|
||||||
const obj = {}
|
const obj = { collection: true }
|
||||||
|
|
||||||
for (const project of projects.value) {
|
for (const project of projects.value) {
|
||||||
obj[project.project_type] = true
|
obj[project.project_type] = true
|
||||||
@ -519,6 +594,70 @@ export default defineNuxtComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.user-header-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 auto -1.5rem;
|
margin: 0 auto -1.5rem;
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -38,7 +38,7 @@ dependencies:
|
|||||||
specifier: ^13.0.1
|
specifier: ^13.0.1
|
||||||
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
version: 13.0.1(patch_hash=3vlxaukqep4gvqytxeznhg6wbq)
|
||||||
omorphia:
|
omorphia:
|
||||||
specifier: ^0.7.1
|
specifier: '=0.7.1'
|
||||||
version: 0.7.1(vue@3.3.4)
|
version: 0.7.1(vue@3.3.4)
|
||||||
qrcode.vue:
|
qrcode.vue:
|
||||||
specifier: ^3.4.0
|
specifier: ^3.4.0
|
||||||
|
|||||||
@ -55,6 +55,14 @@ const projectTypeMessages = defineMessages({
|
|||||||
id: 'project-type.project.plural',
|
id: 'project-type.project.plural',
|
||||||
defaultMessage: 'Projects',
|
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
|
type ExtractSingulars<K extends string> = K extends `${infer T}s` ? T : never
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user