Browse Source

Merge pull request #3368 from nocodb/feat/3345-project-theme

feat(gui-v2): project theme/color
pull/3375/head
navi 2 years ago committed by GitHub
parent
commit
f9ccbb9419
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      packages/nc-gui-v2/assets/style.scss
  2. 1
      packages/nc-gui-v2/components.d.ts
  3. 12
      packages/nc-gui-v2/components/general/ColorPicker.vue
  4. 48
      packages/nc-gui-v2/composables/useProject.ts
  5. 33
      packages/nc-gui-v2/composables/useTheme/index.ts
  6. 27
      packages/nc-gui-v2/package-lock.json
  7. 3
      packages/nc-gui-v2/package.json
  8. 168
      packages/nc-gui-v2/pages/[projectType]/[projectId]/index.vue
  9. 70
      packages/nc-gui-v2/pages/index/index/[id].vue
  10. 8
      packages/nc-gui-v2/pages/index/index/index.vue
  11. 14
      packages/nocodb/src/lib/meta/api/projectApis.ts
  12. 12
      packages/nocodb/src/lib/meta/helpers/extractProps.ts
  13. 2
      packages/nocodb/src/lib/models/Base.ts
  14. 2
      packages/nocodb/src/lib/models/FormViewColumn.ts
  15. 2
      packages/nocodb/src/lib/models/FormulaColumn.ts
  16. 2
      packages/nocodb/src/lib/models/GridViewColumn.ts
  17. 2
      packages/nocodb/src/lib/models/HookLog.ts
  18. 2
      packages/nocodb/src/lib/models/Project.ts
  19. 2
      packages/nocodb/src/lib/models/SyncSource.ts
  20. 2
      packages/nocodb/src/lib/models/User.ts
  21. 2
      packages/nocodb/src/lib/models/View.ts

4
packages/nc-gui-v2/assets/style.scss

@ -214,10 +214,6 @@ a {
@apply !p-0 !rounded; @apply !p-0 !rounded;
} }
.ant-dropdown-menu-submenu-popup {
@apply scrollbar-thin-dull min-w-50 max-h-90vh overflow-auto !shadow !rounded;
}
.ant-tabs-dropdown-menu-title-content { .ant-tabs-dropdown-menu-title-content {
@apply flex items-center; @apply flex items-center;
} }

1
packages/nc-gui-v2/components.d.ts vendored

