├── .cursor └── rules │ └── project.mdc ├── .gitignore ├── LICENSE ├── README.md ├── app ├── dashboard │ ├── layout.tsx │ └── page.tsx ├── globals.css ├── layout.tsx ├── page.tsx ├── privacy │ └── page.tsx ├── robots.ts ├── sitemap.ts └── terms │ └── page.tsx ├── components.json ├── components ├── dashboard │ ├── channel-distribution-chart.tsx │ ├── daily-views-chart.tsx │ ├── stats-overview.tsx │ ├── top-videos-chart.tsx │ └── viewing-time-chart.tsx ├── file-upload-form.tsx ├── theme-provider.tsx └── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── breadcrumb.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── carousel.tsx │ ├── chart.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── hover-card.tsx │ ├── input-otp.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── pagination.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── resizable.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── sidebar.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle-group.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ ├── use-mobile.tsx │ └── use-toast.ts ├── hooks ├── use-mobile.tsx └── use-toast.ts ├── images └── readme.webp ├── lib └── utils.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── placeholder-logo.png ├── placeholder-logo.svg ├── placeholder-user.jpg ├── placeholder.jpg ├── placeholder.svg └── youtube-analytics-overview.png ├── styles └── globals.css ├── tailwind.config.ts └── tsconfig.json /.cursor/rules/project.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 1. use pnpm as package manager, not npm 7 | 2. don't request npm run dev in agent mode, i will do it by myself 8 | 3. Ignore lint errors from npm package imports in code, sometimes they're just due to local caching issues. 9 | 4. If there's an error, please use your brain and don't just randomly change the code. Carefully investigate the problem. You can start by looking at what the error actually is before modifying the code. Don't be a smart aleck. 10 | 5. don't repeat yourself, less is more. 11 | 6. always write code and code comment in english format style. 12 | 7. When creating database tables, do not use foreign keys; logical associations can be made at the code level. 13 | 8. Don't recreate pages with existing functionality. Always check the code repository first to see if a corresponding file or page already exists. 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # next.js 7 | /.next/ 8 | /out/ 9 | 10 | # production 11 | /build 12 | 13 | # debug 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | .pnpm-debug.log* 18 | 19 | # env files 20 | .env* 21 | 22 | # vercel 23 | .vercel 24 | 25 | # typescript 26 | *.tsbuildinfo 27 | next-env.d.ts 28 | 29 | watch-history.json 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 ronething 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouTube History Analyzer 2 | 3 | yt-history - Illuminate Your Viewing Journey | Product Hunt 4 | 5 | A web application that allows users to visualize and analyze their YouTube watch history. 6 | 7 | ## Features 8 | 9 | - Import YouTube history data from Google Takeout 10 | - Visualize watching patterns and trends 11 | - Filter and search through watch history 12 | - Generate insights about most-watched channels and categories 13 | - Track time spent watching different types of content 14 | 15 | ## Installation 16 | 17 | ```bash 18 | # Clone the repository 19 | git clone https://github.com/ronething/yt-history.git 20 | cd yt-history 21 | 22 | # Install dependencies 23 | pnpm install 24 | 25 | # Start the development server 26 | pnpm dev 27 | ``` 28 | 29 | ## Usage 30 | 31 | 1. Export your YouTube history data from [Google Takeout](https://takeout.google.com/) 32 | 2. Import the JSON file into the application 33 | 3. Explore your personalized YouTube analytics dashboard 34 | 35 | ## Technologies 36 | 37 | - Frontend: React/Next.js 38 | - Styling: Tailwind CSS 39 | - Data Visualization: Recharts 40 | - State Management: React Context API 41 | 42 | ## Effect 43 | 44 | ![youtube-history](images/readme.webp) 45 | 46 | Buy Me A Coffee 47 | -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | export const metadata: Metadata = { 4 | title: "Dashboard | YouTube History Visualizer", 5 | description: "View insights and analytics from your YouTube watch history data.", 6 | openGraph: { 7 | title: "Dashboard | YouTube History Visualizer", 8 | description: "View insights and analytics from your YouTube watch history data.", 9 | url: "https://youtubestats.forgetimer.com/dashboard", 10 | }, 11 | twitter: { 12 | title: "Dashboard | YouTube History Visualizer", 13 | description: "View insights and analytics from your YouTube watch history data.", 14 | }, 15 | alternates: { 16 | canonical: "https://youtubestats.forgetimer.com/dashboard", 17 | }, 18 | } 19 | 20 | export default function DashboardLayout({ 21 | children, 22 | }: { 23 | children: React.ReactNode 24 | }) { 25 | return <>{children}; 26 | } -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { ArrowLeft, Calendar, Clock, Film, User } from "lucide-react" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" 10 | import DailyViewsChart from "@/components/dashboard/daily-views-chart" 11 | import TopVideosChart from "@/components/dashboard/top-videos-chart" 12 | import ViewingTimeChart from "@/components/dashboard/viewing-time-chart" 13 | import ChannelDistributionChart from "@/components/dashboard/channel-distribution-chart" 14 | import StatsOverview from "@/components/dashboard/stats-overview" 15 | 16 | // Define interfaces for the processed data 17 | interface Stats { 18 | totalVideos: number 19 | oldestDate: string 20 | newestDate: string 21 | uniqueChannels: number 22 | daysDifference: number 23 | } 24 | 25 | interface DailyView { 26 | date: string 27 | count: number 28 | } 29 | 30 | interface HourlyView { 31 | hour: number 32 | count: number 33 | } 34 | 35 | interface TopVideo { 36 | id: string 37 | title: string 38 | channel?: string 39 | count: number 40 | } 41 | 42 | interface ChannelCount { 43 | name: string 44 | count: number 45 | } 46 | 47 | export default function DashboardPage() { 48 | const router = useRouter() 49 | const [stats, setStats] = useState(null) 50 | const [dailyViews, setDailyViews] = useState([]) 51 | const [hourlyViews, setHourlyViews] = useState([]) 52 | const [topVideos, setTopVideos] = useState([]) 53 | const [channelCounts, setChannelCounts] = useState([]) 54 | const [isLoading, setIsLoading] = useState(true) 55 | const [error, setError] = useState(null) 56 | 57 | useEffect(() => { 58 | // Load the processed data from sessionStorage 59 | try { 60 | const statsJson = sessionStorage.getItem("youtubeHistoryStats") 61 | const dailyViewsJson = sessionStorage.getItem("youtubeHistoryDailyViews") 62 | const hourlyViewsJson = sessionStorage.getItem("youtubeHistoryHourlyViews") 63 | const topVideosJson = sessionStorage.getItem("youtubeHistoryTopVideos") 64 | const channelsJson = sessionStorage.getItem("youtubeHistoryChannels") 65 | 66 | if (!statsJson) { 67 | setError("No data found. Please upload your YouTube history file.") 68 | setIsLoading(false) 69 | return 70 | } 71 | 72 | setStats(JSON.parse(statsJson)) 73 | setDailyViews(dailyViewsJson ? JSON.parse(dailyViewsJson) : []) 74 | setHourlyViews(hourlyViewsJson ? JSON.parse(hourlyViewsJson) : []) 75 | setTopVideos(topVideosJson ? JSON.parse(topVideosJson) : []) 76 | setChannelCounts(channelsJson ? JSON.parse(channelsJson) : []) 77 | setIsLoading(false) 78 | } catch (err) { 79 | console.error("Error loading data:", err) 80 | setError("Failed to load the processed data. Please try uploading your file again.") 81 | setIsLoading(false) 82 | } 83 | }, []) 84 | 85 | const handleBackToHome = () => { 86 | router.push("/") 87 | } 88 | 89 | if (isLoading) { 90 | return ( 91 |
92 |
93 |
94 |

