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) |
||||||
|
}) |