@ -187,6 +187,7 @@ declare module '@vue/runtime-core' {
MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default'] MdiMinusCircleOutline: typeof import('~icons/mdi/minus-circle-outline')['default']
MdiMoonFull: typeof import('~icons/mdi/moon-full')['default'] MdiMoonFull: typeof import('~icons/mdi/moon-full')['default']
MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default'] MdiNotebookCheckOutline: typeof import('~icons/mdi/notebook-check-outline')['default']
MdiNull: typeof import('~icons/mdi/null')['default']
MdiNumeric: typeof import('~icons/mdi/numeric')['default'] MdiNumeric: typeof import('~icons/mdi/numeric')['default']
MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default'] MdiOpenInNew: typeof import('~icons/mdi/open-in-new')['default']
MdiPencil: typeof import('~icons/mdi/pencil')['default'] MdiPencil: typeof import('~icons/mdi/pencil')['default']

12
packages/nc-gui-v2/components/general/ColorPicker.vue

@ -23,22 +23,22 @@ const emit = defineEmits(['update:modelValue'])
const vModel = computed({ const vModel = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => { set: (val) => {
emit('update:modelValue', val.hex ? val.hex : val || null) emit('update:modelValue', val.hex8 ? val.hex8 : val || null)
}, },
}) })
const picked = ref<string | Record<string, any>>(props.modelValue || enumColor.light[0]) const picked = ref<string | Record<string, any>>(props.modelValue || enumColor.light[0])
const selectColor = (color: string | Record<string, any>) => { const selectColor = (color: string | Record<string, any>) => {
picked.value = typeof color === 'string' ? color : color.hex ? color.hex : color picked.value = typeof color === 'string' ? color : color.hex8 ? color.hex8 : color
vModel.value = typeof color === 'string' ? color : color.hex ? color.hex : color vModel.value = typeof color === 'string' ? color : color.hex8 ? color.hex8 : color
} }
const compare = (colorA: string, colorB: string) => colorA.toLowerCase() === colorB.toLowerCase() const compare = (colorA: string, colorB: string) => colorA.toLowerCase() === colorB.toLowerCase()
watch(picked, (n, _o) => { watch(picked, (n, _o) => {
if (!props.pickButton) { if (!props.pickButton) {
vModel.value = typeof n === 'string' ? n : n.hex ? n.hex : n vModel.value = typeof n === 'string' ? n : n.hex8 ? n.hex8 : n
} }
}) })
</script> </script>
@ -50,11 +50,11 @@ watch(picked, (n, _o) => {
v-for="(color, i) of colors.slice((colId - 1) * rowSize, colId * rowSize)" v-for="(color, i) of colors.slice((colId - 1) * rowSize, colId * rowSize)"
:key="`color-${colId}-${i}`" :key="`color-${colId}-${i}`"
class="color-selector" class="color-selector"
:class="compare(picked, color) ? 'selected' : ''" :class="compare(typeof picked === 'string' ? picked : picked.hex8, color) ? 'selected' : ''"
:style="{ 'background-color': `${color}` }" :style="{ 'background-color': `${color}` }"
@click="selectColor(color)" @click="selectColor(color)"
> >
{{ compare(picked, color) ? '&#10003;' : '' }} {{ compare(typeof picked === 'string' ? picked : picked.hex8, color) ? '&#10003;' : '' }}
</button> </button>
</div> </div>
<a-card v-if="props.advanced" class="w-full mt-2" :body-style="{ padding: '0px' }" :bordered="false"> <a-card v-if="props.advanced" class="w-full mt-2" :body-style="{ padding: '0px' }" :bordered="false">

48
packages/nc-gui-v2/composables/useProject.ts

@ -4,6 +4,7 @@ import type { MaybeRef } from '@vueuse/core'
import { useNuxtApp, useRoute, useState } from '#app' import { useNuxtApp, useRoute, useState } from '#app'
import type { ProjectMetaInfo } from '~/lib' import type { ProjectMetaInfo } from '~/lib'
import { USER_PROJECT_ROLES } from '~/lib' import { USER_PROJECT_ROLES } from '~/lib'
import type { ThemeConfig } from '@/composables/useTheme'
export function useProject(projectId?: MaybeRef<string>) { export function useProject(projectId?: MaybeRef<string>) {
const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({})) const projectRoles = useState<Record<string, boolean>>(USER_PROJECT_ROLES, () => ({}))
@ -14,9 +15,11 @@ export function useProject(projectId?: MaybeRef<string>) {
const tables = useState<TableType[]>('tables', () => [] as TableType[]) const tables = useState<TableType[]>('tables', () => [] as TableType[])
const route = useRoute() const route = useRoute()
const { includeM2M } = useGlobal() const { includeM2M } = useGlobal()
const { setTheme } = useTheme()
const projectMetaInfo = useState<ProjectMetaInfo | undefined>('projectMetaInfo') const projectMetaInfo = useState<ProjectMetaInfo | undefined>('projectMetaInfo')
// todo: refactor path param name and variable name // todo: refactor path param name and variable name
const projectType = $computed(() => route.params.projectType as string) const projectType = $computed(() => route.params.projectType as string)
const isLoaded = ref(false)
const projectBaseType = $computed(() => project.value?.bases?.[0]?.type || '') const projectBaseType = $computed(() => project.value?.bases?.[0]?.type || '')
const isMysql = computed(() => ['mysql', 'mysql2'].includes(projectBaseType)) const isMysql = computed(() => ['mysql', 'mysql2'].includes(projectBaseType))
@ -27,6 +30,14 @@ export function useProject(projectId?: MaybeRef<string>) {
) )
const isSharedBase = computed(() => projectType === 'base') const isSharedBase = computed(() => projectType === 'base')
const projectMeta = computed(() => {
try {
return typeof project.value.meta === 'string' ? JSON.parse(project.value.meta) : project.value.meta
} catch (e) {
return {}
}
})
async function loadProjectMetaInfo(force?: boolean) { async function loadProjectMetaInfo(force?: boolean) {
if (!projectMetaInfo.value || force) { if (!projectMetaInfo.value || force) {
const data = await $api.project.metaGet(project.value.id!, {}, {}) const data = await $api.project.metaGet(project.value.id!, {}, {})
@ -71,16 +82,51 @@ export function useProject(projectId?: MaybeRef<string>) {
} else { } else {
_projectId = route.params.projectId as string _projectId = route.params.projectId as string
} }
isLoaded.value = true
project.value = await $api.project.read(_projectId!) project.value = await $api.project.read(_projectId!)
await loadProjectRoles() await loadProjectRoles()
await loadTables() await loadTables()
setTheme(projectMeta.value?.theme)
}
async function updateProject(data: Partial<ProjectType>) {
if (unref(projectId)) {
_projectId = unref(projectId)!
} else if (projectType === 'base') {
return
} else {
_projectId = route.params.projectId as string
} }
await $api.project.update(_projectId, data)
}
async function saveTheme(theme: Partial<ThemeConfig>) {
await updateProject({
meta: JSON.stringify({
...projectMeta.value,
theme,
}),
})
setTheme(theme)
}
onScopeDispose(() => {
if (isLoaded.value === true) {
project.value = {}
tables.value = []
projectMetaInfo.value = undefined
projectRoles.value = {}
setTheme({})
}
})
return { return {
project, project,
tables, tables,
loadProjectRoles, loadProjectRoles,
loadProject, loadProject,
updateProject,
loadTables, loadTables,
isMysql, isMysql,
isMssql, isMssql,
@ -89,5 +135,7 @@ export function useProject(projectId?: MaybeRef<string>) {
isSharedBase, isSharedBase,
loadProjectMetaInfo, loadProjectMetaInfo,
projectMetaInfo, projectMetaInfo,
projectMeta,
saveTheme,
} }
} }

33
packages/nc-gui-v2/composables/useTheme/index.ts

@ -1,9 +1,9 @@
import { ConfigProvider } from 'ant-design-vue' import { ConfigProvider } from 'ant-design-vue'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider' import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import { useStorage } from '@vueuse/core' import tinycolor from 'tinycolor2'
import { NOCO, hexToRGB, themeV2Colors, useCssVar, useInjectionState } from '#imports' import { hexToRGB, themeV2Colors, useCssVar, useInjectionState } from '#imports'
interface ThemeConfig extends AntTheme { export interface ThemeConfig extends AntTheme {
primaryColor: string primaryColor: string
accentColor: string accentColor: string
} }
@ -13,29 +13,32 @@ const [setup, use] = useInjectionState((config?: Partial<ThemeConfig>) => {
const accentColor = useCssVar('--color-accent', typeof document !== 'undefined' ? document.documentElement : null) const accentColor = useCssVar('--color-accent', typeof document !== 'undefined' ? document.documentElement : null)
/** current theme config */ /** current theme config */
const currentTheme = useStorage<ThemeConfig>( const currentTheme = ref({
`${NOCO}db-theme`,
{
primaryColor: themeV2Colors['royal-blue'].DEFAULT, primaryColor: themeV2Colors['royal-blue'].DEFAULT,
accentColor: themeV2Colors.pink['500'], accentColor: themeV2Colors.pink['500'],
}, })
localStorage,
{ mergeDefaults: true },
)
/** set initial config */ /** set initial config */
setTheme(config ?? currentTheme.value) setTheme(config ?? currentTheme.value)
/** set theme (persists in localstorage) */ /** set theme (persists in localstorage) */
function setTheme(theme: Partial<ThemeConfig>) { function setTheme(theme: Partial<ThemeConfig>) {
// convert hex colors to rgb values const themePrimary = theme?.primaryColor ? tinycolor(theme.primaryColor) : tinycolor(themeV2Colors['royal-blue'].DEFAULT)
if (theme.primaryColor) primaryColor.value = hexToRGB(theme.primaryColor) const themeAccent = theme?.accentColor ? tinycolor(theme.accentColor) : tinycolor(themeV2Colors.pink['500'])
if (theme.accentColor) accentColor.value = hexToRGB(theme.accentColor)
currentTheme.value = theme as ThemeConfig // convert hex colors to rgb values
primaryColor.value = themePrimary.isValid()
? hexToRGB(themePrimary.toHex8String())
: hexToRGB(themeV2Colors['royal-blue'].DEFAULT)
accentColor.value = themeAccent.isValid() ? hexToRGB(themeAccent.toHex8String()) : hexToRGB(themeV2Colors.pink['500'])
currentTheme.value = {
primaryColor: themePrimary.toHex8String().toUpperCase(),
accentColor: themeAccent.toHex8String().toUpperCase(),
}
ConfigProvider.config({ ConfigProvider.config({
theme, theme: currentTheme.value,
}) })
} }

27
packages/nc-gui-v2/package-lock.json generated

@ -23,6 +23,7 @@
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"vue-dompurify-html": "^3.0.0", "vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3", "vue-github-button": "^3.0.3",
@ -50,6 +51,7 @@
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"@types/papaparse": "^5.3.2", "@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0", "@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3",
"@vitest/ui": "^0.18.0", "@vitest/ui": "^0.18.0",
"@vue/compiler-sfc": "^3.2.37", "@vue/compiler-sfc": "^3.2.37",
"@vue/test-utils": "^2.0.2", "@vue/test-utils": "^2.0.2",
@ -2446,6 +2448,12 @@
"integrity": "sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==", "integrity": "sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==",
"dev": true "dev": true
}, },
"node_modules/@types/tinycolor2": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.3.tgz",
"integrity": "sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==",
"dev": true
},
"node_modules/@types/tough-cookie": { "node_modules/@types/tough-cookie": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@ -13677,6 +13685,14 @@
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==", "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==",
"dev": true "dev": true
}, },
"node_modules/tinycolor2": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
"integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==",
"engines": {
"node": "*"
}
},
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.2.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.2.tgz",
@ -17077,6 +17093,12 @@
"integrity": "sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==", "integrity": "sha512-C3064MH72iEfeGCYEGCt7FCxXoAXaMPG0QPnstcxvPmbl54erpISu06d++FY37Smja64iWy5L8wOyHHBghWbJQ==",
"dev": true "dev": true
}, },
"@types/tinycolor2": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.3.tgz",
"integrity": "sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==",
"dev": true
},
"@types/tough-cookie": { "@types/tough-cookie": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.2.tgz",
@ -25393,6 +25415,11 @@
"integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==", "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==",
"dev": true "dev": true
}, },
"tinycolor2": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz",
"integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA=="
},
"tinypool": { "tinypool": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.2.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.2.2.tgz",

3
packages/nc-gui-v2/package.json

@ -11,7 +11,6 @@
"coverage": "vitest -c test/vite.config.ts run --coverage", "coverage": "vitest -c test/vite.config.ts run --coverage",
"build:copy": "npm run generate; rm -rf ../nc-lib-gui-v2/lib/dist/; rsync -rvzh ./dist/ ../nc-lib-gui-v2/lib/dist/", "build:copy": "npm run generate; rm -rf ../nc-lib-gui-v2/lib/dist/; rsync -rvzh ./dist/ ../nc-lib-gui-v2/lib/dist/",
"build:copy:publish": "npm run generate; rm -rf ../nc-lib-gui-v2/lib/dist/; rsync -rvzh ./dist/ ../nc-lib-gui-v2/lib/dist/; npm publish ../nc-lib-gui-v2" "build:copy:publish": "npm run generate; rm -rf ../nc-lib-gui-v2/lib/dist/; rsync -rvzh ./dist/ ../nc-lib-gui-v2/lib/dist/; npm publish ../nc-lib-gui-v2"
}, },
"dependencies": { "dependencies": {
"@ckpack/vue-color": "^1.2.0", "@ckpack/vue-color": "^1.2.0",
@ -32,6 +31,7 @@
"papaparse": "^5.3.2", "papaparse": "^5.3.2",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"tinycolor2": "^1.4.2",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"vue-dompurify-html": "^3.0.0", "vue-dompurify-html": "^3.0.0",
"vue-github-button": "^3.0.3", "vue-github-button": "^3.0.3",
@ -59,6 +59,7 @@
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"@types/papaparse": "^5.3.2", "@types/papaparse": "^5.3.2",
"@types/sortablejs": "^1.13.0", "@types/sortablejs": "^1.13.0",
"@types/tinycolor2": "^1.4.3",
"@vitest/ui": "^0.18.0", "@vitest/ui": "^0.18.0",
"@vue/compiler-sfc": "^3.2.37", "@vue/compiler-sfc": "^3.2.37",
"@vue/test-utils": "^2.0.2", "@vue/test-utils": "^2.0.2",

168
packages/nc-gui-v2/pages/[projectType]/[projectId]/index.vue

@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Chrome } from '@ckpack/vue-color'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { Chrome } from '@ckpack/vue-color'
import tinycolor from 'tinycolor2'
import { import {
computed, computed,
definePageMeta, definePageMeta,
enumColor,
navigateTo, navigateTo,
onKeyStroke, onKeyStroke,
openLink, openLink,
@ -29,7 +31,7 @@ const route = useRoute()
const { appInfo, token, signOut, signedIn, user } = useGlobal() const { appInfo, token, signOut, signedIn, user } = useGlobal()
const { project, loadProject, loadTables, isSharedBase, loadProjectMetaInfo, projectMetaInfo } = useProject() const { project, loadProject, loadTables, isSharedBase, loadProjectMetaInfo, projectMetaInfo, saveTheme } = useProject()
const { addTab, clearTabs } = useTabs() const { addTab, clearTabs } = useTabs()
@ -53,22 +55,9 @@ const dropdownOpen = ref(false)
/** Sidebar ref */ /** Sidebar ref */
const sidebar = ref() const sidebar = ref()
const pickedColor = ref<any>('#ffffff')
let pickerActive = $ref<boolean | 'primary' | 'accent'>(false)
const email = computed(() => user.value?.email ?? '---') const email = computed(() => user.value?.email ?? '---')
const { setTheme, theme } = useTheme() const { theme } = useTheme()
watch(pickedColor, (nextColor) => {
if (pickerActive && nextColor.hex) {
setTheme({
primaryColor: pickerActive === 'primary' ? nextColor.hex : theme.value.primaryColor,
accentColor: pickerActive === 'accent' ? nextColor.hex : theme.value.accentColor,
})
}
})
const logout = () => { const logout = () => {
signOut() signOut()
@ -94,6 +83,31 @@ await loadProject()
await loadTables() await loadTables()
const themePrimaryColor = ref<any>(theme.value.primaryColor)
const themeAccentColor = ref<any>(theme.value.accentColor)
// Chrome provides object so if custom picker used we only edit primary otherwise use analogous as accent
watch(themePrimaryColor, (nextColor) => {
const hexColor = nextColor.hex ? nextColor.hex : nextColor
const tcolor = tinycolor(hexColor)
if (tcolor) {
const analogous = tcolor.complement()
saveTheme({
primaryColor: hexColor,
accentColor: nextColor.hex ? theme.value.accentColor : analogous.toHexString(),
})
}
})
watch(themeAccentColor, (nextColor) => {
const hexColor = nextColor.hex ? nextColor.hex : nextColor
saveTheme({
primaryColor: theme.value.primaryColor,
accentColor: hexColor,
})
})
if (!route.params.type && isUIAllowed('teamAndAuth')) { if (!route.params.type && isUIAllowed('teamAndAuth')) {
addTab({ type: TabType.AUTH, title: 'Team & Auth' }) addTab({ type: TabType.AUTH, title: 'Team & Auth' })
} }
@ -125,22 +139,6 @@ const copyAuthToken = async () => {
message.error(e.message) message.error(e.message)
} }
} }
const openColorPicker = (type: 'primary' | 'accent') => {
if (!pickerActive || pickerActive !== type) {
pickedColor.value = type === 'primary' ? theme.value.primaryColor : theme.value.accentColor
pickerActive = type
} else {
pickerActive = false
}
}
const onMenuClose = (visible: boolean) => {
if (!visible) {
pickedColor.value = '#ffffff'
pickerActive = false
}
}
</script> </script>
<template> <template>
@ -188,7 +186,7 @@ const onMenuClose = (visible: boolean) => {
</template> </template>
</div> </div>
<a-dropdown v-else class="h-full min-w-0 flex-1" :trigger="['click']" placement="bottom" @visible-change="onMenuClose"> <a-dropdown v-else class="h-full min-w-0 flex-1" :trigger="['click']" placement="bottom">
<div <div
:style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }" :style="{ width: isOpen ? 'calc(100% - 40px) pr-2' : '100%' }"
:class="[isOpen ? '' : 'justify-center']" :class="[isOpen ? '' : 'justify-center']"
@ -272,6 +270,63 @@ const onMenuClose = (visible: boolean) => {
</div> </div>
</a-menu-item> </a-menu-item>
<template v-if="isUIAllowed('projectTheme')">
<a-sub-menu key="theme">
<template #title>
<div class="nc-project-menu-item group">
<ClarityImageLine class="group-hover:text-accent" />
Project Theme
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</template>
<template #expandIcon></template>
<GeneralColorPicker v-model="themePrimaryColor" :colors="enumColor.dark" :row-size="5" :advanced="false" />
<a-sub-menu key="theme-2">
<template #title>
<div class="nc-project-menu-item group">
Custom Theme
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</template>
<template #expandIcon></template>
<a-sub-menu key="pick-primary">
<template #title>
<div class="nc-project-menu-item group">
<ClarityColorPickerSolid class="group-hover:text-accent" />
Primary Color
</div>
</template>
<template #expandIcon></template>
<Chrome v-model="themePrimaryColor" />
</a-sub-menu>
<a-sub-menu key="pick-accent">
<template #title>
<div class="nc-project-menu-item group">
<ClarityColorPickerSolid class="group-hover:text-accent" />
Accent Color
</div>
</template>
<template #expandIcon></template>
<Chrome v-model="themeAccentColor" />
</a-sub-menu>
</a-sub-menu>
</a-sub-menu>
</template>
<a-menu-divider /> <a-menu-divider />
<a-sub-menu v-if="isUIAllowed('previewAs')" key="preview-as"> <a-sub-menu v-if="isUIAllowed('previewAs')" key="preview-as">
@ -293,7 +348,11 @@ const onMenuClose = (visible: boolean) => {
<GeneralPreviewAs /> <GeneralPreviewAs />
</a-sub-menu> </a-sub-menu>
<a-sub-menu key="language" class="lang-menu scrollbar-thin-dull min-w-50 max-h-90vh overflow-auto !py-0"> <a-sub-menu
key="language"
class="lang-menu !py-0"
popup-class-name="scrollbar-thin-dull min-w-50 max-h-90vh !overflow-auto"
>
<template #title> <template #title>
<div class="nc-project-menu-item group"> <div class="nc-project-menu-item group">
<MaterialSymbolsTranslate class="group-hover:text-accent nc-language" /> <MaterialSymbolsTranslate class="group-hover:text-accent nc-language" />
@ -345,47 +404,6 @@ const onMenuClose = (visible: boolean) => {
</a-menu-item> </a-menu-item>
</a-sub-menu> </a-sub-menu>
</template> </template>
<a-menu-divider />
<a-sub-menu>
<template #title>
<div class="nc-project-menu-item group">
<ClarityImageLine class="group-hover:text-accent" />
Theme
<div class="flex-1" />
<MaterialSymbolsChevronRightRounded
class="transform group-hover:(scale-115 text-accent) text-xl text-gray-400"
/>
</div>
</template>
<template #expandIcon></template>
<a-menu-item>
<div class="nc-project-menu-item group" @click.stop="openColorPicker('primary')">
<ClarityColorPickerSolid class="group-hover:text-accent" />
Primary Color
</div>
</a-menu-item>
<a-menu-item>
<div class="nc-project-menu-item group" @click.stop="openColorPicker('accent')">
<ClarityColorPickerSolid class="group-hover:text-accent" />
Accent Color
</div>
</a-menu-item>
</a-sub-menu>
<Chrome
v-if="pickerActive"
v-model="pickedColor"
class="z-99 absolute right-[-225px]"
@click.stop
@blur="onMenuClose(false)"
/>
</a-menu-item-group> </a-menu-item-group>
</a-menu> </a-menu>
</template> </template>

70
packages/nc-gui-v2/pages/index/index/[id].vue

@ -1,7 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Form } from 'ant-design-vue' import type { Form } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { ProjectType } from 'nocodb-sdk'
import tinycolor from 'tinycolor2'
import { import {
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
navigateTo, navigateTo,
@ -15,12 +16,16 @@ import {
useSidebar, useSidebar,
} from '#imports' } from '#imports'
const { api, isLoading } = useApi() const { isLoading } = useApi()
useSidebar({ hasSidebar: false }) useSidebar({ hasSidebar: false })
const route = useRoute() const route = useRoute()
const { project, loadProject, updateProject } = useProject(route.params.id as string)
await loadProject()
const nameValidationRules = [ const nameValidationRules = [
{ {
required: true, required: true,
@ -31,22 +36,15 @@ const nameValidationRules = [
const form = ref<typeof Form>() const form = ref<typeof Form>()
const formState = reactive({ const formState = reactive<Partial<ProjectType>>({
title: '', title: '',
color: '#FFFFFF00',
}) })
const getProject = async () => {
try {
const result: ProjectType = await api.project.read(route.params.id as string)
formState.title = result.title as string
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const renameProject = async () => { const renameProject = async () => {
formState.color = formState.color === '#FFFFFF00' ? '' : formState.color
try { try {
await api.project.update(route.params.id as string, formState) await updateProject(formState)
navigateTo(`/nc/${route.params.id}`) navigateTo(`/nc/${route.params.id}`)
} catch (e: any) { } catch (e: any) {
@ -56,6 +54,8 @@ const renameProject = async () => {
// select and focus title field on load // select and focus title field on load
onMounted(async () => { onMounted(async () => {
formState.title = project.value.title as string
formState.color = project.value.color && tinycolor(project.value.color).isValid() ? project.value.color : '#FFFFFF00'
await nextTick(() => { await nextTick(() => {
// todo: replace setTimeout and follow better approach // todo: replace setTimeout and follow better approach
setTimeout(() => { setTimeout(() => {
@ -67,8 +67,6 @@ onMounted(async () => {
}, 500) }, 500)
}) })
}) })
await getProject()
</script> </script>
<template> <template>
@ -100,6 +98,27 @@ await getProject()
<a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" /> <a-input v-model:value="formState.title" name="title" class="nc-metadb-project-name" />
</a-form-item> </a-form-item>
<div class="flex items-center">
<span>Project color: </span>
<a-menu class="!border-0 !m-0 !p-0">
<a-sub-menu key="project-color">
<template #title>
<button type="button" class="color-selector" :style="{ 'background-color': formState.color }">
<MdiNull v-if="formState.color === '#FFFFFF00'" />
</button>
</template>
<template #expandIcon></template>
<GeneralColorPicker v-model="formState.color" name="color" class="nc-metadb-project-color" />
</a-sub-menu>
</a-menu>
<MdiClose
v-show="formState.color !== '#FFFFFF00'"
class="cursor-pointer"
:style="{ color: 'red' }"
@click="formState.color = '#FFFFFF00'"
/>
</div>
<div class="text-center"> <div class="text-center">
<button type="submit" class="submit"> <button type="submit" class="submit">
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
@ -112,7 +131,7 @@ await getProject()
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss" scoped>
.update-project { .update-project {
.ant-input-affix-wrapper, .ant-input-affix-wrapper,
.ant-input { .ant-input {
@ -137,4 +156,23 @@ await getProject()
} }
} }
} }
:deep(.ant-menu-submenu-title) {
@apply !p-0 !mx-2;
}
.color-selector {
position: relative;
height: 32px;
width: 32px;
border-radius: 5px;
-webkit-text-stroke-width: 1px;
-webkit-text-stroke-color: white;
@apply flex text-gray-500 border-4 items-center justify-center;
}
.color-selector:hover {
filter: brightness(90%);
-webkit-filter: brightness(90%);
}
</style> </style>

8
packages/nc-gui-v2/pages/index/index/index.vue

@ -158,9 +158,13 @@ await loadProjects()
> >
<!-- Title --> <!-- Title -->
<a-table-column key="title" :title="$t('general.title')" data-index="title"> <a-table-column key="title" :title="$t('general.title')" data-index="title">
<template #default="{ text }"> <template #default="{ text, record }">
<div <div
class="capitalize color-transition group-hover:text-primary !w-[400px] overflow-hidden overflow-ellipsis whitespace-nowrap" class="capitalize color-transition group-hover:text-primary !w-[400px] h-full overflow-hidden overflow-ellipsis whitespace-nowrap pl-2"
:class="{ 'border-l-4': record.color }"
:style="{
'border-color': record.color,
}"
> >
{{ text }} {{ text }}
</div> </div>

14
packages/nocodb/src/lib/meta/api/projectApis.ts

@ -23,6 +23,7 @@ import getColumnUiType from '../helpers/getColumnUiType';
import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue'; import mapDefaultPrimaryValue from '../helpers/mapDefaultPrimaryValue';
import { extractAndGenerateManyToManyRelations } from './metaDiffApis'; import { extractAndGenerateManyToManyRelations } from './metaDiffApis';
import { metaApiMetrics } from '../helpers/apiMetrics'; import { metaApiMetrics } from '../helpers/apiMetrics';
import { extractPropsAndSanitize } from '../helpers/extractProps';
const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4); const nanoid = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz_', 4);
@ -46,12 +47,15 @@ export async function projectUpdate(
req: Request<any, any, any>, req: Request<any, any, any>,
res: Response<ProjectListType> res: Response<ProjectListType>
) { ) {
// only support updating title at this moment const project = await Project.getWithInfo(req.params.projectId);
const data: any = {
title: DOMPurify.sanitize(req?.body?.title), const data: Partial<Project> = extractPropsAndSanitize(req?.body, [
}; 'title',
'meta',
'color',
]);
if (await Project.getByTitle(data.title)) { if (data?.title && project.title !== data.title && await Project.getByTitle(data.title)) {
NcError.badRequest('Project title already in use'); NcError.badRequest('Project title already in use');
} }

12
packages/nocodb/src/lib/meta/helpers/extractProps.ts

@ -1,7 +1,17 @@
export default function extractProps<T>(body: T, props: string[]): Partial<T> { import DOMPurify from 'isomorphic-dompurify';
export function extractProps<T>(body: T, props: string[]): Partial<T> {
// todo: throw error if no props found // todo: throw error if no props found
return props.reduce((o, key) => { return props.reduce((o, key) => {
if (key in body) o[key] = body[key]; if (key in body) o[key] = body[key];
return o; return o;
}, {}); }, {});
} }
export function extractPropsAndSanitize<T>(body: T, props: string[]): Partial<T> {
// todo: throw error if no props found
return props.reduce((o, key) => {
if (key in body) o[key] = body[key] === '' ? null : DOMPurify.sanitize(body[key]);
return o;
}, {});
}

2
packages/nocodb/src/lib/models/Base.ts

@ -10,7 +10,7 @@ import Model from './Model';
import { BaseType } from 'nocodb-sdk'; import { BaseType } from 'nocodb-sdk';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
// todo: hide credentials // todo: hide credentials
export default class Base implements BaseType { export default class Base implements BaseType {

2
packages/nocodb/src/lib/models/FormViewColumn.ts

@ -3,7 +3,7 @@ import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { FormColumnType } from 'nocodb-sdk'; import { FormColumnType } from 'nocodb-sdk';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
export default class FormViewColumn implements FormColumnType { export default class FormViewColumn implements FormColumnType {
id?: string; id?: string;

2
packages/nocodb/src/lib/models/FormulaColumn.ts

@ -1,7 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
export default class FormulaColumn { export default class FormulaColumn {
formula: string; formula: string;

2
packages/nocodb/src/lib/models/GridViewColumn.ts

@ -1,7 +1,7 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import { GridColumnType } from 'nocodb-sdk'; import { GridColumnType } from 'nocodb-sdk';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import View from './View'; import View from './View';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';

2
packages/nocodb/src/lib/models/HookLog.ts

@ -1,6 +1,6 @@
import { MetaTable } from '../utils/globals'; import { MetaTable } from '../utils/globals';
import Noco from '../Noco'; import Noco from '../Noco';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import Hook from './Hook'; import Hook from './Hook';
import { HookLogType } from 'nocodb-sdk'; import { HookLogType } from 'nocodb-sdk';

2
packages/nocodb/src/lib/models/Project.ts

@ -7,7 +7,7 @@ import {
CacheScope, CacheScope,
MetaTable, MetaTable,
} from '../utils/globals'; } from '../utils/globals';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
export default class Project implements ProjectType { export default class Project implements ProjectType {

2
packages/nocodb/src/lib/models/SyncSource.ts

@ -1,6 +1,6 @@
import Noco from '../Noco'; import Noco from '../Noco';
import { MetaTable } from '../utils/globals'; import { MetaTable } from '../utils/globals';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import User from './User'; import User from './User';
export default class SyncSource { export default class SyncSource {

2
packages/nocodb/src/lib/models/User.ts

@ -1,7 +1,7 @@
import { UserType } from 'nocodb-sdk'; import { UserType } from 'nocodb-sdk';
import { CacheGetType, CacheScope, MetaTable } from '../utils/globals'; import { CacheGetType, CacheScope, MetaTable } from '../utils/globals';
import Noco from '../Noco'; import Noco from '../Noco';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
export default class User implements UserType { export default class User implements UserType {
id: string; id: string;

2
packages/nocodb/src/lib/models/View.ts

@ -18,7 +18,7 @@ import GalleryViewColumn from './GalleryViewColumn';
import FormViewColumn from './FormViewColumn'; import FormViewColumn from './FormViewColumn';
import Column from './Column'; import Column from './Column';
import NocoCache from '../cache/NocoCache'; import NocoCache from '../cache/NocoCache';
import extractProps from '../meta/helpers/extractProps'; import { extractProps } from '../meta/helpers/extractProps';
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
export default class View implements ViewType { export default class View implements ViewType {

Loading…
Cancel
Save