feat: implement AI timeline loading overlay with animations and localization support

This commit is contained in:
DIYgod 2025-11-19 21:36:09 +08:00
parent 26c842751a
commit 6a64f53de6
No known key found for this signature in database
7 changed files with 268 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -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": "イメージのみ",

View File

@ -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": "仅图片",

View File

@ -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": "僅圖片",