Loading your YouTube history data...

95 |
96 |
97 | ) 98 | } 99 | 100 | if (error || !stats) { 101 | return ( 102 |
103 |
104 |
105 | 117 | 118 | 119 | 120 | 121 |
122 |

Error Loading Data

123 |

{error}

124 | 127 |
128 |
129 | ) 130 | } 131 | 132 | return ( 133 |
134 |
135 |
136 | 140 |

YouTube History Dashboard

141 |
142 | Analyzing {stats.totalVideos.toLocaleString()} videos 143 |
144 |
145 |
146 |
147 |
148 | 149 | 150 | 151 | 152 | 153 | 154 | Daily Views 155 | Daily 156 | 157 | 158 | 159 | Top Videos 160 | Videos 161 | 162 | 163 | 164 | Viewing Time 165 | Time 166 | 167 | 168 | 169 | Channels 170 | Channels 171 | 172 | 173 | 174 | 175 | 176 | Daily Viewing Activity 177 | Number of videos watched per day over time 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | Most Watched Videos 188 | Your top 10 most frequently watched videos 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | Viewing Time Distribution 199 | When you watch YouTube throughout the day 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | Channel Distribution 210 | Breakdown of your most watched channels 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 |
219 |
220 |
221 |
222 |

© {new Date().getFullYear()} YouTube History Visualizer

223 |

Your data never leaves your browser

224 |
225 |
226 |
227 | ) 228 | } 229 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer utilities { 10 | .text-balance { 11 | text-wrap: balance; 12 | } 13 | } 14 | 15 | @layer base { 16 | :root { 17 | --background: 0 0% 100%; 18 | --foreground: 0 0% 3.9%; 19 | --card: 0 0% 100%; 20 | --card-foreground: 0 0% 3.9%; 21 | --popover: 0 0% 100%; 22 | --popover-foreground: 0 0% 3.9%; 23 | --primary: 0 0% 9%; 24 | --primary-foreground: 0 0% 98%; 25 | --secondary: 0 0% 96.1%; 26 | --secondary-foreground: 0 0% 9%; 27 | --muted: 0 0% 96.1%; 28 | --muted-foreground: 0 0% 45.1%; 29 | --accent: 0 0% 96.1%; 30 | --accent-foreground: 0 0% 9%; 31 | --destructive: 0 84.2% 60.2%; 32 | --destructive-foreground: 0 0% 98%; 33 | --border: 0 0% 89.8%; 34 | --input: 0 0% 89.8%; 35 | --ring: 0 0% 3.9%; 36 | --chart-1: 12 76% 61%; 37 | --chart-2: 173 58% 39%; 38 | --chart-3: 197 37% 24%; 39 | --chart-4: 43 74% 66%; 40 | --chart-5: 27 87% 67%; 41 | --radius: 0.5rem; 42 | --sidebar-background: 0 0% 98%; 43 | --sidebar-foreground: 240 5.3% 26.1%; 44 | --sidebar-primary: 240 5.9% 10%; 45 | --sidebar-primary-foreground: 0 0% 98%; 46 | --sidebar-accent: 240 4.8% 95.9%; 47 | --sidebar-accent-foreground: 240 5.9% 10%; 48 | --sidebar-border: 220 13% 91%; 49 | --sidebar-ring: 217.2 91.2% 59.8%; 50 | } 51 | .dark { 52 | --background: 0 0% 3.9%; 53 | --foreground: 0 0% 98%; 54 | --card: 0 0% 3.9%; 55 | --card-foreground: 0 0% 98%; 56 | --popover: 0 0% 3.9%; 57 | --popover-foreground: 0 0% 98%; 58 | --primary: 0 0% 98%; 59 | --primary-foreground: 0 0% 9%; 60 | --secondary: 0 0% 14.9%; 61 | --secondary-foreground: 0 0% 98%; 62 | --muted: 0 0% 14.9%; 63 | --muted-foreground: 0 0% 63.9%; 64 | --accent: 0 0% 14.9%; 65 | --accent-foreground: 0 0% 98%; 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 0 0% 98%; 68 | --border: 0 0% 14.9%; 69 | --input: 0 0% 14.9%; 70 | --ring: 0 0% 83.1%; 71 | --chart-1: 220 70% 50%; 72 | --chart-2: 160 60% 45%; 73 | --chart-3: 30 80% 55%; 74 | --chart-4: 280 65% 60%; 75 | --chart-5: 340 75% 55%; 76 | --sidebar-background: 240 5.9% 10%; 77 | --sidebar-foreground: 240 4.8% 95.9%; 78 | --sidebar-primary: 224.3 76.3% 48%; 79 | --sidebar-primary-foreground: 0 0% 100%; 80 | --sidebar-accent: 240 3.7% 15.9%; 81 | --sidebar-accent-foreground: 240 4.8% 95.9%; 82 | --sidebar-border: 240 3.7% 15.9%; 83 | --sidebar-ring: 217.2 91.2% 59.8%; 84 | } 85 | } 86 | 87 | @layer base { 88 | * { 89 | @apply border-border; 90 | } 91 | body { 92 | @apply bg-background text-foreground; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next' 2 | import './globals.css' 3 | import Script from 'next/script' 4 | 5 | export const metadata: Metadata = { 6 | metadataBase: new URL('https://youtubestats.forgetimer.com'), 7 | authors: [{ name: 'YouTube History Visualizer' }], 8 | generator: 'Next.js', 9 | } 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: Readonly<{ 14 | children: React.ReactNode 15 | }>) { 16 | return ( 17 | 18 | 19 | 31 | 32 | {children} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { ArrowLeft } from "lucide-react" 3 | import { Metadata } from "next" 4 | 5 | import { Button } from "@/components/ui/button" 6 | 7 | export const metadata: Metadata = { 8 | title: "Privacy Policy | YouTube History Visualizer", 9 | description: "Privacy policy and data handling practices for the YouTube History Visualizer tool.", 10 | openGraph: { 11 | title: "Privacy Policy | YouTube History Visualizer", 12 | description: "Privacy policy and data handling practices for the YouTube History Visualizer tool.", 13 | url: "https://youtubestats.forgetimer.com/privacy", 14 | }, 15 | twitter: { 16 | title: "Privacy Policy | YouTube History Visualizer", 17 | description: "Privacy policy and data handling practices for the YouTube History Visualizer tool.", 18 | }, 19 | alternates: { 20 | canonical: "https://youtubestats.forgetimer.com/privacy", 21 | }, 22 | } 23 | 24 | export default function PrivacyPolicy() { 25 | return ( 26 |
27 |
28 |
29 | 30 |

YouTube History Visualizer

31 | 32 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |

Privacy Policy

46 |

Last Updated: {new Date().toLocaleDateString()}

47 |
48 | 49 |
50 |

Introduction

51 |

52 | At YouTube History Visualizer, we take your privacy seriously. This Privacy Policy explains how we 53 | handle your data when you use our service to visualize your YouTube watch history. 54 |

55 |
56 | 57 |
58 |

Data Collection and Processing

59 |

60 | Client-Side Processing Only: Our service operates entirely within your web browser. 61 | When you upload your YouTube watch history file: 62 |

63 |
    64 |
  • The file is processed locally on your device
  • 65 |
  • Your data is never sent to our servers
  • 66 |
  • We do not store, collect, or transmit any of your personal data
  • 67 |
  • 68 | The data is temporarily stored in your browser's memory only for the duration of your session 69 |
  • 70 |
71 |
72 | 73 |
74 |

Information Usage

75 |

76 | Since we do not collect any personal data, we do not use your information for any purposes beyond 77 | providing the visualization service directly in your browser. 78 |

79 |
80 | 81 |
82 |

Cookies and Tracking

83 |

84 | Our website does not use cookies or tracking technologies to monitor your activity or collect 85 | information about you. 86 |

87 |
88 | 89 |
90 |

Third-Party Services

91 |

92 | We do not integrate with third-party services that would collect your data. All processing and 93 | visualization happen locally in your web browser. 94 |

95 |
96 | 97 |
98 |

Data Security

99 |

100 | Since your data never leaves your device, the security of your data depends on your own device's 101 | security. We recommend: 102 |

103 |
    104 |
  • Using up-to-date browsers with strong security features
  • 105 |
  • Ensuring your device has proper security measures in place
  • 106 |
  • Closing the browser tab when you're done using our service
  • 107 |
108 |
109 | 110 |
111 |

Changes to This Privacy Policy

112 |

113 | We may update our Privacy Policy from time to time. We will notify you of any changes by posting the 114 | new Privacy Policy on this page. You are advised to review this Privacy Policy periodically for any 115 | changes. 116 |

117 |
118 | 119 |
120 |

Contact Us

121 |

122 | If you have any questions about this Privacy Policy, please contact us at: 123 | support@forgetimer.com 124 |

125 |
126 |
127 |
128 |
129 |
130 |
131 |

132 | © {new Date().getFullYear()} YouTube History Visualizer. All rights reserved. 133 |

134 |
135 | 136 | Privacy Policy 137 | 138 | 139 | Terms of Service 140 | 141 |
142 |
143 |
144 |
145 | ) 146 | } -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next' 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: '*', 7 | allow: '/', 8 | }, 9 | sitemap: 'https://youtubestats.forgetimer.com/sitemap.xml', 10 | } 11 | } -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next' 2 | 3 | export default function sitemap(): MetadataRoute.Sitemap { 4 | const baseUrl = 'https://youtubestats.forgetimer.com' 5 | const currentDate = new Date().toISOString() 6 | 7 | return [ 8 | { 9 | url: baseUrl, 10 | lastModified: currentDate, 11 | changeFrequency: 'monthly', 12 | priority: 1.0, 13 | }, 14 | { 15 | url: `${baseUrl}/privacy`, 16 | lastModified: currentDate, 17 | changeFrequency: 'yearly', 18 | priority: 0.5, 19 | }, 20 | { 21 | url: `${baseUrl}/terms`, 22 | lastModified: currentDate, 23 | changeFrequency: 'yearly', 24 | priority: 0.5, 25 | }, 26 | ] 27 | } -------------------------------------------------------------------------------- /app/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { ArrowLeft } from "lucide-react" 3 | import { Metadata } from "next" 4 | 5 | import { Button } from "@/components/ui/button" 6 | 7 | export const metadata: Metadata = { 8 | title: "Terms of Service | YouTube History Visualizer", 9 | description: "Terms of service and usage conditions for the YouTube History Visualizer tool.", 10 | openGraph: { 11 | title: "Terms of Service | YouTube History Visualizer", 12 | description: "Terms of service and usage conditions for the YouTube History Visualizer tool.", 13 | url: "https://youtubestats.forgetimer.com/terms", 14 | }, 15 | twitter: { 16 | title: "Terms of Service | YouTube History Visualizer", 17 | description: "Terms of service and usage conditions for the YouTube History Visualizer tool.", 18 | }, 19 | alternates: { 20 | canonical: "https://youtubestats.forgetimer.com/terms", 21 | }, 22 | } 23 | 24 | export default function TermsOfService() { 25 | return ( 26 |
27 |
28 |
29 | 30 |

