Browse Source

Merge pull request #9323 from nocodb/nc-product-feed

Nc product feed
pull/9579/head
navi 2 months ago committed by GitHub
parent
commit
f2380fe153
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 15
      packages/nc-gui/assets/nc-icons/discord.svg
  2. 5
      packages/nc-gui/assets/nc-icons/megaphone.svg
  3. 13
      packages/nc-gui/assets/nc-icons/nocodb.svg
  4. 18
      packages/nc-gui/assets/nc-icons/reddit.svg
  5. 12
      packages/nc-gui/assets/nc-icons/refresh-cw.svg
  6. 2
      packages/nc-gui/assets/nc-icons/star.svg
  7. 10
      packages/nc-gui/assets/nc-icons/twitter-x-line.svg
  8. 11
      packages/nc-gui/assets/nc-icons/youtube2.svg
  9. 3
      packages/nc-gui/components/dashboard/Sidebar.vue
  10. 110
      packages/nc-gui/components/dashboard/Sidebar/Feed.vue
  11. 194
      packages/nc-gui/components/feed/Changelog/Item.vue
  12. 40
      packages/nc-gui/components/feed/Changelog/index.vue
  13. 44
      packages/nc-gui/components/feed/Error.vue
  14. 13
      packages/nc-gui/components/feed/Header.vue
  15. 138
      packages/nc-gui/components/feed/Recents/Card.vue
  16. 37
      packages/nc-gui/components/feed/Recents/index.vue
  17. 35
      packages/nc-gui/components/feed/Roadmap.vue
  18. 82
      packages/nc-gui/components/feed/Social.vue
  19. 37
      packages/nc-gui/components/feed/Twitter.vue
  20. 111
      packages/nc-gui/components/feed/View.vue
  21. 44
      packages/nc-gui/components/feed/Youtube/Player.vue
  22. 49
      packages/nc-gui/components/feed/Youtube/index.vue
  23. 1
      packages/nc-gui/components/nc/Badge.vue
  24. 210
      packages/nc-gui/components/nc/Popover.vue
  25. 6
      packages/nc-gui/components/smartsheet/Toolbar.vue
  26. 1
      packages/nc-gui/components/smartsheet/Topbar.vue
  27. 1
      packages/nc-gui/composables/useGlobal/types.ts
  28. 80
      packages/nc-gui/composables/useProductFeed.ts
  29. 1
      packages/nc-gui/lang/en.json
  30. 5
      packages/nc-gui/layouts/shared-view.vue
  31. 12
      packages/nc-gui/lib/types.ts
  32. 9
      packages/nc-gui/package.json
  33. 7
      packages/nc-gui/pages/index/[typeOrId]/feed.vue
  34. 5
      packages/nc-gui/plugins/animation.ts
  35. 14
      packages/nc-gui/store/workspace.ts
  36. 15
      packages/nc-gui/utils/iconUtils.ts
  37. 24
      packages/nc-gui/utils/urlUtils.ts
  38. 51
      packages/nocodb-sdk/src/lib/Api.ts
  39. 20
      packages/nocodb/package.json
  40. 7
      packages/nocodb/src/controllers/utils.controller.ts
  41. 81
      packages/nocodb/src/schema/swagger.json
  42. 81
      packages/nocodb/src/services/utils.service.ts
  43. 1
      packages/nocodb/src/utils/globals.ts
  44. 36
      packages/nocodb/src/utils/tele.ts
  45. 1515
      pnpm-lock.yaml

15
packages/nc-gui/assets/nc-icons/discord.svg

