Browse Source

fix: missing scrollbar in shared view

closes #6276

Signed-off-by: Pranav C <pranavxc@gmail.com>
pull/6287/head
Pranav C 1 year ago
parent
commit
6123727deb
  1. 19
      packages/nc-gui/pages/index-old/apps.vue
  2. 11
      packages/nc-gui/pages/index-old/index.vue
  3. 110
      packages/nc-gui/pages/index-old/index/[projectId].vue
  4. 643
      packages/nc-gui/pages/index-old/index/create-external.vue
  5. 171
      packages/nc-gui/pages/index-old/index/create.vue
  6. 354
      packages/nc-gui/pages/index-old/index/index-old.vue
  7. 306
      packages/nc-gui/pages/index-old/index/index.vue
  8. 162
      packages/nc-gui/pages/index-old/index/user.vue
  9. 11
      packages/nc-gui/pages/index.vue
  10. 2
      packages/nc-gui/pages/index/[typeOrId].vue
  11. 4
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId].vue
  12. 21
      packages/nc-gui/pages/ws/[typeOrId].vue
  13. 44
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]].vue
  14. 155
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index.vue
  15. 157
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index.vue
  16. 38
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/[type]/[viewId]/[[viewTitle]].vue
  17. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/auth.vue
  18. 17
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/erd/[baseId].vue
  19. 133
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/index.vue
  20. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/sql/[baseId].vue
  21. 16
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/base/[baseId].vue
  22. 3
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/cowriter/[projectId]/[...slugs].vue
  23. 126
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId].vue
  24. 148
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index.vue
  25. 141
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/index.vue
  26. 472
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/survey.vue
  27. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/gallery/[viewId]/index.vue
  28. 22
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/index.vue
  29. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/kanban/[viewId]/index.vue
  30. 35
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/map/[viewId]/index.vue
  31. 59
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/shared/[erdUuid]/index.vue
  32. 36
      packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/view/[viewId].vue
  33. 6
      packages/nocodb/src/services/telemetry.service.ts

19
packages/nc-gui/pages/index-old/apps.vue

