feat: implement AI timeline loading overlay with animations and localization support
This commit is contained in:
parent
26c842751a
commit
6a64f53de6
|
|
@ -0,0 +1,212 @@
|
|||
.ai-timeline-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(255, 92, 0, 0.08),
|
||||
rgba(0, 0, 0, 0.1)
|
||||
);
|
||||
opacity: 0.75;
|
||||
animation: aiTimelineGlassPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__glow {
|
||||
position: absolute;
|
||||
inset: 10% 8% 16% 8%;
|
||||
border-radius: 48px;
|
||||
background: radial-gradient(
|
||||
circle at 70% 25%,
|
||||
rgba(255, 92, 0, 0.35),
|
||||
rgba(255, 140, 0, 0.15),
|
||||
transparent 65%
|
||||
);
|
||||
filter: blur(42px);
|
||||
opacity: 0.8;
|
||||
animation: aiTimelineGlow 3.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__beam {
|
||||
position: absolute;
|
||||
top: -5%;
|
||||
bottom: -5%;
|
||||
width: 32%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
rgba(255, 92, 0, 0.15),
|
||||
rgba(255, 92, 0, 0)
|
||||
);
|
||||
filter: blur(18px);
|
||||
opacity: 0;
|
||||
animation: aiTimelineBeam 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__beam--left {
|
||||
left: -15%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__beam--right {
|
||||
right: -15%;
|
||||
animation-delay: -1.2s;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__sparkles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
filter: blur(0.5px);
|
||||
}
|
||||
|
||||
.ai-timeline-loading__sparkles span {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
box-shadow:
|
||||
0 0 18px rgba(255, 92, 0, 0.4),
|
||||
0 0 8px rgba(255, 255, 255, 0.3);
|
||||
opacity: 0;
|
||||
animation: aiTimelineSparkle 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__sparkles span:nth-child(1) {
|
||||
top: 25%;
|
||||
left: 32%;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__sparkles span:nth-child(2) {
|
||||
top: 55%;
|
||||
left: 62%;
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__sparkles span:nth-child(3) {
|
||||
top: 38%;
|
||||
left: 78%;
|
||||
animation-delay: -1.8s;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__badge {
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
bottom: 1.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(16, 16, 16, 0.85),
|
||||
rgba(32, 24, 20, 0.85),
|
||||
rgba(255, 92, 0, 0.25)
|
||||
);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow:
|
||||
0 15px 40px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
animation: aiTimelineBadgePulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__badge::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(
|
||||
120deg,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
rgba(255, 92, 0, 0.35),
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ai-timeline-loading__badge span {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
@keyframes aiTimelineGlassPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiTimelineGlow {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiTimelineBeam {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-35%) scaleX(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.65;
|
||||
transform: translateX(20%) scaleX(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(30%) scaleX(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiTimelineSparkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) translate3d(0, 0, 0);
|
||||
}
|
||||
35% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1) translate3d(4px, -6px, 0);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.6) translate3d(-4px, 6px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aiTimelineBadgePulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
box-shadow:
|
||||
0 15px 40px rgba(0, 0, 0, 0.35),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
box-shadow:
|
||||
0 20px 45px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import "./AITimelineLoadingOverlay.css"
|
||||
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import type { FC } from "react"
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
label: string
|
||||
}
|
||||
|
||||
export const AITimelineLoadingOverlay: FC<Props> = ({ visible, label }) => (
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="ai-timeline-loading"
|
||||
>
|
||||
<div className="ai-timeline-loading__backdrop" />
|
||||
<div className="ai-timeline-loading__glow" />
|
||||
<div className="ai-timeline-loading__beam ai-timeline-loading__beam--left" />
|
||||
<div className="ai-timeline-loading__beam ai-timeline-loading__beam--right" />
|
||||
<div className="ai-timeline-loading__sparkles">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="ai-timeline-loading__badge flex items-center gap-2 px-4 py-1.5 text-xs font-semibold text-text">
|
||||
<span className="inline-flex size-2 rounded-full bg-orange-400 shadow-[0_0_12px_rgba(251,146,60,0.7)]" />
|
||||
<span className="i-mgc-robot-2-cute-re text-base text-orange-400 dark:text-orange-300" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
</m.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
|
@ -7,8 +7,9 @@ import { unreadSyncService } from "@follow/store/unread/store"
|
|||
import { useIsLoggedIn } from "@follow/store/user/hooks"
|
||||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { Range, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { atom } from "jotai"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { Focusable } from "~/components/common/Focusable"
|
||||
|
|
@ -19,6 +20,8 @@ import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRoutePara
|
|||
import { useFeedQuery } from "~/queries/feed"
|
||||
import { useFeedHeaderTitle } from "~/store/feed/hooks"
|
||||
|
||||
import { aiTimelineEnabledAtom } from "./atoms/ai-timeline"
|
||||
import { AITimelineLoadingOverlay } from "./components/ai-timeline-loading/AITimelineLoadingOverlay"
|
||||
import { EntryColumnWrapper } from "./components/entry-column-wrapper/EntryColumnWrapper"
|
||||
import { FooterMarkItem } from "./components/FooterMarkItem"
|
||||
import { useEntriesActions, useEntriesState } from "./context/EntriesContext"
|
||||
|
|
@ -34,6 +37,7 @@ import { EntryRootStateContext } from "./store/EntryColumnContext"
|
|||
|
||||
function EntryColumnContent() {
|
||||
const listRef = useRef<Virtualizer<HTMLElement, Element>>(undefined)
|
||||
const { t } = useTranslation()
|
||||
const state = useEntriesState()
|
||||
|
||||
const actions = useEntriesActions()
|
||||
|
|
@ -111,6 +115,8 @@ function EntryColumnContent() {
|
|||
|
||||
const rangeQueueRef = useRef<Range[]>([])
|
||||
const isRefreshing = state.isFetching && !state.isFetchingNextPage
|
||||
const aiTimelineEnabled = useAtomValue(aiTimelineEnabledAtom)
|
||||
const showAiTimelineLoading = aiTimelineEnabled && state.isLoading && !state.isFetchingNextPage
|
||||
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
|
||||
const handleRangeChange = useCallback(
|
||||
(e: Range) => {
|
||||
|
|
@ -192,6 +198,11 @@ function EntryColumnContent() {
|
|||
/>
|
||||
)}
|
||||
</EntryColumnWrapper>
|
||||
|
||||
<AITimelineLoadingOverlay
|
||||
visible={showAiTimelineLoading}
|
||||
label={t("entry_list_header.ai_timeline_loading")}
|
||||
/>
|
||||
</Focusable>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,7 @@
|
|||
"entry_content.web_app_notice": "Maybe web app doesn't support this content type. But you can download the desktop app.",
|
||||
"entry_list.zero_unread": "Zero Unread",
|
||||
"entry_list_header.ai_timeline": "AI timeline",
|
||||
"entry_list_header.ai_timeline_loading": "Reordering timeline with AI...",
|
||||
"entry_list_header.ai_timeline_prompt_required": "Set your AI timeline sorting prompt first",
|
||||
"entry_list_header.grid": "Grid",
|
||||
"entry_list_header.image_only": "Image Only",
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@
|
|||
"entry_content.web_app_notice": "このコンテンツタイプはウェブアプリではサポートされていないかもしれません。デスクトップアプリをダウンロードしてください。",
|
||||
"entry_list.zero_unread": "未読ゼロ",
|
||||
"entry_list_header.ai_timeline": "AIタイムライン",
|
||||
"entry_list_header.ai_timeline_loading": "AI でタイムラインを並び替えています…",
|
||||
"entry_list_header.ai_timeline_prompt_required": "AIタイムラインの並び替えプロンプトを先に設定してください",
|
||||
"entry_list_header.grid": "グリッド",
|
||||
"entry_list_header.image_only": "イメージのみ",
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@
|
|||
"entry_content.web_app_notice": "网页版不支持展示此类型,请下载客户端查看。",
|
||||
"entry_list.zero_unread": "全部已读",
|
||||
"entry_list_header.ai_timeline": "AI 时间线",
|
||||
"entry_list_header.ai_timeline_loading": "正在用 AI 重新排序时间线…",
|
||||
"entry_list_header.ai_timeline_prompt_required": "请先设置 AI 时间线排序提示",
|
||||
"entry_list_header.grid": "网格布局",
|
||||
"entry_list_header.image_only": "仅图片",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@
|
|||
"entry_content.web_app_notice": "Web 應用程式不支援此類型內容。您可以下載桌面應用程式。",
|
||||
"entry_list.zero_unread": "全部已讀",
|
||||
"entry_list_header.ai_timeline": "AI 時間線",
|
||||
"entry_list_header.ai_timeline_loading": "正在用 AI 重新排序時間線…",
|
||||
"entry_list_header.ai_timeline_prompt_required": "請先設定 AI 時間線排序提示",
|
||||
"entry_list_header.grid": "格線佈局",
|
||||
"entry_list_header.image_only": "僅圖片",
|
||||
|
|
|
|||
Loading…
Reference in New Issue