@ -1,5 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12.8516 3.88457C11.9593 3.47514 11.0024 3.17349 10.0019 3.00072C9.98372 2.99739 9.96552 3.00572 9.95613 3.02239C9.83307 3.24126 9.69676 3.52681 9.6013 3.75124C8.52524 3.59014 7.45469 3.59014 6.40069 3.75124C6.30521 3.52182 6.16395 3.24126 6.04033 3.02239C6.03095 3.00628 6.01275 2.99795 5.99453 3.00072C4.99461 3.17294 4.03774 3.47459 3.14488 3.88457C3.13715 3.88791 3.13052 3.89347 3.12613 3.90068C1.31115 6.61223 0.813946 9.25712 1.05786 11.8692C1.05896 11.882 1.06613 11.8942 1.07607 11.902C2.27354 12.7814 3.4335 13.3153 4.57191 13.6691C4.59013 13.6747 4.60944 13.668 4.62103 13.653C4.89032 13.2853 5.13037 12.8975 5.33619 12.4897C5.34834 12.4659 5.33675 12.4375 5.31192 12.4281C4.93116 12.2836 4.5686 12.1075 4.21984 11.9076C4.19226 11.8915 4.19005 11.852 4.21543 11.8331C4.28882 11.7781 4.36223 11.7209 4.43231 11.6631C4.44499 11.6526 4.46265 11.6503 4.47756 11.657C6.76875 12.7031 9.24923 12.7031 11.5134 11.657C11.5283 11.6498 11.546 11.652 11.5592 11.6626C11.6293 11.7203 11.7027 11.7781 11.7766 11.8331C11.802 11.852 11.8003 11.8915 11.7728 11.9076C11.424 12.1114 11.0614 12.2836 10.6801 12.4275C10.6553 12.437 10.6443 12.4659 10.6564 12.4897C10.8666 12.8969 11.1067 13.2847 11.371 13.6525C11.3821 13.668 11.4019 13.6747 11.4201 13.6691C12.5641 13.3153 13.724 12.7814 14.9215 11.902C14.932 11.8942 14.9386 11.8826 14.9397 11.8698C15.2316 8.8499 14.4508 6.2267 12.8698 3.90124C12.8659 3.89347 12.8593 3.88791 12.8516 3.88457ZM5.67835 10.2787C4.98854 10.2787 4.42016 9.64544 4.42016 8.86769C4.42016 8.08994 4.97752 7.45665 5.67835 7.45665C6.38468 7.45665 6.94755 8.0955 6.93651 8.86769C6.93651 9.64544 6.37915 10.2787 5.67835 10.2787ZM10.3303 10.2787C9.64048 10.2787 9.0721 9.64544 9.0721 8.86769C9.0721 8.08994 9.62944 7.45665 10.3303 7.45665C11.0366 7.45665 11.5995 8.0955 11.5885 8.86769C11.5885 9.64544 11.0366 10.2787 10.3303 10.2787Z"
fill="#5865F2" />
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Social Icons" clip-path="url(#clip0_304_20533)">
<path id="Vector" d="M16.9308 3.4629C15.6561 2.87799 14.2892 2.44707 12.8599 2.20025C12.8339 2.19549 12.8079 2.20739 12.7945 2.2312C12.6187 2.54388 12.4239 2.9518 12.2876 3.27242C10.7503 3.04228 9.22099 3.04228 7.71527 3.27242C7.57887 2.94467 7.37707 2.54388 7.20048 2.2312C7.18707 2.20819 7.16107 2.19629 7.13504 2.20025C5.70659 2.44628 4.33963 2.87721 3.06411 3.4629C3.05307 3.46766 3.04361 3.4756 3.03732 3.48591C0.444493 7.35954 -0.265792 11.138 0.0826501 14.8695C0.0842267 14.8878 0.0944749 14.9053 0.108665 14.9164C1.81934 16.1726 3.47642 16.9353 5.10273 17.4408C5.12876 17.4488 5.15634 17.4393 5.1729 17.4178C5.55761 16.8925 5.90054 16.3385 6.19456 15.756C6.21192 15.7219 6.19535 15.6814 6.15989 15.6679C5.61594 15.4616 5.098 15.21 4.59977 14.9243C4.56037 14.9013 4.55721 14.8449 4.59346 14.8179C4.69831 14.7394 4.80318 14.6576 4.9033 14.5751C4.92141 14.56 4.94665 14.5568 4.96794 14.5664C8.24107 16.0608 11.7846 16.0608 15.0191 14.5664C15.0404 14.5561 15.0657 14.5592 15.0846 14.5743C15.1847 14.6568 15.2895 14.7394 15.3952 14.8179C15.4314 14.8449 15.4291 14.9013 15.3897 14.9243C14.8914 15.2155 14.3735 15.4616 13.8288 15.6671C13.7933 15.6806 13.7775 15.7219 13.7949 15.756C14.0952 16.3377 14.4381 16.8917 14.8157 17.417C14.8315 17.4393 14.8599 17.4488 14.8859 17.4408C16.5201 16.9353 18.1772 16.1726 19.8879 14.9164C19.9028 14.9053 19.9123 14.8886 19.9139 14.8703C20.3309 10.5562 19.2154 6.80878 16.9568 3.4867C16.9513 3.4756 16.9419 3.46766 16.9308 3.4629ZM6.68335 12.5974C5.69792 12.5974 4.88594 11.6927 4.88594 10.5816C4.88594 9.47056 5.68217 8.56585 6.68335 8.56585C7.69239 8.56585 8.49651 9.4785 8.48073 10.5816C8.48073 11.6927 7.68451 12.5974 6.68335 12.5974ZM13.329 12.5974C12.3435 12.5974 11.5316 11.6927 11.5316 10.5816C11.5316 9.47056 12.3278 8.56585 13.329 8.56585C14.338 8.56585 15.1421 9.4785 15.1264 10.5816C15.1264 11.6927 14.338 12.5974 13.329 12.5974Z" fill="#5865F2"/>
</g>
<defs>
<clipPath id="clip0_304_20533">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

5
packages/nc-gui/assets/nc-icons/megaphone.svg

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="megaphone">
<path id="Vector" d="M7.19333 5.66732L13.5 1.33398L13.5 14.6673L7.19333 10.334M7.19333 5.66732L2.50003 5.66732L2.5 10.334H4.84667M7.19333 5.66732L7.19333 10.334M7.19333 10.334H4.84667M7.19333 10.334L7.19333 14.0006H4.84667L4.84667 10.334" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 460 B

13
packages/nc-gui/assets/nc-icons/nocodb.svg

@ -0,0 +1,13 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame">
<path id="Vector" d="M16.6797 1H3.32031C2.03884 1 1 2.03884 1 3.32031V16.6797C1 17.9612 2.03884 19 3.32031 19H16.6797C17.9612 19 19 17.9612 19 16.6797V3.32031C19 2.03884 17.9612 1 16.6797 1Z" fill="white"/>
<path id="Vector_2" d="M16.6797 1H3.32031C2.03884 1 1 2.03884 1 3.32031V16.6797C1 17.9612 2.03884 19 3.32031 19H16.6797C17.9612 19 19 17.9612 19 16.6797V3.32031C19 2.03884 17.9612 1 16.6797 1Z" fill="url(#paint0_linear_304_21100)"/>
<path id="Vector_3" d="M4.86719 9.50131L7.31271 11.9484V15.8224H4.86719V9.50131ZM15.139 4.29891V15.4234C15.139 15.6518 14.9527 15.8365 14.7244 15.8365C14.6148 15.8365 14.51 15.7942 14.4318 15.716L4.86719 7.0902V4.64938C4.86719 4.42094 5.05182 4.23633 5.28025 4.23633H5.30217C5.41169 4.23633 5.51807 4.28014 5.59474 4.35681L12.6919 10.5151V4.29891H15.139Z" fill="white"/>
</g>
<defs>
<linearGradient id="paint0_linear_304_21100" x1="9.99859" y1="25.0599" x2="9.99859" y2="-4.04602" gradientUnits="userSpaceOnUse">
<stop stop-color="#4351E8"/>
<stop offset="1" stop-color="#2A1EA5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

18
packages/nc-gui/assets/nc-icons/reddit.svg

@ -1,7 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M8 15C11.866 15 15 11.866 15 8C15 4.13401 11.866 1 8 1C4.13401 1 1 4.13401 1 8C1 11.866 4.13401 15 8 15Z"
fill="#FF4500" />
<path
d="M12.6667 8.00008C12.6667 7.43517 12.2082 6.97669 11.6433 6.97669C11.3649 6.97669 11.1193 7.08312 10.9392 7.26324C10.2433 6.76382 9.27722 6.43634 8.21289 6.3954L8.67956 4.20944L10.1942 4.52874C10.2106 4.91353 10.5298 5.22464 10.9228 5.22464C11.324 5.22464 11.6515 4.89716 11.6515 4.49599C11.6515 4.09482 11.324 3.76733 10.9228 3.76733C10.6363 3.76733 10.3907 3.93108 10.276 4.17669L8.58131 3.81646C8.53219 3.80827 8.48307 3.81646 8.44213 3.84102C8.40119 3.86558 8.37663 3.90652 8.36026 3.95564L7.84447 6.3954C6.75558 6.42815 5.78131 6.74745 5.07722 7.26324C4.8971 7.09131 4.6433 6.97669 4.37312 6.97669C3.80821 6.97669 3.34973 7.43517 3.34973 8.00008C3.34973 8.41763 3.59535 8.76967 3.95558 8.93342C3.93921 9.03166 3.93102 9.1381 3.93102 9.24453C3.93102 10.8165 5.75675 12.0855 8.0164 12.0855C10.276 12.0855 12.1018 10.8165 12.1018 9.24453C12.1018 9.1381 12.0936 9.03985 12.0772 8.9416C12.4129 8.77786 12.6667 8.41763 12.6667 8.00008ZM5.66669 8.72874C5.66669 8.32757 5.99418 8.00008 6.39535 8.00008C6.79652 8.00008 7.124 8.32757 7.124 8.72874C7.124 9.12991 6.79652 9.45739 6.39535 9.45739C5.99418 9.45739 5.66669 9.12991 5.66669 8.72874ZM9.7357 10.6527C9.23628 11.1521 8.28657 11.1849 8.00821 11.1849C7.72985 11.1849 6.77195 11.1439 6.28073 10.6527C6.20704 10.579 6.20704 10.4562 6.28073 10.3825C6.35441 10.3089 6.47722 10.3089 6.5509 10.3825C6.86201 10.6936 7.53336 10.8083 8.0164 10.8083C8.49944 10.8083 9.1626 10.6936 9.4819 10.3825C9.55558 10.3089 9.67839 10.3089 9.75207 10.3825C9.80938 10.4644 9.80938 10.579 9.7357 10.6527ZM9.6047 9.45739C9.20353 9.45739 8.87605 9.12991 8.87605 8.72874C8.87605 8.32757 9.20353 8.00008 9.6047 8.00008C10.0059 8.00008 10.3334 8.32757 10.3334 8.72874C10.3334 9.12991 10.0059 9.45739 9.6047 9.45739Z"
fill="white" />
</svg>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Social Icons" clip-path="url(#clip0_304_20536)">
<path id="Vector" d="M10 20C15.5228 20 20 15.5228 20 10C20 4.47715 15.5228 0 10 0C4.47715 0 0 4.47715 0 10C0 15.5228 4.47715 20 10 20Z" fill="#FF4500"/>
<path id="Vector_2" d="M16.6666 9.99991C16.6666 9.19289 16.0117 8.53792 15.2046 8.53792C14.807 8.53792 14.4561 8.68997 14.1988 8.94728C13.2046 8.23383 11.8245 7.76599 10.3041 7.70751L10.9707 4.5847L13.1345 5.04084C13.1579 5.59055 13.614 6.035 14.1754 6.035C14.7485 6.035 15.2163 5.56716 15.2163 4.99406C15.2163 4.42096 14.7485 3.95312 14.1754 3.95312C13.766 3.95312 13.4152 4.18704 13.2514 4.53792L10.8304 4.0233C10.7602 4.01161 10.69 4.0233 10.6315 4.05839C10.5731 4.09348 10.538 4.15196 10.5146 4.22213L9.77774 7.70751C8.22219 7.7543 6.83037 8.21043 5.82453 8.94728C5.56722 8.70166 5.20464 8.53792 4.81868 8.53792C4.01166 8.53792 3.35669 9.19289 3.35669 9.99991C3.35669 10.5964 3.70757 11.0993 4.22219 11.3332C4.19879 11.4736 4.1871 11.6256 4.1871 11.7777C4.1871 14.0233 6.79529 15.8362 10.0234 15.8362C13.2514 15.8362 15.8596 14.0233 15.8596 11.7777C15.8596 11.6256 15.8479 11.4853 15.8245 11.3449C16.3041 11.111 16.6666 10.5964 16.6666 9.99991ZM6.66663 11.0408C6.66663 10.4677 7.13447 9.99991 7.70757 9.99991C8.28067 9.99991 8.7485 10.4677 8.7485 11.0408C8.7485 11.6139 8.28067 12.0818 7.70757 12.0818C7.13447 12.0818 6.66663 11.6139 6.66663 11.0408ZM12.4795 13.7894C11.766 14.5028 10.4093 14.5496 10.0117 14.5496C9.614 14.5496 8.24558 14.4911 7.54382 13.7894C7.43856 13.6841 7.43856 13.5087 7.54382 13.4034C7.64909 13.2982 7.82453 13.2982 7.92979 13.4034C8.37423 13.8479 9.3333 14.0116 10.0234 14.0116C10.7134 14.0116 11.6608 13.8479 12.1169 13.4034C12.2222 13.2982 12.3976 13.2982 12.5029 13.4034C12.5848 13.5204 12.5848 13.6841 12.4795 13.7894ZM12.2924 12.0818C11.7193 12.0818 11.2514 11.6139 11.2514 11.0408C11.2514 10.4677 11.7193 9.99991 12.2924 9.99991C12.8655 9.99991 13.3333 10.4677 13.3333 11.0408C13.3333 11.6139 12.8655 12.0818 12.2924 12.0818Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_304_20536">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

12
packages/nc-gui/assets/nc-icons/refresh-cw.svg

@ -0,0 +1,12 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="refresh-cw" clip-path="url(#clip0_195_4453)">
<path id="Vector" d="M1.16699 13.3335V9.3335H5.16699" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_2" d="M15.833 2.6665V6.6665H11.833" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_3" d="M2.84033 5.99989C3.17844 5.04441 3.75308 4.19016 4.51064 3.51683C5.26819 2.84351 6.18397 2.37306 7.17252 2.14939C8.16106 1.92572 9.19016 1.95612 10.1638 2.23774C11.1374 2.51936 12.0238 3.04303 12.7403 3.75989L15.8337 6.66655M1.16699 9.33322L4.26033 12.2399C4.97682 12.9567 5.86324 13.4804 6.83687 13.762C7.81049 14.0437 8.83959 14.0741 9.82813 13.8504C10.8167 13.6267 11.7325 13.1563 12.49 12.4829C13.2476 11.8096 13.8222 10.9554 14.1603 9.99989" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_195_4453">
<rect width="16" height="16" fill="white" transform="translate(0.5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

2
packages/nc-gui/assets/nc-icons/star.svg

@ -1,5 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="star">
<path id="Vector" d="M7.99992 1.33333L10.0599 5.50666L14.6666 6.18L11.3333 9.42666L12.1199 14.0133L7.99992 11.8467L3.87992 14.0133L4.66659 9.42666L1.33325 6.18L5.93992 5.50666L7.99992 1.33333Z" stroke="#374151" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M7.99992 1.33333L10.0599 5.50666L14.6666 6.18L11.3333 9.42666L12.1199 14.0133L7.99992 11.8467L3.87992 14.0133L4.66659 9.42666L1.33325 6.18L5.93992 5.50666L7.99992 1.33333Z" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 410 B

10
packages/nc-gui/assets/nc-icons/twitter-x-line.svg

@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Social Icons" clip-path="url(#clip0_304_20527)">
<path id="Vector" d="M0.0390097 0.666016L6.21643 8.92581L0 15.6414H1.39907L6.84153 9.76182L11.2389 15.6414H16L9.475 6.91698L15.2612 0.666016H13.8621L8.8499 6.08098L4.8001 0.666016H0.0390097ZM2.09644 1.69657H4.2837L13.9422 14.6106H11.755L2.09644 1.69657Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_304_20527">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 537 B

11
packages/nc-gui/assets/nc-icons/youtube2.svg

@ -0,0 +1,11 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Social Icons" clip-path="url(#clip0_304_20530)">
<path id="Vector" d="M19.6016 5.15508C19.4885 4.72959 19.2657 4.34126 18.9554 4.02896C18.6451 3.71665 18.2582 3.49133 17.8334 3.37553C16.2698 2.95508 10.0198 2.95508 10.0198 2.95508C10.0198 2.95508 3.76978 2.95508 2.20614 3.37553C1.78138 3.49133 1.39449 3.71665 1.08418 4.02896C0.773872 4.34126 0.55103 4.72959 0.437957 5.15508C0.0197754 6.72553 0.0197754 10.0005 0.0197754 10.0005C0.0197754 10.0005 0.0197754 13.2755 0.437957 14.846C0.55103 15.2715 0.773872 15.6598 1.08418 15.9721C1.39449 16.2844 1.78138 16.5097 2.20614 16.6255C3.76978 17.046 10.0198 17.046 10.0198 17.046C10.0198 17.046 16.2698 17.046 17.8334 16.6255C18.2582 16.5097 18.6451 16.2844 18.9554 15.9721C19.2657 15.6598 19.4885 15.2715 19.6016 14.846C20.0198 13.2755 20.0198 10.0005 20.0198 10.0005C20.0198 10.0005 20.0198 6.72553 19.6016 5.15508Z" fill="#FF0302"/>
<path id="Vector_2" d="M7.97437 12.9731V7.02539L13.2016 9.99925L7.97437 12.9731Z" fill="#FEFEFE"/>
</g>
<defs>
<clipPath id="clip0_304_20530">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

3
packages/nc-gui/components/dashboard/Sidebar.vue

@ -5,7 +5,7 @@ const { isWorkspaceLoading } = storeToRefs(workspaceStore)
const { isSharedBase } = storeToRefs(useBase())
const { isMobileMode } = useGlobal()
const { isMobileMode, appInfo } = useGlobal()
const treeViewDom = ref<HTMLElement>()
@ -60,6 +60,7 @@ onUnmounted(() => {
<GeneralGift v-if="!isEeUI" />
<div class="border-t-1 w-full"></div>
<DashboardSidebarBeforeUserInfo />
<DashboardSidebarFeed v-if="appInfo.feedEnabled" />
<DashboardSidebarUserInfo />
</div>
</div>

110
packages/nc-gui/components/dashboard/Sidebar/Feed.vue

@ -0,0 +1,110 @@
<script setup lang="ts">
import dayjs from 'dayjs'
const workspaceStore = useWorkspace()
const { navigateToFeed } = workspaceStore
const { isFeedPageOpened } = storeToRefs(workspaceStore)
const { appInfo } = useGlobal()
const { loadFeed, socialFeed } = useProductFeed()
const isNewFeedAvailable = ref(false)
const checkNewFeed = async () => {
try {
await loadFeed({ type: 'all', loadMore: false })
if (!socialFeed.value.length) return
const [latestFeed] = socialFeed.value
const lastFeedTime = localStorage.getItem('lastFeedPublishedTime')
const lastFeed = dayjs(lastFeedTime)
if (!lastFeed.isValid() || dayjs(latestFeed['Published Time']).isAfter(lastFeed)) {
isNewFeedAvailable.value = true
localStorage.setItem('lastFeedPublishedTime', latestFeed['Published Time'])
}
} catch (error) {
console.error('Error while checking new feed', error)
}
}
const intervalId = ref()
const checkFeedWithInterval = async () => {
await checkNewFeed()
intervalId.value = setTimeout(checkFeedWithInterval, 3 * 60 * 60 * 1000)
}
onMounted(() => {
if (appInfo.value.feedEnabled) {
checkFeedWithInterval()
}
})
onUnmounted(() => {
if (intervalId.value) {
clearTimeout(intervalId.value)
intervalId.value = null
}
})
const gotoFeed = () => navigateToFeed()
</script>
<template>
<NcButton
v-e="['c:product-feed']"
type="text"
full-width
size="xsmall"
class="n!xs:hidden my-0.5 !h-7 w-full !rounded-md !font-normal !px-3"
data-testid="nc-sidebar-product-feed"
:centered="false"
:class="{
'!text-brand-600 !bg-brand-50 !hover:bg-brand-50': isFeedPageOpened,
'!hover:(bg-gray-200 text-gray-700)': !isFeedPageOpened,
}"
@click="gotoFeed"
>
<div
class="flex !w-full items-center gap-2"
:class="{
'font-semibold': isFeedPageOpened,
}"
>
<div class="flex flex-1 w-full items-center gap-3">
<GeneralIcon icon="megaPhone" class="!h-4" />
<span class="">Whats New!</span>
</div>
<div v-if="isNewFeedAvailable" class="w-3 h-3 pulsing-dot bg-nc-fill-red-medium border-2 border-white rounded-full"></div>
</div>
</NcButton>
</template>
<style scoped lang="scss">
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.pulsing-dot {
animation: pulse 1.5s infinite ease-in-out;
}
:deep(.nc-btn-inner) {
@apply !w-full;
}
</style>

194
packages/nc-gui/components/feed/Changelog/Item.vue

@ -0,0 +1,194 @@
<script setup lang="ts">
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
import dayjs from 'dayjs'
import type { ProductFeedItem } from '../../../lib/types'
const props = defineProps<{
item: ProductFeedItem
index: number
}>()
const {
item: { 'Published Time': CreatedAt, Description, Title, Tags, Images },
} = props
const iconColorMap = {
'Hotfix': {
icon: iconMap.ncTool,
color: 'red',
},
'Feature': {
icon: iconMap.star,
color: 'purple',
},
'Bug Fixes': {
icon: iconMap.ncTool,
color: 'green',
},
}
const tags = computed(() => [
...(props.index === 0
? [
{
text: 'Latest Release',
color: 'purple',
},
]
: []),
...(Tags?.split(',').map((tag) => ({
text: tag,
...(iconColorMap[tag] || {}),
})) || []),
])
const { getPossibleAttachmentSrc } = useAttachment()
const renderMarkdown = async (markdown: string) => {
return await unified().use(remarkParse).use(remarkRehype).use(rehypeSanitize).use(rehypeStringify).process(markdown)
}
const truncate = ref(true)
const renderedText = computedAsync(async () => {
return await renderMarkdown(
truncate.value
? Description.replace(/[*_~]|\[.*?\]|<\/?[^>]+(>|$)/g, '')
.replace(/\(https?:\/\/[^\s)]+\)\]\(https?:\/\/[^\s)]+\)/g, '')
.replace(/^(\*\*)?#?\s*(\p{Emoji})\s*NocoDB\s*v[\d.]+(\s*-\s*|\*\*$)/u, '# ')
.replace(/(!?\(https?:\/\/[^\s)]+\)(?:\]\(https?:\/\/[^\s)]+(?:\s+"[^"]*")?\))?)/g, '')
.replace('-', '')
.substring(0, 100)
.concat('...')
: Description.replace(/^\[!\[.*?\]\(https?:\/\/.*?\)\]\(https?:\/\/.*?\)/m, '').replace(
/^(\*\*)?#?\s*(\p{Emoji})\s*NocoDB\s*v[\d.]+(\s*-\s*|\*\*$)/u,
'# ',
),
)
})
const { $e } = useNuxtApp()
const expand = (e) => {
e.stopPropagation()
truncate.value = false
$e('c:nocodb:feed:changelog:expand', {
title: Title,
})
}
</script>
<template>
<div class="relative rounded-xl flex flex-col mt-6.25 bg-white changelog-card">
<div
class="w-full relative border cursor-pointer border-black h-[334px] xl:h-[394px] w-[540px] xl:w-[638px] border-opacity-10 rounded-t-xl overflow-hidden"
@click="openLink(item.Url)"
>
<LazyCellAttachmentPreviewImage
:srcs="getPossibleAttachmentSrc(Images[0] ?? [], 'card_cover')"
class="absolute w-full h-full inset-0 object-cover transition-all ease-in-out transform hover:scale-105"
/>
</div>
<div class="flex my-4 px-4 items-center justify-between">
<div class="flex items-center">
<NcBadge :border="false" color="brand" class="font-semibold text-[13px] mr-3 nc-title-badge cursor-pointer">
{{ Title }}
</NcBadge>
<span
v-for="tag in tags"
:key="tag.text"
:class="{
'bg-red-50': tag.color === 'red',
'bg-purple-50': tag.color === 'purple',
'bg-green-50': tag.color === 'green',
}"
class="mr-3 flex gap-2 items-center px-1 rounded-md"
>
<component
:is="tag.icon"
:class="{
'fill-red-700 text-transparent': tag.color === 'red',
'fill-purple-700 text-transparent': tag.color === 'purple',
'fill-green-700 text-transparent': tag.color === 'green',
}"
class="w-4 h-4"
/>
<span
:class="{
'text-red-500': tag.color === 'red',
'text-purple-500': tag.color === 'purple',
'text-green-700': tag.color === 'green',
}"
class="leading-5 text-[13px]"
>
{{ tag.text }}
</span>
</span>
</div>
<span class="font-medium text-sm text-gray-500">
{{ dayjs(CreatedAt).format('MMM DD, YYYY') }}
</span>
</div>
<div class="flex flex-1 px-4 pb-3 justify-between flex-col gap-2">
<div class="prose max-w-none" v-html="renderedText"></div>
</div>
<NcButton v-if="truncate" size="small" class="w-29 mx-4 mb-3" type="text" @click="expand">
<div class="gap-2 flex items-center">
Show more
<GeneralIcon icon="arrowDown" />
</div>
</NcButton>
</div>
</template>
<style scoped lang="scss">
.nc-title-badge {
width: fit-content;
}
.changelog-card {
@apply transform transition-all ease-in-out;
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
}
a {
@apply !no-underline;
}
:deep(.prose) {
@apply !max-w-auto;
a {
@apply text-gray-900;
}
h1 {
@apply text-3xl text-nc-content-gray-emphasis leading-9 mb-0;
font-weight: 700;
}
h2 {
@apply text-nc-content-gray-emphasis text-xl leading-6 !my-4;
}
p {
@apply text-nc-content-gray-emphasis leading-6;
font-size: 14px !important;
}
li {
@apply text-nc-content-gray-emphasis leading-6;
font-size: 14px !important;
}
h3 {
@apply text-nc-content-gray-emphasis text-lg leading-6 mb-0;
}
img {
@apply !my-4;
}
}
</style>

