One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #145 from EvanTechDev/codex/remove-comments-and-fix-indentation

feat: i18n event color labels, add color translations, localize mini-calendar and remove default time categories

authored by

Evan Huang and committed by
GitHub
d0892a67 30f078c5

+4618 -3611
+190 -265
app/page.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import type React from "react" 3 + import type React from "react"; 4 4 5 - import { useState, useEffect, useRef } from "react" 6 - import SmartSimpleBrilliant from "@/components/marketing/smart-simple-brilliant" 7 - import YourWorkInSync from "@/components/marketing/your-work-in-sync" 8 - import DocumentationSection from "@/components/marketing/documentation-section" 9 - import FAQSection from "@/components/marketing/faq-section" 10 - import PricingSection from "@/components/marketing/pricing-section" 11 - import CTASection from "@/components/marketing/cta-section" 12 - import FooterSection from "@/components/marketing/footer-section" 13 - import Image from "next/image" 14 - import { useUser } from "@clerk/nextjs" 15 - import { useRouter } from "next/navigation" 16 - import { readEncryptedLocalStorage, writeEncryptedLocalStorage } from "@/hooks/useLocalStorage" 5 + import { useState, useEffect, useRef } from "react"; 6 + import SmartSimpleBrilliant from "@/components/marketing/smart-simple-brilliant"; 7 + import YourWorkInSync from "@/components/marketing/your-work-in-sync"; 8 + import DocumentationSection from "@/components/marketing/documentation-section"; 9 + import FAQSection from "@/components/marketing/faq-section"; 10 + import PricingSection from "@/components/marketing/pricing-section"; 11 + import CTASection from "@/components/marketing/cta-section"; 12 + import FooterSection from "@/components/marketing/footer-section"; 13 + import Image from "next/image"; 14 + import { useUser } from "@clerk/nextjs"; 15 + import { useRouter } from "next/navigation"; 16 + import { 17 + readEncryptedLocalStorage, 18 + writeEncryptedLocalStorage, 19 + } from "@/hooks/useLocalStorage"; 17 20 18 - // Reusable Badge Component 19 21 function Badge({ icon, text }: { icon: React.ReactNode; text: string }) { 20 22 return ( 21 23 <div className="px-[14px] py-[6px] bg-white shadow-[0px_0px_0px_4px_rgba(55,50,47,0.05)] overflow-hidden rounded-[90px] flex justify-start items-center gap-[8px] border border-[rgba(2,6,23,0.08)] shadow-xs"> 22 - <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center">{icon}</div> 24 + <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center"> 25 + {icon} 26 + </div> 23 27 <div className="text-center flex justify-center flex-col text-[#37322F] text-xs font-medium leading-3 font-sans"> 24 28 {text} 25 29 </div> 26 30 </div> 27 - ) 31 + ); 28 32 } 29 33 30 34 export default function LandingPage() { 31 - const [activeCard, setActiveCard] = useState(0) 32 - const [progress, setProgress] = useState(0) 33 - const router = useRouter() 34 - const { isLoaded, isSignedIn } = useUser() 35 - const mountedRef = useRef(true) 36 - const [shouldRender, setShouldRender] = useState(false) 35 + const [activeCard, setActiveCard] = useState(0); 36 + const [progress, setProgress] = useState(0); 37 + const router = useRouter(); 38 + const { isLoaded, isSignedIn } = useUser(); 39 + const mountedRef = useRef(true); 40 + const [shouldRender, setShouldRender] = useState(false); 37 41 38 42 useEffect(() => { 39 43 const progressInterval = setInterval(() => { 40 - if (!mountedRef.current) return 44 + if (!mountedRef.current) return; 41 45 42 46 setProgress((prev) => { 43 47 if (prev >= 100) { 44 48 if (mountedRef.current) { 45 - setActiveCard((current) => (current + 1) % 3) 49 + setActiveCard((current) => (current + 1) % 3); 46 50 } 47 - return 0 51 + return 0; 48 52 } 49 - return prev + 2 // 2% every 100ms = 5 seconds total 50 - }) 51 - }, 100) 53 + return prev + 2; 54 + }); 55 + }, 100); 52 56 53 57 return () => { 54 - clearInterval(progressInterval) 55 - mountedRef.current = false 56 - } 57 - }, []) 58 + clearInterval(progressInterval); 59 + mountedRef.current = false; 60 + }; 61 + }, []); 58 62 59 63 useEffect(() => { 60 64 return () => { 61 - mountedRef.current = false 62 - } 63 - }, []) 65 + mountedRef.current = false; 66 + }; 67 + }, []); 64 68 65 69 const handleCardClick = (index: number) => { 66 - if (!mountedRef.current) return 67 - setActiveCard(index) 68 - setProgress(0) 69 - } 70 + if (!mountedRef.current) return; 71 + setActiveCard(index); 72 + setProgress(0); 73 + }; 70 74 71 75 const getDashboardContent = () => { 72 76 switch (activeCard) { 73 77 case 0: 74 - return <div className="text-[#828387] text-sm">Customer Subscription Status and Details</div> 78 + return ( 79 + <div className="text-[#828387] text-sm"> 80 + Customer Subscription Status and Details 81 + </div> 82 + ); 75 83 case 1: 76 - return <div className="text-[#828387] text-sm">Analytics Dashboard - Real-time Insights</div> 84 + return ( 85 + <div className="text-[#828387] text-sm"> 86 + Analytics Dashboard - Real-time Insights 87 + </div> 88 + ); 77 89 case 2: 78 - return <div className="text-[#828387] text-sm">Data Visualization - Charts and Metrics</div> 90 + return ( 91 + <div className="text-[#828387] text-sm"> 92 + Data Visualization - Charts and Metrics 93 + </div> 94 + ); 79 95 default: 80 - return <div className="text-[#828387] text-sm">Customer Subscription Status and Details</div> 96 + return ( 97 + <div className="text-[#828387] text-sm"> 98 + Customer Subscription Status and Details 99 + </div> 100 + ); 81 101 } 82 - } 83 - 102 + }; 103 + 84 104 useEffect(() => { 85 - let active = true 86 - readEncryptedLocalStorage<string | boolean | null>("skip-landing", null).then((value) => { 87 - if (!active) return 88 - const hasSkippedLanding = value === "true" || value === true 105 + let active = true; 106 + readEncryptedLocalStorage<string | boolean | null>( 107 + "skip-landing", 108 + null, 109 + ).then((value) => { 110 + if (!active) return; 111 + const hasSkippedLanding = value === "true" || value === true; 89 112 if (hasSkippedLanding || (isLoaded && isSignedIn)) { 90 - router.replace("/app") 113 + router.replace("/app"); 91 114 } else if (isLoaded) { 92 - setShouldRender(true) 115 + setShouldRender(true); 93 116 } 94 - }) 117 + }); 95 118 return () => { 96 - active = false 97 - } 98 - }, [isLoaded, isSignedIn, router]) 119 + active = false; 120 + }; 121 + }, [isLoaded, isSignedIn, router]); 99 122 100 123 const handleGetStarted = () => { 101 - void writeEncryptedLocalStorage("skip-landing", "true") 102 - router.push("/app") 103 - } 124 + void writeEncryptedLocalStorage("skip-landing", "true"); 125 + router.push("/app"); 126 + }; 104 127 105 - if (!shouldRender) return null 128 + if (!shouldRender) return null; 106 129 107 130 return ( 108 131 <div className="w-full min-h-screen relative bg-white overflow-x-hidden flex flex-col justify-start items-center"> 109 132 <div className="relative flex flex-col justify-start items-center w-full"> 110 - {/* Main container with proper margins */} 133 + {} 111 134 <div className="w-full max-w-none px-4 sm:px-6 md:px-8 lg:px-0 lg:max-w-[1060px] lg:w-[1060px] relative flex flex-col justify-start items-start min-h-screen"> 112 - {/* Left vertical line */} 135 + {} 113 136 <div className="w-[1px] h-full absolute left-4 sm:left-6 md:left-8 lg:left-0 top-0 bg-[rgba(55,50,47,0.12)] shadow-[1px_0px_0px_white] z-0"></div> 114 137 115 - {/* Right vertical line */} 138 + {} 116 139 <div className="w-[1px] h-full absolute right-4 sm:right-6 md:right-8 lg:right-0 top-0 bg-[rgba(55,50,47,0.12)] shadow-[1px_0px_0px_white] z-0"></div> 117 140 118 141 <div className="self-stretch pt-[9px] overflow-hidden border-b border-[rgba(55,50,47,0.06)] flex flex-col justify-center items-center gap-4 sm:gap-6 md:gap-8 lg:gap-[66px] relative z-10"> 119 - {/* Navigation */} 142 + {} 120 143 <div className="w-full h-12 sm:h-14 md:h-16 lg:h-[84px] absolute left-0 top-0 flex justify-center items-center z-20 px-6 sm:px-8 md:px-12 lg:px-0"> 121 144 <div className="w-full h-0 absolute left-0 top-6 sm:top-7 md:top-8 lg:top-[42px] border-t border-[rgba(55,50,47,0.12)] shadow-[0px_1px_0px_white]"></div> 122 145 ··· 124 147 <div className="flex justify-center items-center"> 125 148 <div className="flex justify-start items-center"> 126 149 <div className="flex items-center gap-2 py-2 px-3"> 127 - <Image src="/icon.svg" alt="One Calendar" width={24} height={24} /> 150 + <Image 151 + src="/icon.svg" 152 + alt="One Calendar" 153 + width={24} 154 + height={24} 155 + /> 128 156 </div> 129 157 </div> 130 158 <div className="pl-3 sm:pl-4 md:pl-5 lg:pl-5 flex justify-start items-start hidden sm:flex flex-row gap-2 sm:gap-3 md:gap-4 lg:gap-4"> 131 159 <div className="flex justify-start items-center"> 132 160 <div className="flex flex-col justify-center text-[rgba(49,45,43,0.80)] text-xs md:text-[13px] font-medium leading-[14px] font-sans"> 133 - <a href="/about"> 134 - About 135 - </a> 161 + <a href="/about">About</a> 136 162 </div> 137 163 </div> 138 164 </div> ··· 140 166 <div className="h-6 sm:h-7 md:h-8 flex justify-start items-start gap-2 sm:gap-3"> 141 167 <div className="px-2 sm:px-3 md:px-[14px] py-1 sm:py-[6px] bg-white shadow-[0px_1px_2px_rgba(55,50,47,0.12)] overflow-hidden rounded-full flex justify-center items-center"> 142 168 <div className="flex flex-col justify-center text-[#37322F] text-xs md:text-[13px] font-medium leading-5 font-sans"> 143 - <a href="/sign-in"> 144 - Log in 145 - </a> 169 + <a href="/sign-in">Log in</a> 146 170 </div> 147 171 </div> 148 172 </div> 149 173 </div> 150 174 </div> 151 175 152 - {/* Hero Section */} 176 + {} 153 177 <div className="pt-16 sm:pt-20 md:pt-24 lg:pt-[216px] pb-8 sm:pb-12 md:pb-16 flex flex-col justify-start items-center px-2 sm:px-4 md:px-8 lg:px-0 w-full sm:pl-0 sm:pr-0 pl-0 pr-0"> 154 178 <div className="w-full max-w-[937px] lg:w-[937px] flex flex-col justify-center items-center gap-3 sm:gap-4 md:gap-5 lg:gap-6"> 155 179 <div className="self-stretch rounded-[3px] flex flex-col justify-center items-center gap-4 sm:gap-5 md:gap-6 lg:gap-8"> ··· 167 191 </div> 168 192 169 193 <a href="/sign-up"> 170 - <div className="w-full max-w-[497px] lg:w-[497px] flex flex-col justify-center items-center gap-6 sm:gap-8 md:gap-10 lg:gap-12 relative z-10 mt-6 sm:mt-8 md:mt-10 lg:mt-12"> 171 - <div className="backdrop-blur-[8.25px] flex justify-start items-center gap-4"> 172 - <div className="h-10 sm:h-11 md:h-12 px-6 sm:px-8 md:px-10 lg:px-12 py-2 sm:py-[6px] relative bg-[#37322F] shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset] overflow-hidden rounded-full flex justify-center items-center"> 173 - <div className="w-20 sm:w-24 md:w-28 lg:w-44 h-[41px] absolute left-0 top-[-0.5px] bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(0,0,0,0.10)] mix-blend-multiply"></div> 174 - <div className="flex flex-col justify-center text-white text-sm sm:text-base md:text-[15px] font-medium leading-5 font-sans"> 194 + <div className="w-full max-w-[497px] lg:w-[497px] flex flex-col justify-center items-center gap-6 sm:gap-8 md:gap-10 lg:gap-12 relative z-10 mt-6 sm:mt-8 md:mt-10 lg:mt-12"> 195 + <div className="backdrop-blur-[8.25px] flex justify-start items-center gap-4"> 196 + <div className="h-10 sm:h-11 md:h-12 px-6 sm:px-8 md:px-10 lg:px-12 py-2 sm:py-[6px] relative bg-[#37322F] shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset] overflow-hidden rounded-full flex justify-center items-center"> 197 + <div className="w-20 sm:w-24 md:w-28 lg:w-44 h-[41px] absolute left-0 top-[-0.5px] bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(0,0,0,0.10)] mix-blend-multiply"></div> 198 + <div className="flex flex-col justify-center text-white text-sm sm:text-base md:text-[15px] font-medium leading-5 font-sans"> 175 199 Get Started 200 + </div> 176 201 </div> 177 202 </div> 178 203 </div> 179 - </div> 180 204 </a> 181 205 182 206 <div className="absolute top-[232px] sm:top-[248px] md:top-[264px] lg:top-[320px] left-1/2 transform -translate-x-1/2 z-0 pointer-events-none"> ··· 192 216 193 217 <div className="w-full max-w-[960px] lg:w-[960px] pt-2 sm:pt-4 pb-6 sm:pb-8 md:pb-10 px-2 sm:px-4 md:px-6 lg:px-11 flex flex-col justify-center items-center gap-2 relative z-5 my-8 sm:my-12 md:my-16 lg:my-16 mb-0 lg:pb-0"> 194 218 <div className="w-full max-w-[960px] lg:w-[960px] h-[200px] sm:h-[280px] md:h-[450px] lg:h-[695.55px] bg-white shadow-[0px_0px_0px_0.9056603908538818px_rgba(0,0,0,0.08)] overflow-hidden rounded-[6px] sm:rounded-[8px] lg:rounded-[9.06px] flex flex-col justify-start items-start"> 195 - {/* Dashboard Content */} 219 + {} 196 220 <div className="self-stretch flex-1 flex justify-start items-start"> 197 - {/* Main Content */} 221 + {} 198 222 <div className="w-full h-full flex items-center justify-center"> 199 223 <div className="relative w-full h-full overflow-hidden"> 200 - {/* Product Image 1 - Plan your schedules */} 224 + {} 201 225 <div 202 226 className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 203 - activeCard === 0 ? "opacity-100 scale-100 blur-0" : "opacity-0 scale-95 blur-sm" 227 + activeCard === 0 228 + ? "opacity-100 scale-100 blur-0" 229 + : "opacity-0 scale-95 blur-sm" 204 230 }`} 205 231 > 206 232 <img ··· 210 236 /> 211 237 </div> 212 238 213 - {/* Product Image 2 - Data to insights */} 239 + {} 214 240 <div 215 241 className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 216 - activeCard === 1 ? "opacity-100 scale-100 blur-0" : "opacity-0 scale-95 blur-sm" 242 + activeCard === 1 243 + ? "opacity-100 scale-100 blur-0" 244 + : "opacity-0 scale-95 blur-sm" 217 245 }`} 218 246 > 219 247 <img ··· 223 251 /> 224 252 </div> 225 253 226 - {/* Product Image 3 - Data visualization */} 254 + {} 227 255 <div 228 256 className={`absolute inset-0 transition-all duration-500 ease-in-out ${ 229 - activeCard === 2 ? "opacity-100 scale-100 blur-0" : "opacity-0 scale-95 blur-sm" 257 + activeCard === 2 258 + ? "opacity-100 scale-100 blur-0" 259 + : "opacity-0 scale-95 blur-sm" 230 260 }`} 231 261 > 232 262 <img 233 263 src="/S.jpg" 234 264 alt="Share Page" 235 - className="w-full h-full object-contain" // Changed from object-cover to object-contain to preserve landscape aspect ratio 265 + className="w-full h-full object-contain" 236 266 /> 237 267 </div> 238 268 </div> ··· 241 271 </div> 242 272 </div> 243 273 244 - 245 274 <div className="self-stretch border-t border-[#E0DEDB] border-b border-[#E0DEDB] flex justify-center items-start"> 246 275 <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 247 - {/* Left decorative pattern */} 276 + {} 248 277 <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 249 278 {Array.from({ length: 50 }).map((_, i) => ( 250 279 <div ··· 256 285 </div> 257 286 258 287 <div className="flex-1 px-0 sm:px-2 md:px-0 flex flex-col md:flex-row justify-center items-stretch gap-0"> 259 - {/* Feature Cards */} 288 + {} 260 289 <FeatureCard 261 290 title="Plan your schedules" 262 291 description="Plan your schedules and manage your calendars with One Calendar." ··· 281 310 </div> 282 311 283 312 <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 284 - {/* Right decorative pattern */} 313 + {} 285 314 <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 286 315 {Array.from({ length: 50 }).map((_, i) => ( 287 316 <div ··· 293 322 </div> 294 323 </div> 295 324 296 - {/* Social Proof Section */} 297 - {/*<div className="w-full border-b border-[rgba(55,50,47,0.12)] flex flex-col justify-center items-center"> 298 - <div className="self-stretch px-4 sm:px-6 md:px-24 py-8 sm:py-12 md:py-16 border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6"> 299 - <div className="w-full max-w-[586px] px-4 sm:px-6 py-4 sm:py-5 shadow-[0px_2px_4px_rgba(50,45,43,0.06)] overflow-hidden rounded-lg flex flex-col justify-start items-center gap-3 sm:gap-4 shadow-none"> 300 - <Badge 301 - icon={ 302 - <svg width="12" height="10" viewBox="0 0 12 10" fill="none" xmlns="http://www.w3.org/2000/svg"> 303 - <rect x="1" y="3" width="4" height="6" stroke="#37322F" strokeWidth="1" fill="none" /> 304 - <rect x="7" y="1" width="4" height="8" stroke="#37322F" strokeWidth="1" fill="none" /> 305 - <rect x="2" y="4" width="1" height="1" fill="#37322F" /> 306 - <rect x="3.5" y="4" width="1" height="1" fill="#37322F" /> 307 - <rect x="2" y="5.5" width="1" height="1" fill="#37322F" /> 308 - <rect x="3.5" y="5.5" width="1" height="1" fill="#37322F" /> 309 - <rect x="8" y="2" width="1" height="1" fill="#37322F" /> 310 - <rect x="9.5" y="2" width="1" height="1" fill="#37322F" /> 311 - <rect x="8" y="3.5" width="1" height="1" fill="#37322F" /> 312 - <rect x="9.5" y="3.5" width="1" height="1" fill="#37322F" /> 313 - <rect x="8" y="5" width="1" height="1" fill="#37322F" /> 314 - <rect x="9.5" y="5" width="1" height="1" fill="#37322F" /> 315 - </svg> 316 - } 317 - text="Social Proof" 318 - /> 319 - <div className="w-full max-w-[472.55px] text-center flex justify-center flex-col text-[#49423D] text-xl sm:text-2xl md:text-3xl lg:text-5xl font-semibold leading-tight md:leading-[60px] font-sans tracking-tight"> 320 - Confidence backed by results 321 - </div> 322 - <div className="self-stretch text-center text-[#605A57] text-sm sm:text-base font-normal leading-6 sm:leading-7 font-sans"> 323 - Our customers achieve more each day 324 - <br className="hidden sm:block" /> 325 - because their tools are simple, powerful, and clear. 326 - </div> 327 - </div> 328 - </div> 325 + {} 326 + {} 329 327 330 - <div className="self-stretch border-[rgba(55,50,47,0.12)] flex justify-center items-start border-t border-b-0"> 331 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 332 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 333 - {Array.from({ length: 50 }).map((_, i) => ( 334 - <div 335 - key={i} 336 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 337 - /> 338 - ))} 339 - </div> 340 - </div> 341 - 342 - <div className="flex-1 grid grid-cols-2 sm:grid-cols-4 md:grid-cols-4 gap-0 border-l border-r border-[rgba(55,50,47,0.12)]"> 343 - {Array.from({ length: 8 }).map((_, index) => { 344 - const isMobileFirstColumn = index % 2 === 0 345 - const isMobileLastColumn = index % 2 === 1 346 - const isDesktopFirstColumn = index % 4 === 0 347 - const isDesktopLastColumn = index % 4 === 3 348 - const isMobileBottomRow = index >= 6 349 - const isDesktopTopRow = index < 4 350 - const isDesktopBottomRow = index >= 4 351 - 352 - return ( 353 - <div 354 - key={index} 355 - className={` 356 - h-24 xs:h-28 sm:h-32 md:h-36 lg:h-40 flex justify-center items-center gap-1 xs:gap-2 sm:gap-3 357 - border-b border-[rgba(227,226,225,0.5)] 358 - ${index < 6 ? "sm:border-b-[0.5px]" : "sm:border-b"} 359 - ${index >= 6 ? "border-b" : ""} 360 - ${isMobileFirstColumn ? "border-r-[0.5px]" : ""} 361 - sm:border-r-[0.5px] sm:border-l-0 362 - ${isDesktopFirstColumn ? "md:border-l" : "md:border-l-[0.5px]"} 363 - ${isDesktopLastColumn ? "md:border-r" : "md:border-r-[0.5px]"} 364 - ${isDesktopTopRow ? "md:border-b-[0.5px]" : ""} 365 - ${isDesktopBottomRow ? "md:border-t-[0.5px] md:border-b" : ""} 366 - border-[#E3E2E1] 367 - `} 368 - > 369 - <div className="w-6 h-6 xs:w-7 xs:h-7 sm:w-8 sm:h-8 md:w-9 md:h-9 lg:w-10 lg:h-10 relative shadow-[0px_-4px_8px_rgba(255,255,255,0.64)_inset] overflow-hidden rounded-full"> 370 - <img src="/horizon-icon.svg" alt="Horizon" className="w-full h-full object-contain" /> 371 - </div> 372 - <div className="text-center flex justify-center flex-col text-[#37322F] text-sm xs:text-base sm:text-lg md:text-xl lg:text-2xl font-medium leading-tight md:leading-9 font-sans"> 373 - Acute 374 - </div> 375 - </div> 376 - ) 377 - })} 378 - </div> 379 - 380 - <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 381 - <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 382 - {Array.from({ length: 50 }).map((_, i) => ( 383 - <div 384 - key={i} 385 - className="self-stretch h-3 sm:h-4 rotate-[-45deg] origin-top-left outline outline-[0.5px] outline-[rgba(3,7,18,0.08)] outline-offset-[-0.25px]" 386 - /> 387 - ))} 388 - </div> 389 - </div> 390 - </div> 391 - </div>*/} 392 - 393 - {/* Bento Grid Section */} 328 + {} 394 329 <div className="w-full border-b border-[rgba(55,50,47,0.12)] flex flex-col justify-center items-center"> 395 - {/* Header Section */} 330 + {} 396 331 <div className="self-stretch px-4 sm:px-6 md:px-8 lg:px-0 lg:max-w-[1060px] lg:w-[1060px] py-8 sm:py-12 md:py-16 border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6"> 397 332 <div className="w-full max-w-[616px] lg:w-[616px] px-4 sm:px-6 py-4 sm:py-5 shadow-[0px_2px_4px_rgba(50,45,43,0.06)] overflow-hidden rounded-lg flex flex-col justify-start items-center gap-3 sm:gap-4 shadow-none"> 398 333 <Badge 399 334 icon={ 400 - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> 401 - <rect x="1" y="1" width="4" height="4" stroke="#37322F" strokeWidth="1" fill="none" /> 402 - <rect x="7" y="1" width="4" height="4" stroke="#37322F" strokeWidth="1" fill="none" /> 403 - <rect x="1" y="7" width="4" height="4" stroke="#37322F" strokeWidth="1" fill="none" /> 404 - <rect x="7" y="7" width="4" height="4" stroke="#37322F" strokeWidth="1" fill="none" /> 335 + <svg 336 + width="12" 337 + height="12" 338 + viewBox="0 0 12 12" 339 + fill="none" 340 + xmlns="http://www.w3.org/2000/svg" 341 + > 342 + <rect 343 + x="1" 344 + y="1" 345 + width="4" 346 + height="4" 347 + stroke="#37322F" 348 + strokeWidth="1" 349 + fill="none" 350 + /> 351 + <rect 352 + x="7" 353 + y="1" 354 + width="4" 355 + height="4" 356 + stroke="#37322F" 357 + strokeWidth="1" 358 + fill="none" 359 + /> 360 + <rect 361 + x="1" 362 + y="7" 363 + width="4" 364 + height="4" 365 + stroke="#37322F" 366 + strokeWidth="1" 367 + fill="none" 368 + /> 369 + <rect 370 + x="7" 371 + y="7" 372 + width="4" 373 + height="4" 374 + stroke="#37322F" 375 + strokeWidth="1" 376 + fill="none" 377 + /> 405 378 </svg> 406 379 } 407 380 text="Bento grid" ··· 417 390 </div> 418 391 </div> 419 392 420 - {/* Bento Grid Content */} 393 + {} 421 394 <div className="self-stretch flex justify-center items-start"> 422 395 <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 423 - {/* Left decorative pattern */} 396 + {} 424 397 <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 425 398 {Array.from({ length: 200 }).map((_, i) => ( 426 399 <div ··· 432 405 </div> 433 406 434 407 <div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-0 border-l border-r border-[rgba(55,50,47,0.12)]"> 435 - {/* Top Left - Smart. Simple. Brilliant. */} 408 + {} 436 409 <div className="border-b border-r-0 md:border-r border-[rgba(55,50,47,0.12)] p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6"> 437 410 <div className="flex flex-col gap-2"> 438 411 <h3 className="text-[#37322F] text-lg sm:text-xl font-semibold leading-tight font-sans"> 439 412 Smart. Simple. Brilliant. 440 413 </h3> 441 414 <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 442 - Your data is beautifully organized so you see everything clearly without the clutter. 415 + Your data is beautifully organized so you see 416 + everything clearly without the clutter. 443 417 </p> 444 418 </div> 445 419 <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex items-center justify-center overflow-hidden"> ··· 452 426 </div> 453 427 </div> 454 428 455 - {/* Top Right - Your work, in sync */} 429 + {} 456 430 <div className="border-b border-[rgba(55,50,47,0.12)] p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6"> 457 431 <div className="flex flex-col gap-2"> 458 432 <h3 className="text-[#37322F] font-semibold leading-tight font-sans text-lg sm:text-xl"> 459 433 Your personal AI assistant. 460 434 </h3> 461 435 <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 462 - Every users can talk to our One AI and get fast response 436 + Every users can talk to our One AI and get fast 437 + response 463 438 </p> 464 439 </div> 465 440 <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex overflow-hidden text-right items-center justify-center"> ··· 472 447 </div> 473 448 </div> 474 449 475 - {/* Bottom Left - Effortless integration 476 - <div className="border-r-0 md:border-r border-[rgba(55,50,47,0.12)] p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6 bg-transparent"> 477 - <div className="flex flex-col gap-2"> 478 - <h3 className="text-[#37322F] text-lg sm:text-xl font-semibold leading-tight font-sans"> 479 - Effortless integration 480 - </h3> 481 - <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 482 - All your favorite tools connect in one place and work together seamlessly by design. 483 - </p> 484 - </div> 485 - <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex overflow-hidden justify-center items-center relative bg-transparent"> 486 - <div className="w-full h-full flex items-center justify-center bg-transparent"> 487 - <EffortlessIntegration width={400} height={250} className="max-w-full max-h-full" /> 488 - </div> 489 - Gradient mask for soft bottom edge 490 - <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-[#F7F5F3] to-transparent pointer-events-none"></div> 491 - </div> 492 - </div> 493 - 494 - Bottom Right - Numbers that speak 495 - <div className="p-4 sm:p-6 md:p-8 lg:p-12 flex flex-col justify-start items-start gap-4 sm:gap-6"> 496 - <div className="flex flex-col gap-2"> 497 - <h3 className="text-[#37322F] text-lg sm:text-xl font-semibold leading-tight font-sans"> 498 - Numbers that speak 499 - </h3> 500 - <p className="text-[#605A57] text-sm md:text-base font-normal leading-relaxed font-sans"> 501 - Track growth with precision and turn raw data into confident decisions you can trust. 502 - </p> 503 - </div> 504 - <div className="w-full h-[200px] sm:h-[250px] md:h-[300px] rounded-lg flex overflow-hidden items-center justify-center relative"> 505 - <div className="absolute inset-0 flex items-center justify-center"> 506 - <NumbersThatSpeak 507 - width="100%" 508 - height="100%" 509 - theme="light" 510 - className="w-full h-full object-contain" 511 - /> 512 - </div> 513 - Gradient mask for soft bottom edge 514 - <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-[#F7F5F3] to-transparent pointer-events-none"></div> 515 - Fallback content if component doesn't render 516 - <div className="absolute inset-0 flex items-center justify-center opacity-20 hidden"> 517 - <div className="flex flex-col items-center gap-2 p-4"> 518 - <div className="w-3/4 h-full bg-green-500 rounded-full"></div> 519 - </div> 520 - <div className="text-sm text-green-600">Growth Rate</div> 521 - </div> 522 - </div> 523 - </div>*/} 450 + {} 524 451 </div> 525 452 526 453 <div className="w-4 sm:w-6 md:w-8 lg:w-12 self-stretch relative overflow-hidden"> 527 - {/* Right decorative pattern */} 454 + {} 528 455 <div className="w-[120px] sm:w-[140px] md:w-[162px] left-[-40px] sm:left-[-50px] md:left-[-58px] top-[-120px] absolute flex flex-col justify-start items-start"> 529 456 {Array.from({ length: 200 }).map((_, i) => ( 530 457 <div ··· 537 464 </div> 538 465 </div> 539 466 540 - {/* Documentation Section */} 467 + {} 541 468 <DocumentationSection /> 542 469 543 - {/* Testimonials Section 544 - <TestimonialsSection />*/} 470 + {} 545 471 546 - {/* Pricing Section */} 472 + {} 547 473 <PricingSection /> 548 474 549 - {/* FAQ Section */} 475 + {} 550 476 <FAQSection /> 551 477 552 - {/* CTA Section */} 478 + {} 553 479 <CTASection /> 554 480 555 - {/* Footer Section */} 481 + {} 556 482 <FooterSection /> 557 483 </div> 558 484 </div> 559 485 </div> 560 486 </div> 561 487 </div> 562 - ) 488 + ); 563 489 } 564 490 565 - // FeatureCard component definition inline to fix import error 566 491 function FeatureCard({ 567 492 title, 568 493 description, ··· 570 495 progress, 571 496 onClick, 572 497 }: { 573 - title: string 574 - description: string 575 - isActive: boolean 576 - progress: number 577 - onClick: () => void 498 + title: string; 499 + description: string; 500 + isActive: boolean; 501 + progress: number; 502 + onClick: () => void; 578 503 }) { 579 504 return ( 580 505 <div ··· 601 526 {description} 602 527 </div> 603 528 </div> 604 - ) 529 + ); 605 530 }
+19 -22
app/sitemap.ts
··· 1 - import { MetadataRoute } from 'next'; 2 - import fs from 'fs'; 3 - import path from 'path'; 1 + import { MetadataRoute } from "next"; 2 + import fs from "fs"; 3 + import path from "path"; 4 4 5 - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://calendar.xyehr.cn'; 5 + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://calendar.xyehr.cn"; 6 6 7 - export const revalidate = 86400; // Revalidate once per day 7 + export const revalidate = 86400; 8 8 9 9 export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 10 - 11 - // Get file modification dates for static pages 12 10 const getFileModDate = (filePath: string) => { 13 11 try { 14 12 const stats = fs.statSync(path.join(process.cwd(), filePath)); ··· 18 16 } 19 17 }; 20 18 21 - // Static routes with actual file modification dates 22 19 const routes: MetadataRoute.Sitemap = [ 23 20 { 24 21 url: baseUrl, 25 - lastModified: getFileModDate('app/page.tsx'), 26 - changeFrequency: 'daily', 22 + lastModified: getFileModDate("app/page.tsx"), 23 + changeFrequency: "daily", 27 24 priority: 1.0, 28 25 }, 29 26 { 30 27 url: `${baseUrl}/about`, 31 - lastModified: getFileModDate('app/about/page.tsx'), 32 - changeFrequency: 'monthly', 28 + lastModified: getFileModDate("app/about/page.tsx"), 29 + changeFrequency: "monthly", 33 30 priority: 0.8, 34 31 }, 35 32 { 36 33 url: `${baseUrl}/privacy`, 37 - lastModified: getFileModDate('app/privacy/page.tsx'), 38 - changeFrequency: 'monthly', 34 + lastModified: getFileModDate("app/privacy/page.tsx"), 35 + changeFrequency: "monthly", 39 36 priority: 0.8, 40 37 }, 41 38 { 42 39 url: `${baseUrl}/terms`, 43 - lastModified: getFileModDate('app/terms/page.tsx'), 44 - changeFrequency: 'monthly', 40 + lastModified: getFileModDate("app/terms/page.tsx"), 41 + changeFrequency: "monthly", 45 42 priority: 0.8, 46 43 }, 47 44 { 48 45 url: `${baseUrl}/app`, 49 - lastModified: getFileModDate('app/app/page.tsx'), 50 - changeFrequency: 'daliy', 46 + lastModified: getFileModDate("app/app/page.tsx"), 47 + changeFrequency: "daliy", 51 48 priority: 1.0, 52 49 }, 53 50 { 54 51 url: `${baseUrl}/sign-in`, 55 - lastModified: getFileModDate('app/sign-in/page.tsx'), 56 - changeFrequency: 'monthly', 52 + lastModified: getFileModDate("app/sign-in/page.tsx"), 53 + changeFrequency: "monthly", 57 54 priority: 0.7, 58 55 }, 59 56 { 60 57 url: `${baseUrl}/sign-up`, 61 - lastModified: getFileModDate('app/sign-up/page.tsx'), 62 - changeFrequency: 'monthly', 58 + lastModified: getFileModDate("app/sign-up/page.tsx"), 59 + changeFrequency: "monthly", 63 60 priority: 0.7, 64 61 }, 65 62 ];
+136 -96
components/app/analytics/events-calendar.tsx
··· 1 - import { format, startOfWeek, addDays, startOfYear, endOfYear, isSameDay, parseISO, getDay, differenceInDays } from 'date-fns'; 2 - import { getEncryptionState, readEncryptedLocalStorage, subscribeEncryptionState } from "@/hooks/useLocalStorage"; 3 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 1 + import { 2 + format, 3 + startOfWeek, 4 + addDays, 5 + startOfYear, 6 + endOfYear, 7 + isSameDay, 8 + parseISO, 9 + getDay, 10 + differenceInDays, 11 + } from "date-fns"; 12 + import { 13 + getEncryptionState, 14 + readEncryptedLocalStorage, 15 + subscribeEncryptionState, 16 + } from "@/hooks/useLocalStorage"; 17 + import { 18 + Select, 19 + SelectContent, 20 + SelectItem, 21 + SelectTrigger, 22 + SelectValue, 23 + } from "@/components/ui/select"; 4 24 import { isZhLanguage, translations, useLanguage } from "@/lib/i18n"; 5 25 import { Card, CardContent } from "@/components/ui/card"; 6 - import React, { useEffect, useState } from 'react'; 26 + import React, { useEffect, useState } from "react"; 7 27 8 28 interface CalendarEvent { 9 29 id: string; ··· 23 43 const EventsCalendar: React.FC = () => { 24 44 const [events, setEvents] = useState<CalendarEvent[]>([]); 25 45 const [availableYears, setAvailableYears] = useState<number[]>([]); 26 - const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear()); 46 + const [selectedYear, setSelectedYear] = useState<number>( 47 + new Date().getFullYear(), 48 + ); 27 49 const [language] = useLanguage(); 28 50 const t = translations[language]; 29 51 const isZh = isZhLanguage(language); 30 - 52 + 31 53 useEffect(() => { 32 54 let active = true; 33 55 const loadEvents = () => 34 - readEncryptedLocalStorage<CalendarEvent[]>('calendar-events', []).then((parsedEvents) => { 35 - if (!active) return; 36 - setEvents(parsedEvents); 56 + readEncryptedLocalStorage<CalendarEvent[]>("calendar-events", []).then( 57 + (parsedEvents) => { 58 + if (!active) return; 59 + setEvents(parsedEvents); 37 60 38 - // ่ฎก็ฎ—ๆ‰€ๆœ‰ๆœ‰ไบ‹ไปถ็š„ๅนดไปฝ 39 - const years = new Set<number>(); 40 - parsedEvents.forEach(event => { 41 - const startYear = new Date(event.startDate).getFullYear(); 42 - const endYear = new Date(event.endDate).getFullYear(); 43 - for (let year = startYear; year <= endYear; year++) { 44 - years.add(year); 45 - } 46 - }); 61 + const years = new Set<number>(); 62 + parsedEvents.forEach((event) => { 63 + const startYear = new Date(event.startDate).getFullYear(); 64 + const endYear = new Date(event.endDate).getFullYear(); 65 + for (let year = startYear; year <= endYear; year++) { 66 + years.add(year); 67 + } 68 + }); 47 69 48 - const sortedYears = Array.from(years).sort(); 49 - setAvailableYears(sortedYears); 70 + const sortedYears = Array.from(years).sort(); 71 + setAvailableYears(sortedYears); 50 72 51 - // ๅฆ‚ๆžœๆœ‰ไบ‹ไปถๅนดไปฝ,้ป˜่ฎค้€‰ๆ‹ฉๆœ€่ฟ‘็š„ๅนดไปฝ 52 - if (sortedYears.length > 0) { 53 - const currentYear = new Date().getFullYear(); 54 - // ๆ‰พๅˆฐๅฝ“ๅ‰ๅนดไปฝๆˆ–่€…ๆœ€่ฟ‘็š„ๅนดไปฝ 55 - const closestYear = sortedYears.reduce((prev, curr) => 56 - Math.abs(curr - currentYear) < Math.abs(prev - currentYear) ? curr : prev 57 - ); 58 - setSelectedYear(closestYear); 59 - } 60 - }); 73 + if (sortedYears.length > 0) { 74 + const currentYear = new Date().getFullYear(); 75 + 76 + const closestYear = sortedYears.reduce((prev, curr) => 77 + Math.abs(curr - currentYear) < Math.abs(prev - currentYear) 78 + ? curr 79 + : prev, 80 + ); 81 + setSelectedYear(closestYear); 82 + } 83 + }, 84 + ); 61 85 62 86 loadEvents(); 63 87 const unsubscribe = subscribeEncryptionState(() => { ··· 72 96 }, []); 73 97 74 98 const getEventCountForDay = (day: Date) => { 75 - return events.filter(event => { 99 + return events.filter((event) => { 76 100 const startDate = parseISO(event.startDate); 77 101 const endDate = parseISO(event.endDate); 78 - 102 + 79 103 return ( 80 - isSameDay(day, startDate) || 104 + isSameDay(day, startDate) || 81 105 isSameDay(day, endDate) || 82 106 (day > startDate && day < endDate) 83 107 ); ··· 85 109 }; 86 110 87 111 const getColorIntensity = (count: number) => { 88 - if (count === 0) return 'bg-gray-100 dark:bg-gray-800'; 89 - if (count === 1) return 'bg-emerald-100 dark:bg-emerald-900'; 90 - if (count === 2) return 'bg-emerald-300 dark:bg-emerald-700'; 91 - if (count === 3) return 'bg-emerald-500 dark:bg-emerald-600'; 92 - return 'bg-emerald-700 dark:bg-emerald-500'; 112 + if (count === 0) return "bg-gray-100 dark:bg-gray-800"; 113 + if (count === 1) return "bg-emerald-100 dark:bg-emerald-900"; 114 + if (count === 2) return "bg-emerald-300 dark:bg-emerald-700"; 115 + if (count === 3) return "bg-emerald-500 dark:bg-emerald-600"; 116 + return "bg-emerald-700 dark:bg-emerald-500"; 93 117 }; 94 118 95 119 const formatMonthLabel = (date: Date) => { 96 - // ่Žทๅ–ๆœˆไปฝ็ดขๅผ• (0-11) 97 120 const monthIndex = date.getMonth(); 98 - // ไปŽ็ฟป่ฏ‘ๅฏน่ฑกไธญ่Žทๅ–ๅฏนๅบ”่ฏญ่จ€็š„ๆœˆไปฝๅ็งฐ 121 + 99 122 return t.months[monthIndex]; 100 123 }; 101 124 102 125 const renderCalendarGrid = () => { 103 126 if (availableYears.length === 0) { 104 - return <div className="text-gray-500 dark:text-gray-400">{t.noEventsFound}</div>; 127 + return ( 128 + <div className="text-gray-500 dark:text-gray-400"> 129 + {t.noEventsFound} 130 + </div> 131 + ); 105 132 } 106 133 107 - // ๅˆ›ๅปบๆ—ฅๅކ็š„ๆ•ฐๆฎ 108 134 const firstDayOfYear = startOfYear(new Date(selectedYear, 0, 1)); 109 135 const lastDayOfYear = endOfYear(new Date(selectedYear, 11, 31)); 110 - 111 - // ็กฎไฟๆ—ฅๅކไปŽๅ‘จๆ—ฅๅผ€ๅง‹ 136 + 112 137 const startDay = startOfWeek(firstDayOfYear); 113 - // ็กฎไฟๆ—ฅๅކๅˆฐๅ‘จๅ…ญ็ป“ๆŸ 138 + 114 139 const endDay = addDays(lastDayOfYear, 6 - getDay(lastDayOfYear)); 115 - 116 - // ่ฎก็ฎ—ๆ€ปๅคฉๆ•ฐ 140 + 117 141 const totalDays = differenceInDays(endDay, startDay) + 1; 118 - // ่ฎก็ฎ—ๆ€ปๅ‘จๆ•ฐ 142 + 119 143 const totalWeeks = Math.ceil(totalDays / 7); 120 - 121 - // ็”Ÿๆˆๆ‰€ๆœ‰ๆ—ฅๆœŸ 144 + 122 145 const allDates = []; 123 146 for (let i = 0; i < totalDays; i++) { 124 147 allDates.push(addDays(startDay, i)); 125 148 } 126 - 127 - // ่ฎก็ฎ—ๆฏไธชๆœˆ็š„็ฌฌไธ€ๅคฉๅŠๅ…ถไฝ็ฝฎ 149 + 128 150 const monthLabels = []; 129 151 for (let month = 0; month < 12; month++) { 130 152 const firstDayOfMonth = new Date(selectedYear, month, 1); 131 - // ๅฆ‚ๆžœ่ฟ™ไธชๆœˆ็š„็ฌฌไธ€ๅคฉๅœจๆ—ฅๅކ่Œƒๅ›ดๅ†… 153 + 132 154 if (firstDayOfMonth >= startDay && firstDayOfMonth <= endDay) { 133 155 const dayIndex = differenceInDays(firstDayOfMonth, startDay); 134 156 const weekIndex = Math.floor(dayIndex / 7); 135 157 monthLabels.push({ 136 158 label: formatMonthLabel(firstDayOfMonth), 137 - weekIndex: weekIndex 159 + weekIndex: weekIndex, 138 160 }); 139 161 } 140 162 } 141 163 142 - // ่ฎก็ฎ—ๆฏไธชๆ—ฅๆœŸๅ—็š„ๅฐบๅฏธๅ’Œ้—ด่ท 143 - const cellSize = 15; // 15px 144 - const cellGap = 3; // 3px 164 + const cellSize = 15; 165 + const cellGap = 3; 145 166 const cellWithGap = cellSize + cellGap; 146 - 147 - // ๆœˆไปฝๆ ‡็ญพๅ‘ๅณๅ็งป้‡(ๅƒ็ด ) 148 - const monthLabelOffset = 48; // ๅ‘ๅณๅ็งป48ๅƒ็ด  149 - 167 + 168 + const monthLabelOffset = 48; 169 + 150 170 return ( 151 171 <div className="relative"> 152 172 <div className="flex items-center mb-6"> 153 173 <h2 className="text-lg font-semibold mr-4">{t.eventsCalendar}</h2> 154 - <Select 174 + <Select 155 175 value={selectedYear.toString()} 156 176 onValueChange={(value) => setSelectedYear(Number(value))} 157 177 > ··· 159 179 <SelectValue placeholder={t.selectYear} /> 160 180 </SelectTrigger> 161 181 <SelectContent> 162 - {availableYears.map(year => ( 163 - <SelectItem key={year} value={year.toString()}>{year}</SelectItem> 182 + {availableYears.map((year) => ( 183 + <SelectItem key={year} value={year.toString()}> 184 + {year} 185 + </SelectItem> 164 186 ))} 165 187 </SelectContent> 166 188 </Select> 167 189 </div> 168 - 190 + 169 191 <div className="overflow-x-auto pb-2"> 170 - <div style={{ position: 'relative', paddingTop: '20px', minWidth: `${Math.max(totalWeeks * cellWithGap, 720)}px` }}> 171 - {/* ๆœˆไปฝๆ ‡็ญพ */} 172 - <div style={{ position: 'absolute', top: 0, left: 0, right: 0 }}> 192 + <div 193 + style={{ 194 + position: "relative", 195 + paddingTop: "20px", 196 + minWidth: `${Math.max(totalWeeks * cellWithGap, 720)}px`, 197 + }} 198 + > 199 + {} 200 + <div style={{ position: "absolute", top: 0, left: 0, right: 0 }}> 173 201 {monthLabels.map((month, i) => ( 174 - <div 175 - key={`month-${i}`} 202 + <div 203 + key={`month-${i}`} 176 204 className="text-xs text-gray-500 dark:text-gray-400 absolute" 177 - style={{ left: `${month.weekIndex * cellWithGap + monthLabelOffset}px` }} 205 + style={{ 206 + left: `${month.weekIndex * cellWithGap + monthLabelOffset}px`, 207 + }} 178 208 > 179 209 {month.label} 180 210 </div> 181 211 ))} 182 212 </div> 183 - 184 - {/* ๆ˜ŸๆœŸๆ ‡็ญพๅ’Œๆ—ฅๅކ็ฝ‘ๆ ผ */} 213 + 214 + {} 185 215 <div className="flex"> 186 - {/* ๆ˜ŸๆœŸๆ ‡็ญพ */} 216 + {} 187 217 <div className="flex flex-col pr-2"> 188 218 {t.weekdays.map((day, i) => ( 189 - <div 190 - key={`day-${i}`} 219 + <div 220 + key={`day-${i}`} 191 221 className="text-xs text-gray-500 dark:text-gray-400 flex items-center justify-end" 192 - style={{ height: `${cellSize}px`, marginBottom: `${cellGap}px` }} 222 + style={{ 223 + height: `${cellSize}px`, 224 + marginBottom: `${cellGap}px`, 225 + }} 193 226 > 194 - {i % 2 === 0 ? day : ''} 227 + {i % 2 === 0 ? day : ""} 195 228 </div> 196 229 ))} 197 230 </div> 198 - 199 - {/* ๆ—ฅๅކ็ฝ‘ๆ ผ */} 200 - <div style={{ display: 'flex' }}> 231 + 232 + {} 233 + <div style={{ display: "flex" }}> 201 234 {Array.from({ length: totalWeeks }).map((_, weekIndex) => ( 202 - <div key={`week-${weekIndex}`} style={{ marginRight: `${cellGap}px` }}> 235 + <div 236 + key={`week-${weekIndex}`} 237 + style={{ marginRight: `${cellGap}px` }} 238 + > 203 239 {Array.from({ length: 7 }).map((_, dayIndex) => { 204 240 const dateIndex = weekIndex * 7 + dayIndex; 205 241 const date = allDates[dateIndex]; 206 242 const isCurrentYear = date.getFullYear() === selectedYear; 207 - 208 - const eventCount = isCurrentYear ? getEventCountForDay(date) : 0; 243 + 244 + const eventCount = isCurrentYear 245 + ? getEventCountForDay(date) 246 + : 0; 209 247 const colorClass = getColorIntensity(eventCount); 210 - 248 + 211 249 return ( 212 - <div 250 + <div 213 251 key={`cell-${weekIndex}-${dayIndex}`} 214 - className={`rounded-sm ${isCurrentYear ? colorClass : 'bg-transparent'} cursor-pointer hover:ring-1 hover:ring-gray-400 dark:hover:ring-gray-500 transition-colors duration-200`} 215 - style={{ 216 - width: `${cellSize}px`, 217 - height: `${cellSize}px`, 218 - marginBottom: `${cellGap}px` 252 + className={`rounded-sm ${isCurrentYear ? colorClass : "bg-transparent"} cursor-pointer hover:ring-1 hover:ring-gray-400 dark:hover:ring-gray-500 transition-colors duration-200`} 253 + style={{ 254 + width: `${cellSize}px`, 255 + height: `${cellSize}px`, 256 + marginBottom: `${cellGap}px`, 219 257 }} 220 - title={isCurrentYear ? `${format(date, 'yyyy-MM-dd')}: ${eventCount} ${isZh ? 'ไธชไบ‹ไปถ' : 'events'}` : ''} 258 + title={ 259 + isCurrentYear 260 + ? `${format(date, "yyyy-MM-dd")}: ${eventCount} ${isZh ? "ไธชไบ‹ไปถ" : "events"}` 261 + : "" 262 + } 221 263 /> 222 264 ); 223 265 })} ··· 227 269 </div> 228 270 </div> 229 271 </div> 230 - 272 + 231 273 <div className="flex items-center mt-2 text-xs text-gray-600 dark:text-gray-300"> 232 274 <span className="mr-2">{t.less}</span> 233 275 <div className="flex gap-1"> ··· 245 287 246 288 return ( 247 289 <Card className="overflow-hidden"> 248 - <CardContent className="pt-4"> 249 - {renderCalendarGrid()} 250 - </CardContent> 290 + <CardContent className="pt-4">{renderCalendarGrid()}</CardContent> 251 291 </Card> 252 292 ); 253 293 };
+466 -339
components/app/analytics/import-export.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 4 - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 5 - import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" 6 - import { Download, Upload, CalendarIcon, ExternalLink, AlertCircle } from 'lucide-react' 7 - import { decryptPayload, encryptPayload, isEncryptedPayload } from "@/lib/crypto" 8 - import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" 9 - import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 10 - import { translations, useLanguage } from "@/lib/i18n" 11 - import { Checkbox } from "@/components/ui/checkbox" 12 - import type { CalendarEvent } from "../calendar" 13 - import { Button } from "@/components/ui/button" 14 - import { Input } from "@/components/ui/input" 15 - import { Label } from "@/components/ui/label" 16 - import { useState, useEffect } from "react" 17 - import { toast } from "sonner" 3 + import { 4 + Select, 5 + SelectContent, 6 + SelectItem, 7 + SelectTrigger, 8 + SelectValue, 9 + } from "@/components/ui/select"; 10 + import { 11 + Card, 12 + CardContent, 13 + CardDescription, 14 + CardHeader, 15 + CardTitle, 16 + } from "@/components/ui/card"; 17 + import { 18 + Dialog, 19 + DialogContent, 20 + DialogHeader, 21 + DialogTitle, 22 + } from "@/components/ui/dialog"; 23 + import { 24 + Download, 25 + Upload, 26 + CalendarIcon, 27 + ExternalLink, 28 + AlertCircle, 29 + } from "lucide-react"; 30 + import { 31 + decryptPayload, 32 + encryptPayload, 33 + isEncryptedPayload, 34 + } from "@/lib/crypto"; 35 + import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; 36 + import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 37 + import { translations, useLanguage } from "@/lib/i18n"; 38 + import { Checkbox } from "@/components/ui/checkbox"; 39 + import type { CalendarEvent } from "../calendar"; 40 + import { Button } from "@/components/ui/button"; 41 + import { Input } from "@/components/ui/input"; 42 + import { Label } from "@/components/ui/label"; 43 + import { useState, useEffect } from "react"; 44 + import { toast } from "sonner"; 18 45 19 46 interface ImportExportProps { 20 - events: CalendarEvent[] 21 - onImportEvents: (events: CalendarEvent[]) => void 47 + events: CalendarEvent[]; 48 + onImportEvents: (events: CalendarEvent[]) => void; 22 49 } 23 50 24 - export default function ImportExport({ events, onImportEvents }: ImportExportProps) { 25 - const [importDialogOpen, setImportDialogOpen] = useState(false) 26 - const [exportDialogOpen, setExportDialogOpen] = useState(false) 27 - const [exportFormat, setExportFormat] = useState("ics") 28 - const [importTab, setImportTab] = useState("file") 29 - const [importUrl, setImportUrl] = useState("") 30 - const [selectedFile, setSelectedFile] = useState<File | null>(null) 31 - const [includeCompleted, setIncludeCompleted] = useState(true) 32 - const [dateRangeOption, setDateRangeOption] = useState("all") 33 - const [isLoading, setIsLoading] = useState(false) 34 - const [jsonPassword, setJsonPassword] = useState("") 35 - const [jsonPasswordConfirm, setJsonPasswordConfirm] = useState("") 36 - const [jsonImportEncrypted, setJsonImportEncrypted] = useState(false) 37 - const [jsonImportPassword, setJsonImportPassword] = useState("") 38 - const [debugMode, setDebugMode] = useState(false) 39 - const [debugInfo, setDebugInfo] = useState<string>("") 40 - const [language] = useLanguage() // ไฝฟ็”จuseLanguage้’ฉๅญ 41 - const t = translations[language] 42 - // ๆทปๅŠ ไธ€ไธช็Šถๆ€ๆฅๅผบๅˆถ็ป„ไปถ้‡ๆ–ฐๆธฒๆŸ“ 43 - const [forceUpdate, setForceUpdate] = useState(0) 51 + export default function ImportExport({ 52 + events, 53 + onImportEvents, 54 + }: ImportExportProps) { 55 + const [importDialogOpen, setImportDialogOpen] = useState(false); 56 + const [exportDialogOpen, setExportDialogOpen] = useState(false); 57 + const [exportFormat, setExportFormat] = useState("ics"); 58 + const [importTab, setImportTab] = useState("file"); 59 + const [importUrl, setImportUrl] = useState(""); 60 + const [selectedFile, setSelectedFile] = useState<File | null>(null); 61 + const [includeCompleted, setIncludeCompleted] = useState(true); 62 + const [dateRangeOption, setDateRangeOption] = useState("all"); 63 + const [isLoading, setIsLoading] = useState(false); 64 + const [jsonPassword, setJsonPassword] = useState(""); 65 + const [jsonPasswordConfirm, setJsonPasswordConfirm] = useState(""); 66 + const [jsonImportEncrypted, setJsonImportEncrypted] = useState(false); 67 + const [jsonImportPassword, setJsonImportPassword] = useState(""); 68 + const [debugMode, setDebugMode] = useState(false); 69 + const [debugInfo, setDebugInfo] = useState<string>(""); 70 + const [language] = useLanguage(); 71 + const t = translations[language]; 44 72 45 - // ็›‘ๅฌ่ฏญ่จ€ๅ˜ๅŒ–ไบ‹ไปถ 73 + const [forceUpdate, setForceUpdate] = useState(0); 74 + 46 75 useEffect(() => { 47 76 const handleLanguageChange = () => { 48 - // ๅผบๅˆถ็ป„ไปถ้‡ๆ–ฐๆธฒๆŸ“ 49 - setForceUpdate((prev) => prev + 1) 50 - } 77 + setForceUpdate((prev) => prev + 1); 78 + }; 51 79 52 - window.addEventListener("languagechange", handleLanguageChange) 80 + window.addEventListener("languagechange", handleLanguageChange); 53 81 return () => { 54 - window.removeEventListener("languagechange", handleLanguageChange) 55 - } 56 - }, []) 82 + window.removeEventListener("languagechange", handleLanguageChange); 83 + }; 84 + }, []); 57 85 58 86 const handleExport = async () => { 59 87 try { 60 - setIsLoading(true) 88 + setIsLoading(true); 61 89 62 - // Filter events based on options 63 - let filteredEvents = [...events] 90 + let filteredEvents = [...events]; 64 91 65 92 if (dateRangeOption === "future") { 66 - const now = new Date() 67 - filteredEvents = filteredEvents.filter((event) => new Date(event.startDate) >= now) 93 + const now = new Date(); 94 + filteredEvents = filteredEvents.filter( 95 + (event) => new Date(event.startDate) >= now, 96 + ); 68 97 } else if (dateRangeOption === "past") { 69 - const now = new Date() 70 - filteredEvents = filteredEvents.filter((event) => new Date(event.startDate) < now) 98 + const now = new Date(); 99 + filteredEvents = filteredEvents.filter( 100 + (event) => new Date(event.startDate) < now, 101 + ); 71 102 } else if (dateRangeOption === "30days") { 72 - const now = new Date() 73 - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) 103 + const now = new Date(); 104 + const thirtyDaysAgo = new Date( 105 + now.getTime() - 30 * 24 * 60 * 60 * 1000, 106 + ); 74 107 filteredEvents = filteredEvents.filter( 75 - (event) => new Date(event.startDate) >= thirtyDaysAgo && new Date(event.startDate) <= now, 76 - ) 108 + (event) => 109 + new Date(event.startDate) >= thirtyDaysAgo && 110 + new Date(event.startDate) <= now, 111 + ); 77 112 } else if (dateRangeOption === "90days") { 78 - const now = new Date() 79 - const ninetyDaysAgo = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000) 113 + const now = new Date(); 114 + const ninetyDaysAgo = new Date( 115 + now.getTime() - 90 * 24 * 60 * 60 * 1000, 116 + ); 80 117 filteredEvents = filteredEvents.filter( 81 - (event) => new Date(event.startDate) >= ninetyDaysAgo && new Date(event.startDate) <= now, 82 - ) 118 + (event) => 119 + new Date(event.startDate) >= ninetyDaysAgo && 120 + new Date(event.startDate) <= now, 121 + ); 83 122 } 84 123 85 - // Convert events to appropriate format 86 124 if (exportFormat === "ics") { 87 - // Create iCalendar format 88 - const icsContent = generateICSFile(filteredEvents) 89 - downloadFile(icsContent, "calendar-export.ics", "text/calendar") 125 + const icsContent = generateICSFile(filteredEvents); 126 + downloadFile(icsContent, "calendar-export.ics", "text/calendar"); 90 127 } else if (exportFormat === "json") { 91 - // Export as encrypted JSON payload 92 - let jsonContent = JSON.stringify(filteredEvents, null, 2) 128 + let jsonContent = JSON.stringify(filteredEvents, null, 2); 93 129 94 - const hasAnyPasswordInput = jsonPassword.trim() || jsonPasswordConfirm.trim() 130 + const hasAnyPasswordInput = 131 + jsonPassword.trim() || jsonPasswordConfirm.trim(); 95 132 if (hasAnyPasswordInput) { 96 133 if (!jsonPassword.trim()) { 97 - throw new Error(t.passwordRequired || "Password is required") 134 + throw new Error(t.passwordRequired || "Password is required"); 98 135 } 99 136 100 137 if (jsonPassword !== jsonPasswordConfirm) { 101 - throw new Error(t.passwordsDoNotMatch || "Passwords do not match") 138 + throw new Error(t.passwordsDoNotMatch || "Passwords do not match"); 102 139 } 103 140 104 - const encrypted = await encryptPayload(jsonPassword, jsonContent) 105 - jsonContent = JSON.stringify({ ...encrypted, encrypted: true, format: "one-calendar-json-v1" }, null, 2) 141 + const encrypted = await encryptPayload(jsonPassword, jsonContent); 142 + jsonContent = JSON.stringify( 143 + { ...encrypted, encrypted: true, format: "one-calendar-json-v1" }, 144 + null, 145 + 2, 146 + ); 106 147 } 107 148 108 - downloadFile(jsonContent, "calendar-export.json", "application/json") 149 + downloadFile(jsonContent, "calendar-export.json", "application/json"); 109 150 } else if (exportFormat === "csv") { 110 - // Export as CSV 111 - const csvContent = generateCSV(filteredEvents) 112 - downloadFile(csvContent, "calendar-export.csv", "text/csv") 151 + const csvContent = generateCSV(filteredEvents); 152 + downloadFile(csvContent, "calendar-export.csv", "text/csv"); 113 153 } 114 154 115 - toast(t.exportSuccess.replace("{count}", filteredEvents.length.toString()), { 116 - description: `${filteredEvents.length} ${t.events || "events"}`, 117 - }) 155 + toast( 156 + t.exportSuccess.replace("{count}", filteredEvents.length.toString()), 157 + { 158 + description: `${filteredEvents.length} ${t.events || "events"}`, 159 + }, 160 + ); 118 161 119 - setExportDialogOpen(false) 162 + setExportDialogOpen(false); 120 163 } catch (error) { 121 164 toast(t.exportError, { 122 165 description: t.exportError, 123 166 variant: "destructive", 124 - }) 125 - console.error("Export error:", error) 167 + }); 168 + console.error("Export error:", error); 126 169 } finally { 127 - setIsLoading(false) 170 + setIsLoading(false); 128 171 } 129 - } 172 + }; 130 173 131 174 const handleImport = async () => { 132 175 try { 133 - setIsLoading(true) 134 - setDebugInfo("") 135 - let importedEvents: CalendarEvent[] = [] 136 - let rawContent = "" 176 + setIsLoading(true); 177 + setDebugInfo(""); 178 + let importedEvents: CalendarEvent[] = []; 179 + let rawContent = ""; 137 180 138 181 if (importTab === "file" && selectedFile) { 139 - // Parse file content based on extension 140 - const fileExt = selectedFile.name.split(".").pop()?.toLowerCase() 141 - rawContent = await selectedFile.text() 182 + const fileExt = selectedFile.name.split(".").pop()?.toLowerCase(); 183 + rawContent = await selectedFile.text(); 142 184 143 185 if (fileExt === "ics") { 144 - importedEvents = parseICS(rawContent) 186 + importedEvents = parseICS(rawContent); 145 187 } else if (fileExt === "json") { 146 - importedEvents = await parseJsonEvents(rawContent) 188 + importedEvents = await parseJsonEvents(rawContent); 147 189 } else if (fileExt === "csv") { 148 - importedEvents = parseCSV(rawContent) 190 + importedEvents = parseCSV(rawContent); 149 191 } else { 150 - throw new Error(t.unsupportedFormat || "Unsupported file format") 192 + throw new Error(t.unsupportedFormat || "Unsupported file format"); 151 193 } 152 194 } else if (importTab === "url" && importUrl) { 153 - // Fetch from URL and parse 154 - const response = await fetch(importUrl) 155 - rawContent = await response.text() 195 + const response = await fetch(importUrl); 196 + rawContent = await response.text(); 156 197 157 198 if (importUrl.endsWith(".ics")) { 158 - importedEvents = parseICS(rawContent) 199 + importedEvents = parseICS(rawContent); 159 200 } else if (importUrl.endsWith(".json")) { 160 - importedEvents = await parseJsonEvents(rawContent) 201 + importedEvents = await parseJsonEvents(rawContent); 161 202 } else { 162 - throw new Error(t.unsupportedUrlFormat || "Unsupported URL format") 203 + throw new Error(t.unsupportedUrlFormat || "Unsupported URL format"); 163 204 } 164 205 } 165 206 ··· 167 208 setDebugInfo(`${t.parsedEvents || "Parsed"} ${importedEvents.length} ${t.events || "events"} 168 209 169 210 ${t.rawContentPreview || "Raw content preview"}: 170 - ${rawContent.substring(0, 500)}...`) 211 + ${rawContent.substring(0, 500)}...`); 171 212 } 172 213 173 214 if (importedEvents.length === 0) { 174 215 toast(t.importWarning, { 175 216 description: t.importWarning, 176 217 variant: "destructive", 177 - }) 178 - return 218 + }); 219 + return; 179 220 } 180 221 181 - onImportEvents(importedEvents) 222 + onImportEvents(importedEvents); 182 223 183 - toast(t.importSuccess.replace("{count}", importedEvents.length.toString()), { 184 - description: `${importedEvents.length} ${t.events || "events"}`, 185 - }); 224 + toast( 225 + t.importSuccess.replace("{count}", importedEvents.length.toString()), 226 + { 227 + description: `${importedEvents.length} ${t.events || "events"}`, 228 + }, 229 + ); 186 230 187 231 if (!debugMode) { 188 - setImportDialogOpen(false) 232 + setImportDialogOpen(false); 189 233 } 190 234 if (debugMode && importedEvents.length > 0) { 191 - const firstEvent = importedEvents[0] 235 + const firstEvent = importedEvents[0]; 192 236 setDebugInfo(`${t.parsedEvents || "Parsed"} ${importedEvents.length} ${t.events || "events"} 193 237 194 238 First event details: ··· 199 243 UTC End: ${new Date(firstEvent.endDate).toUTCString()} 200 244 201 245 ${t.rawContentPreview || "Raw content preview"}: 202 - ${rawContent.substring(0, 500)}...`) 246 + ${rawContent.substring(0, 500)}...`); 203 247 } 204 248 } catch (error) { 205 - const errorMessage = error instanceof Error ? error.message : t.unknownError || "Unknown error" 249 + const errorMessage = 250 + error instanceof Error 251 + ? error.message 252 + : t.unknownError || "Unknown error"; 206 253 toast(t.importError.replace("{error}", errorMessage), { 207 254 description: errorMessage, 208 255 variant: "destructive", 209 - }) 210 - console.error("Import error:", error) 256 + }); 257 + console.error("Import error:", error); 211 258 212 259 if (debugMode) { 213 - setDebugInfo(`${t.importError}: ${errorMessage}`) 260 + setDebugInfo(`${t.importError}: ${errorMessage}`); 214 261 } 215 262 } finally { 216 - setIsLoading(false) 263 + setIsLoading(false); 217 264 } 218 - } 265 + }; 219 266 220 - const parseJsonEvents = async (rawContent: string): Promise<CalendarEvent[]> => { 221 - const parsed = JSON.parse(rawContent) 267 + const parseJsonEvents = async ( 268 + rawContent: string, 269 + ): Promise<CalendarEvent[]> => { 270 + const parsed = JSON.parse(rawContent); 222 271 223 272 if (isEncryptedPayload(parsed) || parsed?.encrypted) { 224 273 if (!jsonImportEncrypted) { 225 - throw new Error(t.encryptedJsonNeedToggle || "This JSON file is encrypted. Please enable encrypted import.") 274 + throw new Error( 275 + t.encryptedJsonNeedToggle || 276 + "This JSON file is encrypted. Please enable encrypted import.", 277 + ); 226 278 } 227 279 228 280 if (!jsonImportPassword.trim()) { 229 - throw new Error(t.passwordRequired || "Password is required") 281 + throw new Error(t.passwordRequired || "Password is required"); 230 282 } 231 283 232 284 if (!isEncryptedPayload(parsed)) { 233 - throw new Error(t.invalidEncryptedJson || "Invalid encrypted JSON payload") 285 + throw new Error( 286 + t.invalidEncryptedJson || "Invalid encrypted JSON payload", 287 + ); 234 288 } 235 289 236 - const decrypted = await decryptPayload(jsonImportPassword, parsed.ciphertext, parsed.iv) 237 - const decryptedEvents = JSON.parse(decrypted) 290 + const decrypted = await decryptPayload( 291 + jsonImportPassword, 292 + parsed.ciphertext, 293 + parsed.iv, 294 + ); 295 + const decryptedEvents = JSON.parse(decrypted); 238 296 239 297 if (!Array.isArray(decryptedEvents)) { 240 - throw new Error(t.invalidEncryptedJson || "Invalid encrypted JSON payload") 298 + throw new Error( 299 + t.invalidEncryptedJson || "Invalid encrypted JSON payload", 300 + ); 241 301 } 242 302 243 - return decryptedEvents as CalendarEvent[] 303 + return decryptedEvents as CalendarEvent[]; 244 304 } 245 305 246 306 if (Array.isArray(parsed)) { 247 - return parsed as CalendarEvent[] 307 + return parsed as CalendarEvent[]; 248 308 } 249 309 250 - throw new Error(t.unsupportedFormat || "Unsupported file format") 251 - } 310 + throw new Error(t.unsupportedFormat || "Unsupported file format"); 311 + }; 252 312 253 313 const generateICSFile = (events: CalendarEvent[]): string => { 254 314 let icsContent = `BEGIN:VCALENDAR ··· 256 316 PRODID:-//One Calendar//NONSGML v1.0//EN 257 317 CALSCALE:GREGORIAN 258 318 METHOD:PUBLISH 259 - ` 319 + `; 260 320 261 - // ไฟฎๆ”นๆ ผๅผๅŒ–ๆ—ฅๆœŸ็š„ๅ‡ฝๆ•ฐ๏ผŒ็กฎไฟไฝฟ็”จUTCๆ—ถ้—ด 262 321 const formatDate = (date: Date) => { 263 - // ่ฝฌๆขไธบUTCๆ—ถ้—ด 264 - const utcYear = date.getUTCFullYear() 265 - const utcMonth = String(date.getUTCMonth() + 1).padStart(2, "0") 266 - const utcDay = String(date.getUTCDate()).padStart(2, "0") 267 - const utcHours = String(date.getUTCHours()).padStart(2, "0") 268 - const utcMinutes = String(date.getUTCMinutes()).padStart(2, "0") 269 - const utcSeconds = String(date.getUTCSeconds()).padStart(2, "0") 322 + const utcYear = date.getUTCFullYear(); 323 + const utcMonth = String(date.getUTCMonth() + 1).padStart(2, "0"); 324 + const utcDay = String(date.getUTCDate()).padStart(2, "0"); 325 + const utcHours = String(date.getUTCHours()).padStart(2, "0"); 326 + const utcMinutes = String(date.getUTCMinutes()).padStart(2, "0"); 327 + const utcSeconds = String(date.getUTCSeconds()).padStart(2, "0"); 270 328 271 - // ่ฟ”ๅ›žUTCๆ ผๅผ็š„ๆ—ฅๆœŸๅญ—็ฌฆไธฒ 272 - return `${utcYear}${utcMonth}${utcDay}T${utcHours}${utcMinutes}${utcSeconds}Z` 273 - } 329 + return `${utcYear}${utcMonth}${utcDay}T${utcHours}${utcMinutes}${utcSeconds}Z`; 330 + }; 274 331 275 332 events.forEach((event) => { 276 - const startDate = new Date(event.startDate) 277 - const endDate = new Date(event.endDate) 278 - 279 - // Format dates for ICS format 333 + const startDate = new Date(event.startDate); 334 + const endDate = new Date(event.endDate); 280 335 281 336 icsContent += `BEGIN:VEVENT 282 337 UID:${event.id} ··· 287 342 ${event.description ? `DESCRIPTION:${event.description.replace(/\n/g, "\\n")}` : ""} 288 343 ${event.location ? `LOCATION:${event.location}` : ""} 289 344 END:VEVENT 290 - ` 291 - }) 345 + `; 346 + }); 292 347 293 - icsContent += "END:VCALENDAR" 294 - return icsContent 295 - } 348 + icsContent += "END:VCALENDAR"; 349 + return icsContent; 350 + }; 296 351 297 352 const generateCSV = (events: CalendarEvent[]): string => { 298 - const headers = ["Title", "Start Date", "End Date", "Location", "Description", "Color"] 353 + const headers = [ 354 + "Title", 355 + "Start Date", 356 + "End Date", 357 + "Location", 358 + "Description", 359 + "Color", 360 + ]; 299 361 300 362 const rows = events.map((event) => [ 301 363 event.title, ··· 304 366 event.location || "", 305 367 event.description || "", 306 368 event.color, 307 - ]) 369 + ]); 308 370 309 371 const csvContent = [ 310 372 headers.join(","), 311 - ...rows.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(",")), 312 - ].join("\n") 373 + ...rows.map((row) => 374 + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), 375 + ), 376 + ].join("\n"); 313 377 314 - return csvContent 315 - } 378 + return csvContent; 379 + }; 316 380 317 381 const parseICS = (icsContent: string): CalendarEvent[] => { 318 - const events: CalendarEvent[] = [] 319 - const lines = icsContent.split(/\r\n|\n|\r/) 382 + const events: CalendarEvent[] = []; 383 + const lines = icsContent.split(/\r\n|\n|\r/); 320 384 321 - let currentEvent: Partial<CalendarEvent> = {} 322 - let inEvent = false 323 - const continuationLine = "" 385 + let currentEvent: Partial<CalendarEvent> = {}; 386 + let inEvent = false; 387 + const continuationLine = ""; 324 388 325 - // ้ข„ๅค„็†๏ผšๅค„็†ๆŠ˜ๅ ่กŒ๏ผˆ้•ฟ่กŒ่ขซๅˆ†ๆˆๅคš่กŒ๏ผŒๅŽ็ปญ่กŒไปฅ็ฉบๆ ผๆˆ–ๅˆถ่กจ็ฌฆๅผ€ๅคด๏ผ‰ 326 - const processedLines: string[] = [] 389 + const processedLines: string[] = []; 327 390 for (let i = 0; i < lines.length; i++) { 328 - const line = lines[i] 391 + const line = lines[i]; 329 392 if (line.startsWith(" ") || line.startsWith("\t")) { 330 - // ่ฟ™ๆ˜ฏไธ€ไธชๆŠ˜ๅ ่กŒ๏ผŒ้™„ๅŠ ๅˆฐๅ‰ไธ€่กŒ 331 393 if (processedLines.length > 0) { 332 - processedLines[processedLines.length - 1] += line.substring(1) 394 + processedLines[processedLines.length - 1] += line.substring(1); 333 395 } 334 396 } else { 335 - processedLines.push(line) 397 + processedLines.push(line); 336 398 } 337 399 } 338 400 339 401 for (const line of processedLines) { 340 402 if (line.startsWith("BEGIN:VEVENT")) { 341 403 currentEvent = { 342 - id: Date.now().toString() + Math.random().toString(36).substring(2, 9), 404 + id: 405 + Date.now().toString() + Math.random().toString(36).substring(2, 9), 343 406 title: t.unnamedEvent || "Unnamed Event", 344 407 isAllDay: false, 345 408 recurrence: "none", ··· 347 410 notification: 0, 348 411 color: "bg-blue-500", 349 412 calendarId: "1", 350 - } 351 - inEvent = true 413 + }; 414 + inEvent = true; 352 415 } else if (line.startsWith("END:VEVENT")) { 353 416 if (inEvent && currentEvent.title && currentEvent.startDate) { 354 - // ็กฎไฟ็ป“ๆŸๆ—ถ้—ดไธๆ—ฉไบŽๅผ€ๅง‹ๆ—ถ้—ด 355 - if (!currentEvent.endDate || new Date(currentEvent.endDate) < new Date(currentEvent.startDate)) { 356 - currentEvent.endDate = new Date(new Date(currentEvent.startDate).getTime() + 60 * 60 * 1000) // ้ป˜่ฎค1ๅฐๆ—ถ 417 + if ( 418 + !currentEvent.endDate || 419 + new Date(currentEvent.endDate) < new Date(currentEvent.startDate) 420 + ) { 421 + currentEvent.endDate = new Date( 422 + new Date(currentEvent.startDate).getTime() + 60 * 60 * 1000, 423 + ); 357 424 } 358 425 359 - events.push(currentEvent as CalendarEvent) 426 + events.push(currentEvent as CalendarEvent); 360 427 } 361 - currentEvent = {} 362 - inEvent = false 428 + currentEvent = {}; 429 + inEvent = false; 363 430 } else if (inEvent) { 364 - // ่งฃๆžไบ‹ไปถๅฑžๆ€ง 365 - const colonIndex = line.indexOf(":") 431 + const colonIndex = line.indexOf(":"); 366 432 if (colonIndex > 0) { 367 - const key = line.substring(0, colonIndex) 368 - const value = line.substring(colonIndex + 1) 433 + const key = line.substring(0, colonIndex); 434 + const value = line.substring(colonIndex + 1); 369 435 370 - // ๅค„็†ๅธฆๅ‚ๆ•ฐ็š„ๅฑžๆ€ง๏ผˆๅฆ‚ DTSTART;TZID=America/New_York:20230101T120000๏ผ‰ 371 - const [mainKey, ...params] = key.split(";") 436 + const [mainKey, ...params] = key.split(";"); 372 437 373 438 switch (mainKey) { 374 439 case "SUMMARY": 375 - currentEvent.title = value 376 - break 440 + currentEvent.title = value; 441 + break; 377 442 case "DESCRIPTION": 378 - currentEvent.description = value.replace(/\\n/g, "\n").replace(/\\,/g, ",") 379 - break 443 + currentEvent.description = value 444 + .replace(/\\n/g, "\n") 445 + .replace(/\\,/g, ","); 446 + break; 380 447 case "LOCATION": 381 - currentEvent.location = value 382 - break 448 + currentEvent.location = value; 449 + break; 383 450 case "UID": 384 - currentEvent.id = value 385 - break 451 + currentEvent.id = value; 452 + break; 386 453 case "DTSTART": 387 454 try { 388 - // ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰ๆ—ถๅŒบๅ‚ๆ•ฐ 389 - const hasTimeZone = params.some((p) => p.startsWith("TZID=")) 390 - const isAllDay = !value.includes("T") 455 + const hasTimeZone = params.some((p) => p.startsWith("TZID=")); 456 + const isAllDay = !value.includes("T"); 391 457 392 - currentEvent.startDate = parseICSDate(value, hasTimeZone) 393 - currentEvent.isAllDay = isAllDay 458 + currentEvent.startDate = parseICSDate(value, hasTimeZone); 459 + currentEvent.isAllDay = isAllDay; 394 460 } catch (e) { 395 - console.error("Error parsing DTSTART:", value, e) 461 + console.error("Error parsing DTSTART:", value, e); 396 462 } 397 - break 463 + break; 398 464 case "DTEND": 399 465 try { 400 - const hasTimeZone = params.some((p) => p.startsWith("TZID=")) 401 - currentEvent.endDate = parseICSDate(value, hasTimeZone) 466 + const hasTimeZone = params.some((p) => p.startsWith("TZID=")); 467 + currentEvent.endDate = parseICSDate(value, hasTimeZone); 402 468 } catch (e) { 403 - console.error("Error parsing DTEND:", value, e) 469 + console.error("Error parsing DTEND:", value, e); 404 470 } 405 - break 471 + break; 406 472 case "RRULE": 407 - // ๅค„็†้‡ๅค่ง„ๅˆ™ 408 473 if (value.includes("FREQ=DAILY")) { 409 - currentEvent.recurrence = "daily" 474 + currentEvent.recurrence = "daily"; 410 475 } else if (value.includes("FREQ=WEEKLY")) { 411 - currentEvent.recurrence = "weekly" 476 + currentEvent.recurrence = "weekly"; 412 477 } else if (value.includes("FREQ=MONTHLY")) { 413 - currentEvent.recurrence = "monthly" 478 + currentEvent.recurrence = "monthly"; 414 479 } else if (value.includes("FREQ=YEARLY")) { 415 - currentEvent.recurrence = "yearly" 480 + currentEvent.recurrence = "yearly"; 416 481 } 417 - break 482 + break; 418 483 } 419 484 } 420 485 } 421 486 } 422 487 423 - return events 424 - } 488 + return events; 489 + }; 425 490 426 - // ๆ”น่ฟ›็š„ICSๆ—ฅๆœŸ่งฃๆžๅ‡ฝๆ•ฐ 427 491 const parseICSDate = (dateString: string, hasTimeZone: boolean): Date => { 428 - // ๅค„็†ไธๅŒๆ ผๅผ็š„ๆ—ฅๆœŸ 429 - // 1. ๅŸบๆœฌๆ ผๅผ๏ผš20230101T120000Z (UTCๆ—ถ้—ด) 430 - // 2. ๆฒกๆœ‰T็š„ๆ ผๅผ๏ผˆๅ…จๅคฉไบ‹ไปถ๏ผ‰๏ผš20230101 431 - // 3. ๅธฆๆ—ถๅŒบ็š„ๆ ผๅผ๏ผš20230101T120000 ๆˆ– 20230101T120000+0800 432 - 433 492 let year, 434 493 month, 435 494 day, 436 495 hour = 0, 437 496 minute = 0, 438 - second = 0 497 + second = 0; 439 498 440 - // ๆฃ€ๆŸฅๆ˜ฏๅฆๆœ‰ๆ—ถๅŒบๅ็งปไฟกๆฏ 441 - const hasOffset = dateString.includes("+") || (dateString.includes("-") && dateString.indexOf("-") > 8) 442 - const isUTC = dateString.endsWith("Z") 499 + const hasOffset = 500 + dateString.includes("+") || 501 + (dateString.includes("-") && dateString.indexOf("-") > 8); 502 + const isUTC = dateString.endsWith("Z"); 443 503 444 504 if (dateString.includes("T")) { 445 - // ๆœ‰ๆ—ถ้—ด้ƒจๅˆ† 446 - let datePart, timePart 505 + let datePart, timePart; 447 506 448 507 if (hasOffset) { 449 - // ๅค„็†ๅธฆๆœ‰ๆ˜Ž็กฎๆ—ถๅŒบๅ็งป็š„ๆ ผๅผ (ๅฆ‚ 20230101T120000+0800 ๆˆ– 20230101T120000-0500) 450 - const offsetIndex = Math.max(dateString.lastIndexOf("+"), dateString.lastIndexOf("-")) 451 - datePart = dateString.substring(0, dateString.indexOf("T")) 452 - timePart = dateString.substring(dateString.indexOf("T") + 1, offsetIndex) 508 + const offsetIndex = Math.max( 509 + dateString.lastIndexOf("+"), 510 + dateString.lastIndexOf("-"), 511 + ); 512 + datePart = dateString.substring(0, dateString.indexOf("T")); 513 + timePart = dateString.substring( 514 + dateString.indexOf("T") + 1, 515 + offsetIndex, 516 + ); 453 517 454 - const offsetPart = dateString.substring(offsetIndex) 455 - const isoDateString = `${datePart.substring(0, 4)}-${datePart.substring(4, 6)}-${datePart.substring(6, 8)}T${timePart.substring(0, 2)}:${timePart.substring(2, 4)}:${timePart.substring(4, 6)}${offsetPart.substring(0, 3)}:${offsetPart.substring(3, 5)}` 456 - const parsedDate = new Date(isoDateString) 518 + const offsetPart = dateString.substring(offsetIndex); 519 + const isoDateString = `${datePart.substring(0, 4)}-${datePart.substring(4, 6)}-${datePart.substring(6, 8)}T${timePart.substring(0, 2)}:${timePart.substring(2, 4)}:${timePart.substring(4, 6)}${offsetPart.substring(0, 3)}:${offsetPart.substring(3, 5)}`; 520 + const parsedDate = new Date(isoDateString); 457 521 458 522 if (!Number.isNaN(parsedDate.getTime())) { 459 - return parsedDate 523 + return parsedDate; 460 524 } 461 525 462 - // ๅ…œๅบ•๏ผšๆŒ‰ๅ็งปๆ‰‹ๅŠจ่ฝฌๆขๅˆฐUTCๆ—ถ้—ด๏ผŒ้ฟๅ…่ดŸๆ—ถๅŒบๅ็งปๆ—ฅๆœŸ้”™่ฏฏ 463 - year = Number.parseInt(datePart.substring(0, 4), 10) 464 - month = Number.parseInt(datePart.substring(4, 6), 10) - 1 465 - day = Number.parseInt(datePart.substring(6, 8), 10) 466 - hour = Number.parseInt(timePart.substring(0, 2), 10) 467 - minute = Number.parseInt(timePart.substring(2, 4), 10) 468 - second = Number.parseInt(timePart.substring(4, 6), 10) 526 + year = Number.parseInt(datePart.substring(0, 4), 10); 527 + month = Number.parseInt(datePart.substring(4, 6), 10) - 1; 528 + day = Number.parseInt(datePart.substring(6, 8), 10); 529 + hour = Number.parseInt(timePart.substring(0, 2), 10); 530 + minute = Number.parseInt(timePart.substring(2, 4), 10); 531 + second = Number.parseInt(timePart.substring(4, 6), 10); 469 532 470 - const offsetSign = offsetPart.charAt(0) === "+" ? 1 : -1 471 - const offsetHours = Number.parseInt(offsetPart.substring(1, 3), 10) 472 - const offsetMinutes = Number.parseInt(offsetPart.substring(3, 5), 10) 473 - const totalOffsetMinutes = offsetSign * (offsetHours * 60 + offsetMinutes) 474 - const utcTime = Date.UTC(year, month, day, hour, minute, second) - totalOffsetMinutes * 60 * 1000 533 + const offsetSign = offsetPart.charAt(0) === "+" ? 1 : -1; 534 + const offsetHours = Number.parseInt(offsetPart.substring(1, 3), 10); 535 + const offsetMinutes = Number.parseInt(offsetPart.substring(3, 5), 10); 536 + const totalOffsetMinutes = 537 + offsetSign * (offsetHours * 60 + offsetMinutes); 538 + const utcTime = 539 + Date.UTC(year, month, day, hour, minute, second) - 540 + totalOffsetMinutes * 60 * 1000; 475 541 476 - return new Date(utcTime) 542 + return new Date(utcTime); 477 543 } else if (isUTC) { 478 - // ๅค„็†UTCๆ—ถ้—ด (Z็ป“ๅฐพ) 479 - datePart = dateString.split("T")[0] 480 - timePart = dateString.split("T")[1].replace("Z", "") 544 + datePart = dateString.split("T")[0]; 545 + timePart = dateString.split("T")[1].replace("Z", ""); 481 546 482 - year = Number.parseInt(datePart.substring(0, 4), 10) 483 - month = Number.parseInt(datePart.substring(4, 6), 10) - 1 484 - day = Number.parseInt(datePart.substring(6, 8), 10) 547 + year = Number.parseInt(datePart.substring(0, 4), 10); 548 + month = Number.parseInt(datePart.substring(4, 6), 10) - 1; 549 + day = Number.parseInt(datePart.substring(6, 8), 10); 485 550 486 551 if (timePart.length >= 6) { 487 - hour = Number.parseInt(timePart.substring(0, 2), 10) 488 - minute = Number.parseInt(timePart.substring(2, 4), 10) 489 - second = Number.parseInt(timePart.substring(4, 6), 10) 552 + hour = Number.parseInt(timePart.substring(0, 2), 10); 553 + minute = Number.parseInt(timePart.substring(2, 4), 10); 554 + second = Number.parseInt(timePart.substring(4, 6), 10); 490 555 } 491 556 492 - // ๅˆ›ๅปบUTCๆ—ฅๆœŸ 493 - return new Date(Date.UTC(year, month, day, hour, minute, second)) 557 + return new Date(Date.UTC(year, month, day, hour, minute, second)); 494 558 } else { 495 - // ๅค„็†ๆฒกๆœ‰ๆ˜Ž็กฎๆ—ถๅŒบ็š„ๆ—ถ้—ด๏ผŒๅ‡ๅฎšไธบๆœฌๅœฐๆ—ถ้—ด 496 - datePart = dateString.split("T")[0] 497 - timePart = dateString.split("T")[1] 559 + datePart = dateString.split("T")[0]; 560 + timePart = dateString.split("T")[1]; 498 561 499 - year = Number.parseInt(datePart.substring(0, 4), 10) 500 - month = Number.parseInt(datePart.substring(4, 6), 10) - 1 501 - day = Number.parseInt(datePart.substring(6, 8), 10) 562 + year = Number.parseInt(datePart.substring(0, 4), 10); 563 + month = Number.parseInt(datePart.substring(4, 6), 10) - 1; 564 + day = Number.parseInt(datePart.substring(6, 8), 10); 502 565 503 566 if (timePart.length >= 6) { 504 - hour = Number.parseInt(timePart.substring(0, 2), 10) 505 - minute = Number.parseInt(timePart.substring(2, 4), 10) 506 - second = Number.parseInt(timePart.substring(4, 6), 10) 567 + hour = Number.parseInt(timePart.substring(0, 2), 10); 568 + minute = Number.parseInt(timePart.substring(2, 4), 10); 569 + second = Number.parseInt(timePart.substring(4, 6), 10); 507 570 } 508 571 509 - // ๅˆ›ๅปบๆœฌๅœฐๆ—ฅๆœŸ 510 - return new Date(year, month, day, hour, minute, second) 572 + return new Date(year, month, day, hour, minute, second); 511 573 } 512 574 } else { 513 - // ๅชๆœ‰ๆ—ฅๆœŸ้ƒจๅˆ†๏ผˆๅ…จๅคฉไบ‹ไปถ๏ผ‰ 514 - year = Number.parseInt(dateString.substring(0, 4), 10) 515 - month = Number.parseInt(dateString.substring(4, 6), 10) - 1 516 - day = Number.parseInt(dateString.substring(6, 8), 10) 575 + year = Number.parseInt(dateString.substring(0, 4), 10); 576 + month = Number.parseInt(dateString.substring(4, 6), 10) - 1; 577 + day = Number.parseInt(dateString.substring(6, 8), 10); 517 578 518 - // ๅˆ›ๅปบๆœฌๅœฐๆ—ฅๆœŸ 519 - return new Date(year, month, day) 579 + return new Date(year, month, day); 520 580 } 521 - } 581 + }; 522 582 523 583 const parseCSV = (csvContent: string): CalendarEvent[] => { 524 - const lines = csvContent.split("\n") 525 - if (lines.length < 2) return [] // ่‡ณๅฐ‘้œ€่ฆๆ ‡้ข˜่กŒๅ’Œไธ€่กŒๆ•ฐๆฎ 584 + const lines = csvContent.split("\n"); 585 + if (lines.length < 2) return []; 526 586 527 - const headers = parseCSVLine(lines[0]) 587 + const headers = parseCSVLine(lines[0]); 528 588 529 - const events: CalendarEvent[] = [] 589 + const events: CalendarEvent[] = []; 530 590 531 591 for (let i = 1; i < lines.length; i++) { 532 - if (!lines[i].trim()) continue 592 + if (!lines[i].trim()) continue; 533 593 534 - const values = parseCSVLine(lines[i]) 594 + const values = parseCSVLine(lines[i]); 535 595 536 596 if (values.length >= 2) { 537 - const titleIndex = headers.findIndex((h) => h.toLowerCase().includes("title")) 538 - const startDateIndex = headers.findIndex((h) => h.toLowerCase().includes("start")) 539 - const endDateIndex = headers.findIndex((h) => h.toLowerCase().includes("end")) 540 - const locationIndex = headers.findIndex((h) => h.toLowerCase().includes("location")) 541 - const descriptionIndex = headers.findIndex((h) => h.toLowerCase().includes("description")) 542 - const colorIndex = headers.findIndex((h) => h.toLowerCase().includes("color")) 597 + const titleIndex = headers.findIndex((h) => 598 + h.toLowerCase().includes("title"), 599 + ); 600 + const startDateIndex = headers.findIndex((h) => 601 + h.toLowerCase().includes("start"), 602 + ); 603 + const endDateIndex = headers.findIndex((h) => 604 + h.toLowerCase().includes("end"), 605 + ); 606 + const locationIndex = headers.findIndex((h) => 607 + h.toLowerCase().includes("location"), 608 + ); 609 + const descriptionIndex = headers.findIndex((h) => 610 + h.toLowerCase().includes("description"), 611 + ); 612 + const colorIndex = headers.findIndex((h) => 613 + h.toLowerCase().includes("color"), 614 + ); 543 615 544 616 const title = 545 - titleIndex >= 0 && titleIndex < values.length ? values[titleIndex] : t.unnamedEvent || "Unnamed Event" 617 + titleIndex >= 0 && titleIndex < values.length 618 + ? values[titleIndex] 619 + : t.unnamedEvent || "Unnamed Event"; 546 620 const startDate = 547 - startDateIndex >= 0 && startDateIndex < values.length ? new Date(values[startDateIndex]) : new Date() 621 + startDateIndex >= 0 && startDateIndex < values.length 622 + ? new Date(values[startDateIndex]) 623 + : new Date(); 548 624 let endDate = 549 625 endDateIndex >= 0 && endDateIndex < values.length 550 626 ? new Date(values[endDateIndex]) 551 - : new Date(startDate.getTime() + 60 * 60 * 1000) 627 + : new Date(startDate.getTime() + 60 * 60 * 1000); 552 628 553 - // ็กฎไฟ็ป“ๆŸๆ—ถ้—ดไธๆ—ฉไบŽๅผ€ๅง‹ๆ—ถ้—ด 554 629 if (endDate < startDate) { 555 - endDate = new Date(startDate.getTime() + 60 * 60 * 1000) 630 + endDate = new Date(startDate.getTime() + 60 * 60 * 1000); 556 631 } 557 632 558 633 events.push({ 559 - id: Date.now().toString() + Math.random().toString(36).substring(2, 9), 634 + id: 635 + Date.now().toString() + Math.random().toString(36).substring(2, 9), 560 636 title, 561 637 startDate, 562 638 endDate, 563 639 isAllDay: false, 564 640 recurrence: "none", 565 - location: locationIndex >= 0 && locationIndex < values.length ? values[locationIndex] : undefined, 641 + location: 642 + locationIndex >= 0 && locationIndex < values.length 643 + ? values[locationIndex] 644 + : undefined, 566 645 participants: [], 567 646 notification: 0, 568 - description: descriptionIndex >= 0 && descriptionIndex < values.length ? values[descriptionIndex] : undefined, 569 - color: colorIndex >= 0 && colorIndex < values.length ? values[colorIndex] : "bg-blue-500", 647 + description: 648 + descriptionIndex >= 0 && descriptionIndex < values.length 649 + ? values[descriptionIndex] 650 + : undefined, 651 + color: 652 + colorIndex >= 0 && colorIndex < values.length 653 + ? values[colorIndex] 654 + : "bg-blue-500", 570 655 calendarId: "1", 571 - }) 656 + }); 572 657 } 573 658 } 574 659 575 - return events 576 - } 660 + return events; 661 + }; 577 662 578 663 const parseCSVLine = (line: string): string[] => { 579 - const result = [] 580 - let insideQuotes = false 581 - let currentValue = "" 664 + const result = []; 665 + let insideQuotes = false; 666 + let currentValue = ""; 582 667 583 668 for (let i = 0; i < line.length; i++) { 584 - const char = line[i] 669 + const char = line[i]; 585 670 586 671 if (char === '"') { 587 672 if (i < line.length - 1 && line[i + 1] === '"') { 588 - // ๅค„็†่ฝฌไน‰็š„ๅผ•ๅท 589 - currentValue += '"' 590 - i++ 673 + currentValue += '"'; 674 + i++; 591 675 } else { 592 - insideQuotes = !insideQuotes 676 + insideQuotes = !insideQuotes; 593 677 } 594 678 } else if (char === "," && !insideQuotes) { 595 - result.push(currentValue.trim()) 596 - currentValue = "" 679 + result.push(currentValue.trim()); 680 + currentValue = ""; 597 681 } else { 598 - currentValue += char 682 + currentValue += char; 599 683 } 600 684 } 601 685 602 - result.push(currentValue.trim()) 603 - return result 604 - } 686 + result.push(currentValue.trim()); 687 + return result; 688 + }; 605 689 606 - const downloadFile = (content: string, filename: string, mimeType: string) => { 607 - const blob = new Blob([content], { type: mimeType }) 608 - const url = URL.createObjectURL(blob) 609 - const link = document.createElement("a") 610 - link.href = url 611 - link.setAttribute("download", filename) 612 - document.body.appendChild(link) 613 - link.click() 614 - document.body.removeChild(link) 615 - URL.revokeObjectURL(url) 616 - } 690 + const downloadFile = ( 691 + content: string, 692 + filename: string, 693 + mimeType: string, 694 + ) => { 695 + const blob = new Blob([content], { type: mimeType }); 696 + const url = URL.createObjectURL(blob); 697 + const link = document.createElement("a"); 698 + link.href = url; 699 + link.setAttribute("download", filename); 700 + document.body.appendChild(link); 701 + link.click(); 702 + document.body.removeChild(link); 703 + URL.revokeObjectURL(url); 704 + }; 617 705 618 706 return ( 619 707 <Card className="w-full"> ··· 621 709 <div className="flex justify-between items-center"> 622 710 <div> 623 711 <CardTitle>{t.importExport}</CardTitle> 624 - <CardDescription>{t.importExportDesc || "Exchange data with other calendar applications"}</CardDescription> 712 + <CardDescription> 713 + {t.importExportDesc || 714 + "Exchange data with other calendar applications"} 715 + </CardDescription> 625 716 </div> 626 717 <div className="flex space-x-2"> 627 718 <Button variant="outline" onClick={() => setImportDialogOpen(true)}> ··· 656 747 </div> 657 748 </CardContent> 658 749 659 - {/* Import Dialog */} 750 + {} 660 751 <Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}> 661 752 <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> 662 753 <DialogHeader> 663 754 <DialogTitle>{t.importCalendar}</DialogTitle> 664 755 </DialogHeader> 665 756 666 - <Tabs defaultValue="file" value={importTab} onValueChange={setImportTab}> 757 + <Tabs 758 + defaultValue="file" 759 + value={importTab} 760 + onValueChange={setImportTab} 761 + > 667 762 <TabsList className="grid grid-cols-2"> 668 763 <TabsTrigger value="file">{t.fileImport}</TabsTrigger> 669 764 <TabsTrigger value="url">{t.urlImport}</TabsTrigger> ··· 678 773 accept=".ics,.json,.csv" 679 774 onChange={(e) => setSelectedFile(e.target.files?.[0] || null)} 680 775 /> 681 - <p className="text-xs text-muted-foreground">{t.supportedFormats}</p> 776 + <p className="text-xs text-muted-foreground"> 777 + {t.supportedFormats} 778 + </p> 682 779 </div> 683 780 684 781 <Alert variant="outline"> ··· 690 787 <Checkbox 691 788 id="debug-mode" 692 789 checked={debugMode} 693 - onCheckedChange={(checked) => setDebugMode(checked as boolean)} 790 + onCheckedChange={(checked) => 791 + setDebugMode(checked as boolean) 792 + } 694 793 /> 695 794 <Label htmlFor="debug-mode">{t.debugMode}</Label> 696 795 </div> ··· 699 798 <Checkbox 700 799 id="json-import-encrypted" 701 800 checked={jsonImportEncrypted} 702 - onCheckedChange={(checked) => setJsonImportEncrypted(checked as boolean)} 801 + onCheckedChange={(checked) => 802 + setJsonImportEncrypted(checked as boolean) 803 + } 703 804 /> 704 - <Label htmlFor="json-import-encrypted">{t.thisJsonEncrypted || "This JSON file is password-encrypted"}</Label> 805 + <Label htmlFor="json-import-encrypted"> 806 + {t.thisJsonEncrypted || 807 + "This JSON file is password-encrypted"} 808 + </Label> 705 809 </div> 706 810 707 811 {jsonImportEncrypted && ( 708 812 <div className="space-y-2"> 709 - <Label htmlFor="json-import-password">{t.password || "Password"}</Label> 813 + <Label htmlFor="json-import-password"> 814 + {t.password || "Password"} 815 + </Label> 710 816 <Input 711 817 id="json-import-password" 712 818 type="password" ··· 735 841 <Checkbox 736 842 id="debug-mode-url" 737 843 checked={debugMode} 738 - onCheckedChange={(checked) => setDebugMode(checked as boolean)} 844 + onCheckedChange={(checked) => 845 + setDebugMode(checked as boolean) 846 + } 739 847 /> 740 848 <Label htmlFor="debug-mode-url">{t.debugMode}</Label> 741 849 </div> ··· 745 853 {debugInfo && ( 746 854 <div className="mt-4 rounded-md bg-muted p-2"> 747 855 <h4 className="font-medium mb-1">{t.debugInfo}</h4> 748 - <pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap">{debugInfo}</pre> 856 + <pre className="text-xs overflow-auto max-h-40 whitespace-pre-wrap"> 857 + {debugInfo} 858 + </pre> 749 859 </div> 750 860 )} 751 861 752 862 <div className="flex justify-end space-x-2 pt-4"> 753 - <Button variant="outline" onClick={() => setImportDialogOpen(false)}> 863 + <Button 864 + variant="outline" 865 + onClick={() => setImportDialogOpen(false)} 866 + > 754 867 {t.cancel} 755 868 </Button> 756 869 <Button onClick={handleImport} disabled={isLoading}> ··· 760 873 </DialogContent> 761 874 </Dialog> 762 875 763 - {/* Export Dialog */} 876 + {} 764 877 <Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}> 765 878 <DialogContent className="max-w-md max-h-[90vh] overflow-y-auto"> 766 879 <DialogHeader> ··· 784 897 785 898 <div className="space-y-2"> 786 899 <Label htmlFor="date-range">{t.dateRange}</Label> 787 - <Select value={dateRangeOption} onValueChange={setDateRangeOption}> 900 + <Select 901 + value={dateRangeOption} 902 + onValueChange={setDateRangeOption} 903 + > 788 904 <SelectTrigger id="date-range"> 789 905 <SelectValue /> 790 906 </SelectTrigger> ··· 801 917 {exportFormat === "json" && ( 802 918 <div className="space-y-3 rounded-md border p-3"> 803 919 <div className="space-y-2"> 804 - <Label htmlFor="json-password">{t.passwordOptionalForEncryption}</Label> 920 + <Label htmlFor="json-password"> 921 + {t.passwordOptionalForEncryption} 922 + </Label> 805 923 <Input 806 924 id="json-password" 807 925 type="password" ··· 811 929 /> 812 930 </div> 813 931 <div className="space-y-2"> 814 - <Label htmlFor="json-password-confirm">{t.confirmYourPassword || "Confirm your password"}</Label> 932 + <Label htmlFor="json-password-confirm"> 933 + {t.confirmYourPassword || "Confirm your password"} 934 + </Label> 815 935 <Input 816 936 id="json-password-confirm" 817 937 type="password" 818 938 value={jsonPasswordConfirm} 819 939 onChange={(e) => setJsonPasswordConfirm(e.target.value)} 820 - placeholder={t.confirmYourPassword || "Confirm your password"} 940 + placeholder={ 941 + t.confirmYourPassword || "Confirm your password" 942 + } 821 943 /> 822 944 </div> 823 945 </div> ··· 827 949 <Checkbox 828 950 id="include-completed" 829 951 checked={includeCompleted} 830 - onCheckedChange={(checked) => setIncludeCompleted(checked as boolean)} 952 + onCheckedChange={(checked) => 953 + setIncludeCompleted(checked as boolean) 954 + } 831 955 /> 832 956 <Label htmlFor="include-completed">{t.includeCompleted}</Label> 833 957 </div> 834 958 </div> 835 959 836 960 <div className="flex justify-end space-x-2"> 837 - <Button variant="outline" onClick={() => setExportDialogOpen(false)}> 961 + <Button 962 + variant="outline" 963 + onClick={() => setExportDialogOpen(false)} 964 + > 838 965 {t.cancel} 839 966 </Button> 840 967 <Button onClick={handleExport} disabled={isLoading}> ··· 844 971 </DialogContent> 845 972 </Dialog> 846 973 </Card> 847 - ) 974 + ); 848 975 }
+155 -80
components/app/analytics/time-analytics.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { PieChart, Pie, Cell, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, Legend } from "recharts" 4 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 5 - import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" 6 - import { analyzeTimeUsage, type TimeAnalytics } from "@/lib/time-analytics" 7 - import type { CalendarCategory } from "../sidebar/sidebar" 8 - import { useLocalStorage } from "@/hooks/useLocalStorage" 9 - import { translations, useLanguage } from "@/lib/i18n" 10 - import type { CalendarEvent } from "../calendar" 11 - import { useState, useEffect } from "react" 3 + import { 4 + PieChart, 5 + Pie, 6 + Cell, 7 + ResponsiveContainer, 8 + BarChart, 9 + Bar, 10 + XAxis, 11 + YAxis, 12 + Tooltip, 13 + Legend, 14 + } from "recharts"; 15 + import { 16 + Select, 17 + SelectContent, 18 + SelectItem, 19 + SelectTrigger, 20 + SelectValue, 21 + } from "@/components/ui/select"; 22 + import { 23 + Card, 24 + CardContent, 25 + CardDescription, 26 + CardHeader, 27 + CardTitle, 28 + } from "@/components/ui/card"; 29 + import { analyzeTimeUsage, type TimeAnalytics } from "@/lib/time-analytics"; 30 + import type { CalendarCategory } from "../sidebar/sidebar"; 31 + import { useLocalStorage } from "@/hooks/useLocalStorage"; 32 + import { translations, useLanguage } from "@/lib/i18n"; 33 + import type { CalendarEvent } from "../calendar"; 34 + import { useState, useEffect } from "react"; 12 35 13 36 interface TimeAnalyticsProps { 14 - events: CalendarEvent[] 15 - calendars?: CalendarCategory[] 37 + events: CalendarEvent[]; 38 + calendars?: CalendarCategory[]; 16 39 } 17 40 18 - export default function TimeAnalyticsComponent({ events, calendars = [] }: TimeAnalyticsProps) { 19 - const [timeCategories, setTimeCategories] = useLocalStorage<CalendarCategory[]>("calendar-categories", calendars) 20 - const [analytics, setAnalytics] = useState<TimeAnalytics | null>(null) 41 + export default function TimeAnalyticsComponent({ 42 + events, 43 + calendars = [], 44 + }: TimeAnalyticsProps) { 45 + const [timeCategories, setTimeCategories] = useLocalStorage< 46 + CalendarCategory[] 47 + >("calendar-categories", calendars); 48 + const [analytics, setAnalytics] = useState<TimeAnalytics | null>(null); 21 49 const [newCategory, setNewCategory] = useState<Partial<CalendarCategory>>({ 22 50 name: "", 23 51 color: "bg-gray-500", 24 52 keywords: [], 25 - }) 26 - const [timeRange, setTimeRange] = useState<"week" | "month" | "year">("month") 27 - const [language] = useLanguage() 28 - const t = translations[language] 29 - // ๆทปๅŠ ไธ€ไธช็Šถๆ€ๆฅๅผบๅˆถ็ป„ไปถ้‡ๆ–ฐๆธฒๆŸ“ 30 - const [forceUpdate, setForceUpdate] = useState(0) 53 + }); 54 + const [timeRange, setTimeRange] = useState<"week" | "month" | "year">( 55 + "month", 56 + ); 57 + const [language] = useLanguage(); 58 + const t = translations[language]; 59 + 60 + const [forceUpdate, setForceUpdate] = useState(0); 31 61 32 - // ็›‘ๅฌ่ฏญ่จ€ๅ˜ๅŒ–ไบ‹ไปถ 33 62 useEffect(() => { 34 63 const handleLanguageChange = () => { 35 - // ๅผบๅˆถ็ป„ไปถ้‡ๆ–ฐๆธฒๆŸ“ 36 - setForceUpdate((prev) => prev + 1) 37 - } 64 + setForceUpdate((prev) => prev + 1); 65 + }; 38 66 39 - window.addEventListener("languagechange", handleLanguageChange) 67 + window.addEventListener("languagechange", handleLanguageChange); 40 68 return () => { 41 - window.removeEventListener("languagechange", handleLanguageChange) 42 - } 43 - }, []) 69 + window.removeEventListener("languagechange", handleLanguageChange); 70 + }; 71 + }, []); 44 72 45 73 useEffect(() => { 46 - // ๆ นๆฎ้€‰ๆ‹ฉ็š„ๆ—ถ้—ด่Œƒๅ›ด่ฟ‡ๆปคไบ‹ไปถ 47 - const now = new Date() 74 + const now = new Date(); 48 75 const filteredEvents = events.filter((event) => { 49 - const eventDate = new Date(event.startDate) 76 + const eventDate = new Date(event.startDate); 50 77 if (timeRange === "week") { 51 - const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) 52 - return eventDate >= oneWeekAgo 78 + const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); 79 + return eventDate >= oneWeekAgo; 53 80 } else if (timeRange === "month") { 54 - const oneMonthAgo = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()) 55 - return eventDate >= oneMonthAgo 81 + const oneMonthAgo = new Date( 82 + now.getFullYear(), 83 + now.getMonth() - 1, 84 + now.getDate(), 85 + ); 86 + return eventDate >= oneMonthAgo; 56 87 } else if (timeRange === "year") { 57 - const oneYearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate()) 58 - return eventDate >= oneYearAgo 88 + const oneYearAgo = new Date( 89 + now.getFullYear() - 1, 90 + now.getMonth(), 91 + now.getDate(), 92 + ); 93 + return eventDate >= oneYearAgo; 59 94 } 60 - return true 61 - }) 95 + return true; 96 + }); 62 97 63 - const result = analyzeTimeUsage(filteredEvents, timeCategories) 64 - setAnalytics(result) 65 - }, [events, timeCategories, timeRange, language, forceUpdate]) // ๆทปๅŠ languageไพ่ต–๏ผŒ็กฎไฟ่ฏญ่จ€ๅ˜ๅŒ–ๆ—ถ้‡ๆ–ฐๆธฒๆŸ“ 98 + const result = analyzeTimeUsage(filteredEvents, timeCategories); 99 + setAnalytics(result); 100 + }, [events, timeCategories, timeRange, language, forceUpdate]); 66 101 67 102 const handleAddCategory = () => { 68 103 if (newCategory.name) { ··· 70 105 id: Date.now().toString(), 71 106 name: newCategory.name, 72 107 color: newCategory.color || "bg-gray-500", 73 - keywords: [], // ้ป˜่ฎคไธบ็ฉบๆ•ฐ็ป„ 74 - } 75 - setTimeCategories([...timeCategories, newCat]) 108 + keywords: [], 109 + }; 110 + setTimeCategories([...timeCategories, newCat]); 76 111 setNewCategory({ 77 112 name: "", 78 113 color: "bg-gray-500", 79 114 keywords: [], 80 - }) 115 + }); 81 116 } 82 - } 117 + }; 83 118 84 119 const handleRemoveCategory = (id: string) => { 85 - setTimeCategories(timeCategories.filter((cat) => cat.id !== id)) 86 - } 120 + setTimeCategories(timeCategories.filter((cat) => cat.id !== id)); 121 + }; 87 122 88 123 if (!analytics) { 89 - return <div>{t.loading}</div> 124 + return <div>{t.loading}</div>; 90 125 } 91 126 92 - // ไธบๆ‰‡ๅฝข็ปŸ่ฎกๅ›พๅ‡†ๅค‡ๆ•ฐๆฎ 93 127 const pieData = Object.entries(analytics.categorizedHours) 94 128 .filter(([_, hours]) => hours > 0) 95 129 .map(([categoryId, hours]) => { 96 - const category = timeCategories.find((cat) => cat.id === categoryId) 130 + const category = timeCategories.find((cat) => cat.id === categoryId); 97 131 return { 98 132 name: category ? category.name : t.uncategorized, 99 133 value: Math.round(hours * 10) / 10, 100 134 color: category ? category.color.replace("bg-", "") : "gray-500", 101 - } 102 - }) 135 + }; 136 + }); 103 137 104 - // ไธบๆกๅฝข็ปŸ่ฎกๅ›พๅ›พๅ‡†ๅค‡ๆ•ฐๆฎ 105 138 const barData = Object.entries(analytics.categorizedHours) 106 139 .filter(([_, hours]) => hours > 0) 107 140 .map(([categoryId, hours]) => { 108 - const category = timeCategories.find((cat) => cat.id === categoryId) 141 + const category = timeCategories.find((cat) => cat.id === categoryId); 109 142 return { 110 143 name: category ? category.name : t.uncategorized, 111 144 hours: Math.round(hours * 10) / 10, 112 145 color: category ? category.color.replace("bg-", "") : "gray-500", 113 - } 114 - }) 146 + }; 147 + }); 115 148 116 149 const colorMap: Record<string, string> = { 117 150 "blue-500": "#3b82f6", ··· 124 157 "indigo-500": "#6366f1", 125 158 "orange-500": "#f97316", 126 159 "teal-500": "#14b8a6", 127 - } 160 + }; 128 161 129 162 const busiestCategoryName = (() => { 130 - if (analytics.busiestCategoryId === "uncategorized") return t.uncategorized 131 - return timeCategories.find((cat) => cat.id === analytics.busiestCategoryId)?.name || t.uncategorized 132 - })() 163 + if (analytics.busiestCategoryId === "uncategorized") return t.uncategorized; 164 + return ( 165 + timeCategories.find((cat) => cat.id === analytics.busiestCategoryId) 166 + ?.name || t.uncategorized 167 + ); 168 + })(); 133 169 134 170 return ( 135 171 <Card className="w-full"> ··· 137 173 <div className="flex items-center justify-between gap-3"> 138 174 <div> 139 175 <CardTitle>{t.timeAnalytics}</CardTitle> 140 - <CardDescription>{t.timeAnalyticsDesc || "Analyze how you spend your time"}</CardDescription> 176 + <CardDescription> 177 + {t.timeAnalyticsDesc || "Analyze how you spend your time"} 178 + </CardDescription> 141 179 </div> 142 - <Select value={timeRange} onValueChange={(value: "week" | "month" | "year") => setTimeRange(value)}> 180 + <Select 181 + value={timeRange} 182 + onValueChange={(value: "week" | "month" | "year") => 183 + setTimeRange(value) 184 + } 185 + > 143 186 <SelectTrigger className="w-[140px]"> 144 187 <SelectValue /> 145 188 </SelectTrigger> 146 189 <SelectContent> 147 190 <SelectItem value="week">{t.thisWeek || "This Week"}</SelectItem> 148 - <SelectItem value="month">{t.thisMonth || "This Month"}</SelectItem> 191 + <SelectItem value="month"> 192 + {t.thisMonth || "This Month"} 193 + </SelectItem> 149 194 <SelectItem value="year">{t.thisYear || "This Year"}</SelectItem> 150 195 </SelectContent> 151 196 </Select> ··· 169 214 dataKey="value" 170 215 > 171 216 {pieData.map((entry, index) => ( 172 - <Cell key={`cell-${index}`} fill={colorMap[entry.color] || "#6b7280"} /> 217 + <Cell 218 + key={`cell-${index}`} 219 + fill={colorMap[entry.color] || "#6b7280"} 220 + /> 173 221 ))} 174 222 </Pie> 175 223 <Tooltip formatter={(value) => [`${value} ${t.hours}`, ""]} /> ··· 181 229 <h3 className="text-lg font-medium mb-2">{t.categoryTime}</h3> 182 230 <div className="h-[300px]"> 183 231 <ResponsiveContainer width="100%" height="100%"> 184 - <BarChart data={barData} layout="vertical" margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> 232 + <BarChart 233 + data={barData} 234 + layout="vertical" 235 + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} 236 + > 185 237 <XAxis type="number" /> 186 238 <YAxis dataKey="name" type="category" width={80} /> 187 239 <Tooltip formatter={(value) => [`${value} ${t.hours}`, ""]} /> 188 240 <Legend /> 189 241 <Bar dataKey="hours" name={t.hours}> 190 242 {barData.map((entry, index) => ( 191 - <Cell key={`cell-${index}`} fill={colorMap[entry.color] || "#6b7280"} /> 243 + <Cell 244 + key={`cell-${index}`} 245 + fill={colorMap[entry.color] || "#6b7280"} 246 + /> 192 247 ))} 193 248 </Bar> 194 249 </BarChart> ··· 201 256 <CardContent className="pt-6"> 202 257 <div className="text-center"> 203 258 <h3 className="text-lg font-medium">{t.totalEvents}</h3> 204 - <p className="text-3xl font-bold mt-2">{analytics.totalEvents}</p> 259 + <p className="text-3xl font-bold mt-2"> 260 + {analytics.totalEvents} 261 + </p> 205 262 </div> 206 263 </CardContent> 207 264 </Card> ··· 210 267 <div className="text-center"> 211 268 <h3 className="text-lg font-medium">{t.mostProductiveDay}</h3> 212 269 <p className="text-3xl font-bold mt-2"> 213 - {analytics.mostProductiveDay ? new Date(analytics.mostProductiveDay).toLocaleDateString() : t.noData} 270 + {analytics.mostProductiveDay 271 + ? new Date(analytics.mostProductiveDay).toLocaleDateString() 272 + : t.noData} 214 273 </p> 215 274 </div> 216 275 </CardContent> ··· 220 279 <div className="text-center"> 221 280 <h3 className="text-lg font-medium">{t.mostProductiveHour}</h3> 222 281 <p className="text-3xl font-bold mt-2"> 223 - {analytics.mostProductiveHour !== undefined ? `${analytics.mostProductiveHour}:00` : t.noData} 282 + {analytics.mostProductiveHour !== undefined 283 + ? `${analytics.mostProductiveHour}:00` 284 + : t.noData} 224 285 </p> 225 286 </div> 226 287 </CardContent> ··· 231 292 <Card> 232 293 <CardContent className="pt-6"> 233 294 <div className="text-center"> 234 - <h3 className="text-lg font-medium">{t.totalHours || "Total Hours"}</h3> 235 - <p className="text-3xl font-bold mt-2">{analytics.totalHours.toFixed(1)}h</p> 295 + <h3 className="text-lg font-medium"> 296 + {t.totalHours || "Total Hours"} 297 + </h3> 298 + <p className="text-3xl font-bold mt-2"> 299 + {analytics.totalHours.toFixed(1)}h 300 + </p> 236 301 </div> 237 302 </CardContent> 238 303 </Card> 239 304 <Card> 240 305 <CardContent className="pt-6"> 241 306 <div className="text-center"> 242 - <h3 className="text-lg font-medium">{t.averageEventDuration || "Average Event Duration"}</h3> 243 - <p className="text-3xl font-bold mt-2">{analytics.averageEventDuration.toFixed(1)}h</p> 307 + <h3 className="text-lg font-medium"> 308 + {t.averageEventDuration || "Average Event Duration"} 309 + </h3> 310 + <p className="text-3xl font-bold mt-2"> 311 + {analytics.averageEventDuration.toFixed(1)}h 312 + </p> 244 313 </div> 245 314 </CardContent> 246 315 </Card> 247 316 <Card> 248 317 <CardContent className="pt-6"> 249 318 <div className="text-center"> 250 - <h3 className="text-lg font-medium">{t.busiestCategory || "Busiest Category"}</h3> 251 - <p className="text-2xl font-bold mt-2">{busiestCategoryName || t.noData}</p> 252 - <p className="text-sm text-muted-foreground mt-1">{t.activeDays || "Active Days"}: {analytics.activeDays}</p> 319 + <h3 className="text-lg font-medium"> 320 + {t.busiestCategory || "Busiest Category"} 321 + </h3> 322 + <p className="text-2xl font-bold mt-2"> 323 + {busiestCategoryName || t.noData} 324 + </p> 325 + <p className="text-sm text-muted-foreground mt-1"> 326 + {t.activeDays || "Active Days"}: {analytics.activeDays} 327 + </p> 253 328 </div> 254 329 </CardContent> 255 330 </Card> 256 331 </div> 257 332 </CardContent> 258 333 </Card> 259 - ) 334 + ); 260 335 }
+722 -623
components/app/calendar.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { checkPendingNotifications, clearAllNotificationTimers, type NOTIFICATION_SOUNDS } from "@/lib/notifications" 4 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, SelectGroup } from "@/components/ui/select" 5 - import { ChevronLeft, ChevronRight, Search, PanelLeft, BarChart2, Settings as SettingsIcon } from 'lucide-react' 6 - import dynamic from "next/dynamic" 7 - import { readEncryptedLocalStorage, useLocalStorage, writeEncryptedLocalStorage } from "@/hooks/useLocalStorage" 8 - import UserProfileButton, { type UserProfileSection } from "@/components/app/profile/user-profile-button" 9 - import { useState, useEffect, useRef, useMemo } from "react" 10 - import { useCalendar } from "@/components/providers/calendar-context" 11 - import RightSidebar from "@/components/app/sidebar/right-sidebar" 12 - import { addDays, addYears, subDays, subYears } from "date-fns" 13 - import EventPreview from "@/components/app/event/event-preview" 14 - import EventDialog from "@/components/app/event/event-dialog" 15 - import DailyToast from "@/components/app/profile/daily-toast" 16 - import { ScrollArea } from "@/components/ui/scroll-area" 17 - import Sidebar from "@/components/app/sidebar/sidebar" 18 - import { translations, useLanguage } from "@/lib/i18n" 19 - import { Button } from "@/components/ui/button" 20 - import { Input } from "@/components/ui/input" 21 - import { cn } from "@/lib/utils" 22 - import { toast } from "sonner" 3 + import { 4 + checkPendingNotifications, 5 + clearAllNotificationTimers, 6 + type NOTIFICATION_SOUNDS, 7 + } from "@/lib/notifications"; 8 + import { 9 + Select, 10 + SelectContent, 11 + SelectItem, 12 + SelectTrigger, 13 + SelectValue, 14 + SelectGroup, 15 + } from "@/components/ui/select"; 16 + import { 17 + ChevronLeft, 18 + ChevronRight, 19 + Search, 20 + PanelLeft, 21 + BarChart2, 22 + Settings as SettingsIcon, 23 + } from "lucide-react"; 24 + import dynamic from "next/dynamic"; 25 + import { 26 + readEncryptedLocalStorage, 27 + useLocalStorage, 28 + writeEncryptedLocalStorage, 29 + } from "@/hooks/useLocalStorage"; 30 + import UserProfileButton, { 31 + type UserProfileSection, 32 + } from "@/components/app/profile/user-profile-button"; 33 + import { useState, useEffect, useRef, useMemo } from "react"; 34 + import { useCalendar } from "@/components/providers/calendar-context"; 35 + import RightSidebar from "@/components/app/sidebar/right-sidebar"; 36 + import { addDays, addYears, subDays, subYears } from "date-fns"; 37 + import EventPreview from "@/components/app/event/event-preview"; 38 + import EventDialog from "@/components/app/event/event-dialog"; 39 + import DailyToast from "@/components/app/profile/daily-toast"; 40 + import { ScrollArea } from "@/components/ui/scroll-area"; 41 + import Sidebar from "@/components/app/sidebar/sidebar"; 42 + import { translations, useLanguage } from "@/lib/i18n"; 43 + import { Button } from "@/components/ui/button"; 44 + import { Input } from "@/components/ui/input"; 45 + import { cn } from "@/lib/utils"; 46 + import { toast } from "sonner"; 23 47 import { 24 48 AlertDialog, 25 49 AlertDialogAction, ··· 29 53 AlertDialogFooter, 30 54 AlertDialogHeader, 31 55 AlertDialogTitle, 32 - } from "@/components/ui/alert-dialog" 56 + } from "@/components/ui/alert-dialog"; 33 57 34 - const loadDayView = () => import("@/components/app/views/day-view") 35 - const loadWeekView = () => import("@/components/app/views/week-view") 36 - const loadMonthView = () => import("@/components/app/views/month-view") 37 - const loadYearView = () => import("@/components/app/views/year-view") 38 - const loadAnalyticsView = () => import("@/components/app/analytics/analytics-view") 39 - const loadSettings = () => import("@/components/app/profile/settings") 58 + const loadDayView = () => import("@/components/app/views/day-view"); 59 + const loadWeekView = () => import("@/components/app/views/week-view"); 60 + const loadMonthView = () => import("@/components/app/views/month-view"); 61 + const loadYearView = () => import("@/components/app/views/year-view"); 62 + const loadAnalyticsView = () => 63 + import("@/components/app/analytics/analytics-view"); 64 + const loadSettings = () => import("@/components/app/profile/settings"); 40 65 41 - const DayView = dynamic(loadDayView) 42 - const WeekView = dynamic(loadWeekView) 43 - const MonthView = dynamic(loadMonthView) 44 - const YearView = dynamic(loadYearView) 45 - const AnalyticsView = dynamic(loadAnalyticsView) 46 - const Settings = dynamic(loadSettings) 66 + const DayView = dynamic(loadDayView); 67 + const WeekView = dynamic(loadWeekView); 68 + const MonthView = dynamic(loadMonthView); 69 + const YearView = dynamic(loadYearView); 70 + const AnalyticsView = dynamic(loadAnalyticsView); 71 + const Settings = dynamic(loadSettings); 47 72 48 - type ViewType = "day" | "week" | "four-day" | "month" | "year" | "analytics" | "settings" 73 + type ViewType = 74 + | "day" 75 + | "week" 76 + | "four-day" 77 + | "month" 78 + | "year" 79 + | "analytics" 80 + | "settings"; 49 81 50 82 export interface CalendarEvent { 51 - id: string 52 - title: string 53 - startDate: Date 54 - endDate: Date 55 - isAllDay: boolean 56 - recurrence: "none" | "daily" | "weekly" | "monthly" | "yearly" 57 - location?: string 58 - participants: string[] 59 - notification: number 60 - description?: string 61 - color: string 62 - calendarId: string 83 + id: string; 84 + title: string; 85 + startDate: Date; 86 + endDate: Date; 87 + isAllDay: boolean; 88 + recurrence: "none" | "daily" | "weekly" | "monthly" | "yearly"; 89 + location?: string; 90 + participants: string[]; 91 + notification: number; 92 + description?: string; 93 + color: string; 94 + calendarId: string; 63 95 } 64 - 65 96 66 97 export default function Calendar({ className, ...props }: CalendarProps) { 67 - const [openShareImmediately, setOpenShareImmediately] = useState(false) 68 - const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false) 69 - const [isSidebarExpanding, setIsSidebarExpanding] = useState(false) 70 - const [date, setDate] = useState(new Date()) 71 - const [view, setView] = useState<ViewType>("week") 72 - const [eventDialogOpen, setEventDialogOpen] = useState(false) 73 - const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null) 74 - const { events, setEvents, calendars } = useCalendar() 75 - const [searchTerm, setSearchTerm] = useState("") 76 - const [isSearchFocused, setIsSearchFocused] = useState(false) 77 - const [selectedCategoryFilters, setSelectedCategoryFilters] = useState<string[]>([]) 78 - const calendarRef = useRef<HTMLDivElement>(null) 79 - const [language, setLanguage] = useLanguage() 80 - const t = translations[language] 81 - const [firstDayOfWeek, setFirstDayOfWeek] = useLocalStorage<number>("first-day-of-week", 0) 82 - const [timezone, setTimezone] = useLocalStorage<string>("timezone", Intl.DateTimeFormat().resolvedOptions().timeZone) 83 - const [notificationSound, setNotificationSound] = useLocalStorage<keyof typeof NOTIFICATION_SOUNDS>( 84 - "notification-sound", 85 - "telegram", 86 - ) 87 - const notificationIntervalRef = useRef<NodeJS.Timeout | null>(null) 88 - const notificationsInitializedRef = useRef(false) 89 - const [previewEvent, setPreviewEvent] = useState<CalendarEvent | null>(null) 90 - const [previewOpen, setPreviewOpen] = useState(false) 91 - const [focusUserProfileSection, setFocusUserProfileSection] = useState<UserProfileSection | null>(null) 92 - const [sidebarDate, setSidebarDate] = useState<Date>(new Date()) 93 - const [pendingDeleteEvent, setPendingDeleteEvent] = useState<CalendarEvent | null>(null) 94 - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) 95 - 98 + const [openShareImmediately, setOpenShareImmediately] = useState(false); 99 + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); 100 + const [isSidebarExpanding, setIsSidebarExpanding] = useState(false); 101 + const [date, setDate] = useState(new Date()); 102 + const [view, setView] = useState<ViewType>("week"); 103 + const [eventDialogOpen, setEventDialogOpen] = useState(false); 104 + const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>( 105 + null, 106 + ); 107 + const { events, setEvents, calendars } = useCalendar(); 108 + const [searchTerm, setSearchTerm] = useState(""); 109 + const [isSearchFocused, setIsSearchFocused] = useState(false); 110 + const [selectedCategoryFilters, setSelectedCategoryFilters] = useState< 111 + string[] 112 + >([]); 113 + const calendarRef = useRef<HTMLDivElement>(null); 114 + const [language, setLanguage] = useLanguage(); 115 + const t = translations[language]; 116 + const [firstDayOfWeek, setFirstDayOfWeek] = useLocalStorage<number>( 117 + "first-day-of-week", 118 + 0, 119 + ); 120 + const [timezone, setTimezone] = useLocalStorage<string>( 121 + "timezone", 122 + Intl.DateTimeFormat().resolvedOptions().timeZone, 123 + ); 124 + const [notificationSound, setNotificationSound] = useLocalStorage< 125 + keyof typeof NOTIFICATION_SOUNDS 126 + >("notification-sound", "telegram"); 127 + const notificationIntervalRef = useRef<NodeJS.Timeout | null>(null); 128 + const notificationsInitializedRef = useRef(false); 129 + const [previewEvent, setPreviewEvent] = useState<CalendarEvent | null>(null); 130 + const [previewOpen, setPreviewOpen] = useState(false); 131 + const [focusUserProfileSection, setFocusUserProfileSection] = 132 + useState<UserProfileSection | null>(null); 133 + const [sidebarDate, setSidebarDate] = useState<Date>(new Date()); 134 + const [pendingDeleteEvent, setPendingDeleteEvent] = 135 + useState<CalendarEvent | null>(null); 136 + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); 96 137 97 138 const updateEvent = (updatedEvent) => { 98 - setEvents(prevEvents => 99 - prevEvents.map(event => 100 - event.id === updatedEvent.id ? updatedEvent : event 101 - ) 102 - ) 103 - } 104 - 105 - // ๆ–ฐๅขž๏ผšๅฟซ้€Ÿๅˆ›ๅปบไบ‹ไปถ็š„ๅˆๅง‹ๆ—ถ้—ด 106 - const [quickCreateStartTime, setQuickCreateStartTime] = useState<Date | null>(null) 139 + setEvents((prevEvents) => 140 + prevEvents.map((event) => 141 + event.id === updatedEvent.id ? updatedEvent : event, 142 + ), 143 + ); 144 + }; 107 145 108 - // Add the new state variables for default view and keyboard shortcuts 109 - const [defaultView, setDefaultView] = useLocalStorage<ViewType>("default-view", "week") 110 - const [enableShortcuts, setEnableShortcuts] = useLocalStorage<boolean>("enable-shortcuts", true) 111 - const [timeFormat, setTimeFormat] = useLocalStorage<"24h" | "12h">("time-format", "24h") 146 + const [quickCreateStartTime, setQuickCreateStartTime] = useState<Date | null>( 147 + null, 148 + ); 149 + 150 + const [defaultView, setDefaultView] = useLocalStorage<ViewType>( 151 + "default-view", 152 + "week", 153 + ); 154 + const [enableShortcuts, setEnableShortcuts] = useLocalStorage<boolean>( 155 + "enable-shortcuts", 156 + true, 157 + ); 158 + const [timeFormat, setTimeFormat] = useLocalStorage<"24h" | "12h">( 159 + "time-format", 160 + "24h", 161 + ); 112 162 113 - // Add a useEffect to set the initial view based on the default view setting 114 163 useEffect(() => { 115 - // Only set the view on initial load 116 164 if (view !== defaultView) { 117 - setView(defaultView as ViewType) 165 + setView(defaultView as ViewType); 118 166 } 119 - }, []) 167 + }, []); 120 168 121 169 useEffect(() => { 122 170 const prefetch = () => { 123 - void loadDayView() 124 - void loadWeekView() 125 - void loadMonthView() 126 - void loadYearView() 127 - void loadAnalyticsView() 128 - void loadSettings() 129 - } 171 + void loadDayView(); 172 + void loadWeekView(); 173 + void loadMonthView(); 174 + void loadYearView(); 175 + void loadAnalyticsView(); 176 + void loadSettings(); 177 + }; 130 178 131 - if (typeof window === "undefined") return 179 + if (typeof window === "undefined") return; 132 180 133 181 if ("requestIdleCallback" in window) { 134 - const id = window.requestIdleCallback(prefetch) 135 - return () => window.cancelIdleCallback(id) 182 + const id = window.requestIdleCallback(prefetch); 183 + return () => window.cancelIdleCallback(id); 136 184 } 137 185 138 - const timeoutId = window.setTimeout(prefetch, 800) 139 - return () => window.clearTimeout(timeoutId) 140 - }, []) 186 + const timeoutId = window.setTimeout(prefetch, 800); 187 + return () => window.clearTimeout(timeoutId); 188 + }, []); 141 189 142 - // Add the keyboard shortcut handler 143 190 useEffect(() => { 144 - if (!enableShortcuts) return // Early return if shortcuts are disabled 191 + if (!enableShortcuts) return; 145 192 146 193 const handleKeyDown = (e: KeyboardEvent) => { 147 - // Skip if user is typing in an input, textarea, or contentEditable element 148 194 if ( 149 195 document.activeElement instanceof HTMLInputElement || 150 196 document.activeElement instanceof HTMLTextAreaElement || 151 197 document.activeElement?.getAttribute("contenteditable") === "true" 152 198 ) { 153 - return 199 + return; 154 200 } 155 201 156 202 switch (e.key) { 157 203 case "n": 158 204 case "N": 159 - e.preventDefault() 160 - setSelectedEvent(null) // ็กฎไฟๆ˜ฏๅˆ›ๅปบๆ–ฐไบ‹ไปถ 161 - setQuickCreateStartTime(new Date()) // ไฝฟ็”จๅฝ“ๅ‰ๆ—ถ้—ด 162 - setEventDialogOpen(true) 163 - break 205 + e.preventDefault(); 206 + setSelectedEvent(null); 207 + setQuickCreateStartTime(new Date()); 208 + setEventDialogOpen(true); 209 + break; 164 210 case "/": 165 - e.preventDefault() 166 - // Focus the search input 167 - const searchInput = document.querySelector('input[placeholder="' + t.searchEvents + '"]') as HTMLInputElement 211 + e.preventDefault(); 212 + 213 + const searchInput = document.querySelector( 214 + 'input[placeholder="' + t.searchEvents + '"]', 215 + ) as HTMLInputElement; 168 216 if (searchInput) { 169 - searchInput.focus() 217 + searchInput.focus(); 170 218 } 171 - break 219 + break; 172 220 case "t": 173 221 case "T": 174 - e.preventDefault() 175 - handleTodayClick() 176 - break 222 + e.preventDefault(); 223 + handleTodayClick(); 224 + break; 177 225 case "1": 178 - e.preventDefault() 179 - setView("day") 180 - break 226 + e.preventDefault(); 227 + setView("day"); 228 + break; 181 229 case "2": 182 - e.preventDefault() 183 - setView("week") 184 - break 230 + e.preventDefault(); 231 + setView("week"); 232 + break; 185 233 case "3": 186 - e.preventDefault() 187 - setView("month") 188 - break 234 + e.preventDefault(); 235 + setView("month"); 236 + break; 189 237 case "4": 190 - e.preventDefault() 191 - setView("year") 192 - break 238 + e.preventDefault(); 239 + setView("year"); 240 + break; 193 241 case "5": 194 - e.preventDefault() 195 - setView("four-day") 196 - break 242 + e.preventDefault(); 243 + setView("four-day"); 244 + break; 197 245 case "ArrowRight": 198 - e.preventDefault() 199 - handleNext() 200 - break 246 + e.preventDefault(); 247 + handleNext(); 248 + break; 201 249 case "ArrowLeft": 202 - e.preventDefault() 203 - handlePrevious() 204 - break 250 + e.preventDefault(); 251 + handlePrevious(); 252 + break; 205 253 } 206 - } 254 + }; 207 255 208 - window.addEventListener("keydown", handleKeyDown) 256 + window.addEventListener("keydown", handleKeyDown); 209 257 return () => { 210 - window.removeEventListener("keydown", handleKeyDown) 211 - } 212 - }, [enableShortcuts, t.searchEvents]) // Make sure enableShortcuts is in the dependency array 258 + window.removeEventListener("keydown", handleKeyDown); 259 + }; 260 + }, [enableShortcuts, t.searchEvents]); 213 261 214 262 const toggleSidebar = () => { 215 263 setIsSidebarCollapsed((prev) => { 216 - const nextCollapsed = !prev 264 + const nextCollapsed = !prev; 217 265 if (!nextCollapsed) { 218 - setIsSidebarExpanding(true) 266 + setIsSidebarExpanding(true); 219 267 } else { 220 - setIsSidebarExpanding(false) 268 + setIsSidebarExpanding(false); 221 269 } 222 - return nextCollapsed 223 - }) 224 - } 270 + return nextCollapsed; 271 + }); 272 + }; 225 273 226 274 const handleDateSelect = (date: Date) => { 227 - setDate(date) 228 - setSidebarDate(date) 229 - } 275 + setDate(date); 276 + setSidebarDate(date); 277 + }; 230 278 231 279 const handleViewChange = (newView: ViewType) => { 232 - setView(newView) 233 - } 280 + setView(newView); 281 + }; 234 282 235 283 const handleUserProfileSectionNavigate = (section: UserProfileSection) => { 236 - setView("settings") 237 - setFocusUserProfileSection(null) 238 - setTimeout(() => setFocusUserProfileSection(section), 0) 239 - } 284 + setView("settings"); 285 + setFocusUserProfileSection(null); 286 + setTimeout(() => setFocusUserProfileSection(section), 0); 287 + }; 240 288 241 289 const handleTodayClick = () => { 242 - const today = new Date() 243 - setDate(today) 244 - setSidebarDate(today) // Add this line to update the sidebar calendar 245 - } 290 + const today = new Date(); 291 + setDate(today); 292 + setSidebarDate(today); 293 + }; 246 294 247 295 const handlePrevious = () => { 248 296 setDate((prevDate) => { 249 - if (view === "day") return subDays(prevDate, 1) 250 - if (view === "week") return subDays(prevDate, 7) 251 - if (view === "four-day") return subDays(prevDate, 4) 252 - if (view === "year") return subYears(prevDate, 1) 253 - return subDays(prevDate, 30) 254 - }) 255 - } 297 + if (view === "day") return subDays(prevDate, 1); 298 + if (view === "week") return subDays(prevDate, 7); 299 + if (view === "four-day") return subDays(prevDate, 4); 300 + if (view === "year") return subYears(prevDate, 1); 301 + return subDays(prevDate, 30); 302 + }); 303 + }; 256 304 257 305 const handleNext = () => { 258 306 setDate((prevDate) => { 259 - if (view === "day") return addDays(prevDate, 1) 260 - if (view === "week") return addDays(prevDate, 7) 261 - if (view === "four-day") return addDays(prevDate, 4) 262 - if (view === "year") return addYears(prevDate, 1) 263 - return addDays(prevDate, 30) 264 - }) 265 - } 307 + if (view === "day") return addDays(prevDate, 1); 308 + if (view === "week") return addDays(prevDate, 7); 309 + if (view === "four-day") return addDays(prevDate, 4); 310 + if (view === "year") return addYears(prevDate, 1); 311 + return addDays(prevDate, 30); 312 + }); 313 + }; 266 314 267 - // ไฟฎๆ”น๏ผšๆ นๆฎ่ฏญ่จ€่ฎพ็ฝฎไธๅŒ็š„ๆ—ฅๆœŸๆ ผๅผ 268 315 const formatDateDisplay = (date: Date) => { 269 316 if (view === "year") { 270 - return date.getFullYear().toString() 317 + return date.getFullYear().toString(); 271 318 } 272 319 273 320 if (view === "four-day") { 274 - const startDate = new Date(date) 275 - const endDate = addDays(startDate, 3) 276 - const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" } 277 - return `${startDate.toLocaleDateString(language, options)} - ${endDate.toLocaleDateString(language, options)}` 321 + const startDate = new Date(date); 322 + const endDate = addDays(startDate, 3); 323 + const options: Intl.DateTimeFormatOptions = { 324 + month: "short", 325 + day: "numeric", 326 + }; 327 + return `${startDate.toLocaleDateString(language, options)} - ${endDate.toLocaleDateString(language, options)}`; 278 328 } 279 329 280 330 if (language === "en") { 281 - const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long" } 282 - return date.toLocaleDateString(language, options) 331 + const options: Intl.DateTimeFormatOptions = { 332 + year: "numeric", 333 + month: "long", 334 + }; 335 + return date.toLocaleDateString(language, options); 283 336 } else { 284 - const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long" } 285 - return date.toLocaleDateString(language, options) 337 + const options: Intl.DateTimeFormatOptions = { 338 + year: "numeric", 339 + month: "long", 340 + }; 341 + return date.toLocaleDateString(language, options); 286 342 } 287 - } 343 + }; 288 344 289 345 const handleEventClick = (event: CalendarEvent) => { 290 - setPreviewEvent(event) 291 - setPreviewOpen(true) 292 - } 346 + setPreviewEvent(event); 347 + setPreviewOpen(true); 348 + }; 293 349 294 350 const handleEventAdd = (event: CalendarEvent) => { 295 - // Make sure we're adding a new event with the correct ID 296 351 const newEvent = { 297 352 ...event, 298 - id: event.id || Date.now().toString() + Math.random().toString(36).substring(2, 9), 299 - } 353 + id: 354 + event.id || 355 + Date.now().toString() + Math.random().toString(36).substring(2, 9), 356 + }; 300 357 301 - setEvents((prevEvents) => [...prevEvents, newEvent]) 302 - toast(t.eventCreated) 303 - setEventDialogOpen(false) 304 - setSelectedEvent(null) 305 - setQuickCreateStartTime(null) // Reset the quick create time 306 - } 358 + setEvents((prevEvents) => [...prevEvents, newEvent]); 359 + toast(t.eventCreated); 360 + setEventDialogOpen(false); 361 + setSelectedEvent(null); 362 + setQuickCreateStartTime(null); 363 + }; 307 364 308 365 const handleEventUpdate = (updatedEvent: CalendarEvent) => { 309 - setEvents((prevEvents) => prevEvents.map((event) => (event.id === updatedEvent.id ? updatedEvent : event))) 310 - toast(t.eventUpdated) 311 - setEventDialogOpen(false) 312 - setSelectedEvent(null) 313 - setQuickCreateStartTime(null) // Reset the quick create time 314 - } 366 + setEvents((prevEvents) => 367 + prevEvents.map((event) => 368 + event.id === updatedEvent.id ? updatedEvent : event, 369 + ), 370 + ); 371 + toast(t.eventUpdated); 372 + setEventDialogOpen(false); 373 + setSelectedEvent(null); 374 + setQuickCreateStartTime(null); 375 + }; 315 376 316 377 const handleEventDelete = (eventId: string) => { 317 - const targetEvent = events.find((event) => event.id === eventId) 318 - if (!targetEvent) return 319 - setPendingDeleteEvent(targetEvent) 320 - setDeleteConfirmOpen(true) 321 - } 378 + const targetEvent = events.find((event) => event.id === eventId); 379 + if (!targetEvent) return; 380 + setPendingDeleteEvent(targetEvent); 381 + setDeleteConfirmOpen(true); 382 + }; 322 383 323 384 const confirmEventDelete = () => { 324 - if (!pendingDeleteEvent) return 385 + if (!pendingDeleteEvent) return; 325 386 326 - const deletedEvent = pendingDeleteEvent 327 - setEvents((prevEvents) => prevEvents.filter((event) => event.id !== deletedEvent.id)) 328 - setEventDialogOpen(false) 329 - setSelectedEvent(null) 330 - setPreviewOpen(false) 331 - setDeleteConfirmOpen(false) 332 - setPendingDeleteEvent(null) 387 + const deletedEvent = pendingDeleteEvent; 388 + setEvents((prevEvents) => 389 + prevEvents.filter((event) => event.id !== deletedEvent.id), 390 + ); 391 + setEventDialogOpen(false); 392 + setSelectedEvent(null); 393 + setPreviewOpen(false); 394 + setDeleteConfirmOpen(false); 395 + setPendingDeleteEvent(null); 333 396 334 397 toast(t.eventDeleted, { 335 398 description: deletedEvent.title, ··· 337 400 label: t.undo, 338 401 onClick: () => { 339 402 setEvents((prevEvents) => { 340 - if (prevEvents.some((event) => event.id === deletedEvent.id)) return prevEvents 403 + if (prevEvents.some((event) => event.id === deletedEvent.id)) 404 + return prevEvents; 341 405 return [...prevEvents, deletedEvent].sort( 342 - (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), 343 - ) 344 - }) 345 - toast(t.deletionUndone) 406 + (a, b) => 407 + new Date(a.startDate).getTime() - 408 + new Date(b.startDate).getTime(), 409 + ); 410 + }); 411 + toast(t.deletionUndone); 346 412 }, 347 413 }, 348 - }) 349 - } 414 + }); 415 + }; 350 416 351 417 const handleImportEvents = (importedEvents: CalendarEvent[]) => { 352 418 const newEvents = importedEvents.map((event) => ({ 353 419 ...event, 354 420 id: event.id || Math.random().toString(36).substring(7), 355 - })) as CalendarEvent[] 356 - setEvents((prevEvents) => [...prevEvents, ...newEvents]) 357 - } 421 + })) as CalendarEvent[]; 422 + setEvents((prevEvents) => [...prevEvents, ...newEvents]); 423 + }; 358 424 359 - // ไฟฎๆ”นhandleEventEditๅ‡ฝๆ•ฐ๏ผŒ็กฎไฟๆญฃ็กฎไผ ้€’ไบ‹ไปถๅฏน่ฑก็š„ๆทฑๆ‹ท่ด 360 425 const handleEventEdit = () => { 361 426 if (previewEvent) { 362 - // ไฝฟ็”จๅฝ“ๅ‰้ข„่งˆ็š„ไบ‹ไปถ 363 - setSelectedEvent(previewEvent) 364 - setQuickCreateStartTime(null) 365 - setEventDialogOpen(true) 366 - setPreviewOpen(false) 427 + setSelectedEvent(previewEvent); 428 + setQuickCreateStartTime(null); 429 + setEventDialogOpen(true); 430 + setPreviewOpen(false); 367 431 } 368 - } 432 + }; 369 433 370 434 const handleEventDuplicate = (event: CalendarEvent) => { 371 - const duplicatedEvent = { ...event, id: Math.random().toString(36).substring(7) } 372 - setEvents((prevEvents) => [...prevEvents, duplicatedEvent]) 373 - setPreviewOpen(false) 374 - } 435 + const duplicatedEvent = { 436 + ...event, 437 + id: Math.random().toString(36).substring(7), 438 + }; 439 + setEvents((prevEvents) => [...prevEvents, duplicatedEvent]); 440 + setPreviewOpen(false); 441 + }; 375 442 376 - // ๆ–ฐๅขž๏ผšๅค„็†ๆ—ถ้—ดๆ ผๅญ็‚นๅ‡ปไบ‹ไปถ 377 443 const handleTimeSlotClick = (clickTime: Date) => { 378 - // ่ฎพ็ฝฎๅฟซ้€Ÿๅˆ›ๅปบๆ—ถ้—ด 379 - setQuickCreateStartTime(clickTime) 444 + setQuickCreateStartTime(clickTime); 380 445 381 - // ้‡่ฆ๏ผš่ฎพ็ฝฎไธบnull่กจ็คบๅˆ›ๅปบๆ–ฐไบ‹ไปถ 382 - setSelectedEvent(null) 383 - setEventDialogOpen(true) 384 - } 446 + setSelectedEvent(null); 447 + setEventDialogOpen(true); 448 + }; 385 449 386 - // ๆ”ถ่—๏ผˆไนฆ็ญพ๏ผ‰ๅŠŸ่ƒฝ 387 450 const toggleBookmark = async (event: CalendarEvent) => { 388 - const bookmarks = await readEncryptedLocalStorage<any[]>("bookmarked-events", []) 451 + const bookmarks = await readEncryptedLocalStorage<any[]>( 452 + "bookmarked-events", 453 + [], 454 + ); 389 455 390 - const isBookmarked = bookmarks.some((b: any) => b.id === event.id) 456 + const isBookmarked = bookmarks.some((b: any) => b.id === event.id); 391 457 if (isBookmarked) { 392 - const updated = bookmarks.filter((b: any) => b.id !== event.id) 393 - await writeEncryptedLocalStorage("bookmarked-events", updated) 458 + const updated = bookmarks.filter((b: any) => b.id !== event.id); 459 + await writeEncryptedLocalStorage("bookmarked-events", updated); 394 460 } else { 395 461 const bookmarkData = { 396 462 id: event.id, ··· 400 466 color: event.color, 401 467 location: event.location, 402 468 bookmarkedAt: new Date().toISOString(), 403 - } 404 - await writeEncryptedLocalStorage("bookmarked-events", [...bookmarks, bookmarkData]) 469 + }; 470 + await writeEncryptedLocalStorage("bookmarked-events", [ 471 + ...bookmarks, 472 + bookmarkData, 473 + ]); 405 474 } 406 - } 475 + }; 407 476 408 477 const handleShare = (event: CalendarEvent) => { 409 - setPreviewEvent(event) 410 - setPreviewOpen(true) 411 - } 412 - 478 + setPreviewEvent(event); 479 + setPreviewOpen(true); 480 + }; 413 481 414 482 const eventsByCategory = useMemo(() => { 415 - if (selectedCategoryFilters.length === 0) return events 483 + if (selectedCategoryFilters.length === 0) return events; 416 484 417 485 return events.filter((event) => { 418 486 if (!event.calendarId) { 419 - return selectedCategoryFilters.includes("__uncategorized__") 487 + return selectedCategoryFilters.includes("__uncategorized__"); 420 488 } 421 489 422 - const hasCategory = calendars.some((cal) => cal.id === event.calendarId) 423 - if (!hasCategory) return selectedCategoryFilters.includes("__uncategorized__") 424 - return selectedCategoryFilters.includes(event.calendarId) 425 - }) 426 - }, [events, selectedCategoryFilters, calendars]) 490 + const hasCategory = calendars.some((cal) => cal.id === event.calendarId); 491 + if (!hasCategory) 492 + return selectedCategoryFilters.includes("__uncategorized__"); 493 + return selectedCategoryFilters.includes(event.calendarId); 494 + }); 495 + }, [events, selectedCategoryFilters, calendars]); 427 496 428 497 const filteredEvents = useMemo(() => { 429 - const keyword = searchTerm.trim().toLowerCase() 430 - if (!keyword) return eventsByCategory 498 + const keyword = searchTerm.trim().toLowerCase(); 499 + if (!keyword) return eventsByCategory; 431 500 432 501 return eventsByCategory 433 502 .filter((event) => { 434 - const title = event.title?.toLowerCase() || "" 435 - const location = event.location?.toLowerCase() || "" 436 - const description = event.description?.toLowerCase() || "" 437 - return title.includes(keyword) || location.includes(keyword) || description.includes(keyword) 503 + const title = event.title?.toLowerCase() || ""; 504 + const location = event.location?.toLowerCase() || ""; 505 + const description = event.description?.toLowerCase() || ""; 506 + return ( 507 + title.includes(keyword) || 508 + location.includes(keyword) || 509 + description.includes(keyword) 510 + ); 438 511 }) 439 - .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()) 440 - }, [eventsByCategory, searchTerm]) 512 + .sort( 513 + (a, b) => 514 + new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), 515 + ); 516 + }, [eventsByCategory, searchTerm]); 441 517 442 518 const searchResultEvents = useMemo(() => { 443 - if (!searchTerm.trim()) return [] 444 - return filteredEvents.slice(0, 8) 445 - }, [filteredEvents, searchTerm]) 519 + if (!searchTerm.trim()) return []; 520 + return filteredEvents.slice(0, 8); 521 + }, [filteredEvents, searchTerm]); 446 522 447 523 useEffect(() => { 448 524 if (!notificationsInitializedRef.current) { 449 - checkPendingNotifications(notificationSound) 450 - notificationsInitializedRef.current = true 525 + checkPendingNotifications(notificationSound); 526 + notificationsInitializedRef.current = true; 451 527 } 452 528 453 529 if (!notificationIntervalRef.current) { 454 530 notificationIntervalRef.current = setInterval(() => { 455 - checkPendingNotifications(notificationSound) 456 - }, 60000) 531 + checkPendingNotifications(notificationSound); 532 + }, 60000); 457 533 } 458 534 459 535 return () => { 460 536 if (notificationIntervalRef.current) { 461 - clearInterval(notificationIntervalRef.current) 537 + clearInterval(notificationIntervalRef.current); 462 538 } 463 - } 464 - }, [notificationSound]) 539 + }; 540 + }, [notificationSound]); 465 541 466 542 useEffect(() => { 467 - window.addEventListener("beforeunload", clearAllNotificationTimers) 543 + window.addEventListener("beforeunload", clearAllNotificationTimers); 468 544 return () => { 469 - window.removeEventListener("beforeunload", clearAllNotificationTimers) 470 - } 471 - }, []) 545 + window.removeEventListener("beforeunload", clearAllNotificationTimers); 546 + }; 547 + }, []); 472 548 473 549 return ( 474 550 <div className={className}> 475 - <div className="relative flex h-dvh overflow-hidden bg-background"> 476 - {/* <div className="w-80 border-r bg-background"> */} 551 + <div className="relative flex h-dvh overflow-hidden bg-background"> 552 + {} 477 553 <Sidebar 478 554 onCreateEvent={() => { 479 - setSelectedEvent(null) // ็กฎไฟๆ˜ฏๅˆ›ๅปบๆ–ฐไบ‹ไปถ 480 - setQuickCreateStartTime(new Date()) // ไฝฟ็”จๅฝ“ๅ‰ๆ—ถ้—ด 481 - setEventDialogOpen(true) 555 + setSelectedEvent(null); 556 + setQuickCreateStartTime(new Date()); 557 + setEventDialogOpen(true); 482 558 }} 483 559 onDateSelect={handleDateSelect} 484 560 onViewChange={handleViewChange} ··· 491 567 onCategoryFilterChange={(categoryId, checked) => { 492 568 setSelectedCategoryFilters((prev) => { 493 569 if (checked) { 494 - return prev.includes(categoryId) ? prev : [...prev, categoryId] 570 + return prev.includes(categoryId) ? prev : [...prev, categoryId]; 495 571 } 496 - return prev.filter((id) => id !== categoryId) 497 - }) 572 + return prev.filter((id) => id !== categoryId); 573 + }); 498 574 }} 499 575 /> 500 576 501 - <div className="flex min-h-0 min-w-0 flex-1 flex-col pr-14"> 502 - {" "} 503 - <header className="flex items-center justify-between px-4 h-16 border-b relative z-40 bg-background"> 504 - <div className="flex items-center space-x-4"> 505 - <Button 506 - variant="outline" 507 - onClick={toggleSidebar} 508 - size="sm" 509 - > 510 - <PanelLeft /> 511 - </Button> 512 - <Button variant="outline" size="sm" onClick={handleTodayClick}> 513 - {t.today || "ไปŠๅคฉ"} 514 - </Button> 515 - </div> 577 + <div className="flex min-h-0 min-w-0 flex-1 flex-col pr-14"> 578 + {" "} 579 + <header className="flex items-center justify-between px-4 h-16 border-b relative z-40 bg-background"> 580 + <div className="flex items-center space-x-4"> 581 + <Button variant="outline" onClick={toggleSidebar} size="sm"> 582 + <PanelLeft /> 583 + </Button> 584 + <Button variant="outline" size="sm" onClick={handleTodayClick}> 585 + {t.today || "ไปŠๅคฉ"} 586 + </Button> 587 + </div> 516 588 517 - <div className="flex items-center space-x-2"> 518 - {view !== "analytics" && view !== "settings" && ( 519 - <> 520 - <div className="flex items-center space-x-1"> 521 - <Button variant="ghost" size="icon" onClick={handlePrevious}> 522 - <ChevronLeft className="h-4 w-4" /> 523 - </Button> 524 - <Button variant="ghost" size="icon" onClick={handleNext}> 525 - <ChevronRight className="h-4 w-4" /> 526 - </Button> 527 - </div> 528 - <span className="text-lg">{formatDateDisplay(date)}</span> 529 - </> 530 - )} 531 - </div> 589 + <div className="flex items-center space-x-2"> 590 + {view !== "analytics" && view !== "settings" && ( 591 + <> 592 + <div className="flex items-center space-x-1"> 593 + <Button 594 + variant="ghost" 595 + size="icon" 596 + onClick={handlePrevious} 597 + > 598 + <ChevronLeft className="h-4 w-4" /> 599 + </Button> 600 + <Button variant="ghost" size="icon" onClick={handleNext}> 601 + <ChevronRight className="h-4 w-4" /> 602 + </Button> 603 + </div> 604 + <span className="text-lg">{formatDateDisplay(date)}</span> 605 + </> 606 + )} 607 + </div> 532 608 533 - <div className="flex items-center space-x-2"> 534 - <div className="relative z-50"> 535 - <Select 536 - value={ 537 - view === "day" || view === "week" || view === "four-day" || view === "month" || view === "year" 538 - ? view 539 - : defaultView === "day" || defaultView === "week" || defaultView === "four-day" || defaultView === "month" || defaultView === "year" 540 - ? defaultView 541 - : "week" 542 - } 543 - onValueChange={(value: ViewType) => setView(value)} 609 + <div className="flex items-center space-x-2"> 610 + <div className="relative z-50"> 611 + <Select 612 + value={ 613 + view === "day" || 614 + view === "week" || 615 + view === "four-day" || 616 + view === "month" || 617 + view === "year" 618 + ? view 619 + : defaultView === "day" || 620 + defaultView === "week" || 621 + defaultView === "four-day" || 622 + defaultView === "month" || 623 + defaultView === "year" 624 + ? defaultView 625 + : "week" 626 + } 627 + onValueChange={(value: ViewType) => setView(value)} 628 + > 629 + <SelectTrigger className="w-[100px]"> 630 + <SelectValue /> 631 + </SelectTrigger> 632 + <SelectContent> 633 + <SelectGroup> 634 + <SelectItem value="day">{t.day}</SelectItem> 635 + <SelectItem value="week">{t.week}</SelectItem> 636 + <SelectItem value="month">{t.month}</SelectItem> 637 + <SelectItem value="year">{t.year}</SelectItem> 638 + <SelectItem value="four-day">{t.fourDay}</SelectItem> 639 + </SelectGroup> 640 + </SelectContent> 641 + </Select> 642 + </div> 643 + <div className="relative z-50"> 644 + <Search className="pointer-events-none h-5 w-5 absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-400" /> 645 + <Input 646 + type="text" 647 + placeholder={t.searchEvents} 648 + value={searchTerm} 649 + onFocus={() => setIsSearchFocused(true)} 650 + onBlur={() => { 651 + window.setTimeout(() => setIsSearchFocused(false), 120); 652 + }} 653 + onChange={(e) => setSearchTerm(e.target.value)} 654 + onKeyDown={(e) => { 655 + if (e.key === "Enter" && searchResultEvents.length > 0) { 656 + setPreviewEvent(searchResultEvents[0]); 657 + setPreviewOpen(true); 658 + setSearchTerm(""); 659 + setIsSearchFocused(false); 660 + } 661 + }} 662 + className="pl-9 pr-4 py-2 w-48" 663 + /> 664 + {isSearchFocused && !!searchTerm && ( 665 + <div className="absolute right-0 top-[calc(100%+6px)] w-72 rounded-md border bg-popover p-1 shadow-md z-50"> 666 + {searchResultEvents.length > 0 ? ( 667 + <ScrollArea className="max-h-[320px]"> 668 + <div className="space-y-1"> 669 + {searchResultEvents.map((event) => ( 670 + <button 671 + key={event.id} 672 + type="button" 673 + className="w-full cursor-pointer rounded-sm px-2 py-1.5 text-left hover:bg-accent" 674 + onMouseDown={(e) => { 675 + e.preventDefault(); 676 + setPreviewEvent(event); 677 + setPreviewOpen(true); 678 + setSearchTerm(""); 679 + setIsSearchFocused(false); 680 + }} 681 + > 682 + <div className="font-medium leading-none"> 683 + {event.title || t.unnamedEvent} 684 + </div> 685 + <div className="text-xs text-muted-foreground mt-1"> 686 + {formatDateDisplay(new Date(event.startDate))} 687 + </div> 688 + </button> 689 + ))} 690 + </div> 691 + </ScrollArea> 692 + ) : ( 693 + <div className="px-2 py-1.5 text-sm text-muted-foreground"> 694 + {t.noMatchingEvents} 695 + </div> 696 + )} 697 + </div> 698 + )} 699 + </div> 700 + <Button 701 + variant="outline" 702 + size="icon" 703 + className="rounded-full h-8 w-8" 704 + onClick={() => setView("analytics")} 705 + aria-label={t.analytics} 544 706 > 545 - <SelectTrigger className="w-[100px]"> 546 - <SelectValue /> 547 - </SelectTrigger> 548 - <SelectContent> 549 - <SelectGroup> 550 - <SelectItem value="day">{t.day}</SelectItem> 551 - <SelectItem value="week">{t.week}</SelectItem> 552 - <SelectItem value="month">{t.month}</SelectItem> 553 - <SelectItem value="year">{t.year}</SelectItem> 554 - <SelectItem value="four-day">{t.fourDay}</SelectItem> 555 - 556 - </SelectGroup> 557 - </SelectContent> 558 - </Select> 707 + <BarChart2 className="h-4 w-4" /> 708 + </Button> 709 + <Button 710 + variant="outline" 711 + size="icon" 712 + className="rounded-full h-8 w-8" 713 + onClick={() => setView("settings")} 714 + aria-label={t.settings} 715 + > 716 + <SettingsIcon className="h-4 w-4" /> 717 + </Button> 718 + <UserProfileButton 719 + variant="outline" 720 + className="rounded-full h-8 w-8" 721 + onNavigateToSettings={handleUserProfileSectionNavigate} 722 + /> 559 723 </div> 560 - <div className="relative z-50"> 561 - <Search className="pointer-events-none h-5 w-5 absolute left-2 top-1/2 transform -translate-y-1/2 text-gray-400" /> 562 - <Input 563 - type="text" 564 - placeholder={t.searchEvents} 565 - value={searchTerm} 566 - onFocus={() => setIsSearchFocused(true)} 567 - onBlur={() => { 568 - window.setTimeout(() => setIsSearchFocused(false), 120) 724 + </header> 725 + <div className="flex-1 overflow-auto" ref={calendarRef}> 726 + {view === "day" && ( 727 + <DayView 728 + date={date} 729 + events={filteredEvents} 730 + onEventClick={handleEventClick} 731 + onTimeSlotClick={handleTimeSlotClick} 732 + language={language} 733 + timezone={timezone} 734 + timeFormat={timeFormat} 735 + onEditEvent={handleEventEdit} 736 + onDeleteEvent={(event) => handleEventDelete(event.id)} 737 + onShareEvent={(event) => { 738 + setPreviewEvent(event); 739 + setPreviewOpen(true); 740 + setOpenShareImmediately(true); 569 741 }} 570 - onChange={(e) => setSearchTerm(e.target.value)} 571 - onKeyDown={(e) => { 572 - if (e.key === "Enter" && searchResultEvents.length > 0) { 573 - setPreviewEvent(searchResultEvents[0]) 574 - setPreviewOpen(true) 575 - setSearchTerm("") 576 - setIsSearchFocused(false) 577 - } 742 + onBookmarkEvent={toggleBookmark} 743 + onEventDrop={(event, newStartDate, newEndDate) => { 744 + const updatedEvent = { 745 + ...event, 746 + startDate: newStartDate, 747 + endDate: newEndDate, 748 + }; 749 + 750 + updateEvent(updatedEvent); 578 751 }} 579 - className="pl-9 pr-4 py-2 w-48" 580 752 /> 581 - {isSearchFocused && !!searchTerm && ( 582 - <div className="absolute right-0 top-[calc(100%+6px)] w-72 rounded-md border bg-popover p-1 shadow-md z-50"> 583 - {searchResultEvents.length > 0 ? ( 584 - <ScrollArea className="max-h-[320px]"> 585 - <div className="space-y-1"> 586 - {searchResultEvents.map((event) => ( 587 - <button 588 - key={event.id} 589 - type="button" 590 - className="w-full cursor-pointer rounded-sm px-2 py-1.5 text-left hover:bg-accent" 591 - onMouseDown={(e) => { 592 - e.preventDefault() 593 - setPreviewEvent(event) 594 - setPreviewOpen(true) 595 - setSearchTerm("") 596 - setIsSearchFocused(false) 597 - }} 598 - > 599 - <div className="font-medium leading-none">{event.title || t.unnamedEvent}</div> 600 - <div className="text-xs text-muted-foreground mt-1"> 601 - {formatDateDisplay(new Date(event.startDate))} 602 - </div> 603 - </button> 604 - ))} 605 - </div> 606 - </ScrollArea> 607 - ) : ( 608 - <div className="px-2 py-1.5 text-sm text-muted-foreground">{t.noMatchingEvents}</div> 609 - )} 610 - </div> 611 - )} 612 - </div> 613 - <Button 614 - variant="outline" 615 - size="icon" 616 - className="rounded-full h-8 w-8" 617 - onClick={() => setView("analytics")} 618 - aria-label={t.analytics} 619 - > 620 - <BarChart2 className="h-4 w-4" /> 621 - </Button> 622 - <Button 623 - variant="outline" 624 - size="icon" 625 - className="rounded-full h-8 w-8" 626 - onClick={() => setView("settings")} 627 - aria-label={t.settings} 628 - > 629 - <SettingsIcon className="h-4 w-4" /> 630 - </Button> 631 - <UserProfileButton 632 - variant="outline" 633 - className="rounded-full h-8 w-8" 634 - onNavigateToSettings={handleUserProfileSectionNavigate} 635 - /> 636 - </div> 637 - </header> 638 - <div className="flex-1 overflow-auto" ref={calendarRef}> 639 - {view === "day" && ( 640 - <DayView 641 - date={date} 642 - events={filteredEvents} 643 - onEventClick={handleEventClick} 644 - onTimeSlotClick={handleTimeSlotClick} 645 - language={language} 646 - timezone={timezone} 647 - timeFormat={timeFormat} 648 - onEditEvent={handleEventEdit} 649 - onDeleteEvent={(event) => handleEventDelete(event.id)} 650 - onShareEvent={(event) => { 651 - setPreviewEvent(event) 652 - setPreviewOpen(true) 653 - setOpenShareImmediately(true)}} 654 - onBookmarkEvent={toggleBookmark} 655 - onEventDrop={(event, newStartDate, newEndDate) => { 656 - const updatedEvent = { 657 - ...event, 658 - startDate: newStartDate, 659 - endDate: newEndDate 660 - } 661 - 662 - updateEvent(updatedEvent) 663 - }} 664 - /> 665 - )} 666 - {view === "week" && ( 667 - <WeekView 668 - date={date} 669 - events={filteredEvents} 670 - onEventClick={handleEventClick} 671 - onTimeSlotClick={handleTimeSlotClick} 672 - language={language} 673 - firstDayOfWeek={firstDayOfWeek} 674 - timezone={timezone} 675 - timeFormat={timeFormat} 676 - onEditEvent={handleEventEdit} 677 - onDeleteEvent={(event) => handleEventDelete(event.id)} 678 - onShareEvent={(event) => { 679 - setPreviewEvent(event) 680 - setPreviewOpen(true) 681 - setOpenShareImmediately(true)}} 682 - onBookmarkEvent={toggleBookmark} 683 - onEventDrop={(event, newStartDate, newEndDate) => { 684 - const updatedEvent = { 685 - ...event, 686 - startDate: newStartDate, 687 - endDate: newEndDate 688 - } 753 + )} 754 + {view === "week" && ( 755 + <WeekView 756 + date={date} 757 + events={filteredEvents} 758 + onEventClick={handleEventClick} 759 + onTimeSlotClick={handleTimeSlotClick} 760 + language={language} 761 + firstDayOfWeek={firstDayOfWeek} 762 + timezone={timezone} 763 + timeFormat={timeFormat} 764 + onEditEvent={handleEventEdit} 765 + onDeleteEvent={(event) => handleEventDelete(event.id)} 766 + onShareEvent={(event) => { 767 + setPreviewEvent(event); 768 + setPreviewOpen(true); 769 + setOpenShareImmediately(true); 770 + }} 771 + onBookmarkEvent={toggleBookmark} 772 + onEventDrop={(event, newStartDate, newEndDate) => { 773 + const updatedEvent = { 774 + ...event, 775 + startDate: newStartDate, 776 + endDate: newEndDate, 777 + }; 689 778 690 - updateEvent(updatedEvent) 691 - }} 692 - /> 693 - )} 694 - {view === "four-day" && ( 695 - <WeekView 696 - date={date} 697 - events={filteredEvents} 698 - onEventClick={handleEventClick} 699 - onTimeSlotClick={handleTimeSlotClick} 700 - language={language} 701 - firstDayOfWeek={firstDayOfWeek} 702 - timezone={timezone} 703 - timeFormat={timeFormat} 704 - onEditEvent={handleEventEdit} 705 - onDeleteEvent={(event) => handleEventDelete(event.id)} 706 - onShareEvent={(event) => { 707 - setPreviewEvent(event) 708 - setPreviewOpen(true) 709 - setOpenShareImmediately(true)}} 710 - onBookmarkEvent={toggleBookmark} 711 - onEventDrop={(event, newStartDate, newEndDate) => { 712 - const updatedEvent = { 713 - ...event, 714 - startDate: newStartDate, 715 - endDate: newEndDate 716 - } 779 + updateEvent(updatedEvent); 780 + }} 781 + /> 782 + )} 783 + {view === "four-day" && ( 784 + <WeekView 785 + date={date} 786 + events={filteredEvents} 787 + onEventClick={handleEventClick} 788 + onTimeSlotClick={handleTimeSlotClick} 789 + language={language} 790 + firstDayOfWeek={firstDayOfWeek} 791 + timezone={timezone} 792 + timeFormat={timeFormat} 793 + onEditEvent={handleEventEdit} 794 + onDeleteEvent={(event) => handleEventDelete(event.id)} 795 + onShareEvent={(event) => { 796 + setPreviewEvent(event); 797 + setPreviewOpen(true); 798 + setOpenShareImmediately(true); 799 + }} 800 + onBookmarkEvent={toggleBookmark} 801 + onEventDrop={(event, newStartDate, newEndDate) => { 802 + const updatedEvent = { 803 + ...event, 804 + startDate: newStartDate, 805 + endDate: newEndDate, 806 + }; 717 807 718 - updateEvent(updatedEvent) 719 - }} 720 - daysToShow={4} 721 - fixedStartDate={date} 722 - /> 723 - )} 724 - {view === "month" && ( 725 - <MonthView 726 - date={date} 727 - events={filteredEvents} 728 - onEventClick={handleEventClick} 729 - language={language} 730 - firstDayOfWeek={firstDayOfWeek} 731 - timezone={timezone} 732 - /> 733 - )} 734 - {view === "year" && ( 735 - <YearView 736 - date={date} 737 - events={filteredEvents} 738 - onEventClick={handleEventClick} 739 - language={language} 740 - firstDayOfWeek={firstDayOfWeek} 741 - isSidebarCollapsed={isSidebarCollapsed} 742 - isSidebarExpanding={isSidebarExpanding} 743 - /> 744 - )} 745 - {view === "analytics" && ( 746 - <AnalyticsView 747 - events={events} 748 - onCreateEvent={(startDate, endDate) => { 749 - setSelectedEvent(null) // ็กฎไฟๆ˜ฏๅˆ›ๅปบๆ–ฐไบ‹ไปถ 750 - setQuickCreateStartTime(startDate) 751 - setEventDialogOpen(true) 752 - }} 753 - /> 754 - )} 755 - {view === "settings" && ( 756 - <Settings 757 - language={language} 758 - setLanguage={setLanguage} 759 - firstDayOfWeek={firstDayOfWeek} 760 - setFirstDayOfWeek={setFirstDayOfWeek} 761 - timezone={timezone} 762 - setTimezone={setTimezone} 763 - notificationSound={notificationSound} 764 - setNotificationSound={setNotificationSound} 765 - defaultView={defaultView} 766 - setDefaultView={setDefaultView} 767 - enableShortcuts={enableShortcuts} 768 - setEnableShortcuts={setEnableShortcuts} 769 - timeFormat={timeFormat} 770 - setTimeFormat={setTimeFormat} 771 - events={events} 772 - onImportEvents={handleImportEvents} 773 - focusUserProfileSection={focusUserProfileSection} 774 - /> 775 - )} 808 + updateEvent(updatedEvent); 809 + }} 810 + daysToShow={4} 811 + fixedStartDate={date} 812 + /> 813 + )} 814 + {view === "month" && ( 815 + <MonthView 816 + date={date} 817 + events={filteredEvents} 818 + onEventClick={handleEventClick} 819 + language={language} 820 + firstDayOfWeek={firstDayOfWeek} 821 + timezone={timezone} 822 + /> 823 + )} 824 + {view === "year" && ( 825 + <YearView 826 + date={date} 827 + events={filteredEvents} 828 + onEventClick={handleEventClick} 829 + language={language} 830 + firstDayOfWeek={firstDayOfWeek} 831 + isSidebarCollapsed={isSidebarCollapsed} 832 + isSidebarExpanding={isSidebarExpanding} 833 + /> 834 + )} 835 + {view === "analytics" && ( 836 + <AnalyticsView 837 + events={events} 838 + onCreateEvent={(startDate, endDate) => { 839 + setSelectedEvent(null); 840 + setQuickCreateStartTime(startDate); 841 + setEventDialogOpen(true); 842 + }} 843 + /> 844 + )} 845 + {view === "settings" && ( 846 + <Settings 847 + language={language} 848 + setLanguage={setLanguage} 849 + firstDayOfWeek={firstDayOfWeek} 850 + setFirstDayOfWeek={setFirstDayOfWeek} 851 + timezone={timezone} 852 + setTimezone={setTimezone} 853 + notificationSound={notificationSound} 854 + setNotificationSound={setNotificationSound} 855 + defaultView={defaultView} 856 + setDefaultView={setDefaultView} 857 + enableShortcuts={enableShortcuts} 858 + setEnableShortcuts={setEnableShortcuts} 859 + timeFormat={timeFormat} 860 + setTimeFormat={setTimeFormat} 861 + events={events} 862 + onImportEvents={handleImportEvents} 863 + focusUserProfileSection={focusUserProfileSection} 864 + /> 865 + )} 866 + </div> 776 867 </div> 777 - </div> 778 868 779 - {/* ๅณไพง่พนๆ  - ็ŽฐๅœจไปŽ้กถ้ƒจๆ ไธ‹ๆ–นๅผ€ๅง‹ */} 780 - <RightSidebar onViewChange={handleViewChange} onEventClick={handleEventClick} /> 869 + {} 870 + <RightSidebar 871 + onViewChange={handleViewChange} 872 + onEventClick={handleEventClick} 873 + /> 781 874 782 - {/* ไฟๆŒๅŽŸๆœ‰็š„ๅฏน่ฏๆก†ๅ’Œๅ…ถไป–็ป„ไปถไธๅ˜ */} 783 - <EventPreview 784 - event={previewEvent} 785 - open={previewOpen} 786 - onOpenChange={(open) => { 787 - setPreviewOpen(open) 788 - if (!open) setOpenShareImmediately(false) 789 - }} 790 - onEdit={handleEventEdit} 791 - onDelete={() => { 792 - if (previewEvent) { 793 - handleEventDelete(previewEvent.id) 794 - setPreviewOpen(false) 795 - } 796 - }} 797 - onDuplicate={handleEventDuplicate} 798 - language={language} 799 - timezone={timezone} 800 - openShareImmediately={openShareImmediately} 801 - /> 875 + {} 876 + <EventPreview 877 + event={previewEvent} 878 + open={previewOpen} 879 + onOpenChange={(open) => { 880 + setPreviewOpen(open); 881 + if (!open) setOpenShareImmediately(false); 882 + }} 883 + onEdit={handleEventEdit} 884 + onDelete={() => { 885 + if (previewEvent) { 886 + handleEventDelete(previewEvent.id); 887 + setPreviewOpen(false); 888 + } 889 + }} 890 + onDuplicate={handleEventDuplicate} 891 + language={language} 892 + timezone={timezone} 893 + openShareImmediately={openShareImmediately} 894 + /> 802 895 803 - <EventDialog 804 - open={eventDialogOpen} 805 - onOpenChange={setEventDialogOpen} 806 - onEventAdd={handleEventAdd} 807 - onEventUpdate={handleEventUpdate} 808 - onEventDelete={handleEventDelete} 809 - initialDate={quickCreateStartTime || date} 810 - event={selectedEvent} 811 - language={language} 812 - timezone={timezone} 813 - /> 896 + <EventDialog 897 + open={eventDialogOpen} 898 + onOpenChange={setEventDialogOpen} 899 + onEventAdd={handleEventAdd} 900 + onEventUpdate={handleEventUpdate} 901 + onEventDelete={handleEventDelete} 902 + initialDate={quickCreateStartTime || date} 903 + event={selectedEvent} 904 + language={language} 905 + timezone={timezone} 906 + /> 814 907 815 - <DailyToast /> 908 + <DailyToast /> 816 909 817 - <AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> 818 - <AlertDialogContent> 819 - <AlertDialogHeader> 820 - <AlertDialogTitle>{t.deleteEventConfirmTitle}</AlertDialogTitle> 821 - <AlertDialogDescription> 822 - {t.deleteEventConfirmDescription} 823 - </AlertDialogDescription> 824 - </AlertDialogHeader> 825 - <AlertDialogFooter> 826 - <AlertDialogCancel onClick={() => setPendingDeleteEvent(null)}> 827 - {t.cancel} 828 - </AlertDialogCancel> 829 - <AlertDialogAction className="bg-destructive text-destructive-foreground" onClick={confirmEventDelete}> 830 - {t.delete} 831 - </AlertDialogAction> 832 - </AlertDialogFooter> 833 - </AlertDialogContent> 834 - </AlertDialog> 835 - </div> 910 + <AlertDialog 911 + open={deleteConfirmOpen} 912 + onOpenChange={setDeleteConfirmOpen} 913 + > 914 + <AlertDialogContent> 915 + <AlertDialogHeader> 916 + <AlertDialogTitle>{t.deleteEventConfirmTitle}</AlertDialogTitle> 917 + <AlertDialogDescription> 918 + {t.deleteEventConfirmDescription} 919 + </AlertDialogDescription> 920 + </AlertDialogHeader> 921 + <AlertDialogFooter> 922 + <AlertDialogCancel onClick={() => setPendingDeleteEvent(null)}> 923 + {t.cancel} 924 + </AlertDialogCancel> 925 + <AlertDialogAction 926 + className="bg-destructive text-destructive-foreground" 927 + onClick={confirmEventDelete} 928 + > 929 + {t.delete} 930 + </AlertDialogAction> 931 + </AlertDialogFooter> 932 + </AlertDialogContent> 933 + </AlertDialog> 934 + </div> 836 935 </div> 837 - ) 936 + ); 838 937 }
+396 -319
components/app/event/event-dialog.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 4 - import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" 5 - import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" 6 - import { format, parse, isValid, set, getHours, getMinutes } from "date-fns" 7 - import { ArrowRight, Calendar as CalendarIcon, Clock } from "lucide-react" 8 - import { isZhLanguage, translations, type Language } from "@/lib/i18n" 9 - import { useCalendar } from "@/components/providers/calendar-context" 10 - import { Checkbox } from "@/components/ui/checkbox" 11 - import { Textarea } from "@/components/ui/textarea" 12 - import { Calendar } from "@/components/ui/calendar" 13 - import { Button } from "@/components/ui/button" 14 - import { Input } from "@/components/ui/input" 15 - import { Label } from "@/components/ui/label" 16 - import { useState, useEffect } from "react" 17 - import { cn } from "@/lib/utils" 3 + import { 4 + Select, 5 + SelectContent, 6 + SelectItem, 7 + SelectTrigger, 8 + SelectValue, 9 + } from "@/components/ui/select"; 10 + import { 11 + Dialog, 12 + DialogContent, 13 + DialogHeader, 14 + DialogTitle, 15 + } from "@/components/ui/dialog"; 16 + import { 17 + Popover, 18 + PopoverContent, 19 + PopoverTrigger, 20 + } from "@/components/ui/popover"; 21 + import { format, parse, isValid, set, getHours, getMinutes } from "date-fns"; 22 + import { ArrowRight, Calendar as CalendarIcon, Clock } from "lucide-react"; 23 + import { isZhLanguage, translations, type Language } from "@/lib/i18n"; 24 + import { useCalendar } from "@/components/providers/calendar-context"; 25 + import { Checkbox } from "@/components/ui/checkbox"; 26 + import { Textarea } from "@/components/ui/textarea"; 27 + import { Calendar } from "@/components/ui/calendar"; 28 + import { Button } from "@/components/ui/button"; 29 + import { Input } from "@/components/ui/input"; 30 + import { Label } from "@/components/ui/label"; 31 + import { useState, useEffect } from "react"; 32 + import { cn } from "@/lib/utils"; 18 33 19 34 const colorOptions = [ 20 - { value: "bg-[#E6F6FD]", label: "Blue", calendarColor: "bg-blue-500" }, 21 - { value: "bg-[#E7F8F2]", label: "Green", calendarColor: "bg-green-500" }, 22 - { value: "bg-[#FEF5E6]", label: "Amber", calendarColor: "bg-yellow-500" }, 23 - { value: "bg-[#FFE4E6]", label: "Red", calendarColor: "bg-red-500" }, 24 - { value: "bg-[#F3EEFE]", label: "Purple", calendarColor: "bg-purple-500" }, 25 - { value: "bg-[#FCE7F3]", label: "Pink", calendarColor: "bg-pink-500" }, 26 - { value: "bg-[#E6FAF7]", label: "Teal", calendarColor: "bg-teal-500" }, 27 - ] 35 + { 36 + value: "bg-[#E6F6FD]", 37 + labelKey: "colorBlue" as const, 38 + calendarColor: "bg-blue-500", 39 + }, 40 + { 41 + value: "bg-[#E7F8F2]", 42 + labelKey: "colorGreen" as const, 43 + calendarColor: "bg-green-500", 44 + }, 45 + { 46 + value: "bg-[#FEF5E6]", 47 + labelKey: "colorAmber" as const, 48 + calendarColor: "bg-yellow-500", 49 + }, 50 + { 51 + value: "bg-[#FFE4E6]", 52 + labelKey: "colorRed" as const, 53 + calendarColor: "bg-red-500", 54 + }, 55 + { 56 + value: "bg-[#F3EEFE]", 57 + labelKey: "colorPurple" as const, 58 + calendarColor: "bg-purple-500", 59 + }, 60 + { 61 + value: "bg-[#FCE7F3]", 62 + labelKey: "colorPink" as const, 63 + calendarColor: "bg-pink-500", 64 + }, 65 + { 66 + value: "bg-[#E6FAF7]", 67 + labelKey: "colorTeal" as const, 68 + calendarColor: "bg-teal-500", 69 + }, 70 + ]; 28 71 29 - const calendarColorToEventColor = Object.fromEntries(colorOptions.map((option) => [option.calendarColor, option.value])) 72 + const calendarColorToEventColor = Object.fromEntries( 73 + colorOptions.map((option) => [option.calendarColor, option.value]), 74 + ); 30 75 31 76 const colorMapping: Record<string, string> = { 32 - 'bg-[#E6F6FD]': '#3B82F6', 33 - 'bg-[#E7F8F2]': '#10B981', 34 - 'bg-[#FEF5E6]': '#F59E0B', 35 - 'bg-[#FFE4E6]': '#EF4444', 36 - 'bg-[#F3EEFE]': '#8B5CF6', 37 - 'bg-[#FCE7F3]': '#EC4899', 38 - 'bg-[#E6FAF7]': '#14B8A6', 39 - } 77 + "bg-[#E6F6FD]": "#3B82F6", 78 + "bg-[#E7F8F2]": "#10B981", 79 + "bg-[#FEF5E6]": "#F59E0B", 80 + "bg-[#FFE4E6]": "#EF4444", 81 + "bg-[#F3EEFE]": "#8B5CF6", 82 + "bg-[#FCE7F3]": "#EC4899", 83 + "bg-[#E6FAF7]": "#14B8A6", 84 + }; 40 85 41 - // ็”Ÿๆˆๅฐๆ—ถ้€‰้กน (0-23) 42 86 const hourOptions = Array.from({ length: 24 }, (_, i) => ({ 43 - value: i.toString().padStart(2, '0'), 44 - label: i.toString().padStart(2, '0') 45 - })) 87 + value: i.toString().padStart(2, "0"), 88 + label: i.toString().padStart(2, "0"), 89 + })); 46 90 47 - // ็”Ÿๆˆๅˆ†้’Ÿ้€‰้กน (0, 5, 10, 15, ..., 55) 48 91 const minuteOptions = Array.from({ length: 12 }, (_, i) => ({ 49 - value: (i * 5).toString().padStart(2, '0'), 50 - label: (i * 5).toString().padStart(2, '0') 51 - })) 92 + value: (i * 5).toString().padStart(2, "0"), 93 + label: (i * 5).toString().padStart(2, "0"), 94 + })); 52 95 53 96 interface EventDialogProps { 54 - open: boolean 55 - onOpenChange: (open: boolean) => void 56 - onEventAdd: (event: CalendarEvent) => void 57 - onEventUpdate: (event: CalendarEvent) => void 58 - onEventDelete: (eventId: string) => void 59 - initialDate: Date 60 - event: CalendarEvent | null 61 - language: Language 62 - timezone: string 97 + open: boolean; 98 + onOpenChange: (open: boolean) => void; 99 + onEventAdd: (event: CalendarEvent) => void; 100 + onEventUpdate: (event: CalendarEvent) => void; 101 + onEventDelete: (eventId: string) => void; 102 + initialDate: Date; 103 + event: CalendarEvent | null; 104 + language: Language; 105 + timezone: string; 63 106 } 64 107 65 108 interface TimeInput { ··· 80 123 language, 81 124 timezone, 82 125 }: EventDialogProps) { 83 - const { calendars } = useCalendar() 84 - const [title, setTitle] = useState("") 85 - const [isAllDay, setIsAllDay] = useState(false) 86 - 87 - // ๆ—ฅๆœŸ้€‰ๆ‹ฉ 88 - const [startDate, setStartDate] = useState(initialDate) 89 - const [endDate, setEndDate] = useState(initialDate) 90 - 91 - // ๆ—ถ้—ด้€‰ๆ‹ฉ 126 + const { calendars } = useCalendar(); 127 + const [title, setTitle] = useState(""); 128 + const [isAllDay, setIsAllDay] = useState(false); 129 + 130 + const [startDate, setStartDate] = useState(initialDate); 131 + const [endDate, setEndDate] = useState(initialDate); 132 + 92 133 const [startTime, setStartTime] = useState<TimeInput>({ 93 134 hours: "00", 94 135 minutes: "00", 95 136 rawInput: "", 96 - isCustomInput: false 97 - }) 137 + isCustomInput: false, 138 + }); 98 139 const [endTime, setEndTime] = useState<TimeInput>({ 99 140 hours: "00", 100 141 minutes: "30", 101 142 rawInput: "", 102 - isCustomInput: false 103 - }) 104 - 105 - // ๆ—ถ้—ด้€‰ๆ‹ฉ็š„ๅผนๅ‡บ็Šถๆ€ 106 - const [startTimeOpen, setStartTimeOpen] = useState(false) 107 - const [endTimeOpen, setEndTimeOpen] = useState(false) 108 - 109 - // ๆ—ฅๆœŸ้€‰ๆ‹ฉ็š„ๅผนๅ‡บ็Šถๆ€ 110 - const [startDateOpen, setStartDateOpen] = useState(false) 111 - const [endDateOpen, setEndDateOpen] = useState(false) 112 - 113 - const [location, setLocation] = useState("") 114 - const [participants, setParticipants] = useState("") 115 - const [notification, setNotification] = useState("0") 116 - const [customNotificationTime, setCustomNotificationTime] = useState("10") 117 - const [description, setDescription] = useState("") 118 - const [color, setColor] = useState(colorOptions[0].value) 119 - const [selectedCalendar, setSelectedCalendar] = useState("") 120 - const [aiPrompt, setAiPrompt] = useState("") 121 - const [isAiLoading, setIsAiLoading] = useState(false) 122 - 123 - // ๆ—ถ้—ดๆ ผๅผ้”™่ฏฏ็Šถๆ€ 124 - const [startTimeError, setStartTimeError] = useState(false) 125 - const [endTimeError, setEndTimeError] = useState(false) 143 + isCustomInput: false, 144 + }); 145 + 146 + const [startTimeOpen, setStartTimeOpen] = useState(false); 147 + const [endTimeOpen, setEndTimeOpen] = useState(false); 148 + 149 + const [startDateOpen, setStartDateOpen] = useState(false); 150 + const [endDateOpen, setEndDateOpen] = useState(false); 126 151 127 - const t = translations[language] 128 - const isZh = isZhLanguage(language) 129 - const calendarSelectValue = selectedCalendar || (calendars.length > 0 ? "__uncategorized__" : "") 130 - const getEventColorByCalendarId = (calendarId: string) => { 131 - const calendar = calendars.find((item) => item.id === calendarId) 132 - if (!calendar) return colorOptions[0].value 133 - return calendarColorToEventColor[calendar.color] ?? colorOptions[0].value 134 - } 152 + const [location, setLocation] = useState(""); 153 + const [participants, setParticipants] = useState(""); 154 + const [notification, setNotification] = useState("0"); 155 + const [customNotificationTime, setCustomNotificationTime] = useState("10"); 156 + const [description, setDescription] = useState(""); 157 + const [color, setColor] = useState(colorOptions[0].value); 158 + const [selectedCalendar, setSelectedCalendar] = useState(""); 159 + const [aiPrompt, setAiPrompt] = useState(""); 160 + const [isAiLoading, setIsAiLoading] = useState(false); 135 161 162 + const [startTimeError, setStartTimeError] = useState(false); 163 + const [endTimeError, setEndTimeError] = useState(false); 136 164 137 - // ๅˆๅนถๆ—ฅๆœŸๅ’Œๆ—ถ้—ด 165 + const t = translations[language]; 166 + const isZh = isZhLanguage(language); 167 + const calendarSelectValue = 168 + selectedCalendar || (calendars.length > 0 ? "__uncategorized__" : ""); 169 + const getEventColorByCalendarId = (calendarId: string) => { 170 + const calendar = calendars.find((item) => item.id === calendarId); 171 + if (!calendar) return colorOptions[0].value; 172 + return calendarColorToEventColor[calendar.color] ?? colorOptions[0].value; 173 + }; 174 + 138 175 const combineDateTime = (date: Date, timeInput: TimeInput): Date => { 139 176 if (timeInput.isCustomInput && timeInput.rawInput) { 140 - // ๅฐ่ฏ•่งฃๆž่‡ชๅฎšไน‰่พ“ๅ…ฅ็š„ๆ—ถ้—ด (ๆ ผๅผ: HH:mm ๆˆ– H:m) 141 - const timeParts = timeInput.rawInput.split(':'); 177 + const timeParts = timeInput.rawInput.split(":"); 142 178 if (timeParts.length === 2) { 143 179 const hours = parseInt(timeParts[0], 10); 144 180 const minutes = parseInt(timeParts[1], 10); 145 - 146 - if (!isNaN(hours) && !isNaN(minutes) && hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60) { 147 - return set(new Date(date), { hours, minutes, seconds: 0, milliseconds: 0 }); 181 + 182 + if ( 183 + !isNaN(hours) && 184 + !isNaN(minutes) && 185 + hours >= 0 && 186 + hours < 24 && 187 + minutes >= 0 && 188 + minutes < 60 189 + ) { 190 + return set(new Date(date), { 191 + hours, 192 + minutes, 193 + seconds: 0, 194 + milliseconds: 0, 195 + }); 148 196 } 149 197 } 150 - // ๅฆ‚ๆžœ่งฃๆžๅคฑ่ดฅ๏ผŒๅ›ž้€€ๅˆฐ้€‰ๆ‹ฉ็š„ๆ—ถ้—ด 151 - return set(new Date(date), { 152 - hours: parseInt(timeInput.hours, 10), 198 + 199 + return set(new Date(date), { 200 + hours: parseInt(timeInput.hours, 10), 153 201 minutes: parseInt(timeInput.minutes, 10), 154 - seconds: 0, 155 - milliseconds: 0 202 + seconds: 0, 203 + milliseconds: 0, 156 204 }); 157 205 } 158 - 159 - // ไฝฟ็”จ้€‰ๆ‹ฉ็š„ๆ—ถ้—ด 160 - return set(new Date(date), { 161 - hours: parseInt(timeInput.hours, 10), 206 + 207 + return set(new Date(date), { 208 + hours: parseInt(timeInput.hours, 10), 162 209 minutes: parseInt(timeInput.minutes, 10), 163 - seconds: 0, 164 - milliseconds: 0 210 + seconds: 0, 211 + milliseconds: 0, 165 212 }); 166 213 }; 167 214 168 - // ่Žทๅ–ๅฎŒๆ•ด็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ฅๆœŸๆ—ถ้—ด 169 215 const getFullStartDate = () => combineDateTime(startDate, startTime); 170 216 const getFullEndDate = () => combineDateTime(endDate, endTime); 171 217 172 - // ้ชŒ่ฏๆ—ถ้—ดๆ ผๅผ 173 218 const validateTimeFormat = (input: string): boolean => { 174 219 if (!input) return false; 175 - 176 - const timeParts = input.split(':'); 220 + 221 + const timeParts = input.split(":"); 177 222 if (timeParts.length !== 2) return false; 178 - 223 + 179 224 const hours = parseInt(timeParts[0], 10); 180 225 const minutes = parseInt(timeParts[1], 10); 181 - 182 - return !isNaN(hours) && !isNaN(minutes) && 183 - hours >= 0 && hours < 24 && 184 - minutes >= 0 && minutes < 60; 226 + 227 + return ( 228 + !isNaN(hours) && 229 + !isNaN(minutes) && 230 + hours >= 0 && 231 + hours < 24 && 232 + minutes >= 0 && 233 + minutes < 60 234 + ); 185 235 }; 186 236 187 - // ๅค„็†ๅผ€ๅง‹ๆ—ถ้—ด็š„่‡ชๅฎšไน‰่พ“ๅ…ฅ 188 237 const handleStartTimeInput = (input: string) => { 189 - setStartTime(prev => ({ 238 + setStartTime((prev) => ({ 190 239 ...prev, 191 240 rawInput: input, 192 - isCustomInput: true 241 + isCustomInput: true, 193 242 })); 194 - 243 + 195 244 if (input === "" || validateTimeFormat(input)) { 196 245 setStartTimeError(false); 197 246 } else { ··· 199 248 } 200 249 }; 201 250 202 - // ๅค„็†็ป“ๆŸๆ—ถ้—ด็š„่‡ชๅฎšไน‰่พ“ๅ…ฅ 203 251 const handleEndTimeInput = (input: string) => { 204 - setEndTime(prev => ({ 252 + setEndTime((prev) => ({ 205 253 ...prev, 206 254 rawInput: input, 207 - isCustomInput: true 255 + isCustomInput: true, 208 256 })); 209 - 257 + 210 258 if (input === "" || validateTimeFormat(input)) { 211 259 setEndTimeError(false); 212 260 } else { ··· 214 262 } 215 263 }; 216 264 217 - // ไปŽๆ—ฅๆœŸๅฏน่ฑกไธญๆๅ–ๆ—ถ้—ดไฟกๆฏ 218 265 const extractTimeFromDate = (date: Date): TimeInput => { 219 266 return { 220 - hours: getHours(date).toString().padStart(2, '0'), 221 - minutes: getMinutes(date).toString().padStart(2, '0'), 222 - rawInput: format(date, 'HH:mm'), 223 - isCustomInput: false 267 + hours: getHours(date).toString().padStart(2, "0"), 268 + minutes: getMinutes(date).toString().padStart(2, "0"), 269 + rawInput: format(date, "HH:mm"), 270 + isCustomInput: false, 224 271 }; 225 272 }; 226 273 227 274 useEffect(() => { 228 275 if (open) { 229 276 if (event) { 230 - setTitle(event.title) 231 - setIsAllDay(event.isAllDay) 232 - 277 + setTitle(event.title); 278 + setIsAllDay(event.isAllDay); 279 + 233 280 const startDateObj = new Date(event.startDate); 234 281 const endDateObj = new Date(event.endDate); 235 - 236 - setStartDate(startDateObj) 237 - setEndDate(endDateObj) 238 - setStartTime(extractTimeFromDate(startDateObj)) 239 - setEndTime(extractTimeFromDate(endDateObj)) 240 - 241 - setLocation(event.location || "") 242 - setParticipants(event.participants.join(", ")) 282 + 283 + setStartDate(startDateObj); 284 + setEndDate(endDateObj); 285 + setStartTime(extractTimeFromDate(startDateObj)); 286 + setEndTime(extractTimeFromDate(endDateObj)); 287 + 288 + setLocation(event.location || ""); 289 + setParticipants(event.participants.join(", ")); 243 290 if (event.notification !== undefined) { 244 291 if ( 245 292 event.notification > 0 && ··· 248 295 event.notification !== 30 && 249 296 event.notification !== 60 250 297 ) { 251 - setNotification("custom") 252 - setCustomNotificationTime(event.notification.toString()) 298 + setNotification("custom"); 299 + setCustomNotificationTime(event.notification.toString()); 253 300 } else { 254 - setNotification(event.notification.toString()) 301 + setNotification(event.notification.toString()); 255 302 } 256 303 } else { 257 - setNotification("0") 304 + setNotification("0"); 258 305 } 259 - setDescription(event.description || "") 260 - setColor(event.color) 261 - setSelectedCalendar(event.calendarId || "") 306 + setDescription(event.description || ""); 307 + setColor(event.color); 308 + setSelectedCalendar(event.calendarId || ""); 262 309 } else { 263 - resetForm() 310 + resetForm(); 264 311 if (initialDate) { 265 - setStartDate(initialDate) 312 + setStartDate(initialDate); 266 313 if (calendars.length > 0) { 267 - setColor(getEventColorByCalendarId(calendars[0].id)) 314 + setColor(getEventColorByCalendarId(calendars[0].id)); 268 315 } 269 - setEndDate(initialDate) 270 - 316 + setEndDate(initialDate); 317 + 271 318 const initialHour = getHours(initialDate); 272 319 const initialMinute = getMinutes(initialDate); 273 320 const endTime = new Date(initialDate); 274 321 endTime.setMinutes(initialMinute + 30); 275 - 322 + 276 323 setStartTime({ 277 - hours: initialHour.toString().padStart(2, '0'), 278 - minutes: initialMinute.toString().padStart(2, '0'), 279 - rawInput: format(initialDate, 'HH:mm'), 280 - isCustomInput: false 324 + hours: initialHour.toString().padStart(2, "0"), 325 + minutes: initialMinute.toString().padStart(2, "0"), 326 + rawInput: format(initialDate, "HH:mm"), 327 + isCustomInput: false, 281 328 }); 282 - 329 + 283 330 setEndTime({ 284 - hours: getHours(endTime).toString().padStart(2, '0'), 285 - minutes: getMinutes(endTime).toString().padStart(2, '0'), 286 - rawInput: format(endTime, 'HH:mm'), 287 - isCustomInput: false 331 + hours: getHours(endTime).toString().padStart(2, "0"), 332 + minutes: getMinutes(endTime).toString().padStart(2, "0"), 333 + rawInput: format(endTime, "HH:mm"), 334 + isCustomInput: false, 288 335 }); 289 336 } 290 337 } 291 338 } 292 - }, [event, calendars, initialDate, open]) 339 + }, [event, calendars, initialDate, open]); 293 340 294 341 const resetForm = () => { 295 - const now = new Date() 296 - const thirtyMinutesLater = new Date(now.getTime() + 30 * 60000) 297 - 298 - setTitle("") 299 - setIsAllDay(false) 300 - setStartDate(now) 301 - setEndDate(now) 302 - setStartTime(extractTimeFromDate(now)) 303 - setEndTime(extractTimeFromDate(thirtyMinutesLater)) 304 - setLocation("") 305 - setParticipants("") 306 - setNotification("0") 307 - setCustomNotificationTime("10") 308 - setDescription("") 309 - setColor(colorOptions[0].value) 310 - setSelectedCalendar("") 311 - setStartTimeError(false) 312 - setEndTimeError(false) 313 - } 342 + const now = new Date(); 343 + const thirtyMinutesLater = new Date(now.getTime() + 30 * 60000); 314 344 315 - // ๆ›ดๆ–ฐๅผ€ๅง‹ๆ—ฅๆœŸๆ—ถ๏ผŒๅฆ‚ๆžœ็ป“ๆŸๆ—ฅๆœŸๆ—ฉไบŽๅผ€ๅง‹ๆ—ฅๆœŸ๏ผŒๆ›ดๆ–ฐ็ป“ๆŸๆ—ฅๆœŸ 345 + setTitle(""); 346 + setIsAllDay(false); 347 + setStartDate(now); 348 + setEndDate(now); 349 + setStartTime(extractTimeFromDate(now)); 350 + setEndTime(extractTimeFromDate(thirtyMinutesLater)); 351 + setLocation(""); 352 + setParticipants(""); 353 + setNotification("0"); 354 + setCustomNotificationTime("10"); 355 + setDescription(""); 356 + setColor(colorOptions[0].value); 357 + setSelectedCalendar(""); 358 + setStartTimeError(false); 359 + setEndTimeError(false); 360 + }; 361 + 316 362 const handleStartDateChange = (newDate: Date | undefined) => { 317 363 if (!newDate) return; 318 - 364 + 319 365 setStartDate(newDate); 320 - 321 - // ่Žทๅ–ๅฎŒๆ•ด็š„ๆ—ฅๆœŸๆ—ถ้—ด 366 + 322 367 const fullNewStartDate = combineDateTime(newDate, startTime); 323 368 const fullCurrentEndDate = getFullEndDate(); 324 - 325 - // ๅฆ‚ๆžœ็ป“ๆŸๆ—ฅๆœŸๆ—ฉไบŽๅผ€ๅง‹ๆ—ฅๆœŸ๏ผŒๅˆ™ๆ›ดๆ–ฐ็ป“ๆŸๆ—ฅๆœŸ 369 + 326 370 if (fullCurrentEndDate < fullNewStartDate) { 327 371 const newEndDate = new Date(fullNewStartDate); 328 372 newEndDate.setMinutes(newEndDate.getMinutes() + 30); 329 - 373 + 330 374 setEndDate(newDate); 331 375 setEndTime(extractTimeFromDate(newEndDate)); 332 376 } 333 377 }; 334 378 335 - // ๆ›ดๆ–ฐๅผ€ๅง‹ๆ—ถ้—ดๆ—ถ๏ผŒๆ›ดๆ–ฐ็ป“ๆŸๆ—ถ้—ด 336 379 const handleStartTimeChange = (hours: string, minutes: string) => { 337 380 setStartTime({ 338 381 hours, 339 382 minutes, 340 383 rawInput: `${hours}:${minutes}`, 341 - isCustomInput: false 384 + isCustomInput: false, 342 385 }); 343 - 344 - // ่Žทๅ–ๆ–ฐ็š„ๅผ€ๅง‹ๆ—ถ้—ด 386 + 345 387 const newStartDate = set(new Date(startDate), { 346 388 hours: parseInt(hours), 347 389 minutes: parseInt(minutes), 348 390 seconds: 0, 349 - milliseconds: 0 391 + milliseconds: 0, 350 392 }); 351 - 352 - // ่Žทๅ–ๅฝ“ๅ‰็ป“ๆŸๆ—ถ้—ด 393 + 353 394 const currentEndDate = getFullEndDate(); 354 - 355 - // ๅฆ‚ๆžœ็ป“ๆŸๆ—ถ้—ดๆ—ฉไบŽๆˆ–็ญ‰ไบŽๅผ€ๅง‹ๆ—ถ้—ด๏ผŒๅˆ™่ฐƒๆ•ด็ป“ๆŸๆ—ถ้—ดไธบๅผ€ๅง‹ๆ—ถ้—ด+30ๅˆ†้’Ÿ 395 + 356 396 if (currentEndDate <= newStartDate) { 357 397 const newEndDate = new Date(newStartDate); 358 398 newEndDate.setMinutes(newStartDate.getMinutes() + 30); 359 - 399 + 360 400 setEndTime(extractTimeFromDate(newEndDate)); 361 - 362 - // ๅฆ‚ๆžœๅผ€ๅง‹ๆ—ถ้—ดๅ’Œ็ป“ๆŸๆ—ถ้—ดๆ˜ฏไธๅŒ็š„ๆ—ฅๆœŸ๏ผŒๅˆ™ๆ›ดๆ–ฐ็ป“ๆŸๆ—ฅๆœŸ 363 - if (endDate.getDate() !== startDate.getDate() || 364 - endDate.getMonth() !== startDate.getMonth() || 365 - endDate.getFullYear() !== startDate.getFullYear()) { 401 + 402 + if ( 403 + endDate.getDate() !== startDate.getDate() || 404 + endDate.getMonth() !== startDate.getMonth() || 405 + endDate.getFullYear() !== startDate.getFullYear() 406 + ) { 366 407 setEndDate(startDate); 367 408 } 368 409 } 369 410 }; 370 411 371 - // ้ข„ๅค„็†ๆไบคๅ‰็š„ๆ•ฐๆฎ้ชŒ่ฏ 372 412 const validateForm = (): boolean => { 373 - // ้ชŒ่ฏๅผ€ๅง‹ๆ—ถ้—ดๆ ผๅผ 374 413 if (startTime.isCustomInput && !validateTimeFormat(startTime.rawInput)) { 375 414 setStartTimeError(true); 376 415 return false; 377 416 } 378 - 379 - // ้ชŒ่ฏ็ป“ๆŸๆ—ถ้—ดๆ ผๅผ 417 + 380 418 if (endTime.isCustomInput && !validateTimeFormat(endTime.rawInput)) { 381 419 setEndTimeError(true); 382 420 return false; 383 421 } 384 - 385 - // ้ชŒ่ฏ็ป“ๆŸๆ—ถ้—ดไธๆ—ฉไบŽๅผ€ๅง‹ๆ—ถ้—ด 422 + 386 423 const fullStartDate = getFullStartDate(); 387 424 const fullEndDate = getFullEndDate(); 388 - 425 + 389 426 if (fullEndDate < fullStartDate) { 390 427 setEndTimeError(true); 391 428 alert(t.endTimeError); 392 429 return false; 393 430 } 394 - 431 + 395 432 return true; 396 433 }; 397 434 398 435 const handleSubmit = (e: React.FormEvent) => { 399 436 e.preventDefault(); 400 - 401 - // ้ชŒ่ฏ่กจๅ•ๆ•ฐๆฎ 437 + 402 438 if (!validateForm()) { 403 439 return; 404 440 } 405 - 441 + 406 442 let notificationMinutes = Number.parseInt(notification); 407 443 if (notification === "custom") { 408 444 notificationMinutes = Number.parseInt(customNotificationTime); ··· 412 448 const fullEndDate = getFullEndDate(); 413 449 414 450 const eventData: CalendarEvent = { 415 - id: event?.id || Date.now().toString() + Math.random().toString(36).substring(2, 9), 451 + id: 452 + event?.id || 453 + Date.now().toString() + Math.random().toString(36).substring(2, 9), 416 454 title: title.trim() || t.untitledInParentheses, 417 455 isAllDay, 418 456 startDate: fullStartDate, ··· 426 464 notification: notificationMinutes, 427 465 description, 428 466 color, 429 - calendarId: selectedCalendar === "__uncategorized__" ? "" : selectedCalendar, 430 - } 467 + calendarId: 468 + selectedCalendar === "__uncategorized__" ? "" : selectedCalendar, 469 + }; 431 470 432 471 if (event) { 433 - onEventUpdate(eventData) 472 + onEventUpdate(eventData); 434 473 } else { 435 - onEventAdd(eventData) 474 + onEventAdd(eventData); 436 475 } 437 - onOpenChange(false) 438 - } 476 + onOpenChange(false); 477 + }; 439 478 440 479 const handleAiSubmit = async () => { 441 - if (!aiPrompt.trim()) return 442 - setIsAiLoading(true) 443 - 480 + if (!aiPrompt.trim()) return; 481 + setIsAiLoading(true); 482 + 444 483 try { 445 - const response = await fetch('/api/chat/schedule', { 446 - method: 'POST', 484 + const response = await fetch("/api/chat/schedule", { 485 + method: "POST", 447 486 headers: { 448 - 'Content-Type': 'application/json', 487 + "Content-Type": "application/json", 449 488 }, 450 489 body: JSON.stringify({ 451 490 prompt: aiPrompt, ··· 455 494 endDate: format(getFullEndDate(), "yyyy-MM-dd'T'HH:mm"), 456 495 location, 457 496 participants, 458 - description 497 + description, 459 498 }, 460 499 }), 461 - }) 500 + }); 462 501 463 - if (!response.ok) throw new Error('AI่ฏทๆฑ‚ๅคฑ่ดฅ') 464 - 465 - const result = await response.json() 502 + if (!response.ok) throw new Error("AI่ฏทๆฑ‚ๅคฑ่ดฅ"); 503 + 504 + const result = await response.json(); 466 505 if (result.data) { 467 - const { title: newTitle, startDate: newStart, endDate: newEnd, location: newLocation, participants: newParticipants, description: newDescription } = result.data 468 - 469 - if (newTitle) setTitle(newTitle) 470 - 506 + const { 507 + title: newTitle, 508 + startDate: newStart, 509 + endDate: newEnd, 510 + location: newLocation, 511 + participants: newParticipants, 512 + description: newDescription, 513 + } = result.data; 514 + 515 + if (newTitle) setTitle(newTitle); 516 + 471 517 if (newStart) { 472 518 const startDateObj = new Date(newStart); 473 519 setStartDate(startDateObj); 474 520 setStartTime(extractTimeFromDate(startDateObj)); 475 521 } 476 - 522 + 477 523 if (newEnd) { 478 524 const endDateObj = new Date(newEnd); 479 525 setEndDate(endDateObj); 480 526 setEndTime(extractTimeFromDate(endDateObj)); 481 527 } 482 - 483 - if (newLocation) setLocation(newLocation) 484 - if (newParticipants) setParticipants(newParticipants) 485 - if (newDescription) setDescription(newDescription) 528 + 529 + if (newLocation) setLocation(newLocation); 530 + if (newParticipants) setParticipants(newParticipants); 531 + if (newDescription) setDescription(newDescription); 486 532 } 487 533 } catch (error) { 488 - console.error('AI้”™่ฏฏ:', error) 534 + console.error("AI้”™่ฏฏ:", error); 489 535 } finally { 490 - setIsAiLoading(false) 536 + setIsAiLoading(false); 491 537 } 492 - } 538 + }; 493 539 494 - // ๆธฒๆŸ“ๆ—ถ้—ด้€‰ๆ‹ฉUI 495 540 const renderTimeSelector = ( 496 - value: TimeInput, 541 + value: TimeInput, 497 542 onChange: (hours: string, minutes: string) => void, 498 543 onCustomInput: (input: string) => void, 499 544 isOpen: boolean, 500 545 setOpen: (open: boolean) => void, 501 - hasError: boolean 546 + hasError: boolean, 502 547 ) => { 503 - const displayTime = value.isCustomInput 504 - ? value.rawInput 548 + const displayTime = value.isCustomInput 549 + ? value.rawInput 505 550 : `${value.hours}:${value.minutes}`; 506 - 551 + 507 552 return ( 508 553 <Popover open={isOpen} onOpenChange={setOpen}> 509 554 <PopoverTrigger asChild> ··· 512 557 className={cn( 513 558 "w-auto justify-start text-left font-normal", 514 559 hasError && "border-red-500 text-red-500", 515 - !displayTime && "text-muted-foreground" 560 + !displayTime && "text-muted-foreground", 516 561 )} 517 562 > 518 563 <Clock className="mr-2 h-4 w-4" /> ··· 522 567 <PopoverContent className="w-auto p-0" align="start"> 523 568 <div className="p-3 space-y-3"> 524 569 <div className="flex items-center space-x-2"> 525 - <Select 526 - value={value.hours} 570 + <Select 571 + value={value.hours} 527 572 onValueChange={(newHour) => onChange(newHour, value.minutes)} 528 573 > 529 574 <SelectTrigger className="w-[70px]"> 530 575 <SelectValue placeholder={isZh ? "ๆ—ถ" : "Hour"} /> 531 576 </SelectTrigger> 532 577 <SelectContent> 533 - {hourOptions.map(option => ( 578 + {hourOptions.map((option) => ( 534 579 <SelectItem key={option.value} value={option.value}> 535 580 {option.label} 536 581 </SelectItem> 537 582 ))} 538 583 </SelectContent> 539 584 </Select> 540 - 585 + 541 586 <span className="text-center">:</span> 542 - 543 - <Select 544 - value={value.minutes} 587 + 588 + <Select 589 + value={value.minutes} 545 590 onValueChange={(newMinute) => onChange(value.hours, newMinute)} 546 591 > 547 592 <SelectTrigger className="w-[70px]"> 548 593 <SelectValue placeholder={isZh ? "ๅˆ†" : "Min"} /> 549 594 </SelectTrigger> 550 595 <SelectContent> 551 - {minuteOptions.map(option => ( 596 + {minuteOptions.map((option) => ( 552 597 <SelectItem key={option.value} value={option.value}> 553 598 {option.label} 554 599 </SelectItem> ··· 556 601 </SelectContent> 557 602 </Select> 558 603 </div> 559 - 604 + 560 605 <div className="flex flex-col space-y-1"> 561 606 <Label htmlFor="custom-time"> 562 607 {isZh ? "่‡ชๅฎšไน‰ๆ—ถ้—ด (HH:mm)" : "Custom time (HH:mm)"} 563 608 </Label> 564 - <Input 609 + <Input 565 610 id="custom-time" 566 611 value={value.isCustomInput ? value.rawInput : ""} 567 612 onChange={(e) => onCustomInput(e.target.value)} ··· 570 615 /> 571 616 {hasError && ( 572 617 <p className="text-xs text-red-500"> 573 - {isZh ? "่ฏทไฝฟ็”จๆญฃ็กฎ็š„ๆ ผๅผ (HH:mm)" : "Please use the correct format (HH:mm)"} 618 + {isZh 619 + ? "่ฏทไฝฟ็”จๆญฃ็กฎ็š„ๆ ผๅผ (HH:mm)" 620 + : "Please use the correct format (HH:mm)"} 574 621 </p> 575 622 )} 576 623 </div> ··· 591 638 <form onSubmit={handleSubmit} className="space-y-4 pb-6"> 592 639 <div> 593 640 <Label htmlFor="title">{t.title}</Label> 594 - <Input id="title" value={title} onChange={(e) => setTitle(e.target.value)} /> 641 + <Input 642 + id="title" 643 + value={title} 644 + onChange={(e) => setTitle(e.target.value)} 645 + /> 595 646 </div> 596 647 597 648 <div className="flex items-center space-x-2"> ··· 599 650 id="all-day" 600 651 checked={isAllDay} 601 652 onCheckedChange={(checked) => { 602 - const isChecked = checked as boolean 603 - setIsAllDay(isChecked) 653 + const isChecked = checked as boolean; 654 + setIsAllDay(isChecked); 604 655 605 656 if (isChecked) { 606 - // If checked, set start time to beginning of day and end time to end of day 607 - const startOfDay = new Date(startDate) 608 - startOfDay.setHours(0, 0, 0, 0) 657 + const startOfDay = new Date(startDate); 658 + startOfDay.setHours(0, 0, 0, 0); 609 659 610 - const endOfDay = new Date(startDate) 611 - endOfDay.setHours(23, 59, 59, 999) 660 + const endOfDay = new Date(startDate); 661 + endOfDay.setHours(23, 59, 59, 999); 612 662 613 663 setStartTime({ 614 664 hours: "00", 615 665 minutes: "00", 616 666 rawInput: "00:00", 617 - isCustomInput: false 618 - }) 619 - 667 + isCustomInput: false, 668 + }); 669 + 620 670 setEndTime({ 621 671 hours: "23", 622 672 minutes: "59", 623 673 rawInput: "23:59", 624 - isCustomInput: false 625 - }) 674 + isCustomInput: false, 675 + }); 626 676 } 627 677 }} 628 678 /> ··· 657 707 /> 658 708 </PopoverContent> 659 709 </Popover> 660 - 661 - {!isAllDay && ( 710 + 711 + {!isAllDay && 662 712 renderTimeSelector( 663 713 startTime, 664 714 handleStartTimeChange, 665 715 handleStartTimeInput, 666 716 startTimeOpen, 667 717 setStartTimeOpen, 668 - startTimeError 669 - ) 670 - )} 718 + startTimeError, 719 + )} 671 720 </div> 672 721 </div> 673 - 722 + 674 723 <div className="space-y-2"> 675 724 <Label>{t.endTime}</Label> 676 725 <div className="flex flex-col space-y-2"> ··· 692 741 if (date) { 693 742 setEndDate(date); 694 743 setEndDateOpen(false); 695 - 696 - // ๆฃ€ๆŸฅๆ—ฅๆœŸ+ๆ—ถ้—ดๆ˜ฏๅฆๆœ‰ๆ•ˆ 744 + 697 745 const fullStartDate = getFullStartDate(); 698 - const possibleEndDate = combineDateTime(date, endTime); 699 - 746 + const possibleEndDate = combineDateTime( 747 + date, 748 + endTime, 749 + ); 750 + 700 751 if (possibleEndDate < fullStartDate) { 701 752 setEndTimeError(true); 702 753 } else { ··· 709 760 /> 710 761 </PopoverContent> 711 762 </Popover> 712 - 713 - {!isAllDay && ( 763 + 764 + {!isAllDay && 714 765 renderTimeSelector( 715 766 endTime, 716 767 (hours, minutes) => { ··· 718 769 hours, 719 770 minutes, 720 771 rawInput: `${hours}:${minutes}`, 721 - isCustomInput: false 772 + isCustomInput: false, 722 773 }); 723 - 724 - // ้ชŒ่ฏ็ป“ๆŸๆ—ถ้—ดไธๆ—ฉไบŽๅผ€ๅง‹ๆ—ถ้—ด 774 + 725 775 const fullStartDate = getFullStartDate(); 726 - const possibleEndDate = set(new Date(endDate), { 776 + const possibleEndDate = set(new Date(endDate), { 727 777 hours: parseInt(hours), 728 778 minutes: parseInt(minutes), 729 - seconds: 0 779 + seconds: 0, 730 780 }); 731 - 781 + 732 782 setEndTimeError(possibleEndDate < fullStartDate); 733 783 }, 734 784 handleEndTimeInput, 735 785 endTimeOpen, 736 786 setEndTimeOpen, 737 - endTimeError 738 - ) 739 - )} 787 + endTimeError, 788 + )} 740 789 </div> 741 790 {endTimeError && !isAllDay && ( 742 - <p className="text-xs text-red-500"> 743 - {t.endTimeError} 744 - </p> 791 + <p className="text-xs text-red-500">{t.endTimeError}</p> 745 792 )} 746 793 </div> 747 794 </div> ··· 751 798 <Select 752 799 value={calendarSelectValue} 753 800 onValueChange={(value) => { 754 - setSelectedCalendar(value) 801 + setSelectedCalendar(value); 755 802 if (value !== "__uncategorized__") { 756 - setColor(getEventColorByCalendarId(value)) 803 + setColor(getEventColorByCalendarId(value)); 757 804 } 758 805 }} 759 806 > ··· 772 819 {calendars.map((calendar) => ( 773 820 <SelectItem key={calendar.id} value={calendar.id}> 774 821 <div className="flex items-center"> 775 - <div className={cn("w-4 h-4 rounded-full mr-2", calendar.color)} /> 822 + <div 823 + className={cn( 824 + "w-4 h-4 rounded-full mr-2", 825 + calendar.color, 826 + )} 827 + /> 776 828 {calendar.name} 777 829 </div> 778 830 </SelectItem> ··· 791 843 {colorOptions.map((option) => ( 792 844 <SelectItem key={option.value} value={option.value}> 793 845 <div className="flex items-center"> 794 - <div className={cn("w-4 h-4 rounded-full mr-2")} style={{ backgroundColor: colorMapping[option.value]}}/> 795 - {option.label} 846 + <div 847 + className={cn("w-4 h-4 rounded-full mr-2")} 848 + style={{ backgroundColor: colorMapping[option.value] }} 849 + /> 850 + {t[option.labelKey]} 796 851 </div> 797 852 </SelectItem> 798 853 ))} ··· 802 857 803 858 <div> 804 859 <Label htmlFor="location">{t.location}</Label> 805 - <Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} /> 860 + <Input 861 + id="location" 862 + value={location} 863 + onChange={(e) => setLocation(e.target.value)} 864 + /> 806 865 </div> 807 866 808 867 <div> ··· 823 882 </SelectTrigger> 824 883 <SelectContent> 825 884 <SelectItem value="0">{t.atEventTime}</SelectItem> 826 - <SelectItem value="5">{t.minutesBefore.replace("{minutes}", "5")}</SelectItem> 827 - <SelectItem value="15">{t.minutesBefore.replace("{minutes}", "15")}</SelectItem> 828 - <SelectItem value="30">{t.minutesBefore.replace("{minutes}", "30")}</SelectItem> 829 - <SelectItem value="60">{t.hourBefore.replace("{hours}", "1")}</SelectItem> 885 + <SelectItem value="5"> 886 + {t.minutesBefore.replace("{minutes}", "5")} 887 + </SelectItem> 888 + <SelectItem value="15"> 889 + {t.minutesBefore.replace("{minutes}", "15")} 890 + </SelectItem> 891 + <SelectItem value="30"> 892 + {t.minutesBefore.replace("{minutes}", "30")} 893 + </SelectItem> 894 + <SelectItem value="60"> 895 + {t.hourBefore.replace("{hours}", "1")} 896 + </SelectItem> 830 897 <SelectItem value="custom">{t.customTime}</SelectItem> 831 898 </SelectContent> 832 899 </Select> ··· 834 901 835 902 {notification === "custom" && ( 836 903 <div> 837 - <Label htmlFor="custom-notification-time">{t.customTimeMinutes}</Label> 904 + <Label htmlFor="custom-notification-time"> 905 + {t.customTimeMinutes} 906 + </Label> 838 907 <Input 839 908 id="custom-notification-time" 840 909 type="number" ··· 848 917 849 918 <div> 850 919 <Label htmlFor="description">{t.description}</Label> 851 - <Textarea id="description" value={description} onChange={(e) => setDescription(e.target.value)} /> 920 + <Textarea 921 + id="description" 922 + value={description} 923 + onChange={(e) => setDescription(e.target.value)} 924 + /> 852 925 </div> 853 926 854 927 <div className="flex justify-end gap-2"> 855 - <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> 928 + <Button 929 + type="button" 930 + variant="outline" 931 + onClick={() => onOpenChange(false)} 932 + > 856 933 {t.cancel} 857 934 </Button> 858 935 <Button type="submit">{event ? t.update : t.save}</Button> ··· 861 938 type="button" 862 939 variant="destructive" 863 940 onClick={() => { 864 - onEventDelete(event.id) 865 - onOpenChange(false) 941 + onEventDelete(event.id); 942 + onOpenChange(false); 866 943 }} 867 944 > 868 945 {t.delete} ··· 872 949 </form> 873 950 </DialogContent> 874 951 </Dialog> 875 - ) 952 + ); 876 953 }
+103 -83
components/app/sidebar/bookmark-panel.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import type React from "react" 3 + import type React from "react"; 4 4 5 - import { useState, useEffect } from "react" 6 - import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" 7 - import { Button } from "@/components/ui/button" 8 - import { ScrollArea } from "@/components/ui/scroll-area" 9 - import { Input } from "@/components/ui/input" 10 - import { Bookmark, Search, Trash2 } from "lucide-react" 11 - import { format } from "date-fns" 12 - import { zhCN, enUS } from "date-fns/locale" 13 - import { toast } from "sonner" 14 - import { cn } from "@/lib/utils" 15 - import { isZhLanguage, translations, useLanguage } from "@/lib/i18n" 16 - import { getEncryptionState, readEncryptedLocalStorage, subscribeEncryptionState, writeEncryptedLocalStorage } from "@/hooks/useLocalStorage" 5 + import { useState, useEffect } from "react"; 6 + import { 7 + Sheet, 8 + SheetContent, 9 + SheetHeader, 10 + SheetTitle, 11 + } from "@/components/ui/sheet"; 12 + import { Button } from "@/components/ui/button"; 13 + import { ScrollArea } from "@/components/ui/scroll-area"; 14 + import { Input } from "@/components/ui/input"; 15 + import { Bookmark, Search, Trash2 } from "lucide-react"; 16 + import { format } from "date-fns"; 17 + import { zhCN, enUS } from "date-fns/locale"; 18 + import { toast } from "sonner"; 19 + import { cn } from "@/lib/utils"; 20 + import { isZhLanguage, translations, useLanguage } from "@/lib/i18n"; 21 + import { 22 + getEncryptionState, 23 + readEncryptedLocalStorage, 24 + subscribeEncryptionState, 25 + writeEncryptedLocalStorage, 26 + } from "@/hooks/useLocalStorage"; 17 27 18 28 interface BookmarkPanelProps { 19 - open: boolean 20 - onOpenChange: (open: boolean) => void 21 - onEventClick: (event: any) => void 29 + open: boolean; 30 + onOpenChange: (open: boolean) => void; 31 + onEventClick: (event: any) => void; 22 32 } 23 33 24 34 interface BookmarkedEvent { 25 - id: string 26 - title: string 27 - startDate: string | Date 28 - endDate: string | Date 29 - color: string 30 - location?: string 31 - bookmarkedAt: string 35 + id: string; 36 + title: string; 37 + startDate: string | Date; 38 + endDate: string | Date; 39 + color: string; 40 + location?: string; 41 + bookmarkedAt: string; 32 42 } 33 43 34 44 function getDarkerColorClass(color: string) { 35 - const colorMapping: Record<string, string> = { 36 - 'bg-[#E6F6FD]': '#3B82F6', 37 - 'bg-[#E7F8F2]': '#10B981', 38 - 'bg-[#FEF5E6]': '#F59E0B', 39 - 'bg-[#FFE4E6]': '#EF4444', 40 - 'bg-[#F3EEFE]': '#8B5CF6', 41 - 'bg-[#FCE7F3]': '#EC4899', 42 - 'bg-[#EEF2FF]': '#6366F1', 43 - 'bg-[#FFF0E5]': '#FB923C', 44 - 'bg-[#E6FAF7]': '#14B8A6', 45 - } 46 - 45 + const colorMapping: Record<string, string> = { 46 + "bg-[#E6F6FD]": "#3B82F6", 47 + "bg-[#E7F8F2]": "#10B981", 48 + "bg-[#FEF5E6]": "#F59E0B", 49 + "bg-[#FFE4E6]": "#EF4444", 50 + "bg-[#F3EEFE]": "#8B5CF6", 51 + "bg-[#FCE7F3]": "#EC4899", 52 + "bg-[#EEF2FF]": "#6366F1", 53 + "bg-[#FFF0E5]": "#FB923C", 54 + "bg-[#E6FAF7]": "#14B8A6", 55 + }; 47 56 48 - return colorMapping[color] || '#3A3A3A'; 57 + return colorMapping[color] || "#3A3A3A"; 49 58 } 50 59 51 - export default function BookmarkPanel({ open, onOpenChange, onEventClick }: BookmarkPanelProps) { 52 - const [language] = useLanguage() 53 - const t = translations[language] 54 - const isZh = isZhLanguage(language) 55 - const [bookmarks, setBookmarks] = useState<BookmarkedEvent[]>([]) 56 - const [searchTerm, setSearchTerm] = useState("") 60 + export default function BookmarkPanel({ 61 + open, 62 + onOpenChange, 63 + onEventClick, 64 + }: BookmarkPanelProps) { 65 + const [language] = useLanguage(); 66 + const t = translations[language]; 67 + const isZh = isZhLanguage(language); 68 + const [bookmarks, setBookmarks] = useState<BookmarkedEvent[]>([]); 69 + const [searchTerm, setSearchTerm] = useState(""); 57 70 58 - // Load bookmarks from localStorage 59 71 useEffect(() => { 60 72 if (open) { 61 73 const loadBookmarks = () => 62 - readEncryptedLocalStorage<BookmarkedEvent[]>("bookmarked-events", []).then((stored) => { 63 - const parsedBookmarks = [...stored] 64 - // Sort by bookmarked date (newest first) 74 + readEncryptedLocalStorage<BookmarkedEvent[]>( 75 + "bookmarked-events", 76 + [], 77 + ).then((stored) => { 78 + const parsedBookmarks = [...stored]; 79 + 65 80 parsedBookmarks.sort( 66 81 (a: BookmarkedEvent, b: BookmarkedEvent) => 67 - new Date(b.bookmarkedAt).getTime() - new Date(a.bookmarkedAt).getTime(), 68 - ) 69 - setBookmarks(parsedBookmarks) 70 - }) 82 + new Date(b.bookmarkedAt).getTime() - 83 + new Date(a.bookmarkedAt).getTime(), 84 + ); 85 + setBookmarks(parsedBookmarks); 86 + }); 71 87 72 - loadBookmarks() 88 + loadBookmarks(); 73 89 74 90 const unsubscribe = subscribeEncryptionState(() => { 75 - if (!getEncryptionState().ready) return 76 - loadBookmarks() 77 - }) 91 + if (!getEncryptionState().ready) return; 92 + loadBookmarks(); 93 + }); 78 94 return () => { 79 - unsubscribe() 80 - } 95 + unsubscribe(); 96 + }; 81 97 } 82 - return undefined 83 - }, [open]) 98 + return undefined; 99 + }, [open]); 84 100 85 - // Format date for display 86 101 const formatEventDate = (dateString: string | Date) => { 87 - const date = new Date(dateString) 88 - return format(date, "yyyy-MM-dd HH:mm", { locale: isZh ? zhCN : enUS }) 89 - } 102 + const date = new Date(dateString); 103 + return format(date, "yyyy-MM-dd HH:mm", { locale: isZh ? zhCN : enUS }); 104 + }; 90 105 91 - // Remove bookmark 92 106 const removeBookmark = (id: string, e: React.MouseEvent) => { 93 - e.stopPropagation() 94 - const updatedBookmarks = bookmarks.filter((bookmark) => bookmark.id !== id) 95 - void writeEncryptedLocalStorage("bookmarked-events", updatedBookmarks) 96 - setBookmarks(updatedBookmarks) 107 + e.stopPropagation(); 108 + const updatedBookmarks = bookmarks.filter((bookmark) => bookmark.id !== id); 109 + void writeEncryptedLocalStorage("bookmarked-events", updatedBookmarks); 110 + setBookmarks(updatedBookmarks); 97 111 toast(t.bookmarkRemoved, { 98 112 description: t.eventRemovedFromBookmarks, 99 - }) 100 - } 113 + }); 114 + }; 101 115 102 - // Handle event click 103 116 const handleEventClick = (event: BookmarkedEvent) => { 104 - // Close the bookmark panel 105 - onOpenChange(false) 117 + onOpenChange(false); 106 118 107 - // Find the full event in the calendar events 108 - onEventClick(event) 109 - } 119 + onEventClick(event); 120 + }; 110 121 111 - // Filter bookmarks based on search term 112 122 const filteredBookmarks = bookmarks.filter( 113 123 (bookmark) => 114 124 bookmark.title.toLowerCase().includes(searchTerm.toLowerCase()) || 115 - (bookmark.location && bookmark.location.toLowerCase().includes(searchTerm.toLowerCase())), 116 - ) 125 + (bookmark.location && 126 + bookmark.location.toLowerCase().includes(searchTerm.toLowerCase())), 127 + ); 117 128 118 129 return ( 119 130 <Sheet open={open} onOpenChange={onOpenChange}> ··· 155 166 className="flex items-start p-3 border rounded-md hover:bg-accent cursor-pointer group" 156 167 onClick={() => handleEventClick(bookmark)} 157 168 > 158 - <div className={cn("w-1.5 self-stretch rounded-full mr-3")} style={{ backgroundColor: getDarkerColorClass(bookmark.color) }}/> 169 + <div 170 + className={cn("w-1.5 self-stretch rounded-full mr-3")} 171 + style={{ 172 + backgroundColor: getDarkerColorClass(bookmark.color), 173 + }} 174 + /> 159 175 <div className="flex-1 min-w-0"> 160 176 <h4 className="font-medium truncate">{bookmark.title}</h4> 161 - <p className="text-sm text-muted-foreground">{formatEventDate(bookmark.startDate)}</p> 177 + <p className="text-sm text-muted-foreground"> 178 + {formatEventDate(bookmark.startDate)} 179 + </p> 162 180 {bookmark.location && ( 163 - <p className="text-xs text-muted-foreground truncate mt-1">{bookmark.location}</p> 181 + <p className="text-xs text-muted-foreground truncate mt-1"> 182 + {bookmark.location} 183 + </p> 164 184 )} 165 185 </div> 166 186 <Button ··· 179 199 </div> 180 200 </SheetContent> 181 201 </Sheet> 182 - ) 202 + ); 183 203 }
+135 -56
components/app/sidebar/countdown.tsx
··· 26 26 PopoverContent, 27 27 PopoverTrigger, 28 28 } from "@/components/ui/popover"; 29 - import { Plus, ArrowLeft, Edit2, Trash2, Calendar as CalendarIcon, Clock, Search } from "lucide-react"; 29 + import { 30 + Plus, 31 + ArrowLeft, 32 + Edit2, 33 + Trash2, 34 + Calendar as CalendarIcon, 35 + Clock, 36 + Search, 37 + } from "lucide-react"; 30 38 import { Avatar, AvatarFallback } from "@/components/ui/avatar"; 31 39 import { cn } from "@/lib/utils"; 32 40 import { format } from "date-fns"; ··· 48 56 onOpenChange: (open: boolean) => void; 49 57 } 50 58 51 - type TranslationKey = keyof typeof translations["en"] 59 + type TranslationKey = keyof (typeof translations)["en"]; 52 60 53 61 const colorOptions: { value: string; labelKey: TranslationKey }[] = [ 54 62 { value: "bg-red-500", labelKey: "colorRed" }, ··· 62 70 ]; 63 71 64 72 export function CountdownTool({ open, onOpenChange }: CountdownToolProps) { 65 - const [countdowns, setCountdowns] = useLocalStorage<Countdown[]>("countdowns", []); 66 - const [selectedCountdown, setSelectedCountdown] = useState<Countdown | null>(null); 73 + const [countdowns, setCountdowns] = useLocalStorage<Countdown[]>( 74 + "countdowns", 75 + [], 76 + ); 77 + const [selectedCountdown, setSelectedCountdown] = useState<Countdown | null>( 78 + null, 79 + ); 67 80 const [newCountdown, setNewCountdown] = useState<Partial<Countdown>>({ 68 - color: "bg-blue-500" 81 + color: "bg-blue-500", 69 82 }); 70 83 const [view, setView] = useState<"list" | "detail" | "edit">("list"); 71 84 const [language] = useLanguage(); 72 85 const t = translations[language]; 73 86 const isZh = isZhLanguage(language); 74 87 const [search, setSearch] = useState(""); 75 - const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date()); 88 + const [selectedDate, setSelectedDate] = useState<Date | undefined>( 89 + new Date(), 90 + ); 76 91 const [calendarOpen, setCalendarOpen] = useState(false); 77 92 78 - // useLocalStorage handles persistence. 79 - 80 93 const formatDate = (dateStr: string) => { 81 94 const date = new Date(dateStr); 82 - const options: Intl.DateTimeFormatOptions = { 83 - year: 'numeric', 84 - month: 'short', 85 - day: 'numeric' 95 + const options: Intl.DateTimeFormatOptions = { 96 + year: "numeric", 97 + month: "short", 98 + day: "numeric", 86 99 }; 87 100 return date.toLocaleDateString(isZh ? "zh-CN" : "en-US", options); 88 101 }; 89 102 90 103 const formatDateLong = (dateStr: string) => { 91 104 const date = new Date(dateStr); 92 - const options: Intl.DateTimeFormatOptions = { 93 - year: 'numeric', 94 - month: 'long', 95 - day: 'numeric' 105 + const options: Intl.DateTimeFormatOptions = { 106 + year: "numeric", 107 + month: "long", 108 + day: "numeric", 96 109 }; 97 110 return date.toLocaleDateString(isZh ? "zh-CN" : "en-US", options); 98 111 }; ··· 100 113 const calculateDaysLeft = (dateStr: string, repeat: Countdown["repeat"]) => { 101 114 const today = new Date(); 102 115 today.setHours(0, 0, 0, 0); 103 - 116 + 104 117 const targetDate = new Date(dateStr); 105 - let nextDate = new Date(today.getFullYear(), targetDate.getMonth(), targetDate.getDate()); 118 + let nextDate = new Date( 119 + today.getFullYear(), 120 + targetDate.getMonth(), 121 + targetDate.getDate(), 122 + ); 106 123 107 124 if (repeat === "weekly") { 108 125 const targetDay = targetDate.getDay(); ··· 111 128 nextDate = new Date(today); 112 129 nextDate.setDate(today.getDate() + daysToAdd); 113 130 } else if (repeat === "monthly") { 114 - nextDate = new Date(today.getFullYear(), today.getMonth(), targetDate.getDate()); 131 + nextDate = new Date( 132 + today.getFullYear(), 133 + today.getMonth(), 134 + targetDate.getDate(), 135 + ); 115 136 if (nextDate < today) nextDate.setMonth(nextDate.getMonth() + 1); 116 137 } else if (repeat === "yearly") { 117 - nextDate = new Date(today.getFullYear(), targetDate.getMonth(), targetDate.getDate()); 138 + nextDate = new Date( 139 + today.getFullYear(), 140 + targetDate.getMonth(), 141 + targetDate.getDate(), 142 + ); 118 143 if (nextDate < today) nextDate.setFullYear(today.getFullYear() + 1); 119 144 } 120 145 ··· 124 149 125 150 const getTodayDateString = () => { 126 151 const today = new Date(); 127 - return today.toISOString().split('T')[0]; 152 + return today.toISOString().split("T")[0]; 128 153 }; 129 154 130 155 const tRepeat = (key: Countdown["repeat"]) => ··· 133 158 weekly: t.countdownRepeatWeekly, 134 159 monthly: t.countdownRepeatMonthly, 135 160 yearly: t.countdownRepeatYearly, 136 - }[key]); 161 + })[key]; 137 162 138 163 const startAddCountdown = () => { 139 164 const today = new Date(); ··· 142 167 date: getTodayDateString(), 143 168 repeat: "none", 144 169 description: "", 145 - color: "bg-blue-500" 170 + color: "bg-blue-500", 146 171 }); 147 172 setSelectedDate(today); 148 173 setSelectedCountdown(null); ··· 167 192 168 193 const saveCountdown = () => { 169 194 if (!newCountdown.name || !selectedDate || !newCountdown.color) return; 170 - 195 + 171 196 const countdown: Countdown = { 172 197 id: selectedCountdown?.id || Date.now().toString(), 173 198 name: newCountdown.name, 174 - date: selectedDate.toISOString().split('T')[0], 199 + date: selectedDate.toISOString().split("T")[0], 175 200 repeat: newCountdown.repeat || "none", 176 201 description: newCountdown.description || "", 177 - color: newCountdown.color 202 + color: newCountdown.color, 178 203 }; 179 204 180 205 if (selectedCountdown) { 181 - setCountdowns(prev => prev.map(c => c.id === countdown.id ? countdown : c)); 206 + setCountdowns((prev) => 207 + prev.map((c) => (c.id === countdown.id ? countdown : c)), 208 + ); 182 209 } else { 183 - setCountdowns(prev => [...prev, countdown]); 210 + setCountdowns((prev) => [...prev, countdown]); 184 211 } 185 212 186 213 setView("list"); ··· 190 217 }; 191 218 192 219 const deleteCountdown = (id: string) => { 193 - setCountdowns(prev => prev.filter(c => c.id !== id)); 220 + setCountdowns((prev) => prev.filter((c) => c.id !== id)); 194 221 setView("list"); 195 222 setSelectedCountdown(null); 196 223 }; 197 224 198 - // ๆธฒๆŸ“ๅ€’ๆ•ฐๆ—ฅๅˆ—่กจ่ง†ๅ›พ 199 225 const renderCountdownListView = () => ( 200 226 <> 201 227 <SheetHeader className="p-4 border-b"> 202 228 <div className="flex items-center justify-between"> 203 - <SheetTitle className="flex items-center gap-2"><ClockDashed className="h-4 w-4" />{t.countdownTitle}</SheetTitle> 229 + <SheetTitle className="flex items-center gap-2"> 230 + <ClockDashed className="h-4 w-4" /> 231 + {t.countdownTitle} 232 + </SheetTitle> 204 233 </div> 205 234 </SheetHeader> 206 235 <div className="p-4"> ··· 230 259 ) : ( 231 260 <div className="space-y-2"> 232 261 {countdowns 233 - .filter((countdown) => 234 - countdown.name.toLowerCase().includes(search.toLowerCase()) || 235 - countdown.description?.toLowerCase().includes(search.toLowerCase()) 262 + .filter( 263 + (countdown) => 264 + countdown.name 265 + .toLowerCase() 266 + .includes(search.toLowerCase()) || 267 + countdown.description 268 + ?.toLowerCase() 269 + .includes(search.toLowerCase()), 236 270 ) 237 271 .map((countdown) => { 238 - const daysLeft = calculateDaysLeft(countdown.date, countdown.repeat); 272 + const daysLeft = calculateDaysLeft( 273 + countdown.date, 274 + countdown.repeat, 275 + ); 239 276 const formattedDate = formatDate(countdown.date); 240 - 277 + 241 278 return ( 242 279 <div 243 280 key={countdown.id} ··· 259 296 </div> 260 297 </div> 261 298 <div className="text-right"> 262 - <div className={`text-lg font-bold ${daysLeft < 0 ? "text-red-500" : "text-primary"}`}> 299 + <div 300 + className={`text-lg font-bold ${daysLeft < 0 ? "text-red-500" : "text-primary"}`} 301 + > 263 302 {Math.abs(daysLeft)} 264 303 </div> 265 304 <div className="text-xs text-muted-foreground"> ··· 276 315 </> 277 316 ); 278 317 279 - // ๆธฒๆŸ“ๅ€’ๆ•ฐๆ—ฅ่ฏฆๆƒ…่ง†ๅ›พ 280 318 const renderCountdownDetailView = () => { 281 319 if (!selectedCountdown) return null; 282 - const daysLeft = calculateDaysLeft(selectedCountdown.date, selectedCountdown.repeat); 320 + const daysLeft = calculateDaysLeft( 321 + selectedCountdown.date, 322 + selectedCountdown.repeat, 323 + ); 283 324 const formattedDate = formatDateLong(selectedCountdown.date); 284 325 285 326 return ( 286 327 <> 287 328 <SheetHeader className="p-4 border-b"> 288 329 <div className="flex items-center"> 289 - <Button variant="ghost" size="icon" className="mr-2" onClick={backToCountdownList}> 330 + <Button 331 + variant="ghost" 332 + size="icon" 333 + className="mr-2" 334 + onClick={backToCountdownList} 335 + > 290 336 <ArrowLeft className="h-4 w-4" /> 291 337 </Button> 292 338 <SheetTitle>{t.countdownDetails}</SheetTitle> ··· 303 349 </Avatar> 304 350 <div> 305 351 <h2 className="text-xl font-bold">{selectedCountdown.name}</h2> 306 - <div className={`text-2xl font-bold mt-1 ${daysLeft < 0 ? "text-red-500" : "text-primary"}`}> 352 + <div 353 + className={`text-2xl font-bold mt-1 ${daysLeft < 0 ? "text-red-500" : "text-primary"}`} 354 + > 307 355 {Math.abs(daysLeft)} {t.countdownDaysLeft} 308 356 </div> 309 357 </div> ··· 331 379 <h3 className="text-sm font-medium text-muted-foreground mb-1"> 332 380 {t.countdownDescription} 333 381 </h3> 334 - <p className="whitespace-pre-wrap text-base">{selectedCountdown.description}</p> 382 + <p className="whitespace-pre-wrap text-base"> 383 + {selectedCountdown.description} 384 + </p> 335 385 </div> 336 386 )} 337 387 </div> 338 388 339 389 <div className="flex space-x-2 mt-8"> 340 - <Button variant="outline" className="flex-1" onClick={() => startEditCountdown(selectedCountdown)}> 390 + <Button 391 + variant="outline" 392 + className="flex-1" 393 + onClick={() => startEditCountdown(selectedCountdown)} 394 + > 341 395 <Edit2 className="mr-2 h-4 w-4" /> 342 396 {t.countdownEdit} 343 397 </Button> 344 - <Button variant="destructive" className="flex-1" onClick={() => deleteCountdown(selectedCountdown.id)}> 398 + <Button 399 + variant="destructive" 400 + className="flex-1" 401 + onClick={() => deleteCountdown(selectedCountdown.id)} 402 + > 345 403 <Trash2 className="mr-2 h-4 w-4" /> 346 404 {t.countdownDelete} 347 405 </Button> ··· 351 409 ); 352 410 }; 353 411 354 - // ๆธฒๆŸ“ๅ€’ๆ•ฐๆ—ฅ็ผ–่พ‘่ง†ๅ›พ 355 412 const renderCountdownEditView = () => ( 356 413 <div className="h-full flex flex-col"> 357 414 <SheetHeader className="p-4 border-b"> ··· 382 439 <Input 383 440 id="name" 384 441 value={newCountdown.name || ""} 385 - onChange={(e) => setNewCountdown({ ...newCountdown, name: e.target.value })} 442 + onChange={(e) => 443 + setNewCountdown({ ...newCountdown, name: e.target.value }) 444 + } 386 445 required 387 446 /> 388 447 </div> 389 448 390 449 <div className="space-y-2"> 391 450 <Label htmlFor="color">{t.countdownColor}*</Label> 392 - <Select value={newCountdown.color} onValueChange={(value) => setNewCountdown({ ...newCountdown, color: value })}> 451 + <Select 452 + value={newCountdown.color} 453 + onValueChange={(value) => 454 + setNewCountdown({ ...newCountdown, color: value }) 455 + } 456 + > 393 457 <SelectTrigger id="color"> 394 458 <SelectValue placeholder={t.countdownColor} /> 395 459 </SelectTrigger> ··· 397 461 {colorOptions.map((option) => ( 398 462 <SelectItem key={option.value} value={option.value}> 399 463 <div className="flex items-center"> 400 - <div className={cn("w-4 h-4 rounded-full mr-2", option.value)} /> 464 + <div 465 + className={cn( 466 + "w-4 h-4 rounded-full mr-2", 467 + option.value, 468 + )} 469 + /> 401 470 {t[option.labelKey]} 402 471 </div> 403 472 </SelectItem> ··· 414 483 variant="outline" 415 484 className={cn( 416 485 "w-full justify-start text-left font-normal", 417 - !selectedDate && "text-muted-foreground" 486 + !selectedDate && "text-muted-foreground", 418 487 )} 419 488 > 420 489 <CalendarIcon className="mr-2 h-4 w-4" /> 421 490 {selectedDate ? ( 422 - format(selectedDate, "PPP", { 423 - locale: isZh ? zhCN : enUS 491 + format(selectedDate, "PPP", { 492 + locale: isZh ? zhCN : enUS, 424 493 }) 425 494 ) : ( 426 495 <span>{t.countdownSelectDate}</span> ··· 468 537 <Textarea 469 538 id="description" 470 539 value={newCountdown.description || ""} 471 - onChange={(e) => setNewCountdown({ ...newCountdown, description: e.target.value })} 540 + onChange={(e) => 541 + setNewCountdown({ 542 + ...newCountdown, 543 + description: e.target.value, 544 + }) 545 + } 472 546 rows={3} 473 547 /> 474 548 </div> ··· 476 550 </div> 477 551 <div className="p-4 border-t flex justify-between"> 478 552 {selectedCountdown && ( 479 - <Button variant="destructive" onClick={() => deleteCountdown(selectedCountdown.id)}> 553 + <Button 554 + variant="destructive" 555 + onClick={() => deleteCountdown(selectedCountdown.id)} 556 + > 480 557 {t.countdownDelete} 481 558 </Button> 482 559 )} ··· 493 570 > 494 571 {t.countdownCancel} 495 572 </Button> 496 - <Button 497 - onClick={saveCountdown} 498 - disabled={!newCountdown.name || !selectedDate || !newCountdown.color} 573 + <Button 574 + onClick={saveCountdown} 575 + disabled={ 576 + !newCountdown.name || !selectedDate || !newCountdown.color 577 + } 499 578 > 500 579 {t.countdownSave} 501 580 </Button>
+144 -80
components/app/sidebar/mini-calendar-sheet.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { format, addDays, subDays, startOfWeek, endOfWeek, isSameDay, isToday } from "date-fns" 4 - import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" 5 - import { useCalendar } from "@/components/providers/calendar-context" 6 - import { isZhLanguage, translations, useLanguage } from "@/lib/i18n" 7 - import { CalendarDays, ChevronRight } from "lucide-react" 8 - import { ScrollArea } from "@/components/ui/scroll-area" 9 - import type { CalendarEvent } from "../calendar" 10 - import { Button } from "@/components/ui/button" 11 - import { useState, useEffect } from "react" 12 - import { cn } from "@/lib/utils" 3 + import { 4 + format, 5 + addDays, 6 + subDays, 7 + startOfWeek, 8 + isSameDay, 9 + isToday, 10 + } from "date-fns"; 11 + import { 12 + Sheet, 13 + SheetContent, 14 + SheetHeader, 15 + SheetTitle, 16 + } from "@/components/ui/sheet"; 17 + import { useCalendar } from "@/components/providers/calendar-context"; 18 + import { translations, useLanguage } from "@/lib/i18n"; 19 + import { CalendarDays, ChevronRight } from "lucide-react"; 20 + import { ScrollArea } from "@/components/ui/scroll-area"; 21 + import type { CalendarEvent } from "../calendar"; 22 + import { Button } from "@/components/ui/button"; 23 + import { useState, useEffect } from "react"; 24 + import { cn } from "@/lib/utils"; 25 + 26 + const getWeekStartsOn = (locale: string): 0 | 1 => { 27 + try { 28 + const intlLocale = new Intl.Locale(locale); 29 + const firstDay = intlLocale.weekInfo?.firstDay; 30 + if (firstDay === 1) return 1; 31 + if (firstDay === 7) return 0; 32 + } catch (_error) {} 33 + 34 + return [ 35 + "zh-CN", 36 + "zh-TW", 37 + "zh-HK", 38 + "de", 39 + "fr", 40 + "es", 41 + "it", 42 + "pt", 43 + "ru", 44 + "sv", 45 + "fi", 46 + "nb", 47 + "pl", 48 + "tr", 49 + "uk", 50 + "lt", 51 + "lv", 52 + "sl", 53 + "mk", 54 + "sr", 55 + "th", 56 + "vi", 57 + ].includes(locale) 58 + ? 1 59 + : 0; 60 + }; 13 61 14 62 interface MiniCalendarSheetProps { 15 - open: boolean 16 - onOpenChange: (open: boolean) => void 17 - selectedDate: Date 18 - onDateSelect: (date: Date) => void 63 + open: boolean; 64 + onOpenChange: (open: boolean) => void; 65 + selectedDate: Date; 66 + onDateSelect: (date: Date) => void; 19 67 } 20 68 21 - export default function MiniCalendarSheet({ open, onOpenChange, selectedDate, onDateSelect }: MiniCalendarSheetProps) { 22 - const [language] = useLanguage() 23 - const t = translations[language] 24 - const isZh = isZhLanguage(language) 25 - const { events } = useCalendar() 26 - const [currentDate, setCurrentDate] = useState(selectedDate) 69 + export default function MiniCalendarSheet({ 70 + open, 71 + onOpenChange, 72 + selectedDate, 73 + onDateSelect, 74 + }: MiniCalendarSheetProps) { 75 + const [language] = useLanguage(); 76 + const t = translations[language]; 77 + const { events } = useCalendar(); 78 + const [currentDate, setCurrentDate] = useState(selectedDate); 27 79 28 - // Update internal date when selectedDate changes 29 80 useEffect(() => { 30 - setCurrentDate(selectedDate) 31 - }, [selectedDate]) 81 + setCurrentDate(selectedDate); 82 + }, [selectedDate]); 32 83 33 - // Get the current week days 34 - const weekStart = startOfWeek(currentDate, { weekStartsOn: isZh ? 1 : 0 }) 35 - const weekEnd = endOfWeek(currentDate, { weekStartsOn: isZh ? 1 : 0 }) 36 - const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)) 84 + const weekStartsOn = getWeekStartsOn(language); 85 + const weekStart = startOfWeek(currentDate, { weekStartsOn }); 86 + const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); 37 87 38 - // Get events for the selected day 39 88 const dayEvents = events 40 89 .filter((event) => isSameDay(new Date(event.startDate), currentDate)) 41 - .sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()) 90 + .sort( 91 + (a, b) => 92 + new Date(a.startDate).getTime() - new Date(b.startDate).getTime(), 93 + ); 42 94 43 - // Handle day selection 44 95 const handleDayClick = (day: Date) => { 45 - setCurrentDate(day) 46 - onDateSelect(day) 47 - } 96 + setCurrentDate(day); 97 + onDateSelect(day); 98 + }; 48 99 49 100 function getDarkerColorClass(color: string) { 50 101 const colorMapping: Record<string, string> = { 51 - 'bg-[#E6F6FD]': '#3B82F6', 52 - 'bg-[#E7F8F2]': '#10B981', 53 - 'bg-[#FEF5E6]': '#F59E0B', 54 - 'bg-[#FFE4E6]': '#EF4444', 55 - 'bg-[#F3EEFE]': '#8B5CF6', 56 - 'bg-[#FCE7F3]': '#EC4899', 57 - 'bg-[#EEF2FF]': '#6366F1', 58 - 'bg-[#FFF0E5]': '#FB923C', 59 - 'bg-[#E6FAF7]': '#14B8A6', 60 - } 61 - 102 + "bg-[#E6F6FD]": "#3B82F6", 103 + "bg-[#E7F8F2]": "#10B981", 104 + "bg-[#FEF5E6]": "#F59E0B", 105 + "bg-[#FFE4E6]": "#EF4444", 106 + "bg-[#F3EEFE]": "#8B5CF6", 107 + "bg-[#FCE7F3]": "#EC4899", 108 + "bg-[#EEF2FF]": "#6366F1", 109 + "bg-[#FFF0E5]": "#FB923C", 110 + "bg-[#E6FAF7]": "#14B8A6", 111 + }; 62 112 63 - return colorMapping[color] || '#3A3A3A'; 113 + return colorMapping[color] || "#3A3A3A"; 64 114 } 65 - 66 - // Handle previous/next month 115 + 67 116 const handlePreviousWeek = () => { 68 - setCurrentDate((prevDate) => subDays(prevDate, 7)) 69 - } 117 + setCurrentDate((prevDate) => subDays(prevDate, 7)); 118 + }; 70 119 71 120 const handleNextWeek = () => { 72 - setCurrentDate((prevDate) => addDays(prevDate, 7)) 73 - } 121 + setCurrentDate((prevDate) => addDays(prevDate, 7)); 122 + }; 74 123 75 - // Handle today button click 76 124 const handleTodayClick = () => { 77 - const today = new Date() 78 - setCurrentDate(today) 79 - onDateSelect(today) 80 - } 125 + const today = new Date(); 126 + setCurrentDate(today); 127 + onDateSelect(today); 128 + }; 81 129 82 - // Format event time 83 130 const formatEventTime = (event: CalendarEvent) => { 84 - const startDate = new Date(event.startDate) 85 - return format(startDate, "HH:mm") 86 - } 131 + const startDate = new Date(event.startDate); 132 + return format(startDate, "HH:mm"); 133 + }; 87 134 88 - // Calculate event duration in minutes 89 135 const calculateDuration = (event: CalendarEvent) => { 90 - const startDate = new Date(event.startDate) 91 - const endDate = new Date(event.endDate) 92 - const durationMs = endDate.getTime() - startDate.getTime() 93 - const durationMinutes = Math.round(durationMs / (1000 * 60)) 136 + const startDate = new Date(event.startDate); 137 + const endDate = new Date(event.endDate); 138 + const durationMs = endDate.getTime() - startDate.getTime(); 139 + const durationMinutes = Math.round(durationMs / (1000 * 60)); 94 140 95 - return `${durationMinutes} ${t.minutesShort}` 96 - } 141 + return `${durationMinutes} ${t.minutesShort}`; 142 + }; 97 143 98 - // Get day names based on language 99 - const getDayNames = () => { 100 - const orderedDays = [...t.weekdays.slice(1), t.weekdays[0]] 101 - return isZh ? orderedDays : t.weekdays 102 - } 144 + const getDayNames = () => 145 + Array.from({ length: 7 }, (_, index) => 146 + new Intl.DateTimeFormat(language, { weekday: "short" }).format( 147 + addDays(weekStart, index), 148 + ), 149 + ); 150 + 151 + const monthYearLabel = new Intl.DateTimeFormat(language, { 152 + year: "numeric", 153 + month: "long", 154 + }).format(currentDate); 103 155 104 156 return ( 105 157 <Sheet open={open} onOpenChange={onOpenChange}> ··· 114 166 <div className="p-4 border-b"> 115 167 <div className="flex items-center justify-between mb-4"> 116 168 <div className="flex items-center"> 117 - <span className="text-lg font-medium">{t.months[currentDate.getMonth()]}</span> 169 + <span className="text-lg font-medium">{monthYearLabel}</span> 118 170 </div> 119 171 <div className="flex items-center space-x-2"> 120 172 <Button variant="ghost" size="sm" onClick={handleTodayClick}> ··· 141 193 variant="ghost" 142 194 className={cn( 143 195 "h-10 w-10 p-0 rounded-full", 144 - isSameDay(day, currentDate) && "bg-primary text-primary-foreground", 145 - isToday(day) && !isSameDay(day, currentDate) && "border border-primary", 196 + isSameDay(day, currentDate) && 197 + "bg-primary text-primary-foreground", 198 + isToday(day) && 199 + !isSameDay(day, currentDate) && 200 + "border border-primary", 146 201 )} 147 202 onClick={() => handleDayClick(day)} 148 203 > ··· 170 225 <div className="space-y-4"> 171 226 {dayEvents.map((event) => ( 172 227 <div key={event.id} className="flex items-start"> 173 - <div className={cn("w-1 self-stretch rounded-full mr-3")} style={{ backgroundColor: getDarkerColorClass(event.color) }}/> 228 + <div 229 + className={cn("w-1 self-stretch rounded-full mr-3")} 230 + style={{ 231 + backgroundColor: getDarkerColorClass(event.color), 232 + }} 233 + /> 174 234 <div className="flex-1"> 175 235 <div className="flex justify-between"> 176 236 <div className="font-medium">{event.title}</div> 177 - <div className="text-muted-foreground">{formatEventTime(event)}</div> 237 + <div className="text-muted-foreground"> 238 + {formatEventTime(event)} 239 + </div> 178 240 </div> 179 - <div className="text-sm text-muted-foreground">{calculateDuration(event)}</div> 241 + <div className="text-sm text-muted-foreground"> 242 + {calculateDuration(event)} 243 + </div> 180 244 </div> 181 245 </div> 182 246 ))} ··· 186 250 </div> 187 251 </SheetContent> 188 252 </Sheet> 189 - ) 253 + ); 190 254 }
+98 -77
components/app/sidebar/right-sidebar.tsx
··· 1 - import { User, BookText, Plus, ArrowLeft, Edit2, Trash2, Calendar, Bookmark, MessageSquare, Sun } from 'lucide-react' 2 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 3 - import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet" 4 - import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" 5 - import { ClockDashed } from "@/components/icons/clock-dashed" 6 - import { ScrollArea } from "@/components/ui/scroll-area" 7 - import MiniCalendarSheet from "./mini-calendar-sheet" 8 - import { Textarea } from "@/components/ui/textarea" 9 - import { Button } from "@/components/ui/button" 10 - import { Input } from "@/components/ui/input" 11 - import { Label } from "@/components/ui/label" 12 - import BookmarkPanel from "./bookmark-panel" 13 - import { useRouter } from "next/navigation" 14 - import { CountdownTool } from "./countdown" 15 - import { useState } from "react" 16 - import { cn } from "@/lib/utils" 1 + import { 2 + User, 3 + BookText, 4 + Plus, 5 + ArrowLeft, 6 + Edit2, 7 + Trash2, 8 + Calendar, 9 + Bookmark, 10 + MessageSquare, 11 + Sun, 12 + } from "lucide-react"; 13 + import { 14 + Select, 15 + SelectContent, 16 + SelectItem, 17 + SelectTrigger, 18 + SelectValue, 19 + } from "@/components/ui/select"; 20 + import { 21 + Sheet, 22 + SheetContent, 23 + SheetHeader, 24 + SheetTitle, 25 + } from "@/components/ui/sheet"; 26 + import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 27 + import { ClockDashed } from "@/components/icons/clock-dashed"; 28 + import { ScrollArea } from "@/components/ui/scroll-area"; 29 + import MiniCalendarSheet from "./mini-calendar-sheet"; 30 + import { Textarea } from "@/components/ui/textarea"; 31 + import { Button } from "@/components/ui/button"; 32 + import { Input } from "@/components/ui/input"; 33 + import { Label } from "@/components/ui/label"; 34 + import BookmarkPanel from "./bookmark-panel"; 35 + import { useRouter } from "next/navigation"; 36 + import { CountdownTool } from "./countdown"; 37 + import { useState } from "react"; 38 + import { cn } from "@/lib/utils"; 17 39 18 40 const colorOptions = [ 19 41 { value: "bg-blue-500", label: "Blue" }, ··· 25 47 { value: "bg-indigo-500", label: "Indigo" }, 26 48 { value: "bg-orange-500", label: "Orange" }, 27 49 { value: "bg-teal-500", label: "Teal" }, 28 - ] 29 - 50 + ]; 30 51 31 52 interface RightSidebarProps { 32 - onViewChange?: (view: string) => void 33 - onEventClick: (event: any) => void 53 + onViewChange?: (view: string) => void; 54 + onEventClick: (event: any) => void; 34 55 } 35 56 36 - export default function RightSidebar({ onViewChange, onEventClick }: RightSidebarProps) { 37 - const [miniCalendarOpen, setMiniCalendarOpen] = useState(false) 38 - const [selectedDate, setSelectedDate] = useState(new Date()) 39 - const [bookmarkPanelOpen, setBookmarkPanelOpen] = useState(false) 57 + export default function RightSidebar({ 58 + onViewChange, 59 + onEventClick, 60 + }: RightSidebarProps) { 61 + const [miniCalendarOpen, setMiniCalendarOpen] = useState(false); 62 + const [selectedDate, setSelectedDate] = useState(new Date()); 63 + const [bookmarkPanelOpen, setBookmarkPanelOpen] = useState(false); 40 64 const [countdownOpen, setCountdownOpen] = useState(false); 41 65 const router = useRouter(); 42 - // ๅค„็†ๆ—ฅๆœŸ้€‰ๆ‹ฉ 66 + 43 67 const handleDateSelect = (date: Date) => { 44 - setSelectedDate(date) 45 - } 68 + setSelectedDate(date); 69 + }; 46 70 47 - return ( 48 - <> 49 - <div className="w-14 bg-background border-l flex flex-col items-center py-4 absolute right-0 top-16 bottom-0 z-30"> 50 - <div className="flex flex-col items-center space-y-6 flex-1"> 51 - {/* Mini Calendar Button */} 52 - <Button 53 - variant="secondary" 54 - size="icon" 55 - className="rounded-full size-10" 56 - onClick={() => setMiniCalendarOpen(true)} 57 - > 58 - <Calendar className="h-6 w-6 text-black dark:text-white" /> 59 - </Button> 71 + return ( 72 + <> 73 + <div className="w-14 bg-background border-l flex flex-col items-center py-4 absolute right-0 top-16 bottom-0 z-30"> 74 + <div className="flex flex-col items-center space-y-6 flex-1"> 75 + {} 76 + <Button 77 + variant="secondary" 78 + size="icon" 79 + className="rounded-full size-10" 80 + onClick={() => setMiniCalendarOpen(true)} 81 + > 82 + <Calendar className="h-6 w-6 text-black dark:text-white" /> 83 + </Button> 60 84 61 - <Button 62 - variant="secondary" 63 - size="icon" 64 - className="rounded-full size-10" 65 - onClick={() => setBookmarkPanelOpen(true)} 66 - > 67 - <Bookmark className="h-6 w-6 text-black dark:text-white" /> 68 - </Button> 85 + <Button 86 + variant="secondary" 87 + size="icon" 88 + className="rounded-full size-10" 89 + onClick={() => setBookmarkPanelOpen(true)} 90 + > 91 + <Bookmark className="h-6 w-6 text-black dark:text-white" /> 92 + </Button> 69 93 70 - <Button 71 - variant="secondary" 72 - size="icon" 73 - className={cn( 74 - "rounded-full size-10", 75 - countdownOpen && "ring-2 ring-primary" 76 - )} 77 - onClick={() => setCountdownOpen(true)} 78 - > 79 - <ClockDashed className="h-6 w-6 text-black dark:text-white" /> 80 - </Button> 94 + <Button 95 + variant="secondary" 96 + size="icon" 97 + className={cn( 98 + "rounded-full size-10", 99 + countdownOpen && "ring-2 ring-primary", 100 + )} 101 + onClick={() => setCountdownOpen(true)} 102 + > 103 + <ClockDashed className="h-6 w-6 text-black dark:text-white" /> 104 + </Button> 81 105 82 - <CountdownTool 83 - open={countdownOpen} 84 - onOpenChange={setCountdownOpen} 85 - /> 106 + <CountdownTool open={countdownOpen} onOpenChange={setCountdownOpen} /> 107 + </div> 86 108 </div> 87 - </div> 88 109 89 - {/* Mini Calendar Sheet */} 90 - <MiniCalendarSheet 91 - open={miniCalendarOpen} 92 - onOpenChange={setMiniCalendarOpen} 93 - selectedDate={selectedDate} 94 - onDateSelect={handleDateSelect} 95 - /> 110 + {} 111 + <MiniCalendarSheet 112 + open={miniCalendarOpen} 113 + onOpenChange={setMiniCalendarOpen} 114 + selectedDate={selectedDate} 115 + onDateSelect={handleDateSelect} 116 + /> 96 117 97 - <BookmarkPanel 98 - open={bookmarkPanelOpen} 99 - onOpenChange={setBookmarkPanelOpen} 100 - /> 101 - </> 102 - ) 118 + <BookmarkPanel 119 + open={bookmarkPanelOpen} 120 + onOpenChange={setBookmarkPanelOpen} 121 + /> 122 + </> 123 + ); 103 124 }
+394 -335
components/app/views/day-view.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useEffect, useRef, useState } from "react" 4 - import type React from "react" 3 + import { useEffect, useRef, useState } from "react"; 4 + import type React from "react"; 5 5 import { 6 6 ContextMenu, 7 7 ContextMenuContent, 8 8 ContextMenuItem, 9 9 ContextMenuTrigger, 10 - } from "@/components/ui/context-menu" 11 - import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react" 12 - import { format, isSameDay, isWithinInterval, endOfDay, startOfDay, add } from "date-fns" 13 - import { cn } from "@/lib/utils" 14 - import type { CalendarEvent } from "../calendar" 15 - import { translations, type Language } from "@/lib/i18n" 10 + } from "@/components/ui/context-menu"; 11 + import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react"; 12 + import { 13 + format, 14 + isSameDay, 15 + isWithinInterval, 16 + endOfDay, 17 + startOfDay, 18 + add, 19 + } from "date-fns"; 20 + import { cn } from "@/lib/utils"; 21 + import type { CalendarEvent } from "../calendar"; 22 + import { translations, type Language } from "@/lib/i18n"; 16 23 17 24 interface DayViewProps { 18 - date: Date 19 - events: CalendarEvent[] 20 - onEventClick: (event: CalendarEvent) => void 21 - onTimeSlotClick: (date: Date) => void 22 - language: Language 23 - timezone: string 24 - timeFormat: "24h" | "12h" 25 - onEditEvent?: (event: CalendarEvent) => void 26 - onDeleteEvent?: (event: CalendarEvent) => void 27 - onShareEvent?: (event: CalendarEvent) => void 28 - onBookmarkEvent?: (event: CalendarEvent) => void 29 - onEventDrop?: (event: CalendarEvent, newStartDate: Date, newEndDate: Date) => void // ๆ–ฐๅขžๆ‹–ๆ‹ฝไบ‹ไปถๅค„็†ๅ‡ฝๆ•ฐ 25 + date: Date; 26 + events: CalendarEvent[]; 27 + onEventClick: (event: CalendarEvent) => void; 28 + onTimeSlotClick: (date: Date) => void; 29 + language: Language; 30 + timezone: string; 31 + timeFormat: "24h" | "12h"; 32 + onEditEvent?: (event: CalendarEvent) => void; 33 + onDeleteEvent?: (event: CalendarEvent) => void; 34 + onShareEvent?: (event: CalendarEvent) => void; 35 + onBookmarkEvent?: (event: CalendarEvent) => void; 36 + onEventDrop?: ( 37 + event: CalendarEvent, 38 + newStartDate: Date, 39 + newEndDate: Date, 40 + ) => void; 30 41 } 31 42 32 - export default function DayView({ 33 - date, 34 - events, 35 - onEventClick, 36 - onTimeSlotClick, 37 - language, 43 + export default function DayView({ 44 + date, 45 + events, 46 + onEventClick, 47 + onTimeSlotClick, 48 + language, 38 49 timezone, 39 50 timeFormat, 40 51 onEditEvent, 41 52 onDeleteEvent, 42 53 onShareEvent, 43 54 onBookmarkEvent, 44 - onEventDrop 55 + onEventDrop, 45 56 }: DayViewProps) { 46 - const hours = Array.from({ length: 24 }, (_, i) => i) 47 - const scrollContainerRef = useRef<HTMLDivElement>(null) 48 - const hasScrolledRef = useRef(false) 49 - const [currentTime, setCurrentTime] = useState(new Date()) 50 - const t = translations[language] 51 - 52 - // ๆ‹–ๆ‹ฝ็›ธๅ…ณ็Šถๆ€ 53 - const [draggingEvent, setDraggingEvent] = useState<CalendarEvent | null>(null) 54 - const [dragStartPosition, setDragStartPosition] = useState<{ x: number; y: number } | null>(null) 55 - const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>(null) 56 - const [dragPreview, setDragPreview] = useState<{ hour: number; minute: number } | null>(null) 57 - const [dragEventDuration, setDragEventDuration] = useState<number>(0) // ไบ‹ไปถๆŒ็ปญๆ—ถ้—ด๏ผˆๅˆ†้’Ÿ๏ผ‰ 58 - const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null) 59 - const isDraggingRef = useRef(false) 57 + const hours = Array.from({ length: 24 }, (_, i) => i); 58 + const scrollContainerRef = useRef<HTMLDivElement>(null); 59 + const hasScrolledRef = useRef(false); 60 + const [currentTime, setCurrentTime] = useState(new Date()); 61 + const t = translations[language]; 62 + 63 + const [draggingEvent, setDraggingEvent] = useState<CalendarEvent | null>( 64 + null, 65 + ); 66 + const [dragStartPosition, setDragStartPosition] = useState<{ 67 + x: number; 68 + y: number; 69 + } | null>(null); 70 + const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>( 71 + null, 72 + ); 73 + const [dragPreview, setDragPreview] = useState<{ 74 + hour: number; 75 + minute: number; 76 + } | null>(null); 77 + const [dragEventDuration, setDragEventDuration] = useState<number>(0); 78 + const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 79 + const isDraggingRef = useRef(false); 60 80 61 81 const isDark = 62 82 typeof document !== "undefined" && 63 - document.documentElement.classList.contains("dark") 83 + document.documentElement.classList.contains("dark"); 64 84 65 85 const menuLabels = { 66 86 edit: t.edit, 67 87 share: t.share, 68 88 bookmark: t.bookmark, 69 89 delete: t.delete, 70 - } 90 + }; 71 91 72 92 const formatTime = (hour: number) => { 73 93 if (timeFormat === "12h") { 74 - const period = hour >= 12 ? "PM" : "AM" 75 - const twelveHour = hour % 12 || 12 76 - return `${twelveHour} ${period}` 94 + const period = hour >= 12 ? "PM" : "AM"; 95 + const twelveHour = hour % 12 || 12; 96 + return `${twelveHour} ${period}`; 77 97 } 78 - return `${hour.toString().padStart(2, "0")}:00` 79 - } 80 - 98 + return `${hour.toString().padStart(2, "0")}:00`; 99 + }; 81 100 82 101 const formatHourMinute = (hour: number, minute: number) => { 83 102 if (timeFormat === "12h") { 84 - const period = hour >= 12 ? "PM" : "AM" 85 - const twelveHour = hour % 12 || 12 86 - return `${twelveHour}:${minute.toString().padStart(2, "0")} ${period}` 103 + const period = hour >= 12 ? "PM" : "AM"; 104 + const twelveHour = hour % 12 || 12; 105 + return `${twelveHour}:${minute.toString().padStart(2, "0")} ${period}`; 87 106 } 88 - return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}` 89 - } 107 + return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; 108 + }; 90 109 91 110 const formatDateWithTimezone = (date: Date) => { 92 111 const options: Intl.DateTimeFormatOptions = { ··· 94 113 minute: "2-digit", 95 114 hour12: timeFormat === "12h", 96 115 timeZone: timezone, 97 - } 98 - return new Intl.DateTimeFormat(language, options).format(date) 99 - } 116 + }; 117 + return new Intl.DateTimeFormat(language, options).format(date); 118 + }; 100 119 101 120 function getDarkerColorClass(color: string) { 102 121 const colorMapping: Record<string, string> = { 103 - 'bg-[#E6F6FD]': '#3B82F6', 104 - 'bg-[#E7F8F2]': '#10B981', 105 - 'bg-[#FEF5E6]': '#F59E0B', 106 - 'bg-[#FFE4E6]': '#EF4444', 107 - 'bg-[#F3EEFE]': '#8B5CF6', 108 - 'bg-[#FCE7F3]': '#EC4899', 109 - 'bg-[#EEF2FF]': '#6366F1', 110 - 'bg-[#FFF0E5]': '#FB923C', 111 - 'bg-[#E6FAF7]': '#14B8A6', 112 - } 122 + "bg-[#E6F6FD]": "#3B82F6", 123 + "bg-[#E7F8F2]": "#10B981", 124 + "bg-[#FEF5E6]": "#F59E0B", 125 + "bg-[#FFE4E6]": "#EF4444", 126 + "bg-[#F3EEFE]": "#8B5CF6", 127 + "bg-[#FCE7F3]": "#EC4899", 128 + "bg-[#EEF2FF]": "#6366F1", 129 + "bg-[#FFF0E5]": "#FB923C", 130 + "bg-[#E6FAF7]": "#14B8A6", 131 + }; 113 132 114 - return colorMapping[color] || '#3A3A3A'; 133 + return colorMapping[color] || "#3A3A3A"; 115 134 } 116 135 117 136 function getEventBackgroundColor(color: string) { 118 - if (!isDark) return undefined 137 + if (!isDark) return undefined; 119 138 120 139 const darkModeColorMapping: Record<string, string> = { 121 - 'bg-[#E6F6FD]': '#2F4655', 122 - 'bg-[#E7F8F2]': '#2D4935', 123 - 'bg-[#FEF5E6]': '#4F3F1B', 124 - 'bg-[#FFE4E6]': '#6C2920', 125 - 'bg-[#F3EEFE]': '#483A63', 126 - 'bg-[#FCE7F3]': '#5A334A', 127 - 'bg-[#E6FAF7]': '#1F4A47', 128 - } 140 + "bg-[#E6F6FD]": "#2F4655", 141 + "bg-[#E7F8F2]": "#2D4935", 142 + "bg-[#FEF5E6]": "#4F3F1B", 143 + "bg-[#FFE4E6]": "#6C2920", 144 + "bg-[#F3EEFE]": "#483A63", 145 + "bg-[#FCE7F3]": "#5A334A", 146 + "bg-[#E6FAF7]": "#1F4A47", 147 + }; 129 148 130 - return darkModeColorMapping[color] 149 + return darkModeColorMapping[color]; 131 150 } 132 151 133 - // ๅˆคๆ–ญไบ‹ไปถๆ˜ฏๅฆไธบๅ…จๅคฉไบ‹ไปถ 134 152 const isAllDayEvent = (event: CalendarEvent) => { 135 - if (event.isAllDay) return true 136 - 137 - const start = new Date(event.startDate) 138 - const end = new Date(event.endDate) 139 - 140 - // ๆฃ€ๆŸฅๆ˜ฏๅฆไธบ00:00-23:59็š„ไบ‹ไปถๆˆ–่ทจๅคœไบ‹ไปถ(00:00-ๆฌกๆ—ฅ00:00) 141 - const isFullDay = 142 - start.getHours() === 0 && 143 - start.getMinutes() === 0 && 144 - ((end.getHours() === 23 && end.getMinutes() === 59) || 145 - (end.getHours() === 0 && end.getMinutes() === 0 && end.getDate() !== start.getDate())) 146 - 147 - return isFullDay 148 - } 153 + if (event.isAllDay) return true; 154 + 155 + const start = new Date(event.startDate); 156 + const end = new Date(event.endDate); 157 + 158 + const isFullDay = 159 + start.getHours() === 0 && 160 + start.getMinutes() === 0 && 161 + ((end.getHours() === 23 && end.getMinutes() === 59) || 162 + (end.getHours() === 0 && 163 + end.getMinutes() === 0 && 164 + end.getDate() !== start.getDate())); 149 165 150 - // ๆฃ€ๆŸฅไบ‹ไปถๆ˜ฏๅฆ่ทจๅคฉ 166 + return isFullDay; 167 + }; 168 + 151 169 const isMultiDayEvent = (start: Date, end: Date) => { 152 170 return ( 153 171 start.getDate() !== end.getDate() || 154 172 start.getMonth() !== end.getMonth() || 155 173 start.getFullYear() !== end.getFullYear() 156 - ) 157 - } 174 + ); 175 + }; 158 176 159 - // ๅฐ†ไบ‹ไปถๅˆ†ไธบๅ…จๅคฉไบ‹ไปถๅ’Œๆญฃๅธธไบ‹ไปถ 160 177 const separateEvents = (dayEvents: CalendarEvent[]) => { 161 - const allDayEvents: CalendarEvent[] = [] 162 - const regularEvents: CalendarEvent[] = [] 178 + const allDayEvents: CalendarEvent[] = []; 179 + const regularEvents: CalendarEvent[] = []; 163 180 164 - dayEvents.forEach(event => { 181 + dayEvents.forEach((event) => { 165 182 if (isAllDayEvent(event)) { 166 - allDayEvents.push(event) 183 + allDayEvents.push(event); 167 184 } else { 168 - regularEvents.push(event) 185 + regularEvents.push(event); 169 186 } 170 - }) 187 + }); 171 188 172 - return { allDayEvents, regularEvents } 173 - } 189 + return { allDayEvents, regularEvents }; 190 + }; 174 191 175 - // ไฟฎๆ”น่‡ชๅŠจๆปšๅŠจๅˆฐๅฝ“ๅ‰ๆ—ถ้—ด็š„ๆ•ˆๆžœ,ๅชๅœจ็ป„ไปถๆŒ‚่ฝฝๆ—ถๆ‰ง่กŒไธ€ๆฌก 176 192 useEffect(() => { 177 - // ๅชๅœจ็ป„ไปถๆŒ‚่ฝฝๆ—ถๆ‰ง่กŒไธ€ๆฌกๆปšๅŠจ 178 193 if (!hasScrolledRef.current && scrollContainerRef.current) { 179 - const now = new Date() 180 - const currentHour = now.getHours() 194 + const now = new Date(); 195 + const currentHour = now.getHours(); 181 196 182 - // ๆ‰พๅˆฐๅฏนๅบ”ๅฝ“ๅ‰ๅฐๆ—ถ็š„DOMๅ…ƒ็ด  183 - const hourElements = scrollContainerRef.current.querySelectorAll(".h-\\[60px\\]") 197 + const hourElements = 198 + scrollContainerRef.current.querySelectorAll(".h-\\[60px\\]"); 184 199 if (hourElements.length > 0 && currentHour < hourElements.length) { 185 - // ่Žทๅ–ๅฝ“ๅ‰ๅฐๆ—ถ็š„ๅ…ƒ็ด  186 - const currentHourElement = hourElements[currentHour + 1] // +1 ๆ˜ฏๅ› ไธบ็ฌฌไธ€่กŒๆ˜ฏๆ—ถ้—ดๆ ‡็ญพ 200 + const currentHourElement = hourElements[currentHour + 1]; 187 201 188 202 if (currentHourElement) { 189 - // ๆปšๅŠจๅˆฐๅฝ“ๅ‰ๅฐๆ—ถ็š„ไฝ็ฝฎ,ๅนถๅ‘ไธŠๅ็งป100pxไฝฟๅ…ถๅœจ่ง†ๅ›พไธญ้—ดๅไธŠ 190 203 scrollContainerRef.current.scrollTo({ 191 204 top: (currentHourElement as HTMLElement).offsetTop - 100, 192 205 behavior: "auto", 193 - }) 206 + }); 194 207 195 - // ๆ ‡่ฎฐๅทฒ็ปๆปšๅŠจ่ฟ‡ 196 - hasScrolledRef.current = true 208 + hasScrolledRef.current = true; 197 209 } 198 210 } 199 211 } 200 - }, [date]) 212 + }, [date]); 201 213 202 - // ไฟฎๆ”นๆ—ถ้—ดๆ›ดๆ–ฐ้€ป่พ‘,ๅชๆ›ดๆ–ฐๆ—ถ้—ด็บฟไฝ็ฝฎ,ไธๆ”นๅ˜ๆปšๅŠจไฝ็ฝฎ 203 214 useEffect(() => { 204 - // ็ซ‹ๅณๆ›ดๆ–ฐๆ—ถ้—ด 205 - setCurrentTime(new Date()) 215 + setCurrentTime(new Date()); 206 216 207 - // ่ฎพ็ฝฎๅฎšๆ—ถๅ™จๆฏๅˆ†้’Ÿๆ›ดๆ–ฐๆ—ถ้—ด 208 217 const interval = setInterval(() => { 209 - setCurrentTime(new Date()) 210 - // ไธๅ†่ฐƒ็”จๆปšๅŠจๅ‡ฝๆ•ฐ 211 - }, 60000) // 60000 ms = 1 ๅˆ†้’Ÿ 218 + setCurrentTime(new Date()); 219 + }, 60000); 212 220 213 - return () => clearInterval(interval) 214 - }, []) 221 + return () => clearInterval(interval); 222 + }, []); 215 223 216 - // ๆทปๅŠ ๅ…จๅฑ€mouseup/mousemove็›‘ๅฌๅ™จๆฅๅค„็†ๆ‹–ๆ‹ฝ 217 224 useEffect(() => { 218 225 const handleMouseMove = (e: MouseEvent) => { 219 - if (draggingEvent && isDraggingRef.current && dragStartPosition && scrollContainerRef.current) { 220 - // ่ฎก็ฎ—้ผ ๆ ‡็›ธๅฏนไบŽๆ—ฅๅކๅฎนๅ™จ็š„ไฝ็ฝฎ 221 - const containerRect = scrollContainerRef.current.getBoundingClientRect(); 222 - 223 - // ่ฎก็ฎ—ๅฐๆ—ถๅ’Œๅˆ†้’Ÿ 224 - const relativeY = e.clientY - containerRect.top + scrollContainerRef.current.scrollTop; 226 + if ( 227 + draggingEvent && 228 + isDraggingRef.current && 229 + dragStartPosition && 230 + scrollContainerRef.current 231 + ) { 232 + const containerRect = 233 + scrollContainerRef.current.getBoundingClientRect(); 234 + 235 + const relativeY = 236 + e.clientY - containerRect.top + scrollContainerRef.current.scrollTop; 225 237 const hour = Math.floor(relativeY / 60); 226 - const minute = Math.floor((relativeY % 60) / 15) * 15; // ๆŒ‰15ๅˆ†้’Ÿๅ–ๆ•ด 227 - 238 + const minute = Math.floor((relativeY % 60) / 15) * 15; 239 + 228 240 setDragPreview({ 229 241 hour: hour, 230 - minute: minute 242 + minute: minute, 231 243 }); 232 244 } 233 245 }; 234 - 246 + 235 247 const handleMouseUp = () => { 236 - if (draggingEvent && isDraggingRef.current && dragPreview && onEventDrop) { 237 - // ่ฎก็ฎ—ๆ–ฐ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด 248 + if ( 249 + draggingEvent && 250 + isDraggingRef.current && 251 + dragPreview && 252 + onEventDrop 253 + ) { 238 254 const newStartDate = new Date(date); 239 255 newStartDate.setHours(dragPreview.hour, dragPreview.minute, 0, 0); 240 - 241 - // ่ฎก็ฎ—ๆ–ฐ็š„็ป“ๆŸๆ—ถ้—ด (ไฟๆŒไบ‹ไปถๆŒ็ปญๆ—ถ้—ดไธๅ˜) 256 + 242 257 const newEndDate = add(newStartDate, { minutes: dragEventDuration }); 243 - 244 - // ่ฐƒ็”จๅ›ž่ฐƒๅ‡ฝๆ•ฐๆ›ดๆ–ฐไบ‹ไปถ 258 + 245 259 onEventDrop(draggingEvent, newStartDate, newEndDate); 246 260 } 247 - 248 - // ๆธ…้™คๆ‹–ๆ‹ฝ็Šถๆ€ 261 + 249 262 isDraggingRef.current = false; 250 263 setDraggingEvent(null); 251 264 setDragStartPosition(null); 252 265 setDragOffset(null); 253 266 setDragPreview(null); 254 267 }; 255 - 256 - // ๅฆ‚ๆžœๆญฃๅœจๆ‹–ๆ‹ฝ๏ผŒๆทปๅŠ ๅ…จๅฑ€ไบ‹ไปถ็›‘ๅฌๅ™จ 268 + 257 269 if (draggingEvent) { 258 - document.addEventListener('mousemove', handleMouseMove); 259 - document.addEventListener('mouseup', handleMouseUp); 270 + document.addEventListener("mousemove", handleMouseMove); 271 + document.addEventListener("mouseup", handleMouseUp); 260 272 } 261 - 273 + 262 274 return () => { 263 - document.removeEventListener('mousemove', handleMouseMove); 264 - document.removeEventListener('mouseup', handleMouseUp); 275 + document.removeEventListener("mousemove", handleMouseMove); 276 + document.removeEventListener("mouseup", handleMouseUp); 265 277 }; 266 - }, [draggingEvent, dragStartPosition, dragPreview, onEventDrop, date, dragEventDuration]); 278 + }, [ 279 + draggingEvent, 280 + dragStartPosition, 281 + dragPreview, 282 + onEventDrop, 283 + date, 284 + dragEventDuration, 285 + ]); 267 286 268 - // ่Žทๅ–ๅฝ“ๅคฉ็š„ไบ‹ไปถๅธƒๅฑ€ 269 287 const layoutEvents = (events: CalendarEvent[]) => { 270 - if (!events || events.length === 0) return [] 288 + if (!events || events.length === 0) return []; 271 289 272 - // ๆŒ‰ๅผ€ๅง‹ๆ—ถ้—ดๆŽ’ๅบ 273 290 const sortedEvents = [...events].sort((a, b) => { 274 - const startA = new Date(a.startDate).getTime() 275 - const startB = new Date(b.startDate).getTime() 276 - return startA - startB 277 - }) 291 + const startA = new Date(a.startDate).getTime(); 292 + const startB = new Date(b.startDate).getTime(); 293 + return startA - startB; 294 + }); 278 295 279 - // ๅˆ›ๅปบๆ—ถ้—ดๆฎตๆ•ฐ็ป„,ๆฏไธชๆ—ถ้—ดๆฎตๅŒ…ๅซๅœจ่ฏฅๆ—ถ้—ดๆฎตๅ†…ๆดป่ทƒ็š„ไบ‹ไปถ 280 - type TimePoint = { time: number; isStart: boolean; eventIndex: number } 281 - const timePoints: TimePoint[] = [] 296 + type TimePoint = { time: number; isStart: boolean; eventIndex: number }; 297 + const timePoints: TimePoint[] = []; 282 298 283 - // ๆทปๅŠ ๆ‰€ๆœ‰ไบ‹ไปถ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด็‚น 284 299 sortedEvents.forEach((event, index) => { 285 - const start = new Date(event.startDate) 286 - const end = new Date(event.endDate) 287 - 288 - timePoints.push({ time: start.getTime(), isStart: true, eventIndex: index }) 289 - timePoints.push({ time: end.getTime(), isStart: false, eventIndex: index }) 290 - }) 300 + const start = new Date(event.startDate); 301 + const end = new Date(event.endDate); 302 + 303 + timePoints.push({ 304 + time: start.getTime(), 305 + isStart: true, 306 + eventIndex: index, 307 + }); 308 + timePoints.push({ 309 + time: end.getTime(), 310 + isStart: false, 311 + eventIndex: index, 312 + }); 313 + }); 291 314 292 - // ๆŒ‰ๆ—ถ้—ดๆŽ’ๅบ 293 315 timePoints.sort((a, b) => { 294 - // ๅฆ‚ๆžœๆ—ถ้—ด็›ธๅŒ,็ป“ๆŸๆ—ถ้—ด็‚นๆŽ’ๅœจๅผ€ๅง‹ๆ—ถ้—ด็‚นไน‹ๅ‰ 295 316 if (a.time === b.time) { 296 - return a.isStart ? 1 : -1 317 + return a.isStart ? 1 : -1; 297 318 } 298 - return a.time - b.time 299 - }) 319 + return a.time - b.time; 320 + }); 300 321 301 - // ๅค„็†ๆฏไธชๆ—ถ้—ดๆฎต 302 322 const eventLayouts: Array<{ 303 - event: CalendarEvent 304 - column: number 305 - totalColumns: number 306 - }> = [] 323 + event: CalendarEvent; 324 + column: number; 325 + totalColumns: number; 326 + }> = []; 307 327 308 - // ๅฝ“ๅ‰ๆดป่ทƒ็š„ไบ‹ไปถ 309 - const activeEvents = new Set<number>() 310 - // ไบ‹ไปถๅˆฐๅˆ—็š„ๆ˜ ๅฐ„ 311 - const eventToColumn = new Map<number, number>() 328 + const activeEvents = new Set<number>(); 329 + 330 + const eventToColumn = new Map<number, number>(); 312 331 313 332 for (let i = 0; i < timePoints.length; i++) { 314 - const point = timePoints[i] 333 + const point = timePoints[i]; 315 334 316 335 if (point.isStart) { 317 - // ไบ‹ไปถๅผ€ๅง‹ 318 - activeEvents.add(point.eventIndex) 336 + activeEvents.add(point.eventIndex); 319 337 320 - // ๆ‰พๅˆฐๅฏ็”จ็š„ๆœ€ๅฐๅˆ—ๅท 321 - let column = 0 322 - const usedColumns = new Set<number>() 338 + let column = 0; 339 + const usedColumns = new Set<number>(); 323 340 324 - // ๆ”ถ้›†ๅฝ“ๅ‰ๅทฒไฝฟ็”จ็š„ๅˆ— 325 341 activeEvents.forEach((eventIndex) => { 326 342 if (eventToColumn.has(eventIndex)) { 327 - usedColumns.add(eventToColumn.get(eventIndex)!) 343 + usedColumns.add(eventToColumn.get(eventIndex)!); 328 344 } 329 - }) 345 + }); 330 346 331 - // ๆ‰พๅˆฐ็ฌฌไธ€ไธชๆœชไฝฟ็”จ็š„ๅˆ— 332 347 while (usedColumns.has(column)) { 333 - column++ 348 + column++; 334 349 } 335 350 336 - // ๅˆ†้…ๅˆ— 337 - eventToColumn.set(point.eventIndex, column) 351 + eventToColumn.set(point.eventIndex, column); 338 352 } else { 339 - // ไบ‹ไปถ็ป“ๆŸ 340 - activeEvents.delete(point.eventIndex) 353 + activeEvents.delete(point.eventIndex); 341 354 } 342 355 343 - // ๅฆ‚ๆžœๆ˜ฏๆœ€ๅŽไธ€ไธชๆ—ถ้—ด็‚นๆˆ–ไธ‹ไธ€ไธชๆ—ถ้—ด็‚นไธŽๅฝ“ๅ‰ไธๅŒ,ๅค„็†ๅฝ“ๅ‰ๆ—ถ้—ดๆฎต 344 - if (i === timePoints.length - 1 || timePoints[i + 1].time !== point.time) { 345 - // ่ฎก็ฎ—ๅฝ“ๅ‰ๆดป่ทƒไบ‹ไปถ็š„ๅธƒๅฑ€ 356 + if ( 357 + i === timePoints.length - 1 || 358 + timePoints[i + 1].time !== point.time 359 + ) { 346 360 const totalColumns = 347 - activeEvents.size > 0 ? Math.max(...Array.from(activeEvents).map((idx) => eventToColumn.get(idx)!)) + 1 : 0 361 + activeEvents.size > 0 362 + ? Math.max( 363 + ...Array.from(activeEvents).map( 364 + (idx) => eventToColumn.get(idx)!, 365 + ), 366 + ) + 1 367 + : 0; 348 368 349 - // ๆ›ดๆ–ฐๆ‰€ๆœ‰ๆดป่ทƒไบ‹ไปถ็š„ๆ€ปๅˆ—ๆ•ฐ 350 369 activeEvents.forEach((eventIndex) => { 351 - const column = eventToColumn.get(eventIndex)! 352 - const event = sortedEvents[eventIndex] 370 + const column = eventToColumn.get(eventIndex)!; 371 + const event = sortedEvents[eventIndex]; 353 372 354 - // ๆฃ€ๆŸฅๆ˜ฏๅฆๅทฒ็ปๆทปๅŠ ่ฟ‡่ฟ™ไธชไบ‹ไปถ 355 - const existingLayout = eventLayouts.find((layout) => layout.event.id === event.id) 373 + const existingLayout = eventLayouts.find( 374 + (layout) => layout.event.id === event.id, 375 + ); 356 376 357 377 if (!existingLayout) { 358 378 eventLayouts.push({ 359 379 event, 360 380 column, 361 381 totalColumns: Math.max(totalColumns, 1), 362 - }) 382 + }); 363 383 } 364 - }) 384 + }); 365 385 } 366 386 } 367 387 368 - return eventLayouts 369 - } 388 + return eventLayouts; 389 + }; 370 390 371 - // ๅค„็†ไบ‹ไปถๆ‹–ๆ‹ฝๅผ€ๅง‹ 372 391 const handleEventDragStart = (event: CalendarEvent, e: React.MouseEvent) => { 373 392 e.preventDefault(); 374 393 e.stopPropagation(); 375 - 376 - // ไฝฟ็”จๅฎšๆ—ถๅ™จๆจกๆ‹Ÿ้•ฟๆŒ‰ๆ•ˆๆžœ๏ผŒ็บฆ300ๆฏซ็ง’ 394 + 377 395 longPressTimeoutRef.current = setTimeout(() => { 378 396 const start = new Date(event.startDate); 379 397 const end = new Date(event.endDate); 380 - 381 - // ่ฎก็ฎ—ไบ‹ไปถๆŒ็ปญๆ—ถ้—ด๏ผˆๅˆ†้’Ÿ๏ผ‰ 398 + 382 399 const durationMs = end.getTime() - start.getTime(); 383 400 const durationMinutes = Math.round(durationMs / (1000 * 60)); 384 - 401 + 385 402 setDraggingEvent(event); 386 403 setDragStartPosition({ x: e.clientX, y: e.clientY }); 387 404 setDragEventDuration(durationMinutes); 388 405 isDraggingRef.current = true; 389 406 }, 300); 390 407 }; 391 - 392 - // ๅค„็†ไบ‹ไปถๆ‹–ๆ‹ฝ็ป“ๆŸ 408 + 393 409 const handleEventDragEnd = () => { 394 410 if (longPressTimeoutRef.current) { 395 411 clearTimeout(longPressTimeoutRef.current); ··· 397 413 } 398 414 }; 399 415 400 - // ๅค„็†ๆ—ถ้—ดๆ ผๅญ็‚นๅ‡ป,ๆ นๆฎ็‚นๅ‡ปไฝ็ฝฎ็กฎๅฎšๆ›ด็ฒพ็กฎ็š„ๆ—ถ้—ด 401 - const handleTimeSlotClick = (hour: number, event: React.MouseEvent<HTMLDivElement>) => { 402 - // ่Žทๅ–็‚นๅ‡ปไฝ็ฝฎๅœจๆ—ถ้—ดๆ ผๅญๅ†…็š„็›ธๅฏนไฝ็ฝฎ 403 - const rect = event.currentTarget.getBoundingClientRect() 404 - const relativeY = event.clientY - rect.top 405 - const cellHeight = rect.height 416 + const handleTimeSlotClick = ( 417 + hour: number, 418 + event: React.MouseEvent<HTMLDivElement>, 419 + ) => { 420 + const rect = event.currentTarget.getBoundingClientRect(); 421 + const relativeY = event.clientY - rect.top; 422 + const cellHeight = rect.height; 406 423 407 - // ๆ นๆฎ็‚นๅ‡ปไฝ็ฝฎ็กฎๅฎšๅˆ†้’Ÿๆ•ฐ 408 - // ๅฆ‚ๆžœ็‚นๅ‡ปๅœจๆ ผๅญ็š„ไธŠๅŠ้ƒจๅˆ†,ๅˆ†้’Ÿไธบ0,ๅฆๅˆ™ไธบ30 409 - const minutes = relativeY < cellHeight / 2 ? 0 : 30 424 + const minutes = relativeY < cellHeight / 2 ? 0 : 30; 410 425 411 - // ๅˆ›ๅปบไธ€ไธชๆ–ฐ็š„ๆ—ฅๆœŸๅฏน่ฑก,่ฎพ็ฝฎไธบๅฝ“ๅ‰ๆ—ฅๆœŸ็š„ๆŒ‡ๅฎšๅฐๆ—ถๅ’Œๅˆ†้’Ÿ 412 - const clickTime = new Date(date) 413 - clickTime.setHours(hour, minutes, 0, 0) 426 + const clickTime = new Date(date); 427 + clickTime.setHours(hour, minutes, 0, 0); 414 428 415 - // ่ฐƒ็”จไผ ๅ…ฅ็š„ๅ›ž่ฐƒๅ‡ฝๆ•ฐ 416 - onTimeSlotClick(clickTime) 417 - } 429 + onTimeSlotClick(clickTime); 430 + }; 418 431 419 - // ๆธฒๆŸ“ๅ…จๅคฉไบ‹ไปถ็š„ๅ‡ฝๆ•ฐ 420 432 const renderAllDayEvents = (allDayEvents: CalendarEvent[]) => { 421 - // ่ฎพ็ฝฎไบ‹ไปถไน‹้—ด็š„้—ด้š”ๅคงๅฐ 422 433 const eventSpacing = 3; 423 - 434 + 424 435 return allDayEvents.map((event, index) => ( 425 436 <ContextMenu key={`allday-${event.id}`}> 426 437 <ContextMenuTrigger asChild> 427 438 <div 428 - className={cn("relative rounded-lg p-1 text-xs cursor-pointer overflow-hidden", event.color)} 439 + className={cn( 440 + "relative rounded-lg p-1 text-xs cursor-pointer overflow-hidden", 441 + event.color, 442 + )} 429 443 style={{ 430 444 height: "20px", 431 445 top: index * (20 + eventSpacing) + "px", ··· 440 454 onMouseUp={handleEventDragEnd} 441 455 onMouseLeave={handleEventDragEnd} 442 456 onClick={(e) => { 443 - e.stopPropagation() 457 + e.stopPropagation(); 444 458 if (!isDraggingRef.current) { 445 - onEventClick(event) 459 + onEventClick(event); 446 460 } 447 461 }} 448 462 > 449 - <div 450 - className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 451 - style={{ backgroundColor: getDarkerColorClass(event.color) }} 463 + <div 464 + className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 465 + style={{ backgroundColor: getDarkerColorClass(event.color) }} 452 466 /> 453 - <div className="pl-1.5 truncate" style={{ color: getDarkerColorClass(event.color) }}> 467 + <div 468 + className="pl-1.5 truncate" 469 + style={{ color: getDarkerColorClass(event.color) }} 470 + > 454 471 {event.title} 455 472 </div> 456 473 </div> ··· 469 486 <Bookmark className="mr-2 h-4 w-4" /> 470 487 {menuLabels.bookmark} 471 488 </ContextMenuItem> 472 - <ContextMenuItem onClick={() => onDeleteEvent?.(event)} className="text-red-600"> 489 + <ContextMenuItem 490 + onClick={() => onDeleteEvent?.(event)} 491 + className="text-red-600" 492 + > 473 493 <Trash2 className="mr-2 h-4 w-4" /> 474 494 {menuLabels.delete} 475 495 </ContextMenuItem> 476 496 </ContextMenuContent> 477 497 </ContextMenu> 478 - )) 479 - } 498 + )); 499 + }; 480 500 481 - // ๆธฒๆŸ“ๆ‹–ๆ‹ฝ้ข„่งˆ 482 501 const renderDragPreview = () => { 483 502 if (!dragPreview || !draggingEvent) return null; 484 - 503 + 485 504 const startMinutes = dragPreview.hour * 60 + dragPreview.minute; 486 505 const endMinutes = startMinutes + dragEventDuration; 487 - 506 + 488 507 return ( 489 508 <div 490 - className={cn("absolute rounded-lg p-2 text-sm overflow-hidden", draggingEvent.color)} 509 + className={cn( 510 + "absolute rounded-lg p-2 text-sm overflow-hidden", 511 + draggingEvent.color, 512 + )} 491 513 style={{ 492 514 top: `${startMinutes}px`, 493 515 height: `${dragEventDuration}px`, 494 516 opacity: 0.6, 495 517 width: `calc(100% - 4px)`, 496 - left: '2px', 518 + left: "2px", 497 519 zIndex: 100, 498 - border: '2px dashed white', 499 - pointerEvents: 'none', 520 + border: "2px dashed white", 521 + pointerEvents: "none", 500 522 }} 501 523 > 502 - <div className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 503 - style={{ backgroundColor: getDarkerColorClass(draggingEvent.color) }} 524 + <div 525 + className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 526 + style={{ backgroundColor: getDarkerColorClass(draggingEvent.color) }} 504 527 /> 505 528 <div className="pl-1"> 506 - <div className="font-medium truncate" style={{ color: getDarkerColorClass(draggingEvent.color) }}>{draggingEvent.title}</div> 529 + <div 530 + className="font-medium truncate" 531 + style={{ color: getDarkerColorClass(draggingEvent.color) }} 532 + > 533 + {draggingEvent.title} 534 + </div> 507 535 {dragEventDuration >= 40 && ( 508 536 <div className="text-xs text-white/90 truncate"> 509 - {formatHourMinute(dragPreview.hour, dragPreview.minute)} - {formatHourMinute(Math.floor(endMinutes / 60), endMinutes % 60)} 537 + {formatHourMinute(dragPreview.hour, dragPreview.minute)} -{" "} 538 + {formatHourMinute(Math.floor(endMinutes / 60), endMinutes % 60)} 510 539 </div> 511 540 )} 512 541 </div> ··· 514 543 ); 515 544 }; 516 545 517 - // ่Žทๅ–ๅฝ“ๅ‰ๆ—ฅๆœŸ็š„ไบ‹ไปถ 518 - const dayEvents = events.filter(event => { 546 + const dayEvents = events.filter((event) => { 519 547 const start = new Date(event.startDate); 520 548 const end = new Date(event.endDate); 521 - 522 - // ้žๅ…จๅคฉไบ‹ไปถไฝฟ็”จๅŽŸๆœ‰้€ป่พ‘ๆฃ€ๆŸฅ 549 + 523 550 if (!isAllDayEvent(event)) { 524 551 if (isSameDay(start, date)) return true; 525 - 552 + 526 553 if (isMultiDayEvent(start, end)) { 527 554 return isWithinInterval(date, { start, end }); 528 555 } 529 - 556 + 530 557 return false; 531 558 } 532 559 533 560 if (isMultiDayEvent(start, end)) { 534 561 return isSameDay(start, date); 535 562 } 536 - 563 + 537 564 return isSameDay(start, date); 538 565 }); 539 - 540 - // ๅˆ†็ฆปๅ…จๅคฉไบ‹ไปถๅ’Œๆ™ฎ้€šไบ‹ไปถ 566 + 541 567 const { allDayEvents, regularEvents } = separateEvents(dayEvents); 542 - 543 - // ่ฎก็ฎ—ๅ…จๅคฉไบ‹ไปถๅŒบๅŸŸ็š„้ซ˜ๅบฆ 544 - const eventSpacing = 2; // ไฟๆŒไธŽrenderAllDayEventsๅ‡ฝๆ•ฐไธญ็›ธๅŒ็š„ๅ€ผ 545 - const allDayEventsHeight = allDayEvents.length > 0 546 - ? allDayEvents.length * 20 + (allDayEvents.length - 1) * eventSpacing 547 - : 0; 548 - 549 - // ๅฏนๆ™ฎ้€šไบ‹ไปถ่ฟ›่กŒๅธƒๅฑ€ 568 + 569 + const eventSpacing = 2; 570 + const allDayEventsHeight = 571 + allDayEvents.length > 0 572 + ? allDayEvents.length * 20 + (allDayEvents.length - 1) * eventSpacing 573 + : 0; 574 + 550 575 const eventLayouts = layoutEvents(regularEvents); 551 576 552 577 return ( ··· 556 581 <div className="text-sm text-muted-foreground"> 557 582 {t.weekdays[date.getDay()]} 558 583 </div> 559 - <div className="text-3xl font-semibold text-[#0066ff] green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]">{format(date, "d")}</div> 584 + <div className="text-3xl font-semibold text-[#0066ff] green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]"> 585 + {format(date, "d")} 586 + </div> 560 587 </div> 561 588 <div className="p-2"> 562 - {/* ๅ…จๅคฉไบ‹ไปถๅŒบๅŸŸ */} 563 - <div 564 - className="relative" 589 + {} 590 + <div 591 + className="relative" 565 592 style={{ height: allDayEventsHeight + "px" }} 566 593 > 567 594 {renderAllDayEvents(allDayEvents)} ··· 569 596 </div> 570 597 </div> 571 598 572 - <div className="flex-1 grid grid-cols-[100px_1fr] overflow-auto" ref={scrollContainerRef}> 599 + <div 600 + className="flex-1 grid grid-cols-[100px_1fr] overflow-auto" 601 + ref={scrollContainerRef} 602 + > 573 603 <div className="text-sm text-muted-foreground"> 574 604 {hours.map((hour) => ( 575 605 <div key={hour} className="h-[60px] relative"> 576 - <span className={cn("absolute right-4", hour === 0 ? "top-0" : "top-0 -translate-y-1/2")}>{formatTime(hour)}</span> 606 + <span 607 + className={cn( 608 + "absolute right-4", 609 + hour === 0 ? "top-0" : "top-0 -translate-y-1/2", 610 + )} 611 + > 612 + {formatTime(hour)} 613 + </span> 577 614 </div> 578 615 ))} 579 616 </div> ··· 590 627 {eventLayouts.map(({ event, column, totalColumns }) => { 591 628 const start = new Date(event.startDate); 592 629 const end = new Date(event.endDate); 593 - 594 - const startMinutes = start.getHours() * 60 + start.getMinutes() 595 - const endMinutes = end.getHours() * 60 + end.getMinutes() 596 - const duration = endMinutes - startMinutes 630 + 631 + const startMinutes = start.getHours() * 60 + start.getMinutes(); 632 + const endMinutes = end.getHours() * 60 + end.getMinutes(); 633 + const duration = endMinutes - startMinutes; 597 634 598 - // ็กฎไฟไบ‹ไปถไธไผš่ถ…ๅ‡บๅฝ“ๅคฉ็š„ๆ—ถ้—ด่Œƒๅ›ด 599 - const maxEndMinutes = 24 * 60 // ๆœ€ๅคงๅˆฐๅˆๅคœ 600 - const displayDuration = Math.min(duration, maxEndMinutes - startMinutes) 635 + const maxEndMinutes = 24 * 60; 636 + const displayDuration = Math.min( 637 + duration, 638 + maxEndMinutes - startMinutes, 639 + ); 601 640 602 - // ่ฎพ็ฝฎๆœ€ๅฐ้ซ˜ๅบฆ,็กฎไฟ็Ÿญไบ‹ไปถไนŸ่ƒฝๆ˜พ็คบๆ–‡ๆœฌ 603 - const minHeight = 20 // ๆœ€ๅฐ้ซ˜ๅบฆไธบ20px 604 - const height = Math.max(displayDuration, minHeight) 641 + const minHeight = 20; 642 + const height = Math.max(displayDuration, minHeight); 605 643 606 - // ่ฎก็ฎ—ไบ‹ไปถๅฎฝๅบฆๅ’Œไฝ็ฝฎ,ๅค„็†้‡ๅ  607 - const width = `calc((100% - 8px) / ${totalColumns})` 608 - const left = `calc(${column} * ${width})` 644 + const width = `calc((100% - 8px) / ${totalColumns})`; 645 + const left = `calc(${column} * ${width})`; 609 646 610 647 return ( 611 648 <ContextMenu key={event.id}> 612 649 <ContextMenuTrigger asChild> 613 650 <div 614 - className={cn("relative absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", event.color)} 651 + className={cn( 652 + "relative absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", 653 + event.color, 654 + )} 615 655 style={{ 616 656 top: `${startMinutes}px`, 617 657 height: `${height}px`, ··· 625 665 onMouseUp={handleEventDragEnd} 626 666 onMouseLeave={handleEventDragEnd} 627 667 onClick={(e) => { 628 - e.stopPropagation() 668 + e.stopPropagation(); 629 669 if (!isDraggingRef.current) { 630 - onEventClick(event) 670 + onEventClick(event); 631 671 } 632 672 }} 633 673 > 634 - <div className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} style={{ backgroundColor: getDarkerColorClass(event.color) }} /> 635 - <div className="pl-1"> 636 674 <div 637 - className="font-medium leading-tight break-words" 675 + className={cn( 676 + "absolute left-0 top-0 w-1 h-full rounded-l-md", 677 + )} 638 678 style={{ 639 - color: getDarkerColorClass(event.color), 640 - display: "-webkit-box", 641 - WebkitBoxOrient: "vertical", 642 - WebkitLineClamp: Math.max(1, Math.floor((height - 8) / 16)), 643 - overflow: "hidden", 644 - textOverflow: "ellipsis", 679 + backgroundColor: getDarkerColorClass(event.color), 645 680 }} 646 - > 647 - {event.title} 648 - </div> 649 - {height >= 40 && ( 650 - <div className="text-xs truncate" style={{ color: getDarkerColorClass(event.color) }}> 651 - {formatDateWithTimezone(start)} - {formatDateWithTimezone(end)} 681 + /> 682 + <div className="pl-1"> 683 + <div 684 + className="font-medium leading-tight break-words" 685 + style={{ 686 + color: getDarkerColorClass(event.color), 687 + display: "-webkit-box", 688 + WebkitBoxOrient: "vertical", 689 + WebkitLineClamp: Math.max( 690 + 1, 691 + Math.floor((height - 8) / 16), 692 + ), 693 + overflow: "hidden", 694 + textOverflow: "ellipsis", 695 + }} 696 + > 697 + {event.title} 652 698 </div> 653 - )} 699 + {height >= 40 && ( 700 + <div 701 + className="text-xs truncate" 702 + style={{ color: getDarkerColorClass(event.color) }} 703 + > 704 + {formatDateWithTimezone(start)} -{" "} 705 + {formatDateWithTimezone(end)} 706 + </div> 707 + )} 654 708 </div> 655 709 </div> 656 710 </ContextMenuTrigger> ··· 668 722 <Bookmark className="mr-2 h-4 w-4" /> 669 723 {menuLabels.bookmark} 670 724 </ContextMenuItem> 671 - <ContextMenuItem onClick={() => onDeleteEvent?.(event)} className="text-red-600"> 725 + <ContextMenuItem 726 + onClick={() => onDeleteEvent?.(event)} 727 + className="text-red-600" 728 + > 672 729 <Trash2 className="mr-2 h-4 w-4" /> 673 730 {menuLabels.delete} 674 731 </ContextMenuItem> 675 732 </ContextMenuContent> 676 733 </ContextMenu> 677 - ) 734 + ); 678 735 })} 679 736 680 - {/* ๆ‹–ๆ‹ฝ้ข„่งˆ */} 737 + {} 681 738 {dragPreview && renderDragPreview()} 682 739 683 740 {(() => { 684 - // ๆฃ€ๆŸฅๅฝ“ๅ‰ๆ—ฅๆœŸๆ˜ฏๅฆๆ˜ฏไปŠๅคฉ 685 - const today = new Date() 686 - const isToday = isSameDay(date, today) 741 + const today = new Date(); 742 + const isToday = isSameDay(date, today); 687 743 688 - // ๅชๅœจไปŠๅคฉๆ˜พ็คบๆ—ถ้—ดๆŒ‡็คบๅ™จ 689 - if (!isToday) return null 744 + if (!isToday) return null; 690 745 691 - // ่Žทๅ–ๅฝ“ๅ‰ๆ—ถๅŒบ็š„ๆ—ถ้—ด 692 - const currentTimeInTimezone = new Date(currentTime.toLocaleString("en-US", { timeZone: timezone })) 693 - const currentHours = currentTimeInTimezone.getHours() 694 - const currentMinutes = currentTimeInTimezone.getMinutes() 746 + const currentTimeInTimezone = new Date( 747 + currentTime.toLocaleString("en-US", { timeZone: timezone }), 748 + ); 749 + const currentHours = currentTimeInTimezone.getHours(); 750 + const currentMinutes = currentTimeInTimezone.getMinutes(); 695 751 696 - // ่ฎก็ฎ—ๅƒ็ด ไฝ็ฝฎ 697 - const topPosition = currentHours * 60 + currentMinutes 752 + const topPosition = currentHours * 60 + currentMinutes; 698 753 699 754 return ( 700 755 <div ··· 703 758 top: `${topPosition}px`, 704 759 }} 705 760 /> 706 - ) 761 + ); 707 762 })()} 708 763 </div> 709 764 </div> 710 765 711 766 {draggingEvent && ( 712 - <div 767 + <div 713 768 className="fixed px-2 py-1 bg-black text-white rounded-md text-xs z-50 pointer-events-none" 714 769 style={{ 715 - left: dragOffset ? dragStartPosition!.x + dragOffset.x + 10 : dragStartPosition!.x + 10, 716 - top: dragOffset ? dragStartPosition!.y + dragOffset.y + 10 : dragStartPosition!.y + 10, 770 + left: dragOffset 771 + ? dragStartPosition!.x + dragOffset.x + 10 772 + : dragStartPosition!.x + 10, 773 + top: dragOffset 774 + ? dragStartPosition!.y + dragOffset.y + 10 775 + : dragStartPosition!.y + 10, 717 776 opacity: 0.8, 718 777 }} 719 778 > ··· 721 780 </div> 722 781 )} 723 782 </div> 724 - ) 783 + ); 725 784 }
+497 -418
components/app/views/week-view.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useEffect, useRef, useState } from "react" 4 - import type React from "react" 3 + import { useEffect, useRef, useState } from "react"; 4 + import type React from "react"; 5 5 import { 6 6 ContextMenu, 7 7 ContextMenuContent, 8 8 ContextMenuItem, 9 9 ContextMenuTrigger, 10 - } from "@/components/ui/context-menu" 11 - import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react" 12 - import { format, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, isWithinInterval, add, addDays, startOfDay } from "date-fns" 13 - import { cn } from "@/lib/utils" 14 - import { translations, type Language } from "@/lib/i18n" 10 + } from "@/components/ui/context-menu"; 11 + import { Edit3, Share2, Bookmark, Trash2 } from "lucide-react"; 12 + import { 13 + format, 14 + startOfWeek, 15 + endOfWeek, 16 + eachDayOfInterval, 17 + isSameDay, 18 + isWithinInterval, 19 + add, 20 + addDays, 21 + startOfDay, 22 + } from "date-fns"; 23 + import { cn } from "@/lib/utils"; 24 + import { translations, type Language } from "@/lib/i18n"; 15 25 16 26 interface WeekViewProps { 17 - date: Date 18 - events: any[] 19 - onEventClick: (event: any) => void 20 - onTimeSlotClick: (date: Date) => void 21 - language: Language 22 - firstDayOfWeek: number 23 - timezone: string 24 - timeFormat: "24h" | "12h" 25 - onEditEvent?: (event: CalendarEvent) => void 26 - onDeleteEvent?: (event: CalendarEvent) => void 27 - onShareEvent?: (event: CalendarEvent) => void 28 - onBookmarkEvent?: (event: CalendarEvent) => void 29 - onEventDrop?: (event: CalendarEvent, newStartDate: Date, newEndDate: Date) => void // ๆ–ฐๅขžๆ‹–ๆ‹ฝไบ‹ไปถๅค„็†ๅ‡ฝๆ•ฐ 30 - daysToShow?: number 31 - fixedStartDate?: Date 27 + date: Date; 28 + events: any[]; 29 + onEventClick: (event: any) => void; 30 + onTimeSlotClick: (date: Date) => void; 31 + language: Language; 32 + firstDayOfWeek: number; 33 + timezone: string; 34 + timeFormat: "24h" | "12h"; 35 + onEditEvent?: (event: CalendarEvent) => void; 36 + onDeleteEvent?: (event: CalendarEvent) => void; 37 + onShareEvent?: (event: CalendarEvent) => void; 38 + onBookmarkEvent?: (event: CalendarEvent) => void; 39 + onEventDrop?: ( 40 + event: CalendarEvent, 41 + newStartDate: Date, 42 + newEndDate: Date, 43 + ) => void; 44 + daysToShow?: number; 45 + fixedStartDate?: Date; 32 46 } 33 47 34 48 interface CalendarEvent { 35 - id: string 36 - startDate: string | Date 37 - endDate: string | Date 38 - title: string 39 - color: string 40 - isAllDay?: boolean 49 + id: string; 50 + startDate: string | Date; 51 + endDate: string | Date; 52 + title: string; 53 + color: string; 54 + isAllDay?: boolean; 41 55 } 42 56 43 57 export default function WeekView({ ··· 53 67 onDeleteEvent, 54 68 onShareEvent, 55 69 onBookmarkEvent, 56 - onEventDrop, // ๆ–ฐๅขžๆ‹–ๆ‹ฝไบ‹ไปถๅค„็†ๅ‡ฝๆ•ฐ 70 + onEventDrop, 57 71 daysToShow, 58 72 fixedStartDate, 59 73 }: WeekViewProps) { 60 - const weekStart = startOfWeek(date, { weekStartsOn: firstDayOfWeek }) 61 - const weekEnd = endOfWeek(date, { weekStartsOn: firstDayOfWeek }) 74 + const weekStart = startOfWeek(date, { weekStartsOn: firstDayOfWeek }); 75 + const weekEnd = endOfWeek(date, { weekStartsOn: firstDayOfWeek }); 62 76 const weekDays = daysToShow 63 77 ? Array.from({ length: daysToShow }, (_, index) => 64 78 addDays(startOfDay(fixedStartDate ?? date), index), 65 79 ) 66 - : eachDayOfInterval({ start: weekStart, end: weekEnd }) 67 - const hours = Array.from({ length: 24 }, (_, i) => i) 68 - const gridTemplateColumns = `100px repeat(${weekDays.length}, minmax(0, 1fr))` 69 - const today = new Date() 70 - const t = translations[language] 80 + : eachDayOfInterval({ start: weekStart, end: weekEnd }); 81 + const hours = Array.from({ length: 24 }, (_, i) => i); 82 + const gridTemplateColumns = `100px repeat(${weekDays.length}, minmax(0, 1fr))`; 83 + const today = new Date(); 84 + const t = translations[language]; 85 + 86 + const [currentTime, setCurrentTime] = useState(new Date()); 87 + const hasScrolledRef = useRef(false); 88 + const scrollContainerRef = useRef<HTMLDivElement>(null); 71 89 72 - const [currentTime, setCurrentTime] = useState(new Date()) 73 - const hasScrolledRef = useRef(false) 74 - const scrollContainerRef = useRef<HTMLDivElement>(null) 75 - 76 - // ๆ‹–ๆ‹ฝ็›ธๅ…ณ็Šถๆ€ 77 - const [draggingEvent, setDraggingEvent] = useState<CalendarEvent | null>(null) 78 - const [dragStartPosition, setDragStartPosition] = useState<{ x: number; y: number } | null>(null) 79 - const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>(null) 80 - const [dragPreview, setDragPreview] = useState<{ day: Date; hour: number; minute: number } | null>(null) 81 - const [dragEventDuration, setDragEventDuration] = useState<number>(0) // ไบ‹ไปถๆŒ็ปญๆ—ถ้—ด๏ผˆๅˆ†้’Ÿ๏ผ‰ 82 - const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null) 83 - const isDraggingRef = useRef(false) 90 + const [draggingEvent, setDraggingEvent] = useState<CalendarEvent | null>( 91 + null, 92 + ); 93 + const [dragStartPosition, setDragStartPosition] = useState<{ 94 + x: number; 95 + y: number; 96 + } | null>(null); 97 + const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>( 98 + null, 99 + ); 100 + const [dragPreview, setDragPreview] = useState<{ 101 + day: Date; 102 + hour: number; 103 + minute: number; 104 + } | null>(null); 105 + const [dragEventDuration, setDragEventDuration] = useState<number>(0); 106 + const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); 107 + const isDraggingRef = useRef(false); 84 108 const isDark = 85 109 typeof document !== "undefined" && 86 - document.documentElement.classList.contains("dark") 87 - 110 + document.documentElement.classList.contains("dark"); 88 111 89 112 const menuLabels = { 90 113 edit: t.edit, 91 114 share: t.share, 92 115 bookmark: t.bookmark, 93 116 delete: t.delete, 94 - } 117 + }; 95 118 96 119 function getDarkerColorClass(color: string) { 97 120 const colorMapping: Record<string, string> = { 98 - 'bg-[#E6F6FD]': '#3B82F6', 99 - 'bg-[#E7F8F2]': '#10B981', 100 - 'bg-[#FEF5E6]': '#F59E0B', 101 - 'bg-[#FFE4E6]': '#EF4444', 102 - 'bg-[#F3EEFE]': '#8B5CF6', 103 - 'bg-[#FCE7F3]': '#EC4899', 104 - 'bg-[#EEF2FF]': '#6366F1', 105 - 'bg-[#FFF0E5]': '#FB923C', 106 - 'bg-[#E6FAF7]': '#14B8A6', 107 - } 121 + "bg-[#E6F6FD]": "#3B82F6", 122 + "bg-[#E7F8F2]": "#10B981", 123 + "bg-[#FEF5E6]": "#F59E0B", 124 + "bg-[#FFE4E6]": "#EF4444", 125 + "bg-[#F3EEFE]": "#8B5CF6", 126 + "bg-[#FCE7F3]": "#EC4899", 127 + "bg-[#EEF2FF]": "#6366F1", 128 + "bg-[#FFF0E5]": "#FB923C", 129 + "bg-[#E6FAF7]": "#14B8A6", 130 + }; 108 131 109 - 110 - return colorMapping[color] || '#3A3A3A'; 132 + return colorMapping[color] || "#3A3A3A"; 111 133 } 112 134 113 135 function getEventBackgroundColor(color: string) { 114 - if (!isDark) return undefined 136 + if (!isDark) return undefined; 115 137 116 138 const darkModeColorMapping: Record<string, string> = { 117 - 'bg-[#E6F6FD]': '#2F4655', 118 - 'bg-[#E7F8F2]': '#2D4935', 119 - 'bg-[#FEF5E6]': '#4F3F1B', 120 - 'bg-[#FFE4E6]': '#6C2920', 121 - 'bg-[#F3EEFE]': '#483A63', 122 - 'bg-[#FCE7F3]': '#5A334A', 123 - 'bg-[#E6FAF7]': '#1F4A47', 124 - } 139 + "bg-[#E6F6FD]": "#2F4655", 140 + "bg-[#E7F8F2]": "#2D4935", 141 + "bg-[#FEF5E6]": "#4F3F1B", 142 + "bg-[#FFE4E6]": "#6C2920", 143 + "bg-[#F3EEFE]": "#483A63", 144 + "bg-[#FCE7F3]": "#5A334A", 145 + "bg-[#E6FAF7]": "#1F4A47", 146 + }; 125 147 126 - return darkModeColorMapping[color] 148 + return darkModeColorMapping[color]; 127 149 } 128 150 129 - // ไฟฎๆ”น่‡ชๅŠจๆปšๅŠจๅˆฐๅฝ“ๅ‰ๆ—ถ้—ด็š„ๆ•ˆๆžœ,ๅชๅœจ็ป„ไปถๆŒ‚่ฝฝๆ—ถๆ‰ง่กŒไธ€ๆฌก 130 151 useEffect(() => { 131 - // ๅชๅœจ็ป„ไปถๆŒ‚่ฝฝๆ—ถๆ‰ง่กŒไธ€ๆฌกๆปšๅŠจ 132 152 if (!hasScrolledRef.current && scrollContainerRef.current) { 133 - const now = new Date() 134 - const currentHour = now.getHours() 153 + const now = new Date(); 154 + const currentHour = now.getHours(); 135 155 136 - // ๆ‰พๅˆฐๅฏนๅบ”ๅฝ“ๅ‰ๅฐๆ—ถ็š„DOMๅ…ƒ็ด  137 - const hourElements = scrollContainerRef.current.querySelectorAll(".h-\\[60px\\]") 156 + const hourElements = 157 + scrollContainerRef.current.querySelectorAll(".h-\\[60px\\]"); 138 158 if (hourElements.length > 0 && currentHour < hourElements.length) { 139 - // ่Žทๅ–ๅฝ“ๅ‰ๅฐๆ—ถ็š„ๅ…ƒ็ด  140 - const currentHourElement = hourElements[currentHour + 1] // +1 ๆ˜ฏๅ› ไธบ็ฌฌไธ€่กŒๆ˜ฏๆ—ถ้—ดๆ ‡็ญพ 159 + const currentHourElement = hourElements[currentHour + 1]; 141 160 142 161 if (currentHourElement) { 143 - // ๆปšๅŠจๅˆฐๅฝ“ๅ‰ๅฐๆ—ถ็š„ไฝ็ฝฎ,ๅนถๅ‘ไธŠๅ็งป100pxไฝฟๅ…ถๅœจ่ง†ๅ›พไธญ้—ดๅไธŠ 144 162 scrollContainerRef.current.scrollTo({ 145 163 top: (currentHourElement as HTMLElement).offsetTop - 100, 146 164 behavior: "auto", 147 - }) 165 + }); 148 166 149 - // ๆ ‡่ฎฐๅทฒ็ปๆปšๅŠจ่ฟ‡ 150 - hasScrolledRef.current = true 167 + hasScrolledRef.current = true; 151 168 } 152 169 } 153 170 } 154 - }, [date, weekDays]) 171 + }, [date, weekDays]); 155 172 156 - // ไฟฎๆ”นๆ—ถ้—ดๆ›ดๆ–ฐ้€ป่พ‘,ๅชๆ›ดๆ–ฐๆ—ถ้—ด็บฟไฝ็ฝฎ,ไธๆ”นๅ˜ๆปšๅŠจไฝ็ฝฎ 157 173 useEffect(() => { 158 - // ็ซ‹ๅณๆ›ดๆ–ฐๆ—ถ้—ด 159 - setCurrentTime(new Date()) 174 + setCurrentTime(new Date()); 160 175 161 - // ่ฎพ็ฝฎๅฎšๆ—ถๅ™จๆฏๅˆ†้’Ÿๆ›ดๆ–ฐๆ—ถ้—ด 162 176 const interval = setInterval(() => { 163 - setCurrentTime(new Date()) 164 - // ไธๅ†่ฐƒ็”จๆปšๅŠจๅ‡ฝๆ•ฐ 165 - }, 60000) // 60000 ms = 1 ๅˆ†้’Ÿ 177 + setCurrentTime(new Date()); 178 + }, 60000); 166 179 167 - return () => clearInterval(interval) 168 - }, []) 180 + return () => clearInterval(interval); 181 + }, []); 169 182 170 - // ๆทปๅŠ ๅ…จๅฑ€mouseup/mousemove็›‘ๅฌๅ™จๆฅๅค„็†ๆ‹–ๆ‹ฝ 171 183 useEffect(() => { 172 184 const handleMouseMove = (e: MouseEvent) => { 173 - if (draggingEvent && isDraggingRef.current && dragStartPosition && scrollContainerRef.current) { 174 - // ่ฎก็ฎ—้ผ ๆ ‡็›ธๅฏนไบŽๆ—ฅๅކๅฎนๅ™จ็š„ไฝ็ฝฎ 175 - const containerRect = scrollContainerRef.current.getBoundingClientRect(); 176 - const gridItems = scrollContainerRef.current.querySelectorAll('.grid-col'); 177 - 178 - // ๆ‰พๅˆฐๆœ€่ฟ‘็š„ๆ—ฅๆœŸๅˆ— 185 + if ( 186 + draggingEvent && 187 + isDraggingRef.current && 188 + dragStartPosition && 189 + scrollContainerRef.current 190 + ) { 191 + const containerRect = 192 + scrollContainerRef.current.getBoundingClientRect(); 193 + const gridItems = 194 + scrollContainerRef.current.querySelectorAll(".grid-col"); 195 + 179 196 let closestDayIndex = 0; 180 197 let minDistance = Infinity; 181 - 198 + 182 199 gridItems.forEach((item, index) => { 183 200 const rect = item.getBoundingClientRect(); 184 201 const centerX = rect.left + rect.width / 2; 185 202 const distance = Math.abs(e.clientX - centerX); 186 - 203 + 187 204 if (distance < minDistance) { 188 205 minDistance = distance; 189 206 closestDayIndex = index; 190 207 } 191 208 }); 192 - 193 - // ่ฎก็ฎ—ๅฐๆ—ถๅ’Œๅˆ†้’Ÿ 194 - const relativeY = e.clientY - containerRect.top + scrollContainerRef.current.scrollTop; 209 + 210 + const relativeY = 211 + e.clientY - containerRect.top + scrollContainerRef.current.scrollTop; 195 212 const hour = Math.floor(relativeY / 60); 196 - const minute = Math.floor((relativeY % 60) / 15) * 15; // ๆŒ‰15ๅˆ†้’Ÿๅ–ๆ•ด 197 - 213 + const minute = Math.floor((relativeY % 60) / 15) * 15; 214 + 198 215 if (closestDayIndex < weekDays.length) { 199 216 setDragPreview({ 200 217 day: weekDays[closestDayIndex], 201 218 hour: hour, 202 - minute: minute 219 + minute: minute, 203 220 }); 204 221 } 205 222 } 206 223 }; 207 - 224 + 208 225 const handleMouseUp = () => { 209 - if (draggingEvent && isDraggingRef.current && dragPreview && onEventDrop) { 210 - // ่ฎก็ฎ—ๆ–ฐ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด 226 + if ( 227 + draggingEvent && 228 + isDraggingRef.current && 229 + dragPreview && 230 + onEventDrop 231 + ) { 211 232 const newStartDate = new Date(dragPreview.day); 212 233 newStartDate.setHours(dragPreview.hour, dragPreview.minute, 0, 0); 213 - 214 - // ่ฎก็ฎ—ๆ–ฐ็š„็ป“ๆŸๆ—ถ้—ด (ไฟๆŒไบ‹ไปถๆŒ็ปญๆ—ถ้—ดไธๅ˜) 234 + 215 235 const newEndDate = add(newStartDate, { minutes: dragEventDuration }); 216 - 217 - // ่ฐƒ็”จๅ›ž่ฐƒๅ‡ฝๆ•ฐๆ›ดๆ–ฐไบ‹ไปถ 236 + 218 237 onEventDrop(draggingEvent, newStartDate, newEndDate); 219 238 } 220 - 221 - // ๆธ…้™คๆ‹–ๆ‹ฝ็Šถๆ€ 239 + 222 240 isDraggingRef.current = false; 223 241 setDraggingEvent(null); 224 242 setDragStartPosition(null); 225 243 setDragOffset(null); 226 244 setDragPreview(null); 227 245 }; 228 - 229 - // ๅฆ‚ๆžœๆญฃๅœจๆ‹–ๆ‹ฝ๏ผŒๆทปๅŠ ๅ…จๅฑ€ไบ‹ไปถ็›‘ๅฌๅ™จ 246 + 230 247 if (draggingEvent) { 231 - document.addEventListener('mousemove', handleMouseMove); 232 - document.addEventListener('mouseup', handleMouseUp); 248 + document.addEventListener("mousemove", handleMouseMove); 249 + document.addEventListener("mouseup", handleMouseUp); 233 250 } 234 - 251 + 235 252 return () => { 236 - document.removeEventListener('mousemove', handleMouseMove); 237 - document.removeEventListener('mouseup', handleMouseUp); 253 + document.removeEventListener("mousemove", handleMouseMove); 254 + document.removeEventListener("mouseup", handleMouseUp); 238 255 }; 239 - }, [draggingEvent, dragStartPosition, dragPreview, onEventDrop, weekDays, dragEventDuration]); 256 + }, [ 257 + draggingEvent, 258 + dragStartPosition, 259 + dragPreview, 260 + onEventDrop, 261 + weekDays, 262 + dragEventDuration, 263 + ]); 240 264 241 265 const formatTime = (hour: number) => { 242 266 if (timeFormat === "12h") { 243 - const period = hour >= 12 ? "PM" : "AM" 244 - const twelveHour = hour % 12 || 12 245 - return `${twelveHour} ${period}` 267 + const period = hour >= 12 ? "PM" : "AM"; 268 + const twelveHour = hour % 12 || 12; 269 + return `${twelveHour} ${period}`; 246 270 } 247 - return `${hour.toString().padStart(2, "0")}:00` 248 - } 271 + return `${hour.toString().padStart(2, "0")}:00`; 272 + }; 249 273 250 274 const formatHourMinute = (hour: number, minute: number) => { 251 275 if (timeFormat === "12h") { 252 - const period = hour >= 12 ? "PM" : "AM" 253 - const twelveHour = hour % 12 || 12 254 - return `${twelveHour}:${minute.toString().padStart(2, "0")} ${period}` 276 + const period = hour >= 12 ? "PM" : "AM"; 277 + const twelveHour = hour % 12 || 12; 278 + return `${twelveHour}:${minute.toString().padStart(2, "0")} ${period}`; 255 279 } 256 - return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}` 257 - } 280 + return `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; 281 + }; 258 282 259 283 const formatDateWithTimezone = (date: Date) => { 260 284 const options: Intl.DateTimeFormatOptions = { ··· 262 286 minute: "2-digit", 263 287 hour12: timeFormat === "12h", 264 288 timeZone: timezone, 265 - } 266 - return new Intl.DateTimeFormat(language, options).format(date) 267 - } 289 + }; 290 + return new Intl.DateTimeFormat(language, options).format(date); 291 + }; 268 292 269 - // ๅˆคๆ–ญไบ‹ไปถๆ˜ฏๅฆไธบๅ…จๅคฉไบ‹ไปถ 270 293 const isAllDayEvent = (event: CalendarEvent) => { 271 - if (event.isAllDay) return true 272 - 273 - const start = new Date(event.startDate) 274 - const end = new Date(event.endDate) 275 - 276 - // ๆฃ€ๆŸฅๆ˜ฏๅฆไธบ00:00-23:59็š„ไบ‹ไปถๆˆ–่ทจๅคœไบ‹ไปถ(00:00-ๆฌกๆ—ฅ00:00) 277 - const isFullDay = 278 - start.getHours() === 0 && 279 - start.getMinutes() === 0 && 280 - ((end.getHours() === 23 && end.getMinutes() === 59) || 281 - (end.getHours() === 0 && end.getMinutes() === 0 && end.getDate() !== start.getDate())) 282 - 283 - return isFullDay 284 - } 294 + if (event.isAllDay) return true; 295 + 296 + const start = new Date(event.startDate); 297 + const end = new Date(event.endDate); 298 + 299 + const isFullDay = 300 + start.getHours() === 0 && 301 + start.getMinutes() === 0 && 302 + ((end.getHours() === 23 && end.getMinutes() === 59) || 303 + (end.getHours() === 0 && 304 + end.getMinutes() === 0 && 305 + end.getDate() !== start.getDate())); 285 306 286 - // ๅฎ‰ๅ…จๅœฐๆฃ€ๆŸฅไบ‹ไปถๆ˜ฏๅฆ่ทจ่ถŠๅคšๅคฉ 307 + return isFullDay; 308 + }; 309 + 287 310 const isMultiDayEvent = (start: Date, end: Date) => { 288 - if (!start || !end) return false 311 + if (!start || !end) return false; 289 312 290 313 return ( 291 314 start.getDate() !== end.getDate() || 292 315 start.getMonth() !== end.getMonth() || 293 316 start.getFullYear() !== end.getFullYear() 294 - ) 295 - } 317 + ); 318 + }; 296 319 297 - // ๆฃ€ๆŸฅไบ‹ไปถๆ˜ฏๅฆๅœจ็‰นๅฎšๆ—ฅๆœŸๆ˜พ็คบ 298 320 const shouldShowEventOnDay = (event: CalendarEvent, day: Date) => { 299 - const start = new Date(event.startDate) 300 - const end = new Date(event.endDate) 321 + const start = new Date(event.startDate); 322 + const end = new Date(event.endDate); 301 323 302 - // ๅฆ‚ๆžœๆ˜ฏๅ…จๅคฉไบ‹ไปถไธ”่ทจๅคฉ๏ผˆ00:00-ๆฌกๆ—ฅ00:00๏ผ‰๏ผŒๅชๅœจๅผ€ๅง‹ๅฝ“ๅคฉๆ˜พ็คบ 303 324 if (isAllDayEvent(event) && isMultiDayEvent(start, end)) { 304 - return isSameDay(start, day) 325 + return isSameDay(start, day); 305 326 } 306 327 307 - // ๅฆ‚ๆžœไบ‹ไปถๅœจๅฝ“ๅคฉๅผ€ๅง‹ 308 - if (isSameDay(start, day)) return true 328 + if (isSameDay(start, day)) return true; 309 329 310 - // ๅฆ‚ๆžœๆ˜ฏๅคšๅคฉไบ‹ไปถ๏ผˆไธ”ไธๆ˜ฏๅ…จๅคฉไบ‹ไปถ๏ผ‰๏ผŒๆฃ€ๆŸฅๅฝ“ๅคฉๆ˜ฏๅฆๅœจไบ‹ไปถ่Œƒๅ›ดๅ†… 311 330 if (isMultiDayEvent(start, end) && !isAllDayEvent(event)) { 312 - return isWithinInterval(day, { start, end }) 331 + return isWithinInterval(day, { start, end }); 313 332 } 314 333 315 - return false 316 - } 334 + return false; 335 + }; 317 336 318 - // ่ฎก็ฎ—ไบ‹ไปถๅœจ็‰นๅฎšๆ—ฅๆœŸ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด 319 337 const getEventTimesForDay = (event: CalendarEvent, day: Date) => { 320 - const start = new Date(event.startDate) 321 - const end = new Date(event.endDate) 338 + const start = new Date(event.startDate); 339 + const end = new Date(event.endDate); 322 340 323 - // ๅฎ‰ๅ…จๆฃ€ๆŸฅ 324 - if (!start || !end) return null 341 + if (!start || !end) return null; 325 342 326 - const isMultiDay = isMultiDayEvent(start, end) 343 + const isMultiDay = isMultiDayEvent(start, end); 327 344 328 - // ่ฎก็ฎ—ๅฝ“ๅคฉ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด 329 - let dayStart = start 330 - let dayEnd = end 345 + let dayStart = start; 346 + let dayEnd = end; 331 347 332 348 if (isMultiDay) { 333 - // ๅฆ‚ๆžœไธๆ˜ฏไบ‹ไปถ็š„ๅผ€ๅง‹ๆ—ฅ,ไปŽๅฝ“ๅคฉ0็‚นๅผ€ๅง‹ 334 349 if (!isSameDay(start, day)) { 335 - dayStart = new Date(day) 336 - dayStart.setHours(0, 0, 0, 0) 350 + dayStart = new Date(day); 351 + dayStart.setHours(0, 0, 0, 0); 337 352 } 338 353 339 - // ๅฆ‚ๆžœไธๆ˜ฏไบ‹ไปถ็š„็ป“ๆŸๆ—ฅ,ๅˆฐๅฝ“ๅคฉ24็‚น็ป“ๆŸ 340 354 if (!isSameDay(end, day)) { 341 - dayEnd = new Date(day) 342 - dayEnd.setHours(23, 59, 59, 999) 355 + dayEnd = new Date(day); 356 + dayEnd.setHours(23, 59, 59, 999); 343 357 } 344 358 } 345 359 ··· 347 361 start: dayStart, 348 362 end: dayEnd, 349 363 isMultiDay, 350 - } 351 - } 364 + }; 365 + }; 352 366 353 - // ๅฐ†ไบ‹ไปถๅˆ†ไธบๅ…จๅคฉไบ‹ไปถๅ’Œๆญฃๅธธไบ‹ไปถ 354 367 const separateEvents = (dayEvents: CalendarEvent[], day: Date) => { 355 - const allDayEvents: CalendarEvent[] = [] 356 - const regularEvents: CalendarEvent[] = [] 368 + const allDayEvents: CalendarEvent[] = []; 369 + const regularEvents: CalendarEvent[] = []; 357 370 358 - dayEvents.forEach(event => { 371 + dayEvents.forEach((event) => { 359 372 if (isAllDayEvent(event)) { 360 - allDayEvents.push(event) 373 + allDayEvents.push(event); 361 374 } else { 362 - regularEvents.push(event) 375 + regularEvents.push(event); 363 376 } 364 - }) 377 + }); 365 378 366 - return { allDayEvents, regularEvents } 367 - } 379 + return { allDayEvents, regularEvents }; 380 + }; 368 381 369 - // ๆ”น่ฟ›็š„ไบ‹ไปถๅธƒๅฑ€็ฎ—ๆณ•,ๅค„็†้‡ๅ ไบ‹ไปถ 370 382 const layoutEventsForDay = (dayEvents: CalendarEvent[], day: Date) => { 371 - if (!dayEvents || dayEvents.length === 0) return [] 383 + if (!dayEvents || dayEvents.length === 0) return []; 372 384 373 - // ่Žทๅ–ๅฝ“ๅคฉ็š„ไบ‹ไปถๆ—ถ้—ด 374 385 const eventsWithTimes = dayEvents 375 386 .map((event) => { 376 - const times = getEventTimesForDay(event, day) 377 - if (!times) return null 378 - return { event, ...times } 387 + const times = getEventTimesForDay(event, day); 388 + if (!times) return null; 389 + return { event, ...times }; 379 390 }) 380 391 .filter(Boolean) as Array<{ 381 - event: CalendarEvent 382 - start: Date 383 - end: Date 384 - isMultiDay: boolean 385 - }> 392 + event: CalendarEvent; 393 + start: Date; 394 + end: Date; 395 + isMultiDay: boolean; 396 + }>; 386 397 387 - // ๆŒ‰ๅผ€ๅง‹ๆ—ถ้—ดๆŽ’ๅบ 388 - eventsWithTimes.sort((a, b) => a.start.getTime() - b.start.getTime()) 398 + eventsWithTimes.sort((a, b) => a.start.getTime() - b.start.getTime()); 389 399 390 - // ๅˆ›ๅปบๆ—ถ้—ดๆฎตๆ•ฐ็ป„,ๆฏไธชๆ—ถ้—ดๆฎตๅŒ…ๅซๅœจ่ฏฅๆ—ถ้—ดๆฎตๅ†…ๆดป่ทƒ็š„ไบ‹ไปถ 391 - type TimePoint = { time: number; isStart: boolean; eventIndex: number } 392 - const timePoints: TimePoint[] = [] 400 + type TimePoint = { time: number; isStart: boolean; eventIndex: number }; 401 + const timePoints: TimePoint[] = []; 393 402 394 - // ๆทปๅŠ ๆ‰€ๆœ‰ไบ‹ไปถ็š„ๅผ€ๅง‹ๅ’Œ็ป“ๆŸๆ—ถ้—ด็‚น 395 403 eventsWithTimes.forEach((eventWithTime, index) => { 396 - const startTime = eventWithTime.start.getTime() 397 - const endTime = eventWithTime.end.getTime() 404 + const startTime = eventWithTime.start.getTime(); 405 + const endTime = eventWithTime.end.getTime(); 398 406 399 - timePoints.push({ time: startTime, isStart: true, eventIndex: index }) 400 - timePoints.push({ time: endTime, isStart: false, eventIndex: index }) 401 - }) 407 + timePoints.push({ time: startTime, isStart: true, eventIndex: index }); 408 + timePoints.push({ time: endTime, isStart: false, eventIndex: index }); 409 + }); 402 410 403 - // ๆŒ‰ๆ—ถ้—ดๆŽ’ๅบ 404 411 timePoints.sort((a, b) => { 405 - // ๅฆ‚ๆžœๆ—ถ้—ด็›ธๅŒ,็ป“ๆŸๆ—ถ้—ด็‚นๆŽ’ๅœจๅผ€ๅง‹ๆ—ถ้—ด็‚นไน‹ๅ‰ 406 412 if (a.time === b.time) { 407 - return a.isStart ? 1 : -1 413 + return a.isStart ? 1 : -1; 408 414 } 409 - return a.time - b.time 410 - }) 415 + return a.time - b.time; 416 + }); 411 417 412 - // ๅค„็†ๆฏไธชๆ—ถ้—ดๆฎต 413 418 const eventLayouts: Array<{ 414 - event: CalendarEvent 415 - start: Date 416 - end: Date 417 - column: number 418 - totalColumns: number 419 - isMultiDay: boolean 420 - }> = [] 419 + event: CalendarEvent; 420 + start: Date; 421 + end: Date; 422 + column: number; 423 + totalColumns: number; 424 + isMultiDay: boolean; 425 + }> = []; 426 + 427 + const activeEvents = new Set<number>(); 421 428 422 - // ๅฝ“ๅ‰ๆดป่ทƒ็š„ไบ‹ไปถ 423 - const activeEvents = new Set<number>() 424 - // ไบ‹ไปถๅˆฐๅˆ—็š„ๆ˜ ๅฐ„ 425 - const eventToColumn = new Map<number, number>() 429 + const eventToColumn = new Map<number, number>(); 426 430 427 431 for (let i = 0; i < timePoints.length; i++) { 428 - const point = timePoints[i] 432 + const point = timePoints[i]; 429 433 430 434 if (point.isStart) { 431 - // ไบ‹ไปถๅผ€ๅง‹ 432 - activeEvents.add(point.eventIndex) 435 + activeEvents.add(point.eventIndex); 433 436 434 - // ๆ‰พๅˆฐๅฏ็”จ็š„ๆœ€ๅฐๅˆ—ๅท 435 - let column = 0 436 - const usedColumns = new Set<number>() 437 + let column = 0; 438 + const usedColumns = new Set<number>(); 437 439 438 - // ๆ”ถ้›†ๅฝ“ๅ‰ๅทฒไฝฟ็”จ็š„ๅˆ— 439 440 activeEvents.forEach((eventIndex) => { 440 441 if (eventToColumn.has(eventIndex)) { 441 - usedColumns.add(eventToColumn.get(eventIndex)!) 442 + usedColumns.add(eventToColumn.get(eventIndex)!); 442 443 } 443 - }) 444 + }); 444 445 445 - // ๆ‰พๅˆฐ็ฌฌไธ€ไธชๆœชไฝฟ็”จ็š„ๅˆ— 446 446 while (usedColumns.has(column)) { 447 - column++ 447 + column++; 448 448 } 449 449 450 - // ๅˆ†้…ๅˆ— 451 - eventToColumn.set(point.eventIndex, column) 450 + eventToColumn.set(point.eventIndex, column); 452 451 } else { 453 - // ไบ‹ไปถ็ป“ๆŸ 454 - activeEvents.delete(point.eventIndex) 452 + activeEvents.delete(point.eventIndex); 455 453 } 456 454 457 - // ๅฆ‚ๆžœๆ˜ฏๆœ€ๅŽไธ€ไธชๆ—ถ้—ด็‚นๆˆ–ไธ‹ไธ€ไธชๆ—ถ้—ด็‚นไธŽๅฝ“ๅ‰ไธๅŒ,ๅค„็†ๅฝ“ๅ‰ๆ—ถ้—ดๆฎต 458 - if (i === timePoints.length - 1 || timePoints[i + 1].time !== point.time) { 459 - // ่ฎก็ฎ—ๅฝ“ๅ‰ๆดป่ทƒไบ‹ไปถ็š„ๅธƒๅฑ€ 455 + if ( 456 + i === timePoints.length - 1 || 457 + timePoints[i + 1].time !== point.time 458 + ) { 460 459 const totalColumns = 461 - activeEvents.size > 0 ? Math.max(...Array.from(activeEvents).map((idx) => eventToColumn.get(idx)!)) + 1 : 0 460 + activeEvents.size > 0 461 + ? Math.max( 462 + ...Array.from(activeEvents).map( 463 + (idx) => eventToColumn.get(idx)!, 464 + ), 465 + ) + 1 466 + : 0; 462 467 463 - // ๆ›ดๆ–ฐๆ‰€ๆœ‰ๆดป่ทƒไบ‹ไปถ็š„ๆ€ปๅˆ—ๆ•ฐ 464 468 activeEvents.forEach((eventIndex) => { 465 - const column = eventToColumn.get(eventIndex)! 466 - const { event, start, end, isMultiDay } = eventsWithTimes[eventIndex] 469 + const column = eventToColumn.get(eventIndex)!; 470 + const { event, start, end, isMultiDay } = eventsWithTimes[eventIndex]; 467 471 468 - // ๆฃ€ๆŸฅๆ˜ฏๅฆๅทฒ็ปๆทปๅŠ ่ฟ‡่ฟ™ไธชไบ‹ไปถ 469 - const existingLayout = eventLayouts.find((layout) => layout.event.id === event.id) 472 + const existingLayout = eventLayouts.find( 473 + (layout) => layout.event.id === event.id, 474 + ); 470 475 471 476 if (!existingLayout) { 472 477 eventLayouts.push({ ··· 476 481 column, 477 482 totalColumns: Math.max(totalColumns, 1), 478 483 isMultiDay, 479 - }) 484 + }); 480 485 } 481 - }) 486 + }); 482 487 } 483 488 } 484 489 485 - return eventLayouts 486 - } 490 + return eventLayouts; 491 + }; 487 492 488 - // ๅค„็†ไบ‹ไปถๆ‹–ๆ‹ฝๅผ€ๅง‹ 489 493 const handleEventDragStart = (event: CalendarEvent, e: React.MouseEvent) => { 490 494 e.preventDefault(); 491 495 e.stopPropagation(); 492 - 493 - // ไฝฟ็”จๅฎšๆ—ถๅ™จๆจกๆ‹Ÿ้•ฟๆŒ‰ๆ•ˆๆžœ๏ผŒ็บฆ300ๆฏซ็ง’ 496 + 494 497 longPressTimeoutRef.current = setTimeout(() => { 495 498 const start = new Date(event.startDate); 496 499 const end = new Date(event.endDate); 497 - 498 - // ่ฎก็ฎ—ไบ‹ไปถๆŒ็ปญๆ—ถ้—ด๏ผˆๅˆ†้’Ÿ๏ผ‰ 500 + 499 501 const durationMs = end.getTime() - start.getTime(); 500 502 const durationMinutes = Math.round(durationMs / (1000 * 60)); 501 - 503 + 502 504 setDraggingEvent(event); 503 505 setDragStartPosition({ x: e.clientX, y: e.clientY }); 504 506 setDragEventDuration(durationMinutes); 505 507 isDraggingRef.current = true; 506 508 }, 300); 507 509 }; 508 - 509 - // ๅค„็†ไบ‹ไปถๆ‹–ๆ‹ฝ็ป“ๆŸ 510 + 510 511 const handleEventDragEnd = () => { 511 512 if (longPressTimeoutRef.current) { 512 513 clearTimeout(longPressTimeoutRef.current); ··· 514 515 } 515 516 }; 516 517 517 - // ๅค„็†ๆ—ถ้—ดๆ ผๅญ็‚นๅ‡ป,ๆ นๆฎ็‚นๅ‡ปไฝ็ฝฎ็กฎๅฎšๆ›ด็ฒพ็กฎ็š„ๆ—ถ้—ด 518 - const handleTimeSlotClick = (day: Date, hour: number, event: React.MouseEvent<HTMLDivElement>) => { 519 - // ่Žทๅ–็‚นๅ‡ปไฝ็ฝฎๅœจๆ—ถ้—ดๆ ผๅญๅ†…็š„็›ธๅฏนไฝ็ฝฎ 520 - const rect = event.currentTarget.getBoundingClientRect() 521 - const relativeY = event.clientY - rect.top 522 - const cellHeight = rect.height 518 + const handleTimeSlotClick = ( 519 + day: Date, 520 + hour: number, 521 + event: React.MouseEvent<HTMLDivElement>, 522 + ) => { 523 + const rect = event.currentTarget.getBoundingClientRect(); 524 + const relativeY = event.clientY - rect.top; 525 + const cellHeight = rect.height; 523 526 524 - // ๆ นๆฎ็‚นๅ‡ปไฝ็ฝฎ็กฎๅฎšๅˆ†้’Ÿๆ•ฐ 525 - // ๅฆ‚ๆžœ็‚นๅ‡ปๅœจๆ ผๅญ็š„ไธŠๅŠ้ƒจๅˆ†,ๅˆ†้’Ÿไธบ0,ๅฆๅˆ™ไธบ30 526 - const minutes = relativeY < cellHeight / 2 ? 0 : 30 527 + const minutes = relativeY < cellHeight / 2 ? 0 : 30; 527 528 528 - // ๅˆ›ๅปบไธ€ไธชๆ–ฐ็š„ๆ—ฅๆœŸๅฏน่ฑก,่ฎพ็ฝฎไธบๆŒ‡ๅฎšๆ—ฅๆœŸ็š„ๆŒ‡ๅฎšๅฐๆ—ถๅ’Œๅˆ†้’Ÿ 529 - const clickTime = new Date(day) 530 - clickTime.setHours(hour, minutes, 0, 0) 529 + const clickTime = new Date(day); 530 + clickTime.setHours(hour, minutes, 0, 0); 531 531 532 - // ่ฐƒ็”จไผ ๅ…ฅ็š„ๅ›ž่ฐƒๅ‡ฝๆ•ฐ 533 - onTimeSlotClick(clickTime) 534 - } 532 + onTimeSlotClick(clickTime); 533 + }; 535 534 536 - // ๆธฒๆŸ“ๅ…จๅคฉไบ‹ไปถ็š„ๅ‡ฝๆ•ฐ 537 535 const renderAllDayEvents = (day: Date, allDayEvents: CalendarEvent[]) => { 538 - // ่ฟ™้‡Œๅฏไปฅ่ฎพ็ฝฎไบ‹ไปถไน‹้—ด็š„้—ด้š”ๅคงๅฐ 539 - const eventSpacing = 2; // ่ฐƒๆ•ด่ฟ™ไธชๅ€ผๆฅๆ”นๅ˜ไบ‹ไปถไน‹้—ด็š„้—ด้š”๏ผŒๅ•ไฝๆ˜ฏๅƒ็ด  540 - 536 + const eventSpacing = 2; 537 + 541 538 return allDayEvents.map((event, index) => ( 542 - <ContextMenu key={`allday-${event.id}-${day.toISOString().split("T")[0]}`}> 539 + <ContextMenu 540 + key={`allday-${event.id}-${day.toISOString().split("T")[0]}`} 541 + > 543 542 <ContextMenuTrigger asChild> 544 543 <div 545 - className={cn("relative rounded-lg p-1 text-xs cursor-pointer overflow-hidden", event.color)} 544 + className={cn( 545 + "relative rounded-lg p-1 text-xs cursor-pointer overflow-hidden", 546 + event.color, 547 + )} 546 548 style={{ 547 - height: "20px", // ๅ›บๅฎš้ซ˜ๅบฆ 548 - // ๅœจ่ฟ™้‡Œไฝฟ็”จ eventSpacing ่ฎพ็ฝฎไบ‹ไปถ้—ด้š” 549 - top: index * (20 + eventSpacing) + "px", // ๅ †ๅ ไบ‹ไปถๅนถๆทปๅŠ ้—ด้š” 549 + height: "20px", 550 + 551 + top: index * (20 + eventSpacing) + "px", 550 552 position: "absolute", 551 553 left: "0", 552 554 right: "0", ··· 558 560 onMouseUp={handleEventDragEnd} 559 561 onMouseLeave={handleEventDragEnd} 560 562 onClick={(e) => { 561 - e.stopPropagation() 563 + e.stopPropagation(); 562 564 if (!isDraggingRef.current) { 563 - onEventClick(event) 565 + onEventClick(event); 564 566 } 565 567 }} 566 568 > 567 - <div 568 - className={cn("absolute left-0 top-0 w-2 h-full rounded-l-md")} 569 - style={{ backgroundColor: getDarkerColorClass(event.color) }} 569 + <div 570 + className={cn("absolute left-0 top-0 w-2 h-full rounded-l-md")} 571 + style={{ backgroundColor: getDarkerColorClass(event.color) }} 570 572 /> 571 - <div className="pl-1.5 truncate" style={{ color: getDarkerColorClass(event.color) }}> 573 + <div 574 + className="pl-1.5 truncate" 575 + style={{ color: getDarkerColorClass(event.color) }} 576 + > 572 577 {event.title} 573 578 </div> 574 579 </div> ··· 587 592 <Bookmark className="mr-2 h-4 w-4" /> 588 593 {menuLabels.bookmark} 589 594 </ContextMenuItem> 590 - <ContextMenuItem onClick={() => onDeleteEvent?.(event)} className="text-red-600"> 595 + <ContextMenuItem 596 + onClick={() => onDeleteEvent?.(event)} 597 + className="text-red-600" 598 + > 591 599 <Trash2 className="mr-2 h-4 w-4" /> 592 600 {menuLabels.delete} 593 601 </ContextMenuItem> 594 602 </ContextMenuContent> 595 603 </ContextMenu> 596 - )) 597 - } 604 + )); 605 + }; 598 606 599 - // ๆธฒๆŸ“ๆ‹–ๆ‹ฝ้ข„่งˆ 600 607 const renderDragPreview = () => { 601 608 if (!dragPreview || !draggingEvent) return null; 602 - 603 - const dayIndex = weekDays.findIndex(day => isSameDay(day, dragPreview.day)); 609 + 610 + const dayIndex = weekDays.findIndex((day) => 611 + isSameDay(day, dragPreview.day), 612 + ); 604 613 if (dayIndex === -1) return null; 605 - 614 + 606 615 const startMinutes = dragPreview.hour * 60 + dragPreview.minute; 607 616 const endMinutes = startMinutes + dragEventDuration; 608 - 617 + 609 618 return ( 610 619 <div 611 - className={cn("absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", draggingEvent.color)} 620 + className={cn( 621 + "absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", 622 + draggingEvent.color, 623 + )} 612 624 style={{ 613 625 top: `${startMinutes}px`, 614 626 height: `${dragEventDuration}px`, 615 627 opacity: 0.6, 616 628 width: `calc(100% - 4px)`, 617 - left: '2px', 629 + left: "2px", 618 630 zIndex: 100, 619 - border: '2px dashed white', 620 - pointerEvents: 'none', // ็กฎไฟๆ‹–ๆ‹ฝ้ข„่งˆไธไผšๅนฒๆ‰ฐ้ผ ๆ ‡ไบ‹ไปถ 631 + border: "2px dashed white", 632 + pointerEvents: "none", 621 633 }} 622 634 > 623 - <div className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 624 - style={{ backgroundColor: getDarkerColorClass(draggingEvent.color) }} 635 + <div 636 + className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} 637 + style={{ backgroundColor: getDarkerColorClass(draggingEvent.color) }} 625 638 /> 626 639 <div className="pl-1"> 627 - <div className="font-medium truncate" style={{ color: getDarkerColorClass(draggingEvent.color) }}>{draggingEvent.title}</div> 640 + <div 641 + className="font-medium truncate" 642 + style={{ color: getDarkerColorClass(draggingEvent.color) }} 643 + > 644 + {draggingEvent.title} 645 + </div> 628 646 {dragEventDuration >= 40 && ( 629 647 <div className="text-xs text-white/90 truncate"> 630 - {formatHourMinute(dragPreview.hour, dragPreview.minute)} - {formatHourMinute(Math.floor(endMinutes / 60), endMinutes % 60)} 648 + {formatHourMinute(dragPreview.hour, dragPreview.minute)} -{" "} 649 + {formatHourMinute(Math.floor(endMinutes / 60), endMinutes % 60)} 631 650 </div> 632 651 )} 633 652 </div> ··· 637 656 638 657 return ( 639 658 <div className="flex flex-col h-full"> 640 - <div className="grid divide-x relative z-30 bg-background" style={{ gridTemplateColumns }}> 659 + <div 660 + className="grid divide-x relative z-30 bg-background" 661 + style={{ gridTemplateColumns }} 662 + > 641 663 <div className="sticky top-0 z-30 bg-background" /> 642 664 {weekDays.map((day) => { 643 - // ่Žทๅ–ๅฝ“ๅคฉ็š„ไบ‹ไปถ 644 - const dayEvents = events.filter((event) => shouldShowEventOnDay(event, day)) 645 - // ๅˆ†็ฆปๅ…จๅคฉไบ‹ไปถๅ’Œๆ™ฎ้€šไบ‹ไปถ 646 - const { allDayEvents } = separateEvents(dayEvents, day) 647 - 648 - // ่ฎก็ฎ—ๅ…จๅคฉไบ‹ไปถๅŒบๅŸŸ็š„้ซ˜ๅบฆ๏ผŒๆฒกๆœ‰้—ด้š” 649 - const eventSpacing = 2; // ไฟๆŒไธŽrenderAllDayEventsๅ‡ฝๆ•ฐไธญ็›ธๅŒ็š„ๅ€ผ 650 - const allDayEventsHeight = allDayEvents.length > 0 651 - ? allDayEvents.length * 20 + (allDayEvents.length - 1) * eventSpacing 652 - : 0; 653 - 665 + const dayEvents = events.filter((event) => 666 + shouldShowEventOnDay(event, day), 667 + ); 668 + 669 + const { allDayEvents } = separateEvents(dayEvents, day); 670 + 671 + const eventSpacing = 2; 672 + const allDayEventsHeight = 673 + allDayEvents.length > 0 674 + ? allDayEvents.length * 20 + 675 + (allDayEvents.length - 1) * eventSpacing 676 + : 0; 677 + 654 678 return ( 655 - <div key={day.toString()} className="sticky top-0 z-30 bg-background"> 679 + <div 680 + key={day.toString()} 681 + className="sticky top-0 z-30 bg-background" 682 + > 656 683 <div className="p-2 text-center"> 657 684 <div>{t.weekdays[day.getDay()]}</div> 658 - {/* ๅฆ‚ๆžœๆ˜ฏไปŠๅคฉ,ไฝฟ็”จ่“่‰ฒ้ซ˜ไบฎๆ˜พ็คบๆ—ฅๆœŸ */} 659 - <div className={cn(isSameDay(day, today) ? "text-[#0066FF] font-bold green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]" : "")}> 685 + {} 686 + <div 687 + className={cn( 688 + isSameDay(day, today) 689 + ? "text-[#0066FF] font-bold green:text-[#24a854] orange:text-[#e26912] azalea:text-[#CD2F7B]" 690 + : "", 691 + )} 692 + > 660 693 {format(day, "d")} 661 694 </div> 662 695 </div> 663 - 664 - {/* ๅ…จๅคฉไบ‹ไปถๅŒบๅŸŸ */} 665 - <div 666 - className="relative" 696 + 697 + {} 698 + <div 699 + className="relative" 667 700 style={{ height: allDayEventsHeight + "px" }} 668 701 > 669 702 {renderAllDayEvents(day, allDayEvents)} 670 703 </div> 671 704 </div> 672 - ) 705 + ); 673 706 })} 674 707 </div> 675 708 676 - <div className="flex-1 grid divide-x overflow-auto" style={{ gridTemplateColumns }} ref={scrollContainerRef}> 709 + <div 710 + className="flex-1 grid divide-x overflow-auto" 711 + style={{ gridTemplateColumns }} 712 + ref={scrollContainerRef} 713 + > 677 714 <div className="text-sm text-muted-foreground"> 678 715 {hours.map((hour) => ( 679 716 <div key={hour} className="h-[60px] relative border-gray-200"> 680 - <span className={cn("absolute right-4", hour === 0 ? "top-0" : "top-0 -translate-y-1/2")}>{formatTime(hour)}</span> 717 + <span 718 + className={cn( 719 + "absolute right-4", 720 + hour === 0 ? "top-0" : "top-0 -translate-y-1/2", 721 + )} 722 + > 723 + {formatTime(hour)} 724 + </span> 681 725 </div> 682 726 ))} 683 727 </div> 684 728 685 729 {weekDays.map((day, dayIndex) => { 686 - // ่Žทๅ–ๅฝ“ๅคฉ็š„ไบ‹ไปถ 687 - const dayEvents = events.filter((event) => shouldShowEventOnDay(event, day)) 688 - // ๅˆ†็ฆปๅ…จๅคฉไบ‹ไปถๅ’Œๆ™ฎ้€šไบ‹ไปถ 689 - const { regularEvents } = separateEvents(dayEvents, day) 690 - // ๅฏนไบ‹ไปถ่ฟ›่กŒๅธƒๅฑ€ 691 - const eventLayouts = layoutEventsForDay(regularEvents, day) 730 + const dayEvents = events.filter((event) => 731 + shouldShowEventOnDay(event, day), 732 + ); 733 + 734 + const { regularEvents } = separateEvents(dayEvents, day); 735 + 736 + const eventLayouts = layoutEventsForDay(regularEvents, day); 692 737 693 738 return ( 694 739 <div key={day.toString()} className="relative border-l grid-col"> ··· 700 745 /> 701 746 ))} 702 747 703 - {eventLayouts.map(({ event, start, end, column, totalColumns }) => { 704 - const startMinutes = start.getHours() * 60 + start.getMinutes() 705 - const endMinutes = end.getHours() * 60 + end.getMinutes() 706 - const duration = endMinutes - startMinutes 748 + {eventLayouts.map( 749 + ({ event, start, end, column, totalColumns }) => { 750 + const startMinutes = 751 + start.getHours() * 60 + start.getMinutes(); 752 + const endMinutes = end.getHours() * 60 + end.getMinutes(); 753 + const duration = endMinutes - startMinutes; 707 754 708 - // ่ฎพ็ฝฎๆœ€ๅฐ้ซ˜ๅบฆ,็กฎไฟ็Ÿญไบ‹ไปถไนŸ่ƒฝๆ˜พ็คบๆ–‡ๆœฌ 709 - const minHeight = 20 // ๆœ€ๅฐ้ซ˜ๅบฆไธบ20px 710 - const height = Math.max(duration, minHeight) 755 + const minHeight = 20; 756 + const height = Math.max(duration, minHeight); 711 757 712 - // ่ฎก็ฎ—ไบ‹ไปถๅฎฝๅบฆๅ’Œไฝ็ฝฎ,ๅค„็†้‡ๅ  713 - const width = `calc((100% - 4px) / ${totalColumns})` 714 - const left = `calc(${column} * ${width})` 758 + const width = `calc((100% - 4px) / ${totalColumns})`; 759 + const left = `calc(${column} * ${width})`; 715 760 716 - return ( 717 - <ContextMenu key={`${event.id}-${day.toISOString().split("T")[0]}`}> 761 + return ( 762 + <ContextMenu 763 + key={`${event.id}-${day.toISOString().split("T")[0]}`} 764 + > 718 765 <ContextMenuTrigger asChild> 719 766 <div 720 - className={cn("relative absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", event.color)} 767 + className={cn( 768 + "relative absolute rounded-lg p-2 text-sm cursor-pointer overflow-hidden", 769 + event.color, 770 + )} 721 771 style={{ 722 772 top: `${startMinutes}px`, 723 773 height: `${height}px`, 724 774 opacity: isDark ? 1 : 0.92, 725 - backgroundColor: getEventBackgroundColor(event.color), 775 + backgroundColor: getEventBackgroundColor( 776 + event.color, 777 + ), 726 778 width, 727 779 left, 728 780 zIndex: column + 1, ··· 730 782 onMouseDown={(e) => handleEventDragStart(event, e)} 731 783 onMouseUp={handleEventDragEnd} 732 784 onMouseLeave={handleEventDragEnd} 733 - onClick={(e) => { 734 - e.stopPropagation() 785 + onClick={(e) => { 786 + e.stopPropagation(); 735 787 if (!isDraggingRef.current) { 736 - onEventClick(event) 788 + onEventClick(event); 737 789 } 738 790 }} 739 791 > 740 - 741 - <div className={cn("absolute left-0 top-0 w-1 h-full rounded-l-md")} style={{ backgroundColor: getDarkerColorClass(event.color) }} /> 742 - <div className="pl-1"> 743 792 <div 744 - className="font-medium leading-tight break-words" 793 + className={cn( 794 + "absolute left-0 top-0 w-1 h-full rounded-l-md", 795 + )} 745 796 style={{ 746 - color: getDarkerColorClass(event.color), 747 - display: "-webkit-box", 748 - WebkitBoxOrient: "vertical", 749 - WebkitLineClamp: Math.max(1, Math.floor((height - 8) / 16)), 750 - overflow: "hidden", 751 - textOverflow: "ellipsis", 797 + backgroundColor: getDarkerColorClass(event.color), 752 798 }} 753 - > 754 - {event.title} 755 - </div> 756 - {height >= 40 && ( 757 - <div className="text-xs truncate" style={{ color: getDarkerColorClass(event.color) }}> 758 - {formatDateWithTimezone(start)} - {formatDateWithTimezone(end)} 799 + /> 800 + <div className="pl-1"> 801 + <div 802 + className="font-medium leading-tight break-words" 803 + style={{ 804 + color: getDarkerColorClass(event.color), 805 + display: "-webkit-box", 806 + WebkitBoxOrient: "vertical", 807 + WebkitLineClamp: Math.max( 808 + 1, 809 + Math.floor((height - 8) / 16), 810 + ), 811 + overflow: "hidden", 812 + textOverflow: "ellipsis", 813 + }} 814 + > 815 + {event.title} 759 816 </div> 760 - )} 817 + {height >= 40 && ( 818 + <div 819 + className="text-xs truncate" 820 + style={{ 821 + color: getDarkerColorClass(event.color), 822 + }} 823 + > 824 + {formatDateWithTimezone(start)} -{" "} 825 + {formatDateWithTimezone(end)} 826 + </div> 827 + )} 761 828 </div> 762 829 </div> 763 - 764 830 </ContextMenuTrigger> 765 - 766 - <ContextMenuContent className="w-40"> 767 - <ContextMenuItem onClick={() => onEditEvent?.(event)}> 768 - <Edit3 className="mr-2 h-4 w-4" /> 769 - {menuLabels.edit} 770 - </ContextMenuItem> 771 - <ContextMenuItem onClick={() => onShareEvent?.(event)}> 772 - <Share2 className="mr-2 h-4 w-4" /> 773 - {menuLabels.share} 774 - </ContextMenuItem> 775 - <ContextMenuItem onClick={() => onBookmarkEvent?.(event)}> 776 - <Bookmark className="mr-2 h-4 w-4" /> 777 - {menuLabels.bookmark} 778 - </ContextMenuItem> 779 - <ContextMenuItem onClick={() => onDeleteEvent?.(event)} className="text-red-600"> 780 - <Trash2 className="mr-2 h-4 w-4" /> 781 - {menuLabels.delete} 782 - </ContextMenuItem> 783 - </ContextMenuContent> 784 - </ContextMenu> 785 - ) 786 - })} 787 831 788 - {/* ๅฆ‚ๆžœๅฝ“ๅ‰ๆ—ฅๆœŸๅˆ—ๆ˜ฏๆ‹–ๆ‹ฝ้ข„่งˆ็š„็›ฎๆ ‡๏ผŒๆ˜พ็คบๆ‹–ๆ‹ฝ้ข„่งˆ */} 789 - {dragPreview && isSameDay(dragPreview.day, day) && renderDragPreview()} 832 + <ContextMenuContent className="w-40"> 833 + <ContextMenuItem onClick={() => onEditEvent?.(event)}> 834 + <Edit3 className="mr-2 h-4 w-4" /> 835 + {menuLabels.edit} 836 + </ContextMenuItem> 837 + <ContextMenuItem onClick={() => onShareEvent?.(event)}> 838 + <Share2 className="mr-2 h-4 w-4" /> 839 + {menuLabels.share} 840 + </ContextMenuItem> 841 + <ContextMenuItem 842 + onClick={() => onBookmarkEvent?.(event)} 843 + > 844 + <Bookmark className="mr-2 h-4 w-4" /> 845 + {menuLabels.bookmark} 846 + </ContextMenuItem> 847 + <ContextMenuItem 848 + onClick={() => onDeleteEvent?.(event)} 849 + className="text-red-600" 850 + > 851 + <Trash2 className="mr-2 h-4 w-4" /> 852 + {menuLabels.delete} 853 + </ContextMenuItem> 854 + </ContextMenuContent> 855 + </ContextMenu> 856 + ); 857 + }, 858 + )} 859 + 860 + {} 861 + {dragPreview && 862 + isSameDay(dragPreview.day, day) && 863 + renderDragPreview()} 790 864 791 865 {isSameDay(day, today) && 792 866 (() => { 793 - const currentTimeInTimezone = new Date(currentTime.toLocaleString("en-US", { timeZone: timezone })) 794 - const currentHours = currentTimeInTimezone.getHours() 795 - const currentMinutes = currentTimeInTimezone.getMinutes() 867 + const currentTimeInTimezone = new Date( 868 + currentTime.toLocaleString("en-US", { timeZone: timezone }), 869 + ); 870 + const currentHours = currentTimeInTimezone.getHours(); 871 + const currentMinutes = currentTimeInTimezone.getMinutes(); 796 872 797 - // ่ฎก็ฎ—ๅƒ็ด ไฝ็ฝฎ 798 - const topPosition = currentHours * 60 + currentMinutes 873 + const topPosition = currentHours * 60 + currentMinutes; 799 874 800 875 return ( 801 876 <div ··· 804 879 top: `${topPosition}px`, 805 880 }} 806 881 /> 807 - ) 882 + ); 808 883 })()} 809 884 </div> 810 - ) 885 + ); 811 886 })} 812 887 </div> 813 - 814 - {/* ๅ…จๅฑ€ๆ‹–ๆ‹ฝๆ็คบ๏ผŒๆ˜พ็คบๆ‹–ๆ‹ฝๆŒ‡็คบๅ’Œไบ‹ไปถไฟกๆฏ */} 888 + 889 + {} 815 890 {draggingEvent && ( 816 - <div 891 + <div 817 892 className="fixed px-2 py-1 bg-black text-white rounded-md text-xs z-50 pointer-events-none" 818 893 style={{ 819 - left: dragOffset ? dragStartPosition!.x + dragOffset.x + 10 : dragStartPosition!.x + 10, 820 - top: dragOffset ? dragStartPosition!.y + dragOffset.y + 10 : dragStartPosition!.y + 10, 894 + left: dragOffset 895 + ? dragStartPosition!.x + dragOffset.x + 10 896 + : dragStartPosition!.x + 10, 897 + top: dragOffset 898 + ? dragStartPosition!.y + dragOffset.y + 10 899 + : dragStartPosition!.y + 10, 821 900 opacity: 0.8, 822 901 }} 823 902 > ··· 825 904 </div> 826 905 )} 827 906 </div> 828 - ) 907 + ); 829 908 }
+10 -10
components/marketing/cta-section.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 3 export default function CTASection() { 4 4 return ( 5 5 <div className="w-full relative overflow-hidden flex flex-col justify-center items-center gap-2"> 6 - {/* Content */} 6 + {} 7 7 <div className="self-stretch px-6 md:px-24 py-12 md:py-12 border-t border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6 relative z-10"> 8 8 <div className="absolute inset-0 w-full h-full overflow-hidden"> 9 9 <div className="w-full h-full relative"> ··· 33 33 </div> 34 34 </div> 35 35 <a href="/sign-up"> 36 - <div className="w-full max-w-[497px] flex flex-col justify-center items-center gap-12"> 37 - <div className="flex justify-start items-center gap-4"> 38 - <div className="h-10 px-12 py-[6px] relative bg-[#37322F] shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset] overflow-hidden rounded-full flex justify-center items-center cursor-pointer hover:bg-[#2A2520] transition-colors"> 39 - <div className="w-44 h-[41px] absolute left-0 top-0 bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(0,0,0,0.10)] mix-blend-multiply"></div> 40 - <div className="flex flex-col justify-center text-white text-[13px] font-medium leading-5 font-sans"> 41 - Get Started 36 + <div className="w-full max-w-[497px] flex flex-col justify-center items-center gap-12"> 37 + <div className="flex justify-start items-center gap-4"> 38 + <div className="h-10 px-12 py-[6px] relative bg-[#37322F] shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset] overflow-hidden rounded-full flex justify-center items-center cursor-pointer hover:bg-[#2A2520] transition-colors"> 39 + <div className="w-44 h-[41px] absolute left-0 top-0 bg-gradient-to-b from-[rgba(255,255,255,0)] to-[rgba(0,0,0,0.10)] mix-blend-multiply"></div> 40 + <div className="flex flex-col justify-center text-white text-[13px] font-medium leading-5 font-sans"> 41 + Get Started 42 + </div> 42 43 </div> 43 44 </div> 44 45 </div> 45 - </div> 46 46 </a> 47 47 </div> 48 48 </div> 49 49 </div> 50 - ) 50 + ); 51 51 }
+39 -16
components/marketing/dashboard-preview.tsx
··· 1 - import { Button } from "@/components/ui/button" 1 + import { Button } from "@/components/ui/button"; 2 2 export function DashboardPreview() { 3 3 return ( 4 4 <section className="relative pb-16"> 5 5 <div className="max-w-[1060px] mx-auto px-4"> 6 - {/* Dashboard Interface Mockup */} 6 + {} 7 7 <div className="relative bg-white rounded-lg shadow-lg border border-[#e0dedb] overflow-hidden"> 8 - {/* Dashboard Header */} 8 + {} 9 9 <div className="flex items-center justify-between p-4 border-b border-[#e0dedb]"> 10 10 <div className="flex items-center gap-3"> 11 11 <div className="text-[#37322f] font-semibold">Brillance</div> ··· 17 17 </div> 18 18 </div> 19 19 20 - {/* Sidebar and Main Content */} 20 + {} 21 21 <div className="flex"> 22 - {/* Sidebar */} 22 + {} 23 23 <div className="w-48 bg-[#fbfaf9] border-r border-[#e0dedb] p-4"> 24 24 <nav className="space-y-2"> 25 - <div className="text-xs font-medium text-[#605a57] uppercase tracking-wide mb-3">Navigation</div> 26 - {["Home", "Customers", "Billing", "Schedules", "Invoices", "Products"].map((item) => ( 27 - <div key={item} className="text-sm text-[#37322f] py-1 hover:text-[#37322f]/80 cursor-pointer"> 25 + <div className="text-xs font-medium text-[#605a57] uppercase tracking-wide mb-3"> 26 + Navigation 27 + </div> 28 + {[ 29 + "Home", 30 + "Customers", 31 + "Billing", 32 + "Schedules", 33 + "Invoices", 34 + "Products", 35 + ].map((item) => ( 36 + <div 37 + key={item} 38 + className="text-sm text-[#37322f] py-1 hover:text-[#37322f]/80 cursor-pointer" 39 + > 28 40 {item} 29 41 </div> 30 42 ))} 31 43 </nav> 32 44 </div> 33 45 34 - {/* Main Content */} 46 + {} 35 47 <div className="flex-1 p-6"> 36 48 <div className="flex items-center justify-between mb-6"> 37 - <h2 className="text-xl font-semibold text-[#37322f]">Schedules</h2> 38 - <Button className="bg-[#37322f] hover:bg-[#37322f]/90 text-white text-sm">Create schedule</Button> 49 + <h2 className="text-xl font-semibold text-[#37322f]"> 50 + Schedules 51 + </h2> 52 + <Button className="bg-[#37322f] hover:bg-[#37322f]/90 text-white text-sm"> 53 + Create schedule 54 + </Button> 39 55 </div> 40 56 41 - {/* Table Mockup */} 57 + {} 42 58 <div className="bg-white border border-[#e0dedb] rounded-lg overflow-hidden"> 43 59 <div className="grid grid-cols-6 gap-4 p-4 bg-[#fbfaf9] border-b border-[#e0dedb] text-sm font-medium text-[#605a57]"> 44 60 <div>Customer</div> ··· 49 65 <div>End date</div> 50 66 </div> 51 67 52 - {/* Table Rows */} 68 + {} 53 69 {Array.from({ length: 8 }).map((_, i) => ( 54 - <div key={i} className="grid grid-cols-6 gap-4 p-4 border-b border-[#e0dedb] text-sm"> 70 + <div 71 + key={i} 72 + className="grid grid-cols-6 gap-4 p-4 border-b border-[#e0dedb] text-sm" 73 + > 55 74 <div className="flex items-center gap-2"> 56 75 <div className="w-6 h-6 bg-[#37322f] rounded-full"></div> 57 76 <span>Hypernise</span> ··· 66 85 : "bg-gray-100 text-gray-700" 67 86 }`} 68 87 > 69 - {i % 3 === 0 ? "Complete" : i % 3 === 1 ? "Active" : "Draft"} 88 + {i % 3 === 0 89 + ? "Complete" 90 + : i % 3 === 1 91 + ? "Active" 92 + : "Draft"} 70 93 </span> 71 94 </div> 72 95 <div className="text-[#605a57]">Platform access fee</div> ··· 81 104 </div> 82 105 </div> 83 106 </section> 84 - ) 107 + ); 85 108 }
+29 -26
components/marketing/documentation-section.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useState, useEffect } from "react" 4 - import type React from "react" 3 + import { useState, useEffect } from "react"; 4 + import type React from "react"; 5 5 6 - // Badge component for consistency 7 6 function Badge({ icon, text }: { icon: React.ReactNode; text: string }) { 8 7 return ( 9 8 <div className="px-[14px] py-[6px] bg-white shadow-[0px_0px_0px_4px_rgba(55,50,47,0.05)] overflow-hidden rounded-[90px] flex justify-start items-center gap-[8px] border border-[rgba(2,6,23,0.08)] shadow-xs"> 10 - <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center">{icon}</div> 9 + <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center"> 10 + {icon} 11 + </div> 11 12 <div className="text-center flex justify-center flex-col text-[#37322F] text-xs font-medium leading-3 font-sans"> 12 13 {text} 13 14 </div> 14 15 </div> 15 - ) 16 + ); 16 17 } 17 18 18 19 export default function DocumentationSection() { 19 - const [activeCard, setActiveCard] = useState(0) 20 - const [animationKey, setAnimationKey] = useState(0) 20 + const [activeCard, setActiveCard] = useState(0); 21 + const [animationKey, setAnimationKey] = useState(0); 21 22 22 23 const cards = [ 23 24 { 24 25 title: "Plan your schedules", 25 - description: "Explore your data, build your dashboard,\nsave your time together.", 26 + description: 27 + "Explore your data, build your dashboard,\nsave your time together.", 26 28 image: "/Banner.jpg", 27 29 }, 28 30 { 29 31 title: "Data to insights in minutes", 30 - description: "Transform raw data into actionable insights\nwith powerful analytics tools.", 32 + description: 33 + "Transform raw data into actionable insights\nwith powerful analytics tools.", 31 34 image: "/A.jpg", 32 35 }, 33 36 { ··· 35 38 description: "Password-protect share with anyone\nand burn-after-read.", 36 39 image: "/S.jpg", 37 40 }, 38 - ] 41 + ]; 39 42 40 43 useEffect(() => { 41 44 const interval = setInterval(() => { 42 - setActiveCard((prev) => (prev + 1) % cards.length) 43 - setAnimationKey((prev) => prev + 1) 44 - }, 5000) 45 + setActiveCard((prev) => (prev + 1) % cards.length); 46 + setAnimationKey((prev) => prev + 1); 47 + }, 5000); 45 48 46 - return () => clearInterval(interval) 47 - }, [cards.length]) 49 + return () => clearInterval(interval); 50 + }, [cards.length]); 48 51 49 52 const handleCardClick = (index: number) => { 50 - setActiveCard(index) 51 - setAnimationKey((prev) => prev + 1) 52 - } 53 + setActiveCard(index); 54 + setAnimationKey((prev) => prev + 1); 55 + }; 53 56 54 57 return ( 55 58 <div className="w-full border-b border-[rgba(55,50,47,0.12)] flex flex-col justify-center items-center"> 56 - {/* Header Section */} 59 + {} 57 60 <div className="self-stretch px-6 md:px-24 py-12 md:py-16 border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6"> 58 61 <div className="w-full max-w-[586px] px-6 py-5 shadow-[0px_2px_4px_rgba(50,45,43,0.06)] overflow-hidden rounded-lg flex flex-col justify-start items-center gap-4 shadow-none"> 59 62 <Badge ··· 73 76 </div> 74 77 </div> 75 78 76 - {/* Content Section */} 79 + {} 77 80 <div className="self-stretch px-4 md:px-9 overflow-hidden flex justify-start items-center"> 78 81 <div className="flex-1 py-8 md:py-11 flex flex-col md:flex-row justify-start items-center gap-6 md:gap-12"> 79 - {/* Left Column - Feature Cards */} 82 + {} 80 83 <div className="w-full md:w-auto md:max-w-[400px] flex flex-col justify-center items-center gap-4 order-2 md:order-1"> 81 84 {cards.map((card, index) => { 82 - const isActive = index === activeCard 85 + const isActive = index === activeCard; 83 86 84 87 return ( 85 88 <div ··· 108 111 </div> 109 112 </div> 110 113 </div> 111 - ) 114 + ); 112 115 })} 113 116 </div> 114 117 115 - {/* Right Column - Image */} 118 + {} 116 119 <div className="w-full md:w-auto rounded-lg flex flex-col justify-center items-center gap-2 order-1 md:order-2 md:px-0 px-[00]"> 117 120 <div className="w-full md:w-[580px] h-[250px] md:h-[420px] bg-white shadow-[0px_0px_0px_0.9056603908538818px_rgba(0,0,0,0.08)] overflow-hidden rounded-lg flex flex-col justify-start items-start"> 118 121 <div ··· 140 143 } 141 144 `}</style> 142 145 </div> 143 - ) 146 + ); 144 147 }
+27 -16
components/marketing/faq-section.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useState } from "react" 3 + import { useState } from "react"; 4 4 5 5 interface FAQItem { 6 - question: string 7 - answer: string 6 + question: string; 7 + answer: string; 8 8 } 9 9 10 10 const faqData: FAQItem[] = [ ··· 38 38 answer: 39 39 "Getting started is simple! Sign up an account, import your existing calendars, and set up automatic backup with E2EE. Done!", 40 40 }, 41 - ] 41 + ]; 42 42 43 43 function ChevronDownIcon({ className }: { className?: string }) { 44 44 return ( ··· 50 50 fill="none" 51 51 xmlns="http://www.w3.org/2000/svg" 52 52 > 53 - <path d="m6 9 6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> 53 + <path 54 + d="m6 9 6 6 6-6" 55 + stroke="currentColor" 56 + strokeWidth="2" 57 + strokeLinecap="round" 58 + strokeLinejoin="round" 59 + /> 54 60 </svg> 55 - ) 61 + ); 56 62 } 57 63 58 64 export default function FAQSection() { 59 - const [openItems, setOpenItems] = useState<number[]>([]) 65 + const [openItems, setOpenItems] = useState<number[]>([]); 60 66 61 67 const toggleItem = (index: number) => { 62 - setOpenItems((prev) => (prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index])) 63 - } 68 + setOpenItems((prev) => 69 + prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], 70 + ); 71 + }; 64 72 65 73 return ( 66 74 <div className="w-full flex justify-center items-start"> 67 75 <div className="flex-1 px-4 md:px-12 py-16 md:py-20 flex flex-col lg:flex-row justify-start items-start gap-6 lg:gap-12"> 68 - {/* Left Column - Header */} 76 + {} 69 77 <div className="w-full lg:flex-1 flex flex-col justify-center items-start gap-4 lg:py-5"> 70 78 <div className="w-full flex flex-col justify-center text-[#49423D] font-semibold leading-tight md:leading-[44px] font-sans text-4xl tracking-tight"> 71 79 Frequently Asked Questions ··· 77 85 </div> 78 86 </div> 79 87 80 - {/* Right Column - FAQ Items */} 88 + {} 81 89 <div className="w-full lg:flex-1 flex flex-col justify-center items-center"> 82 90 <div className="w-full flex flex-col"> 83 91 {faqData.map((item, index) => { 84 - const isOpen = openItems.includes(index) 92 + const isOpen = openItems.includes(index); 85 93 86 94 return ( 87 - <div key={index} className="w-full border-b border-[rgba(73,66,61,0.16)] overflow-hidden"> 95 + <div 96 + key={index} 97 + className="w-full border-b border-[rgba(73,66,61,0.16)] overflow-hidden" 98 + > 88 99 <button 89 100 onClick={() => toggleItem(index)} 90 101 className="w-full px-5 py-[18px] flex justify-between items-center gap-5 text-left hover:bg-[rgba(73,66,61,0.02)] transition-colors duration-200" ··· 112 123 </div> 113 124 </div> 114 125 </div> 115 - ) 126 + ); 116 127 })} 117 128 </div> 118 129 </div> 119 130 </div> 120 131 </div> 121 - ) 132 + ); 122 133 }
+17 -9
components/marketing/feature-cards.tsx
··· 2 2 const features = [ 3 3 { 4 4 title: "Plan your schedules", 5 - description: "Explore your data, build your dashboard,\nbring your team together.", 5 + description: 6 + "Explore your data, build your dashboard,\nbring your team together.", 6 7 highlighted: true, 7 8 }, 8 9 { 9 10 title: "Data to insights in the minutes", 10 - description: "Explore your data, build your dashboard,\nbring your team together.", 11 + description: 12 + "Explore your data, build your dashboard,\nbring your team together.", 11 13 highlighted: false, 12 14 }, 13 15 { 14 16 title: "Data to insights in the minutes", 15 - description: "Explore your data, build your dashboard,\nbring your team together.", 17 + description: 18 + "Explore your data, build your dashboard,\nbring your team together.", 16 19 highlighted: false, 17 20 }, 18 - ] 21 + ]; 19 22 20 23 return ( 21 24 <section className="border-t border-[#e0dedb] border-b border-[#e0dedb]"> ··· 25 28 <div 26 29 key={index} 27 30 className={`p-6 flex flex-col gap-2 ${ 28 - // Updated feature card borders to 1px 29 - feature.highlighted ? "bg-white border border-[#e0dedb] shadow-sm" : "border border-[#e0dedb]/80" 31 + feature.highlighted 32 + ? "bg-white border border-[#e0dedb] shadow-sm" 33 + : "border border-[#e0dedb]/80" 30 34 }`} 31 35 > 32 36 {feature.highlighted && ( ··· 35 39 <div className="w-32 h-0.5 bg-[#322d2b]"></div> 36 40 </div> 37 41 )} 38 - <h3 className="text-[#49423d] text-sm font-semibold leading-6">{feature.title}</h3> 39 - <p className="text-[#605a57] text-sm leading-[22px] whitespace-pre-line">{feature.description}</p> 42 + <h3 className="text-[#49423d] text-sm font-semibold leading-6"> 43 + {feature.title} 44 + </h3> 45 + <p className="text-[#605a57] text-sm leading-[22px] whitespace-pre-line"> 46 + {feature.description} 47 + </p> 40 48 </div> 41 49 ))} 42 50 </div> 43 51 </div> 44 52 </section> 45 - ) 53 + ); 46 54 }
+37 -51
components/marketing/footer-section.tsx
··· 1 1 export default function FooterSection() { 2 2 return ( 3 3 <div className="w-full pt-10 flex flex-col justify-start items-start"> 4 - {/* Main Footer Content */} 4 + {} 5 5 <div className="self-stretch h-auto flex flex-col md:flex-row justify-between items-stretch pr-0 pb-8 pt-0"> 6 6 <div className="h-auto p-4 md:p-8 flex flex-col justify-start items-start gap-8"> 7 - {/* Brand Section */} 7 + {} 8 8 <div className="self-stretch flex justify-start items-center gap-3"> 9 - <div className="text-center text-[#49423D] text-xl font-semibold leading-4 font-sans">One Calendar</div> 9 + <div className="text-center text-[#49423D] text-xl font-semibold leading-4 font-sans"> 10 + One Calendar 11 + </div> 10 12 </div> 11 13 <div className="text-[rgba(73,66,61,0.90)] text-sm font-medium leading-[18px] font-sans"> 12 - Store your schedule safety 14 + Store your schedule safety 13 15 </div> 14 16 15 - {/* Social Media Icons */} 17 + {} 16 18 <div className="flex justify-start items-start gap-4"> 17 - {/* Twitter/X Icon */} 19 + {} 18 20 <div className="w-6 h-6 relative overflow-hidden"> 19 21 <div className="w-6 h-6 left-0 top-0 absolute flex items-center justify-center"> 20 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 22 + <svg 23 + width="16" 24 + height="16" 25 + viewBox="0 0 24 24" 26 + fill="none" 27 + xmlns="http://www.w3.org/2000/svg" 28 + > 21 29 <path 22 30 d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" 23 31 fill="#49423D" ··· 26 34 </div> 27 35 </div> 28 36 29 - {/* LinkedIn Icon * 30 - <div className="w-6 h-6 relative overflow-hidden"> 31 - <div className="w-6 h-6 left-0 top-0 absolute flex items-center justify-center"> 32 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 33 - <path 34 - d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z" 35 - fill="#49423D" 36 - /> 37 - </svg> 38 - </div> 39 - </div>*/} 37 + {} 40 38 41 - {/* GitHub Icon */} 39 + {} 42 40 <div className="w-6 h-6 relative overflow-hidden"> 43 41 <div className="w-6 h-6 left-0 top-0 absolute flex items-center justify-center"> 44 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 42 + <svg 43 + width="16" 44 + height="16" 45 + viewBox="0 0 24 24" 46 + fill="none" 47 + xmlns="http://www.w3.org/2000/svg" 48 + > 45 49 <path 46 50 d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0112 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576C20.566 21.797 24 17.300 24 12c0-6.627-5.374-12-12-12z" 47 51 fill="#49423D" ··· 52 56 </div> 53 57 </div> 54 58 55 - {/* Navigation Links */} 59 + {} 56 60 <div className="self-stretch p-4 md:p-8 flex flex-col sm:flex-row flex-wrap justify-start sm:justify-between items-start gap-6 md:gap-8"> 57 - {/* Product Column */} 61 + {} 58 62 59 - {/* Product Column 60 - <div className="flex flex-col justify-start items-start gap-3 flex-1 min-w-[120px]"> 61 - <div className="self-stretch text-[rgba(73,66,61,0.50)] text-sm font-medium leading-5 font-sans"> 62 - Product 63 - </div> 64 - <div className="flex flex-col justify-end items-start gap-2"> 65 - <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 66 - Features 67 - </div> 68 - <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 69 - Pricing 70 - </div> 71 - <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 72 - Integrations 73 - </div> 74 - <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 75 - Real-time Previews 76 - </div> 77 - <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 78 - Multi-Agent Coding 79 - </div> 80 - </div> 81 - </div>*/} 63 + {} 82 64 83 - {/* Company Column */} 65 + {} 84 66 <div className="flex flex-col justify-start items-start gap-3 flex-1 min-w-[120px]"> 85 - <div className="text-[rgba(73,66,61,0.50)] text-sm font-medium leading-5 font-sans">Company</div> 67 + <div className="text-[rgba(73,66,61,0.50)] text-sm font-medium leading-5 font-sans"> 68 + Company 69 + </div> 86 70 <div className="flex flex-col justify-center items-start gap-2"> 87 71 <div className="text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 88 72 About us ··· 99 83 </div> 100 84 </div> 101 85 102 - {/* Resources Column */} 86 + {} 103 87 <div className="flex flex-col justify-start items-start gap-3 flex-1 min-w-[120px]"> 104 - <div className="text-[rgba(73,66,61,0.50)] text-sm font-medium leading-5 font-sans">Resources</div> 88 + <div className="text-[rgba(73,66,61,0.50)] text-sm font-medium leading-5 font-sans"> 89 + Resources 90 + </div> 105 91 <div className="flex flex-col justify-center items-center gap-2"> 106 92 <div className="self-stretch text-[#49423D] text-sm font-normal leading-5 font-sans cursor-pointer hover:text-[#37322F] transition-colors"> 107 93 Terms of use ··· 120 106 </div> 121 107 </div> 122 108 123 - {/* Bottom Section with Pattern */} 109 + {} 124 110 <div className="self-stretch h-12 relative overflow-hidden border-t border-b border-[rgba(55,50,47,0.12)]"> 125 111 <div className="absolute inset-0 w-full h-full overflow-hidden"> 126 112 <div className="w-full h-full relative"> ··· 140 126 </div> 141 127 </div> 142 128 </div> 143 - ) 129 + ); 144 130 }
+8 -5
components/marketing/hero-section.tsx
··· 1 - import { Button } from "@/components/ui/button" 1 + import { Button } from "@/components/ui/button"; 2 2 3 3 export function HeroSection() { 4 4 return ( 5 5 <section className="relative pt-[216px] pb-16"> 6 6 <div className="max-w-[1060px] mx-auto px-4"> 7 7 <div className="flex flex-col items-center gap-12"> 8 - {/* Hero Content */} 8 + {} 9 9 <div className="max-w-[937px] flex flex-col items-center gap-3"> 10 10 <div className="flex flex-col items-center gap-6"> 11 11 <h1 className="max-w-[748px] text-center text-[#37322f] text-5xl md:text-[80px] font-normal leading-tight md:leading-[96px] font-serif"> ··· 17 17 </div> 18 18 </div> 19 19 20 - {/* CTA Button */} 20 + {} 21 21 <div className="flex justify-center"> 22 - <Button className="h-10 px-12 bg-[#37322f] hover:bg-[#37322f]/90 text-white rounded-full font-medium text-sm shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset]" onClick={() => router.push("/sign-up")}> 22 + <Button 23 + className="h-10 px-12 bg-[#37322f] hover:bg-[#37322f]/90 text-white rounded-full font-medium text-sm shadow-[0px_0px_0px_2.5px_rgba(255,255,255,0.08)_inset]" 24 + onClick={() => router.push("/sign-up")} 25 + > 23 26 Get Started 24 27 </Button> 25 28 </div> 26 29 </div> 27 30 </div> 28 31 </section> 29 - ) 32 + ); 30 33 }
+65 -27
components/marketing/pricing-section.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useState } from "react" 3 + import { useState } from "react"; 4 4 5 5 export default function PricingSection() { 6 - const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annually">("annually") 6 + const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annually">( 7 + "annually", 8 + ); 7 9 8 10 const pricing = { 9 11 starter: { ··· 12 14 }, 13 15 professional: { 14 16 monthly: 0, 15 - annually: 0, // 20% discount for annual 17 + annually: 0, 16 18 }, 17 19 enterprise: { 18 20 monthly: 200, 19 - annually: 160, // 20% discount for annual 21 + annually: 160, 20 22 }, 21 - } 23 + }; 22 24 23 25 return ( 24 26 <div className="w-full flex flex-col justify-center items-center gap-2"> 25 - {/* Header Section */} 27 + {} 26 28 <div className="self-stretch px-6 md:px-24 py-12 md:py-16 border-b border-[rgba(55,50,47,0.12)] flex justify-center items-center gap-6"> 27 29 <div className="w-full max-w-[586px] px-6 py-5 shadow-[0px_2px_4px_rgba(50,45,43,0.06)] overflow-hidden rounded-lg flex flex-col justify-start items-center gap-4 shadow-none"> 28 - {/* Pricing Badge */} 30 + {} 29 31 <div className="px-[14px] py-[6px] bg-white shadow-[0px_0px_0px_4px_rgba(55,50,47,0.05)] overflow-hidden rounded-[90px] flex justify-start items-center gap-[8px] border border-[rgba(2,6,23,0.08)] shadow-xs"> 30 32 <div className="w-[14px] h-[14px] relative overflow-hidden flex items-center justify-center"> 31 - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> 33 + <svg 34 + width="12" 35 + height="12" 36 + viewBox="0 0 12 12" 37 + fill="none" 38 + xmlns="http://www.w3.org/2000/svg" 39 + > 32 40 <path 33 41 d="M6 1V11M8.5 3H4.75C4.28587 3 3.84075 3.18437 3.51256 3.51256C3.18437 3.84075 3 4.28587 3 4.75C3 5.21413 3.18437 5.65925 3.51256 5.98744C3.84075 6.31563 4.28587 6.5 4.75 6.5H7.25C7.71413 6.5 8.15925 6.68437 8.48744 7.01256C8.81563 7.34075 9 7.78587 9 8.25C9 8.71413 8.81563 9.15925 8.48744 9.48744C8.15925 9.81563 7.71413 10 7.25 10H3.5" 34 42 stroke="#37322F" ··· 43 51 </div> 44 52 </div> 45 53 46 - {/* Title */} 54 + {} 47 55 <div className="self-stretch text-center flex justify-center flex-col text-[#49423D] text-3xl md:text-5xl font-semibold leading-tight md:leading-[60px] font-sans tracking-tight"> 48 56 Choose the perfect plan for your business 49 57 </div> 50 58 51 - {/* Description */} 59 + {} 52 60 <div className="self-stretch text-center text-[#605A57] text-base font-normal leading-7 font-sans"> 53 - Scale your operations with flexible pricing that grows with your team. 61 + Scale your operations with flexible pricing that grows with your 62 + team. 54 63 <br /> 55 64 Start free, upgrade when you're ready. 56 65 </div> ··· 77 86 > 78 87 <div 79 88 className={`text-[13px] font-medium leading-5 font-sans transition-colors duration-300 ${ 80 - billingPeriod === "annually" ? "text-[#37322F]" : "text-[#6B7280]" 89 + billingPeriod === "annually" 90 + ? "text-[#37322F]" 91 + : "text-[#6B7280]" 81 92 }`} 82 93 > 83 94 Annually ··· 90 101 > 91 102 <div 92 103 className={`text-[13px] font-medium leading-5 font-sans transition-colors duration-300 ${ 93 - billingPeriod === "monthly" ? "text-[#37322F]" : "text-[#6B7280]" 104 + billingPeriod === "monthly" 105 + ? "text-[#37322F]" 106 + : "text-[#6B7280]" 94 107 }`} 95 108 > 96 109 Monthly ··· 208 221 {/* Plan Header */} 209 222 <div className="self-stretch flex flex-col justify-start items-center gap-9"> 210 223 <div className="self-stretch flex flex-col justify-start items-start gap-2"> 211 - <div className="text-[#FBFAF9] text-lg font-medium leading-7 font-sans">Standard</div> 224 + <div className="text-[#FBFAF9] text-lg font-medium leading-7 font-sans"> 225 + Standard 226 + </div> 212 227 <div className="w-full max-w-[242px] text-[#B2AEA9] text-sm font-normal leading-5 font-sans"> 213 228 Advanced features for growing teams and businesses. 214 229 </div> ··· 217 232 <div className="self-stretch flex flex-col justify-start items-start gap-2"> 218 233 <div className="flex flex-col justify-start items-start gap-1"> 219 234 <div className="relative h-[60px] flex items-center text-[#F0EFEE] text-5xl font-medium leading-[60px] font-serif"> 220 - <span className="invisible">${pricing.professional[billingPeriod]}</span> 235 + <span className="invisible"> 236 + ${pricing.professional[billingPeriod]} 237 + </span> 221 238 <span 222 239 className="absolute inset-0 flex items-center transition-all duration-500" 223 240 style={{ ··· 242 259 </span> 243 260 </div> 244 261 <div className="text-[#D2C6BF] text-sm font-medium font-sans"> 245 - per {billingPeriod === "monthly" ? "month" : "year"}, per user. 262 + per {billingPeriod === "monthly" ? "month" : "year"}, per 263 + user. 246 264 </div> 247 265 </div> 248 266 </div> ··· 266 284 "Password-Protect shares", 267 285 "Burn-after-read shares", 268 286 ].map((feature, index) => ( 269 - <div key={index} className="self-stretch flex justify-start items-center gap-[13px]"> 287 + <div 288 + key={index} 289 + className="self-stretch flex justify-start items-center gap-[13px]" 290 + > 270 291 <div className="w-4 h-4 relative flex items-center justify-center"> 271 - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> 292 + <svg 293 + width="12" 294 + height="12" 295 + viewBox="0 0 12 12" 296 + fill="none" 297 + xmlns="http://www.w3.org/2000/svg" 298 + > 272 299 <path 273 300 d="M10 3L4.5 8.5L2 6" 274 301 stroke="#FF8000" ··· 278 305 /> 279 306 </svg> 280 307 </div> 281 - <div className="flex-1 text-[#F0EFEE] text-[12.5px] font-normal leading-5 font-sans">{feature}</div> 308 + <div className="flex-1 text-[#F0EFEE] text-[12.5px] font-normal leading-5 font-sans"> 309 + {feature} 310 + </div> 282 311 </div> 283 312 ))} 284 313 </div> ··· 289 318 {/* Plan Header */} 290 319 <div className="self-stretch flex flex-col justify-start items-center gap-9"> 291 320 <div className="self-stretch flex flex-col justify-start items-start gap-2"> 292 - <div className="text-[rgba(55,50,47,0.90)] text-lg font-medium leading-7 font-sans">Enterprise</div> 321 + <div className="text-[rgba(55,50,47,0.90)] text-lg font-medium leading-7 font-sans"> 322 + Enterprise 323 + </div> 293 324 <div className="w-full max-w-[242px] text-[rgba(41,37,35,0.70)] text-sm font-normal leading-5 font-sans"> 294 325 Complete solution for large organizations and enterprises. 295 326 </div> ··· 322 353 Contact sales 323 354 </span> 324 355 </div> 325 - <div className="text-[#847971] text-sm font-medium font-sans"> 326 - 327 - </div> 356 + <div className="text-[#847971] text-sm font-medium font-sans"></div> 328 357 </div> 329 358 </div> 330 359 ··· 345 374 "Advanced security features", 346 375 "Custom domain", 347 376 ].map((feature, index) => ( 348 - <div key={index} className="self-stretch flex justify-start items-center gap-[13px]"> 377 + <div 378 + key={index} 379 + className="self-stretch flex justify-start items-center gap-[13px]" 380 + > 349 381 <div className="w-4 h-4 relative flex items-center justify-center"> 350 - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> 382 + <svg 383 + width="12" 384 + height="12" 385 + viewBox="0 0 12 12" 386 + fill="none" 387 + xmlns="http://www.w3.org/2000/svg" 388 + > 351 389 <path 352 390 d="M10 3L4.5 8.5L2 6" 353 391 stroke="#9CA3AF" ··· 380 418 </div> 381 419 </div> 382 420 </div> 383 - ) 421 + ); 384 422 }
+289 -73
components/marketing/smart-simple-brilliant.tsx
··· 1 - import type React from "react" 1 + import type React from "react"; 2 2 3 3 interface SmartSimpleBrilliantProps { 4 - /** Fixed width from Figma: 482px */ 5 - width?: number | string 6 - /** Fixed height from Figma: 300px */ 7 - height?: number | string 8 - /** Optional className to pass to root */ 9 - className?: string 10 - /** Theme palette */ 11 - theme?: "light" | "dark" 4 + width?: number | string; 5 + 6 + height?: number | string; 7 + 8 + className?: string; 9 + 10 + theme?: "light" | "dark"; 12 11 } 13 12 14 - /** 15 - * Smart ยท Simple ยท Brilliant โ€“ Calendar cards 16 - * Generated from Figma via MCP with exact measurements (482ร—300px) 17 - * Single-file component following the v0-ready pattern used in this repo. 18 - */ 19 13 const SmartSimpleBrilliant: React.FC<SmartSimpleBrilliantProps> = ({ 20 14 width = 482, 21 15 height = 300, 22 16 className = "", 23 17 theme = "dark", 24 18 }) => { 25 - // Design tokens (derived from Figma local variables) 26 19 const themeVars = 27 20 theme === "light" 28 21 ? { ··· 38 31 "--ssb-border": "rgba(255,255,255,0.16)", 39 32 "--ssb-inner-border": "rgba(255,255,255,0.12)", 40 33 "--ssb-shadow": "rgba(0,0,0,0.28)", 41 - } as React.CSSProperties) 34 + } as React.CSSProperties); 42 35 43 - // Figma-exported SVG assets used for small icons 44 - const img = "http://localhost:3845/assets/1b1e60b441119fb176db990a3c7fe2670a764855.svg" 45 - const img1 = "http://localhost:3845/assets/a502f04ccfc3811f304b58a3a982a5b6fa070e91.svg" 46 - const img2 = "http://localhost:3845/assets/9c07375bf3b9f1f1d8a0a24447829eb6f54fa928.svg" 47 - const img3 = "http://localhost:3845/assets/19500d66798ef5ea9dc9d5f971cd0e9c29674bd3.svg" 36 + const img = 37 + "http://localhost:3845/assets/1b1e60b441119fb176db990a3c7fe2670a764855.svg"; 38 + const img1 = 39 + "http://localhost:3845/assets/a502f04ccfc3811f304b58a3a982a5b6fa070e91.svg"; 40 + const img2 = 41 + "http://localhost:3845/assets/9c07375bf3b9f1f1d8a0a24447829eb6f54fa928.svg"; 42 + const img3 = 43 + "http://localhost:3845/assets/19500d66798ef5ea9dc9d5f971cd0e9c29674bd3.svg"; 48 44 49 45 return ( 50 46 <div ··· 69 65 position: "relative", 70 66 width: "295.297px", 71 67 height: "212.272px", 72 - transform: "scale(1.2)", // Added 1.2x scale transform 68 + transform: "scale(1.2)", 73 69 }} 74 70 > 75 - {/* Left tilted card group */} 76 - <div style={{ position: "absolute", left: "123.248px", top: "0px", width: 0, height: 0 }}> 71 + {} 72 + <div 73 + style={{ 74 + position: "absolute", 75 + left: "123.248px", 76 + top: "0px", 77 + width: 0, 78 + height: 0, 79 + }} 80 + > 77 81 <div style={{ transform: "rotate(5deg)", transformOrigin: "center" }}> 78 82 <div 79 83 style={{ ··· 81 85 background: "#ffffff", 82 86 borderRadius: "9px", 83 87 padding: "6px", 84 - boxShadow: "0px 0px 0px 1px rgba(0,0,0,0.08), 0px 2px 4px rgba(0,0,0,0.07)", 88 + boxShadow: 89 + "0px 0px 0px 1px rgba(0,0,0,0.08), 0px 2px 4px rgba(0,0,0,0.07)", 85 90 }} 86 91 > 87 - {/* Amber event */} 92 + {} 88 93 <div 89 94 style={{ 90 95 width: "100%", ··· 97 102 > 98 103 <div style={{ width: "2.25px", background: "#F59E0B" }} /> 99 104 <div style={{ padding: "4.5px", width: "100%" }}> 100 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 105 + <div 106 + style={{ 107 + display: "flex", 108 + gap: "3px", 109 + alignItems: "center", 110 + }} 111 + > 101 112 <span 102 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#92400E" }} 113 + style={{ 114 + fontFamily: "Inter, sans-serif", 115 + fontWeight: 500, 116 + fontSize: "9px", 117 + color: "#92400E", 118 + }} 103 119 > 104 120 2:00 105 121 </span> 106 122 <span 107 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#92400E" }} 123 + style={{ 124 + fontFamily: "Inter, sans-serif", 125 + fontWeight: 500, 126 + fontSize: "9px", 127 + color: "#92400E", 128 + }} 108 129 > 109 130 PM 110 131 </span> 111 - <div style={{ background: "#92400E", padding: "1.5px", borderRadius: "100px" }}> 112 - <div style={{ width: "8px", height: "8px", overflow: "hidden", position: "relative" }}> 132 + <div 133 + style={{ 134 + background: "#92400E", 135 + padding: "1.5px", 136 + borderRadius: "100px", 137 + }} 138 + > 139 + <div 140 + style={{ 141 + width: "8px", 142 + height: "8px", 143 + overflow: "hidden", 144 + position: "relative", 145 + }} 146 + > 113 147 <img 114 148 src={img || "/placeholder.svg"} 115 149 alt="video" 116 - style={{ position: "absolute", inset: "20% 10% 20% 10%" }} 150 + style={{ 151 + position: "absolute", 152 + inset: "20% 10% 20% 10%", 153 + }} 117 154 /> 118 155 </div> 119 156 </div> 120 157 </div> 121 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#92400E" }}> 158 + <div 159 + style={{ 160 + fontFamily: "Inter, sans-serif", 161 + fontWeight: 600, 162 + fontSize: "9px", 163 + color: "#92400E", 164 + }} 165 + > 122 166 1:1 with Heather 123 167 </div> 124 168 </div> 125 169 </div> 126 170 127 - {/* Sky event */} 171 + {} 128 172 <div 129 173 style={{ 130 174 width: "100%", ··· 138 182 > 139 183 <div style={{ width: "2.25px", background: "#0EA5E9" }} /> 140 184 <div style={{ padding: "4.5px", width: "100%" }}> 141 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 185 + <div 186 + style={{ 187 + display: "flex", 188 + gap: "3px", 189 + alignItems: "center", 190 + }} 191 + > 142 192 <span 143 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#0C4A6E" }} 193 + style={{ 194 + fontFamily: "Inter, sans-serif", 195 + fontWeight: 500, 196 + fontSize: "9px", 197 + color: "#0C4A6E", 198 + }} 144 199 > 145 200 2:00 146 201 </span> 147 202 <span 148 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#0C4A6E" }} 203 + style={{ 204 + fontFamily: "Inter, sans-serif", 205 + fontWeight: 500, 206 + fontSize: "9px", 207 + color: "#0C4A6E", 208 + }} 149 209 > 150 210 PM 151 211 </span> 152 - <div style={{ background: "#0C4A6E", padding: "1.5px", borderRadius: "100px" }}> 153 - <div style={{ width: "8px", height: "8px", overflow: "hidden", position: "relative" }}> 212 + <div 213 + style={{ 214 + background: "#0C4A6E", 215 + padding: "1.5px", 216 + borderRadius: "100px", 217 + }} 218 + > 219 + <div 220 + style={{ 221 + width: "8px", 222 + height: "8px", 223 + overflow: "hidden", 224 + position: "relative", 225 + }} 226 + > 154 227 <img 155 228 src={img1 || "/placeholder.svg"} 156 229 alt="video" 157 - style={{ position: "absolute", inset: "20% 10% 20% 10%" }} 230 + style={{ 231 + position: "absolute", 232 + inset: "20% 10% 20% 10%", 233 + }} 158 234 /> 159 235 </div> 160 236 </div> 161 237 </div> 162 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#0C4A6E" }}> 238 + <div 239 + style={{ 240 + fontFamily: "Inter, sans-serif", 241 + fontWeight: 600, 242 + fontSize: "9px", 243 + color: "#0C4A6E", 244 + }} 245 + > 163 246 Concept Design Review II 164 247 </div> 165 248 </div> 166 249 </div> 167 250 168 - {/* Emerald event */} 251 + {} 169 252 <div 170 253 style={{ 171 254 width: "100%", ··· 179 262 > 180 263 <div style={{ width: "2.25px", background: "#10B981" }} /> 181 264 <div style={{ padding: "4.5px", width: "100%" }}> 182 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 265 + <div 266 + style={{ 267 + display: "flex", 268 + gap: "3px", 269 + alignItems: "center", 270 + }} 271 + > 183 272 <span 184 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#064E3B" }} 273 + style={{ 274 + fontFamily: "Inter, sans-serif", 275 + fontWeight: 500, 276 + fontSize: "9px", 277 + color: "#064E3B", 278 + }} 185 279 > 186 280 9:00 187 281 </span> 188 282 <span 189 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#064E3B" }} 283 + style={{ 284 + fontFamily: "Inter, sans-serif", 285 + fontWeight: 500, 286 + fontSize: "9px", 287 + color: "#064E3B", 288 + }} 190 289 > 191 290 AM 192 291 </span> 193 292 </div> 194 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#064E3B" }}> 293 + <div 294 + style={{ 295 + fontFamily: "Inter, sans-serif", 296 + fontWeight: 600, 297 + fontSize: "9px", 298 + color: "#064E3B", 299 + }} 300 + > 195 301 Webinar: Figma ... 196 302 </div> 197 303 </div> ··· 200 306 </div> 201 307 </div> 202 308 203 - {/* Right card */} 204 - <div style={{ position: "absolute", left: "0px", top: "6.075px", width: "155.25px" }}> 205 - <div style={{ transform: "rotate(-5deg)", transformOrigin: "center" }}> 309 + {} 310 + <div 311 + style={{ 312 + position: "absolute", 313 + left: "0px", 314 + top: "6.075px", 315 + width: "155.25px", 316 + }} 317 + > 318 + <div 319 + style={{ transform: "rotate(-5deg)", transformOrigin: "center" }} 320 + > 206 321 <div 207 322 style={{ 208 323 width: "155.25px", ··· 213 328 "-8px 6px 11.3px rgba(0,0,0,0.12), 0px 0px 0px 1px rgba(0,0,0,0.08), 0px 2px 4px rgba(0,0,0,0.06)", 214 329 }} 215 330 > 216 - {/* Violet event */} 331 + {} 217 332 <div 218 333 style={{ 219 334 width: "100%", ··· 226 341 > 227 342 <div style={{ width: "2.25px", background: "#8B5CF6" }} /> 228 343 <div style={{ padding: "4.5px", width: "100%" }}> 229 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 344 + <div 345 + style={{ 346 + display: "flex", 347 + gap: "3px", 348 + alignItems: "center", 349 + }} 350 + > 230 351 <span 231 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#581C87" }} 352 + style={{ 353 + fontFamily: "Inter, sans-serif", 354 + fontWeight: 500, 355 + fontSize: "9px", 356 + color: "#581C87", 357 + }} 232 358 > 233 359 11:00 234 360 </span> 235 361 <span 236 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#581C87" }} 362 + style={{ 363 + fontFamily: "Inter, sans-serif", 364 + fontWeight: 500, 365 + fontSize: "9px", 366 + color: "#581C87", 367 + }} 237 368 > 238 369 AM 239 370 </span> 240 - <div style={{ background: "#581C87", padding: "1.5px", borderRadius: "100px" }}> 241 - <div style={{ width: "8px", height: "8px", overflow: "hidden", position: "relative" }}> 371 + <div 372 + style={{ 373 + background: "#581C87", 374 + padding: "1.5px", 375 + borderRadius: "100px", 376 + }} 377 + > 378 + <div 379 + style={{ 380 + width: "8px", 381 + height: "8px", 382 + overflow: "hidden", 383 + position: "relative", 384 + }} 385 + > 242 386 <img 243 387 src={img2 || "/placeholder.svg"} 244 388 alt="video" 245 - style={{ position: "absolute", inset: "20% 10% 20% 10%" }} 389 + style={{ 390 + position: "absolute", 391 + inset: "20% 10% 20% 10%", 392 + }} 246 393 /> 247 394 </div> 248 395 </div> 249 396 </div> 250 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#581C87" }}> 397 + <div 398 + style={{ 399 + fontFamily: "Inter, sans-serif", 400 + fontWeight: 600, 401 + fontSize: "9px", 402 + color: "#581C87", 403 + }} 404 + > 251 405 Onboarding Presentation 252 406 </div> 253 407 </div> 254 408 </div> 255 409 256 - {/* Rose event */} 410 + {} 257 411 <div 258 412 style={{ 259 413 width: "100%", ··· 267 421 > 268 422 <div style={{ width: "2.25px", background: "#F43F5E" }} /> 269 423 <div style={{ padding: "4.5px", width: "100%" }}> 270 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 424 + <div 425 + style={{ 426 + display: "flex", 427 + gap: "3px", 428 + alignItems: "center", 429 + }} 430 + > 271 431 <span 272 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#BE123C" }} 432 + style={{ 433 + fontFamily: "Inter, sans-serif", 434 + fontWeight: 500, 435 + fontSize: "9px", 436 + color: "#BE123C", 437 + }} 273 438 > 274 439 4:00 275 440 </span> 276 441 <span 277 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#BE123C" }} 442 + style={{ 443 + fontFamily: "Inter, sans-serif", 444 + fontWeight: 500, 445 + fontSize: "9px", 446 + color: "#BE123C", 447 + }} 278 448 > 279 449 PM 280 450 </span> 281 - <div style={{ background: "#BE123C", padding: "1.5px", borderRadius: "100px" }}> 282 - <div style={{ width: "8px", height: "8px", overflow: "hidden", position: "relative" }}> 451 + <div 452 + style={{ 453 + background: "#BE123C", 454 + padding: "1.5px", 455 + borderRadius: "100px", 456 + }} 457 + > 458 + <div 459 + style={{ 460 + width: "8px", 461 + height: "8px", 462 + overflow: "hidden", 463 + position: "relative", 464 + }} 465 + > 283 466 <img 284 467 src={img3 || "/placeholder.svg"} 285 468 alt="video" 286 - style={{ position: "absolute", inset: "20% 10% 20% 10%" }} 469 + style={{ 470 + position: "absolute", 471 + inset: "20% 10% 20% 10%", 472 + }} 287 473 /> 288 474 </div> 289 475 </div> 290 476 </div> 291 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#BE123C" }}> 477 + <div 478 + style={{ 479 + fontFamily: "Inter, sans-serif", 480 + fontWeight: 600, 481 + fontSize: "9px", 482 + color: "#BE123C", 483 + }} 484 + > 292 485 ๐Ÿท Happy Hour 293 486 </div> 294 487 </div> 295 488 </div> 296 489 297 - {/* Violet tall event */} 490 + {} 298 491 <div 299 492 style={{ 300 493 width: "100%", ··· 308 501 > 309 502 <div style={{ width: "2.25px", background: "#8B5CF6" }} /> 310 503 <div style={{ padding: "4.5px", width: "100%" }}> 311 - <div style={{ display: "flex", gap: "3px", alignItems: "center" }}> 504 + <div 505 + style={{ 506 + display: "flex", 507 + gap: "3px", 508 + alignItems: "center", 509 + }} 510 + > 312 511 <span 313 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#581C87" }} 512 + style={{ 513 + fontFamily: "Inter, sans-serif", 514 + fontWeight: 500, 515 + fontSize: "9px", 516 + color: "#581C87", 517 + }} 314 518 > 315 519 11:00 316 520 </span> 317 521 <span 318 - style={{ fontFamily: "Inter, sans-serif", fontWeight: 500, fontSize: "9px", color: "#581C87" }} 522 + style={{ 523 + fontFamily: "Inter, sans-serif", 524 + fontWeight: 500, 525 + fontSize: "9px", 526 + color: "#581C87", 527 + }} 319 528 > 320 529 AM 321 530 </span> 322 531 </div> 323 - <div style={{ fontFamily: "Inter, sans-serif", fontWeight: 600, fontSize: "9px", color: "#581C87" }}> 532 + <div 533 + style={{ 534 + fontFamily: "Inter, sans-serif", 535 + fontWeight: 600, 536 + fontSize: "9px", 537 + color: "#581C87", 538 + }} 539 + > 324 540 ๐Ÿ” New Employee Welcome Lunch! 325 541 </div> 326 542 </div> ··· 330 546 </div> 331 547 </div> 332 548 </div> 333 - ) 334 - } 549 + ); 550 + }; 335 551 336 - export default SmartSimpleBrilliant 552 + export default SmartSimpleBrilliant;
+45 -37
components/marketing/your-work-in-sync.tsx
··· 1 - import type React from "react" 1 + import type React from "react"; 2 2 3 3 interface YourWorkInSyncProps { 4 - /** Fixed width from Figma: 482px */ 5 - width?: number | string 6 - /** Fixed height from Figma: 300px */ 7 - height?: number | string 8 - /** Optional className to pass to root */ 9 - className?: string 10 - /** Theme palette */ 11 - theme?: "light" | "dark" 4 + width?: number | string; 5 + 6 + height?: number | string; 7 + 8 + className?: string; 9 + 10 + theme?: "light" | "dark"; 12 11 } 13 12 14 - /** 15 - * Your work, in sync โ€“ Chat conversation UI 16 - * Generated from Figma via MCP with exact measurements (482ร—300px) 17 - * Single-file component following the v0-ready pattern used in this repo. 18 - */ 19 13 const YourWorkInSync: React.FC<YourWorkInSyncProps> = ({ 20 14 width = 482, 21 15 height = 300, 22 16 className = "", 23 17 theme = "dark", 24 18 }) => { 25 - // Design tokens (derived from Figma local variables) 26 19 const themeVars = 27 20 theme === "light" 28 21 ? { ··· 44 37 "--yws-bubble-white": "#ffffff", 45 38 "--yws-border": "rgba(255,255,255,0.12)", 46 39 "--yws-shadow": "rgba(0,0,0,0.24)", 47 - } as React.CSSProperties) 40 + } as React.CSSProperties); 48 41 49 - // Figma-exported assets 50 - const OneAIIMG = "/ai.png" 51 - const imgFrame2147223206 = "/professional-man-avatar-with-beard-and-glasses-loo.jpg" 52 - const imgFrame2147223207 = "/professional-person-avatar-with-curly-hair-and-war.jpg" 42 + const OneAIIMG = "/ai.png"; 43 + const imgFrame2147223206 = 44 + "/professional-man-avatar-with-beard-and-glasses-loo.jpg"; 45 + const imgFrame2147223207 = 46 + "/professional-person-avatar-with-curly-hair-and-war.jpg"; 53 47 const imgArrowUp = 54 - "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='3' strokeLinecap='round' strokeLinejoin='round'%3E%3Cpath d='m5 12 7-7 7 7'/%3E%3Cpath d='M12 19V5'/%3E%3C/svg%3E" 48 + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='3' strokeLinecap='round' strokeLinejoin='round'%3E%3Cpath d='m5 12 7-7 7 7'/%3E%3Cpath d='M12 19V5'/%3E%3C/svg%3E"; 55 49 56 50 return ( 57 51 <div ··· 68 62 role="img" 69 63 aria-label="Chat conversation showing team collaboration sync" 70 64 > 71 - {/* Root frame size 482ร—300 โ€“ content centered */} 65 + {} 72 66 <div 73 67 style={{ 74 68 position: "absolute", ··· 79 73 height: "216px", 80 74 }} 81 75 > 82 - {/* Remove the flip transformation and position messages normally */} 83 - <div style={{ width: "356px", height: "216px", position: "relative", transform: "scale(1.1)" }}> 84 - {/* Message 1: Left side with avatar */} 76 + {} 77 + <div 78 + style={{ 79 + width: "356px", 80 + height: "216px", 81 + position: "relative", 82 + transform: "scale(1.1)", 83 + }} 84 + > 85 + {} 85 86 <div 86 87 style={{ 87 88 position: "absolute", ··· 94 95 height: "36px", 95 96 }} 96 97 > 97 - {/* Avatar */} 98 + {} 98 99 <div 99 100 style={{ 100 101 width: "36px", ··· 107 108 flexShrink: 0, 108 109 }} 109 110 /> 110 - {/* Message bubble */} 111 + {} 111 112 <div 112 113 style={{ 113 - background: theme === "light" ? "#e8e5e3" : "var(--yws-bubble-light)", 114 + background: 115 + theme === "light" ? "#e8e5e3" : "var(--yws-bubble-light)", 114 116 borderRadius: "999px", 115 117 padding: "0px 12px", 116 118 height: "36px", ··· 126 128 fontSize: "13px", 127 129 lineHeight: "16px", 128 130 letterSpacing: "-0.4px", 129 - color: theme === "light" ? "#37322f" : "var(--yws-text-primary)", 131 + color: 132 + theme === "light" ? "#37322f" : "var(--yws-text-primary)", 130 133 whiteSpace: "nowrap", 131 134 }} 132 135 > ··· 150 153 {/* Message bubble */} 151 154 <div 152 155 style={{ 153 - background: theme === "light" ? "#37322f" : "var(--yws-bubble-dark)", 156 + background: 157 + theme === "light" ? "#37322f" : "var(--yws-bubble-dark)", 154 158 borderRadius: "999px", 155 159 padding: "0px 12px", 156 160 height: "36px", ··· 217 221 {/* Message bubble */} 218 222 <div 219 223 style={{ 220 - background: theme === "light" ? "#e8e5e3" : "var(--yws-bubble-light)", 224 + background: 225 + theme === "light" ? "#e8e5e3" : "var(--yws-bubble-light)", 221 226 borderRadius: "999px", 222 227 padding: "0px 12px", 223 228 height: "36px", ··· 233 238 fontSize: "13px", 234 239 lineHeight: "16px", 235 240 letterSpacing: "-0.4px", 236 - color: theme === "light" ? "#37322f" : "var(--yws-text-primary)", 241 + color: 242 + theme === "light" ? "#37322f" : "var(--yws-text-primary)", 237 243 whiteSpace: "nowrap", 238 244 }} 239 245 > ··· 264 270 display: "flex", 265 271 alignItems: "center", 266 272 justifyContent: "center", 267 - boxShadow: "0px 0px 0px 1px rgba(0,0,0,0.08), 0px 1px 2px -0.4px rgba(0,0,0,0.08)", 273 + boxShadow: 274 + "0px 0px 0px 1px rgba(0,0,0,0.08), 0px 1px 2px -0.4px rgba(0,0,0,0.08)", 268 275 overflow: "hidden", 269 276 }} 270 277 > ··· 287 294 width: "36px", 288 295 height: "36px", 289 296 borderRadius: "44px", 290 - background: theme === "light" ? "#37322f" : "var(--yws-bubble-dark)", 297 + background: 298 + theme === "light" ? "#37322f" : "var(--yws-bubble-dark)", 291 299 display: "flex", 292 300 alignItems: "center", 293 301 justifyContent: "center", ··· 310 318 </div> 311 319 </div> 312 320 </div> 313 - ) 314 - } 321 + ); 322 + }; 315 323 316 - export default YourWorkInSync 324 + export default YourWorkInSync;
+60 -52
components/providers/calendar-context.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useLocalStorage } from "@/hooks/useLocalStorage" 4 - import { createContext, useContext } from "react" 5 - import type React from "react" 3 + import { useLocalStorage } from "@/hooks/useLocalStorage"; 4 + import { createContext, useContext } from "react"; 5 + import type React from "react"; 6 6 7 7 export interface CalendarCategory { 8 - id: string 9 - name: string 10 - color: string 11 - keywords?: string[] 8 + id: string; 9 + name: string; 10 + color: string; 11 + keywords?: string[]; 12 12 } 13 13 14 14 export interface CalendarEvent { 15 - id: string 16 - title: string 17 - startDate: Date 18 - endDate: Date 19 - isAllDay: boolean 20 - recurrence: "none" | "daily" | "weekly" | "monthly" | "yearly" 21 - location?: string 22 - participants: string[] 23 - notification: number 24 - description?: string 25 - color: string 26 - calendarId: string 15 + id: string; 16 + title: string; 17 + startDate: Date; 18 + endDate: Date; 19 + isAllDay: boolean; 20 + recurrence: "none" | "daily" | "weekly" | "monthly" | "yearly"; 21 + location?: string; 22 + participants: string[]; 23 + notification: number; 24 + description?: string; 25 + color: string; 26 + calendarId: string; 27 27 } 28 28 29 29 interface CalendarContextType { 30 - calendars: CalendarCategory[] 31 - setCalendars: (calendars: CalendarCategory[]) => void 32 - events: CalendarEvent[] 33 - setEvents: (events: CalendarEvent[]) => void 34 - addCategory: (category: CalendarCategory) => void 35 - removeCategory: (id: string) => void 36 - updateCategory: (id: string, category: Partial<CalendarCategory>) => void 37 - addEvent: (newEvent: CalendarEvent) => void 30 + calendars: CalendarCategory[]; 31 + setCalendars: (calendars: CalendarCategory[]) => void; 32 + events: CalendarEvent[]; 33 + setEvents: (events: CalendarEvent[]) => void; 34 + addCategory: (category: CalendarCategory) => void; 35 + removeCategory: (id: string) => void; 36 + updateCategory: (id: string, category: Partial<CalendarCategory>) => void; 37 + addEvent: (newEvent: CalendarEvent) => void; 38 38 } 39 39 40 - const CalendarContext = createContext<CalendarContextType | undefined>(undefined) 40 + const CalendarContext = createContext<CalendarContextType | undefined>( 41 + undefined, 42 + ); 41 43 42 - // ้ป˜่ฎคๆ—ฅๅކๅˆ†็ฑป 43 44 const defaultCalendars: CalendarCategory[] = [ 44 45 { 45 46 id: "work", ··· 53 54 color: "bg-green-500", 54 55 keywords: [], 55 56 }, 56 - ] 57 + ]; 57 58 58 59 export function CalendarProvider({ children }: { children: React.ReactNode }) { 59 - // ไฟฎๆ”น่ฟ™้‡Œ๏ผŒไฝฟ็”จ็ฉบๆ•ฐ็ป„ไฝœไธบ้ป˜่ฎคๅ€ผ๏ผŒไธ่‡ชๅŠจๅˆ›ๅปบๅˆ†็ฑป 60 - const [calendars, setCalendars] = useLocalStorage<CalendarCategory[]>("calendar-categories", []) 60 + const [calendars, setCalendars] = useLocalStorage<CalendarCategory[]>( 61 + "calendar-categories", 62 + [], 63 + ); 61 64 62 - const [events, setEvents] = useLocalStorage<CalendarEvent[]>("calendar-events", []) 65 + const [events, setEvents] = useLocalStorage<CalendarEvent[]>( 66 + "calendar-events", 67 + [], 68 + ); 63 69 64 70 const addCategory = (category: CalendarCategory) => { 65 - setCalendars([...calendars, category]) 66 - } 71 + setCalendars([...calendars, category]); 72 + }; 67 73 68 74 const removeCategory = (id: string) => { 69 - setCalendars(calendars.filter((cal) => cal.id !== id)) 70 - } 75 + setCalendars(calendars.filter((cal) => cal.id !== id)); 76 + }; 71 77 72 78 const updateCategory = (id: string, category: Partial<CalendarCategory>) => { 73 - setCalendars(calendars.map((cal) => (cal.id === id ? { ...cal, ...category } : cal))) 74 - } 79 + setCalendars( 80 + calendars.map((cal) => (cal.id === id ? { ...cal, ...category } : cal)), 81 + ); 82 + }; 75 83 76 84 const addEvent = (newEvent: CalendarEvent) => { 77 85 setEvents((prevEvents) => { 78 - // ๆฃ€ๆŸฅไบ‹ไปถๆ˜ฏๅฆๅทฒๅญ˜ๅœจ 79 - const eventExists = prevEvents.some((event) => event.id === newEvent.id) 86 + const eventExists = prevEvents.some((event) => event.id === newEvent.id); 80 87 81 - // ๅฆ‚ๆžœๅทฒๅญ˜ๅœจ๏ผŒๆ›ฟๆขๅฎƒ๏ผ›ๅฆๅˆ™ๆทปๅŠ ๆ–ฐไบ‹ไปถ 82 88 if (eventExists) { 83 - return prevEvents.map((event) => (event.id === newEvent.id ? newEvent : event)) 89 + return prevEvents.map((event) => 90 + event.id === newEvent.id ? newEvent : event, 91 + ); 84 92 } else { 85 - return [...prevEvents, newEvent] 93 + return [...prevEvents, newEvent]; 86 94 } 87 - }) 88 - } 95 + }); 96 + }; 89 97 90 98 return ( 91 99 <CalendarContext.Provider ··· 102 110 > 103 111 {children} 104 112 </CalendarContext.Provider> 105 - ) 113 + ); 106 114 } 107 115 108 116 export function useCalendar() { 109 - const context = useContext(CalendarContext) 117 + const context = useContext(CalendarContext); 110 118 if (context === undefined) { 111 - throw new Error("useCalendar must be used within a CalendarProvider") 119 + throw new Error("useCalendar must be used within a CalendarProvider"); 112 120 } 113 - return context 121 + return context; 114 122 } 115 123 116 - export const useCalendarContext = useCalendar 124 + export const useCalendarContext = useCalendar;
+66 -65
components/ui/use-toast.tsx
··· 1 - "use client" 1 + "use client"; 2 2 3 - import * as React from "react" 3 + import * as React from "react"; 4 4 5 - import type { ToastActionElement, ToastProps } from "@/components/ui/toast" 5 + import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 6 6 7 - const TOAST_LIMIT = 5 8 - const TOAST_REMOVE_DELAY = 5000 // Change from 1000000 to 5000 for testing 7 + const TOAST_LIMIT = 5; 8 + const TOAST_REMOVE_DELAY = 5000; 9 9 10 10 type ToasterToast = ToastProps & { 11 - id: string 12 - title?: React.ReactNode 13 - description?: React.ReactNode 14 - action?: ToastActionElement 15 - } 11 + id: string; 12 + title?: React.ReactNode; 13 + description?: React.ReactNode; 14 + action?: ToastActionElement; 15 + }; 16 16 17 17 const actionTypes = { 18 18 ADD_TOAST: "ADD_TOAST", 19 19 UPDATE_TOAST: "UPDATE_TOAST", 20 20 DISMISS_TOAST: "DISMISS_TOAST", 21 21 REMOVE_TOAST: "REMOVE_TOAST", 22 - } as const 22 + } as const; 23 23 24 - let count = 0 24 + let count = 0; 25 25 26 26 function genId() { 27 - count = (count + 1) % Number.MAX_VALUE 28 - return count.toString() 27 + count = (count + 1) % Number.MAX_VALUE; 28 + return count.toString(); 29 29 } 30 30 31 - type ActionType = typeof actionTypes 31 + type ActionType = typeof actionTypes; 32 32 33 33 type Action = 34 34 | { 35 - type: ActionType["ADD_TOAST"] 36 - toast: ToasterToast 35 + type: ActionType["ADD_TOAST"]; 36 + toast: ToasterToast; 37 37 } 38 38 | { 39 - type: ActionType["UPDATE_TOAST"] 40 - toast: Partial<ToasterToast> 39 + type: ActionType["UPDATE_TOAST"]; 40 + toast: Partial<ToasterToast>; 41 41 } 42 42 | { 43 - type: ActionType["DISMISS_TOAST"] 44 - toastId?: string 43 + type: ActionType["DISMISS_TOAST"]; 44 + toastId?: string; 45 45 } 46 46 | { 47 - type: ActionType["REMOVE_TOAST"] 48 - toastId?: string 49 - } 47 + type: ActionType["REMOVE_TOAST"]; 48 + toastId?: string; 49 + }; 50 50 51 51 interface State { 52 - toasts: ToasterToast[] 52 + toasts: ToasterToast[]; 53 53 } 54 54 55 - const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() 55 + const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); 56 56 57 57 const reducer = (state: State, action: Action): State => { 58 58 switch (action.type) { ··· 60 60 return { 61 61 ...state, 62 62 toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 63 - } 63 + }; 64 64 65 65 case actionTypes.UPDATE_TOAST: 66 66 return { 67 67 ...state, 68 - toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), 69 - } 68 + toasts: state.toasts.map((t) => 69 + t.id === action.toast.id ? { ...t, ...action.toast } : t, 70 + ), 71 + }; 70 72 71 73 case actionTypes.DISMISS_TOAST: { 72 - const { toastId } = action 74 + const { toastId } = action; 73 75 74 - // ! Side effects ! - This could be extracted into a dismissToast() action, 75 - // but I'll keep it here for simplicity 76 76 if (toastId) { 77 - addToRemoveQueue(toastId) 77 + addToRemoveQueue(toastId); 78 78 } else { 79 79 state.toasts.forEach((toast) => { 80 - addToRemoveQueue(toast.id) 81 - }) 80 + addToRemoveQueue(toast.id); 81 + }); 82 82 } 83 83 84 84 return { ··· 91 91 } 92 92 : t, 93 93 ), 94 - } 94 + }; 95 95 } 96 96 case actionTypes.REMOVE_TOAST: 97 97 if (action.toastId === undefined) { 98 98 return { 99 99 ...state, 100 100 toasts: [], 101 - } 101 + }; 102 102 } 103 103 return { 104 104 ...state, 105 105 toasts: state.toasts.filter((t) => t.id !== action.toastId), 106 - } 106 + }; 107 107 } 108 - } 108 + }; 109 109 110 - const listeners: Array<(state: State) => void> = [] 110 + const listeners: Array<(state: State) => void> = []; 111 111 112 - let memoryState: State = { toasts: [] } 112 + let memoryState: State = { toasts: [] }; 113 113 114 114 function dispatch(action: Action) { 115 - memoryState = reducer(memoryState, action) 115 + memoryState = reducer(memoryState, action); 116 116 listeners.forEach((listener) => { 117 - listener(memoryState) 118 - }) 117 + listener(memoryState); 118 + }); 119 119 } 120 120 121 121 function toast({ ...props }: Omit<ToasterToast, "id">) { 122 - const id = genId() 122 + const id = genId(); 123 123 124 124 const update = (props: ToasterToast) => 125 125 dispatch({ 126 126 type: actionTypes.UPDATE_TOAST, 127 127 toast: { ...props, id }, 128 - }) 129 - const dismiss = () => dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }) 128 + }); 129 + const dismiss = () => 130 + dispatch({ type: actionTypes.DISMISS_TOAST, toastId: id }); 130 131 131 132 dispatch({ 132 133 type: actionTypes.ADD_TOAST, ··· 135 136 id, 136 137 open: true, 137 138 onOpenChange: (open) => { 138 - if (!open) dismiss() 139 + if (!open) dismiss(); 139 140 }, 140 141 }, 141 - }) 142 + }); 142 143 143 144 return { 144 145 id: id, 145 146 dismiss, 146 147 update, 147 - } 148 + }; 148 149 } 149 150 150 151 function useToast() { 151 - const [state, setState] = React.useState<State>(memoryState) 152 + const [state, setState] = React.useState<State>(memoryState); 152 153 153 154 React.useEffect(() => { 154 - listeners.push(setState) 155 + listeners.push(setState); 155 156 return () => { 156 - const index = listeners.indexOf(setState) 157 + const index = listeners.indexOf(setState); 157 158 if (index > -1) { 158 - listeners.splice(index, 1) 159 + listeners.splice(index, 1); 159 160 } 160 - } 161 - }, []) 161 + }; 162 + }, []); 162 163 163 164 return { 164 165 ...state, 165 166 toast, 166 - dismiss: (toastId?: string) => dispatch({ type: actionTypes.DISMISS_TOAST, toastId }), 167 - } 167 + dismiss: (toastId?: string) => 168 + dispatch({ type: actionTypes.DISMISS_TOAST, toastId }), 169 + }; 168 170 } 169 171 170 172 function addToRemoveQueue(toastId: string) { 171 173 if (toastTimeouts.has(toastId)) { 172 - return 174 + return; 173 175 } 174 176 175 177 const timeout = setTimeout(() => { 176 - toastTimeouts.delete(toastId) 178 + toastTimeouts.delete(toastId); 177 179 dispatch({ 178 180 type: actionTypes.REMOVE_TOAST, 179 181 toastId, 180 - }) 181 - }, TOAST_REMOVE_DELAY) 182 + }); 183 + }, TOAST_REMOVE_DELAY); 182 184 183 - toastTimeouts.set(toastId, timeout) 185 + toastTimeouts.set(toastId, timeout); 184 186 } 185 187 186 - export { useToast, toast } 187 - 188 + export { useToast, toast };
+64 -70
hooks/use-toast.ts
··· 1 - "use client" 1 + "use client"; 2 2 3 - // Inspired by react-hot-toast library 4 - import * as React from "react" 3 + import * as React from "react"; 5 4 6 - import type { 7 - ToastActionElement, 8 - ToastProps, 9 - } from "@/components/ui/toast" 5 + import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 10 6 11 - const TOAST_LIMIT = 1 12 - const TOAST_REMOVE_DELAY = 1000000 7 + const TOAST_LIMIT = 1; 8 + const TOAST_REMOVE_DELAY = 1000000; 13 9 14 10 type ToasterToast = ToastProps & { 15 - id: string 16 - title?: React.ReactNode 17 - description?: React.ReactNode 18 - action?: ToastActionElement 19 - } 11 + id: string; 12 + title?: React.ReactNode; 13 + description?: React.ReactNode; 14 + action?: ToastActionElement; 15 + }; 20 16 21 17 const actionTypes = { 22 18 ADD_TOAST: "ADD_TOAST", 23 19 UPDATE_TOAST: "UPDATE_TOAST", 24 20 DISMISS_TOAST: "DISMISS_TOAST", 25 21 REMOVE_TOAST: "REMOVE_TOAST", 26 - } as const 22 + } as const; 27 23 28 - let count = 0 24 + let count = 0; 29 25 30 26 function genId() { 31 - count = (count + 1) % Number.MAX_SAFE_INTEGER 32 - return count.toString() 27 + count = (count + 1) % Number.MAX_SAFE_INTEGER; 28 + return count.toString(); 33 29 } 34 30 35 - type ActionType = typeof actionTypes 31 + type ActionType = typeof actionTypes; 36 32 37 33 type Action = 38 34 | { 39 - type: ActionType["ADD_TOAST"] 40 - toast: ToasterToast 35 + type: ActionType["ADD_TOAST"]; 36 + toast: ToasterToast; 41 37 } 42 38 | { 43 - type: ActionType["UPDATE_TOAST"] 44 - toast: Partial<ToasterToast> 39 + type: ActionType["UPDATE_TOAST"]; 40 + toast: Partial<ToasterToast>; 45 41 } 46 42 | { 47 - type: ActionType["DISMISS_TOAST"] 48 - toastId?: ToasterToast["id"] 43 + type: ActionType["DISMISS_TOAST"]; 44 + toastId?: ToasterToast["id"]; 49 45 } 50 46 | { 51 - type: ActionType["REMOVE_TOAST"] 52 - toastId?: ToasterToast["id"] 53 - } 47 + type: ActionType["REMOVE_TOAST"]; 48 + toastId?: ToasterToast["id"]; 49 + }; 54 50 55 51 interface State { 56 - toasts: ToasterToast[] 52 + toasts: ToasterToast[]; 57 53 } 58 54 59 - const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>() 55 + const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); 60 56 61 57 const addToRemoveQueue = (toastId: string) => { 62 58 if (toastTimeouts.has(toastId)) { 63 - return 59 + return; 64 60 } 65 61 66 62 const timeout = setTimeout(() => { 67 - toastTimeouts.delete(toastId) 63 + toastTimeouts.delete(toastId); 68 64 dispatch({ 69 65 type: "REMOVE_TOAST", 70 66 toastId: toastId, 71 - }) 72 - }, TOAST_REMOVE_DELAY) 67 + }); 68 + }, TOAST_REMOVE_DELAY); 73 69 74 - toastTimeouts.set(toastId, timeout) 75 - } 70 + toastTimeouts.set(toastId, timeout); 71 + }; 76 72 77 73 export const reducer = (state: State, action: Action): State => { 78 74 switch (action.type) { ··· 80 76 return { 81 77 ...state, 82 78 toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 - } 79 + }; 84 80 85 81 case "UPDATE_TOAST": 86 82 return { 87 83 ...state, 88 84 toasts: state.toasts.map((t) => 89 - t.id === action.toast.id ? { ...t, ...action.toast } : t 85 + t.id === action.toast.id ? { ...t, ...action.toast } : t, 90 86 ), 91 - } 87 + }; 92 88 93 89 case "DISMISS_TOAST": { 94 - const { toastId } = action 90 + const { toastId } = action; 95 91 96 - // ! Side effects ! - This could be extracted into a dismissToast() action, 97 - // but I'll keep it here for simplicity 98 92 if (toastId) { 99 - addToRemoveQueue(toastId) 93 + addToRemoveQueue(toastId); 100 94 } else { 101 95 state.toasts.forEach((toast) => { 102 - addToRemoveQueue(toast.id) 103 - }) 96 + addToRemoveQueue(toast.id); 97 + }); 104 98 } 105 99 106 100 return { ··· 111 105 ...t, 112 106 open: false, 113 107 } 114 - : t 108 + : t, 115 109 ), 116 - } 110 + }; 117 111 } 118 112 case "REMOVE_TOAST": 119 113 if (action.toastId === undefined) { 120 114 return { 121 115 ...state, 122 116 toasts: [], 123 - } 117 + }; 124 118 } 125 119 return { 126 120 ...state, 127 121 toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 - } 122 + }; 129 123 } 130 - } 124 + }; 131 125 132 - const listeners: Array<(state: State) => void> = [] 126 + const listeners: Array<(state: State) => void> = []; 133 127 134 - let memoryState: State = { toasts: [] } 128 + let memoryState: State = { toasts: [] }; 135 129 136 130 function dispatch(action: Action) { 137 - memoryState = reducer(memoryState, action) 131 + memoryState = reducer(memoryState, action); 138 132 listeners.forEach((listener) => { 139 - listener(memoryState) 140 - }) 133 + listener(memoryState); 134 + }); 141 135 } 142 136 143 - type Toast = Omit<ToasterToast, "id"> 137 + type Toast = Omit<ToasterToast, "id">; 144 138 145 139 function toast({ ...props }: Toast) { 146 - const id = genId() 140 + const id = genId(); 147 141 148 142 const update = (props: ToasterToast) => 149 143 dispatch({ 150 144 type: "UPDATE_TOAST", 151 145 toast: { ...props, id }, 152 - }) 153 - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 146 + }); 147 + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 154 148 155 149 dispatch({ 156 150 type: "ADD_TOAST", ··· 159 153 id, 160 154 open: true, 161 155 onOpenChange: (open) => { 162 - if (!open) dismiss() 156 + if (!open) dismiss(); 163 157 }, 164 158 }, 165 - }) 159 + }); 166 160 167 161 return { 168 162 id: id, 169 163 dismiss, 170 164 update, 171 - } 165 + }; 172 166 } 173 167 174 168 function useToast() { 175 - const [state, setState] = React.useState<State>(memoryState) 169 + const [state, setState] = React.useState<State>(memoryState); 176 170 177 171 React.useEffect(() => { 178 - listeners.push(setState) 172 + listeners.push(setState); 179 173 return () => { 180 - const index = listeners.indexOf(setState) 174 + const index = listeners.indexOf(setState); 181 175 if (index > -1) { 182 - listeners.splice(index, 1) 176 + listeners.splice(index, 1); 183 177 } 184 - } 185 - }, [state]) 178 + }; 179 + }, [state]); 186 180 187 181 return { 188 182 ...state, 189 183 toast, 190 184 dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 - } 185 + }; 192 186 } 193 187 194 - export { useToast, toast } 188 + export { useToast, toast };
+88 -72
lib/i18n.ts
··· 1 - "use client" 1 + "use client"; 2 2 3 - import { useEffect, useState } from "react" 3 + import { useEffect, useState } from "react"; 4 4 import { 5 5 getEncryptionState, 6 6 readEncryptedLocalStorage, 7 7 subscribeEncryptionState, 8 8 writeEncryptedLocalStorage, 9 - } from "@/hooks/useLocalStorage" 10 - import { translations as localeTranslations, type Language } from "@/lib/locales" 9 + } from "@/hooks/useLocalStorage"; 10 + import { 11 + translations as localeTranslations, 12 + type Language, 13 + } from "@/lib/locales"; 11 14 12 - const LANGUAGE_STORAGE_KEY = "preferred-language" 15 + const LANGUAGE_STORAGE_KEY = "preferred-language"; 13 16 14 - export const supportedLanguages = Object.keys(localeTranslations) as Language[] 17 + export const supportedLanguages = Object.keys(localeTranslations) as Language[]; 15 18 16 - const baseLanguage = "en" as const 19 + const baseLanguage = "en" as const; 17 20 18 21 export const translations = Object.fromEntries( 19 22 supportedLanguages.map((lang) => [ ··· 23 26 ...localeTranslations[lang], 24 27 }, 25 28 ]), 26 - ) as typeof localeTranslations 29 + ) as typeof localeTranslations; 27 30 28 31 const LANGUAGE_AUTONYM: Partial<Record<Language, string>> = { 29 32 en: "English", ··· 60 63 mk: "ะœะฐะบะตะดะพะฝัะบะธ", 61 64 sr: "ะกั€ะฟัะบะธ", 62 65 }; 63 - 64 66 65 67 const byExactLowercase = new Map( 66 68 supportedLanguages.map((lang) => [lang.toLowerCase(), lang] as const), 67 - ) 69 + ); 68 70 69 71 const byBaseLowercase = new Map( 70 - supportedLanguages.map((lang) => [lang.toLowerCase().split("-")[0], lang] as const), 71 - ) 72 + supportedLanguages.map( 73 + (lang) => [lang.toLowerCase().split("-")[0], lang] as const, 74 + ), 75 + ); 72 76 73 - const normalizeLanguage = (value: string | null | undefined): Language | null => { 74 - if (!value) return null 77 + const normalizeLanguage = ( 78 + value: string | null | undefined, 79 + ): Language | null => { 80 + if (!value) return null; 75 81 76 - const normalized = value.toLowerCase() 77 - const exact = byExactLowercase.get(normalized) 78 - if (exact) return exact 82 + const normalized = value.toLowerCase(); 83 + const exact = byExactLowercase.get(normalized); 84 + if (exact) return exact; 79 85 80 - const base = normalized.split("-")[0] 81 - return byBaseLowercase.get(base) ?? null 82 - } 86 + const base = normalized.split("-")[0]; 87 + return byBaseLowercase.get(base) ?? null; 88 + }; 83 89 84 90 export const getLanguageAutonym = (language: Language) => { 85 - const configured = LANGUAGE_AUTONYM[language] 86 - if (configured) return configured 91 + const configured = LANGUAGE_AUTONYM[language]; 92 + if (configured) return configured; 87 93 88 - return new Intl.DisplayNames([language], { type: "language" }).of(language) ?? language 89 - } 94 + return ( 95 + new Intl.DisplayNames([language], { type: "language" }).of(language) ?? 96 + language 97 + ); 98 + }; 90 99 91 - export const isZhLanguage = (language: Language) => language.startsWith("zh") 100 + export const isZhLanguage = (language: Language) => language.startsWith("zh"); 92 101 93 102 export const getStoredLanguage = async (): Promise<Language> => { 94 - const storedLanguage = await readEncryptedLocalStorage<string | null>(LANGUAGE_STORAGE_KEY, null) 95 - return normalizeLanguage(storedLanguage) ?? detectSystemLanguage() 96 - } 103 + const storedLanguage = await readEncryptedLocalStorage<string | null>( 104 + LANGUAGE_STORAGE_KEY, 105 + null, 106 + ); 107 + return normalizeLanguage(storedLanguage) ?? detectSystemLanguage(); 108 + }; 97 109 98 - // ๆฃ€ๆต‹็ณป็ปŸ่ฏญ่จ€ 99 110 function detectSystemLanguage(): Language { 100 111 if (typeof window === "undefined") { 101 - return "en" 112 + return "en"; 102 113 } 103 114 104 - // ่Žทๅ–ๆต่งˆๅ™จ่ฏญ่จ€ 105 - const browserLang = navigator.language 106 - return normalizeLanguage(browserLang) ?? "en" 115 + const browserLang = navigator.language; 116 + return normalizeLanguage(browserLang) ?? "en"; 107 117 } 108 118 109 119 export function useLanguage(): [Language, (lang: Language) => void] { 110 - const [language, setLanguageState] = useState<Language>("en") 120 + const [language, setLanguageState] = useState<Language>("en"); 111 121 112 122 useEffect(() => { 113 - // ๅˆๅง‹ๅŒ–ๆ—ถ่ฏปๅ–่ฏญ่จ€่ฎพ็ฝฎ 114 - let active = true 123 + let active = true; 115 124 const loadLanguage = () => 116 - readEncryptedLocalStorage<string | null>(LANGUAGE_STORAGE_KEY, null).then((storedLanguage) => { 117 - if (!active) return 118 - const normalized = normalizeLanguage(storedLanguage) ?? detectSystemLanguage() 119 - setLanguageState(normalized) 120 - if (storedLanguage && normalized !== storedLanguage) { 121 - void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, normalized) 122 - } 123 - }) 125 + readEncryptedLocalStorage<string | null>(LANGUAGE_STORAGE_KEY, null).then( 126 + (storedLanguage) => { 127 + if (!active) return; 128 + const normalized = 129 + normalizeLanguage(storedLanguage) ?? detectSystemLanguage(); 130 + setLanguageState(normalized); 131 + if (storedLanguage && normalized !== storedLanguage) { 132 + void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, normalized); 133 + } 134 + }, 135 + ); 124 136 125 - loadLanguage() 137 + loadLanguage(); 126 138 127 - // ๅˆ›ๅปบไธ€ไธชไบ‹ไปถ็›‘ๅฌๅ™จ๏ผŒๅฝ“localStorageๅ˜ๅŒ–ๆ—ถ่งฆๅ‘ 128 139 const handleStorageChange = (e: StorageEvent) => { 129 140 if (e.key === LANGUAGE_STORAGE_KEY) { 130 - readEncryptedLocalStorage<string | null>(LANGUAGE_STORAGE_KEY, null).then((newLanguage) => { 131 - const normalized = normalizeLanguage(newLanguage) 141 + readEncryptedLocalStorage<string | null>( 142 + LANGUAGE_STORAGE_KEY, 143 + null, 144 + ).then((newLanguage) => { 145 + const normalized = normalizeLanguage(newLanguage); 132 146 if (normalized) { 133 - setLanguageState(normalized) 147 + setLanguageState(normalized); 134 148 } 135 - }) 149 + }); 136 150 } 137 - } 151 + }; 138 152 139 153 const handleCustomLanguageChange = (event: Event) => { 140 - const customEvent = event as CustomEvent<{ language?: string }> 141 - const normalized = normalizeLanguage(customEvent.detail?.language) 154 + const customEvent = event as CustomEvent<{ language?: string }>; 155 + const normalized = normalizeLanguage(customEvent.detail?.language); 142 156 if (normalized) { 143 - setLanguageState(normalized) 157 + setLanguageState(normalized); 144 158 } 145 - } 159 + }; 146 160 147 161 const unsubscribe = subscribeEncryptionState(() => { 148 162 if (getEncryptionState().ready) { 149 - loadLanguage() 163 + loadLanguage(); 150 164 } 151 - }) 165 + }); 152 166 153 - window.addEventListener("storage", handleStorageChange) 154 - window.addEventListener("languagechange", handleCustomLanguageChange) 167 + window.addEventListener("storage", handleStorageChange); 168 + window.addEventListener("languagechange", handleCustomLanguageChange); 155 169 return () => { 156 - active = false 157 - unsubscribe() 158 - window.removeEventListener("storage", handleStorageChange) 159 - window.removeEventListener("languagechange", handleCustomLanguageChange) 160 - } 161 - }, []) 170 + active = false; 171 + unsubscribe(); 172 + window.removeEventListener("storage", handleStorageChange); 173 + window.removeEventListener("languagechange", handleCustomLanguageChange); 174 + }; 175 + }, []); 162 176 163 177 const setLanguage = (lang: Language) => { 164 - setLanguageState(lang) 165 - void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, lang) 166 - // ่งฆๅ‘ไธ€ไธช่‡ชๅฎšไน‰ไบ‹ไปถ๏ผŒ้€š็Ÿฅๅ…ถไป–็ป„ไปถ่ฏญ่จ€ๅทฒๆ›ดๆ”น 167 - window.dispatchEvent(new CustomEvent("languagechange", { detail: { language: lang } })) 168 - } 178 + setLanguageState(lang); 179 + void writeEncryptedLocalStorage(LANGUAGE_STORAGE_KEY, lang); 169 180 170 - return [language, setLanguage] 181 + window.dispatchEvent( 182 + new CustomEvent("languagechange", { detail: { language: lang } }), 183 + ); 184 + }; 185 + 186 + return [language, setLanguage]; 171 187 } 172 - export type { Language } 188 + export type { Language };
+50 -50
lib/icsUtils.ts
··· 1 - import { createEvents, type EventAttributes } from "ics" 1 + import { createEvents, type EventAttributes } from "ics"; 2 2 3 3 interface Event { 4 - id: string 5 - title: string 6 - date: Date 7 - description?: string 4 + id: string; 5 + title: string; 6 + date: Date; 7 + description?: string; 8 8 } 9 9 10 10 export function exportToICS(events: Event[]) { 11 11 const icsEvents: EventAttributes[] = events.map((event) => { 12 - // ่Žทๅ–ไบ‹ไปถ็š„ๆ—ฅๆœŸ 13 - const startDate = new Date(event.date) 12 + const startDate = new Date(event.date); 14 13 15 - // ่ฎก็ฎ—็ป“ๆŸๆ—ถ้—ด๏ผˆ้ป˜่ฎคไธบ1ๅฐๆ—ถๅŽ๏ผ‰ 16 - const endDate = new Date(startDate.getTime() + 60 * 60 * 1000) 14 + const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); 17 15 18 - // ่ฝฌๆขไธบUTCๆ—ถ้—ดๆ•ฐ็ป„ๆ ผๅผ [year, month, day, hour, minute] 19 16 const start = [ 20 17 startDate.getUTCFullYear(), 21 - startDate.getUTCMonth() + 1, // icsๅบ“้œ€่ฆ1-12็š„ๆœˆไปฝ 18 + startDate.getUTCMonth() + 1, 22 19 startDate.getUTCDate(), 23 20 startDate.getUTCHours(), 24 21 startDate.getUTCMinutes(), 25 - ] 22 + ]; 26 23 27 - // ่ฎก็ฎ—ๆŒ็ปญๆ—ถ้—ด๏ผˆๅฐๆ—ถๅ’Œๅˆ†้’Ÿ๏ผ‰ 28 - const durationHours = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60)) 29 - const durationMinutes = Math.floor(((endDate.getTime() - startDate.getTime()) % (1000 * 60 * 60)) / (1000 * 60)) 24 + const durationHours = Math.floor( 25 + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60), 26 + ); 27 + const durationMinutes = Math.floor( 28 + ((endDate.getTime() - startDate.getTime()) % (1000 * 60 * 60)) / 29 + (1000 * 60), 30 + ); 30 31 31 32 return { 32 33 title: event.title, 33 34 description: event.description, 34 35 start: start, 35 36 duration: { hours: durationHours, minutes: durationMinutes }, 36 - } 37 - }) 37 + }; 38 + }); 38 39 39 40 createEvents(icsEvents, (error, value) => { 40 41 if (error) { 41 - console.log(error) 42 - return 42 + console.log(error); 43 + return; 43 44 } 44 45 45 - const blob = new Blob([value], { type: "text/calendar;charset=utf-8" }) 46 - const link = document.createElement("a") 47 - link.href = window.URL.createObjectURL(blob) 48 - link.setAttribute("download", "calendar.ics") 49 - document.body.appendChild(link) 50 - link.click() 51 - document.body.removeChild(link) 52 - }) 46 + const blob = new Blob([value], { type: "text/calendar;charset=utf-8" }); 47 + const link = document.createElement("a"); 48 + link.href = window.URL.createObjectURL(blob); 49 + link.setAttribute("download", "calendar.ics"); 50 + document.body.appendChild(link); 51 + link.click(); 52 + document.body.removeChild(link); 53 + }); 53 54 } 54 55 55 56 export async function importFromICS(file: File): Promise<Event[]> { 56 - const text = await file.text() 57 - const lines = text.split("\n") 58 - const events: Event[] = [] 59 - let currentEvent: Partial<Event> = {} 57 + const text = await file.text(); 58 + const lines = text.split("\n"); 59 + const events: Event[] = []; 60 + let currentEvent: Partial<Event> = {}; 60 61 61 62 for (const line of lines) { 62 63 if (line.startsWith("BEGIN:VEVENT")) { 63 - currentEvent = {} 64 + currentEvent = {}; 64 65 } else if (line.startsWith("END:VEVENT")) { 65 66 if (currentEvent.title && currentEvent.date) { 66 - events.push(currentEvent as Event) 67 + events.push(currentEvent as Event); 67 68 } 68 - currentEvent = {} 69 + currentEvent = {}; 69 70 } else if (line.startsWith("SUMMARY:")) { 70 - currentEvent.title = line.slice(8) 71 + currentEvent.title = line.slice(8); 71 72 } else if (line.startsWith("DTSTART:")) { 72 - const dateString = line.slice(8) 73 - // ๅค„็†UTCๆ—ถ้—ดๆ ผๅผ (YYYYMMDDTHHMMSSZ) 73 + const dateString = line.slice(8); 74 + 74 75 if (dateString.endsWith("Z")) { 75 - const year = Number.parseInt(dateString.slice(0, 4), 10) 76 - const month = Number.parseInt(dateString.slice(4, 6), 10) - 1 // JavaScriptๆœˆไปฝไปŽ0ๅผ€ๅง‹ 77 - const day = Number.parseInt(dateString.slice(6, 8), 10) 78 - const hour = Number.parseInt(dateString.slice(9, 11), 10) 79 - const minute = Number.parseInt(dateString.slice(11, 13), 10) 80 - const second = Number.parseInt(dateString.slice(13, 15), 10) 76 + const year = Number.parseInt(dateString.slice(0, 4), 10); 77 + const month = Number.parseInt(dateString.slice(4, 6), 10) - 1; 78 + const day = Number.parseInt(dateString.slice(6, 8), 10); 79 + const hour = Number.parseInt(dateString.slice(9, 11), 10); 80 + const minute = Number.parseInt(dateString.slice(11, 13), 10); 81 + const second = Number.parseInt(dateString.slice(13, 15), 10); 81 82 82 - // ๅˆ›ๅปบUTCๆ—ฅๆœŸๅนถ่ฝฌๆขไธบๆœฌๅœฐๆ—ถ้—ด 83 - currentEvent.date = new Date(Date.UTC(year, month, day, hour, minute, second)) 83 + currentEvent.date = new Date( 84 + Date.UTC(year, month, day, hour, minute, second), 85 + ); 84 86 } else { 85 - // ๅค„็†ๆœฌๅœฐๆ—ถ้—ดๆ ผๅผ (YYYYMMDDTHHMMSS) 86 87 currentEvent.date = new Date( 87 88 Number.parseInt(dateString.slice(0, 4), 10), 88 89 Number.parseInt(dateString.slice(4, 6), 10) - 1, ··· 90 91 Number.parseInt(dateString.slice(9, 11), 10), 91 92 Number.parseInt(dateString.slice(11, 13), 10), 92 93 Number.parseInt(dateString.slice(13, 15), 10), 93 - ) 94 + ); 94 95 } 95 96 } else if (line.startsWith("DESCRIPTION:")) { 96 - currentEvent.description = line.slice(12) 97 + currentEvent.description = line.slice(12); 97 98 } 98 99 } 99 100 100 - return events 101 + return events; 101 102 } 102 -
+73 -72
lib/notifications.ts
··· 1 - import { toast } from "sonner" 2 - import { readEncryptedLocalStorage } from "@/hooks/useLocalStorage" 3 - import { getStoredLanguage, translations } from "@/lib/i18n" 1 + import { toast } from "sonner"; 2 + import { readEncryptedLocalStorage } from "@/hooks/useLocalStorage"; 3 + import { getStoredLanguage, translations } from "@/lib/i18n"; 4 4 5 5 let notificationInterval: NodeJS.Timeout | null = null; 6 - const firedNotifications = new Map<string, number>() 7 - const NOTIFICATION_GRACE_PERIOD_MS = 2 * 60 * 1000 8 - const NOTIFICATION_CLEANUP_WINDOW_MS = 24 * 60 * 60 * 1000 6 + const firedNotifications = new Map<string, number>(); 7 + const NOTIFICATION_GRACE_PERIOD_MS = 2 * 60 * 1000; 8 + const NOTIFICATION_CLEANUP_WINDOW_MS = 24 * 60 * 60 * 1000; 9 9 10 10 export type NOTIFICATION_SOUNDS = "telegram"; 11 11 ··· 13 13 telegram: "https://cdn.xyehr.cn/source/Voicy_Telegram_notification.mp3", 14 14 }; 15 15 16 - // ๆธ…้™คๆ‰€ๆœ‰็š„้€š็Ÿฅ่ฎกๆ—ถๅ™จ 17 16 export const clearAllNotificationTimers = () => { 18 17 if (notificationInterval) { 19 18 clearInterval(notificationInterval); ··· 21 20 } 22 21 }; 23 22 24 - // ๆฃ€ๆŸฅๅพ…ๅค„็†็š„้€š็Ÿฅ 25 - export const checkPendingNotifications = async (sound: NOTIFICATION_SOUNDS = "telegram") => { 26 - const now = Date.now() 27 - const pendingEvents = await getPendingEvents(now) 23 + export const checkPendingNotifications = async ( 24 + sound: NOTIFICATION_SOUNDS = "telegram", 25 + ) => { 26 + const now = Date.now(); 27 + const pendingEvents = await getPendingEvents(now); 28 28 29 29 await Promise.all( 30 30 pendingEvents.map(async (event) => { 31 - triggerNotification(event, sound) 32 - markNotificationFired(event, now) 33 - await showToast(event) 31 + triggerNotification(event, sound); 32 + markNotificationFired(event, now); 33 + await showToast(event); 34 34 }), 35 - ) 35 + ); 36 36 }; 37 37 38 - // ่Žทๅ–ๅพ…ๅค„็†็š„ไบ‹ไปถ 39 38 const getPendingEvents = async (currentTime: number) => { 40 - cleanupFiredNotifications(currentTime) 41 - const events = await readEncryptedLocalStorage<any[]>("calendar-events", []) 39 + cleanupFiredNotifications(currentTime); 40 + const events = await readEncryptedLocalStorage<any[]>("calendar-events", []); 42 41 return events.filter((event: any) => { 43 - const notificationTime = getNotificationTime(event) 44 - if (!notificationTime) return false 45 - if (notificationTime > currentTime) return false 46 - if (notificationTime <= currentTime - NOTIFICATION_GRACE_PERIOD_MS) return false 47 - const key = getNotificationKey(event, notificationTime) 48 - return !firedNotifications.has(key) 49 - }) 42 + const notificationTime = getNotificationTime(event); 43 + if (!notificationTime) return false; 44 + if (notificationTime > currentTime) return false; 45 + if (notificationTime <= currentTime - NOTIFICATION_GRACE_PERIOD_MS) 46 + return false; 47 + const key = getNotificationKey(event, notificationTime); 48 + return !firedNotifications.has(key); 49 + }); 50 50 }; 51 51 52 - // ๆ’ญๆ”พ้€š็Ÿฅๅฃฐ้Ÿณ 53 - const triggerNotification = async (event: any, soundKey: NOTIFICATION_SOUNDS) => { 54 - const sound = notificationSounds[soundKey] ?? notificationSounds.telegram 55 - const audio = new Audio(sound) 56 - audio.play().catch(() => {}) 57 - await showSystemNotification(event) 52 + const triggerNotification = async ( 53 + event: any, 54 + soundKey: NOTIFICATION_SOUNDS, 55 + ) => { 56 + const sound = notificationSounds[soundKey] ?? notificationSounds.telegram; 57 + const audio = new Audio(sound); 58 + audio.play().catch(() => {}); 59 + await showSystemNotification(event); 58 60 }; 59 61 60 - // ๆ˜พ็คบ Toast ้€š็Ÿฅ 61 62 const showToast = async (event: any) => { 62 - const language = await getStoredLanguage() 63 - const t = translations[language] 63 + const language = await getStoredLanguage(); 64 + const t = translations[language]; 64 65 toast(`${event.title}`, { 65 66 description: event.description || t.noContent, 66 67 duration: 4000, ··· 68 69 }; 69 70 70 71 const showSystemNotification = async (event: any) => { 71 - if (typeof window === "undefined") return 72 - if (!("Notification" in window)) return 72 + if (typeof window === "undefined") return; 73 + if (!("Notification" in window)) return; 73 74 74 75 if (Notification.permission === "default") { 75 76 try { 76 - await Notification.requestPermission() 77 + await Notification.requestPermission(); 77 78 } catch { 78 - return 79 + return; 79 80 } 80 81 } 81 82 82 - if (Notification.permission !== "granted") return 83 + if (Notification.permission !== "granted") return; 83 84 84 - const language = await getStoredLanguage() 85 - const t = translations[language] 85 + const language = await getStoredLanguage(); 86 + const t = translations[language]; 86 87 87 - const title = event.title || "Calendar" 88 - const body = event.description || t.noContent 89 - const tag = event.id ? `event-${event.id}` : "calendar-event" 88 + const title = event.title || "Calendar"; 89 + const body = event.description || t.noContent; 90 + const tag = event.id ? `event-${event.id}` : "calendar-event"; 90 91 91 92 const options: NotificationOptions = { 92 93 body, 93 94 tag, 94 95 icon: "/favicon.ico", 95 96 badge: "/favicon.ico", 96 - } 97 + }; 97 98 98 99 if ("serviceWorker" in navigator) { 99 100 try { 100 - const registration = await navigator.serviceWorker.getRegistration() 101 + const registration = await navigator.serviceWorker.getRegistration(); 101 102 if (registration) { 102 - await registration.showNotification(title, options) 103 - return 103 + await registration.showNotification(title, options); 104 + return; 104 105 } 105 - } catch { 106 - // fall through to direct notification 107 - } 106 + } catch {} 108 107 } 109 108 110 109 try { 111 - new Notification(title, options) 110 + new Notification(title, options); 112 111 } catch { 113 - return 112 + return; 114 113 } 115 - } 114 + }; 116 115 117 116 const getNotificationTime = (event: any) => { 118 - if (!event?.startDate) return null 119 - const startTime = new Date(event.startDate).getTime() 120 - if (Number.isNaN(startTime)) return null 121 - const notificationMinutes = Number.isFinite(event.notification) ? event.notification : 0 122 - if (notificationMinutes < 0) return null 123 - return startTime - notificationMinutes * 60 * 1000 124 - } 117 + if (!event?.startDate) return null; 118 + const startTime = new Date(event.startDate).getTime(); 119 + if (Number.isNaN(startTime)) return null; 120 + const notificationMinutes = Number.isFinite(event.notification) 121 + ? event.notification 122 + : 0; 123 + if (notificationMinutes < 0) return null; 124 + return startTime - notificationMinutes * 60 * 1000; 125 + }; 125 126 126 127 const getNotificationKey = (event: any, notificationTime: number) => { 127 - const eventId = event?.id ?? "unknown" 128 - return `${eventId}-${notificationTime}` 129 - } 128 + const eventId = event?.id ?? "unknown"; 129 + return `${eventId}-${notificationTime}`; 130 + }; 130 131 131 132 const markNotificationFired = (event: any, currentTime: number) => { 132 - const notificationTime = getNotificationTime(event) 133 - if (!notificationTime) return 134 - const key = getNotificationKey(event, notificationTime) 135 - firedNotifications.set(key, currentTime) 136 - } 133 + const notificationTime = getNotificationTime(event); 134 + if (!notificationTime) return; 135 + const key = getNotificationKey(event, notificationTime); 136 + firedNotifications.set(key, currentTime); 137 + }; 137 138 138 139 const cleanupFiredNotifications = (currentTime: number) => { 139 140 firedNotifications.forEach((timestamp, key) => { 140 141 if (currentTime - timestamp > NOTIFICATION_CLEANUP_WINDOW_MS) { 141 - firedNotifications.delete(key) 142 + firedNotifications.delete(key); 142 143 } 143 - }) 144 - } 144 + }); 145 + }; 145 146 146 147 export const startNotificationChecking = () => { 147 148 if (!notificationInterval) {
+66 -102
lib/time-analytics.ts
··· 1 1 export interface TimeCategory { 2 - id: string 3 - name: string 4 - color: string 5 - keywords: string[] 2 + id: string; 3 + name: string; 4 + color: string; 5 + keywords: string[]; 6 6 } 7 7 8 8 export interface TimeAnalytics { 9 - totalEvents: number 10 - totalHours: number 11 - categorizedHours: Record<string, number> 12 - mostProductiveDay: string 13 - mostProductiveHour: number 14 - averageEventDuration: number 15 - activeDays: number 16 - busiestCategoryId: string 9 + totalEvents: number; 10 + totalHours: number; 11 + categorizedHours: Record<string, number>; 12 + mostProductiveDay: string; 13 + mostProductiveHour: number; 14 + averageEventDuration: number; 15 + activeDays: number; 16 + busiestCategoryId: string; 17 17 longestEvent: { 18 - title: string 19 - duration: number 20 - } 18 + title: string; 19 + duration: number; 20 + }; 21 21 } 22 22 23 - export const defaultTimeCategories: TimeCategory[] = [ 24 - { 25 - id: "work", 26 - name: "ๅทฅไฝœ", 27 - color: "bg-blue-500", 28 - keywords: ["ไผš่ฎฎ", "ๅทฅไฝœ", "้กน็›ฎ", "่ฎจ่ฎบ", "meeting", "work", "project"], 29 - }, 30 - { 31 - id: "personal", 32 - name: "ไธชไบบ", 33 - color: "bg-green-500", 34 - keywords: ["ๅฅ่บซ", "้”ป็‚ผ", "ไผ‘ๆฏ", "ๅจฑไน", "personal", "gym", "workout", "rest"], 35 - }, 36 - { 37 - id: "learning", 38 - name: "ๅญฆไน ", 39 - color: "bg-purple-500", 40 - keywords: ["ๅญฆไน ", "่ฏพ็จ‹", "ๅŸน่ฎญ", "็ ”่ฎจไผš", "study", "course", "training"], 41 - }, 42 - { 43 - id: "social", 44 - name: "็คพไบค", 45 - color: "bg-yellow-500", 46 - keywords: ["่šไผš", "็บฆไผš", "ๆœ‹ๅ‹", "ๅฎถไบบ", "party", "date", "friends", "family"], 47 - }, 48 - { 49 - id: "health", 50 - name: "ๅฅๅบท", 51 - color: "bg-red-500", 52 - keywords: ["ๅŒป็”Ÿ", "ๅŒป้™ข", "ๆฃ€ๆŸฅ", "doctor", "hospital", "checkup", "health"], 53 - }, 54 - ] 55 - 56 - export function analyzeTimeUsage(events: any[], categories: TimeCategory[] = defaultTimeCategories): TimeAnalytics { 57 - // ๅˆๅง‹ๅŒ–็ป“ๆžœ 23 + export function analyzeTimeUsage( 24 + events: any[], 25 + categories: TimeCategory[] = [], 26 + ): TimeAnalytics { 58 27 const result: TimeAnalytics = { 59 28 totalEvents: events.length, 60 29 totalHours: 0, ··· 68 37 title: "", 69 38 duration: 0, 70 39 }, 71 - } 40 + }; 72 41 73 - // ๅˆๅง‹ๅŒ–ๅˆ†็ฑปๆ—ถ้—ด 74 42 categories.forEach((category) => { 75 - result.categorizedHours[category.id] = 0 76 - }) 77 - result.categorizedHours["uncategorized"] = 0 43 + result.categorizedHours[category.id] = 0; 44 + }); 45 + result.categorizedHours["uncategorized"] = 0; 78 46 79 - // ๆŒ‰ๅคฉๅ’Œๅฐๆ—ถ็ปŸ่ฎกไบ‹ไปถ 80 - const eventsByDay: Record<string, number> = {} 81 - const eventsByHour: Record<number, number> = {} 47 + const eventsByDay: Record<string, number> = {}; 48 + const eventsByHour: Record<number, number> = {}; 82 49 for (let i = 0; i < 24; i++) { 83 - eventsByHour[i] = 0 50 + eventsByHour[i] = 0; 84 51 } 85 52 86 - // ๅˆ†ๆžๆฏไธชไบ‹ไปถ 87 53 events.forEach((event) => { 88 - const startDate = new Date(event.startDate) 89 - const endDate = new Date(event.endDate) 90 - const durationHours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60) 54 + const startDate = new Date(event.startDate); 55 + const endDate = new Date(event.endDate); 56 + const durationHours = 57 + (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); 91 58 92 - // ๆ›ดๆ–ฐๆ€ปๆ—ถ้—ด 93 - result.totalHours += durationHours 59 + result.totalHours += durationHours; 94 60 95 - // ๆ›ดๆ–ฐๆœ€้•ฟไบ‹ไปถ 96 61 if (durationHours > result.longestEvent.duration) { 97 62 result.longestEvent = { 98 63 title: event.title, 99 64 duration: durationHours, 100 - } 65 + }; 101 66 } 102 67 103 - // ๆŒ‰ๅคฉ็ปŸ่ฎก 104 - const dayKey = startDate.toISOString().split("T")[0] 105 - eventsByDay[dayKey] = (eventsByDay[dayKey] || 0) + durationHours 68 + const dayKey = startDate.toISOString().split("T")[0]; 69 + eventsByDay[dayKey] = (eventsByDay[dayKey] || 0) + durationHours; 106 70 107 - // ๆŒ‰ๅฐๆ—ถ็ปŸ่ฎก 108 - const hour = startDate.getHours() 109 - eventsByHour[hour] = (eventsByHour[hour] || 0) + 1 71 + const hour = startDate.getHours(); 72 + eventsByHour[hour] = (eventsByHour[hour] || 0) + 1; 110 73 111 - // ้ฆ–ๅ…ˆๆฃ€ๆŸฅไบ‹ไปถๆ˜ฏๅฆๆœ‰calendarId๏ผŒๅฆ‚ๆžœๆœ‰๏ผŒ็›ดๆŽฅไฝฟ็”จ่ฏฅๅˆ†็ฑป 112 - if (event.calendarId && categories.some((cat) => cat.id === event.calendarId)) { 113 - result.categorizedHours[event.calendarId] += durationHours 114 - return // ๅทฒๅˆ†็ฑป๏ผŒ่ทณ่ฟ‡ๅ…ณ้”ฎ่ฏๅŒน้… 74 + if ( 75 + event.calendarId && 76 + categories.some((cat) => cat.id === event.calendarId) 77 + ) { 78 + result.categorizedHours[event.calendarId] += durationHours; 79 + return; 115 80 } 116 81 117 - // ๅฆ‚ๆžœๆฒกๆœ‰calendarIdๆˆ–calendarIdไธๅœจๅˆ†็ฑปๅˆ—่กจไธญ๏ผŒๅฐ่ฏ•้€š่ฟ‡ๅ…ณ้”ฎ่ฏๅŒน้… 118 - let categorized = false 82 + let categorized = false; 119 83 for (const category of categories) { 120 84 const matchesKeyword = category.keywords.some( 121 85 (keyword) => 122 86 event.title.toLowerCase().includes(keyword.toLowerCase()) || 123 - (event.description && event.description.toLowerCase().includes(keyword.toLowerCase())), 124 - ) 87 + (event.description && 88 + event.description.toLowerCase().includes(keyword.toLowerCase())), 89 + ); 125 90 126 91 if (matchesKeyword) { 127 - result.categorizedHours[category.id] += durationHours 128 - categorized = true 129 - break 92 + result.categorizedHours[category.id] += durationHours; 93 + categorized = true; 94 + break; 130 95 } 131 96 } 132 97 133 98 if (!categorized) { 134 - result.categorizedHours["uncategorized"] += durationHours 99 + result.categorizedHours["uncategorized"] += durationHours; 135 100 } 136 - }) 101 + }); 137 102 138 - // ๆ‰พๅ‡บๆœ€้ซ˜ๆ•ˆ็š„ๆ—ฅๅญ 139 - let maxHours = 0 103 + let maxHours = 0; 140 104 for (const [day, hours] of Object.entries(eventsByDay)) { 141 105 if (hours > maxHours) { 142 - maxHours = hours 143 - result.mostProductiveDay = day 106 + maxHours = hours; 107 + result.mostProductiveDay = day; 144 108 } 145 109 } 146 110 147 - // ๆ‰พๅ‡บๆœ€้ซ˜ๆ•ˆ็š„ๅฐๆ—ถ 148 - let maxEvents = 0 111 + let maxEvents = 0; 149 112 for (const [hour, count] of Object.entries(eventsByHour)) { 150 113 if (count > maxEvents) { 151 - maxEvents = count 152 - result.mostProductiveHour = Number.parseInt(hour) 114 + maxEvents = count; 115 + result.mostProductiveHour = Number.parseInt(hour); 153 116 } 154 117 } 155 118 156 - result.activeDays = Object.keys(eventsByDay).length 157 - result.averageEventDuration = result.totalEvents > 0 ? result.totalHours / result.totalEvents : 0 119 + result.activeDays = Object.keys(eventsByDay).length; 120 + result.averageEventDuration = 121 + result.totalEvents > 0 ? result.totalHours / result.totalEvents : 0; 158 122 159 - let busiestCategoryId = "uncategorized" 160 - let busiestHours = -1 123 + let busiestCategoryId = "uncategorized"; 124 + let busiestHours = -1; 161 125 for (const [categoryId, hours] of Object.entries(result.categorizedHours)) { 162 126 if (hours > busiestHours) { 163 - busiestHours = hours 164 - busiestCategoryId = categoryId 127 + busiestHours = hours; 128 + busiestCategoryId = categoryId; 165 129 } 166 130 } 167 - result.busiestCategoryId = busiestCategoryId 131 + result.busiestCategoryId = busiestCategoryId; 168 132 169 - return result 133 + return result; 170 134 }
+3 -1
locales/bn.json
··· 452 452 "shareLinkHelp": "เฆเฆ‡ เฆฒเฆฟเฆ™เงเฆ• เฆฅเฆพเฆ•เฆพ เฆฏเง‡ เฆ•เง‡เฆ‰ เฆถเง‡เฆฏเฆผเฆพเฆฐ เฆชเงƒเฆทเงเฆ เฆพ เฆ…เงเฆฏเฆพเฆ•เงเฆธเง‡เฆธ เฆ•เฆฐเฆคเง‡ เฆชเฆพเฆฐเฆฌเง‡เฅค เฆฏเฆฆเฆฟ เฆชเฆพเฆธเฆ“เฆฏเฆผเฆพเฆฐเงเฆก เฆธเงเฆฐเฆ•เงเฆทเฆฟเฆค เฆนเฆฏเฆผ, เฆคเฆฌเง‡ เฆฌเฆฟเฆทเฆฏเฆผเฆฌเฆธเงเฆคเง เฆฆเง‡เฆ–เฆคเง‡ เฆชเฆพเฆธเฆ“เฆฏเฆผเฆพเฆฐเงเฆก เฆชเงเฆฐเฆฌเง‡เฆถ เฆ•เฆฐเฆคเง‡ เฆนเฆฌเง‡เฅค", 453 453 "shareQrGenerateFailed": "เฆ•เฆฟเฆ‰เฆ†เฆฐ เฆ•เง‹เฆก เฆคเงˆเฆฐเฆฟ เฆ•เฆฐเฆคเง‡ เฆฌเงเฆฏเฆฐเงเฆฅ", 454 454 "done": "เฆธเฆฎเงเฆชเฆจเงเฆจ", 455 - "protected": "Protected" 455 + "protected": "Protected", 456 + "colorAmber": "เฆ…เงเฆฏเฆพเฆฎเงเฆฌเฆพเฆฐ", 457 + "colorTeal": "เฆŸเฆฟเฆฒ" 456 458 }
+3 -1
locales/de.json
··· 452 452 "shareLinkHelp": "Jeder mit diesem Link kann auf die Freigabeseite zugreifen. Wenn diese passwortgeschรผtzt ist, muss das Passwort eingegeben werden, um den Inhalt zu sehen.", 453 453 "shareQrGenerateFailed": "QR-Code konnte nicht generiert werden", 454 454 "done": "Fertig", 455 - "protected": "Geschรผtzt" 455 + "protected": "Geschรผtzt", 456 + "colorAmber": "Bernstein", 457 + "colorTeal": "Tรผrkis" 456 458 }
+3 -1
locales/el.json
··· 452 452 "shareLinkHelp": "ฮŸฯ€ฮฟฮนฮฟฯƒฮดฮฎฯ€ฮฟฯ„ฮต ฮญฯ‡ฮตฮน ฮฑฯ…ฯ„ฯŒฮฝ ฯ„ฮฟฮฝ ฯƒฯฮฝฮดฮตฯƒฮผฮฟ ฮผฯ€ฮฟฯฮตฮฏ ฮฝฮฑ ฮญฯ‡ฮตฮน ฯ€ฯฯŒฯƒฮฒฮฑฯƒฮท ฯƒฯ„ฮท ฯƒฮตฮปฮฏฮดฮฑ ฮบฮฟฮนฮฝฮฟฯ€ฮฟฮฏฮทฯƒฮทฯ‚. ฮ‘ฮฝ ฮตฮฏฮฝฮฑฮน ฯ€ฯฮฟฯƒฯ„ฮฑฯ„ฮตฯ…ฮผฮญฮฝฮฟ ฮผฮต ฮบฯ‰ฮดฮนฮบฯŒ ฯ€ฯฯŒฯƒฮฒฮฑฯƒฮทฯ‚, ฯ€ฯฮญฯ€ฮตฮน ฮฝฮฑ ฮตฮนฯƒฮฌฮณฮตฮน ฯ„ฮฟฮฝ ฮบฯ‰ฮดฮนฮบฯŒ ฮณฮนฮฑ ฮฝฮฑ ฮดฮตฮน ฯ„ฮฟ ฯ€ฮตฯฮนฮตฯ‡ฯŒฮผฮตฮฝฮฟ.", 453 453 "shareQrGenerateFailed": "ฮ‘ฯ€ฮฟฯ„ฯ…ฯ‡ฮฏฮฑ ฮดฮทฮผฮนฮฟฯ…ฯฮณฮฏฮฑฯ‚ ฮบฯ‰ฮดฮนฮบฮฟฯ QR", 454 454 "done": "ฮŸฮปฮฟฮบฮปฮทฯฯŽฮธฮทฮบฮต", 455 - "protected": "ฮ ฯฮฟฯƒฯ„ฮฑฯ„ฮตฯ…ฮผฮญฮฝฮฟ" 455 + "protected": "ฮ ฯฮฟฯƒฯ„ฮฑฯ„ฮตฯ…ฮผฮญฮฝฮฟ", 456 + "colorAmber": "ฮšฮตฯ‡ฯฮนฮผฯ€ฮฑฯฮฏ", 457 + "colorTeal": "ฮ ฮตฯ„ฯฯŒฮป" 456 458 }
+3 -1
locales/en-GB.json
··· 452 452 "shareLinkHelp": "Anyone with this link can access the share page. If password protected, they must enter the password to view the content.", 453 453 "shareQrGenerateFailed": "Failed to generate QR code", 454 454 "done": "Done", 455 - "protected": "Protected" 455 + "protected": "Protected", 456 + "colorAmber": "Amber", 457 + "colorTeal": "Teal" 456 458 }
+4 -4
locales/en.json
··· 168 168 "restoreDescription": "Restore your calendar data from a previous backup. This will replace your current data.", 169 169 "password": "Password", 170 170 "confirmPassword": "Confirm Password", 171 - "enterPassword": "Enter a password", 171 + "enterPassword": "Enter password", 172 172 "confirmYourPassword": "Confirm your password", 173 173 "passwordRequirements": "Password must be at least 8 characters and include uppercase, lowercase, number, and special character.", 174 174 "passwordRequirementsHint": "Password must be at least 8 characters and include uppercase, lowercase, number, and special character.", ··· 418 418 "shareManagementLoadFailed": "Failed to load shared events", 419 419 "shareDeleteFailed": "Delete Failed", 420 420 "sharedOn": "Shared on:", 421 - "deleteShare": "Delete Share", 422 421 "deleteShareConfirmDescription": "Are you sure you want to delete this share? This action cannot be undone and the share link will no longer be accessible.", 423 422 "enterSharePassword": "Enter Share Password", 424 423 "sharePasswordDescription": "This share is password protected. Enter the password to view it.", 425 - "enterPassword": "Enter password", 426 424 "decrypt": "Decrypt", 427 425 "decrypting": "Decrypting...", 428 426 "decrypted": "Decrypted", ··· 454 452 "shareLinkHelp": "Anyone with this link can access the share page. If password protected, they must enter the password to view the content.", 455 453 "shareQrGenerateFailed": "Failed to generate QR code", 456 454 "done": "Done", 457 - "protected": "Protected" 455 + "protected": "Protected", 456 + "colorAmber": "Amber", 457 + "colorTeal": "Teal" 458 458 }
+3 -1
locales/es.json
··· 452 452 "shareLinkHelp": "Cualquier persona con este enlace puede acceder a la pรกgina compartida. Si estรก protegida por contraseรฑa, deberรก introducirla para ver el contenido.", 453 453 "shareQrGenerateFailed": "Error al generar el cรณdigo QR", 454 454 "done": "Hecho", 455 - "protected": "Protegido" 455 + "protected": "Protegido", 456 + "colorAmber": "รmbar", 457 + "colorTeal": "Verde azulado" 456 458 }
+3 -1
locales/fi.json
··· 452 452 "shareLinkHelp": "Kuka tahansa, jolla on tรคmรค linkki, voi kรคyttรครค jakosivua. Jos salasana on kรคytรถssรค, heidรคn on syรถtettรคvรค salasana nรคhdรคkseen sisรคllรถn.", 453 453 "shareQrGenerateFailed": "QR-koodin luonti epรคonnistui", 454 454 "done": "Valmis", 455 - "protected": "Suojattu" 455 + "protected": "Suojattu", 456 + "colorAmber": "Meripihka", 457 + "colorTeal": "Sinivihreรค" 456 458 }
+3 -1
locales/fr.json
··· 452 452 "shareLinkHelp": "Toute personne disposant de ce lien peut accรฉder ร  la page de partage. S'il est protรฉgรฉ par mot de passe, elle devra le saisir pour voir le contenu.", 453 453 "shareQrGenerateFailed": "ร‰chec de la gรฉnรฉration du code QR", 454 454 "done": "Terminรฉ", 455 - "protected": "Protรฉgรฉ" 455 + "protected": "Protรฉgรฉ", 456 + "colorAmber": "Ambre", 457 + "colorTeal": "Turquoise" 456 458 }
+3 -1
locales/hi.json
··· 452 452 "shareLinkHelp": "เค‡เคธ เคฒเคฟเค‚เค• เคตเคพเคฒเฅ‡ เค•เฅ‹เคˆ เคญเฅ€ เคตเฅเคฏเค•เฅเคคเคฟ เคธเคพเคเคพ เคชเฅƒเคทเฅเค  เคคเค• เคชเคนเฅเค‚เคš เคธเค•เคคเคพ เคนเฅˆเฅค เคฏเคฆเคฟ เคชเคพเคธเคตเคฐเฅเคก เคธเฅเคฐเค•เฅเคทเคฟเคค เคนเฅˆ, เคคเฅ‹ เค‰เคจเฅเคนเฅ‡เค‚ เคธเคพเคฎเค—เฅเคฐเฅ€ เคฆเฅ‡เค–เคจเฅ‡ เค•เฅ‡ เคฒเคฟเค เคชเคพเคธเคตเคฐเฅเคก เคฆเคฐเฅเคœ เค•เคฐเคจเคพ เคนเฅ‹เค—เคพเฅค", 453 453 "shareQrGenerateFailed": "QR เค•เฅ‹เคก เค‰เคคเฅเคชเคจเฅเคจ เค•เคฐเคจเฅ‡ เคฎเฅ‡เค‚ เคตเคฟเคซเคฒ", 454 454 "done": "เคนเฅ‹ เค—เคฏเคพ", 455 - "protected": "เคธเฅเคฐเค•เฅเคทเคฟเคค" 455 + "protected": "เคธเฅเคฐเค•เฅเคทเคฟเคค", 456 + "colorAmber": "เค…เค‚เคฌเคฐ", 457 + "colorTeal": "เคŸเฅ€เคฒ" 456 458 }
+3 -1
locales/is.json
··· 452 452 "shareLinkHelp": "Allir sem hafa รพennan hlekk geta nรกlgast deilingarsรญรฐuna. Ef lykilorรฐsvernd er virk, verรฐa รพeir aรฐ slรก inn lykilorรฐiรฐ til aรฐ sjรก innihaldiรฐ.", 453 453 "shareQrGenerateFailed": "Tรณkst ekki aรฐ bรบa til QR-kรณรฐa", 454 454 "done": "Lokiรฐ", 455 - "protected": "Verndaรฐ" 455 + "protected": "Verndaรฐ", 456 + "colorAmber": "Rafgulur", 457 + "colorTeal": "Blรกgrรฆnn" 456 458 }
+3 -1
locales/it.json
··· 452 452 "shareLinkHelp": "Chiunque abbia questo link puรฒ accedere alla pagina di condivisione. Se protetto da password, devono inserire la password per visualizzare il contenuto.", 453 453 "shareQrGenerateFailed": "Generazione codice QR fallita", 454 454 "done": "Fatto", 455 - "protected": "Protetto" 455 + "protected": "Protetto", 456 + "colorAmber": "Ambra", 457 + "colorTeal": "Verde petrolio" 456 458 }
+3 -1
locales/ja.json
··· 452 452 "shareLinkHelp": "ใ“ใฎใƒชใƒณใ‚ฏใ‚’ๆŒใคไบบใฏ่ชฐใงใ‚‚ๅ…ฑๆœ‰ใƒšใƒผใ‚ธใซใ‚ขใ‚ฏใ‚ปใ‚นใงใใพใ™ใ€‚ใƒ‘ใ‚นใƒฏใƒผใƒ‰ไฟ่ญทใŒ่จญๅฎšใ•ใ‚Œใฆใ„ใ‚‹ๅ ดๅˆใฏใ€ใƒ‘ใ‚นใƒฏใƒผใƒ‰ใ‚’ๅ…ฅๅŠ›ใ—ใฆๅ†…ๅฎนใ‚’่กจ็คบใ™ใ‚‹ๅฟ…่ฆใŒใ‚ใ‚Šใพใ™ใ€‚", 453 453 "shareQrGenerateFailed": "QRใ‚ณใƒผใƒ‰ใฎ็”Ÿๆˆใซๅคฑๆ•—ใ—ใพใ—ใŸ", 454 454 "done": "ๅฎŒไบ†", 455 - "protected": "ไฟ่ญทๆธˆใฟ" 455 + "protected": "ไฟ่ญทๆธˆใฟ", 456 + "colorAmber": "็ฅ็€่‰ฒ", 457 + "colorTeal": "้’็ท‘" 456 458 }
+3 -1
locales/ko.json
··· 452 452 "shareLinkHelp": "์ด ๋งํฌ๋ฅผ ๊ฐ€์ง„ ๋ชจ๋“  ์‚ฌ๋žŒ์ด ๊ณต์œ  ํŽ˜์ด์ง€์— ์•ก์„ธ์Šคํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์•”ํ˜ธ๋กœ ๋ณดํ˜ธ๋œ ๊ฒฝ์šฐ ์ฝ˜ํ…์ธ ๋ฅผ ๋ณด๋ ค๋ฉด ์•”ํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", 453 453 "shareQrGenerateFailed": "QR ์ฝ”๋“œ ์ƒ์„ฑ ์‹คํŒจ", 454 454 "done": "์™„๋ฃŒ", 455 - "protected": "๋ณดํ˜ธ๋จ" 455 + "protected": "๋ณดํ˜ธ๋จ", 456 + "colorAmber": "ํ˜ธ๋ฐ•์ƒ‰", 457 + "colorTeal": "์ฒญ๋ก์ƒ‰" 456 458 }
+3 -1
locales/lt.json
··· 452 452 "shareLinkHelp": "Bet kas, turintis ลกiฤ… nuorodฤ…, galฤ—s pasiekti dalijimosi puslapฤฏ. Jei yra slaptaลพodลพio apsauga, reikฤ—s ฤฏvesti slaptaลพodฤฏ, kad perลพiลซrฤ—tลณ turinฤฏ.", 453 453 "shareQrGenerateFailed": "Nepavyko sugeneruoti QR kodo.", 454 454 "done": "Baigta", 455 - "protected": "Apsaugota" 455 + "protected": "Apsaugota", 456 + "colorAmber": "Gintarinฤ—", 457 + "colorTeal": "ลฝalsvai mฤ—lyna" 456 458 }
+3 -1
locales/lv.json
··· 452 452 "shareLinkHelp": "Ikviens, kam ir ลกฤซ saite, varฤ“s piekฤผลซt koplietoลกanas lapai. Ja tฤ ir aizsargฤta ar paroli, bลซs jฤievada parole, lai skatฤซtu saturu.", 453 453 "shareQrGenerateFailed": "Neizdevฤs ฤฃenerฤ“t QR kodu", 454 454 "done": "Pabeigts", 455 - "protected": "Aizsargฤts" 455 + "protected": "Aizsargฤts", 456 + "colorAmber": "Dzintara", 457 + "colorTeal": "Zilganzaฤผa" 456 458 }
+3 -1
locales/mk.json
··· 452 452 "shareLinkHelp": "ะกะตะบะพั˜ ัะพ ะพะฒะพั˜ ะปะธะฝะบ ะผะพะถะต ะดะฐ ะฟั€ะธัั‚ะฐะฟะธ ะดะพ ัั‚ั€ะฐะฝะธั†ะฐั‚ะฐ ะทะฐ ัะฟะพะดะตะปัƒะฒะฐัšะต. ะะบะพ ะต ะทะฐัˆั‚ะธั‚ะตะฝะพ ัะพ ะปะพะทะธะฝะบะฐ, ะผะพั€ะฐ ะดะฐ ั˜ะฐ ะฒะฝะตัะฐั‚ ะทะฐ ะดะฐ ะณะพ ะฒะธะดะฐั‚ ัะพะดั€ะถะธะฝะฐั‚ะฐ.", 453 453 "shareQrGenerateFailed": "ะะต ัƒัะฟะตะฐ ะณะตะฝะตั€ะธั€ะฐัšะตั‚ะพ ะฝะฐ QR ะบะพะดะพั‚", 454 454 "done": "ะ“ะพั‚ะพะฒะพ", 455 - "protected": "ะ—ะฐัˆั‚ะธั‚ะตะฝะพ" 455 + "protected": "ะ—ะฐัˆั‚ะธั‚ะตะฝะพ", 456 + "colorAmber": "ะšะธะปะธะฑะฐั€ะฝะฐ", 457 + "colorTeal": "ะขะธั€ะบะธะทะฝะฐ" 456 458 }
+3 -1
locales/nb.json
··· 452 452 "shareLinkHelp": "Alle med denne lenken kan fรฅ tilgang til delingssiden. Hvis passordbeskyttet, mรฅ de skrive inn passordet for รฅ se innholdet.", 453 453 "shareQrGenerateFailed": "Kunne ikke generere QR-kode", 454 454 "done": "Ferdig", 455 - "protected": "Beskyttet" 455 + "protected": "Beskyttet", 456 + "colorAmber": "Rav", 457 + "colorTeal": "Blรฅgrรธnn" 456 458 }
+3 -1
locales/nl.json
··· 452 452 "shareLinkHelp": "Iedereen met deze link kan de deelpagina openen. Als het beveiligd is met een wachtwoord, moeten ze het wachtwoord invoeren om de inhoud te bekijken.", 453 453 "shareQrGenerateFailed": "Genereren van QR-code mislukt", 454 454 "done": "Klaar", 455 - "protected": "Beveiligd" 455 + "protected": "Beveiligd", 456 + "colorAmber": "Amber", 457 + "colorTeal": "Blauwgroen" 456 458 }
+3 -1
locales/pl.json
··· 452 452 "shareLinkHelp": "Kaลผdy, kto ma ten link, moลผe uzyskaฤ‡ dostฤ™p do strony udostฤ™pnienia. Jeล›li jest chronione hasล‚em, muszฤ… wprowadziฤ‡ hasล‚o, aby zobaczyฤ‡ treล›ฤ‡.", 453 453 "shareQrGenerateFailed": "Nie udaล‚o siฤ™ wygenerowaฤ‡ kodu QR", 454 454 "done": "Gotowe", 455 - "protected": "Chronione" 455 + "protected": "Chronione", 456 + "colorAmber": "Bursztynowy", 457 + "colorTeal": "Turkusowy" 456 458 }
+3 -1
locales/pt.json
··· 452 452 "shareLinkHelp": "Qualquer pessoa com este link pode acessar a pรกgina de compartilhamento. Se protegido por senha, eles devem inserir a senha para visualizar o conteรบdo.", 453 453 "shareQrGenerateFailed": "Falha ao gerar cรณdigo QR", 454 454 "done": "Concluรญdo", 455 - "protected": "Protegido" 455 + "protected": "Protegido", 456 + "colorAmber": "ร‚mbar", 457 + "colorTeal": "Verde-azulado" 456 458 }
+3 -1
locales/ro.json
··· 452 452 "shareLinkHelp": "Oricine are acest link poate accesa pagina de partajare. Dacฤƒ este protejatฤƒ cu parolฤƒ, trebuie sฤƒ introducฤƒ parola pentru a vedea conศ›inutul.", 453 453 "shareQrGenerateFailed": "Generarea codului QR a eศ™uat", 454 454 "done": "Gata", 455 - "protected": "Protejat" 455 + "protected": "Protejat", 456 + "colorAmber": "Chihlimbar", 457 + "colorTeal": "Turcoaz" 456 458 }
+3 -1
locales/ru.json
··· 452 452 "shareLinkHelp": "ะ›ัŽะฑะพะน, ัƒ ะบะพะณะพ ะตัั‚ัŒ ัั‚ะฐ ััั‹ะปะบะฐ, ะผะพะถะตั‚ ะฟะพะปัƒั‡ะธั‚ัŒ ะดะพัั‚ัƒะฟ ะบ ัั‚ั€ะฐะฝะธั†ะต ัะพะฒะผะตัั‚ะฝะพะณะพ ะธัะฟะพะปัŒะทะพะฒะฐะฝะธั. ะ•ัะปะธ ะทะฐั‰ะธั‰ะตะฝะพ ะฟะฐั€ะพะปะตะผ, ะธะผ ะฝัƒะถะฝะพ ะฒะฒะตัั‚ะธ ะฟะฐั€ะพะปัŒ, ั‡ั‚ะพะฑั‹ ะฟั€ะพัะผะพั‚ั€ะตั‚ัŒ ัะพะดะตั€ะถะธะผะพะต.", 453 453 "shareQrGenerateFailed": "ะะต ัƒะดะฐะปะพััŒ ัะณะตะฝะตั€ะธั€ะพะฒะฐั‚ัŒ QR-ะบะพะด", 454 454 "done": "ะ“ะพั‚ะพะฒะพ", 455 - "protected": "ะ—ะฐั‰ะธั‰ะตะฝะพ" 455 + "protected": "ะ—ะฐั‰ะธั‰ะตะฝะพ", 456 + "colorAmber": "ะฏะฝั‚ะฐั€ะฝั‹ะน", 457 + "colorTeal": "ะ‘ะธั€ัŽะทะพะฒั‹ะน" 456 458 }
+3 -1
locales/sl.json
··· 452 452 "shareLinkHelp": "Vsakdo s to povezavo lahko dostopa do strani za deljenje. ฤŒe je zaลกฤiteno z geslom, morajo vnesti geslo za ogled vsebine.", 453 453 "shareQrGenerateFailed": "Ustvarjanje QR kode ni uspelo", 454 454 "done": "Konฤano", 455 - "protected": "Zaลกฤiteno" 455 + "protected": "Zaลกฤiteno", 456 + "colorAmber": "Jantarna", 457 + "colorTeal": "Modrozelena" 456 458 }
+3 -1
locales/sq.json
··· 452 452 "shareLinkHelp": "ร‡dokush me kรซtรซ lidhje mund tรซ aksesojรซ faqen e ndarjes. Nรซse รซshtรซ e mbrojtur me fjalรซkalim, ata duhet ta fusin fjalรซkalimin pรซr tรซ parรซ pรซrmbajtjen.", 453 453 "shareQrGenerateFailed": "Gjenerimi i kodit QR dรซshtoi", 454 454 "done": "Pรซrfunduar", 455 - "protected": "I mbrojtur" 455 + "protected": "I mbrojtur", 456 + "colorAmber": "Qelibar", 457 + "colorTeal": "Blu-jeshile" 456 458 }
+3 -1
locales/sr.json
··· 452 452 "shareLinkHelp": "Svi sa ovim linkom mogu pristupiti stranici za deljenje. Ako je zaลกtiฤ‡eno lozinkom, moraju uneti lozinku da bi videli sadrลพaj.", 453 453 "shareQrGenerateFailed": "Generisanje QR koda nije uspelo", 454 454 "done": "Gotovo", 455 - "protected": "Zaลกtiฤ‡eno" 455 + "protected": "Zaลกtiฤ‡eno", 456 + "colorAmber": "ะ‹ะธะปะธะฑะฐั€", 457 + "colorTeal": "ะขะธั€ะบะธะทะฝะฐ" 456 458 }
+3 -1
locales/sv.json
··· 452 452 "shareLinkHelp": "Vem som helst med denna lรคnk kan komma รฅt delningssidan. Om lรถsenordsskyddad mรฅste de ange lรถsenordet fรถr att se innehรฅllet.", 453 453 "shareQrGenerateFailed": "Misslyckades med att generera QR-kod", 454 454 "done": "Klar", 455 - "protected": "Skyddad" 455 + "protected": "Skyddad", 456 + "colorAmber": "Bรคrnsten", 457 + "colorTeal": "Blรฅgrรถn" 456 458 }
+3 -1
locales/sw.json
··· 452 452 "shareLinkHelp": "Mtu yeyote aliye na kiungo hiki anaweza kufikia ukurasa wa kujumuisha. Ikiwa imekingwa kwa nenosiri, wanapaswa kuingiza nenosiri ili kuona maudhui.", 453 453 "shareQrGenerateFailed": "Imeshindwa kuunda msimbo wa QR", 454 454 "done": "Imekamilika", 455 - "protected": "Imekingwa" 455 + "protected": "Imekingwa", 456 + "colorAmber": "Rangi ya kaharabu", 457 + "colorTeal": "Samawati-kijani" 456 458 }
+3 -1
locales/th.json
··· 452 452 "shareLinkHelp": "เนƒเธ„เธฃเธเน‡เธ•เธฒเธกเธ—เธตเนˆเธกเธตเธฅเธดเธ‡เธเนŒเธ™เธตเน‰เธชเธฒเธกเธฒเธฃเธ–เน€เธ‚เน‰เธฒเธ–เธถเธ‡เธซเธ™เน‰เธฒเนเธŠเธฃเนŒเน„เธ”เน‰ เธซเธฒเธเธกเธตเธเธฒเธฃเธ›เน‰เธญเธ‡เธเธฑเธ™เธ”เน‰เธงเธขเธฃเธซเธฑเธชเธœเนˆเธฒเธ™ เธˆเธฐเธ•เน‰เธญเธ‡เธเธฃเธญเธเธฃเธซเธฑเธชเธœเนˆเธฒเธ™เน€เธžเธทเนˆเธญเธ”เธนเน€เธ™เธทเน‰เธญเธซเธฒ", 453 453 "shareQrGenerateFailed": "เน„เธกเนˆเธชเธฒเธกเธฒเธฃเธ–เธชเธฃเน‰เธฒเธ‡เธฃเธซเธฑเธช QR เน„เธ”เน‰", 454 454 "done": "เน€เธชเธฃเน‡เธˆเธชเธดเน‰เธ™", 455 - "protected": "เธกเธตเธเธฒเธฃเธ›เน‰เธญเธ‡เธเธฑเธ™" 455 + "protected": "เธกเธตเธเธฒเธฃเธ›เน‰เธญเธ‡เธเธฑเธ™", 456 + "colorAmber": "เธชเธตเธญเธณเธžเธฑเธ™", 457 + "colorTeal": "เธชเธตเน€เธ‚เธตเธขเธงเธ™เน‰เธณเธ—เธฐเน€เธฅ" 456 458 }
+3 -1
locales/tr.json
··· 452 452 "shareLinkHelp": "Bu baฤŸlantฤฑya sahip herkes paylaลŸฤฑm sayfasฤฑna eriลŸebilir. Parola korumalฤฑysa, iรงeriฤŸi gรถrmek iรงin parolayฤฑ girmeleri gerekir.", 453 453 "shareQrGenerateFailed": "QR kodu oluลŸturulamadฤฑ", 454 454 "done": "Tamam", 455 - "protected": "Korumalฤฑ" 455 + "protected": "Korumalฤฑ", 456 + "colorAmber": "Kehribar", 457 + "colorTeal": "CamgรถbeฤŸi" 456 458 }
+3 -1
locales/uk.json
··· 452 452 "shareLinkHelp": "ะ‘ัƒะดัŒ-ั…ั‚ะพ ะท ั†ะธะผ ะฟะพัะธะปะฐะฝะฝัะผ ะผะพะถะต ะพั‚ั€ะธะผะฐั‚ะธ ะดะพัั‚ัƒะฟ ะดะพ ัั‚ะพั€ั–ะฝะบะธ ัะฟั–ะปัŒะฝะพะณะพ ะดะพัั‚ัƒะฟัƒ. ะฏะบั‰ะพ ะทะฐั…ะธั‰ะตะฝะพ ะฟะฐั€ะพะปะตะผ, ะฟะพั‚ั€ั–ะฑะฝะพ ะฒะฒะตัั‚ะธ ะฟะฐั€ะพะปัŒ ะดะปั ะฟะตั€ะตะณะปัะดัƒ ะฒะผั–ัั‚ัƒ.", 453 453 "shareQrGenerateFailed": "ะะต ะฒะดะฐะปะพัั ะทะณะตะฝะตั€ัƒะฒะฐั‚ะธ QR-ะบะพะด", 454 454 "done": "ะ“ะพั‚ะพะฒะพ", 455 - "protected": "ะ—ะฐั…ะธั‰ะตะฝะพ" 455 + "protected": "ะ—ะฐั…ะธั‰ะตะฝะพ", 456 + "colorAmber": "ะ‘ัƒั€ัˆั‚ะธะฝะพะฒะธะน", 457 + "colorTeal": "ะ‘ั–ั€ัŽะทะพะฒะธะน" 456 458 }
+3 -1
locales/vi.json
··· 452 452 "shareLinkHelp": "Bแบฅt kแปณ ai cรณ liรชn kแบฟt nร y ฤ‘แปu cรณ thแปƒ truy cแบญp trang chia sแบป. Nแบฟu ฤ‘ฦฐแปฃc bแบฃo vแป‡ bแบฑng mแบญt khแบฉu, hแป phแบฃi nhแบญp mแบญt khแบฉu ฤ‘แปƒ xem nแป™i dung.", 453 453 "shareQrGenerateFailed": "Khรดng thแปƒ tแบกo mรฃ QR", 454 454 "done": "Hoร n tแบฅt", 455 - "protected": "ฤฦฐแปฃc bแบฃo vแป‡" 455 + "protected": "ฤฦฐแปฃc bแบฃo vแป‡", 456 + "colorAmber": "Hแป• phรกch", 457 + "colorTeal": "Xanh mรฒng kรฉt" 456 458 }
+3 -1
locales/yue.json
··· 452 452 "shareLinkHelp": "ๆ“ๆœ‰ๅ‘ขๅ€‹้€ฃ็ตๅ˜…ไปปไฝ•ไบบ้ƒฝๅฏไปฅ่จชๅ•ๅˆ†ไบซ้ ้ขใ€‚ๅฆ‚ๆžœๅทฒ่จญๅฎšๅฏ†็ขผไฟ่ญท,้œ€่ฆ่ผธๅ…ฅๅฏ†็ขผๅ…ˆๅฏไปฅ็‡ๅˆฐๅ…งๅฎนใ€‚", 453 453 "shareQrGenerateFailed": "ไบŒ็ถญ็ขผ็”Ÿๆˆๅคฑๆ•—", 454 454 "done": "ๅฎŒๆˆ", 455 - "protected": "ๅทฒไฟ่ญท" 455 + "protected": "ๅทฒไฟ่ญท", 456 + "colorAmber": "็ฅ็€่‰ฒ", 457 + "colorTeal": "่—็ถ ่‰ฒ" 456 458 }
+3 -1
locales/zh-CN.json
··· 452 452 "shareLinkHelp": "ๆ‹ฅๆœ‰ๆญค้“พๆŽฅ็š„ไปปไฝ•ไบบ้ƒฝๅฏไปฅ่ฎฟ้—ฎๅˆ†ไบซ้กต้ขใ€‚ๅฆ‚ๅทฒ่ฎพ็ฝฎๅฏ†็ ไฟๆŠค๏ผŒ้œ€่พ“ๅ…ฅๅฏ†็ ๆ‰่ƒฝๆŸฅ็œ‹ๅ†…ๅฎนใ€‚", 453 453 "shareQrGenerateFailed": "ไบŒ็ปด็ ็”Ÿๆˆๅคฑ่ดฅ", 454 454 "done": "ๅฎŒๆˆ", 455 - "protected": "ๅทฒไฟๆŠค" 455 + "protected": "ๅทฒไฟๆŠค", 456 + "colorAmber": "็ฅ็€่‰ฒ", 457 + "colorTeal": "่“็ปฟ่‰ฒ" 456 458 }
+3 -1
locales/zh-HK.json
··· 452 452 "shareLinkHelp": "ไปปไฝ•ๆ“ๆœ‰ๆญค้€ฃ็ต็š„ไบบ้ƒฝๅฏไปฅๅญ˜ๅ–ๅˆ†ไบซ้ ้ขใ€‚่‹ฅๅ—ๅฏ†็ขผไฟ่ญท๏ผŒๅ‰‡ๅฟ…้ ˆ่ผธๅ…ฅๅฏ†็ขผๆ‰่ƒฝๆŸฅ็œ‹ๅ…งๅฎนใ€‚", 453 453 "shareQrGenerateFailed": "็”ข็”Ÿ QR Code ๅคฑๆ•—", 454 454 "done": "ๅฎŒๆˆ", 455 - "protected": "ๅ—ไฟ่ญท" 455 + "protected": "ๅ—ไฟ่ญท", 456 + "colorAmber": "็ฅ็€่‰ฒ", 457 + "colorTeal": "่—็ถ ่‰ฒ" 456 458 }
+3 -1
locales/zh-TW.json
··· 452 452 "shareLinkHelp": "ๆ“ๆœ‰ๆญค้€ฃ็ต็š„ไปปไฝ•ไบบ้ƒฝๅฏไปฅๅญ˜ๅ–ๅˆ†ไบซ้ ้ขใ€‚ๅฆ‚ๅทฒ่จญๅฎšๅฏ†็ขผไฟ่ญท๏ผŒ้œ€่ผธๅ…ฅๅฏ†็ขผๆ‰่ƒฝๆชข่ฆ–ๅ…งๅฎนใ€‚", 453 453 "shareQrGenerateFailed": "ไบŒ็ถญๆข็ขผ็”Ÿๆˆๅคฑๆ•—", 454 454 "done": "ๅฎŒๆˆ", 455 - "protected": "ๅทฒไฟ่ญท" 455 + "protected": "ๅทฒไฟ่ญท", 456 + "colorAmber": "็ฅ็€่‰ฒ", 457 + "colorTeal": "่—็ถ ่‰ฒ" 456 458 }
+24 -26
next.config.mjs
··· 1 - import { execSync } from "node:child_process" 2 - import { readFileSync } from "node:fs" 1 + import { execSync } from "node:child_process"; 2 + import { readFileSync } from "node:fs"; 3 3 4 - let userConfig = undefined 4 + let userConfig = undefined; 5 5 try { 6 - userConfig = await import("./next.config") 7 - } catch (e) { 6 + userConfig = await import("./next.config"); 7 + } catch (e) {} 8 8 9 - } 10 - 11 - const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")) 9 + const packageJson = JSON.parse( 10 + readFileSync(new URL("./package.json", import.meta.url), "utf8"), 11 + ); 12 12 13 13 const getGitCommit = () => { 14 14 try { 15 - return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim() 15 + return execSync("git rev-parse --short HEAD", { encoding: "utf8" }).trim(); 16 16 } catch { 17 - return "unknown" 17 + return "unknown"; 18 18 } 19 - } 19 + }; 20 20 21 - /** @type {import('next').NextConfig} */ 22 21 const nextConfig = { 23 22 typescript: { 24 23 ignoreBuildErrors: true, ··· 34 33 }, 35 34 36 35 experimental: { 37 - optimizePackageImports: ["lucide-react", "date-fns", "@radix-ui/react-icons", "recharts"], 36 + optimizePackageImports: [ 37 + "lucide-react", 38 + "date-fns", 39 + "@radix-ui/react-icons", 40 + "recharts", 41 + ], 38 42 }, 39 43 40 44 async headers() { ··· 57 61 }, 58 62 ], 59 63 }, 60 - ] 64 + ]; 61 65 }, 62 - /* experimental: { 63 - webpackBuildWorker: true, 64 - parallelServerBuildTraces: true, 65 - parallelServerCompiles: true, 66 - adjustFontFallbacks: true, 67 - },*/ 68 - } 66 + }; 69 67 70 - mergeConfig(nextConfig, userConfig) 68 + mergeConfig(nextConfig, userConfig); 71 69 72 70 function mergeConfig(nextConfig, userConfig) { 73 71 if (!userConfig) { 74 - return 72 + return; 75 73 } 76 74 77 75 for (const key in userConfig) { ··· 82 80 nextConfig[key] = { 83 81 ...nextConfig[key], 84 82 ...userConfig[key], 85 - } 83 + }; 86 84 } else { 87 - nextConfig[key] = userConfig[key] 85 + nextConfig[key] = userConfig[key]; 88 86 } 89 87 } 90 88 } 91 89 92 - export default nextConfig 90 + export default nextConfig;
-1
postcss.config.mjs
··· 1 - /** @type {import('postcss-load-config').Config} */ 2 1 const config = { 3 2 plugins: { 4 3 tailwindcss: {},