YouTube History Visualizer

31 | 32 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |

Terms of Service

46 |

Last Updated: {new Date().toLocaleDateString()}

47 |
48 | 49 |
50 |

1. Introduction

51 |

52 | Welcome to YouTube History Visualizer. By accessing or using our website, you agree to be bound by 53 | these Terms of Service. If you do not agree to these terms, please do not use our service. 54 |

55 |
56 | 57 |
58 |

2. Description of Service

59 |

60 | YouTube History Visualizer provides a tool for users to visualize their YouTube watch history data. 61 | The service processes the data locally in the user's browser to generate visualizations and 62 | insights about their YouTube viewing habits. 63 |

64 |
65 | 66 |
67 |

3. User Responsibilities

68 |

By using our service, you agree to:

69 |
    70 |
  • Provide your own YouTube history data for processing
  • 71 |
  • Use the service only for personal, non-commercial purposes
  • 72 |
  • Not attempt to reverse engineer, modify, or manipulate the service
  • 73 |
  • Not use the service to violate any laws or regulations
  • 74 |
  • 75 | Accept full responsibility for any consequences that may arise from your use of the visualizations and 76 | insights provided 77 |
  • 78 |
79 |
80 | 81 |
82 |

4. Intellectual Property

83 |

84 | All content, features, and functionality of YouTube History Visualizer, including but not limited to 85 | text, graphics, logos, icons, and software code, are the exclusive property of YouTube History 86 | Visualizer and are protected by copyright, trademark, and other intellectual property laws. 87 |

