mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
354 lines
10 KiB
354 lines
10 KiB
<script lang="ts" setup> |
|
import type { ProjectType } from 'nocodb-sdk' |
|
import tinycolor from 'tinycolor2' |
|
import { breakpointsTailwind } from '@vueuse/core' |
|
import { |
|
Empty, |
|
Modal, |
|
computed, |
|
definePageMeta, |
|
extractSdkResponseErrorMsg, |
|
message, |
|
navigateTo, |
|
onBeforeMount, |
|
projectThemeColors, |
|
ref, |
|
themeV2Colors, |
|
useApi, |
|
useBreakpoints, |
|
useCopy, |
|
useGlobal, |
|
useNuxtApp, |
|
useUIPermission, |
|
} from '#imports' |
|
|
|
definePageMeta({ |
|
title: 'title.myProject', |
|
}) |
|
|
|
const { $api, $e } = useNuxtApp() |
|
|
|
const { api, isLoading } = useApi() |
|
|
|
const { isUIAllowed } = useUIPermission() |
|
|
|
const { md } = useBreakpoints(breakpointsTailwind) |
|
|
|
const filterQuery = ref('') |
|
|
|
const projects = ref<ProjectType[]>() |
|
|
|
const { appInfo } = useGlobal() |
|
|
|
const loadProjects = async () => { |
|
const response = await api.project.list({}) |
|
projects.value = response.list |
|
} |
|
|
|
const filteredProjects = computed( |
|
() => |
|
projects.value?.filter( |
|
(project) => !filterQuery.value || project.title?.toLowerCase?.().includes(filterQuery.value.toLowerCase()), |
|
) ?? [], |
|
) |
|
|
|
const deleteProject = (project: ProjectType) => { |
|
$e('c:project:delete') |
|
|
|
Modal.confirm({ |
|
title: `Do you want to delete '${project.title}' project?`, |
|
wrapClassName: 'nc-modal-project-delete', |
|
okText: 'Yes', |
|
okType: 'danger', |
|
cancelText: 'No', |
|
async onOk() { |
|
try { |
|
await api.project.delete(project.id as string) |
|
|
|
$e('a:project:delete') |
|
|
|
projects.value?.splice(projects.value?.indexOf(project), 1) |
|
} catch (e: any) { |
|
message.error(await extractSdkResponseErrorMsg(e)) |
|
} |
|
}, |
|
}) |
|
} |
|
|
|
const handleProjectColor = async (projectId: string, color: string) => { |
|
const tcolor = tinycolor(color) |
|
|
|
if (tcolor.isValid()) { |
|
const complement = tcolor.complement() |
|
|
|
const project: ProjectType = await $api.project.read(projectId) |
|
|
|
const meta = project?.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {} |
|
|
|
await $api.project.update(projectId, { |
|
color, |
|
meta: JSON.stringify({ |
|
...meta, |
|
theme: { |
|
primaryColor: color, |
|
accentColor: complement.toHex8String(), |
|
}, |
|
}), |
|
}) |
|
|
|
// Update local project |
|
const localProject = projects.value?.find((p) => p.id === projectId) |
|
|
|
if (localProject) { |
|
localProject.color = color |
|
|
|
localProject.meta = JSON.stringify({ |
|
...meta, |
|
theme: { |
|
primaryColor: color, |
|
accentColor: complement.toHex8String(), |
|
}, |
|
}) |
|
} |
|
} |
|
} |
|
|
|
const getProjectPrimary = (project: ProjectType) => { |
|
if (!project) return |
|
|
|
const meta = project.meta && typeof project.meta === 'string' ? JSON.parse(project.meta) : project.meta || {} |
|
|
|
return meta.theme?.primaryColor || themeV2Colors['royal-blue'].DEFAULT |
|
} |
|
|
|
const customRow = (record: ProjectType) => ({ |
|
onClick: async () => { |
|
if (record.type === 'docs') { |
|
await navigateTo(`/nc/doc/${record.id}`) |
|
} else { |
|
await navigateTo(`/nc/${record.id}`) |
|
} |
|
|
|
$e('a:project:open') |
|
}, |
|
class: ['group'], |
|
}) |
|
|
|
onBeforeMount(loadProjects) |
|
|
|
const { copy } = useCopy() |
|
|
|
const copyProjectMeta = async () => { |
|
const aggregatedMetaInfo = await $api.utils.aggregatedMetaInfo() |
|
copy(JSON.stringify(aggregatedMetaInfo)) |
|
message.info('Copied aggregated project meta to clipboard') |
|
} |
|
</script> |
|
|
|
<template> |
|
<div |
|
class="relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)" |
|
data-testid="projects-container" |
|
> |
|
<h1 class="flex items-center justify-center gap-2 leading-8 mb-8 mt-4"> |
|
<span class="text-4xl nc-project-page-title" @dblclick="copyProjectMeta">{{ $t('title.myProject') }}</span> |
|
</h1> |
|
|
|
<div class="flex flex-wrap gap-2 mb-6"> |
|
<a-input-search |
|
v-model:value="filterQuery" |
|
class="max-w-[250px] nc-project-page-search rounded" |
|
:placeholder="$t('activity.searchProject')" |
|
/> |
|
|
|
<a-tooltip title="Reload projects"> |
|
<div |
|
class="transition-all duration-200 h-full flex-0 flex items-center group hover:ring active:(ring ring-accent) rounded-full mt-1" |
|
:class="isLoading ? 'animate-spin ring ring-gray-200' : ''" |
|
> |
|
<MdiRefresh |
|
v-e="['a:project:refresh']" |
|
class="text-xl text-gray-500 group-hover:text-accent cursor-pointer" |
|
:class="isLoading ? '!text-primary' : ''" |
|
data-testid="projects-reload-button" |
|
@click="loadProjects" |
|
/> |
|
</div> |
|
</a-tooltip> |
|
|
|
<div class="flex-1" /> |
|
|
|
<a-dropdown v-if="isUIAllowed('projectCreate', true)" :trigger="['click']" overlay-class-name="nc-dropdown-create-project"> |
|
<button class="nc-new-project-menu mt-4 md:mt-0"> |
|
<span class="flex items-center w-full"> |
|
{{ $t('title.newProj') }} |
|
<MdiMenuDown class="menu-icon" /> |
|
</span> |
|
</button> |
|
|
|
<template #overlay> |
|
<a-menu class="!py-0 rounded"> |
|
<a-menu-item> |
|
<div |
|
v-e="['c:project:create:xcdb']" |
|
class="nc-project-menu-item group nc-create-xc-db-project" |
|
@click="navigateTo('/create')" |
|
> |
|
<MdiPlusOutline class="group-hover:text-accent" /> |
|
|
|
<div>{{ $t('activity.createProject') }}</div> |
|
</div> |
|
</a-menu-item> |
|
|
|
<a-menu-item v-if="appInfo.connectToExternalDB"> |
|
<div |
|
v-e="['c:project:create:extdb']" |
|
class="nc-project-menu-item group nc-create-external-db-project" |
|
@click="navigateTo('/create-external')" |
|
> |
|
<MdiDatabaseOutline class="group-hover:text-accent" /> |
|
|
|
<div v-html="$t('activity.createProjectExtended.extDB')" /> |
|
</div> |
|
</a-menu-item> |
|
</a-menu> |
|
</template> |
|
</a-dropdown> |
|
</div> |
|
|
|
<!-- |
|
TODO: bring back transition after fixing the bug with navigation |
|
<Transition name="layout" mode="out-in"> --> |
|
<div v-if="isLoading"> |
|
<a-skeleton /> |
|
</div> |
|
|
|
<a-table |
|
v-else |
|
:custom-row="customRow" |
|
:data-source="filteredProjects" |
|
:pagination="{ position: ['bottomCenter'] }" |
|
:table-layout="md ? 'auto' : 'fixed'" |
|
> |
|
<template #emptyText> |
|
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
|
</template> |
|
|
|
<!-- Title --> |
|
<a-table-column key="title" :title="$t('general.title')" data-index="title"> |
|
<template #default="{ text, record }"> |
|
<div class="flex items-center"> |
|
<div @click.stop> |
|
<a-menu class="!border-0 !m-0 !p-0" trigger-sub-menu-action="click"> |
|
<template v-if="isUIAllowed('projectTheme')"> |
|
<a-sub-menu key="theme" popup-class-name="custom-color"> |
|
<template #title> |
|
<div |
|
class="color-selector" |
|
:style="{ |
|
'background-color': getProjectPrimary(record), |
|
'width': '8px', |
|
'height': '100%', |
|
}" |
|
/> |
|
</template> |
|
|
|
<template #expandIcon></template> |
|
|
|
<LazyGeneralColorPicker |
|
:model-value="getProjectPrimary(record)" |
|
:colors="projectThemeColors" |
|
:row-size="9" |
|
:advanced="false" |
|
@input="handleProjectColor(record.id, $event)" |
|
/> |
|
|
|
<a-sub-menu key="pick-primary"> |
|
<template #title> |
|
<div class="nc-project-menu-item group !py-0"> |
|
<ClarityColorPickerSolid class="group-hover:text-accent" /> |
|
Custom Color |
|
</div> |
|
</template> |
|
|
|
<template #expandIcon></template> |
|
|
|
<LazyGeneralChromeWrapper @input="handleProjectColor(record.id, $event)" /> |
|
</a-sub-menu> |
|
</a-sub-menu> |
|
</template> |
|
</a-menu> |
|
</div> |
|
<div |
|
class="capitalize color-transition group-hover:text-primary !w-[400px] h-full overflow-hidden overflow-ellipsis whitespace-nowrap pl-2" |
|
> |
|
{{ text }} |
|
</div> |
|
</div> |
|
</template> |
|
</a-table-column> |
|
<!-- Actions --> |
|
|
|
<a-table-column key="id" :title="$t('labels.actions')" data-index="id"> |
|
<template #default="{ text, record }"> |
|
<div class="flex items-center gap-2"> |
|
<MdiEditOutline v-e="['c:project:edit:rename']" class="nc-action-btn" @click.stop="navigateTo(`/${text}`)" /> |
|
|
|
<MdiDeleteOutline |
|
class="nc-action-btn" |
|
:data-testid="`delete-project-${record.title}`" |
|
@click.stop="deleteProject(record)" |
|
/> |
|
</div> |
|
</template> |
|
</a-table-column> |
|
</a-table> |
|
<!-- </Transition> --> |
|
</div> |
|
</template> |
|
|
|
<style scoped> |
|
.nc-action-btn { |
|
@apply text-gray-500 group-hover:text-accent active:(ring ring-accent ring-opacity-100) cursor-pointer p-2 w-[30px] h-[30px] hover:bg-gray-300/50 rounded-full; |
|
} |
|
|
|
.nc-new-project-menu { |
|
@apply cursor-pointer z-1 relative color-transition rounded-md px-3 py-2 text-white; |
|
|
|
&::after { |
|
@apply ring-opacity-100 rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary bg-opacity-100; |
|
content: ''; |
|
z-index: -1; |
|
} |
|
|
|
&:hover::after { |
|
@apply transform scale-110; |
|
} |
|
} |
|
|
|
:deep(.ant-table-cell) { |
|
@apply py-1; |
|
} |
|
|
|
:deep(.ant-table-row) { |
|
@apply cursor-pointer; |
|
} |
|
|
|
:deep(.ant-table) { |
|
@apply min-h-[428px]; |
|
} |
|
|
|
:deep(.ant-menu-submenu-title) { |
|
@apply !p-0 !mr-1 !my-0 !h-5; |
|
} |
|
|
|
.color-selector:hover { |
|
filter: brightness(1.5); |
|
} |
|
</style> |
|
|
|
<style> |
|
.custom-color .ant-menu-submenu-title { |
|
height: auto !important; |
|
} |
|
</style>
|
|
|