Spaces:
Sleeping
Sleeping
Added messaging appLets Chat
Browse files- app/api/gemini/chat/route.ts +2 -2
- app/api/messages/route.ts +125 -0
- app/components/Calendar.tsx +274 -332
- app/components/Clock.tsx +310 -147
- app/components/Desktop.tsx +72 -13
- app/components/Dock.tsx +16 -4
- app/components/DraggableDesktopIcon.tsx +10 -1
- app/components/Messages.tsx +288 -0
- app/components/Window.tsx +1 -1
- app/data/messages.json +37 -0
app/api/gemini/chat/route.ts
CHANGED
|
@@ -45,7 +45,7 @@ export async function POST(request: NextRequest) {
|
|
| 45 |
// Process each part to separate thoughts from actual response
|
| 46 |
let text = ''
|
| 47 |
let thought = ''
|
| 48 |
-
|
| 49 |
if (chunk.candidates?.[0]?.content?.parts) {
|
| 50 |
for (const part of chunk.candidates[0].content.parts) {
|
| 51 |
if (!part.text) {
|
|
@@ -76,7 +76,7 @@ export async function POST(request: NextRequest) {
|
|
| 76 |
|
| 77 |
return new NextResponse(stream, {
|
| 78 |
headers: {
|
| 79 |
-
'Content-Type': 'text/event-stream',
|
| 80 |
'Cache-Control': 'no-cache',
|
| 81 |
'Connection': 'keep-alive',
|
| 82 |
},
|
|
|
|
| 45 |
// Process each part to separate thoughts from actual response
|
| 46 |
let text = ''
|
| 47 |
let thought = ''
|
| 48 |
+
|
| 49 |
if (chunk.candidates?.[0]?.content?.parts) {
|
| 50 |
for (const part of chunk.candidates[0].content.parts) {
|
| 51 |
if (!part.text) {
|
|
|
|
| 76 |
|
| 77 |
return new NextResponse(stream, {
|
| 78 |
headers: {
|
| 79 |
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
| 80 |
'Cache-Control': 'no-cache',
|
| 81 |
'Connection': 'keep-alive',
|
| 82 |
},
|
app/api/messages/route.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextResponse } from 'next/server'
|
| 2 |
+
import fs from 'fs'
|
| 3 |
+
import path from 'path'
|
| 4 |
+
|
| 5 |
+
const dataFilePath = path.join(process.cwd(), 'app/data/messages.json')
|
| 6 |
+
|
| 7 |
+
// Simple bad word list (expand as needed)
|
| 8 |
+
const BAD_WORDS = [
|
| 9 |
+
// English
|
| 10 |
+
'badword', 'spam', 'toxic', 'hate', 'violence', 'kill', 'stupid', 'idiot',
|
| 11 |
+
'fuck', 'fuxk', 'fck', 'shit', 'sh!t', 'bitch', 'asshole', 'damn', 'hell',
|
| 12 |
+
'crap', 'piss', 'dick', 'cock', 'pussy', 'bastard', 'slut', 'whore',
|
| 13 |
+
// Spanish
|
| 14 |
+
'puto', 'mierda', 'coño', 'cabron', 'pendejo', 'joder',
|
| 15 |
+
// French
|
| 16 |
+
'merde', 'putain', 'connard', 'salope', 'encule',
|
| 17 |
+
// German
|
| 18 |
+
'scheisse', 'arschloch', 'schlampe', 'fotze',
|
| 19 |
+
// Italian
|
| 20 |
+
'cazzo', 'merda', 'vaffanculo', 'stronzo',
|
| 21 |
+
// Portuguese
|
| 22 |
+
'porra', 'caralho', 'merda', 'puta',
|
| 23 |
+
// Russian (transliterated)
|
| 24 |
+
'cyka', 'blyat', 'nahui', 'pizda',
|
| 25 |
+
// Hindi (transliterated)
|
| 26 |
+
'madarchod', 'bhenchod', 'chutiya', 'kutta', 'kamina'
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
interface Message {
|
| 30 |
+
id: string
|
| 31 |
+
text: string
|
| 32 |
+
sender: string
|
| 33 |
+
userId: string
|
| 34 |
+
timestamp: number
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const getMessages = (): Message[] => {
|
| 38 |
+
try {
|
| 39 |
+
if (!fs.existsSync(dataFilePath)) {
|
| 40 |
+
fs.writeFileSync(dataFilePath, '[]', 'utf8')
|
| 41 |
+
return []
|
| 42 |
+
}
|
| 43 |
+
const fileData = fs.readFileSync(dataFilePath, 'utf8')
|
| 44 |
+
return JSON.parse(fileData)
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error('Error reading messages:', error)
|
| 47 |
+
return []
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const saveMessages = (messages: Message[]) => {
|
| 52 |
+
try {
|
| 53 |
+
fs.writeFileSync(dataFilePath, JSON.stringify(messages, null, 2), 'utf8')
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error('Error saving messages:', error)
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export async function GET() {
|
| 60 |
+
const messages = getMessages()
|
| 61 |
+
return NextResponse.json(messages, {
|
| 62 |
+
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
| 63 |
+
})
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export async function POST(request: Request) {
|
| 67 |
+
try {
|
| 68 |
+
const body = await request.json()
|
| 69 |
+
const { text, sender, userId } = body
|
| 70 |
+
|
| 71 |
+
// 1. Validation: Length
|
| 72 |
+
if (!text || text.length > 200) {
|
| 73 |
+
return NextResponse.json({ error: 'Message too long (max 200 chars)' }, { status: 400 })
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if (!text.trim()) {
|
| 77 |
+
return NextResponse.json({ error: 'Message cannot be empty' }, { status: 400 })
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// 2. Validation: Toxicity
|
| 81 |
+
const lowerText = text.toLowerCase()
|
| 82 |
+
const containsBadWord = BAD_WORDS.some(word => lowerText.includes(word))
|
| 83 |
+
if (containsBadWord) {
|
| 84 |
+
return NextResponse.json({ error: 'Message contains inappropriate content' }, { status: 400 })
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
const messages = getMessages()
|
| 88 |
+
const now = Date.now()
|
| 89 |
+
|
| 90 |
+
// 3. Validation: Spam (Rate Limiting)
|
| 91 |
+
// Check if this user sent a message in the last 2 seconds
|
| 92 |
+
const lastMessageFromUser = messages
|
| 93 |
+
.filter(m => m.userId === userId)
|
| 94 |
+
.sort((a, b) => b.timestamp - a.timestamp)[0]
|
| 95 |
+
|
| 96 |
+
if (lastMessageFromUser && (now - lastMessageFromUser.timestamp) < 2000) {
|
| 97 |
+
return NextResponse.json({ error: 'You are sending messages too fast. Please wait.' }, { status: 429 })
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
// Check for duplicate message from same user
|
| 101 |
+
if (lastMessageFromUser && lastMessageFromUser.text === text) {
|
| 102 |
+
return NextResponse.json({ error: 'Do not send duplicate messages.' }, { status: 400 })
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const newMessage: Message = {
|
| 106 |
+
id: Math.random().toString(36).substring(2, 15),
|
| 107 |
+
text: text.trim(),
|
| 108 |
+
sender: sender || 'Anonymous',
|
| 109 |
+
userId: userId,
|
| 110 |
+
timestamp: now
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Keep only last 100 messages to prevent file from growing too large
|
| 114 |
+
const updatedMessages = [...messages, newMessage].slice(-100)
|
| 115 |
+
|
| 116 |
+
saveMessages(updatedMessages)
|
| 117 |
+
|
| 118 |
+
return NextResponse.json(updatedMessages, {
|
| 119 |
+
headers: { 'Content-Type': 'application/json; charset=utf-8' }
|
| 120 |
+
})
|
| 121 |
+
} catch (error) {
|
| 122 |
+
console.error('Error processing message:', error)
|
| 123 |
+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
|
| 124 |
+
}
|
| 125 |
+
}
|
app/components/Calendar.tsx
CHANGED
|
@@ -6,9 +6,9 @@ import {
|
|
| 6 |
CaretRight,
|
| 7 |
Plus,
|
| 8 |
MagnifyingGlass,
|
| 9 |
-
List,
|
| 10 |
CalendarBlank,
|
| 11 |
-
|
|
|
|
| 12 |
} from '@phosphor-icons/react'
|
| 13 |
import { motion, AnimatePresence } from 'framer-motion'
|
| 14 |
import { useKV } from '../hooks/useKV'
|
|
@@ -32,24 +32,22 @@ type ViewMode = 'day' | 'week' | 'month' | 'year'
|
|
| 32 |
export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: CalendarProps) {
|
| 33 |
const [currentDate, setCurrentDate] = useState(new Date())
|
| 34 |
const [view, setView] = useState<ViewMode>('month')
|
| 35 |
-
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
| 36 |
const [customEvents, setCustomEvents] = useKV<CalendarEvent[]>('custom-calendar-events', [])
|
| 37 |
const [searchQuery, setSearchQuery] = useState('')
|
| 38 |
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
| 39 |
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
| 40 |
|
| 41 |
// Scroll to 8 AM on mount for day/week views
|
| 42 |
useEffect(() => {
|
| 43 |
if ((view === 'day' || view === 'week') && scrollContainerRef.current) {
|
| 44 |
-
// 8 AM is the 8th hour slot, assuming 60px height per hour
|
| 45 |
scrollContainerRef.current.scrollTop = 8 * 60
|
| 46 |
}
|
| 47 |
}, [view])
|
| 48 |
|
| 49 |
-
// Generate all events
|
| 50 |
const allEvents = useMemo(() => {
|
| 51 |
const year = currentDate.getFullYear()
|
| 52 |
-
// Get holidays for previous, current, and next year to handle boundary transitions
|
| 53 |
const years = [year - 1, year, year + 1]
|
| 54 |
let events: CalendarEvent[] = [...customEvents]
|
| 55 |
|
|
@@ -77,9 +75,8 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 77 |
const firstDay = new Date(year, month, 1)
|
| 78 |
const lastDay = new Date(year, month + 1, 0)
|
| 79 |
const daysInMonth = lastDay.getDate()
|
| 80 |
-
const startingDayOfWeek = firstDay.getDay()
|
| 81 |
|
| 82 |
-
// Previous month days to fill the grid
|
| 83 |
const prevMonthDays = []
|
| 84 |
const prevMonthLastDay = new Date(year, month, 0).getDate()
|
| 85 |
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
|
|
@@ -91,7 +88,6 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 91 |
})
|
| 92 |
}
|
| 93 |
|
| 94 |
-
// Current month days
|
| 95 |
const currentMonthDays = []
|
| 96 |
for (let i = 1; i <= daysInMonth; i++) {
|
| 97 |
currentMonthDays.push({
|
|
@@ -102,10 +98,9 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 102 |
})
|
| 103 |
}
|
| 104 |
|
| 105 |
-
// Next month days to complete the grid (6 rows * 7 cols = 42 cells usually)
|
| 106 |
const nextMonthDays = []
|
| 107 |
const totalDaysSoFar = prevMonthDays.length + currentMonthDays.length
|
| 108 |
-
const remainingCells = 42 - totalDaysSoFar
|
| 109 |
|
| 110 |
for (let i = 1; i <= remainingCells; i++) {
|
| 111 |
nextMonthDays.push({
|
|
@@ -134,37 +129,24 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 134 |
|
| 135 |
const handlePrev = () => {
|
| 136 |
const newDate = new Date(currentDate)
|
| 137 |
-
if (view === 'month')
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
} else if (view === 'week') {
|
| 142 |
-
newDate.setDate(newDate.getDate() - 7)
|
| 143 |
-
} else if (view === 'day') {
|
| 144 |
-
newDate.setDate(newDate.getDate() - 1)
|
| 145 |
-
}
|
| 146 |
setCurrentDate(newDate)
|
| 147 |
}
|
| 148 |
|
| 149 |
const handleNext = () => {
|
| 150 |
const newDate = new Date(currentDate)
|
| 151 |
-
if (view === 'month')
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
} else if (view === 'week') {
|
| 156 |
-
newDate.setDate(newDate.getDate() + 7)
|
| 157 |
-
} else if (view === 'day') {
|
| 158 |
-
newDate.setDate(newDate.getDate() + 1)
|
| 159 |
-
}
|
| 160 |
setCurrentDate(newDate)
|
| 161 |
}
|
| 162 |
|
| 163 |
-
const handleToday = () =>
|
| 164 |
-
setCurrentDate(new Date())
|
| 165 |
-
}
|
| 166 |
|
| 167 |
-
// Helper for Week View
|
| 168 |
const getWeekDays = () => {
|
| 169 |
const startOfWeek = new Date(currentDate)
|
| 170 |
const day = startOfWeek.getDay()
|
|
@@ -181,22 +163,15 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 181 |
}
|
| 182 |
|
| 183 |
const renderHeaderTitle = () => {
|
| 184 |
-
if (view === 'year') {
|
| 185 |
-
return <span className="font-bold">{currentDate.getFullYear()}</span>
|
| 186 |
-
}
|
| 187 |
if (view === 'day') {
|
| 188 |
return (
|
| 189 |
<div className="flex flex-col leading-tight">
|
| 190 |
-
<span className="font-
|
| 191 |
-
<span className="text-lg font-normal text-gray-400">{weekDays[currentDate.getDay()]}</span>
|
| 192 |
</div>
|
| 193 |
)
|
| 194 |
}
|
| 195 |
-
return (
|
| 196 |
-
<>
|
| 197 |
-
<span className="font-bold">{monthNames[currentDate.getMonth()]}</span> {currentDate.getFullYear()}
|
| 198 |
-
</>
|
| 199 |
-
)
|
| 200 |
}
|
| 201 |
|
| 202 |
return (
|
|
@@ -213,320 +188,287 @@ export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: C
|
|
| 213 |
height={750}
|
| 214 |
x={50}
|
| 215 |
y={50}
|
| 216 |
-
className="calendar-window"
|
|
|
|
|
|
|
| 217 |
>
|
| 218 |
-
<div className="flex
|
| 219 |
-
{/*
|
| 220 |
-
<
|
| 221 |
-
|
| 222 |
-
<div
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
>
|
| 228 |
-
<CalendarBlank size={18} />
|
| 229 |
-
</button>
|
| 230 |
-
</div>
|
| 231 |
-
<div className="h-6 w-px bg-[#444]" />
|
| 232 |
-
<button className="p-1.5 hover:bg-[#333] rounded-full text-gray-400 hover:text-white transition-colors">
|
| 233 |
-
<Plus size={18} weight="bold" />
|
| 234 |
-
</button>
|
| 235 |
-
</div>
|
| 236 |
-
|
| 237 |
-
<div className="flex items-center bg-[#111] rounded-lg p-1 border border-[#333]">
|
| 238 |
-
{(['day', 'week', 'month', 'year'] as ViewMode[]).map((v) => (
|
| 239 |
-
<button
|
| 240 |
-
key={v}
|
| 241 |
-
onClick={() => setView(v)}
|
| 242 |
-
className={`px-3 py-1 text-xs font-medium rounded-md capitalize transition-all ${view === v
|
| 243 |
-
? 'bg-[#333] text-white shadow-sm'
|
| 244 |
-
: 'text-gray-400 hover:text-gray-200'
|
| 245 |
-
}`}
|
| 246 |
-
>
|
| 247 |
-
{v}
|
| 248 |
-
</button>
|
| 249 |
-
))}
|
| 250 |
-
</div>
|
| 251 |
-
|
| 252 |
-
<div className="flex items-center gap-2">
|
| 253 |
-
<div className={`flex items-center bg-[#111] border border-[#333] rounded-md px-2 py-1 transition-all ${isSearchOpen ? 'w-48' : 'w-8 border-transparent bg-transparent'}`}>
|
| 254 |
-
<button onClick={() => setIsSearchOpen(!isSearchOpen)} className="text-gray-400 hover:text-white">
|
| 255 |
-
<MagnifyingGlass size={16} />
|
| 256 |
-
</button>
|
| 257 |
-
{isSearchOpen && (
|
| 258 |
-
<input
|
| 259 |
-
type="text"
|
| 260 |
-
placeholder="Search"
|
| 261 |
-
className="bg-transparent border-none outline-none text-xs text-white ml-2 w-full"
|
| 262 |
-
value={searchQuery}
|
| 263 |
-
onChange={(e) => setSearchQuery(e.target.value)}
|
| 264 |
-
autoFocus
|
| 265 |
-
/>
|
| 266 |
-
)}
|
| 267 |
-
</div>
|
| 268 |
-
</div>
|
| 269 |
-
</div>
|
| 270 |
-
|
| 271 |
-
{/* Header */}
|
| 272 |
-
<div className="h-20 flex items-center justify-between px-6 py-4 shrink-0">
|
| 273 |
-
<h1 className="text-3xl font-light tracking-tight flex items-baseline gap-2">
|
| 274 |
-
{renderHeaderTitle()}
|
| 275 |
-
</h1>
|
| 276 |
-
|
| 277 |
-
<div className="flex items-center gap-2">
|
| 278 |
-
<button
|
| 279 |
-
onClick={handlePrev}
|
| 280 |
-
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[#333] transition-colors"
|
| 281 |
>
|
| 282 |
-
<
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
{
|
| 313 |
-
{weekDays.map(d => (
|
| 314 |
-
<div key={d} className="text-[10px] text-gray-500 font-medium">{d.charAt(0)}</div>
|
| 315 |
-
))}
|
| 316 |
-
{/* Days */}
|
| 317 |
-
{days.map((d, i) => {
|
| 318 |
-
if (!d.isCurrentMonth) return <div key={i} />
|
| 319 |
-
const dateStr = `${d.year}-${String(d.month + 1).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`
|
| 320 |
-
const hasEvents = getEventsForDate(dateStr).length > 0
|
| 321 |
-
const isCurrentDay = isToday(d.day, d.month, d.year)
|
| 322 |
-
|
| 323 |
-
return (
|
| 324 |
-
<div
|
| 325 |
-
key={i}
|
| 326 |
-
className={`
|
| 327 |
-
text-xs w-6 h-6 flex items-center justify-center rounded-full mx-auto cursor-pointer hover:bg-[#333] relative
|
| 328 |
-
${isCurrentDay ? 'bg-red-500 text-white font-bold' : 'text-gray-300'}
|
| 329 |
-
`}
|
| 330 |
-
onClick={() => {
|
| 331 |
-
setCurrentDate(new Date(d.year, d.month, d.day))
|
| 332 |
-
setView('day')
|
| 333 |
-
}}
|
| 334 |
-
>
|
| 335 |
-
{d.day}
|
| 336 |
-
{hasEvents && !isCurrentDay && (
|
| 337 |
-
<div className="absolute bottom-0.5 w-0.5 h-0.5 bg-gray-400 rounded-full" />
|
| 338 |
-
)}
|
| 339 |
-
</div>
|
| 340 |
-
)
|
| 341 |
-
})}
|
| 342 |
</div>
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
</div>
|
| 347 |
-
</div>
|
| 348 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
|
|
|
|
|
|
| 359 |
</div>
|
| 360 |
|
| 361 |
-
<div className="flex-
|
| 362 |
-
{
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
onClick={() => {
|
| 375 |
-
setCurrentDate(new Date(dateObj.year, dateObj.month, dateObj.day))
|
| 376 |
-
setView('day')
|
| 377 |
-
}}
|
| 378 |
-
>
|
| 379 |
-
<div className="flex justify-end mb-1">
|
| 380 |
-
<span
|
| 381 |
-
className={`
|
| 382 |
-
text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full
|
| 383 |
-
${isCurrentDay
|
| 384 |
-
? 'bg-red-500 text-white shadow-lg shadow-red-900/50'
|
| 385 |
-
: dateObj.isCurrentMonth ? 'text-gray-300' : 'text-gray-600'}
|
| 386 |
-
`}
|
| 387 |
-
>
|
| 388 |
-
{dateObj.day}
|
| 389 |
-
</span>
|
| 390 |
-
</div>
|
| 391 |
-
|
| 392 |
-
<div className="space-y-1 overflow-y-auto max-h-[calc(100%-2rem)] custom-scrollbar">
|
| 393 |
-
{dayEvents.map((event, i) => (
|
| 394 |
-
<div
|
| 395 |
-
key={i}
|
| 396 |
-
className={`
|
| 397 |
-
text-[10px] px-1.5 py-0.5 rounded-sm truncate cursor-pointer hover:opacity-80
|
| 398 |
-
${event.color || 'bg-blue-500'} text-white font-medium shadow-sm
|
| 399 |
-
${event.id === 'reuben-bday' ? 'animate-pulse ring-1 ring-pink-300' : ''}
|
| 400 |
-
`}
|
| 401 |
-
title={event.title}
|
| 402 |
-
>
|
| 403 |
-
{event.title}
|
| 404 |
-
</div>
|
| 405 |
-
))}
|
| 406 |
-
</div>
|
| 407 |
-
</div>
|
| 408 |
-
)
|
| 409 |
-
})}
|
| 410 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
</div>
|
| 412 |
-
|
| 413 |
|
| 414 |
-
{/*
|
| 415 |
-
|
| 416 |
-
<
|
| 417 |
-
{
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
</div>
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
)
|
| 434 |
-
})}
|
| 435 |
-
</div>
|
| 436 |
|
| 437 |
-
|
| 438 |
-
<div className="flex border-b border-[#333] shrink-0 pl-14 pr-4 py-1 min-h-[40px]">
|
| 439 |
-
<div className="absolute left-2 text-[10px] text-gray-500 top-32">all-day</div>
|
| 440 |
-
{getWeekDays().map((d, i) => {
|
| 441 |
-
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
| 442 |
-
const dayEvents = getEventsForDate(dateStr)
|
| 443 |
-
return (
|
| 444 |
-
<div key={i} className="flex-1 px-1 border-l border-[#333] first:border-l-0 space-y-1">
|
| 445 |
-
{dayEvents.map((event, idx) => (
|
| 446 |
<div
|
| 447 |
-
key={
|
| 448 |
className={`
|
| 449 |
-
|
| 450 |
-
${
|
| 451 |
`}
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
| 453 |
>
|
| 454 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
</div>
|
| 456 |
-
)
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
|
| 462 |
-
{
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
{
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
</div>
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
</div>
|
| 484 |
-
</div>
|
| 485 |
-
</div>
|
| 486 |
-
)}
|
| 487 |
-
|
| 488 |
-
{/* DAY VIEW */}
|
| 489 |
-
{view === 'day' && (
|
| 490 |
-
<div className="flex-1 flex flex-col overflow-hidden">
|
| 491 |
-
{/* All Day Section */}
|
| 492 |
-
<div className="flex border-b border-[#333] shrink-0 pl-14 pr-4 py-2 min-h-[50px]">
|
| 493 |
-
<div className="w-14 shrink-0 text-[10px] text-gray-500 pt-1 pr-2 text-right">all-day</div>
|
| 494 |
-
<div className="flex-1 px-1 space-y-1 border-l border-[#333]">
|
| 495 |
-
{getEventsForDate(`${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}`).map((event, idx) => (
|
| 496 |
-
<div
|
| 497 |
-
key={idx}
|
| 498 |
-
className={`
|
| 499 |
-
text-xs px-2 py-1 rounded-sm truncate cursor-pointer hover:opacity-80
|
| 500 |
-
${event.color || 'bg-blue-500'} text-white font-medium
|
| 501 |
-
`}
|
| 502 |
-
title={event.title}
|
| 503 |
-
>
|
| 504 |
-
{event.title}
|
| 505 |
-
</div>
|
| 506 |
-
))}
|
| 507 |
-
</div>
|
| 508 |
-
</div>
|
| 509 |
|
| 510 |
-
{
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
{
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
</div>
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
</div>
|
| 523 |
</div>
|
| 524 |
-
|
| 525 |
-
</div>
|
| 526 |
-
|
| 527 |
-
</
|
| 528 |
-
|
| 529 |
-
|
| 530 |
</div>
|
| 531 |
</div>
|
| 532 |
</Window>
|
|
|
|
| 6 |
CaretRight,
|
| 7 |
Plus,
|
| 8 |
MagnifyingGlass,
|
|
|
|
| 9 |
CalendarBlank,
|
| 10 |
+
List,
|
| 11 |
+
Check
|
| 12 |
} from '@phosphor-icons/react'
|
| 13 |
import { motion, AnimatePresence } from 'framer-motion'
|
| 14 |
import { useKV } from '../hooks/useKV'
|
|
|
|
| 32 |
export function Calendar({ onClose, onMinimize, onMaximize, onFocus, zIndex }: CalendarProps) {
|
| 33 |
const [currentDate, setCurrentDate] = useState(new Date())
|
| 34 |
const [view, setView] = useState<ViewMode>('month')
|
|
|
|
| 35 |
const [customEvents, setCustomEvents] = useKV<CalendarEvent[]>('custom-calendar-events', [])
|
| 36 |
const [searchQuery, setSearchQuery] = useState('')
|
| 37 |
const [isSearchOpen, setIsSearchOpen] = useState(false)
|
| 38 |
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
| 39 |
+
const [sidebarOpen, setSidebarOpen] = useState(true)
|
| 40 |
|
| 41 |
// Scroll to 8 AM on mount for day/week views
|
| 42 |
useEffect(() => {
|
| 43 |
if ((view === 'day' || view === 'week') && scrollContainerRef.current) {
|
|
|
|
| 44 |
scrollContainerRef.current.scrollTop = 8 * 60
|
| 45 |
}
|
| 46 |
}, [view])
|
| 47 |
|
| 48 |
+
// Generate all events
|
| 49 |
const allEvents = useMemo(() => {
|
| 50 |
const year = currentDate.getFullYear()
|
|
|
|
| 51 |
const years = [year - 1, year, year + 1]
|
| 52 |
let events: CalendarEvent[] = [...customEvents]
|
| 53 |
|
|
|
|
| 75 |
const firstDay = new Date(year, month, 1)
|
| 76 |
const lastDay = new Date(year, month + 1, 0)
|
| 77 |
const daysInMonth = lastDay.getDate()
|
| 78 |
+
const startingDayOfWeek = firstDay.getDay()
|
| 79 |
|
|
|
|
| 80 |
const prevMonthDays = []
|
| 81 |
const prevMonthLastDay = new Date(year, month, 0).getDate()
|
| 82 |
for (let i = startingDayOfWeek - 1; i >= 0; i--) {
|
|
|
|
| 88 |
})
|
| 89 |
}
|
| 90 |
|
|
|
|
| 91 |
const currentMonthDays = []
|
| 92 |
for (let i = 1; i <= daysInMonth; i++) {
|
| 93 |
currentMonthDays.push({
|
|
|
|
| 98 |
})
|
| 99 |
}
|
| 100 |
|
|
|
|
| 101 |
const nextMonthDays = []
|
| 102 |
const totalDaysSoFar = prevMonthDays.length + currentMonthDays.length
|
| 103 |
+
const remainingCells = 42 - totalDaysSoFar
|
| 104 |
|
| 105 |
for (let i = 1; i <= remainingCells; i++) {
|
| 106 |
nextMonthDays.push({
|
|
|
|
| 129 |
|
| 130 |
const handlePrev = () => {
|
| 131 |
const newDate = new Date(currentDate)
|
| 132 |
+
if (view === 'month') newDate.setMonth(newDate.getMonth() - 1)
|
| 133 |
+
else if (view === 'year') newDate.setFullYear(newDate.getFullYear() - 1)
|
| 134 |
+
else if (view === 'week') newDate.setDate(newDate.getDate() - 7)
|
| 135 |
+
else if (view === 'day') newDate.setDate(newDate.getDate() - 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
setCurrentDate(newDate)
|
| 137 |
}
|
| 138 |
|
| 139 |
const handleNext = () => {
|
| 140 |
const newDate = new Date(currentDate)
|
| 141 |
+
if (view === 'month') newDate.setMonth(newDate.getMonth() + 1)
|
| 142 |
+
else if (view === 'year') newDate.setFullYear(newDate.getFullYear() + 1)
|
| 143 |
+
else if (view === 'week') newDate.setDate(newDate.getDate() + 7)
|
| 144 |
+
else if (view === 'day') newDate.setDate(newDate.getDate() + 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
setCurrentDate(newDate)
|
| 146 |
}
|
| 147 |
|
| 148 |
+
const handleToday = () => setCurrentDate(new Date())
|
|
|
|
|
|
|
| 149 |
|
|
|
|
| 150 |
const getWeekDays = () => {
|
| 151 |
const startOfWeek = new Date(currentDate)
|
| 152 |
const day = startOfWeek.getDay()
|
|
|
|
| 163 |
}
|
| 164 |
|
| 165 |
const renderHeaderTitle = () => {
|
| 166 |
+
if (view === 'year') return <span className="font-semibold text-xl">{currentDate.getFullYear()}</span>
|
|
|
|
|
|
|
| 167 |
if (view === 'day') {
|
| 168 |
return (
|
| 169 |
<div className="flex flex-col leading-tight">
|
| 170 |
+
<span className="font-semibold text-xl">{currentDate.getDate()} {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}</span>
|
|
|
|
| 171 |
</div>
|
| 172 |
)
|
| 173 |
}
|
| 174 |
+
return <span className="font-semibold text-xl">{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
return (
|
|
|
|
| 188 |
height={750}
|
| 189 |
x={50}
|
| 190 |
y={50}
|
| 191 |
+
className="calendar-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden"
|
| 192 |
+
contentClassName="!bg-transparent"
|
| 193 |
+
headerClassName="!bg-transparent border-b border-white/5"
|
| 194 |
>
|
| 195 |
+
<div className="flex h-full text-white overflow-hidden">
|
| 196 |
+
{/* Sidebar */}
|
| 197 |
+
<AnimatePresence initial={false}>
|
| 198 |
+
{sidebarOpen && (
|
| 199 |
+
<motion.div
|
| 200 |
+
initial={{ width: 0, opacity: 0 }}
|
| 201 |
+
animate={{ width: 240, opacity: 1 }}
|
| 202 |
+
exit={{ width: 0, opacity: 0 }}
|
| 203 |
+
className="border-r border-white/10 bg-black/20 backdrop-blur-md flex flex-col"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
>
|
| 205 |
+
<div className="p-4">
|
| 206 |
+
<div className="grid grid-cols-7 gap-y-2 text-center mb-4">
|
| 207 |
+
{weekDays.map(d => (
|
| 208 |
+
<div key={d} className="text-[10px] text-gray-500 font-medium">{d.charAt(0)}</div>
|
| 209 |
+
))}
|
| 210 |
+
{calendarDays.map((d, i) => {
|
| 211 |
+
if (!d.isCurrentMonth) return <div key={i} className="text-gray-600 text-xs">{d.day}</div>
|
| 212 |
+
const isCurrentDay = isToday(d.day, d.month, d.year)
|
| 213 |
+
const isSelected = d.day === currentDate.getDate() && d.month === currentDate.getMonth()
|
| 214 |
+
|
| 215 |
+
return (
|
| 216 |
+
<div
|
| 217 |
+
key={i}
|
| 218 |
+
onClick={() => setCurrentDate(new Date(d.year, d.month, d.day))}
|
| 219 |
+
className={`
|
| 220 |
+
text-xs w-6 h-6 flex items-center justify-center rounded-full mx-auto cursor-pointer transition-colors
|
| 221 |
+
${isCurrentDay ? 'bg-red-500 text-white font-bold' : isSelected ? 'bg-white/20 text-white' : 'text-gray-300 hover:bg-white/10'}
|
| 222 |
+
`}
|
| 223 |
+
>
|
| 224 |
+
{d.day}
|
| 225 |
+
</div>
|
| 226 |
+
)
|
| 227 |
+
})}
|
| 228 |
+
</div>
|
| 229 |
|
| 230 |
+
<div className="mt-8 space-y-4">
|
| 231 |
+
<div className="flex items-center justify-between text-xs text-gray-400 font-medium px-2">
|
| 232 |
+
<span>CALENDARS</span>
|
| 233 |
+
</div>
|
| 234 |
+
<div className="space-y-1">
|
| 235 |
+
{['Home', 'Work', 'Holidays', 'Birthdays'].map((cal, i) => (
|
| 236 |
+
<div key={cal} className="flex items-center gap-3 px-2 py-1.5 rounded-md hover:bg-white/5 cursor-pointer group">
|
| 237 |
+
<div className={`w-3 h-3 rounded-full border-2 ${i === 0 ? 'border-blue-500 bg-blue-500' :
|
| 238 |
+
i === 1 ? 'border-purple-500 bg-purple-500' :
|
| 239 |
+
i === 2 ? 'border-green-500 bg-green-500' :
|
| 240 |
+
'border-red-500 bg-red-500'
|
| 241 |
+
}`} />
|
| 242 |
+
<span className="text-sm text-gray-300">{cal}</span>
|
| 243 |
+
<Check size={12} className="ml-auto opacity-0 group-hover:opacity-100 text-gray-500" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
</div>
|
| 245 |
+
))}
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
</div>
|
| 249 |
+
</motion.div>
|
| 250 |
)}
|
| 251 |
+
</AnimatePresence>
|
| 252 |
+
|
| 253 |
+
{/* Main Content */}
|
| 254 |
+
<div className="flex-1 flex flex-col bg-transparent">
|
| 255 |
+
{/* Toolbar */}
|
| 256 |
+
<div className="h-14 flex items-center justify-between px-6 border-b border-white/10 bg-[#252525]/30 backdrop-blur-sm">
|
| 257 |
+
<div className="flex items-center gap-4">
|
| 258 |
+
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white">
|
| 259 |
+
<List size={20} />
|
| 260 |
+
</button>
|
| 261 |
+
<div className="text-2xl font-light tracking-tight">{renderHeaderTitle()}</div>
|
| 262 |
+
</div>
|
| 263 |
|
| 264 |
+
<div className="flex items-center gap-4">
|
| 265 |
+
<div className="flex items-center bg-black/20 rounded-lg p-1 border border-white/5">
|
| 266 |
+
<button onClick={handlePrev} className="p-1.5 hover:bg-white/10 rounded-md text-gray-400 hover:text-white transition-colors">
|
| 267 |
+
<CaretLeft size={16} />
|
| 268 |
+
</button>
|
| 269 |
+
<button onClick={handleToday} className="px-3 py-1 text-sm font-medium text-gray-300 hover:text-white transition-colors">
|
| 270 |
+
Today
|
| 271 |
+
</button>
|
| 272 |
+
<button onClick={handleNext} className="p-1.5 hover:bg-white/10 rounded-md text-gray-400 hover:text-white transition-colors">
|
| 273 |
+
<CaretRight size={16} />
|
| 274 |
+
</button>
|
| 275 |
</div>
|
| 276 |
|
| 277 |
+
<div className="flex items-center bg-black/20 rounded-lg p-1 border border-white/5">
|
| 278 |
+
{(['day', 'week', 'month', 'year'] as ViewMode[]).map((v) => (
|
| 279 |
+
<button
|
| 280 |
+
key={v}
|
| 281 |
+
onClick={() => setView(v)}
|
| 282 |
+
className={`px-3 py-1 text-xs font-medium rounded-md capitalize transition-all ${view === v
|
| 283 |
+
? 'bg-[#444] text-white shadow-sm'
|
| 284 |
+
: 'text-gray-400 hover:text-gray-200'
|
| 285 |
+
}`}
|
| 286 |
+
>
|
| 287 |
+
{v}
|
| 288 |
+
</button>
|
| 289 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
</div>
|
| 291 |
+
|
| 292 |
+
<button className="p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white">
|
| 293 |
+
<Plus size={20} />
|
| 294 |
+
</button>
|
| 295 |
+
<button className="p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400 hover:text-white">
|
| 296 |
+
<MagnifyingGlass size={20} />
|
| 297 |
+
</button>
|
| 298 |
</div>
|
| 299 |
+
</div>
|
| 300 |
|
| 301 |
+
{/* View Content */}
|
| 302 |
+
<div className="flex-1 overflow-hidden relative">
|
| 303 |
+
<AnimatePresence mode="wait">
|
| 304 |
+
{view === 'month' && (
|
| 305 |
+
<motion.div
|
| 306 |
+
key="month"
|
| 307 |
+
initial={{ opacity: 0, scale: 0.98 }}
|
| 308 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 309 |
+
exit={{ opacity: 0, scale: 1.02 }}
|
| 310 |
+
transition={{ duration: 0.2 }}
|
| 311 |
+
className="h-full flex flex-col"
|
| 312 |
+
>
|
| 313 |
+
<div className="grid grid-cols-7 border-b border-white/5 bg-black/10">
|
| 314 |
+
{weekDays.map(day => (
|
| 315 |
+
<div key={day} className="py-2 text-right pr-4 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
| 316 |
+
{day}
|
| 317 |
</div>
|
| 318 |
+
))}
|
| 319 |
+
</div>
|
| 320 |
+
<div className="flex-1 grid grid-cols-7 grid-rows-6">
|
| 321 |
+
{calendarDays.map((dateObj, index) => {
|
| 322 |
+
const dateStr = `${dateObj.year}-${String(dateObj.month + 1).padStart(2, '0')}-${String(dateObj.day).padStart(2, '0')}`
|
| 323 |
+
const dayEvents = getEventsForDate(dateStr)
|
| 324 |
+
const isCurrentDay = isToday(dateObj.day, dateObj.month, dateObj.year)
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
+
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
<div
|
| 328 |
+
key={index}
|
| 329 |
className={`
|
| 330 |
+
border-b border-r border-white/5 p-1 relative group transition-colors
|
| 331 |
+
${!dateObj.isCurrentMonth ? 'bg-black/20 text-gray-600' : 'hover:bg-white/5'}
|
| 332 |
`}
|
| 333 |
+
onClick={() => {
|
| 334 |
+
setCurrentDate(new Date(dateObj.year, dateObj.month, dateObj.day))
|
| 335 |
+
setView('day')
|
| 336 |
+
}}
|
| 337 |
>
|
| 338 |
+
<div className="flex justify-end mb-1">
|
| 339 |
+
<span
|
| 340 |
+
className={`
|
| 341 |
+
text-sm font-medium w-7 h-7 flex items-center justify-center rounded-full
|
| 342 |
+
${isCurrentDay
|
| 343 |
+
? 'bg-red-500 text-white shadow-lg shadow-red-900/50'
|
| 344 |
+
: dateObj.isCurrentMonth ? 'text-gray-300' : 'text-gray-600'}
|
| 345 |
+
`}
|
| 346 |
+
>
|
| 347 |
+
{dateObj.day}
|
| 348 |
+
</span>
|
| 349 |
+
</div>
|
| 350 |
+
<div className="space-y-1 overflow-y-auto max-h-[calc(100%-2rem)] [&::-webkit-scrollbar]:hidden">
|
| 351 |
+
{dayEvents.map((event, i) => (
|
| 352 |
+
<div
|
| 353 |
+
key={i}
|
| 354 |
+
className={`
|
| 355 |
+
text-[10px] px-1.5 py-0.5 rounded-sm truncate cursor-pointer hover:opacity-80
|
| 356 |
+
${event.color || 'bg-blue-500'} text-white font-medium shadow-sm backdrop-blur-sm bg-opacity-80
|
| 357 |
+
`}
|
| 358 |
+
>
|
| 359 |
+
{event.title}
|
| 360 |
+
</div>
|
| 361 |
+
))}
|
| 362 |
+
</div>
|
| 363 |
</div>
|
| 364 |
+
)
|
| 365 |
+
})}
|
| 366 |
+
</div>
|
| 367 |
+
</motion.div>
|
| 368 |
+
)}
|
| 369 |
|
| 370 |
+
{view === 'year' && (
|
| 371 |
+
<motion.div
|
| 372 |
+
key="year"
|
| 373 |
+
initial={{ opacity: 0 }}
|
| 374 |
+
animate={{ opacity: 1 }}
|
| 375 |
+
exit={{ opacity: 0 }}
|
| 376 |
+
className="h-full overflow-y-auto p-8 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2"
|
| 377 |
+
>
|
| 378 |
+
<div className="grid grid-cols-4 gap-x-8 gap-y-12">
|
| 379 |
+
{monthNames.map((month, monthIndex) => {
|
| 380 |
+
const days = getDaysInMonth(currentDate.getFullYear(), monthIndex)
|
| 381 |
+
return (
|
| 382 |
+
<div key={month} className="flex flex-col gap-4">
|
| 383 |
+
<h3 className="text-red-500 font-medium text-xl pl-1">{month}</h3>
|
| 384 |
+
<div className="grid grid-cols-7 gap-y-3 text-center">
|
| 385 |
+
{weekDays.map(d => (
|
| 386 |
+
<div key={d} className="text-[10px] text-gray-500 font-medium">{d.charAt(0)}</div>
|
| 387 |
+
))}
|
| 388 |
+
{days.map((d, i) => {
|
| 389 |
+
if (!d.isCurrentMonth) return <div key={i} />
|
| 390 |
+
const isCurrentDay = isToday(d.day, d.month, d.year)
|
| 391 |
+
return (
|
| 392 |
+
<div
|
| 393 |
+
key={i}
|
| 394 |
+
onClick={() => {
|
| 395 |
+
setCurrentDate(new Date(d.year, d.month, d.day))
|
| 396 |
+
setView('month')
|
| 397 |
+
}}
|
| 398 |
+
className={`
|
| 399 |
+
text-xs w-6 h-6 flex items-center justify-center rounded-full mx-auto cursor-pointer hover:bg-white/10
|
| 400 |
+
${isCurrentDay ? 'bg-red-500 text-white font-bold' : 'text-gray-300'}
|
| 401 |
+
`}
|
| 402 |
+
>
|
| 403 |
+
{d.day}
|
| 404 |
+
</div>
|
| 405 |
+
)
|
| 406 |
+
})}
|
| 407 |
</div>
|
| 408 |
+
</div>
|
| 409 |
+
)
|
| 410 |
+
})}
|
| 411 |
+
</div>
|
| 412 |
+
</motion.div>
|
| 413 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
|
| 415 |
+
{(view === 'week' || view === 'day') && (
|
| 416 |
+
<motion.div
|
| 417 |
+
key="week-day"
|
| 418 |
+
initial={{ opacity: 0 }}
|
| 419 |
+
animate={{ opacity: 1 }}
|
| 420 |
+
exit={{ opacity: 0 }}
|
| 421 |
+
className="h-full flex flex-col"
|
| 422 |
+
>
|
| 423 |
+
<div className="flex border-b border-white/10 shrink-0 pl-14 pr-4 py-2">
|
| 424 |
+
{view === 'week' ? getWeekDays().map((d, i) => {
|
| 425 |
+
const isCurrentDay = isToday(d.getDate(), d.getMonth(), d.getFullYear())
|
| 426 |
+
return (
|
| 427 |
+
<div key={i} className="flex-1 text-center border-l border-white/5 first:border-l-0">
|
| 428 |
+
<div className={`text-xs font-medium mb-1 uppercase ${isCurrentDay ? 'text-red-500' : 'text-gray-500'}`}>
|
| 429 |
+
{weekDays[d.getDay()]}
|
| 430 |
+
</div>
|
| 431 |
+
<div className={`
|
| 432 |
+
text-xl font-light w-8 h-8 flex items-center justify-center rounded-full mx-auto
|
| 433 |
+
${isCurrentDay ? 'bg-red-500 text-white font-bold' : 'text-white'}
|
| 434 |
+
`}>
|
| 435 |
+
{d.getDate()}
|
| 436 |
+
</div>
|
| 437 |
+
</div>
|
| 438 |
+
)
|
| 439 |
+
}) : (
|
| 440 |
+
<div className="flex-1 px-4">
|
| 441 |
+
<div className="text-red-500 font-medium uppercase text-sm">{weekDays[currentDate.getDay()]}</div>
|
| 442 |
+
<div className="text-3xl font-light">{currentDate.getDate()}</div>
|
| 443 |
</div>
|
| 444 |
+
)}
|
| 445 |
+
</div>
|
| 446 |
+
|
| 447 |
+
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto relative [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2">
|
| 448 |
+
<div className="absolute top-0 left-0 w-full min-h-full bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjYwIj48bGluZSB4MT0iMCIgeTE9IjYwIiB4Mj0iMTAwJSIgeTI9IjYwIiBzdHJva2U9InJnYmEoMjU1LDI1NSwyNTUsMC4wNSkiIHN0cm9rZS13aWR0aD0iMSIvPjwvc3ZnPg==')]">
|
| 449 |
+
{hours.map((hour) => (
|
| 450 |
+
<div key={hour} className="flex h-[60px] group">
|
| 451 |
+
<div className="w-14 shrink-0 text-right pr-3 text-xs text-gray-500 -mt-2.5 z-10">
|
| 452 |
+
{hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`}
|
| 453 |
+
</div>
|
| 454 |
+
<div className="flex-1 border-l border-white/5 relative group-hover:bg-white/[0.02] transition-colors">
|
| 455 |
+
{/* Time slots */}
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
))}
|
| 459 |
+
{/* Current Time Indicator */}
|
| 460 |
+
<div
|
| 461 |
+
className="absolute left-14 right-0 border-t border-red-500 z-20 pointer-events-none"
|
| 462 |
+
style={{ top: `${(new Date().getHours() * 60 + new Date().getMinutes())}px` }}
|
| 463 |
+
>
|
| 464 |
+
<div className="absolute -left-1.5 -top-1.5 w-3 h-3 rounded-full bg-red-500" />
|
| 465 |
</div>
|
| 466 |
</div>
|
| 467 |
+
</div>
|
| 468 |
+
</motion.div>
|
| 469 |
+
)}
|
| 470 |
+
</AnimatePresence>
|
| 471 |
+
</div>
|
|
|
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
</Window>
|
app/components/Clock.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import React, { useState, useEffect } from 'react'
|
|
|
|
| 4 |
import Window from './Window'
|
|
|
|
| 5 |
|
| 6 |
interface ClockProps {
|
| 7 |
onClose: () => void
|
|
@@ -11,183 +13,344 @@ interface ClockProps {
|
|
| 11 |
zIndex?: number
|
| 12 |
}
|
| 13 |
|
| 14 |
-
|
|
|
|
| 15 |
const [time, setTime] = useState(new Date())
|
| 16 |
-
const [viewMode, setViewMode] = useState<'analog' | 'digital' | 'world'>('analog')
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
-
|
|
|
|
| 20 |
setTime(new Date())
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
}, [])
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
const
|
| 33 |
-
const
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
const worldClocks = [
|
|
|
|
| 36 |
{ city: 'New York', offset: -5 },
|
| 37 |
{ city: 'London', offset: 0 },
|
| 38 |
{ city: 'Tokyo', offset: 9 },
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
]
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
|
| 48 |
return (
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<button
|
| 78 |
-
onClick={() => setViewMode('digital')}
|
| 79 |
-
className={`flex-1 py-3 text-sm font-medium transition-all ${viewMode === 'digital'
|
| 80 |
-
? 'text-orange-400 border-b-2 border-orange-400'
|
| 81 |
-
: 'text-gray-400 hover:text-white'
|
| 82 |
-
}`}
|
| 83 |
-
>
|
| 84 |
-
Digital
|
| 85 |
-
</button>
|
| 86 |
-
<button
|
| 87 |
-
onClick={() => setViewMode('world')}
|
| 88 |
-
className={`flex-1 py-3 text-sm font-medium transition-all ${viewMode === 'world'
|
| 89 |
-
? 'text-orange-400 border-b-2 border-orange-400'
|
| 90 |
-
: 'text-gray-400 hover:text-white'
|
| 91 |
-
}`}
|
| 92 |
-
>
|
| 93 |
-
World Clock
|
| 94 |
-
</button>
|
| 95 |
</div>
|
|
|
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
transformOrigin: '50% 118px'
|
| 116 |
-
}}
|
| 117 |
-
/>
|
| 118 |
-
</div>
|
| 119 |
-
))}
|
| 120 |
-
|
| 121 |
-
{/* Clock Hands */}
|
| 122 |
-
<div
|
| 123 |
-
className="absolute top-1/2 left-1/2 w-2 h-20 bg-white rounded-full origin-bottom shadow-lg"
|
| 124 |
-
style={{
|
| 125 |
-
transform: `translate(-50%, -100%) rotate(${hourDegrees}deg)`
|
| 126 |
-
}}
|
| 127 |
-
/>
|
| 128 |
-
<div
|
| 129 |
-
className="absolute top-1/2 left-1/2 w-1.5 h-28 bg-gray-300 rounded-full origin-bottom shadow-lg"
|
| 130 |
-
style={{
|
| 131 |
-
transform: `translate(-50%, -100%) rotate(${minuteDegrees}deg)`
|
| 132 |
-
}}
|
| 133 |
/>
|
| 134 |
-
<
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
}
|
| 139 |
-
/>
|
| 140 |
-
|
| 141 |
-
{/* Center Dot */}
|
| 142 |
-
<div className="absolute top-1/2 left-1/2 w-4 h-4 bg-orange-500 rounded-full transform -translate-x-1/2 -translate-y-1/2 shadow-lg z-10" />
|
| 143 |
</div>
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
</
|
| 151 |
-
<
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
</div>
|
| 155 |
-
</div>
|
| 156 |
)}
|
| 157 |
|
| 158 |
-
{
|
| 159 |
-
<div
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
</div>
|
| 163 |
-
<div className="mt-8 text-2xl text-gray-400">
|
| 164 |
-
{time.toLocaleDateString('en-US', { weekday: 'long' })}
|
| 165 |
-
</div>
|
| 166 |
-
<div className="text-lg text-gray-500">
|
| 167 |
-
{time.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
|
| 168 |
-
</div>
|
| 169 |
-
</div>
|
| 170 |
-
)}
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
<div>
|
| 178 |
-
<div className="
|
| 179 |
-
<div className="text-xs text-gray-400">
|
| 180 |
</div>
|
| 181 |
-
<div className="text-
|
| 182 |
-
{
|
| 183 |
</div>
|
| 184 |
-
</
|
| 185 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
-
</div>
|
| 188 |
)}
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
</Window>
|
| 192 |
)
|
| 193 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import React, { useState, useEffect, useRef } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
import Window from './Window'
|
| 6 |
+
import { Globe, Clock as ClockIcon, Type, Plus } from 'lucide-react'
|
| 7 |
|
| 8 |
interface ClockProps {
|
| 9 |
onClose: () => void
|
|
|
|
| 13 |
zIndex?: number
|
| 14 |
}
|
| 15 |
|
| 16 |
+
// Helper to get smooth time
|
| 17 |
+
const useSmoothTime = () => {
|
| 18 |
const [time, setTime] = useState(new Date())
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
+
let frameId: number
|
| 22 |
+
const update = () => {
|
| 23 |
setTime(new Date())
|
| 24 |
+
frameId = requestAnimationFrame(update)
|
| 25 |
+
}
|
| 26 |
+
update()
|
| 27 |
+
return () => cancelAnimationFrame(frameId)
|
| 28 |
}, [])
|
| 29 |
|
| 30 |
+
return time
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const AnalogFace = ({ time, city, offset, isSmall = false }: { time: Date, city?: string, offset?: number, isSmall?: boolean }) => {
|
| 34 |
+
// Calculate time with offset if provided
|
| 35 |
+
const displayTime = offset !== undefined
|
| 36 |
+
? new Date(time.getTime() + (time.getTimezoneOffset() * 60000) + (3600000 * offset))
|
| 37 |
+
: time
|
| 38 |
+
|
| 39 |
+
const seconds = displayTime.getSeconds() + displayTime.getMilliseconds() / 1000
|
| 40 |
+
const minutes = displayTime.getMinutes() + seconds / 60
|
| 41 |
+
const hours = displayTime.getHours() % 12 + minutes / 60
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className={`relative flex flex-col items-center ${isSmall ? 'gap-2' : 'gap-8'}`}>
|
| 45 |
+
<div className={`relative ${isSmall ? 'w-32 h-32' : 'w-64 h-64'} rounded-full bg-[#1e1e1e] shadow-[inset_0_0_20px_rgba(0,0,0,0.5)] border-[3px] border-[#333]`}>
|
| 46 |
+
{/* Clock Face Markings */}
|
| 47 |
+
{[...Array(12)].map((_, i) => (
|
| 48 |
+
<div
|
| 49 |
+
key={i}
|
| 50 |
+
className="absolute w-full h-full left-0 top-0"
|
| 51 |
+
style={{ transform: `rotate(${i * 30}deg)` }}
|
| 52 |
+
>
|
| 53 |
+
<div className={`absolute left-1/2 -translate-x-1/2 top-2 bg-gray-400 ${isSmall ? 'w-0.5 h-2' : 'w-1 h-3'
|
| 54 |
+
}`} />
|
| 55 |
+
{!isSmall && (
|
| 56 |
+
<span
|
| 57 |
+
className="absolute left-1/2 -translate-x-1/2 top-6 text-xl font-medium text-gray-300"
|
| 58 |
+
style={{ transform: `rotate(-${i * 30}deg)` }}
|
| 59 |
+
>
|
| 60 |
+
{i === 0 ? 12 : i}
|
| 61 |
+
</span>
|
| 62 |
+
)}
|
| 63 |
+
</div>
|
| 64 |
+
))}
|
| 65 |
+
|
| 66 |
+
{/* Hands Container */}
|
| 67 |
+
<div className="absolute inset-0">
|
| 68 |
+
{/* Hour Hand */}
|
| 69 |
+
<div
|
| 70 |
+
className="absolute left-1/2 top-1/2 bg-white rounded-full origin-bottom shadow-lg z-10"
|
| 71 |
+
style={{
|
| 72 |
+
width: isSmall ? '3px' : '6px',
|
| 73 |
+
height: isSmall ? '25%' : '25%',
|
| 74 |
+
transform: `translate(-50%, -100%) rotate(${hours * 30}deg)`,
|
| 75 |
+
}}
|
| 76 |
+
/>
|
| 77 |
+
|
| 78 |
+
{/* Minute Hand */}
|
| 79 |
+
<div
|
| 80 |
+
className="absolute left-1/2 top-1/2 bg-white rounded-full origin-bottom shadow-lg z-10"
|
| 81 |
+
style={{
|
| 82 |
+
width: isSmall ? '2px' : '4px',
|
| 83 |
+
height: isSmall ? '35%' : '38%',
|
| 84 |
+
transform: `translate(-50%, -100%) rotate(${minutes * 6}deg)`,
|
| 85 |
+
}}
|
| 86 |
+
/>
|
| 87 |
+
|
| 88 |
+
{/* Second Hand (Orange) */}
|
| 89 |
+
<div
|
| 90 |
+
className="absolute left-1/2 top-1/2 bg-orange-500 rounded-full origin-bottom z-20"
|
| 91 |
+
style={{
|
| 92 |
+
width: isSmall ? '1px' : '2px',
|
| 93 |
+
height: isSmall ? '40%' : '45%',
|
| 94 |
+
transform: `translate(-50%, -100%) rotate(${seconds * 6}deg)`,
|
| 95 |
+
}}
|
| 96 |
+
>
|
| 97 |
+
{/* Tail of second hand */}
|
| 98 |
+
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-[20%] w-full h-[20%] bg-orange-500" />
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
{/* Center Cap */}
|
| 102 |
+
<div className={`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black border-2 border-orange-500 z-30 ${isSmall ? 'w-1.5 h-1.5' : 'w-3 h-3'
|
| 103 |
+
}`} />
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Labels */}
|
| 108 |
+
<div className="text-center">
|
| 109 |
+
<div className={`${isSmall ? 'text-sm' : 'text-2xl'} font-medium text-white`}>
|
| 110 |
+
{city || 'Local Time'}
|
| 111 |
+
</div>
|
| 112 |
+
<div className={`${isSmall ? 'text-xs' : 'text-lg'} text-gray-400 font-mono mt-1`}>
|
| 113 |
+
{offset !== undefined
|
| 114 |
+
? (offset === 0 ? 'Today' : `${offset > 0 ? '+' : ''}${offset}HRS`)
|
| 115 |
+
: displayTime.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
|
| 116 |
+
}
|
| 117 |
+
</div>
|
| 118 |
+
{!isSmall && (
|
| 119 |
+
<div className="text-4xl font-light text-white mt-4 tracking-wider">
|
| 120 |
+
{displayTime.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })}
|
| 121 |
+
</div>
|
| 122 |
+
)}
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
)
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// List of available cities with offsets
|
| 129 |
+
const AVAILABLE_CITIES = [
|
| 130 |
+
{ city: 'San Francisco', offset: -8, region: 'USA' },
|
| 131 |
+
{ city: 'New York', offset: -5, region: 'USA' },
|
| 132 |
+
{ city: 'London', offset: 0, region: 'UK' },
|
| 133 |
+
{ city: 'Paris', offset: 1, region: 'Europe' },
|
| 134 |
+
{ city: 'Berlin', offset: 1, region: 'Europe' },
|
| 135 |
+
{ city: 'Moscow', offset: 3, region: 'Russia' },
|
| 136 |
+
{ city: 'Dubai', offset: 4, region: 'UAE' },
|
| 137 |
+
{ city: 'Mumbai', offset: 5.5, region: 'India' },
|
| 138 |
+
{ city: 'Singapore', offset: 8, region: 'Asia' },
|
| 139 |
+
{ city: 'Tokyo', offset: 9, region: 'Japan' },
|
| 140 |
+
{ city: 'Sydney', offset: 11, region: 'Australia' },
|
| 141 |
+
{ city: 'Auckland', offset: 13, region: 'New Zealand' },
|
| 142 |
+
{ city: 'Honolulu', offset: -10, region: 'USA' },
|
| 143 |
+
{ city: 'Rio de Janeiro', offset: -3, region: 'Brazil' },
|
| 144 |
+
]
|
| 145 |
|
| 146 |
+
const ClockContent = () => {
|
| 147 |
+
const time = useSmoothTime()
|
| 148 |
+
const [activeTab, setActiveTab] = useState<'world' | 'analog' | 'digital'>('world')
|
| 149 |
+
const [showAddCity, setShowAddCity] = useState(false)
|
| 150 |
+
const [searchQuery, setSearchQuery] = useState('')
|
| 151 |
|
| 152 |
+
const [worldClocks, setWorldClocks] = useState([
|
| 153 |
+
{ city: 'Cupertino', offset: -8 },
|
| 154 |
{ city: 'New York', offset: -5 },
|
| 155 |
{ city: 'London', offset: 0 },
|
| 156 |
{ city: 'Tokyo', offset: 9 },
|
| 157 |
+
])
|
| 158 |
+
|
| 159 |
+
const tabs = [
|
| 160 |
+
{ id: 'world', label: 'World Clock', icon: Globe },
|
| 161 |
+
{ id: 'analog', label: 'Analog', icon: ClockIcon },
|
| 162 |
+
{ id: 'digital', label: 'Digital', icon: Type },
|
| 163 |
]
|
| 164 |
|
| 165 |
+
const filteredCities = AVAILABLE_CITIES.filter(c =>
|
| 166 |
+
c.city.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
| 167 |
+
!worldClocks.some(wc => wc.city === c.city)
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
const addCity = (city: typeof AVAILABLE_CITIES[0]) => {
|
| 171 |
+
setWorldClocks([...worldClocks, { city: city.city, offset: city.offset }])
|
| 172 |
+
setShowAddCity(false)
|
| 173 |
+
setSearchQuery('')
|
| 174 |
}
|
| 175 |
|
| 176 |
return (
|
| 177 |
+
<div className="flex flex-col h-full text-white relative">
|
| 178 |
+
{/* macOS-style Toolbar */}
|
| 179 |
+
<div className="flex items-center justify-center pt-4 pb-6 border-b border-white/10 bg-[#252525]/50">
|
| 180 |
+
<div className="flex bg-black/20 p-1 rounded-lg backdrop-blur-md">
|
| 181 |
+
{tabs.map((tab) => {
|
| 182 |
+
const Icon = tab.icon
|
| 183 |
+
const isActive = activeTab === tab.id
|
| 184 |
+
return (
|
| 185 |
+
<button
|
| 186 |
+
key={tab.id}
|
| 187 |
+
onClick={() => setActiveTab(tab.id as any)}
|
| 188 |
+
className={`relative px-4 py-1.5 rounded-md text-sm font-medium transition-all duration-200 flex items-center gap-2 ${isActive ? 'text-white' : 'text-gray-400 hover:text-gray-200'
|
| 189 |
+
}`}
|
| 190 |
+
>
|
| 191 |
+
{isActive && (
|
| 192 |
+
<motion.div
|
| 193 |
+
layoutId="activeTab"
|
| 194 |
+
className="absolute inset-0 bg-[#3a3a3a] rounded-md shadow-sm"
|
| 195 |
+
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
| 196 |
+
/>
|
| 197 |
+
)}
|
| 198 |
+
<span className="relative z-10 flex items-center gap-2">
|
| 199 |
+
<Icon size={14} />
|
| 200 |
+
{tab.label}
|
| 201 |
+
</span>
|
| 202 |
+
</button>
|
| 203 |
+
)
|
| 204 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
</div>
|
| 206 |
+
</div>
|
| 207 |
|
| 208 |
+
{/* Content Area */}
|
| 209 |
+
<div className="flex-1 overflow-y-auto p-8 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2">
|
| 210 |
+
<AnimatePresence mode="wait">
|
| 211 |
+
{activeTab === 'world' && !showAddCity && (
|
| 212 |
+
<motion.div
|
| 213 |
+
key="world"
|
| 214 |
+
initial={{ opacity: 0, y: 10 }}
|
| 215 |
+
animate={{ opacity: 1, y: 0 }}
|
| 216 |
+
exit={{ opacity: 0, y: -10 }}
|
| 217 |
+
className="grid grid-cols-2 gap-8 justify-items-center"
|
| 218 |
+
>
|
| 219 |
+
{worldClocks.map((clock) => (
|
| 220 |
+
<div key={clock.city} className="relative group">
|
| 221 |
+
<AnalogFace
|
| 222 |
+
time={time}
|
| 223 |
+
city={clock.city}
|
| 224 |
+
offset={clock.offset}
|
| 225 |
+
isSmall={true}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
/>
|
| 227 |
+
<button
|
| 228 |
+
onClick={() => setWorldClocks(worldClocks.filter(c => c.city !== clock.city))}
|
| 229 |
+
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
|
| 230 |
+
>
|
| 231 |
+
<Plus size={12} className="rotate-45" />
|
| 232 |
+
</button>
|
|
|
|
|
|
|
|
|
|
| 233 |
</div>
|
| 234 |
+
))}
|
| 235 |
+
{/* Add Button */}
|
| 236 |
+
<button
|
| 237 |
+
onClick={() => setShowAddCity(true)}
|
| 238 |
+
className="w-32 h-32 rounded-full border-2 border-dashed border-gray-600 flex flex-col items-center justify-center text-gray-500 hover:text-orange-500 hover:border-orange-500 transition-colors group"
|
| 239 |
+
>
|
| 240 |
+
<Plus size={32} className="group-hover:scale-110 transition-transform" />
|
| 241 |
+
<span className="text-xs mt-2 font-medium">Add City</span>
|
| 242 |
+
</button>
|
| 243 |
+
</motion.div>
|
|
|
|
|
|
|
| 244 |
)}
|
| 245 |
|
| 246 |
+
{activeTab === 'world' && showAddCity && (
|
| 247 |
+
<motion.div
|
| 248 |
+
key="add-city"
|
| 249 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 250 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 251 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 252 |
+
className="flex flex-col h-full max-w-md mx-auto"
|
| 253 |
+
>
|
| 254 |
+
<div className="flex items-center gap-2 mb-4">
|
| 255 |
+
<button
|
| 256 |
+
onClick={() => setShowAddCity(false)}
|
| 257 |
+
className="p-2 hover:bg-white/10 rounded-full transition-colors"
|
| 258 |
+
>
|
| 259 |
+
<Plus size={20} className="rotate-45" />
|
| 260 |
+
</button>
|
| 261 |
+
<input
|
| 262 |
+
type="text"
|
| 263 |
+
placeholder="Search city..."
|
| 264 |
+
value={searchQuery}
|
| 265 |
+
onChange={(e) => setSearchQuery(e.target.value)}
|
| 266 |
+
className="flex-1 bg-white/10 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:outline-none focus:border-orange-500 transition-colors"
|
| 267 |
+
autoFocus
|
| 268 |
+
/>
|
| 269 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
|
| 271 |
+
<div className="flex-1 overflow-y-auto space-y-2 pr-2 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2">
|
| 272 |
+
{filteredCities.map((city) => (
|
| 273 |
+
<button
|
| 274 |
+
key={city.city}
|
| 275 |
+
onClick={() => addCity(city)}
|
| 276 |
+
className="w-full flex items-center justify-between p-3 rounded-lg hover:bg-white/10 transition-colors group text-left"
|
| 277 |
+
>
|
| 278 |
<div>
|
| 279 |
+
<div className="font-medium text-white">{city.city}</div>
|
| 280 |
+
<div className="text-xs text-gray-400">{city.region}</div>
|
| 281 |
</div>
|
| 282 |
+
<div className="text-sm font-mono text-gray-500 group-hover:text-orange-400">
|
| 283 |
+
UTC{city.offset >= 0 ? '+' : ''}{city.offset}
|
| 284 |
</div>
|
| 285 |
+
</button>
|
| 286 |
))}
|
| 287 |
+
{filteredCities.length === 0 && (
|
| 288 |
+
<div className="text-center text-gray-500 mt-8">
|
| 289 |
+
No cities found
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
</div>
|
| 293 |
+
</motion.div>
|
| 294 |
)}
|
| 295 |
+
|
| 296 |
+
{activeTab === 'analog' && (
|
| 297 |
+
<motion.div
|
| 298 |
+
key="analog"
|
| 299 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 300 |
+
animate={{ opacity: 1, scale: 1 }}
|
| 301 |
+
exit={{ opacity: 0, scale: 1.05 }}
|
| 302 |
+
className="flex items-center justify-center h-full"
|
| 303 |
+
>
|
| 304 |
+
<AnalogFace time={time} />
|
| 305 |
+
</motion.div>
|
| 306 |
+
)}
|
| 307 |
+
|
| 308 |
+
{activeTab === 'digital' && (
|
| 309 |
+
<motion.div
|
| 310 |
+
key="digital"
|
| 311 |
+
initial={{ opacity: 0 }}
|
| 312 |
+
animate={{ opacity: 1 }}
|
| 313 |
+
exit={{ opacity: 0 }}
|
| 314 |
+
className="flex flex-col items-center justify-center h-full"
|
| 315 |
+
>
|
| 316 |
+
<div className="text-[8rem] leading-none font-light tracking-tighter text-white tabular-nums">
|
| 317 |
+
{time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' })}
|
| 318 |
+
</div>
|
| 319 |
+
<div className="text-4xl font-light text-orange-500 mt-4 tabular-nums">
|
| 320 |
+
{time.getSeconds().toString().padStart(2, '0')}
|
| 321 |
+
</div>
|
| 322 |
+
<div className="text-xl text-gray-400 mt-8 font-medium tracking-wide uppercase">
|
| 323 |
+
{time.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
|
| 324 |
+
</div>
|
| 325 |
+
</motion.div>
|
| 326 |
+
)}
|
| 327 |
+
</AnimatePresence>
|
| 328 |
</div>
|
| 329 |
+
</div>
|
| 330 |
+
)
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
export function Clock({ onClose, onMinimize, onMaximize, onFocus, zIndex }: ClockProps) {
|
| 334 |
+
return (
|
| 335 |
+
<Window
|
| 336 |
+
id="clock"
|
| 337 |
+
title="Clock"
|
| 338 |
+
isOpen={true}
|
| 339 |
+
onClose={onClose}
|
| 340 |
+
onMinimize={onMinimize}
|
| 341 |
+
onMaximize={onMaximize}
|
| 342 |
+
onFocus={onFocus}
|
| 343 |
+
zIndex={zIndex}
|
| 344 |
+
width={600}
|
| 345 |
+
height={500}
|
| 346 |
+
x={window.innerWidth / 2 - 300}
|
| 347 |
+
y={window.innerHeight / 2 - 250}
|
| 348 |
+
darkMode={true}
|
| 349 |
+
className="clock-app-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden"
|
| 350 |
+
contentClassName="!bg-transparent"
|
| 351 |
+
headerClassName="!bg-transparent border-b border-white/5"
|
| 352 |
+
>
|
| 353 |
+
<ClockContent />
|
| 354 |
</Window>
|
| 355 |
)
|
| 356 |
}
|
app/components/Desktop.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { TopBar } from './TopBar'
|
|
| 6 |
import { FileManager } from './FileManager'
|
| 7 |
import { Calendar } from './Calendar'
|
| 8 |
import { DraggableDesktopIcon } from './DraggableDesktopIcon'
|
|
|
|
| 9 |
|
| 10 |
import { HelpModal } from './HelpModal'
|
| 11 |
import { DesktopContextMenu } from './DesktopContextMenu'
|
|
@@ -38,13 +39,15 @@ import {
|
|
| 38 |
FileText,
|
| 39 |
DeviceMobile,
|
| 40 |
Lightning,
|
| 41 |
-
Function
|
|
|
|
| 42 |
} from '@phosphor-icons/react'
|
| 43 |
|
| 44 |
export function Desktop() {
|
| 45 |
const [fileManagerOpen, setFileManagerOpen] = useState(false)
|
| 46 |
const [calendarOpen, setCalendarOpen] = useState(false)
|
| 47 |
const [clockOpen, setClockOpen] = useState(false)
|
|
|
|
| 48 |
|
| 49 |
const [geminiChatOpen, setGeminiChatOpen] = useState(false)
|
| 50 |
|
|
@@ -73,6 +76,7 @@ export function Desktop() {
|
|
| 73 |
const [fileManagerMinimized, setFileManagerMinimized] = useState(false)
|
| 74 |
const [calendarMinimized, setCalendarMinimized] = useState(false)
|
| 75 |
const [clockMinimized, setClockMinimized] = useState(false)
|
|
|
|
| 76 |
|
| 77 |
const [geminiChatMinimized, setGeminiChatMinimized] = useState(false)
|
| 78 |
|
|
@@ -102,6 +106,7 @@ export function Desktop() {
|
|
| 102 |
setFileManagerOpen(false)
|
| 103 |
setCalendarOpen(false)
|
| 104 |
setClockOpen(false)
|
|
|
|
| 105 |
setGeminiChatOpen(false)
|
| 106 |
setFlutterRunnerOpen(false)
|
| 107 |
|
|
@@ -113,6 +118,7 @@ export function Desktop() {
|
|
| 113 |
setFileManagerMinimized(false)
|
| 114 |
setCalendarMinimized(false)
|
| 115 |
setClockMinimized(false)
|
|
|
|
| 116 |
setGeminiChatMinimized(false)
|
| 117 |
setFlutterRunnerMinimized(false)
|
| 118 |
|
|
@@ -161,7 +167,16 @@ export function Desktop() {
|
|
| 161 |
setClockMinimized(false)
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
const openGeminiChat = () => {
|
| 167 |
console.log('Opening Gemini Chat')
|
|
@@ -254,7 +269,9 @@ export function Desktop() {
|
|
| 254 |
case 'clock':
|
| 255 |
openClock()
|
| 256 |
break
|
| 257 |
-
|
|
|
|
|
|
|
| 258 |
case 'gemini':
|
| 259 |
openGeminiChat()
|
| 260 |
break
|
|
@@ -413,7 +430,19 @@ export function Desktop() {
|
|
| 413 |
})
|
| 414 |
}
|
| 415 |
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
if (geminiChatMinimized && geminiChatOpen) {
|
| 419 |
minimizedApps.push({
|
|
@@ -505,13 +534,15 @@ export function Desktop() {
|
|
| 505 |
fileManagerOpen,
|
| 506 |
calendarOpen,
|
| 507 |
clockOpen,
|
|
|
|
| 508 |
geminiChatOpen,
|
| 509 |
fileManagerMinimized,
|
| 510 |
calendarMinimized,
|
| 511 |
clockMinimized,
|
|
|
|
| 512 |
geminiChatMinimized
|
| 513 |
})
|
| 514 |
-
}, [fileManagerOpen, calendarOpen, clockOpen, geminiChatOpen, fileManagerMinimized, calendarMinimized, clockMinimized, geminiChatMinimized])
|
| 515 |
|
| 516 |
return (
|
| 517 |
<div className="relative h-screen w-screen overflow-hidden flex touch-auto" onContextMenu={handleDesktopRightClick}>
|
|
@@ -549,12 +580,14 @@ export function Desktop() {
|
|
| 549 |
onOpenFileManager={openFileManager}
|
| 550 |
onOpenCalendar={openCalendar}
|
| 551 |
onOpenClock={openClock}
|
|
|
|
| 552 |
onOpenGeminiChat={openGeminiChat}
|
| 553 |
onCloseAllApps={closeAllApps}
|
| 554 |
openApps={{
|
| 555 |
files: fileManagerOpen,
|
| 556 |
calendar: calendarOpen,
|
| 557 |
clock: clockOpen,
|
|
|
|
| 558 |
gemini: geminiChatOpen
|
| 559 |
}}
|
| 560 |
minimizedApps={minimizedApps}
|
|
@@ -574,6 +607,17 @@ export function Desktop() {
|
|
| 574 |
/>
|
| 575 |
</div>
|
| 576 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
<div className="pointer-events-auto w-24 h-24">
|
| 578 |
<DraggableDesktopIcon
|
| 579 |
id="gemini"
|
|
@@ -617,10 +661,6 @@ export function Desktop() {
|
|
| 617 |
/>
|
| 618 |
</div>
|
| 619 |
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
<div className="pointer-events-auto w-24 h-24">
|
| 625 |
<DraggableDesktopIcon
|
| 626 |
id="flutter-editor"
|
|
@@ -737,7 +777,30 @@ export function Desktop() {
|
|
| 737 |
</motion.div>
|
| 738 |
)}
|
| 739 |
|
| 740 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
|
| 742 |
{geminiChatOpen && (
|
| 743 |
<motion.div
|
|
@@ -791,8 +854,6 @@ export function Desktop() {
|
|
| 791 |
</motion.div>
|
| 792 |
)}
|
| 793 |
|
| 794 |
-
|
| 795 |
-
|
| 796 |
{flutterCodeEditorOpen && (
|
| 797 |
<motion.div
|
| 798 |
key="flutter-code-editor"
|
|
@@ -886,8 +947,6 @@ export function Desktop() {
|
|
| 886 |
onAction={handleContextMenuAction}
|
| 887 |
/>
|
| 888 |
|
| 889 |
-
|
| 890 |
-
|
| 891 |
{/* Help Modal */}
|
| 892 |
<HelpModal isOpen={helpModalOpen} onClose={closeHelpModal} />
|
| 893 |
|
|
|
|
| 6 |
import { FileManager } from './FileManager'
|
| 7 |
import { Calendar } from './Calendar'
|
| 8 |
import { DraggableDesktopIcon } from './DraggableDesktopIcon'
|
| 9 |
+
import { Messages } from './Messages'
|
| 10 |
|
| 11 |
import { HelpModal } from './HelpModal'
|
| 12 |
import { DesktopContextMenu } from './DesktopContextMenu'
|
|
|
|
| 39 |
FileText,
|
| 40 |
DeviceMobile,
|
| 41 |
Lightning,
|
| 42 |
+
Function,
|
| 43 |
+
ChatCircleDots
|
| 44 |
} from '@phosphor-icons/react'
|
| 45 |
|
| 46 |
export function Desktop() {
|
| 47 |
const [fileManagerOpen, setFileManagerOpen] = useState(false)
|
| 48 |
const [calendarOpen, setCalendarOpen] = useState(false)
|
| 49 |
const [clockOpen, setClockOpen] = useState(false)
|
| 50 |
+
const [messagesOpen, setMessagesOpen] = useState(false)
|
| 51 |
|
| 52 |
const [geminiChatOpen, setGeminiChatOpen] = useState(false)
|
| 53 |
|
|
|
|
| 76 |
const [fileManagerMinimized, setFileManagerMinimized] = useState(false)
|
| 77 |
const [calendarMinimized, setCalendarMinimized] = useState(false)
|
| 78 |
const [clockMinimized, setClockMinimized] = useState(false)
|
| 79 |
+
const [messagesMinimized, setMessagesMinimized] = useState(false)
|
| 80 |
|
| 81 |
const [geminiChatMinimized, setGeminiChatMinimized] = useState(false)
|
| 82 |
|
|
|
|
| 106 |
setFileManagerOpen(false)
|
| 107 |
setCalendarOpen(false)
|
| 108 |
setClockOpen(false)
|
| 109 |
+
setMessagesOpen(false)
|
| 110 |
setGeminiChatOpen(false)
|
| 111 |
setFlutterRunnerOpen(false)
|
| 112 |
|
|
|
|
| 118 |
setFileManagerMinimized(false)
|
| 119 |
setCalendarMinimized(false)
|
| 120 |
setClockMinimized(false)
|
| 121 |
+
setMessagesMinimized(false)
|
| 122 |
setGeminiChatMinimized(false)
|
| 123 |
setFlutterRunnerMinimized(false)
|
| 124 |
|
|
|
|
| 167 |
setClockMinimized(false)
|
| 168 |
}
|
| 169 |
|
| 170 |
+
const openMessages = () => {
|
| 171 |
+
setMessagesOpen(true)
|
| 172 |
+
setMessagesMinimized(false)
|
| 173 |
+
setWindowZIndices(prev => ({ ...prev, messages: getNextZIndex() }))
|
| 174 |
+
}
|
| 175 |
|
| 176 |
+
const closeMessages = () => {
|
| 177 |
+
setMessagesOpen(false)
|
| 178 |
+
setMessagesMinimized(false)
|
| 179 |
+
}
|
| 180 |
|
| 181 |
const openGeminiChat = () => {
|
| 182 |
console.log('Opening Gemini Chat')
|
|
|
|
| 269 |
case 'clock':
|
| 270 |
openClock()
|
| 271 |
break
|
| 272 |
+
case 'messages':
|
| 273 |
+
openMessages()
|
| 274 |
+
break
|
| 275 |
case 'gemini':
|
| 276 |
openGeminiChat()
|
| 277 |
break
|
|
|
|
| 430 |
})
|
| 431 |
}
|
| 432 |
|
| 433 |
+
if (messagesMinimized && messagesOpen) {
|
| 434 |
+
minimizedApps.push({
|
| 435 |
+
id: 'messages',
|
| 436 |
+
label: 'Messages',
|
| 437 |
+
icon: (
|
| 438 |
+
<div className="bg-gradient-to-b from-[#4CD964] to-[#2E8B57] w-full h-full rounded-[22%] flex items-center justify-center shadow-lg border-[0.5px] border-white/20 relative overflow-hidden">
|
| 439 |
+
<div className="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent opacity-50" />
|
| 440 |
+
<ChatCircleDots size={20} weight="fill" className="text-white relative z-10 drop-shadow-md" />
|
| 441 |
+
</div>
|
| 442 |
+
),
|
| 443 |
+
onRestore: () => setMessagesMinimized(false)
|
| 444 |
+
})
|
| 445 |
+
}
|
| 446 |
|
| 447 |
if (geminiChatMinimized && geminiChatOpen) {
|
| 448 |
minimizedApps.push({
|
|
|
|
| 534 |
fileManagerOpen,
|
| 535 |
calendarOpen,
|
| 536 |
clockOpen,
|
| 537 |
+
messagesOpen,
|
| 538 |
geminiChatOpen,
|
| 539 |
fileManagerMinimized,
|
| 540 |
calendarMinimized,
|
| 541 |
clockMinimized,
|
| 542 |
+
messagesMinimized,
|
| 543 |
geminiChatMinimized
|
| 544 |
})
|
| 545 |
+
}, [fileManagerOpen, calendarOpen, clockOpen, messagesOpen, geminiChatOpen, fileManagerMinimized, calendarMinimized, clockMinimized, messagesMinimized, geminiChatMinimized])
|
| 546 |
|
| 547 |
return (
|
| 548 |
<div className="relative h-screen w-screen overflow-hidden flex touch-auto" onContextMenu={handleDesktopRightClick}>
|
|
|
|
| 580 |
onOpenFileManager={openFileManager}
|
| 581 |
onOpenCalendar={openCalendar}
|
| 582 |
onOpenClock={openClock}
|
| 583 |
+
onOpenMessages={openMessages}
|
| 584 |
onOpenGeminiChat={openGeminiChat}
|
| 585 |
onCloseAllApps={closeAllApps}
|
| 586 |
openApps={{
|
| 587 |
files: fileManagerOpen,
|
| 588 |
calendar: calendarOpen,
|
| 589 |
clock: clockOpen,
|
| 590 |
+
messages: messagesOpen,
|
| 591 |
gemini: geminiChatOpen
|
| 592 |
}}
|
| 593 |
minimizedApps={minimizedApps}
|
|
|
|
| 607 |
/>
|
| 608 |
</div>
|
| 609 |
|
| 610 |
+
<div className="pointer-events-auto w-24 h-24">
|
| 611 |
+
<DraggableDesktopIcon
|
| 612 |
+
id="messages"
|
| 613 |
+
label="Messages"
|
| 614 |
+
iconType="messages"
|
| 615 |
+
initialPosition={{ x: 0, y: 0 }}
|
| 616 |
+
onClick={() => { }}
|
| 617 |
+
onDoubleClick={openMessages}
|
| 618 |
+
/>
|
| 619 |
+
</div>
|
| 620 |
+
|
| 621 |
<div className="pointer-events-auto w-24 h-24">
|
| 622 |
<DraggableDesktopIcon
|
| 623 |
id="gemini"
|
|
|
|
| 661 |
/>
|
| 662 |
</div>
|
| 663 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
<div className="pointer-events-auto w-24 h-24">
|
| 665 |
<DraggableDesktopIcon
|
| 666 |
id="flutter-editor"
|
|
|
|
| 777 |
</motion.div>
|
| 778 |
)}
|
| 779 |
|
| 780 |
+
{messagesOpen && (
|
| 781 |
+
<motion.div
|
| 782 |
+
key="messages"
|
| 783 |
+
initial={{ opacity: 0, scale: 0.95 }}
|
| 784 |
+
animate={{
|
| 785 |
+
opacity: messagesMinimized ? 0 : 1,
|
| 786 |
+
scale: messagesMinimized ? 0.9 : 1,
|
| 787 |
+
y: messagesMinimized ? 100 : 0,
|
| 788 |
+
}}
|
| 789 |
+
exit={{ opacity: 0, scale: 0.95 }}
|
| 790 |
+
transition={{ duration: 0.2 }}
|
| 791 |
+
style={{
|
| 792 |
+
pointerEvents: messagesMinimized ? 'none' : 'auto',
|
| 793 |
+
display: messagesMinimized ? 'none' : 'block'
|
| 794 |
+
}}
|
| 795 |
+
>
|
| 796 |
+
<Messages
|
| 797 |
+
onClose={closeMessages}
|
| 798 |
+
onMinimize={() => setMessagesMinimized(true)}
|
| 799 |
+
onFocus={() => bringWindowToFront('messages')}
|
| 800 |
+
zIndex={windowZIndices.messages || 1000}
|
| 801 |
+
/>
|
| 802 |
+
</motion.div>
|
| 803 |
+
)}
|
| 804 |
|
| 805 |
{geminiChatOpen && (
|
| 806 |
<motion.div
|
|
|
|
| 854 |
</motion.div>
|
| 855 |
)}
|
| 856 |
|
|
|
|
|
|
|
| 857 |
{flutterCodeEditorOpen && (
|
| 858 |
<motion.div
|
| 859 |
key="flutter-code-editor"
|
|
|
|
| 947 |
onAction={handleContextMenuAction}
|
| 948 |
/>
|
| 949 |
|
|
|
|
|
|
|
| 950 |
{/* Help Modal */}
|
| 951 |
<HelpModal isOpen={helpModalOpen} onClose={closeHelpModal} />
|
| 952 |
|
app/components/Dock.tsx
CHANGED
|
@@ -5,14 +5,14 @@ import {
|
|
| 5 |
Folder,
|
| 6 |
Calendar as CalendarIcon,
|
| 7 |
Clock as ClockIcon,
|
| 8 |
-
|
| 9 |
Sparkle,
|
| 10 |
Trash,
|
| 11 |
FolderOpen,
|
| 12 |
Compass,
|
| 13 |
Flask,
|
| 14 |
DeviceMobile,
|
| 15 |
-
Function
|
|
|
|
| 16 |
} from '@phosphor-icons/react'
|
| 17 |
import { motion } from 'framer-motion'
|
| 18 |
import { DynamicClockIcon } from './DynamicClockIcon'
|
|
@@ -29,7 +29,7 @@ interface DockProps {
|
|
| 29 |
onOpenFileManager: (path: string) => void
|
| 30 |
onOpenCalendar: () => void
|
| 31 |
onOpenClock: () => void
|
| 32 |
-
|
| 33 |
onOpenGeminiChat: () => void
|
| 34 |
onCloseAllApps?: () => void
|
| 35 |
openApps: { [key: string]: boolean }
|
|
@@ -81,7 +81,7 @@ export function Dock({
|
|
| 81 |
onOpenFileManager,
|
| 82 |
onOpenCalendar,
|
| 83 |
onOpenClock,
|
| 84 |
-
|
| 85 |
onOpenGeminiChat,
|
| 86 |
onCloseAllApps,
|
| 87 |
openApps,
|
|
@@ -113,6 +113,18 @@ export function Dock({
|
|
| 113 |
isActive: openApps['gemini'],
|
| 114 |
className: ''
|
| 115 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
{
|
| 117 |
icon: (
|
| 118 |
<div className="w-full h-full">
|
|
|
|
| 5 |
Folder,
|
| 6 |
Calendar as CalendarIcon,
|
| 7 |
Clock as ClockIcon,
|
|
|
|
| 8 |
Sparkle,
|
| 9 |
Trash,
|
| 10 |
FolderOpen,
|
| 11 |
Compass,
|
| 12 |
Flask,
|
| 13 |
DeviceMobile,
|
| 14 |
+
Function,
|
| 15 |
+
ChatCircleDots
|
| 16 |
} from '@phosphor-icons/react'
|
| 17 |
import { motion } from 'framer-motion'
|
| 18 |
import { DynamicClockIcon } from './DynamicClockIcon'
|
|
|
|
| 29 |
onOpenFileManager: (path: string) => void
|
| 30 |
onOpenCalendar: () => void
|
| 31 |
onOpenClock: () => void
|
| 32 |
+
onOpenMessages: () => void
|
| 33 |
onOpenGeminiChat: () => void
|
| 34 |
onCloseAllApps?: () => void
|
| 35 |
openApps: { [key: string]: boolean }
|
|
|
|
| 81 |
onOpenFileManager,
|
| 82 |
onOpenCalendar,
|
| 83 |
onOpenClock,
|
| 84 |
+
onOpenMessages,
|
| 85 |
onOpenGeminiChat,
|
| 86 |
onCloseAllApps,
|
| 87 |
openApps,
|
|
|
|
| 113 |
isActive: openApps['gemini'],
|
| 114 |
className: ''
|
| 115 |
},
|
| 116 |
+
{
|
| 117 |
+
icon: (
|
| 118 |
+
<div className="bg-gradient-to-b from-[#4CD964] to-[#2E8B57] w-full h-full rounded-xl flex items-center justify-center shadow-lg border-[0.5px] border-white/20 relative overflow-hidden">
|
| 119 |
+
<div className="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent opacity-50" />
|
| 120 |
+
<ChatCircleDots size={24} weight="fill" className="text-white md:scale-110 drop-shadow-md" />
|
| 121 |
+
</div>
|
| 122 |
+
),
|
| 123 |
+
label: 'Messages',
|
| 124 |
+
onClick: onOpenMessages,
|
| 125 |
+
isActive: openApps['messages'],
|
| 126 |
+
className: ''
|
| 127 |
+
},
|
| 128 |
{
|
| 129 |
icon: (
|
| 130 |
<div className="w-full h-full">
|
app/components/DraggableDesktopIcon.tsx
CHANGED
|
@@ -15,7 +15,8 @@ import {
|
|
| 15 |
Code,
|
| 16 |
Lightning,
|
| 17 |
Key,
|
| 18 |
-
Brain
|
|
|
|
| 19 |
} from '@phosphor-icons/react'
|
| 20 |
import { DynamicClockIcon } from './DynamicClockIcon'
|
| 21 |
import { DynamicCalendarIcon } from './DynamicCalendarIcon'
|
|
@@ -68,6 +69,14 @@ export function DraggableDesktopIcon({
|
|
| 68 |
</div>
|
| 69 |
)
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
case 'flutter':
|
| 72 |
return (
|
| 73 |
<div className="bg-gradient-to-b from-[#54C5F8] to-[#29B6F6] w-full h-full rounded-[22%] flex items-center justify-center shadow-lg border-[0.5px] border-white/20 relative overflow-hidden group-hover:shadow-2xl transition-all duration-300">
|
|
|
|
| 15 |
Code,
|
| 16 |
Lightning,
|
| 17 |
Key,
|
| 18 |
+
Brain,
|
| 19 |
+
ChatCircleDots
|
| 20 |
} from '@phosphor-icons/react'
|
| 21 |
import { DynamicClockIcon } from './DynamicClockIcon'
|
| 22 |
import { DynamicCalendarIcon } from './DynamicCalendarIcon'
|
|
|
|
| 69 |
</div>
|
| 70 |
)
|
| 71 |
|
| 72 |
+
case 'messages':
|
| 73 |
+
return (
|
| 74 |
+
<div className="bg-gradient-to-b from-[#4CD964] to-[#2E8B57] w-full h-full rounded-[22%] flex items-center justify-center shadow-lg border-[0.5px] border-white/20 relative overflow-hidden group-hover:shadow-2xl transition-all duration-300">
|
| 75 |
+
<div className="absolute inset-0 bg-gradient-to-b from-white/20 to-transparent opacity-50" />
|
| 76 |
+
<ChatCircleDots size={36} weight="fill" className="text-white relative z-10 drop-shadow-md" />
|
| 77 |
+
</div>
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
case 'flutter':
|
| 81 |
return (
|
| 82 |
<div className="bg-gradient-to-b from-[#54C5F8] to-[#29B6F6] w-full h-full rounded-[22%] flex items-center justify-center shadow-lg border-[0.5px] border-white/20 relative overflow-hidden group-hover:shadow-2xl transition-all duration-300">
|
app/components/Messages.tsx
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef } from 'react'
|
| 4 |
+
import { motion, AnimatePresence } from 'framer-motion'
|
| 5 |
+
import { PaperPlaneRight, MagnifyingGlass, UserCircle, WarningCircle } from '@phosphor-icons/react'
|
| 6 |
+
import Window from './Window'
|
| 7 |
+
import { useKV } from '../hooks/useKV'
|
| 8 |
+
|
| 9 |
+
interface Message {
|
| 10 |
+
id: string
|
| 11 |
+
text: string
|
| 12 |
+
sender: string
|
| 13 |
+
userId: string
|
| 14 |
+
timestamp: number
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
interface MessagesProps {
|
| 18 |
+
onClose: () => void
|
| 19 |
+
onMinimize?: () => void
|
| 20 |
+
onMaximize?: () => void
|
| 21 |
+
onFocus?: () => void
|
| 22 |
+
zIndex?: number
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function Messages({ onClose, onMinimize, onMaximize, onFocus, zIndex }: MessagesProps) {
|
| 26 |
+
const [messages, setMessages] = useState<Message[]>([])
|
| 27 |
+
const [inputText, setInputText] = useState('')
|
| 28 |
+
const [isLoading, setIsLoading] = useState(false)
|
| 29 |
+
const [error, setError] = useState<string | null>(null)
|
| 30 |
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
| 31 |
+
|
| 32 |
+
// Persistent user identity
|
| 33 |
+
const [userId] = useKV<string>('messages-user-id', `user-${Math.random().toString(36).substring(2, 9)}`)
|
| 34 |
+
const [userName, setUserName] = useKV<string>('messages-user-name', 'Guest')
|
| 35 |
+
const [isEditingName, setIsEditingName] = useState(false)
|
| 36 |
+
const [tempName, setTempName] = useState(userName)
|
| 37 |
+
|
| 38 |
+
const fetchMessages = async () => {
|
| 39 |
+
try {
|
| 40 |
+
const res = await fetch('/api/messages')
|
| 41 |
+
if (res.ok) {
|
| 42 |
+
const data = await res.json()
|
| 43 |
+
setMessages(data)
|
| 44 |
+
}
|
| 45 |
+
} catch (err) {
|
| 46 |
+
console.error('Failed to fetch messages', err)
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Poll for new messages
|
| 51 |
+
useEffect(() => {
|
| 52 |
+
fetchMessages()
|
| 53 |
+
const interval = setInterval(fetchMessages, 3000)
|
| 54 |
+
return () => clearInterval(interval)
|
| 55 |
+
}, [])
|
| 56 |
+
|
| 57 |
+
// Scroll to bottom on new messages
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
| 60 |
+
}, [messages])
|
| 61 |
+
|
| 62 |
+
const handleSend = async (e?: React.FormEvent) => {
|
| 63 |
+
e?.preventDefault()
|
| 64 |
+
if (!inputText.trim() || isLoading) return
|
| 65 |
+
|
| 66 |
+
setIsLoading(true)
|
| 67 |
+
setError(null)
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
const res = await fetch('/api/messages', {
|
| 71 |
+
method: 'POST',
|
| 72 |
+
headers: { 'Content-Type': 'application/json' },
|
| 73 |
+
body: JSON.stringify({
|
| 74 |
+
text: inputText,
|
| 75 |
+
sender: userName,
|
| 76 |
+
userId: userId
|
| 77 |
+
})
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
const data = await res.json()
|
| 81 |
+
|
| 82 |
+
if (!res.ok) {
|
| 83 |
+
setError(data.error || 'Failed to send message')
|
| 84 |
+
} else {
|
| 85 |
+
setMessages(data)
|
| 86 |
+
setInputText('')
|
| 87 |
+
}
|
| 88 |
+
} catch (err) {
|
| 89 |
+
setError('Network error. Please try again.')
|
| 90 |
+
} finally {
|
| 91 |
+
setIsLoading(false)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const handleNameSave = () => {
|
| 96 |
+
if (tempName.trim()) {
|
| 97 |
+
setUserName(tempName.trim())
|
| 98 |
+
setIsEditingName(false)
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<Window
|
| 104 |
+
id="messages"
|
| 105 |
+
title="Messages"
|
| 106 |
+
isOpen={true}
|
| 107 |
+
onClose={onClose}
|
| 108 |
+
onMinimize={onMinimize}
|
| 109 |
+
onMaximize={onMaximize}
|
| 110 |
+
onFocus={onFocus}
|
| 111 |
+
zIndex={zIndex}
|
| 112 |
+
width={800}
|
| 113 |
+
height={600}
|
| 114 |
+
x={100}
|
| 115 |
+
y={100}
|
| 116 |
+
className="messages-window !bg-[#1e1e1e]/80 !backdrop-blur-2xl border border-white/10 shadow-2xl !rounded-xl overflow-hidden"
|
| 117 |
+
contentClassName="!bg-transparent"
|
| 118 |
+
headerClassName="!bg-transparent border-b border-white/5"
|
| 119 |
+
>
|
| 120 |
+
<div className="flex h-full text-white overflow-hidden">
|
| 121 |
+
{/* Sidebar */}
|
| 122 |
+
<div className="w-64 border-r border-white/10 bg-black/20 flex flex-col">
|
| 123 |
+
<div className="p-4 border-b border-white/5">
|
| 124 |
+
<div className="relative">
|
| 125 |
+
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" size={14} />
|
| 126 |
+
<input
|
| 127 |
+
type="text"
|
| 128 |
+
placeholder="Search"
|
| 129 |
+
className="w-full bg-white/10 border-none rounded-md py-1.5 pl-9 pr-3 text-xs text-white placeholder-gray-500 focus:ring-1 focus:ring-blue-500 outline-none"
|
| 130 |
+
/>
|
| 131 |
+
</div>
|
| 132 |
+
</div>
|
| 133 |
+
|
| 134 |
+
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
| 135 |
+
<div className="p-3 rounded-lg bg-blue-600/20 border border-blue-500/30 cursor-pointer">
|
| 136 |
+
<div className="flex justify-between items-start">
|
| 137 |
+
<span className="font-semibold text-sm">Global Chat</span>
|
| 138 |
+
<span className="text-[10px] text-gray-400">Now</span>
|
| 139 |
+
</div>
|
| 140 |
+
<div className="text-xs text-gray-400 mt-1 truncate">
|
| 141 |
+
{messages.length > 0 ? messages[messages.length - 1].text : 'No messages yet'}
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
{/* User Profile */}
|
| 147 |
+
<div className="p-4 border-t border-white/10 bg-black/10">
|
| 148 |
+
{isEditingName ? (
|
| 149 |
+
<div className="flex gap-2">
|
| 150 |
+
<input
|
| 151 |
+
type="text"
|
| 152 |
+
value={tempName}
|
| 153 |
+
onChange={(e) => setTempName(e.target.value)}
|
| 154 |
+
className="flex-1 bg-white/10 rounded px-2 py-1 text-sm outline-none border border-blue-500"
|
| 155 |
+
autoFocus
|
| 156 |
+
onKeyDown={(e) => e.key === 'Enter' && handleNameSave()}
|
| 157 |
+
/>
|
| 158 |
+
<button onClick={handleNameSave} className="text-xs text-blue-400 font-medium">Save</button>
|
| 159 |
+
</div>
|
| 160 |
+
) : (
|
| 161 |
+
<div className="flex items-center gap-3 cursor-pointer hover:bg-white/5 p-2 rounded-md transition-colors" onClick={() => {
|
| 162 |
+
setTempName(userName)
|
| 163 |
+
setIsEditingName(true)
|
| 164 |
+
}}>
|
| 165 |
+
<UserCircle size={32} className="text-gray-400" />
|
| 166 |
+
<div className="flex-1 min-w-0">
|
| 167 |
+
<div className="text-sm font-medium truncate">{userName}</div>
|
| 168 |
+
<div className="text-[10px] text-gray-500">Click to change name</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
)}
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
{/* Chat Area */}
|
| 176 |
+
<div className="flex-1 flex flex-col bg-transparent relative">
|
| 177 |
+
{/* Header */}
|
| 178 |
+
<div className="h-14 border-b border-white/5 flex items-center px-6 bg-[#252525]/30 backdrop-blur-sm justify-between">
|
| 179 |
+
<div className="flex flex-col">
|
| 180 |
+
<span className="text-sm font-semibold">To: Everyone</span>
|
| 181 |
+
<span className="text-[10px] text-gray-400">Global Channel</span>
|
| 182 |
+
</div>
|
| 183 |
+
<div className="flex items-center gap-2 text-orange-400 bg-orange-400/10 px-3 py-1 rounded-full border border-orange-400/20">
|
| 184 |
+
<WarningCircle size={14} />
|
| 185 |
+
<span className="text-[10px] font-medium">Public Chat - Do not share confidential info</span>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
+
{/* Messages List */}
|
| 190 |
+
<div className="flex-1 overflow-y-auto p-6 space-y-1 [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar]:w-2">
|
| 191 |
+
{messages.map((msg, index) => {
|
| 192 |
+
const isMe = msg.userId === userId
|
| 193 |
+
const showHeader = index === 0 || messages[index - 1].userId !== msg.userId
|
| 194 |
+
const showTimestamp = index === 0 || (msg.timestamp - messages[index - 1].timestamp > 300000) // 5 mins
|
| 195 |
+
|
| 196 |
+
return (
|
| 197 |
+
<React.Fragment key={msg.id}>
|
| 198 |
+
{showTimestamp && (
|
| 199 |
+
<div className="text-center my-4">
|
| 200 |
+
<span className="text-[10px] text-gray-500 font-medium">
|
| 201 |
+
{new Date(msg.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
|
| 202 |
+
</span>
|
| 203 |
+
</div>
|
| 204 |
+
)}
|
| 205 |
+
<motion.div
|
| 206 |
+
initial={{ opacity: 0, y: 10 }}
|
| 207 |
+
animate={{ opacity: 1, y: 0 }}
|
| 208 |
+
className={`flex flex-col ${isMe ? 'items-end' : 'items-start'} ${showHeader ? 'mt-2' : 'mt-0.5'}`}
|
| 209 |
+
>
|
| 210 |
+
{showHeader && !isMe && (
|
| 211 |
+
<span className="text-[10px] text-gray-500 ml-3 mb-0.5">{msg.sender}</span>
|
| 212 |
+
)}
|
| 213 |
+
<div
|
| 214 |
+
className={`
|
| 215 |
+
max-w-[70%] px-3 py-1.5 text-[13px] leading-relaxed break-words relative group
|
| 216 |
+
${isMe
|
| 217 |
+
? 'bg-[#0A84FF] text-white rounded-2xl rounded-br-sm'
|
| 218 |
+
: 'bg-[#3a3a3a] text-gray-100 rounded-2xl rounded-bl-sm'}
|
| 219 |
+
`}
|
| 220 |
+
>
|
| 221 |
+
{msg.text}
|
| 222 |
+
{/* Tail for message bubbles */}
|
| 223 |
+
{isMe && showHeader && (
|
| 224 |
+
<svg className="absolute -bottom-[1px] -right-[6px] w-4 h-4 text-[#0A84FF]" viewBox="0 0 20 20" fill="currentColor">
|
| 225 |
+
<path d="M0 20C5 20 8 15 10 10V20H0Z" />
|
| 226 |
+
</svg>
|
| 227 |
+
)}
|
| 228 |
+
{!isMe && showHeader && (
|
| 229 |
+
<svg className="absolute -bottom-[1px] -left-[6px] w-4 h-4 text-[#3a3a3a] transform scale-x-[-1]" viewBox="0 0 20 20" fill="currentColor">
|
| 230 |
+
<path d="M0 20C5 20 8 15 10 10V20H0Z" />
|
| 231 |
+
</svg>
|
| 232 |
+
)}
|
| 233 |
+
</div>
|
| 234 |
+
{/* Delivered status for me */}
|
| 235 |
+
{isMe && index === messages.length - 1 && (
|
| 236 |
+
<span className="text-[10px] text-gray-500 mr-1 mt-0.5 font-medium">Delivered</span>
|
| 237 |
+
)}
|
| 238 |
+
</motion.div>
|
| 239 |
+
</React.Fragment>
|
| 240 |
+
)
|
| 241 |
+
})}
|
| 242 |
+
<div ref={messagesEndRef} />
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Input Area */}
|
| 246 |
+
<div className="p-4 bg-[#1e1e1e]/50 border-t border-white/10 backdrop-blur-md">
|
| 247 |
+
{error && (
|
| 248 |
+
<motion.div
|
| 249 |
+
initial={{ opacity: 0, y: 10 }}
|
| 250 |
+
animate={{ opacity: 1, y: 0 }}
|
| 251 |
+
exit={{ opacity: 0 }}
|
| 252 |
+
className="flex items-center gap-2 text-red-400 text-xs mb-2 px-2 justify-center"
|
| 253 |
+
>
|
| 254 |
+
<WarningCircle size={14} />
|
| 255 |
+
{error}
|
| 256 |
+
</motion.div>
|
| 257 |
+
)}
|
| 258 |
+
<form onSubmit={handleSend} className="relative flex items-center gap-3 max-w-3xl mx-auto w-full">
|
| 259 |
+
<div className="flex-1 relative">
|
| 260 |
+
<input
|
| 261 |
+
type="text"
|
| 262 |
+
value={inputText}
|
| 263 |
+
onChange={(e) => setInputText(e.target.value)}
|
| 264 |
+
placeholder="iMessage"
|
| 265 |
+
maxLength={200}
|
| 266 |
+
disabled={isLoading}
|
| 267 |
+
className="w-full bg-[#2a2a2a] border border-white/10 rounded-full py-1.5 pl-4 pr-10 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-gray-500 transition-colors"
|
| 268 |
+
/>
|
| 269 |
+
<button
|
| 270 |
+
type="submit"
|
| 271 |
+
disabled={!inputText.trim() || isLoading}
|
| 272 |
+
className={`
|
| 273 |
+
absolute right-1 top-1/2 -translate-y-1/2 p-1 rounded-full transition-all
|
| 274 |
+
${inputText.trim() && !isLoading ? 'bg-[#0A84FF] text-white scale-100' : 'bg-gray-600 text-gray-400 scale-90 opacity-0 pointer-events-none'}
|
| 275 |
+
`}
|
| 276 |
+
>
|
| 277 |
+
<PaperPlaneRight size={14} weight="fill" />
|
| 278 |
+
</button>
|
| 279 |
+
</div>
|
| 280 |
+
</form>
|
| 281 |
+
<div className="text-[9px] text-gray-600 text-center mt-2 font-medium">
|
| 282 |
+
{inputText.length}/200 • Press Enter to send
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div> </div>
|
| 286 |
+
</Window>
|
| 287 |
+
)
|
| 288 |
+
}
|
app/components/Window.tsx
CHANGED
|
@@ -119,7 +119,7 @@ const Window: React.FC<WindowProps> = ({
|
|
| 119 |
position: { x: 0, y: 4 }, // 32px for TopBar offset (h-8 = 32px)
|
| 120 |
size: {
|
| 121 |
width: typeof window !== 'undefined' ? window.innerWidth : '100vw',
|
| 122 |
-
height: typeof window !== 'undefined' ? window.innerHeight -
|
| 123 |
},
|
| 124 |
disableDragging: true,
|
| 125 |
enableResizing: false
|
|
|
|
| 119 |
position: { x: 0, y: 4 }, // 32px for TopBar offset (h-8 = 32px)
|
| 120 |
size: {
|
| 121 |
width: typeof window !== 'undefined' ? window.innerWidth : '100vw',
|
| 122 |
+
height: typeof window !== 'undefined' ? window.innerHeight - 120 : 'calc(100vh - 120px)'
|
| 123 |
},
|
| 124 |
disableDragging: true,
|
| 125 |
enableResizing: false
|
app/data/messages.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "kozrvx3833q",
|
| 4 |
+
"text": "hi babe",
|
| 5 |
+
"sender": "Guest",
|
| 6 |
+
"userId": "user-bl6uf1o",
|
| 7 |
+
"timestamp": 1763788262195
|
| 8 |
+
},
|
| 9 |
+
{
|
| 10 |
+
"id": "7lu3jrs4o5",
|
| 11 |
+
"text": "bye babe",
|
| 12 |
+
"sender": "Guest",
|
| 13 |
+
"userId": "user-bl6uf1o",
|
| 14 |
+
"timestamp": 1763788274778
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"id": "yvi2obxcqh",
|
| 18 |
+
"text": "fuxk",
|
| 19 |
+
"sender": "Guest",
|
| 20 |
+
"userId": "user-bl6uf1o",
|
| 21 |
+
"timestamp": 1763788279726
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "ughhky9l5f",
|
| 25 |
+
"text": "fuck",
|
| 26 |
+
"sender": "Guest",
|
| 27 |
+
"userId": "user-bl6uf1o",
|
| 28 |
+
"timestamp": 1763788283646
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "lydc1y7bg1",
|
| 32 |
+
"text": "dd",
|
| 33 |
+
"sender": "reo",
|
| 34 |
+
"userId": "user-bl6uf1o",
|
| 35 |
+
"timestamp": 1763788313420
|
| 36 |
+
}
|
| 37 |
+
]
|