88 |

89 | You are granted a limited, non-exclusive, non-transferable license to use the service for personal, 90 | non-commercial purposes only. 91 |

92 |
93 | 94 |
95 |

5. Disclaimer of Warranties

96 |

97 | YouTube History Visualizer is provided "as is" and "as available" without any 98 | warranties of any kind, either express or implied. We do not guarantee that: 99 |

100 |
    101 |
  • The service will meet your specific requirements
  • 102 |
  • The service will be uninterrupted, timely, secure, or error-free
  • 103 |
  • The results obtained from using the service will be accurate or reliable
  • 104 |
  • Any errors in the service will be corrected
  • 105 |
106 |
107 | 108 |
109 |

6. Limitation of Liability

110 |

111 | In no event shall YouTube History Visualizer, its directors, employees, partners, agents, suppliers, or 112 | affiliates be liable for any indirect, incidental, special, consequential, or punitive damages, 113 | including without limitation, loss of profits, data, use, goodwill, or other intangible losses, 114 | resulting from: 115 |

116 |
    117 |
  • Your access to or use of or inability to access or use the service
  • 118 |
  • Any conduct or content of any third party on the service
  • 119 |
  • Any content obtained from the service
  • 120 |
  • Unauthorized access, use, or alteration of your transmissions or content
  • 121 |
