first commit
This commit is contained in:
511
resources/js/pages/apps/email/index.vue
Normal file
511
resources/js/pages/apps/email/index.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<script setup>
|
||||
import { PerfectScrollbar } from 'vue3-perfect-scrollbar'
|
||||
import ComposeDialog from '@/views/apps/email/ComposeDialog.vue'
|
||||
import EmailLeftSidebarContent from '@/views/apps/email/EmailLeftSidebarContent.vue'
|
||||
import EmailView from '@/views/apps/email/EmailView.vue'
|
||||
import { useEmail } from '@/views/apps/email/useEmail'
|
||||
|
||||
definePage({ meta: { layoutWrapperClasses: 'layout-content-height-fixed' } })
|
||||
|
||||
const { isLeftSidebarOpen } = useResponsiveLeftSidebar()
|
||||
|
||||
// Composables
|
||||
const route = useRoute()
|
||||
const { labels, resolveLabelColor, emailMoveToFolderActions, shallShowMoveToActionFor, moveSelectedEmailTo, updateEmails, updateEmailLabels } = useEmail()
|
||||
|
||||
// Compose dialog
|
||||
const isComposeDialogVisible = ref(false)
|
||||
|
||||
// Ref
|
||||
const q = ref('')
|
||||
|
||||
// Email Selection
|
||||
|
||||
// ------------------------------------------------
|
||||
const selectedEmails = ref([])
|
||||
|
||||
const {
|
||||
data: emailData,
|
||||
execute: fetchEmails,
|
||||
} = await useApi(createUrl('/apps/email', {
|
||||
query: {
|
||||
q,
|
||||
filter: () => 'filter' in route.params ? route.params.filter : undefined,
|
||||
label: () => 'label' in route.params ? route.params.label : undefined,
|
||||
},
|
||||
}))
|
||||
|
||||
const emails = computed(() => emailData.value.emails)
|
||||
|
||||
const toggleSelectedEmail = emailId => {
|
||||
const emailIndex = selectedEmails.value.indexOf(emailId)
|
||||
if (emailIndex === -1)
|
||||
selectedEmails.value.push(emailId)
|
||||
else
|
||||
selectedEmails.value.splice(emailIndex, 1)
|
||||
}
|
||||
|
||||
const selectAllEmailCheckbox = computed(() => emails.value.length && emails.value.length === selectedEmails.value.length)
|
||||
const isSelectAllEmailCheckboxIndeterminate = computed(() => Boolean(selectedEmails.value.length) && emails.value.length !== selectedEmails.value.length)
|
||||
|
||||
const isAllMarkRead = computed(() => {
|
||||
return selectedEmails.value.every(emailId => emails.value.find(email => email.id === emailId)?.isRead)
|
||||
})
|
||||
|
||||
const selectAllCheckboxUpdate = () => {
|
||||
selectedEmails.value = !selectAllEmailCheckbox.value ? emails.value.map(email => email.id) : []
|
||||
}
|
||||
|
||||
// Email View
|
||||
const openedEmail = ref(null)
|
||||
|
||||
const emailViewMeta = computed(() => {
|
||||
const returnValue = {
|
||||
hasNextEmail: false,
|
||||
hasPreviousEmail: false,
|
||||
}
|
||||
|
||||
if (openedEmail.value) {
|
||||
const openedEmailIndex = emails.value.findIndex(e => e.id === openedEmail.value?.id)
|
||||
|
||||
returnValue.hasNextEmail = !!emails.value[openedEmailIndex + 1]
|
||||
returnValue.hasPreviousEmail = !!emails.value[openedEmailIndex - 1]
|
||||
}
|
||||
|
||||
return returnValue
|
||||
})
|
||||
|
||||
const refreshOpenedEmail = async () => {
|
||||
await fetchEmails()
|
||||
if (openedEmail.value)
|
||||
openedEmail.value = emails.value.find(e => e.id === openedEmail.value?.id)
|
||||
}
|
||||
|
||||
/*ℹ️ You can optimize it so it doesn't fetch emails on each action.
|
||||
Currently, if you just star the email, two API calls will get fired.
|
||||
1. star the email
|
||||
2. Fetch all latest emails
|
||||
|
||||
You can limit this to single API call by:
|
||||
- making API to star the email
|
||||
- modify the state (set that email's isStarred property to true/false) in the store instead of making API for fetching emails
|
||||
|
||||
😊 For simplicity of the code and possible of modification, we kept it simple.
|
||||
*/
|
||||
const handleActionClick = async (action, emailIds = selectedEmails.value) => {
|
||||
selectedEmails.value = []
|
||||
if (!emailIds.length)
|
||||
return
|
||||
if (action === 'trash')
|
||||
await updateEmails(emailIds, { isDeleted: true })
|
||||
else if (action === 'spam')
|
||||
await updateEmails(emailIds, { folder: 'spam' })
|
||||
else if (action === 'unread')
|
||||
await updateEmails(emailIds, { isRead: false })
|
||||
else if (action === 'read')
|
||||
await updateEmails(emailIds, { isRead: true })
|
||||
else if (action === 'star')
|
||||
await updateEmails(emailIds, { isStarred: true })
|
||||
else if (action === 'unstar')
|
||||
await updateEmails(emailIds, { isStarred: false })
|
||||
await fetchEmails()
|
||||
if (openedEmail.value)
|
||||
refreshOpenedEmail()
|
||||
}
|
||||
|
||||
const handleMoveMailsTo = async action => {
|
||||
moveSelectedEmailTo(action, selectedEmails.value)
|
||||
await fetchEmails()
|
||||
}
|
||||
|
||||
const changeOpenedEmail = dir => {
|
||||
if (!openedEmail.value)
|
||||
return
|
||||
const openedEmailIndex = emails.value.findIndex(e => e.id === openedEmail.value?.id)
|
||||
const newEmailIndex = dir === 'previous' ? openedEmailIndex - 1 : openedEmailIndex + 1
|
||||
|
||||
openedEmail.value = emails.value[newEmailIndex]
|
||||
}
|
||||
|
||||
const openEmail = async email => {
|
||||
openedEmail.value = email
|
||||
await handleActionClick('read', [email.id])
|
||||
}
|
||||
|
||||
watch(() => route.params, () => {
|
||||
selectedEmails.value = []
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VLayout
|
||||
style="min-block-size: 100%;"
|
||||
class="email-app-layout"
|
||||
>
|
||||
<VNavigationDrawer
|
||||
v-model="isLeftSidebarOpen"
|
||||
absolute
|
||||
touchless
|
||||
location="start"
|
||||
:temporary="$vuetify.display.mdAndDown"
|
||||
>
|
||||
<EmailLeftSidebarContent @toggle-compose-dialog-visibility="isComposeDialogVisible = !isComposeDialogVisible" />
|
||||
</VNavigationDrawer>
|
||||
<EmailView
|
||||
:email="openedEmail"
|
||||
:email-meta="emailViewMeta"
|
||||
@refresh="refreshOpenedEmail"
|
||||
@navigated="changeOpenedEmail"
|
||||
@close="openedEmail = null"
|
||||
@remove="handleActionClick('trash', openedEmail ? [openedEmail.id] : [])"
|
||||
@unread="handleActionClick('unread', openedEmail ? [openedEmail.id] : [])"
|
||||
@star="handleActionClick('star', openedEmail ? [openedEmail.id] : [])"
|
||||
@unstar="handleActionClick('unstar', openedEmail ? [openedEmail.id] : [])"
|
||||
/>
|
||||
<VMain>
|
||||
<VCard
|
||||
flat
|
||||
class="email-content-list h-100 d-flex flex-column"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<IconBtn
|
||||
class="d-lg-none ms-3"
|
||||
@click="isLeftSidebarOpen = true"
|
||||
>
|
||||
<VIcon icon="ri-menu-line" />
|
||||
</IconBtn>
|
||||
<!-- 👉 Search -->
|
||||
<VTextField
|
||||
v-model="q"
|
||||
density="default"
|
||||
class="email-search px-1 flex-grow-1"
|
||||
placeholder="Search mail"
|
||||
>
|
||||
<template #prepend-inner>
|
||||
<VIcon
|
||||
color="disabled"
|
||||
size="22"
|
||||
icon="ri-search-line"
|
||||
/>
|
||||
</template>
|
||||
</VTextField>
|
||||
</div>
|
||||
<VDivider />
|
||||
<!-- 👉 Action bar -->
|
||||
<div class="py-2 px-4 d-flex align-center">
|
||||
<!-- TODO: Make checkbox primary on indeterminate state -->
|
||||
<VCheckbox
|
||||
:model-value="selectAllEmailCheckbox"
|
||||
:indeterminate="isSelectAllEmailCheckboxIndeterminate"
|
||||
class="me-1"
|
||||
@update:model-value="selectAllCheckboxUpdate"
|
||||
/>
|
||||
<div
|
||||
class="w-100 d-flex align-center action-bar-actions gap-1"
|
||||
:style="{
|
||||
visibility:
|
||||
isSelectAllEmailCheckboxIndeterminate || selectAllEmailCheckbox
|
||||
? undefined
|
||||
: 'hidden',
|
||||
}"
|
||||
>
|
||||
<!-- Trash -->
|
||||
<IconBtn
|
||||
v-show="('filter' in route.params ? route.params.filter !== 'trashed' : true)"
|
||||
@click="handleActionClick('trash')"
|
||||
>
|
||||
<VIcon icon="ri-delete-bin-7-line" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
Delete Mail
|
||||
</VTooltip>
|
||||
</IconBtn>
|
||||
|
||||
<!-- Mark unread/read -->
|
||||
<IconBtn @click="isAllMarkRead ? handleActionClick('unread') : handleActionClick('read') ">
|
||||
<VIcon :icon="isAllMarkRead ? 'ri-mail-line' : 'ri-mail-open-line'" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{ isAllMarkRead ? 'Mark as Unread' : 'Mark as Read' }}
|
||||
</VTooltip>
|
||||
</IconBtn>
|
||||
|
||||
<!-- Move to folder -->
|
||||
<IconBtn>
|
||||
<VIcon icon="ri-folder-line" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
Folder
|
||||
</VTooltip>
|
||||
|
||||
<VMenu activator="parent">
|
||||
<VList density="compact">
|
||||
<template
|
||||
v-for="moveTo in emailMoveToFolderActions"
|
||||
:key="moveTo.title"
|
||||
>
|
||||
<VListItem
|
||||
:class="
|
||||
shallShowMoveToActionFor(moveTo.action) ? 'd-flex' : 'd-none'
|
||||
"
|
||||
class="align-center"
|
||||
href="#"
|
||||
@click="handleMoveMailsTo(moveTo.action)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VIcon
|
||||
:icon="moveTo.icon"
|
||||
class="me-2"
|
||||
size="20"
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="text-capitalize">
|
||||
{{ moveTo.action }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
|
||||
<!-- Update labels -->
|
||||
<IconBtn>
|
||||
<VIcon icon="ri-price-tag-3-line" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
Label
|
||||
</VTooltip>
|
||||
|
||||
<VMenu activator="parent">
|
||||
<VList density="compact">
|
||||
<VListItem
|
||||
v-for="label in labels"
|
||||
:key="label.title"
|
||||
href="#"
|
||||
@click="updateEmailLabels(selectedEmails, label.title)"
|
||||
>
|
||||
<template #prepend>
|
||||
<VBadge
|
||||
inline
|
||||
:color="resolveLabelColor(label.title)"
|
||||
dot
|
||||
/>
|
||||
</template>
|
||||
<VListItemTitle class="ms-2 text-capitalize">
|
||||
{{ label.title }}
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VMenu>
|
||||
</IconBtn>
|
||||
</div>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<IconBtn
|
||||
class="me-1"
|
||||
@click="fetchEmails"
|
||||
>
|
||||
<VIcon icon="ri-refresh-line" />
|
||||
</IconBtn>
|
||||
|
||||
<MoreBtn />
|
||||
</div>
|
||||
<VDivider />
|
||||
<!-- 👉 Emails list -->
|
||||
<PerfectScrollbar
|
||||
tag="ul"
|
||||
:options="{ wheelPropagation: false }"
|
||||
class="email-list"
|
||||
>
|
||||
<li
|
||||
v-for="email in emails"
|
||||
v-show="emails?.length"
|
||||
:key="email.id"
|
||||
class="email-item d-flex align-center pa-4 gap-2 cursor-pointer"
|
||||
:class="[{ 'email-read': email.isRead }]"
|
||||
@click="openEmail(email)"
|
||||
>
|
||||
<VCheckbox
|
||||
:model-value="selectedEmails.includes(email.id)"
|
||||
class="flex-shrink-0"
|
||||
@update:model-value="toggleSelectedEmail(email.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<IconBtn
|
||||
:color="email.isStarred ? 'warning' : 'default'"
|
||||
@click.stop=" handleActionClick(email.isStarred ? 'unstar' : 'star', [email.id])"
|
||||
>
|
||||
<VIcon icon="ri-star-line" />
|
||||
</IconBtn>
|
||||
<VAvatar size="32">
|
||||
<VImg
|
||||
:src="email.from.avatar"
|
||||
:alt="email.from.name"
|
||||
/>
|
||||
</VAvatar>
|
||||
<h6 class="text-h6">
|
||||
{{ email.from.name }}
|
||||
</h6>
|
||||
<span class="text-body-2 truncate">{{ email.subject }}</span>
|
||||
|
||||
<VSpacer />
|
||||
|
||||
<!-- 👉 Email meta -->
|
||||
<div
|
||||
class="email-meta align-center gap-2"
|
||||
:class="$vuetify.display.xs ? 'd-none' : ''"
|
||||
>
|
||||
<VIcon
|
||||
v-for="label in email.labels"
|
||||
:key="label"
|
||||
icon="ri-circle-fill"
|
||||
size="10"
|
||||
:color="resolveLabelColor(label)"
|
||||
/>
|
||||
|
||||
<span class="text-sm text-disabled">
|
||||
{{ formatDateToMonthShort(email.time) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 👉 Email actions -->
|
||||
<div class="email-actions d-none">
|
||||
<IconBtn @click.stop="handleActionClick('trash', [email.id])">
|
||||
<VIcon icon="ri-delete-bin-7-line" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
Delete Mail
|
||||
</VTooltip>
|
||||
</IconBtn>
|
||||
<IconBtn
|
||||
class="mx-2"
|
||||
@click.stop=" handleActionClick(email.isRead ? 'unread' : 'read', [email.id])"
|
||||
>
|
||||
<VIcon :icon="email.isRead ? 'ri-mail-line' : 'ri-mail-open-line'" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
{{ email.isRead ? 'Mark as Unread' : 'Mark as Read' }}
|
||||
</VTooltip>
|
||||
</IconBtn>
|
||||
<IconBtn @click.stop="handleActionClick('spam', [email.id])">
|
||||
<VIcon icon="ri-error-warning-line" />
|
||||
<VTooltip
|
||||
activator="parent"
|
||||
location="top"
|
||||
>
|
||||
Move to Spam
|
||||
</VTooltip>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-show="!emails.length"
|
||||
class="py-4 px-5 text-center"
|
||||
>
|
||||
<span class="text-high-emphasis">No items found.</span>
|
||||
</li>
|
||||
</PerfectScrollbar>
|
||||
</VCard>
|
||||
<ComposeDialog
|
||||
v-show="isComposeDialogVisible"
|
||||
@close="isComposeDialogVisible = false"
|
||||
/>
|
||||
</VMain>
|
||||
</VLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@use "@styles/variables/vuetify.scss";
|
||||
@use "@core-scss/base/mixins.scss";
|
||||
|
||||
// ℹ️ Remove border. Using variant plain cause UI issue, caret isn't align in center
|
||||
.email-search {
|
||||
.v-field__outline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.email-app-layout {
|
||||
border-radius: vuetify.$card-border-radius;
|
||||
|
||||
@include mixins.elevation(vuetify.$card-elevation);
|
||||
|
||||
$sel-email-app-layout: &;
|
||||
|
||||
@at-root {
|
||||
.skin--bordered {
|
||||
@include mixins.bordered-skin($sel-email-app-layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.email-content-list {
|
||||
border-end-start-radius: 0;
|
||||
border-start-start-radius: 0;
|
||||
}
|
||||
|
||||
.email-list {
|
||||
white-space: nowrap;
|
||||
|
||||
.email-item {
|
||||
transition: all 0.2s ease-in-out;
|
||||
will-change: transform, box-shadow;
|
||||
|
||||
&.email-read {
|
||||
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
|
||||
}
|
||||
|
||||
& + .email-item {
|
||||
border-block-start: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
}
|
||||
|
||||
.email-item .email-meta {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.email-item:hover {
|
||||
transform: translateY(-2px);
|
||||
|
||||
@include mixins.elevation(4);
|
||||
|
||||
// ℹ️ Don't show actions on hover on mobile & tablet devices
|
||||
@media screen and (min-width: 1280px) {
|
||||
.email-actions {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.email-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+ .email-item {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.email-compose-dialog {
|
||||
position: absolute;
|
||||
inset-block-end: 0;
|
||||
inset-inline-end: 0;
|
||||
min-inline-size: 100%;
|
||||
|
||||
@media only screen and (min-width: 800px) {
|
||||
min-inline-size: 712px;
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user