40
packages/nc-gui/components/feed/Changelog/index.vue

@ -0,0 +1,40 @@
<script setup lang="ts">
const { loadFeed, githubFeed, isErrorOccurred } = useProductFeed()
const scrollContainer = ref<HTMLElement>()
const { isLoading } = useInfiniteScroll(
scrollContainer,
async () => {
if (isLoading.value) return
await loadFeed({
type: 'github',
loadMore: true,
})
},
{ distance: 4 },
)
</script>
<template>
<div
ref="scrollContainer"
:style="{
height: 'calc(100dvh - var(--toolbar-height) - 3rem)',
}"
class="overflow-y-auto nc-scrollbar-md mx-auto w-full"
>
<div v-if="isErrorOccurred?.github && !githubFeed.length" class="h-full flex justify-center items-center">
<FeedError page="github" />
</div>
<div v-else-if="isLoading && !githubFeed.length" class="flex items-center justify-center h-full w-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="mx-auto max-w-[540px] xl:max-w-[638px] justify-around justify-items-center">
<FeedChangelogItem v-for="(feed, index) in githubFeed" :key="feed.Id" :item="feed" :index="index" />
</div>
</div>
</template>
<style scoped lang="scss"></style>

44
packages/nc-gui/components/feed/Error.vue

@ -0,0 +1,44 @@
<script setup lang="ts">
const props = defineProps<{
page: 'all' | 'youtube' | 'github'
}>()
const { loadFeed, socialFeed, youtubeFeed, githubFeed } = useProductFeed()
const triggerReload = async () => {
const data = (await loadFeed({
type: props.page,
loadMore: false,
}))!.filter((item) => item['Feed Source'] !== 'Twitter')
if (props.page === 'all') {
socialFeed.value = data
} else if (props.page === 'youtube') {
youtubeFeed.value = data
} else if (props.page === 'github') {
githubFeed.value = data
}
}
</script>
<template>
<div class="flex items-center justify-center">
<div class="w-[696px] error-box gap-6 border-1 border-gray-200 py-6 rounded-xl flex flex-col items-center justify-center">
<GeneralIcon icon="alertTriangle" class="text-gray-500 w-8 h-8" />
<span class="text-gray-600 text-base font-semibold"> Unable to load feed </span>
<NcButton type="secondary" size="small" @click="triggerReload">
<div class="flex items-center text-gray-700 gap-2">
<GeneralIcon icon="refreshCw" />
<span class="text-sm"> Refresh </span>
</div>
</NcButton>
</div>
</div>
</template>
<style scoped lang="scss">
.error-box {
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
}
</style>