122 |
123 | 124 |
125 |

7. Data Usage and Privacy

126 |

127 | We process your YouTube history data entirely in your browser. We do not collect, store, or transmit 128 | any of your personal data. For more information, please refer to our{" "} 129 | 130 | Privacy Policy 131 | 132 | . 133 |

134 |
135 | 136 |
137 |

8. Changes to Terms

138 |

139 | We reserve the right to modify or replace these Terms of Service at any time at our sole discretion. If 140 | a revision is material, we will provide at least 30 days' notice prior to any new terms taking 141 | effect. What constitutes a material change will be determined at our sole discretion. 142 |

143 |

144 | By continuing to access or use our service after any revisions become effective, you agree to be bound 145 | by the revised terms. If you do not agree to the new terms, you are no longer authorized to use the 146 | service. 147 |

148 |
149 | 150 |
151 |

9. Governing Law

152 |

153 | These Terms shall be governed and construed in accordance with the laws, without regard to its conflict 154 | of law provisions. 155 |

156 |

157 | Our failure to enforce any right or provision of these Terms will not be considered a waiver of those 158 | rights. 159 |

160 |
161 | 162 |
163 |

10. Contact Us

164 |

165 | If you have any questions about these Terms, please contact us at: 166 | support@forgetimer.com 167 |