@ -1,19 +0,0 @@
<script lang="ts" setup>
import { Role, definePageMeta } from '#imports'
definePageMeta({
requiresAuth: true,
allowedRoles: [Role.Super],
title: 'title.appStore',
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
</script>
<template>
<div class="p-10 h-full overflow-auto">
<h1 class="text-3xl text-center mb-11 nc-app-store-title">{{ $t('title.appStore') }}</h1>
<LazyDashboardSettingsAppStore />
</div>
</template>

11
packages/nc-gui/pages/index-old/index.vue

@ -1,11 +0,0 @@
<script lang="ts" setup>
import { useSidebar } from '#imports'
definePageMeta({
hideHeader: true,
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
</script>
<template>
<NuxtPage :transition="false" />
</template>

110
packages/nc-gui/pages/index-old/index/[projectId].vue

@ -1,110 +0,0 @@
<script lang="ts" setup>
import type { Form } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import type { VNodeRef } from '@vue/runtime-core'
import type { RuleObject } from 'ant-design-vue/es/form'
import {
extractSdkResponseErrorMsg,
iconMap,
message,
navigateTo,
projectTitleValidator,
reactive,
ref,
storeToRefs,
useProject,
useRoute,
} from '#imports'
const route = useRoute()
const projectStore = useProject()
const { loadProject, updateProject } = projectStore
const { project, isLoading } = storeToRefs(projectStore)
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
] as RuleObject[]
const form = ref<typeof Form>()
const formState = reactive<Partial<ProjectType>>({
title: '',
})
const renameProject = async () => {
try {
await updateProject(formState)
navigateTo(`/nc/${route.params.projectId}`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
onBeforeMount(async () => {
await loadProject(false)
formState.title = project.value?.title
})
const focus: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</script>
<template>
<div
class="update-project relative flex-auto flex flex-col justify-center gap-2 p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.editProject') }}</h1>
<a-skeleton v-if="isLoading" />
<a-form
v-else
ref="form"
:model="formState"
name="basic"
layout="vertical"
class="lg:max-w-3/4 w-full !mx-auto"
no-style
autocomplete="off"
@finish="renameProject"
>
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules">
<a-input :ref="focus" v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<button v-e="['a:project:edit:rename']" type="submit" class="scaling-btn bg-opacity-100">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.edit') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss" scoped>
.update-project {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
}
</style>

643
packages/nc-gui/pages/index-old/index/create-external.vue

@ -1,643 +0,0 @@
<script lang="ts" setup>
import type { ProjectType } from 'nocodb-sdk'
import type { SelectHandler } from 'ant-design-vue/es/vc-select/Select'
import type { DefaultConnection, ProjectCreateForm } from '#imports'
import {
CertTypes,
ClientType,
Form,
Modal,
SSLUsage,
clientTypes as _clientTypes,
computed,
extractSdkResponseErrorMsg,
fieldRequiredValidator,
generateUniqueName,
getDefaultConnectionConfig,
getTestDatabaseName,
iconMap,
message,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
readFile,
ref,
useApi,
useI18n,
useNuxtApp,
useSidebar,
watch,
} from '#imports'
const useForm = Form.useForm
const testSuccess = ref(false)
const form = ref<typeof Form>()
const { api, isLoading } = useApi({ useGlobalInstance: true })
const { $e } = useNuxtApp()
useSidebar('nc-left-sidebar', { hasSidebar: false })
const { t } = useI18n()
const formState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const customFormState = ref<ProjectCreateForm>({
title: '',
dataSource: { ...getDefaultConnectionConfig(ClientType.MYSQL) },
inflection: {
inflectionColumn: 'none',
inflectionTable: 'none',
},
sslUse: SSLUsage.No,
extraParameters: [],
})
const clientTypes = computed(() => {
return _clientTypes.filter((type) => {
// return appInfo.value?.ee || type.value !== ClientType.SNOWFLAKE
return type.value !== ClientType.SNOWFLAKE
})
})
const validators = computed(() => {
let clientValidations: Record<string, any[]> = {
'dataSource.connection.host': [fieldRequiredValidator()],
'dataSource.connection.port': [fieldRequiredValidator()],
'dataSource.connection.user': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
}
switch (formState.value.dataSource.client) {
case ClientType.SQLITE:
clientValidations = {
'dataSource.connection.connection.filename': [fieldRequiredValidator()],
}
break
case ClientType.SNOWFLAKE:
clientValidations = {
'dataSource.connection.account': [fieldRequiredValidator()],
'dataSource.connection.username': [fieldRequiredValidator()],
'dataSource.connection.password': [fieldRequiredValidator()],
'dataSource.connection.warehouse': [fieldRequiredValidator()],
'dataSource.connection.database': [fieldRequiredValidator()],
'dataSource.connection.schema': [fieldRequiredValidator()],
}
break
case ClientType.PG:
case ClientType.MSSQL:
clientValidations['dataSource.searchPath.0'] = [fieldRequiredValidator()]
break
}
return {
'title': [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
],
'extraParameters': [extraParameterValidator],
'dataSource.client': [fieldRequiredValidator()],
...clientValidations,
}
})
const { validate, validateInfos } = useForm(formState.value, validators)
const populateName = (v: string) => {
formState.value.dataSource.connection.database = `${v.trim()}_noco`
}
const onClientChange = () => {
formState.value.dataSource = { ...getDefaultConnectionConfig(formState.value.dataSource.client) }
populateName(formState.value.title)
}
const onSSLModeChange = ((mode: SSLUsage) => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
switch (mode) {
case SSLUsage.No:
delete connection.ssl
break
case SSLUsage.Allowed:
connection.ssl = 'no-verify'
break
case SSLUsage.Preferred:
connection.ssl = 'true'
break
default:
connection.ssl = {
ca: '',
cert: '',
key: '',
}
break
}
}
}) as SelectHandler
const updateSSLUse = () => {
if (formState.value.dataSource.client !== ClientType.SQLITE) {
const connection = formState.value.dataSource.connection as DefaultConnection
if (connection.ssl) {
if (typeof connection.ssl === 'string') {
formState.value.sslUse = SSLUsage.Allowed
} else {
formState.value.sslUse = SSLUsage.Preferred
}
} else {
formState.value.sslUse = SSLUsage.No
}
}
}
const addNewParam = () => {
formState.value.extraParameters.push({ key: '', value: '' })
}
const removeParam = (index: number) => {
formState.value.extraParameters.splice(index, 1)
}
const inflectionTypes = ['camelize', 'none']
const importURL = ref('')
const configEditDlg = ref(false)
const importURLDlg = ref(false)
const caFileInput = ref<HTMLInputElement>()
const keyFileInput = ref<HTMLInputElement>()
const certFileInput = ref<HTMLInputElement>()
const onFileSelect = (key: CertTypes, el?: HTMLInputElement) => {
if (!el) return
readFile(el, (content) => {
if ('ssl' in formState.value.dataSource.connection && typeof formState.value.dataSource.connection.ssl === 'object')
formState.value.dataSource.connection.ssl[key] = content ?? ''
})
}
const sslFilesRequired = computed(
() => !!formState.value.sslUse && formState.value.sslUse !== SSLUsage.No && formState.value.sslUse !== SSLUsage.Allowed,
)
function getConnectionConfig() {
const extraParameters = Object.fromEntries(new Map(formState.value.extraParameters.map((object) => [object.key, object.value])))
const connection = {
...formState.value.dataSource.connection,
...extraParameters,
}
if ('ssl' in connection && connection.ssl) {
if (
formState.value.sslUse === SSLUsage.No ||
(typeof connection.ssl === 'object' && Object.values(connection.ssl).every((v) => v === null || v === undefined))
) {
delete connection.ssl
}
}
return connection
}
const focusInvalidInput = () => {
form.value?.$el.querySelector('.ant-form-item-explain-error')?.parentNode?.parentNode?.querySelector('input')?.focus()
}
const createProject = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
try {
const connection = getConnectionConfig()
const config = { ...formState.value.dataSource, connection }
const result = (await api.project.create({
title: formState.value.title,
bases: [
{
type: formState.value.dataSource.client,
config,
inflection_column: formState.value.inflection.inflectionColumn,
inflection_table: formState.value.inflection.inflectionTable,
},
],
external: true,
})) as Partial<ProjectType>
$e('a:project:create:extdb')
await navigateTo(`/nc/${result.id}`)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const testConnection = async () => {
try {
await validate()
} catch (e) {
focusInvalidInput()
return
}
$e('a:project:create:extdb:test-connection', [])
try {
if (formState.value.dataSource.client === ClientType.SQLITE) {
testSuccess.value = true
} else {
const connection = getConnectionConfig()
connection.database = getTestDatabaseName(formState.value.dataSource)!
const testConnectionConfig = {
...formState.value.dataSource,
connection,
}
const result = await api.utils.testConnection(testConnectionConfig)
if (result.code === 0) {
testSuccess.value = true
Modal.confirm({
title: t('msg.info.dbConnected'),
icon: null,
type: 'success',
okText: t('activity.OkSaveProject'),
okType: 'primary',
cancelText: t('general.cancel'),
onOk: createProject,
})
} else {
testSuccess.value = false
message.error(`${t('msg.error.dbConnectionFailed')} ${result.message}`)
}
}
} catch (e: any) {
testSuccess.value = false
message.error(await extractSdkResponseErrorMsg(e))
}
}
const handleImportURL = async () => {
if (!importURL.value || importURL.value === '') return
const connectionConfig = await api.utils.urlToConfig({ url: importURL.value })
if (connectionConfig) {
formState.value.dataSource.client = connectionConfig.client
formState.value.dataSource.connection = { ...connectionConfig.connection }
} else {
message.error(t('msg.error.invalidURL'))
}
importURLDlg.value = false
updateSSLUse()
}
const handleEditJSON = () => {
customFormState.value = { ...formState.value }
configEditDlg.value = true
}
const handleOk = () => {
formState.value = { ...customFormState.value }
configEditDlg.value = false
updateSSLUse()
}
// reset test status on config change
watch(
() => formState.value.dataSource,
() => (testSuccess.value = false),
{ deep: true },
)
// populate database name based on title
watch(
() => formState.value.title,
(v) => populateName(v),
)
// select and focus title field on load
onMounted(async () => {
formState.value.title = await generateUniqueName()
await nextTick(() => {
// todo: replace setTimeout and follow better approach
setTimeout(() => {
const input = form.value?.$el?.querySelector('input[type=text]')
input.setSelectionRange(0, formState.value.title.length)
input.focus()
}, 500)
})
})
</script>
<template>
<div
class="create-external relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="external-project-create-form"
layout="horizontal"
no-style
:label-col="{ span: 8 }"
>
<a-form-item :label="$t('placeholder.projName')" v-bind="validateInfos.title">
<a-input v-model:value="formState.title" class="nc-extdb-proj-name" />
</a-form-item>
<a-form-item :label="$t('labels.dbType')" v-bind="validateInfos['dataSource.client']">
<a-select
v-model:value="formState.dataSource.client"
class="nc-extdb-db-type"
dropdown-class-name="nc-dropdown-ext-db-type"
@change="onClientChange"
>
<a-select-option v-for="client in clientTypes" :key="client.value" :value="client.value">
{{ client.text }}
</a-select-option>
</a-select>
</a-form-item>
<!-- SQLite File -->
<a-form-item
v-if="formState.dataSource.client === ClientType.SQLITE"
:label="$t('labels.sqliteFile')"
v-bind="validateInfos['dataSource.connection.connection.filename']"
>
<a-input v-model:value="formState.dataSource.connection.connection.filename" />
</a-form-item>
<template v-else>
<!-- Host Address -->
<a-form-item :label="$t('labels.hostAddress')" v-bind="validateInfos['dataSource.connection.host']">
<a-input v-model:value="formState.dataSource.connection.host" class="nc-extdb-host-address" />
</a-form-item>
<!-- Port Number -->
<a-form-item :label="$t('labels.port')" v-bind="validateInfos['dataSource.connection.port']">
<a-input-number v-model:value="formState.dataSource.connection.port" class="!w-full nc-extdb-host-port" />
</a-form-item>
<!-- Username -->
<a-form-item :label="$t('labels.username')" v-bind="validateInfos['dataSource.connection.user']">
<a-input v-model:value="formState.dataSource.connection.user" class="nc-extdb-host-user" />
</a-form-item>
<!-- Password -->
<a-form-item :label="$t('labels.password')">
<a-input-password v-model:value="formState.dataSource.connection.password" class="nc-extdb-host-password" />
</a-form-item>
<!-- Database -->
<a-form-item :label="$t('labels.database')" v-bind="validateInfos['dataSource.connection.database']">
<!-- Database : create if not exists -->
<a-input
v-model:value="formState.dataSource.connection.database"
:placeholder="$t('labels.dbCreateIfNotExists')"
class="nc-extdb-host-database"
/>
</a-form-item>
<!-- Schema name -->
<a-form-item
v-if="[ClientType.MSSQL, ClientType.PG].includes(formState.dataSource.client) && formState.dataSource.searchPath"
:label="$t('labels.schemaName')"
v-bind="validateInfos['dataSource.searchPath.0']"
>
<a-input v-model:value="formState.dataSource.searchPath[0]" />
</a-form-item>
<a-collapse ghost expand-icon-position="right" class="!mt-6">
<a-collapse-panel key="1">
<template #header>
<div class="flex items-center gap-2">
<!-- Use Connection URL -->
<a-button type="default" class="nc-extdb-btn-import-url" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }}
</a-button>
<span>{{ $t('title.advancedParameters') }}</span>
</div>
</template>
<a-form-item label="SSL mode">
<a-select v-model:value="formState.sslUse" dropdown-class-name="nc-dropdown-ssl-mode" @select="onSSLModeChange">
<a-select-option v-for="opt in Object.values(SSLUsage)" :key="opt" :value="opt">{{ opt }} </a-select-option>
</a-select>
</a-form-item>
<a-form-item label="SSL keys">
<div class="flex gap-2">
<a-tooltip placement="top">
<!-- Select .cert file -->
<template #title>
<span>{{ $t('tooltip.clientCert') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="certFileInput?.click()">
{{ $t('labels.clientCert') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select .key file -->
<template #title>
<span>{{ $t('tooltip.clientKey') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="keyFileInput?.click()">
{{ $t('labels.clientKey') }}
</a-button>
</a-tooltip>
<a-tooltip placement="top">
<!-- Select CA file -->
<template #title>
<span>{{ $t('tooltip.clientCA') }}</span>
</template>
<a-button :disabled="!sslFilesRequired" class="shadow" @click="caFileInput?.click()">
{{ $t('labels.serverCA') }}
</a-button>
</a-tooltip>
</div>
</a-form-item>
<input ref="caFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.ca, caFileInput)" />
<input ref="certFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.cert, certFileInput)" />
<input ref="keyFileInput" type="file" class="!hidden" @change="onFileSelect(CertTypes.key, keyFileInput)" />
<a-divider />
<!-- Extra connection parameters -->
<a-form-item class="mb-2" :label="$t('labels.extraConnectionParameters')" v-bind="validateInfos.extraParameters">
<a-card>
<div v-for="(item, index) of formState.extraParameters" :key="index">
<div class="flex py-1 items-center gap-1">
<a-input v-model:value="item.key" />
<span>:</span>
<a-input v-model:value="item.value" />
<component
:is="iconMap.close"
:style="{ 'font-size': '1.5em', 'color': 'red' }"
@click="removeParam(index)"
/>
</div>
</div>
<a-button type="dashed" class="w-full caption mt-2" @click="addNewParam">
<div class="flex items-center justify-center">
<component :is="iconMap.plus" />
</div>
</a-button>
</a-card>
</a-form-item>
<a-divider />
<a-form-item :label="$t('labels.inflection.tableName')">
<a-select
v-model:value="formState.inflection.inflectionTable"
dropdown-class-name="nc-dropdown-inflection-table-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('labels.inflection.columnName')">
<a-select
v-model:value="formState.inflection.inflectionColumn"
dropdown-class-name="nc-dropdown-inflection-column-name"
>
<a-select-option v-for="type in inflectionTypes" :key="type" :value="type">{{ type }}</a-select-option>
</a-select>
</a-form-item>
<div class="flex justify-end">
<a-button class="!shadow-md" @click="handleEditJSON()">
<!-- Edit connection JSON -->
{{ $t('activity.editConnJson') }}
</a-button>
</div>
</a-collapse-panel>
</a-collapse>
</template>
<a-form-item class="flex justify-center !mt-5">
<div class="flex justify-center gap-2">
<a-button type="primary" ghost class="nc-extdb-btn-test-connection" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>
<a-button type="primary" :disabled="!testSuccess" class="nc-extdb-btn-submit !shadow" @click="createProject">
{{ $t('general.submit') }}
</a-button>
</div>
</a-form-item>
</a-form>
<a-modal
v-model:visible="configEditDlg"
:class="{ active: configEditDlg }"
:title="$t('activity.editConnJson')"
width="600px"
wrap-class-name="nc-modal-edit-connection-json"
@ok="handleOk"
>
<LazyMonacoEditor v-if="configEditDlg" v-model="customFormState" class="h-[400px] w-full" />
</a-modal>
<!-- Use Connection URL -->
<a-modal
v-model:visible="importURLDlg"
:class="{ active: importURLDlg }"
:title="$t('activity.useConnectionUrl')"
width="600px"
:ok-text="$t('general.ok')"
:cancel-text="$t('general.cancel')"
wrap-class-name="nc-modal-connection-url"
@ok="handleImportURL"
>
<a-input v-model:value="importURL" />
</a-modal>
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-collapse-header) {
@apply !pr-10 !-mt-4 text-right justify-end;
}
:deep(.ant-collapse-content-box) {
@apply !px-0;
}
:deep(.ant-form-item-explain-error) {
@apply !text-xs;
}
:deep(.ant-form-item) {
@apply mb-2;
}
:deep(.ant-form-item-with-help .ant-form-item-explain) {
@apply !min-h-0;
}
.create-external {
:deep(.ant-input-affix-wrapper),
:deep(.ant-input),
:deep(.ant-select) {
@apply !appearance-none border-1 border-solid rounded;
}
:deep(.ant-input-password) {
input {
@apply !border-none my-0;
}
}
}
</style>

171
packages/nc-gui/pages/index-old/index/create.vue

@ -1,171 +0,0 @@
<script lang="ts" setup>
import type { Form, Input } from 'ant-design-vue'
import type { RuleObject } from 'ant-design-vue/es/form'
import type { VNodeRef } from '@vue/runtime-core'
import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import {
NcProjectType,
extractSdkResponseErrorMsg,
generateUniqueName,
iconMap,
message,
navigateTo,
nextTick,
onMounted,
projectTitleValidator,
reactive,
ref,
useApi,
useCommandPalette,
useGlobal,
useNuxtApp,
useProject,
useRoute,
useSidebar,
useTable,
} from '#imports'
const { $e } = useNuxtApp()
useProjects()
const { loadTables } = useProject()
useTable(async (_) => {
await loadTables()
})
const { navigateToProject } = useGlobal()
const { refreshCommandPalette } = useCommandPalette()
const { api, isLoading } = useApi({ useGlobalInstance: true })
useSidebar('nc-left-sidebar', { hasSidebar: false })
const nameValidationRules = [
{
required: true,
message: 'Project name is required',
},
projectTitleValidator,
] as RuleObject[]
const form = ref<typeof Form>()
const formState = reactive({
title: '',
})
const route = useRoute()
const creating = ref(false)
const createProject = async () => {
$e('a:project:create:xcdb')
try {
// pick a random color from array and assign to project
const color = projectThemeColors[Math.floor(Math.random() * 1000) % projectThemeColors.length]
const tcolor = tinycolor(color)
const complement = tcolor.complement()
const { getBaseUrl } = useGlobal()
// todo: provide proper project type
creating.value = true
const result = (await api.project.create(
{
title: formState.title,
color,
meta: JSON.stringify({
theme: {
primaryColor: color,
accentColor: complement.toHex8String(),
},
}),
},
{
baseURL: getBaseUrl(route.query.workspaceId as string),
},
)) as Partial<ProjectType>
refreshCommandPalette()
navigateToProject({
projectId: result.id!,
workspaceId: route.query.workspaceId as string,
type: NcProjectType.DB,
})
} catch (e: any) {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
} finally {
creating.value = false
}
}
const input: VNodeRef = ref<typeof Input>()
onMounted(async () => {
formState.title = await generateUniqueName()
await nextTick()
input.value?.$el?.focus()
input.value?.$el?.select()
})
</script>
<template>
<NuxtLayout name="new">
<div class="mt-20">
<div
class="min-w-2/4 xl:max-w-2/4 w-full mx-auto create relative flex flex-col justify-center gap-2 w-full p-8 md:(bg-white rounded-lg border-1 border-gray-200 shadow)"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="navigateTo('/')"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.createProject') }}</h1>
<a-form
ref="form"
:model="formState"
name="basic"
layout="vertical"
class="lg:max-w-3/4 w-full !mx-auto"
no-style
autocomplete="off"
@finish="createProject"
>
<a-form-item :label="$t('labels.projName')" name="title" :rules="nameValidationRules" class="m-10">
<a-input ref="input" v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item>
<div class="text-center">
<a-spin v-if="creating" spinning />
<button v-else class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.create') }}
</span>
</button>
</div>
</a-form>
</div>
</div>
</NuxtLayout>
</template>
<style lang="scss">
.create {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
}
</style>

354
packages/nc-gui/pages/index-old/index/index-old.vue

@ -1,354 +0,0 @@
<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>

306
packages/nc-gui/pages/index-old/index/index.vue

@ -1,306 +0,0 @@
<script setup lang="ts">
import type { Menu } from 'ant-design-vue'
import { nextTick } from '@vue/runtime-core'
import { WorkspaceStatus } from 'nocodb-sdk'
import { computed, onMounted, storeToRefs, useRouter, useSidebar, useWorkspace } from '#imports'
const router = useRouter()
const { isUIAllowed } = useUIPermission()
const workspaceStore = useWorkspace()
const { deleteWorkspace: _deleteWorkspace, loadWorkspaces, populateWorkspace } = workspaceStore
const { workspacesList, activeWorkspace, activePage, collaborators, activeWorkspaceId } = storeToRefs(workspaceStore)
const projectsStore = useProjects()
const { loadProjects } = projectsStore
const route = router.currentRoute
const selectedWorkspaceIndex = computed<number[]>({
get() {
const index = workspacesList?.value?.findIndex((workspace) => workspace.id === (route.value.query?.workspaceId as string))
return activePage?.value === 'workspace' ? [index === -1 ? 0 : index] : []
},
set(index: number[]) {
if (index?.length) {
router.push({ query: { workspaceId: workspacesList.value?.[index[0]]?.id, page: 'workspace' } })
} else {
router.push({ query: {} })
}
},
})
// create a new sidebar state
const { toggle, toggleHasSidebar } = useSidebar('nc-left-sidebar', { hasSidebar: true, isOpen: true })
const isCreateDlgOpen = ref(false)
const isCreateProjectOpen = ref(false)
const menuEl = ref<typeof Menu | null>(null)
onMounted(async () => {
toggle(true)
toggleHasSidebar(true)
loadProjects('recent')
})
watch(
() => route.value.query.workspaceId,
async (newId, oldId) => {
if (!newId || (oldId !== newId && oldId)) {
projectsStore.clearProjects()
collaborators.value = []
}
if (newId) {
populateWorkspace()
}
},
{
immediate: true,
},
)
const tab = computed({
get() {
return route.value.query?.tab ?? 'projects'
},
set(tab: string) {
router.push({ query: { ...route.value.query, tab } })
},
})
const projectListType = computed(() => {
switch (activePage.value) {
case 'recent':
return 'Recent'
case 'shared':
return 'Shared With Me'
case 'starred':
return 'Starred'
default:
return '='
}
})
watch(activeWorkspaceId, async () => {
if (activeWorkspace.value?.status !== WorkspaceStatus.CREATED) return
await loadProjects(activePage.value)
})
watch(
() => activeWorkspace.value?.status,
async (status) => {
if (status === WorkspaceStatus.CREATED) {
await loadProjects()
}
},
)
</script>
<template>
<NuxtLayout name="new">
<template #sidebar>
<div class="h-full flex flex-col min-h-[400px] overflow-auto">
<div class="nc-workspace-group overflow-auto mt-8.5">
<div class="flex text-sm font-medium text-gray-400 mx-4.5 mb-2">All Projects</div>
<div
class="nc-workspace-group-item"
:class="{ active: activePage === 'recent' }"
@click="
navigateTo({
query: {
page: 'recent',
},
})
"
>
<IcOutlineAccessTime class="nc-icon" />
<span>Recent</span>
</div>
<div
class="nc-workspace-group-item"
:class="{ active: activePage === 'shared' }"
@click="
navigateTo({
query: {
page: 'shared',
},
})
"
>
<MaterialSymbolsGroupOutlineRounded class="nc-icon" />
<span>Shared with me</span>
</div>
<div
v-if="false"
class="nc-workspace-group-item"
:class="{ active: activePage === 'starred' }"
@click="
navigateTo({
query: {
page: 'starred',
},
})
"
>
<IcRoundStarBorder class="nc-icon !h-5" />
<span>Starred</span>
</div>
</div>
</div>
</template>
<div class="h-full nc-workspace-container overflow-x-hidden" style="width: calc(100vw - 250px)">
<div class="h-full flex flex-col px-6 mt-3">
<div class="flex items-center gap-2 mb-5.5 mt-4 text-xl ml-5.5">
<h2 class="text-3xl font-weight-bold tracking-[0.5px] mb-0">
{{ projectListType }}
</h2>
<div class="flex-grow min-w-10"></div>
<WorkspaceCreateProjectBtn
v-if="isUIAllowed('projectCreate', false) && tab === 'projects'"
v-model:is-open="isCreateProjectOpen"
class="mt-0.75"
type="primary"
:active-workspace-id="activeWorkspace?.id"
modal
>
<div
class="gap-x-2 flex flex-row w-full items-center rounded py-1.5 pl-2 pr-2.75"
:class="{
'!bg-opacity-10': isCreateProjectOpen,
}"
>
<MdiPlus class="!h-4.2" />
<div class="flex">{{ $t('title.newProj') }}</div>
</div>
</WorkspaceCreateProjectBtn>
</div>
<WorkspaceProjectList class="min-h-20 grow" />
</div>
</div>
</NuxtLayout>
</template>
<style scoped lang="scss">
.nc-workspace-avatar {
@apply min-w-6 h-6 rounded-[6px] flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}
.nc-workspace-list {
.nc-workspace-list-item {
@apply flex gap-2 items-center;
}
:deep(.ant-menu-item) {
@apply relative;
& .color-band {
@apply opacity-0 absolute w-2 h-7 -left-1 top-[6px] bg-[#4351E8] rounded-[99px] trasition-opacity;
}
}
:deep(.ant-menu-item-selected, .ant-menu-item-active) .color-band {
@apply opacity-100;
}
.nc-workspace-menu,
.nc-workspace-drag-icon {
@apply opacity-0 transition-opactity min-w-4 text-gray-500;
}
.nc-workspace-drag-icon {
@apply cursor-move;
}
:deep(.ant-menu-item:hover) {
.nc-workspace-menu,
.nc-workspace-drag-icon {
@apply opacity-100;
}
}
}
:deep(.nc-workspace-list .ant-menu-item) {
@apply !my-0;
}
.nc-workspace-group {
.nc-workspace-group-item {
&:hover {
@apply bg-primary bg-opacity-3 text-primary;
}
&.active {
@apply bg-primary bg-opacity-8 text-primary font-weight-bold;
}
@apply h-[40px] p-4 pl-3 flex items-center gap-2 cursor-pointer;
.nc-icon {
@apply w-6;
}
}
}
// todo: apply globally at windicss level
.nc-root {
@apply text-[#4B5563];
}
.nc-collab-list {
.nc-collab-list-item {
@apply flex gap-2 py-2 px-4 items-center;
.nc-collab-avatar {
@apply w-6 h-6 rounded-full flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}
}
}
:deep(.ant-tabs-nav-list) {
@apply !ml-6;
}
.ant-layout-header {
@apply !h-20 bg-transparent;
border-bottom: 1px solid #f5f5f5;
}
.nc-quick-action-wrapper {
@apply relative;
input {
@apply h-10 w-60 bg-gray-100 rounded-md pl-9 pr-5 mr-2;
}
.nc-quick-action-icon {
@apply absolute left-2 top-6;
}
.nc-quick-action-shortcut {
@apply text-gray-400 absolute right-4 top-0;
}
}
:deep(.ant-tabs-tab:not(ant-tabs-tab-active)) {
@apply !text-gray-500;
}
:deep(.ant-tabs-content) {
@apply !min-h-25 !h-full;
}
:deep(.ant-tabs-nav) {
@apply !mb-0;
}
</style>

162
packages/nc-gui/pages/index-old/index/user.vue

@ -1,162 +0,0 @@
<script lang="ts" setup>
import type { RuleObject } from 'ant-design-vue/es/form'
import { iconMap, message, navigateTo, reactive, ref, useApi, useGlobal, useI18n, useRouter } from '#imports'
const router = useRouter()
const { api, error, isLoading } = useApi({ useGlobalInstance: true })
const { t } = useI18n()
const { signOut } = useGlobal()
const formValidator = ref()
const form = reactive({
currentPassword: '',
password: '',
passwordRepeat: '',
})
const formRules: Record<string, RuleObject[]> = {
currentPassword: [
// Current password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
passwordRepeat: [
// PasswordRepeat is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
// Passwords match
{
validator: (_: unknown, _v: string) => {
return new Promise((resolve, reject) => {
if (form.password === form.passwordRepeat) return resolve()
reject(new Error(t('msg.error.signUpRules.passwdMismatch')))
})
},
message: t('msg.error.signUpRules.passwdMismatch'),
},
],
}
const passwordChange = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error.value = null
await api.auth.passwordChange({
currentPassword: form.currentPassword,
newPassword: form.password,
})
message.success(t('msg.success.passwordChanged'))
await signOut()
navigateTo('/signin')
}
const resetError = () => {
if (error.value) error.value = null
}
</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="user-change-password"
>
<LazyGeneralNocoIcon class="color-transition hover:(ring ring-accent)" :animate="isLoading" />
<div
class="color-transition transform group absolute top-5 left-5 text-4xl rounded-full cursor-pointer"
@click="() => router.back()"
>
<component :is="iconMap.chevronLeft" class="text-black group-hover:(text-accent scale-110)" />
</div>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('activity.changePwd') }}</h1>
<a-form
ref="formValidator"
data-testid="nc-user-settings-form"
layout="vertical"
class="change-password lg:max-w-3/4 w-full !mx-auto"
no-style
:model="form"
@finish="passwordChange"
>
<Transition name="layout">
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
{{ error }}
</div>
</div>
</Transition>
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword">
<a-input-password
v-model:value="form.currentPassword"
data-testid="nc-user-settings-form__current-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.current')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
data-testid="nc-user-settings-form__new-password"
size="large"
class="password"
:placeholder="$t('placeholder.password.new')"
@focus="resetError"
/>
</a-form-item>
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat">
<a-input-password
v-model:value="form.passwordRepeat"
data-testid="nc-user-settings-form__new-password-repeat"
size="large"
class="password"
:placeholder="$t('placeholder.password.confirm')"
@focus="resetError"
/>
</a-form-item>
<div class="text-center">
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit">
<span class="flex items-center gap-2">
<component :is="iconMap.passwordChange" />
{{ $t('activity.changePwd') }}
</span>
</button>
</div>
</a-form>
</div>
</template>
<style lang="scss">
.change-password {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

11
packages/nc-gui/pages/index.vue

@ -35,6 +35,12 @@ const isSharedView = computed(() => {
// check route is not project page by route name
return !routeName.startsWith('index-typeOrId-projectId-') && !['index', 'index-typeOrId'].includes(routeName)
})
const isSharedFormView = computed(() => {
const routeName = (route.value.name as string) || ''
// check route is shared form view route
return routeName.startsWith('index-typeOrId-form-viewId')
})
watch(
() => route.value.params.typeOrId,
@ -67,7 +73,10 @@ provide(ToggleDialogInj, toggleDialog)
<template>
<div>
<NuxtLayout v-if="isSharedView" name="shared-view">
<NuxtLayout v-if="isSharedFormView">
<NuxtPage />
</NuxtLayout>
<NuxtLayout v-else-if="isSharedView" name="shared-view">
<NuxtPage />
</NuxtLayout>
<NuxtLayout v-else name="dashboard">

2
packages/nc-gui/pages/index/[typeOrId].vue

@ -5,7 +5,7 @@ const route = router.currentRoute
</script>
<template>
<div>
<div class="h-full w-full">
<NuxtPage :transition="false" :page-key="route.params.typeOrId" />
</div>
</template>

4
packages/nc-gui/pages/index/[typeOrId]/form/[viewId].vue

@ -59,7 +59,7 @@ watch(
</script>
<template>
<NuxtLayout>
<div class="h-full">
<NuxtPage v-if="!passwordDlg" />
<a-modal
@ -91,7 +91,7 @@ watch(
</a-form>
</div>
</a-modal>
</NuxtLayout>
</div>
</template>
<style lang="scss" scoped>

21
packages/nc-gui/pages/ws/[typeOrId].vue

@ -1,21 +0,0 @@
<script lang="ts" setup>
const router = useRouter()
const route = router.currentRoute
const projectsStore = useProjects()
watch(
() => route.value.params.typeOrId,
async () => {
await projectsStore.loadProjects('recent')
},
{
immediate: true,
},
)
</script>
<template>
<NuxtPage :page-key="route.params.typeOrId" />
</template>

44
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]].vue

@ -1,44 +0,0 @@
<script lang="ts" setup>
definePageMeta({
hideHeader: true,
hasSidebar: true,
})
const dialogOpen = ref(false)
const openDialogKey = ref<string>('')
const dataSourcesState = ref<string>('')
const projectId = ref<string>()
function toggleDialog(value?: boolean, key?: string, dsState?: string, pId?: string) {
dialogOpen.value = value ?? !dialogOpen.value
openDialogKey.value = key || ''
dataSourcesState.value = dsState || ''
projectId.value = pId || ''
}
provide(ToggleDialogInj, toggleDialog)
</script>
<template>
<div>
<NuxtLayout name="dashboard">
<template #sidebar>
<DashboardSidebar />
</template>
<template #content>
<NuxtPage />
</template>
</NuxtLayout>
<LazyDashboardSettingsModal
v-model:model-value="dialogOpen"
v-model:open-key="openDialogKey"
v-model:data-sources-state="dataSourcesState"
:project-id="projectId"
/>
</div>
</template>
<style scoped></style>

155
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index.vue

@ -1,155 +0,0 @@
<script setup lang="ts">
import {
definePageMeta,
extractSdkResponseErrorMsg,
isDrawerOrModalExist,
isMac,
message,
onBeforeMount,
onBeforeUnmount,
onKeyStroke,
onMounted,
ref,
resolveComponent,
useDialog,
useI18n,
useProject,
useRoute,
useRouter,
useSidebar,
useTheme,
} from '#imports'
definePageMeta({
hideHeader: true,
hasSidebar: true,
})
useTheme()
const { t } = useI18n()
const { $e } = useNuxtApp()
const route = useRoute()
const router = useRouter()
const projectStore = useProject()
const { loadProject } = projectStore
// create a new sidebar state
const { toggle, toggleHasSidebar } = useSidebar('nc-left-sidebar', { hasSidebar: true, isOpen: true })
const dropdownOpen = ref(false)
onKeyStroke(
'Escape',
() => {
dropdownOpen.value = false
},
{ eventName: 'keydown' },
)
onBeforeMount(async () => {
try {
await loadProject()
} catch (e: any) {
if (e.response?.status === 403) {
// Project is not accessible
message.error(t('msg.error.projectNotAccessible'))
router.replace('/')
return
}
message.error(await extractSdkResponseErrorMsg(e))
}
// if (route.name.toString().includes('projectType-projectId-index-index') && isUIAllowed('teamAndAuth')) {
// addTab({ id: TabType.AUTH, type: TabType.AUTH, title: t('title.teamAndAuth') })
// }
/** If v1 url found navigate to corresponding new url */
const { type, name, view } = route.query
if (type && name) {
await router.replace(`/nc/${route.params.projectId}/${type}/${name}${view ? `/${view}` : ''}`)
}
})
onMounted(() => {
toggle(true)
toggleHasSidebar(true)
})
onBeforeUnmount(() => {
// clearTabs()
// reset()
})
function openKeyboardShortcutDialog() {
$e('a:actions:keyboard-shortcut')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgKeyboardShortcuts'), {
'modelValue': isOpen,
'onUpdate:modelValue': closeDialog,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
useEventListener(document, 'keydown', async (e: KeyboardEvent) => {
const cmdOrCtrl = isMac() ? e.metaKey : e.ctrlKey
if (cmdOrCtrl) {
switch (e.key) {
case '/':
if (!isDrawerOrModalExist()) {
$e('c:shortcut', { key: 'CTRL + /' })
openKeyboardShortcutDialog()
}
break
}
}
})
</script>
<template>
<div>
<div>
<NuxtPage />
<LazyGeneralPreviewAs float />
</div>
</div>
</template>
<style lang="scss" scoped>
:global(#nc-sidebar-left .ant-layout-sider-collapsed) {
@apply !w-0 !max-w-0 !min-w-0 overflow-x-hidden;
}
.nc-left-sidebar {
.nc-sidebar-left-toggle-icon {
@apply opacity-0 transition-opactity duration-200 transition-color text-gray-500/80 hover:text-gray-500/100;
.nc-left-sidebar {
@apply !border-r-0;
}
}
&:hover .nc-sidebar-left-toggle-icon {
@apply opacity-100;
}
}
:deep(.ant-dropdown-menu-submenu-title) {
@apply py-0;
}
.nc-sidebar-header {
@apply border-[var(--navbar-border)] !bg-[var(--navbar-bg)];
}
</style>

157
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index.vue

@ -1,157 +0,0 @@
<script setup lang="ts">
import { TabMetaInj, provide, storeToRefs, useSidebar, useTabs } from '#imports'
const tabStore = useTabs()
const { activeTab } = storeToRefs(tabStore)
useProjectsShortcuts()
provide(TabMetaInj, activeTab)
useSidebar('nc-left-sidebar')
</script>
<template>
<div class="h-full w-full nc-container">
<div class="h-full w-full flex flex-col">
<!-- <div class="flex items-end !min-h-[var(--sidebar-top-height)] !bg-white-500 nc-tab-bar">
<div
v-if="!isOpen"
class="nc-sidebar-left-toggle-icon hover:after:(bg-primary bg-opacity-75) group nc-sidebar-add-row py-2 px-3 mb-1"
>
<GeneralIcon
v-e="['c:grid:toggle-navdraw']"
icon="sidebarMinimise"
class="cursor-pointer transform transition-transform duration-500 text-gray-500/80 hover:text-gray-500"
:class="{ 'rotate-180': !isOpen }"
@click="toggle(!isOpen)"
/>
</div>
<a-tabs v-model:activeKey="activeTabIndex" class="nc-root-tabs min-w-[500px]" type="editable-card" @edit="onEdit">
<a-tab-pane v-for="(tab, i) of tabs" :key="i">
<template #tab>
<div class="flex items-center gap-2" data-testid="nc-tab-title">
<div class="flex items-center">
<Icon
v-if="tab.meta?.icon"
:icon="tab.meta?.icon"
class="text-xl"
:data-testid="`nc-tab-icon-${tab.meta?.icon}`"
/>
<component :is="icon(tab)" v-else class="text-sm" />
</div>
<div :data-testid="`nc-root-tabs-${tab.title}`">
<GeneralTruncateText :key="tab.title" :length="12">
{{ tab.title }}
</GeneralTruncateText>
</div>
</div>
</template>
</a-tab-pane>
</a-tabs>
<span class="flex-1" />
<div class="flex justify-center self-center mr-2 min-w-[115px]">
<div v-if="isLoading" class="flex items-center gap-2 ml-3 text-gray-200" data-testid="nc-loading">
{{ $t('general.loading') }}
<MdiLoading class="animate-infinite animate-spin" />
</div>
</div>
<LazyGeneralShareBaseButton class="mb-1px" />
<LazyGeneralFullScreen class="nc-fullscreen-icon mb-1px" />
</div>
-->
<div class="w-full min-h-[300px] flex-auto">
<NuxtPage />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.nc-container {
height: 100vh;
flex: 1 1 100%;
}
:deep(.nc-root-tabs) {
& > .ant-tabs-nav {
@apply !mb-0 before:(!border-b-0);
.ant-tabs-extra-content {
@apply !bg-white/0;
}
.ant-tabs-nav-add {
@apply !hidden;
}
.ant-tabs-nav-more {
@apply py-1.5;
}
& > .ant-tabs-nav-wrap > .ant-tabs-nav-list {
& > .ant-tabs-tab {
@apply border-0 !text-sm py-2 font-weight-medium z-2;
border-top-right-radius: 8px;
border-top-left-radius: 8px;
& + .ant-tabs-tab {
@apply ml-1;
}
}
& > .ant-tabs-tab-active {
@apply relative bg-white w-full h-full overflow-y-visible;
border-top: 1px solid white;
border-left: 1px solid white;
border-right: 1px solid white;
@apply !border-[var(--navbar-border)];
&:after {
@apply absolute content-[''] left-0 -bottom-[1px] w-full h-[1px] bg-inherit z-100;
}
}
& > .ant-tabs-tab:not(.ant-tabs-tab-active) {
@apply bg-gray-50 text-gray-500;
.ant-tabs-tab-remove {
@apply !text-default;
}
}
}
}
}
:deep(.ant-menu-item-selected) {
@apply text-inherit !bg-inherit;
}
:deep(.ant-menu-horizontal),
:deep(.ant-menu-item::after),
:deep(.ant-menu-submenu::after) {
@apply !border-none;
}
.nc-tab-bar {
@apply border-gray-150 !bg-gray-50 relative z-1;
:deep(.ant-tabs-tab-remove) {
@apply flex mt-[2px];
}
background: linear-gradient(0deg, var(--navbar-border) 1px, var(--navbar-bg) 1px) !important;
:deep(.ant-tabs-tab:not(.ant-tabs-tab-active)) {
background: linear-gradient(0deg, var(--navbar-border) 1px, #f2f2f2 1px) !important;
}
}
</style>

38
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/[type]/[viewId]/[[viewTitle]].vue

@ -1,38 +0,0 @@
<script setup lang="ts">
import type { TabItem } from '#imports'
import { TabMetaInj, computed, inject, storeToRefs, until, useMetas, useProject, useRoute } from '#imports'
const { getMeta } = useMetas()
const projectStore = useProject()
const { tables } = storeToRefs(projectStore)
const route = useRoute()
const activeTab = inject(
TabMetaInj,
computed(() => ({} as TabItem)),
)
const viewType = computed(() => {
return route.params.type as string
})
watch(
() => route.params.viewId,
(viewId) => {
/** wait until table list loads since meta load requires table list **/
until(tables)
.toMatch((tables) => tables.length > 0)
.then(() => {
getMeta(viewId as string, true)
})
},
{ immediate: true },
)
</script>
<template>
<div class="w-full h-full relative">
<LazyTabsSmartsheet :key="viewType" :active-tab="activeTab" />
</div>
</template>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/auth.vue

@ -1,3 +0,0 @@
<template>
<LazyTabsAuth />
</template>

17
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/erd/[baseId].vue

@ -1,17 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
const route = useRoute()
const { bases } = storeToRefs(useProject())
const base = computed(() => bases.value.find((el) => el.id === route.params?.baseId) || bases.value.filter((el) => el.enabled)[0])
useMetas()
</script>
<template>
<div class="w-full h-full !p-0">
<LazyErdView :base-id="base?.id" />
</div>
</template>

133
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/index.vue

@ -1,133 +0,0 @@
<script lang="ts" setup>
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import type { BaseType } from 'nocodb-sdk'
import { message, ref, resolveComponent, storeToRefs, useDialog, useFileDialog, useNuxtApp, useProject, watch } from '#imports'
const projectStore = useProject()
const { project } = storeToRefs(projectStore)
const { files, reset } = useFileDialog()
const { bases } = storeToRefs(projectStore)
const { $e } = useNuxtApp()
type QuickImportTypes = 'excel' | 'json' | 'csv'
const allowedQuickImportTypes = [
// Excel
'application/vnd.ms-excel', // .xls
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel.sheet.macroenabled.12', // .xlsm
'application/vnd.oasis.opendocument.spreadsheet', // .ods
'application/vnd.oasis.opendocument.spreadsheet-template', // .ots
// CSV
'text/csv',
// JSON
'application/json',
'text/json',
]
watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles), { flush: 'post' })
function onFileSelect(fileList: FileList | null) {
if (!fileList) return
const files = Array.from(fileList).map((file) => file)
onDrop(files)
}
function onDrop(droppedFiles: File[] | null) {
if (!droppedFiles) return
/** we can only handle one file per drop */
if (droppedFiles.length > 1) {
return message.error({
content: `Only one file can be imported at a time.`,
duration: 2,
})
}
let fileType: QuickImportTypes | null = null
const isValid = allowedQuickImportTypes.some((type) => {
const isAllowed = droppedFiles[0].type === type
if (isAllowed) {
const ext = droppedFiles[0].name.split('.').pop()
fileType = ext === 'csv' || ext === 'json' ? ext : ('excel' as QuickImportTypes)
}
return isAllowed
})
/** Invalid file type was dropped */
if (!isValid) {
return message.error({
content: 'Invalid file type',
duration: 2,
})
}
if (fileType && isValid) {
openQuickImportDialog(fileType, droppedFiles[0])
}
}
function openQuickImportDialog(type: QuickImportTypes, file: File) {
$e(`a:actions:import-${type}`)
const isOpen = ref(true)
const { close, vNode } = useDialog(resolveComponent('DlgQuickImport'), {
'modelValue': isOpen,
'importType': type,
'onUpdate:modelValue': closeDialog,
'baseId': bases.value?.filter((base: BaseType) => base.enabled)[0].id,
})
vNode.value?.component?.exposed?.handleChange({
file: {
uid: `${type}-${file.name}-${Math.random().toString(36).substring(2)}`,
name: file.name,
type: file.type,
status: 'done',
fileName: file.name,
lastModified: file.lastModified,
size: file.size,
originFileObj: file,
},
event: { percent: 100 },
} as UploadChangeParam<UploadFile<File>>)
function closeDialog() {
isOpen.value = false
close(1000)
reset()
}
}
watch(
() => project.value.id,
() => {
if (project.value?.id && project.value.type === 'database') {
const { addTab } = useTabs()
addTab({
id: project.value.id,
title: project.value.title!,
type: TabType.DB,
projectId: project.value.id,
})
}
},
)
</script>
<template>
<ProjectView />
</template>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/[projectId]/index/index/sql/[baseId].vue

@ -1,3 +0,0 @@
<template>
<span />
</template>

16
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/base/[baseId].vue

@ -1,16 +0,0 @@
<script setup lang="ts">
/** A dummy page to redirect old shared base url from v1 to latest */
import { useRoute, useRouter } from '#imports'
const route = useRoute()
const router = useRouter()
const { type, name, view } = route.query
if (type && name) {
router.replace(`/base/${route.params.baseId}/${type}/${name}${view ? `/${view}` : ''}`)
} else {
router.replace(`/base/${route.params.baseId}`)
}
</script>

3
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/cowriter/[projectId]/[...slugs].vue

@ -1,3 +0,0 @@
<template>
<span />
</template>

126
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId].vue

@ -1,126 +0,0 @@
<script setup lang="ts">
import {
IsFormInj,
IsPublicInj,
MetaInj,
ReloadViewDataHookInj,
applyLanguageDirection,
createError,
createEventHook,
definePageMeta,
navigateTo,
provide,
reactive,
ref,
useProvideSharedFormStore,
useProvideSmartsheetStore,
useRoute,
useSidebar,
watch,
} from '#imports'
definePageMeta({
public: true,
})
useSidebar('nc-left-sidebar', { hasSidebar: false })
const route = useRoute()
const { loadSharedView, sharedView, sharedViewMeta, meta, notFound, password, passwordDlg, passwordError } =
useProvideSharedFormStore(route.params.viewId as string)
await loadSharedView()
if (!notFound.value) {
provide(ReloadViewDataHookInj, createEventHook())
provide(MetaInj, meta)
provide(IsPublicInj, ref(true))
provide(IsFormInj, ref(true))
useProvideSmartsheetStore(sharedView, meta, true)
applyLanguageDirection(sharedViewMeta.value.rtl ? 'rtl' : 'ltr')
} else {
navigateTo('/error/404')
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' })
}
const form = reactive({
password: '',
})
watch(
() => form.password,
() => {
password.value = form.password
},
)
</script>
<template>
<NuxtLayout>
<NuxtPage v-if="!passwordDlg" />
<a-modal
v-model:visible="passwordDlg"
:class="{ active: passwordDlg }"
:closable="false"
width="min(100%, 450px)"
centered
:footer="null"
:mask-closable="false"
wrap-class-name="nc-modal-shared-form-password-dlg"
@close="passwordDlg = false"
>
<div class="w-full flex flex-col gap-4">
<!-- todo: i18n -->
<h2 class="text-xl font-semibold">This shared view is protected</h2>
<a-form layout="vertical" no-style :model="form" @finish="loadSharedView">
<a-form-item name="password" :rules="[{ required: true, message: $t('msg.error.signUpRules.passwdRequired') }]">
<a-input-password v-model:value="form.password" size="large" :placeholder="$t('msg.info.signUp.enterPassword')" />
</a-form-item>
<Transition name="layout">
<div v-if="passwordError" class="mb-2 text-sm text-red-500">{{ passwordError }}</div>
</Transition>
<!-- Unlock -->
<button type="submit" class="mt-4 scaling-btn bg-opacity-100">{{ $t('general.unlock') }}</button>
</a-form>
</div>
</a-modal>
</NuxtLayout>
</template>
<style lang="scss" scoped>
:deep(.nc-cell-attachment) {
@apply p-0;
.nc-attachment-cell {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md:(w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {
@apply rounded bg-gray-400/75;
}
}
}
.nc-modal-shared-form-password-dlg {
.ant-input-affix-wrapper,
.ant-input {
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded;
}
.password {
input {
@apply !border-none !m-0;
}
}
}
</style>

148
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index.vue

@ -1,148 +0,0 @@
<script setup lang="ts">
import { navigateTo, useDark, useRoute, useRouter, useSharedFormStoreOrThrow, useTheme, watch } from '#imports'
const { sharedViewMeta } = useSharedFormStoreOrThrow()
const isDark = useDark()
const { setTheme } = useTheme()
const route = useRoute()
const router = useRouter()
watch(
() => sharedViewMeta.value.withTheme,
(hasTheme) => {
if (hasTheme && sharedViewMeta.value.theme) setTheme(sharedViewMeta.value.theme)
},
{ immediate: true },
)
const onClick = () => {
isDark.value = !isDark.value
}
const shouldRedirect = (to: string) => {
if (sharedViewMeta.value.surveyMode) {
if (!to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}/survey`)
} else {
if (to.includes('survey')) navigateTo(`/nc/form/${route.params.viewId}`)
}
}
shouldRedirect(route.name as string)
router.afterEach((to) => shouldRedirect(to.name as string))
</script>
<template>
<div
class="scrollbar-thin-dull overflow-y-auto overflow-x-hidden flex flex-col color-transition nc-form-view relative bg-primary bg-opacity-10 dark:(bg-slate-900) h-full min-h-[600px]"
>
<NuxtPage />
<div
class="color-transition flex items-center justify-center cursor-pointer absolute top-4 md:top-15 right-4 md:right-15 rounded-full p-2 bg-white dark:(bg-slate-600) shadow hover:(ring-1 ring-accent ring-opacity-100)"
@click="onClick"
>
<Transition name="slide-left" duration="250" mode="out-in">
<MaterialSymbolsDarkModeOutline v-if="isDark" />
<MaterialSymbolsLightModeOutline v-else />
</Transition>
</div>
</div>
</template>
<style lang="scss">
html,
body,
h1,
h2,
h3,
h4,
h5,
h6,
p {
@apply dark:text-white color-transition;
}
.nc-form-view {
.nc-cell {
@apply bg-white dark:bg-slate-500;
&.nc-cell-checkbox {
@apply color-transition !border-0;
.nc-icon {
@apply !text-2xl;
}
.nc-cell-hover-show {
opacity: 100 !important;
div {
background-color: transparent !important;
}
}
}
&:not(.nc-cell-checkbox) {
@apply bg-white dark:bg-slate-500;
&.nc-input {
@apply w-full rounded p-2 min-h-[40px] flex items-center border-solid border-1 border-gray-300 dark:border-slate-200;
.duration-cell-wrapper {
@apply w-full;
input {
@apply !outline-none;
&::placeholder {
@apply text-gray-400 dark:text-slate-300;
}
}
}
input,
textarea,
&.nc-virtual-cell,
> div {
@apply bg-white dark:(bg-slate-500 text-white);
.ant-btn {
@apply dark:(bg-slate-300);
}
.chip {
@apply dark:(bg-slate-700 text-white);
}
}
&.nc-cell-longtext {
@apply !p-0 pb-2px pr-2px;
}
textarea {
@apply px-4 py-2 rounded;
&:focus {
box-shadow: none !important;
}
}
}
}
.nc-attachment-cell > div {
@apply dark:(bg-slate-100);
}
}
}
.nc-form-column-label {
> * {
@apply dark:text-slate-300;
}
}
</style>

141
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/index.vue

@ -1,141 +0,0 @@
<script lang="ts" setup>
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { useSharedFormStoreOrThrow } from '#imports'
const { sharedFormView, submitForm, v$, formState, notFound, formColumns, submitted, secondsRemain, isLoading } =
useSharedFormStoreOrThrow()
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find((c) => c.id === columnObj.colOptions.fk_child_column_id) as Record<string, any>
}
return !!(required || (columnObj && columnObj.rqd && !columnObj.cdf))
}
</script>
<template>
<div class="h-full flex flex-col items-center">
<div
class="color-transition relative flex flex-col justify-center gap-2 w-full max-w-[max(33%,600px)] m-auto py-4 pb-8 px-16 md:(bg-white dark:bg-slate-700 rounded-lg border-1 border-gray-200 shadow-xl)"
>
<template v-if="sharedFormView">
<h1 class="prose-2xl font-bold self-center my-4">{{ sharedFormView.heading }}</h1>
<h2 v-if="sharedFormView.subheading" class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6">
{{ sharedFormView.subheading }}
</h2>
<a-alert v-if="notFound" type="warning" class="my-4 text-center" message="Not found" />
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="min-w-350px mt-3">
<a-alert
type="success"
class="my-4 text-center"
outlined
:message="sharedFormView.success_msg || 'Successfully submitted form data'"
/>
<p v-if="sharedFormView.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView.submit_another_form" class="text-center">
<a-button type="primary" @click="submitted = false"> Submit Another Form</a-button>
</div>
</div>
</div>
</template>
<template v-else>
<GeneralOverlay class="bg-gray-400/75" :model-value="isLoading" inline transition>
<div class="w-full h-full flex items-center justify-center">
<a-spin size="large" />
</div>
</GeneralOverlay>
<div class="nc-form-wrapper">
<div class="nc-form h-full">
<div class="flex flex-col gap-6">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col gap-2">
<div class="flex nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div>
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="`nc-form-input-${field.title?.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
{{ field.description }}
</div>
</div>
</div>
</div>
<div class="text-center mt-4">
<button
type="submit"
class="uppercase scaling-btn prose-sm"
data-testid="shared-form-submit-button"
@click="submitForm"
>
{{ $t('general.submit') }}
</button>
</div>
</div>
</div>
</template>
</template>
</div>
<div class="flex items-end">
<GeneralPoweredBy />
</div>
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-cell .nc-action-icon) {
@apply !text-white-500 !bg-white/50 !rounded-full !p-1 !text-xs !w-7 !h-7 !flex !items-center !justify-center !cursor-pointer !hover:!bg-white-600 !hover:!text-white-600 !transition;
}
</style>

472
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/form/[viewId]/index/survey.vue

@ -1,472 +0,0 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core'
import {
DropZoneRef,
computed,
onKeyStroke,
onMounted,
provide,
ref,
useBreakpoints,
usePointerSwipe,
useSharedFormStoreOrThrow,
useStepper,
} from '#imports'
enum TransitionDirection {
Left = 'left',
Right = 'right',
}
enum AnimationTarget {
ArrowLeft = 'arrow-left',
ArrowRight = 'arrow-right',
OkButton = 'ok-button',
SubmitButton = 'submit-button',
}
const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } =
useSharedFormStoreOrThrow()
const isTransitioning = ref(false)
const transitionName = ref<TransitionDirection>(TransitionDirection.Left)
const animationTarget = ref<AnimationTarget>(AnimationTarget.ArrowRight)
const isAnimating = ref(false)
const el = ref<HTMLDivElement>()
provide(DropZoneRef, el)
const transitionDuration = computed(() => sharedViewMeta.value.transitionDuration || 50)
const steps = computed(() => {
if (!formColumns.value) return []
return formColumns.value.reduce<string[]>((acc, column) => {
const title = column.label || column.title
if (!title) return acc
acc.push(title)
return acc
}, [])
})
const { index, goToPrevious, goToNext, isFirst, isLast, goTo } = useStepper(steps)
const field = computed(() => formColumns.value?.[index.value])
function isRequired(column: ColumnType, required = false) {
let columnObj = column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
(columnObj.colOptions as { type: RelationTypes }).type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find(
(c) => c.id === (columnObj.colOptions as LinkToAnotherRecordType).fk_child_column_id,
) as ColumnType
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
function transition(direction: TransitionDirection) {
isTransitioning.value = true
transitionName.value = direction
setTimeout(() => {
transitionName.value =
transitionName.value === TransitionDirection.Left ? TransitionDirection.Right : TransitionDirection.Left
}, transitionDuration.value / 2)
setTimeout(() => {
isTransitioning.value = false
setTimeout(focusInput, 100)
}, transitionDuration.value)
}
function animate(target: AnimationTarget) {
animationTarget.value = target
isAnimating.value = true
setTimeout(() => {
isAnimating.value = false
}, transitionDuration.value / 2)
}
async function goNext(animationTarget?: AnimationTarget) {
if (isLast.value || submitted.value) return
if (!field.value || !field.value.title) return
const validationField = v$.value.localState[field.value.title]
if (validationField) {
const isValid = await validationField.$validate()
if (!isValid) return
}
animate(animationTarget || AnimationTarget.ArrowRight)
setTimeout(
() => {
transition(TransitionDirection.Left)
goToNext()
},
animationTarget === AnimationTarget.OkButton ? 300 : 0,
)
}
async function goPrevious(animationTarget?: AnimationTarget) {
if (isFirst.value || submitted.value) return
animate(animationTarget || AnimationTarget.ArrowLeft)
transition(TransitionDirection.Right)
goToPrevious()
}
function focusInput() {
if (document && typeof document !== 'undefined') {
const inputEl =
(document.querySelector('.nc-cell input') as HTMLInputElement) ||
(document.querySelector('.nc-cell textarea') as HTMLTextAreaElement)
if (inputEl) {
inputEl.select()
inputEl.focus()
}
}
}
function resetForm() {
v$.value.$reset()
submitted.value = false
transition(TransitionDirection.Right)
goTo(steps.value[0])
}
function submit() {
if (submitted.value) return
submitForm()
}
onReset(resetForm)
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
goPrevious(AnimationTarget.ArrowLeft)
})
onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
goNext(AnimationTarget.ArrowRight)
})
onKeyStroke(['Enter', 'Space'], () => {
if (isLast.value) {
submit()
} else {
goNext(AnimationTarget.OkButton)
}
})
onMounted(() => {
focusInput()
if (!md.value) {
const { direction } = usePointerSwipe(el, {
onSwipe: () => {
if (isTransitioning.value) return
if (direction.value === 'left') {
goNext()
} else if (direction.value === 'right') {
goPrevious()
}
},
})
}
})
</script>
<template>
<div ref="el" class="survey pt-8 md:p-0 w-full h-full flex flex-col">
<div
v-if="sharedFormView"
style="height: max(40vh, 225px); min-height: 225px"
class="max-w-[max(33%,600px)] mx-auto flex flex-col justify-end"
>
<div class="px-4 md:px-0 flex flex-col justify-end">
<h1 class="prose-2xl font-bold self-center my-4" data-testid="nc-survey-form__heading">
{{ sharedFormView.heading }}
</h1>
<h2
v-if="sharedFormView.subheading && sharedFormView.subheading !== ''"
class="prose-lg text-slate-500 dark:text-slate-300 self-center mb-4 leading-6"
data-testid="nc-survey-form__sub-heading"
>
{{ sharedFormView?.subheading }}
</h2>
</div>
</div>
<div class="h-full w-full flex items-center px-4 md:px-0">
<Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in">
<div
ref="el"
:key="field?.title"
class="color-transition h-full flex flex-col mt-6 gap-4 w-full max-w-[max(33%,600px)] m-auto"
>
<div v-if="field && !submitted" class="flex flex-col gap-2">
<div class="flex nc-form-column-label" data-testid="nc-form-column-label">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(field)"
:column="{ ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:class="field.uidt === UITypes.Checkbox ? 'nc-form-column-label__checkbox' : ''"
:column="{ meta: {}, ...field, title: field.label || field.title }"
:required="isRequired(field, field.required)"
:hide-menu="true"
/>
</div>
<div v-if="field.title">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="true"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
<div
class="block text-[14px]"
:class="field.uidt === UITypes.Checkbox ? 'text-center' : ''"
data-testid="nc-survey-form__field-description"
>
{{ field.description }}
</div>
<div v-if="field.uidt === UITypes.LongText" class="text-sm text-gray-500 flex flex-wrap items-center">
Shift <MdiAppleKeyboardShift class="mx-1 text-primary" /> + Enter
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> to make a line break
</div>
</div>
</div>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-center">
<div v-if="isLast && !submitted && !v$.$invalid" class="text-center my-4">
<button
:class="
animationTarget === AnimationTarget.SubmitButton && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
type="submit"
class="uppercase scaling-btn prose-sm"
data-testid="nc-survey-form__btn-submit"
@click="submit"
>
{{ $t('general.submit') }}
</button>
</div>
<div v-else-if="!submitted" class="flex items-center gap-3 flex-col">
<a-tooltip
:title="v$.localState[field.title]?.$error ? v$.localState[field.title].$errors[0].$message : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button
class="bg-opacity-100 scaling-btn flex items-center gap-1"
data-testid="nc-survey-form__btn-next"
:class="[
v$.localState[field.title]?.$error ? 'after:!bg-gray-100 after:!ring-red-500' : '',
animationTarget === AnimationTarget.OkButton && isAnimating
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
@click="goNext()"
>
<Transition name="fade">
<span v-if="!v$.localState[field.title]?.$error" class="uppercase text-white">Ok</span>
</Transition>
<Transition name="slide-right" mode="out-in">
<MdiCloseCircleOutline v-if="v$.localState[field.title]?.$error" class="text-red-500 md:text-md" />
<MdiCheck v-else class="text-white md:text-md" />
</Transition>
</button>
</a-tooltip>
<!-- todo: i18n -->
<div class="hidden md:flex text-sm text-gray-500 items-center gap-1">
Press Enter <MaterialSymbolsKeyboardReturn class="text-primary" />
</div>
</div>
</div>
</div>
<Transition name="slide-left">
<div v-if="submitted" class="flex flex-col justify-center items-center text-center">
<div class="text-lg px-6 py-3 bg-green-300 text-gray-700 rounded" data-testid="nc-survey-form__success-msg">
<template v-if="sharedFormView?.success_msg">
{{ sharedFormView?.success_msg }}
</template>
<template v-else>
<div class="flex flex-col gap-1">
<div>Thank you!</div>
<div>You have successfully submitted the form data.</div>
</div>
</template>
</div>
<div v-if="sharedFormView" class="mt-3">
<p v-if="sharedFormView?.show_blank_form" class="text-xs text-slate-500 dark:text-slate-300 text-center my-4">
New form will be loaded after {{ secondsRemain }} seconds
</p>
<div v-if="sharedFormView?.submit_another_form" class="text-center">
<button
type="button"
class="scaling-btn bg-opacity-100"
data-testid="nc-survey-form__btn-submit-another-form"
@click="resetForm"
>
Submit Another Form
</button>
</div>
</div>
</div>
</Transition>
</div>
</Transition>
</div>
<template v-if="!submitted">
<div class="mb-24 md:my-4 select-none text-center text-gray-500 dark:text-slate-200" data-testid="nc-survey-form__footer">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
</template>
<div class="relative flex w-full items-end">
<Transition name="fade">
<div
v-if="!submitted"
class="color-transition shadow-sm absolute bottom-18 right-1/2 transform translate-x-[50%] md:bottom-4 md:(right-12 transform-none) flex items-center bg-white border dark:bg-slate-500 rounded divide-x-1"
>
<a-tooltip :title="isFirst ? '' : 'Go to previous'" :mouse-enter-delay="0.25" :mouse-leave-delay="0">
<button
:class="
animationTarget === AnimationTarget.ArrowLeft && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] text-primary'
: ''
"
class="p-0.5 flex items-center group color-transition"
data-testid="nc-survey-form__icon-prev"
@click="goPrevious()"
>
<MdiChevronLeft :class="isFirst ? 'text-gray-300' : 'group-hover:text-accent'" class="text-2xl md:text-md" />
</button>
</a-tooltip>
<a-tooltip
:title="v$.localState[field.title]?.$error ? '' : 'Go to next'"
:mouse-enter-delay="0.25"
:mouse-leave-delay="0"
>
<button
:class="
animationTarget === AnimationTarget.ArrowRight && isAnimating
? 'transform translate-y-[1px] translate-x-[-1px] text-primary'
: ''
"
class="p-0.5 flex items-center group color-transition"
data-testid="nc-survey-form__icon-next"
@click="goNext()"
>
<MdiChevronRight
:class="[isLast || v$.localState[field.title]?.$error ? 'text-gray-300' : 'group-hover:text-accent']"
class="text-2xl md:text-md"
/>
</button>
</a-tooltip>
</div>
</Transition>
<GeneralPoweredBy />
</div>
</div>
</template>
<style lang="scss">
:global(html, body) {
@apply overscroll-x-none;
}
.survey {
.nc-form-column-label {
> * {
@apply !prose-lg;
}
.nc-icon {
@apply mr-2;
}
}
.nc-form-column-label__checkbox {
@apply flex items-center justify-center gap-2 text-left;
}
.nc-input {
@apply appearance-none w-full rounded px-2 py-2 my-2 border-solid border-1 border-primary border-opacity-50;
&.nc-cell-checkbox {
> * {
@apply justify-center flex items-center;
}
}
input {
@apply !py-1 !px-1;
}
}
}
</style>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/gallery/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewGallery v-else />
</NuxtLayout>
</template>

22
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/index.vue

@ -1,22 +0,0 @@
<script lang="ts" setup>
definePageMeta({
hideHeader: true,
})
</script>
<template>
<div>
<div>
<div class="w-full h-full nc-container">
<WorkspaceEmptyPlaceholder buttons />
</div>
</div>
</div>
</template>
<style scoped>
.nc-container {
height: 100vh;
flex: 1 1 100%;
}
</style>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/kanban/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewKanban v-else />
</NuxtLayout>
</template>

35
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/map/[viewId]/index.vue

@ -1,35 +0,0 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { definePageMeta } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewMap v-else />
</NuxtLayout>
</template>

59
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/shared/[erdUuid]/index.vue

@ -1,59 +0,0 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { definePageMeta, navigateTo, onMounted, ref, useGlobal, useMetas, useNuxtApp, useProject, useRoute } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
})
const route = useRoute()
const { appInfo } = useGlobal()
const projectStore = useProject()
const { loadProject } = projectStore
const { project } = storeToRefs(projectStore)
useMetas()
const baseData = ref({} as any)
const { $api } = useNuxtApp()
onMounted(async () => {
try {
baseData.value = await $api.public.sharedErdMetaGet(route.params.erdUuid as string)
} catch (e: any) {
console.error(e)
navigateTo('/')
return
}
await loadProject(false, baseData.value.project_id)
})
</script>
<template>
<div
class="absolute z-60 transition-all duration-200 m-6 cursor-pointer transform hover:scale-105 flex text-xl items-center"
@click="navigateTo('/')"
>
<a-tooltip placement="bottom">
<template #title>
{{ appInfo.version }}
</template>
<img width="50" alt="NocoDB" src="~/assets/img/icons/256x256.png" />
</a-tooltip>
<div class="ml-2 font-bold text-gray-500 uppercase">{{ project.title }}</div>
</div>
<div class="w-full h-full !p-0">
<ErdView :base-id="baseData.id" />
</div>
</template>
<style lang="scss" scoped>
:deep(.nc-erd-histogram.top) {
display: none;
}
</style>

36
packages/nc-gui/pages/ws/[typeOrId]/[[projectType]]/view/[viewId].vue

@ -1,36 +0,0 @@
<script setup lang="ts">
import { definePageMeta, extractSdkResponseErrorMsg, message, ref, useRoute, useSharedView } from '#imports'
definePageMeta({
public: true,
requiresAuth: false,
layout: 'shared-view',
})
const route = useRoute()
const { loadSharedView } = useSharedView()
const showPassword = ref(false)
try {
await loadSharedView(route.params.viewId as string)
} catch (e: any) {
if (e?.response?.status === 403) {
showPassword.value = true
} else {
console.error(e)
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<NuxtLayout class="flex" name="shared-view">
<div v-if="showPassword">
<LazySharedViewAskPassword v-model="showPassword" />
</div>
<LazySharedViewGrid v-else />
</NuxtLayout>
</template>

6
packages/nocodb/src/services/telemetry.service.ts

@ -19,7 +19,9 @@ export class TelemetryService {
evt_type: string;
[key: string]: any;
}) {
if (event === '$pageview') T.page({ ...payload, event_name: event });
else T.event({ ...payload, event_name: event });
if (event === '$pageview') T.page({ ...payload, event });
else {
T.event({ ...payload, event });
}
}
}

Loading…
Cancel
Save