13
packages/nc-gui/components/feed/Header.vue

@ -0,0 +1,13 @@
<script setup lang="ts"></script>
<template>
<div class="w-full px-3 h-12 border-b-1 border-gray-200 py-2">
<div class="flex items-center gap-3">
<GeneralIcon icon="megaPhone" class="text-nc-content-brand" />
<span class="text-gray-800 text-xl font-medium"> Whats New! </span>
</div>
</div>
</template>
<style scoped lang="scss"></style>

138
packages/nc-gui/components/feed/Recents/Card.vue

@ -0,0 +1,138 @@
<script setup lang="ts">
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import { YoutubeVue3 } from 'youtube-vue3'
import type { ProductFeedItem } from '../../../lib/types'
import { extractYoutubeVideoId } from '../../../utils/urlUtils'
import { timeAgo } from '~/utils/datetimeUtils'
const props = defineProps<{
item: ProductFeedItem
}>()
const { getPossibleAttachmentSrc } = useAttachment()
const {
item: { 'Published Time': CreatedAt, Description, Url, Title, 'Feed Source': source, Images },
} = props
const feedIcon = {
Twitter: iconMap.twitter,
Youtube: iconMap.youtube,
Github: iconMap.githubSolid,
}
const truncate = ref(true)
const { $e } = useNuxtApp()
const expand = () => {
truncate.value = false
$e('c:nocodb:feed:recents:expand', {
title: Title,
})
}
const renderedText = computedAsync(async () => {
return await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(
truncate.value
? Description.replace(/!\[.*?\]\(.*?\)/g, '')
.substring(0, 250)
.concat('...')
: Description.replace(/^\[!\[.*?\]\(https?:\/\/.*?\)\]\(https?:\/\/.*?\)/m, ''),
)
})
const { width } = useWindowSize()
</script>
<template>
<div class="bg-white recent-card border-gray-200 border-1 rounded-2xl max-w-[540px] xl:max-w-[640px]">
<div class="flex items-center justify-between px-5 py-4">
<div class="flex items-center gap-3">
<component :is="feedIcon[source as any]" class="w-4 h-4 stroke-transparent" />
<span class="font-weight-medium text-nc-content-gray leading-5 cursor-pointer" @click="openLink(Url)">
{{ source }}
</span>
</div>
<div class="text-sm text-nc-content-gray-muted leading-5">
{{ timeAgo(CreatedAt) }}
</div>
</div>
<template v-if="source === 'Github'">
<div class="pb-5">
<LazyCellAttachmentPreviewImage
v-if="Images?.length"
class="cursor-pointer"
:srcs="getPossibleAttachmentSrc(Images[0], 'card_cover')"
@click="openLink(Url)"
/>
<div class="prose px-5 mt-5" v-html="renderedText"></div>
<NcButton v-if="truncate" size="small" class="w-29 mx-5" type="text" @click="expand">
<div class="gap-2 flex items-center">
Show more
<GeneralIcon icon="arrowDown" />
</div>
</NcButton>
</div>
</template>
<template v-else-if="source === 'Youtube'">
<YoutubeVue3
:videoid="extractYoutubeVideoId(Url)"
:controls="1"
:height="width < 1280 ? 330 : 392"
:width="width < 1280 ? 538 : 638"
:autoplay="0"
/>
<div class="p-5 flex flex-col text-nc-content-gray-emphasis gap-4">
<div class="text-2xl font-semibold truncate">
{{ Title }}
</div>
<div class="font-weight-base text-md">
{{ Description.substring(0, 200).concat('...') }}
</div>
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.recent-card {
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
:deep(.prose) {
a {
@apply text-gray-900;
}
h1 {
@apply text-3xl text-nc-content-gray-emphasis truncate leading-9 mb-0;
font-weight: 700;
}
h2 {
@apply text-nc-content-gray-emphasis text-xl leading-6 !mb-0;
}
p {
@apply text-nc-content-gray-emphasis leading-6;
font-size: 14px !important;
}
li {
@apply text-nc-content-gray-emphasis leading-6;
font-size: 14px !important;
}
h3 {
@apply text-nc-content-gray-emphasis text-lg leading-6 mb-0;
}
}
}
</style>