168 |
169 |
170 |
171 |
172 |
173 |
174 |

175 | © {new Date().getFullYear()} YouTube History Visualizer. All rights reserved. 176 |

177 |
178 | 179 | Privacy Policy 180 | 181 | 182 | Terms of Service 183 | 184 |
185 |
186 |
187 |
188 | ) 189 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/dashboard/channel-distribution-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts" 4 | 5 | interface ChannelCount { 6 | name: string 7 | count: number 8 | } 9 | 10 | interface ChannelDistributionChartProps { 11 | channelCounts: ChannelCount[] 12 | } 13 | 14 | export default function ChannelDistributionChart({ channelCounts }: ChannelDistributionChartProps) { 15 | // Colors for the pie chart 16 | const COLORS = [ 17 | "#8884d8", 18 | "#83a6ed", 19 | "#8dd1e1", 20 | "#82ca9d", 21 | "#a4de6c", 22 | "#d0ed57", 23 | "#ffc658", 24 | "#ff8042", 25 | "#ff6361", 26 | "#bc5090", 27 | "#808080", // for "Other" 28 | ] 29 | 30 | if (channelCounts.length === 0) { 31 | return ( 32 |
33 |

No channel data available

34 |
35 | ) 36 | } 37 | 38 | // Calculate total videos for percentage 39 | const totalVideos = channelCounts.reduce((sum, channel) => sum + channel.count, 0) 40 | 41 | return ( 42 |
43 |
44 | 45 | 46 | `${name}: ${(percent * 100).toFixed(0)}%`} 56 | > 57 | {channelCounts.map((entry, index) => ( 58 | 59 | ))} 60 | 61 | [value.toLocaleString(), "Videos Watched"]} /> 62 | 63 | 64 | 65 |
66 | 67 |
68 |

