diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.css b/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.css new file mode 100644 index 000000000..78a409737 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.css @@ -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); + } +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.tsx new file mode 100644 index 000000000..1e89b0955 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/ai-timeline-loading/AITimelineLoadingOverlay.tsx @@ -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 = ({ visible, label }) => ( + + {visible ? ( + +
+
+
+
+
+ + + +
+ +
+ + + {label} +
+ + ) : null} + +) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index fd0a1ae87..6d02e4f81 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -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>(undefined) + const { t } = useTranslation() const state = useEntriesState() const actions = useEntriesActions() @@ -111,6 +115,8 @@ function EntryColumnContent() { const rangeQueueRef = useRef([]) 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() { /> )} + + ) } diff --git a/locales/app/en.json b/locales/app/en.json index 85527ed37..527f84e77 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -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", diff --git a/locales/app/ja.json b/locales/app/ja.json index 02f36bf27..ef8cf85b1 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -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": "イメージのみ", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 7438fa3d4..6b9cb43b1 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -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": "仅图片", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index 392003496..0feed2a02 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -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": "僅圖片",