37
packages/nc-gui/components/feed/Recents/index.vue

@ -0,0 +1,37 @@
<script setup lang="ts">
const { socialFeed, loadFeed, isErrorOccurred } = useProductFeed()
const scrollContainer = ref<HTMLElement>()
const { isLoading } = useInfiniteScroll(
scrollContainer,
async () => {
if (isLoading.value) return
await loadFeed({
type: 'all',
loadMore: true,
})
},
{ distance: 1, interval: 2000 },
)
</script>
<template>
<div
ref="scrollContainer"
:style="{
height: 'calc(100dvh - var(--toolbar-height) - 3.1rem)',
}"
class="overflow-y-auto nc-scrollbar-md w-full"
>
<div v-if="isErrorOccurred?.social && !socialFeed.length" class="h-full flex justify-center items-center">
<FeedError page="all" />
</div>
<div v-else-if="isLoading && !socialFeed.length" class="flex items-center justify-center h-full w-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="flex flex-col my-6 items-center gap-6">
<FeedRecentsCard v-for="feed in socialFeed" :key="feed.Id" :item="feed" />
</div>
</div>
</template>

35
packages/nc-gui/components/feed/Roadmap.vue

@ -0,0 +1,35 @@
<script setup lang="ts">
const iFrame = ref<HTMLIFrameElement | null>(null)
const isLoaded = ref(false)
const handleIframeLoad = () => {
isLoaded.value = true
}
</script>
<template>
<div
:style="{
height: 'calc(100dvh - var(--toolbar-height) + 4rem)',
}"
>
<div v-if="!isLoaded" class="flex items-center justify-center h-full w-full">
<GeneralLoader size="xlarge" />
</div>
<iframe
ref="iFrame"
src="https://w21dqb1x.nocodb.com/#/nc/kanban/d719962a-1666-464f-8789-054a13a747f7?disableTopbar=true&disableToolbar=true"
width="100%"
height="100%"
style="border: none"
@load="handleIframeLoad"
></iframe>
</div>
<div v-if="!isLoaded" class="flex items-center justify-center h-full">
<NcLoader />
</div>
</template>
<style scoped lang="scss"></style>

82
packages/nc-gui/components/feed/Social.vue

@ -0,0 +1,82 @@
<script setup lang="ts">
const { $e } = useNuxtApp()
const socialIcons = [
{
name: '@nocodb',
icon: iconMap.iconTwitter,
link: 'https://twitter.com/nocodb',
e: 'c:feed::twitter-open',
},
{
name: 'NocoDB',
icon: iconMap.youtube,
e: 'c:feed:youtube-open',
link: 'https://www.youtube.com/@nocodb',
},
{
name: 'NocoDB',
icon: iconMap.iconDiscord,
e: 'c:feed:discord-open',
link: 'http://discord.nocodb.com',
},
{
name: 'r/NocoDB',
icon: iconMap.iconReddit,
e: 'c:feed:reddit-open',
link: 'https://www.reddit.com/r/NocoDB/',
},
{
name: 'Forum',
icon: iconMap.nocodb,
e: 'c:feed:forum-open',
link: 'https://community.nocodb.com/',
},
]
const openUrl = (url: string, e: string) => {
$e(e)
window.open(url, '_blank')
}
</script>
<template>
<div style="width: 230px" class="flex flex-col bg-white border-gray-200 rounded-lg border-1">
<div class="text-gray-800 font-semibold leading-6 border-b-1 border-gray-200 px-4 py-3">Stay tuned</div>
<div class="flex flex-col">
<div
v-for="social in socialIcons"
:key="social.name"
class="flex items-center social-icon-wrapper cursor-pointer rounded-lg hover:bg-gray-100 py-3 px-4 gap-2 text-gray-800"
@click="openUrl(social.link, social.e)"
>
<component :is="social.icon" class="w-5 h-5 stroke-transparent social-icon" />
<span class="font-semibold">{{ social.name }}</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.social-icon {
// Make icon black and white
filter: grayscale(100%);
// Make icon color on hover
&:hover {
filter: grayscale(100%) invert(100%);
}
}
.social-icon-wrapper {
.nc-icon {
@apply mr-0.15;
}
&:hover {
.social-icon {
filter: none !important;
}
}
}
</style>

37
packages/nc-gui/components/feed/Twitter.vue

@ -0,0 +1,37 @@
<script setup lang="ts">
const isLoaded = ref(false)
const scriptTag = ref()
const handleIframeLoad = () => {
setTimeout(() => {
isLoaded.value = true
}, 2000)
}
onMounted(() => {
scriptTag.value.src = 'https://platform.twitter.com/widgets.js'
})
</script>
<template>
<div
ref="scrollContainer"
:style="{
height: 'calc(100dvh - var(--toolbar-height) - 3.25rem)',
}"
class="overflow-y-auto nc-scrollbar-md w-full"
>
<div v-if="!isLoaded" class="flex items-center justify-center h-full w-full">
<GeneralLoader size="xlarge" />
</div>
<div class="mx-auto flex flex-col my-6 items-center">
<div style="min-width: 650px">
<a class="twitter-timeline" href="https://twitter.com/nocodb?ref_src=twsrc%5Etfw"></a>
<Script ref="scriptTag" async charset="utf-8" @load="handleIframeLoad"></Script>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

111
packages/nc-gui/components/feed/View.vue

@ -0,0 +1,111 @@
<script setup lang="ts">
import FeedRecents from './Recents/index.vue'
import FeedChangelog from './Changelog/index.vue'
import FeedYoutube from './Youtube/index.vue'
import FeedTwitter from './Twitter.vue'
// import FeedRoadmap from './Roadmap.vue'
const { activeTab } = useProductFeed()
const { $e } = useNuxtApp()
const tabs: Array<{
key: string
icon: keyof typeof iconMap
title: string
container: any
}> = [
{
key: 'recents',
icon: 'ncClock',
title: 'Recents',
container: FeedRecents,
},
{
key: 'changelog',
icon: 'ncList',
title: 'Changelog',
container: FeedChangelog,
},
/* {
key: 'roadmap',
icon: 'ncMapPin',
title: 'Roadmap',
container: FeedRoadmap,
}, */
{
key: 'youtube',
icon: 'ncYoutube',
title: 'Youtube',
container: FeedYoutube,
},
{
key: 'twitter',
icon: 'ncTwitter',
title: 'Twitter',
container: FeedTwitter,
},
]
const updateTab = (key: string) => {
$e(`c:nocodb:feed, tab:${key}`)
}
</script>
<template>
<FeedHeader />
<div class="flex flex-col h-full">
<NcTabs v-model:activeKey="activeTab" centered>
<a-tab-pane v-for="tab in tabs" :key="tab.key" class="bg-gray-50 !h-full">
<template #tab>
<div class="flex gap-2 items-center" @click="updateTab(tab.key)">
<GeneralIcon
:class="{
'text-brand-500': activeTab === tab.key,
'text-gray-600': activeTab !== tab.key,
}"
:icon="tab.icon as any"
/>
<span
:class="{
'text-brand-500 font-medium': activeTab === tab.key,
'text-gray-700': activeTab !== tab.key,
}"
class="text-sm"
>{{ tab.title }}
</span>
</div>
</template>
<div class="relative">
<FeedSocial
:class="{
'normal-left': tab.key === 'recents' || tab.key === 'youtube',
'changelog-left': tab.key === 'changelog',
'changelog-twitter': tab.key === 'twitter',
}"
class="absolute social-card"
/>
<component :is="tab.container" />
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
<style scoped lang="scss">
.social-card {
top: 24px;
}
.normal-left {
@apply xl:left-[calc(50%+350px)] left-[calc(50%+300px)];
}
.changelog-left {
@apply xl:left-[calc(50%+350px)] left-[calc(50%+300px)];
}
.changelog-twitter {
left: calc(50% + 350px);
}
</style>

44
packages/nc-gui/components/feed/Youtube/Player.vue

@ -0,0 +1,44 @@
<script setup lang="ts">
import { YoutubeVue3 } from 'youtube-vue3'
import type { ProductFeedItem } from '../../../lib/types'
import { extractYoutubeVideoId } from '../../../utils/urlUtils'
const props = defineProps<{
item: ProductFeedItem
isRecent?: boolean
}>()
const {
item: { Title, Description, Url },
} = props
const { width } = useWindowSize()
</script>
<template>
<div class="mt-6 border-1 !bg-white recent-card !rounded-2xl border-gray-200">
<YoutubeVue3
:videoid="extractYoutubeVideoId(Url)"
class="!rounded-t-xl"
:height="width < 1280 ? 330 : 392"
:width="width < 1280 ? 538 : 638"
:autoplay="0"
:controls="1"
/>
<div class="text-nc-content-gray-emphasis flex flex-col p-5 gap-4">
<div class="font-bold leading-9 text-2xl">
{{ Title }}
</div>
<div class="text-md leading-5">
{{ Description.length > 200 ? `${Description.slice(0, 280)}...` : Description }}
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.recent-card {
box-shadow: 0px 4px 8px -2px rgba(0, 0, 0, 0.08), 0px 2px 4px -2px rgba(0, 0, 0, 0.04);
}
</style>

49
packages/nc-gui/components/feed/Youtube/index.vue