Top Channels

69 |
70 | {channelCounts.map((channel, index) => ( 71 |
72 |
73 |
{channel.name}
74 |
{channel.count.toLocaleString()} videos
75 |
{((channel.count / totalVideos) * 100).toFixed(1)}%
76 |
77 | ))} 78 |
79 |
80 |
81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /components/dashboard/daily-views-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useMemo } from "react" 4 | import { ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts" 5 | 6 | interface DailyView { 7 | date: string 8 | count: number 9 | } 10 | 11 | interface DailyViewsChartProps { 12 | dailyViews: DailyView[] 13 | } 14 | 15 | export default function DailyViewsChart({ dailyViews }: DailyViewsChartProps) { 16 | const chartData = useMemo(() => { 17 | // Fill in missing dates with zero counts 18 | const filledData = [] 19 | if (dailyViews.length > 0) { 20 | const startDate = new Date(dailyViews[0].date) 21 | const endDate = new Date(dailyViews[dailyViews.length - 1].date) 22 | 23 | for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { 24 | const dateString = d.toISOString().split("T")[0] 25 | const existingEntry = dailyViews.find((item) => item.date === dateString) 26 | 27 | if (existingEntry) { 28 | filledData.push(existingEntry) 29 | } else { 30 | filledData.push({ date: dateString, count: 0 }) 31 | } 32 | } 33 | } 34 | 35 | // Format dates for display 36 | return filledData.map((item) => ({ 37 | ...item, 38 | formattedDate: new Date(item.date).toLocaleDateString(undefined, { 39 | month: "short", 40 | day: "numeric", 41 | year: "2-digit", 42 | }), 43 | })) 44 | }, [dailyViews]) 45 | 46 | // If we have too many data points, we need to reduce them for better visualization 47 | const displayData = useMemo(() => { 48 | if (chartData.length <= 365) return chartData 49 | 50 | // For large datasets, aggregate by week or month 51 | const aggregatedData = [] 52 | let currentWeek = 0 53 | let weeklyCount = 0 54 | let weekStartDate = "" 55 | 56 | chartData.forEach((item, index) => { 57 | const date = new Date(item.date) 58 | const week = Math.floor(index / 7) 59 | 60 | if (week !== currentWeek) { 61 | if (weekStartDate) { 62 | aggregatedData.push({ 63 | date: weekStartDate, 64 | count: weeklyCount, 65 | formattedDate: `Week of ${new Date(weekStartDate).toLocaleDateString(undefined, { 66 | month: "short", 67 | day: "numeric", 68 | })}`, 69 | }) 70 | } 71 | currentWeek = week 72 | weeklyCount = 0 73 | weekStartDate = item.date 74 | } 75 | 76 | weeklyCount += item.count 77 | 78 | // Handle the last week 79 | if (index === chartData.length - 1) { 80 | aggregatedData.push({ 81 | date: weekStartDate, 82 | count: weeklyCount, 83 | formattedDate: `Week of ${new Date(weekStartDate).toLocaleDateString(undefined, { 84 | month: "short", 85 | day: "numeric", 86 | })}`, 87 | }) 88 | } 89 | }) 90 | 91 | return aggregatedData 92 | }, [chartData]) 93 | 94 | if (displayData.length === 0) { 95 | return ( 96 |
97 |

No data available

98 |
99 | ) 100 | } 101 | 102 | return ( 103 |
104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | value.toLocaleString()} /> 115 | [value.toLocaleString(), "Videos Watched"]} 117 | labelFormatter={(label) => `Date: ${label}`} 118 | /> 119 | 120 | 121 | 122 |
123 | ) 124 | } 125 | -------------------------------------------------------------------------------- /components/dashboard/stats-overview.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Calendar, Clock, Film, User } from "lucide-react" 4 | 5 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 6 | 7 | interface Stats { 8 | totalVideos: number 9 | oldestDate: string 10 | newestDate: string 11 | uniqueChannels: number 12 | daysDifference: number 13 | } 14 | 15 | interface StatsOverviewProps { 16 | stats: Stats 17 | } 18 | 19 | export default function StatsOverview({ stats }: StatsOverviewProps) { 20 | const oldestDate = new Date(stats.oldestDate) 21 | const newestDate = new Date(stats.newestDate) 22 | 23 | return ( 24 |
25 | 26 | 27 | Total Videos Watched 28 | 29 | 30 | 31 |
{stats.totalVideos.toLocaleString()}
32 |

Over {stats.daysDifference.toLocaleString()} days

33 |
34 |
35 | 36 | 37 | Average Per Day 38 | 39 | 40 | 41 |
{(stats.totalVideos / (stats.daysDifference || 1)).toFixed(1)}
42 |

Videos watched daily

43 |
44 |
45 | 46 | 47 | Unique Channels 48 | 49 | 50 | 51 |
{stats.uniqueChannels.toLocaleString()}
52 |

Different content creators

53 |
54 |
55 | 56 | 57 | Date Range 58 | 59 | 60 | 61 |
{stats.daysDifference.toLocaleString()} days
62 |

63 | {oldestDate.toLocaleDateString()} - {newestDate.toLocaleDateString()} 64 |

65 |
66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /components/dashboard/top-videos-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts" 4 | 5 | interface TopVideo { 6 | id: string 7 | title: string 8 | channel?: string 9 | count: number 10 | } 11 | 12 | interface TopVideosChartProps { 13 | topVideos: TopVideo[] 14 | } 15 | 16 | export default function TopVideosChart({ topVideos }: TopVideosChartProps) { 17 | // Format the data for display 18 | const chartData = topVideos.map((video) => ({ 19 | ...video, 20 | // Truncate long titles 21 | shortTitle: video.title.length > 40 ? video.title.substring(0, 37) + "..." : video.title, 22 | })) 23 | 24 | if (chartData.length === 0) { 25 | return ( 26 |
27 |

No data available

28 |
29 | ) 30 | } 31 | 32 | return ( 33 |
34 |
35 | 36 | 37 | 38 | 39 | 40 | [value, "Views"]} labelFormatter={(label) => label} /> 41 | 42 | 43 | 44 |
45 | 46 |
47 |

