Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 460 B |
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 410 B |
After Width: | Height: | Size: 537 B |
After Width: | Height: | Size: 1.2 KiB |
@ -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="">What’s 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> |
@ -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> |
@ -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> |
@ -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> |
@ -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"> What’s New! </span> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"></style> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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> |
@ -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, |
||||
} |
||||
}) |
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script> |
||||
|
||||
<template> |
||||
<FeedView /> |
||||
</template> |
||||
|
||||
<style scoped lang="scss"></style> |
@ -0,0 +1,5 @@
|
||||
import { MotionPlugin } from '@vueuse/motion' |
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => { |
||||
nuxtApp.vueApp.use(MotionPlugin) |
||||
}) |