@ -0,0 +1,49 @@
<script setup lang="ts">
const { youtubeFeed, loadFeed, isErrorOccurred } = useProductFeed()
const scrollContainer = ref<HTMLElement>()
const { isLoading } = useInfiniteScroll(
scrollContainer,
async () => {
if (isLoading.value) return
await loadFeed({
type: 'youtube',
loadMore: true,
})
},
{ distance: 1, interval: 2000 },
)
const gotoChannel = () => {
window.open('https://www.youtube.com/@nocodb?ref=product_feed', '_blank')
}
</script>
<template>
<div
ref="scrollContainer"
:style="{
height: 'calc(100dvh - var(--toolbar-height) - 3rem)',
}"
class="overflow-y-auto nc-scrollbar-md mx-auto w-full"
>
<div v-if="isErrorOccurred?.youtube && !youtubeFeed.length" class="h-full flex justify-center items-center">
<FeedError page="youtube" />
</div>
<div v-else-if="isLoading && !youtubeFeed.length" class="flex items-center justify-center h-full w-full">
<GeneralLoader size="xlarge" />
</div>
<div v-else class="youtube-feed mx-auto">
<div class="flex gap-2 flex-col">
<FeedYoutubePlayer v-for="feed in youtubeFeed" :key="feed.Id" :item="feed" />
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.youtube-feed {
@apply max-w-[540px] xl:max-w-[640px];
}
</style>

1
packages/nc-gui/components/nc/Badge.vue