Top 10 Most Watched Videos

48 |
49 | {chartData.map((video, index) => ( 50 |
51 |
{index + 1}.
52 |
53 | 59 | {video.title} 60 | 61 | {video.channel &&
{video.channel}
} 62 |
63 |
{video.count} views
64 |
65 | ))} 66 |
67 |
68 |
69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /components/dashboard/viewing-time-chart.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from "recharts" 4 | 5 | interface HourlyView { 6 | hour: number 7 | count: number 8 | } 9 | 10 | interface ViewingTimeChartProps { 11 | hourlyViews: HourlyView[] 12 | } 13 | 14 | export default function ViewingTimeChart({ hourlyViews }: ViewingTimeChartProps) { 15 | // Format the data for display 16 | const chartData = hourlyViews.map((item) => ({ 17 | ...item, 18 | label: `${item.hour}:00`, 19 | })) 20 | 21 | if (chartData.length === 0 || chartData.every((item) => item.count === 0)) { 22 | return ( 23 |
24 |

No data available

25 |
26 | ) 27 | } 28 | 29 | return ( 30 |
31 | 32 | 33 | 34 | 35 | value.toLocaleString()} /> 36 | [value.toLocaleString(), "Videos Watched"]} 38 | labelFormatter={(label) => `Time: ${label}`} 39 | /> 40 | 41 | 42 | 43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as React from 'react' 4 | import { 5 | ThemeProvider as NextThemesProvider, 6 | type ThemeProviderProps, 7 | } from 'next-themes' 8 | 9 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | 56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 57 | 58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 59 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>