@ -25,6 +25,7 @@ const props = withDefaults(
'border-red-500 bg-red-100': props.color === 'red',
'border-maroon-500 bg-maroon-50': props.color === 'maroon',
'border-gray-500 bg-gray-50': props.color === 'grey',
'bg-brand-50 text-brand-500': props.color === 'brand',
'border-gray-300': !props.color,
'border-1': props.border,
'h-6': props.size === 'sm',

210
packages/nc-gui/components/nc/Popover.vue

@ -0,0 +1,210 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useMotion } from '@vueuse/motion'
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
placement: {
type: String,
default: 'center',
validator: (value) => ['top', 'bottom', 'left', 'right', 'center'].includes(value),
},
offset: {
type: Array,
default: () => [0, 10],
},
width: {
type: String,
default: 'auto',
},
zIndex: {
type: Number,
default: 1000,
},
closeOnClickOutside: {
type: Boolean,
default: true,
},
closeOnEsc: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['update:modelValue'])
const triggerRef = ref(null)
const popoverRef = ref(null)
const isOpen = useVModel(props, 'modelValue', emit)
const popoverStyle = computed(() => ({
position: 'fixed',
zIndex: props.zIndex,
width: props.width,
}))
const { variant } = useMotion(popoverRef, {
initial: {
opacity: 0,
scale: 0.1,
},
enter: {
opacity: 1,
scale: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 20,
},
},
leave: {
opacity: 0,
scale: 0.1,
transition: {
type: 'spring',
stiffness: 300,
damping: 20,
},
},
})
const updatePopoverPosition = () => {
if (!triggerRef.value || !popoverRef.value) return
const triggerRect = triggerRef.value.getBoundingClientRect()
const popoverRect = popoverRef.value.getBoundingClientRect()
let top, left
switch (props.placement) {
case 'top':
top = triggerRect.top - popoverRect.height - props.offset[1]
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0]
break
case 'bottom':
top = triggerRect.bottom + props.offset[1]
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0]
break
case 'left':
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1]
left = triggerRect.left - popoverRect.width - props.offset[0]
break
case 'right':
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1]
left = triggerRect.right + props.offset[0]
break
case 'center':
top = triggerRect.top + (triggerRect.height - popoverRect.height) / 2 + props.offset[1]
left = triggerRect.left + (triggerRect.width - popoverRect.width) / 2 + props.offset[0]
break
}
// Ensure the popover stays within the viewport
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
top = Math.max(0, Math.min(top, viewportHeight - popoverRect.height))
left = Math.max(0, Math.min(left, viewportWidth - popoverRect.width))
Object.assign(popoverRef.value.style, {
top: `${top}px`,
left: `${left}px`,
})
}
const openPopover = () => {
isOpen.value = true
nextTick(() => {
updatePopoverPosition()
variant.value = 'enter'
})
}
const closePopover = () => {
variant.value = 'leave'
setTimeout(() => {
isOpen.value = false
}, 300)
}
const handleClickOutside = (event) => {
if (
props.closeOnClickOutside &&
popoverRef.value &&
!popoverRef.value.contains(event.target) &&
!triggerRef.value.contains(event.target)
) {
closePopover()
}
}
const handleKeyDown = (event) => {
if (props.closeOnEsc && event.key === 'Escape') {
closePopover()
}
}
onMounted(() => {
if (props.closeOnClickOutside) document.addEventListener('mousedown', handleClickOutside)
if (props.closeOnEsc) document.addEventListener('keydown', handleKeyDown)
window.addEventListener('resize', updatePopoverPosition)
window.addEventListener('scroll', updatePopoverPosition)
})
onUnmounted(() => {
if (props.closeOnClickOutside) document.removeEventListener('mousedown', handleClickOutside)
if (props.closeOnEsc) document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('resize', updatePopoverPosition)
window.removeEventListener('scroll', updatePopoverPosition)
})
watch(
() => props.modelValue,
(newValue) => {
isOpen.value = newValue
if (newValue) {
nextTick(() => {
updatePopoverPosition()
variant.value = 'enter'
})
} else {
variant.value = 'leave'
}
},
)
</script>
<template>
<div>
<div ref="triggerRef" @click="openPopover">
<slot name="trigger" :open="openPopover" :close="closePopover" :is-open="isOpen">
<button>Open Popover</button>
</slot>
</div>
<Teleport to="body">
<div v-if="isOpen" ref="popoverRef" v-motion :style="popoverStyle" class="popover-content">
<slot name="content" :close="closePopover">
<div class="p-4">
<p>Default popover content</p>
<button @click="closePopover">Close</button>
</div>
</slot>
</div>
</Teleport>
</div>
</template>
<style scoped>
.popover-content {
background-color: white;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
</style>

6
packages/nc-gui/components/smartsheet/Toolbar.vue

@ -12,6 +12,10 @@ const containerRef = ref<HTMLElement>()
const { width } = useElementSize(containerRef)
const router = useRouter()
const disableToolbar = computed(() => router.currentRoute.value.query?.disableToolbar === 'true')
const isTab = computed(() => {
if (!isCalendar.value) return false
return width.value > 1200
@ -29,7 +33,7 @@ provide(IsToolbarIconMode, isToolbarIconMode)
<template>
<div
v-if="!isMobileMode"
v-if="!isMobileMode && !disableToolbar"
ref="containerRef"
:class="{
'px-4': isMobileMode,

1
packages/nc-gui/components/smartsheet/Topbar.vue

@ -78,6 +78,7 @@ const topbarBreadcrumbItemWidth = computed(() => {
</div>
</NcButton>
<div v-else-if="!isSharedBase && !extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<div v-if="!isSharedBase">
<LazySmartsheetTopbarCmdK />
</div>

1
packages/nc-gui/composables/useGlobal/types.ts

@ -39,6 +39,7 @@ export interface AppInfo {
samlAuthEnabled: boolean
samlProviderName: string | null
giftUrl: string
feedEnabled: boolean
}
export interface StoredState {

80
packages/nc-gui/composables/useProductFeed.ts

@ -0,0 +1,80 @@
import type { ProductFeedItem } from '../lib/types'
export const useProductFeed = createSharedComposable(() => {
const activeTab = ref('recents')
const { $api } = useNuxtApp()
const youtubeFeed = ref<ProductFeedItem[]>([])
const githubFeed = ref<ProductFeedItem[]>([])
const socialFeed = ref<ProductFeedItem[]>([])
const isErrorOccurred = reactive({
youtube: false,
github: false,
social: false,
})
const loadFeed = async ({ loadMore, type }: { loadMore: boolean; type: 'youtube' | 'github' | 'all' }) => {
try {
let page = 1
if (loadMore) {
switch (type) {
case 'youtube':
page = Math.ceil(youtubeFeed.value.length / 10) + 1
break
case 'github':
page = Math.ceil(githubFeed.value.length / 10) + 1
break
case 'all':
page = Math.ceil(socialFeed.value.length / 10) + 1
break
}
}
const response = await $api.utils.feed({ page, per_page: 10, type })
if (type === 'all' && page === 1 && response.length) {
localStorage.setItem('last_published_at', response[0]['Published Time'] as string)
}
switch (type) {
case 'youtube':
youtubeFeed.value = [...youtubeFeed.value, ...response] as ProductFeedItem[]
break
case 'github':
githubFeed.value = [...githubFeed.value, ...response] as ProductFeedItem[]
break
case 'all':
socialFeed.value = [...socialFeed.value, ...response] as ProductFeedItem[]
break
}
} catch (error) {
switch (type) {
case 'youtube':
isErrorOccurred.youtube = true
break
case 'github':
isErrorOccurred.github = true
break
case 'all':
isErrorOccurred.social = true
break
}
console.error(error)
return []
}
}
return {
isErrorOccurred,
activeTab,
youtubeFeed,
githubFeed,
socialFeed,
loadFeed,
}
})

1
packages/nc-gui/lang/en.json

@ -93,6 +93,7 @@
"none": "None"
},
"general": {
"scripts": "Scripts",
"configure": "Configure",
"switch": "Switch",
"on": "On",

5
packages/nc-gui/layouts/shared-view.vue

@ -7,6 +7,10 @@ const { sharedView, allowCSVDownload } = useSharedView()
const router = useRouter()
const route = router.currentRoute
const disableTopbar = computed(() => route.value.query?.disableTopbar === 'true')
onMounted(() => {
// check if we are inside an iframe
// if we are, communicate to the parent page whenever we navigate to a new url,
@ -50,6 +54,7 @@ export default {
<a-layout id="nc-app">
<a-layout class="!flex-col bg-white">
<a-layout-header
v-if="!disableTopbar"
class="nc-table-topbar flex items-center justify-between !bg-transparent !px-3 !py-2 border-b-1 border-gray-200 !h-[46px]"
>
<div class="flex items-center gap-6 h-7 max-w-[calc(100%_-_280px)] xs:max-w-[calc(100%_-_90px)]">

12
packages/nc-gui/lib/types.ts

@ -277,6 +277,17 @@ interface NcTableColumnProps {
[key: string]: any
}
interface ProductFeedItem {
Id: string
Title: string
Description: string
['Feed Source']: 'Youtube' | 'Github' | 'All'
Url: string
Tags?: string
['Published Time']: string
Images?: Record<string, any>[] | null
}
type SordDirectionType = 'asc' | 'desc' | undefined
export type {
@ -312,4 +323,5 @@ export type {
AuditLogsQuery,
NcTableColumnProps,
SordDirectionType,
ProductFeedItem,
}

9
packages/nc-gui/package.json

@ -109,7 +109,14 @@
"vue3-grid-layout-next": "^1.0.7",
"vue3-text-clamp": "^0.1.2",
"vuedraggable": "^4.1.0",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.0",
"unified": "^11.0.5",
"youtube-vue3": "^0.1.15",
"@vueuse/motion": "^2.2.5"
},
"devDependencies": {
"@antfu/eslint-config": "^0.26.3",

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

@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<FeedView />
</template>
<style scoped lang="scss"></style>

5
packages/nc-gui/plugins/animation.ts

@ -0,0 +1,5 @@
import { MotionPlugin } from '@vueuse/motion'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(MotionPlugin)
})

14
packages/nc-gui/store/workspace.ts

@ -45,6 +45,8 @@ export const useWorkspace = defineStore('workspaceStore', () => {
const isIntegrationsPageOpened = computed(() => route.value.name === 'index-typeOrId-integrations')
const isFeedPageOpened = computed(() => route.value.name === 'index-typeOrId-feed')
const isWorkspaceLoading = ref(true)
const isCollaboratorsLoading = ref(true)
const isInvitingCollaborators = ref(false)
@ -238,6 +240,16 @@ export const useWorkspace = defineStore('workspaceStore', () => {
}
}
const navigateToFeed = async (_?: string, cmdOrCtrl?: boolean) => {
if (cmdOrCtrl) {
await navigateTo('/nc/feed', {
open: navigateToBlankTargetOpenOption,
})
} else {
await navigateTo('/nc/feed')
}
}
const auditLogsQuery = ref<Partial<AuditLogsQuery>>(defaultAuditLogsQuery)
const audits = ref<null | Array<AuditType>>(null)
@ -326,9 +338,11 @@ export const useWorkspace = defineStore('workspaceStore', () => {
auditLogsQuery,
audits,
auditPaginationData,
navigateToFeed,
loadAudits,
isIntegrationsPageOpened,
navigateToIntegrations,
isFeedPageOpened,
}
})

15
packages/nc-gui/utils/iconUtils.ts

@ -138,7 +138,7 @@ import NcCheckCircle from '~icons/nc-icons/check-circle'
import NcCheckFill from '~icons/nc-icons/checkFill'
import NcExternalLink from '~icons/nc-icons/external-link'
import NcCamera from '~icons/nc-icons/camera'
import NcRefreshCW from '~icons/nc-icons/refresh-cw'
// import NcProjectGray from '~icons/nc-icons/project-gray'
import NcPhoneCall from '~icons/nc-icons/phone-call'
import NcItalic from '~icons/nc-icons/italic'
@ -152,6 +152,7 @@ import NcControlPanel from '~icons/nc-icons/control-panel'
import NcHome from '~icons/nc-icons/home'
import NcHome1 from '~icons/nc-icons/home1'
import NcWorkspace from '~icons/nc-icons/workspace'
import NcMegaPhone from '~icons/nc-icons/megaphone'
import NcCellBarcode from '~icons/nc-icons/cell-barcode'
import NcCellCheckbox from '~icons/nc-icons/cell-checkbox'
@ -218,6 +219,7 @@ import NcGitCommit from '~icons/nc-icons/git-commit'
import NcCircle from '~icons/nc-icons/circle'
import NcServer1 from '~icons/nc-icons/server1'
import NcThumbsUpOutline from '~icons/nc-icons/thumbs-up-outline'
import NcNocoDB from '~icons/nc-icons/nocodb'
// Sync data
import NcAppleSolid from '~icons/nc-icons/apple_solid'
@ -531,6 +533,10 @@ import NcMousePointerIcon from '~icons/nc-icons-v2/mouse-pointer.svg'
import NcAlignLeftIcon from '~icons/nc-icons-v2/align-left.svg'
import NcHeartIcon from '~icons/nc-icons-v2/heart.svg'
import NcTrendingUpIcon from '~icons/nc-icons-v2/trending-up.svg'
import NcYoutube2Icon from '~icons/nc-icons/youtube2.svg'
import NcRedditIcon from '~icons/nc-icons/reddit.svg'
import NcTwitterIcon from '~icons/nc-icons/twitter-x-line.svg'
import NcDiscordIcon from '~icons/nc-icons/discord.svg'
import NcSlash1 from '~icons/nc-icons/slash1'
@ -760,6 +766,8 @@ export const iconMap = {
cellAttachment: NcCellAttachment,
cellButton: NcCellButton,
camera: NcCamera,
megaPhone: NcMegaPhone,
nocodb: NcNocoDB,
office: NcOffice,
sort: Sort,
@ -1077,6 +1085,10 @@ export const iconMap = {
claude: NcClaude,
ollama: NcOllama,
groq: NcGroq,
refreshCw: NcRefreshCW,
iconReddit: NcRedditIcon,
iconTwitter: NcTwitterIcon,
iconDiscord: NcDiscordIcon,
// Nc-IconsV2 Buttons
ncColumns: NcColumnsIcon,
@ -1338,6 +1350,7 @@ export const iconMap = {
ncPlaceholderIcon: NcPlaceholderIcon,
ncSpanner: NcSpanner,
ncScript: NcScript,
youtube: NcYoutube2Icon,
}
export const getMdiIcon = (type: string): any => {

24
packages/nc-gui/utils/urlUtils.ts

@ -63,3 +63,27 @@ export const isLinkExpired = async (url: string) => {
return true
}
export const extractYoutubeVideoId = (url: string) => {
if (typeof url !== 'string') {
return ''
}
// Regular expressions to match different YouTube URL formats
const patterns = [
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^?]+)/,
/(?:https?:\/\/)?youtu\.be\/([^?]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/v\/([^?]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/shorts\/([^?]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return ''
}

51
packages/nocodb-sdk/src/lib/Api.ts

@ -10858,6 +10858,57 @@ export class Api<
...params,
}),
/**
* No description
*
* @tags Utils
* @name Feed
* @summary Get Feed
* @request GET:/api/v2/feed
* @response `200` `({
Id?: string,
Description?: string,
Tags?: string,
Images?: (object)[],
Url?: string,
"Published Time"?: string,
})[]` OK
* @response `400` `{
\** @example BadRequest [Error]: <ERROR MESSAGE> *\
msg: string,
}`
*/
feed: (
query?: {
type?: 'all' | 'github' | 'youtube';
per_page?: number;
page?: number;
},
params: RequestParams = {}
) =>
this.request<
{
Id?: string;
Description?: string;
Tags?: string;
Images?: object[];
Url?: string;
'Published Time'?: string;
}[],
{
/** @example BadRequest [Error]: <ERROR MESSAGE> */
msg: string;
}
>({
path: `/api/v2/feed`,
method: 'GET',
query: query,
format: 'json',
...params,
}),
/**
* @description Get Aggregated Meta Info such as tableCount, dbViewCount, viewCount and etc.
*

20
packages/nocodb/package.json

@ -32,16 +32,16 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"watch:run": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:mysql": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunMysql --log-error --project tsconfig.json\"",
"watch:run:pg": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG --log-error --project tsconfig.json\"",
"watch:run:playwright:mysql": "rm -f ./test_noco.db; cross-env DB_TYPE=mysql NC_DB=\"mysql2://localhost:3306?u=root&p=password&d=pw_ncdb\" PLAYWRIGHT_TEST=true NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg": "rm -f ./test_noco.db; cross-env DB_TYPE=pg NC_DB=\"pg://localhost:5432?u=postgres&p=password&d=pw_ncdb\" PLAYWRIGHT_TEST=true NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright": "rm -f ./test_*.db; cross-env DB_TYPE=sqlite DATABASE_URL=sqlite:./test_noco.db PLAYWRIGHT_TEST=true NC_DISABLE_TELE=true EE=true NC_SNAPSHOT_WINDOW_SEC=3 nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:quick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env DATABASE_URL=sqlite:./test_noco.db NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg:cyquick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"",
"test:unit": "cross-env EE=false TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test:unit:pg": "cp tests/unit/.pg.env tests/unit/.env; cross-env EE=false TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"watch:run": "cross-env NC_DISABLE_TELE=true NODE_ENV=development EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:mysql": "cross-env NC_DISABLE_TELE=true NODE_ENV=development EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunMysql --log-error --project tsconfig.json\"",
"watch:run:pg": "cross-env NC_DISABLE_TELE=true NODE_ENV=development EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG --log-error --project tsconfig.json\"",
"watch:run:playwright:mysql": "rm -f ./test_noco.db; cross-env DB_TYPE=mysql NC_DB=\"mysql2://localhost:3306?u=root&p=password&d=pw_ncdb\" PLAYWRIGHT_TEST=true NODE_ENV=test NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg": "rm -f ./test_noco.db; cross-env DB_TYPE=pg NC_DB=\"pg://localhost:5432?u=postgres&p=password&d=pw_ncdb\" PLAYWRIGHT_TEST=true NODE_ENV=test NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright": "rm -f ./test_*.db; cross-env DB_TYPE=sqlite DATABASE_URL=sqlite:./test_noco.db PLAYWRIGHT_TEST=true NODE_ENV=test NC_DISABLE_TELE=true EE=true NC_SNAPSHOT_WINDOW_SEC=3 nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:quick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env NODE_ENV=test DATABASE_URL=sqlite:./test_noco.db NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg:cyquick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env NODE_ENV=test NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"",
"test:unit": "cross-env EE=false NODE_ENV=test TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"test:unit:pg": "cp tests/unit/.pg.env tests/unit/.env; cross-env NODE_ENV=test EE=false TS_NODE_PROJECT=./tests/unit/tsconfig.json mocha -r ts-node/register tests/unit/index.test.ts --recursive --timeout 300000 --exit --delay",
"docker:build": "EE=\"true-xc-test\" webpack --config docker/webpack.config.js"
},
"dependencies": {

7
packages/nocodb/src/controllers/utils.controller.ts

@ -7,6 +7,7 @@ import {
HttpCode,
Post,
Req,
Request,
UseGuards,
} from '@nestjs/common';
import { ProjectRoles, validateAndExtractSSLProp } from 'nocodb-sdk';
@ -166,4 +167,10 @@ export class UtilsController {
// todo: refactor
return (await this.utilsService.aggregatedMetaInfo()) as any;
}
@UseGuards(PublicApiLimiterGuard)
@Get('/api/v2/feed')
async feed(@Request() req: NcRequest) {
return await this.utilsService.feed(req);
}
}

81
packages/nocodb/src/schema/swagger.json

@ -16015,6 +16015,87 @@
]
}
},
"/api/v2/feed": {
"get": {
"summary": "Get Feed",
"operationId": "utils-feed",
"parameters": [
{
"schema": {
"type": "string",
"enum": [
"all",
"github",
"youtube"
]
},
"name": "type",
"in": "query",
"required": false
},
{
"schema": {
"type": "number"
},
"name": "per_page",
"in": "query",
"required": false
},
{
"schema": {
"type": "number"
},
"name": "page",
"in": "query",
"required": false
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"Id": {
"type": "string"
},
"Description": {
"type": "string"
},
"Tags": {
"type": "string"
},
"Images": {
"type": "array",
"items": {
"type": "object"
}
},
"Url": {
"type": "string"
},
"Published Time": {
"type": "string"
}
}
}
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"tags": [
"Utils"
]
}
},
"/api/v1/aggregated-meta-info": {
"parameters": [
{

81
packages/nocodb/src/services/utils.service.ts

@ -1,11 +1,13 @@
import process from 'process';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { compareVersions, validate } from 'compare-versions';
import { ViewTypes } from 'nocodb-sdk';
import { ConfigService } from '@nestjs/config';
import { useAgent } from 'request-filtering-agent';
import type { AppConfig } from '~/interface/config';
import dayjs from 'dayjs';
import type { AppConfig, NcRequest } from '~/interface/config';
import { T } from '~/utils';
import { NC_APP_SETTINGS, NC_ATTACHMENT_FIELD_SIZE } from '~/constants';
import SqlMgrv2 from '~/db/sql-mgr/v2/SqlMgrv2';
import { NcError } from '~/helpers/catchError';
@ -13,7 +15,7 @@ import { Base, Store, User } from '~/models';
import Noco from '~/Noco';
import NcConnectionMgrv2 from '~/utils/common/NcConnectionMgrv2';
import getInstance from '~/utils/getInstance';
import { MetaTable, RootScopes } from '~/utils/globals';
import { CacheScope, MetaTable, RootScopes } from '~/utils/globals';
import { jdbcToXcConfig } from '~/utils/nc-config/helpers';
import { packageVersion } from '~/utils/packageVersion';
import {
@ -21,6 +23,8 @@ import {
defaultLimitConfig,
} from '~/helpers/extractLimitAndOffset';
import { DriverClient } from '~/utils/nc-config';
import NocoCache from '~/cache/NocoCache';
import { getCircularReplacer } from '~/utils';
const versionCache = {
releaseVersion: null,
@ -71,8 +75,12 @@ interface AllMeta {
@Injectable()
export class UtilsService {
protected logger = new Logger(UtilsService.name);
constructor(protected readonly configService: ConfigService<AppConfig>) {}
lastSyncTime = dayjs();
async versionInfo() {
if (
!versionCache.lastFetched ||
@ -385,7 +393,6 @@ export class UtilsService {
if (result.status === 'fulfilled') {
return result.value;
}
console.log(result.reason);
return null;
});
};
@ -460,6 +467,7 @@ export class UtilsService {
disableEmailAuth: this.configService.get('auth.disableEmailAuth', {
infer: true,
}),
feedEnabled: process.env.NC_DISABLE_PRODUCT_FEED !== 'true',
mainSubDomain: this.configService.get('mainSubDomain', { infer: true }),
dashboardPath: this.configService.get('dashboardPath', { infer: true }),
inviteOnlySignup: settings.invite_only_signup,
@ -471,4 +479,69 @@ export class UtilsService {
return result;
}
async feed(req: NcRequest) {
const {
type = 'all',
page = '1',
per_page = '10',
} = req.query as {
type: 'github' | 'youtube' | 'all' | 'twitter' | 'cloud';
page: string;
per_page: string;
};
const perPage = Math.min(Math.max(parseInt(per_page, 10) || 10, 1), 100);
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const cacheKey = `${CacheScope.PRODUCT_FEED}:${type}:${pageNum}:${perPage}`;
const cachedData = await NocoCache.get(cacheKey, 'json');
if (cachedData) {
try {
return JSON.parse(cachedData);
} catch (e) {
this.logger.error(e?.message, e);
await NocoCache.del(cacheKey);
}
}
let payload = null;
if (dayjs().isAfter(this.lastSyncTime.add(3, 'hours'))) {
payload = await T.payload();
this.lastSyncTime = dayjs();
}
let response;
try {
response = await axios.post(
'https://product-feed.nocodb.com/api/v1/social/feed',
payload,
{
params: {
per_page: perPage,
page: pageNum,
type,
},
},
);
} catch (e) {
this.logger.error(e?.message, e);
return [];
}
// The feed includes the attachments, which has the presigned URL
// So the cache should match the presigned URL cache
await NocoCache.setExpiring(
cacheKey,
JSON.stringify(response.data, getCircularReplacer),
Number.isNaN(parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS))
? 2 * 60 * 60
: parseInt(process.env.NC_ATTACHMENT_EXPIRE_SECONDS),
);
return response.data;
}
}

1
packages/nocodb/src/utils/globals.ts

@ -187,6 +187,7 @@ export enum CacheScope {
INTEGRATION = 'integration',
COL_BUTTON = 'colButton',
CMD_PALETTE = 'cmdPalette',
PRODUCT_FEED = 'productFeed',
}
export enum CacheGetType {

36
packages/nocodb/src/utils/tele.ts

@ -5,6 +5,7 @@ import axios from 'axios';
import isDocker from 'is-docker';
import { packageVersion } from '~/utils/packageVersion';
import TeleBatchProcessor from '~/utils/TeleBatchProcessor';
import { isEE } from '~/utils';
const isDisabled = !!process.env.NC_DISABLE_TELE;
const cache = !!process.env.NC_REDIS_URL;
@ -248,6 +249,41 @@ class Tele {
static get id() {
return this.machineId || machineIdSync();
}
static async payload() {
if (
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'development' ||
isEE
)
return null;
const payload: Record<string, any> = {
package_id: packageVersion,
node_version: process.version,
xc_version: process.env.NC_SERVER_UUID,
env: process.env.NODE_ENV || 'production',
oneClick: !!process.env.NC_ONE_CLICK,
};
try {
payload.os_type = os.type();
payload.os_platform = os.platform();
payload.os_release = os.release();
payload.docker = isDocker();
payload.machine_id = `${this.id},,`;
payload.payload = {
...((await Tele.getInstanceMeta()) || {}),
count: global.NC_COUNT,
upTime: Math.round(process.uptime() / 3600),
cache,
litestream,
executable,
};
} catch {
// ignore
}
return payload;
}
}
async function waitForMachineId(teleData) {

1515
pnpm-lock.yaml

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save